mioku-plugin-chat 2.6.0 → 2.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,410 +1,286 @@
1
- import type { AIInstance } from "mioku";
2
- import type { ChatDatabase } from "../db";
3
- import type { ChatConfig, ChatMessage } from "../types";
4
- import { logger } from "mioki";
5
- import * as fs from "fs/promises";
6
1
  import { existsSync, readdirSync } from "fs";
2
+ import * as fs from "fs/promises";
7
3
  import * as path from "path";
4
+ import { getPluginDataDir, type AIInstance } from "mioku";
5
+ import { logger } from "mioki";
6
+ import type { ChatConfig, ChatMessage, TargetMessage } from "../types";
8
7
  import { extractGroupIdFromSession } from "../utils/group-config";
8
+ import { extractJsonObject } from "../utils/json";
9
9
  import type { ChatConfigProvider } from "./index";
10
10
 
11
11
  export interface EmojiPickResult {
12
12
  success: boolean;
13
13
  emojiPath?: string;
14
14
  emojiDescription?: string;
15
- cleanedText?: string;
15
+ cleanedText: string;
16
16
  error?: string;
17
17
  }
18
18
 
19
- const AVAILABLE_EMOTIONS = [
20
- "happy",
21
- "sad",
22
- "angry",
23
- "surprised",
24
- "confused",
25
- "excited",
26
- "tired",
27
- "shy",
28
- "proud",
29
- "default",
30
- "funny",
31
- "cute",
32
- "love",
33
- "neutral",
34
- ];
19
+ export interface EmojiSelectionContext {
20
+ sessionId: string;
21
+ botNickname: string;
22
+ chatHistory: ChatMessage[];
23
+ targetMessage: TargetMessage;
24
+ }
25
+
26
+ interface EmojiCandidate {
27
+ id: string;
28
+ path: string;
29
+ character: string;
30
+ label: string;
31
+ relativePath: string;
32
+ }
33
+
34
+ const IMAGE_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".gif", ".webp"]);
35
+ const STICKER_INTENT_LINE = /^\s*\[\]\s*$/;
35
36
 
36
37
  export class EmojiAgent {
37
- private ai: AIInstance;
38
- private getConfig: ChatConfigProvider;
39
- private db: ChatDatabase;
40
- private readonly memeBaseDir: string;
41
-
42
- constructor(ai: AIInstance, configProvider: ChatConfigProvider, db: ChatDatabase) {
43
- this.ai = ai;
44
- this.getConfig = configProvider;
45
- this.db = db;
46
- this.memeBaseDir = path.join(process.cwd(), "data", "chat", "meme");
47
- }
38
+ private readonly memeBaseDir = path.join(getPluginDataDir("chat"), "meme");
48
39
 
49
- getAvailableCharacters(): string[] {
50
- if (!existsSync(this.memeBaseDir)) {
51
- return [];
52
- }
40
+ constructor(
41
+ private readonly ai: AIInstance,
42
+ private readonly getConfig: ChatConfigProvider,
43
+ ) {}
53
44
 
54
- const entries = readdirSync(this.memeBaseDir, { withFileTypes: true });
55
- return entries
45
+ getAvailableCharacters(): string[] {
46
+ if (!existsSync(this.memeBaseDir)) return [];
47
+ return readdirSync(this.memeBaseDir, { withFileTypes: true })
56
48
  .filter((entry) => entry.isDirectory())
57
- .map((entry) => entry.name);
49
+ .map((entry) => entry.name)
50
+ .sort((a, b) => a.localeCompare(b));
58
51
  }
59
52
 
60
- getAvailableEmotions(character: string): string[] {
61
- const characterDir = path.join(this.memeBaseDir, character);
62
- if (!existsSync(characterDir)) {
63
- return [];
64
- }
65
-
66
- const entries = readdirSync(characterDir, { withFileTypes: true });
67
- return entries
68
- .filter((entry) => entry.isDirectory())
69
- .map((entry) => entry.name);
53
+ hasAvailableEmojis(configuredCharacters: string[] = []): boolean {
54
+ return this.getVisibleCharacters(configuredCharacters).some((character) =>
55
+ this.directoryContainsImage(path.join(this.memeBaseDir, character)),
56
+ );
70
57
  }
71
58
 
72
- parseAllMemeIntents(text: string): { emotion: string }[] {
73
- const regex = /\[meme:([^\]]+)]/gi;
74
- const matches = [...text.matchAll(regex)];
75
- return matches.map((m) => ({ emotion: m[1].trim().toLowerCase() }));
59
+ hasStickerIntent(text: string): boolean {
60
+ return String(text || "")
61
+ .split(/\r?\n/)
62
+ .some((line) => STICKER_INTENT_LINE.test(line));
76
63
  }
77
64
 
78
- async processMemeResponse(
65
+ async processStickerResponse(
79
66
  aiResponseText: string,
80
- sessionId: string,
67
+ context: EmojiSelectionContext,
81
68
  ): Promise<EmojiPickResult> {
82
- const cfg = this.getConfig(extractGroupIdFromSession(sessionId));
83
- if (!cfg.emoji?.enabled) {
84
- return {
85
- success: false,
86
- error: "emoji disabled",
87
- };
69
+ const cleanedText = this.cleanStickerIntent(aiResponseText);
70
+ if (!this.hasStickerIntent(aiResponseText)) {
71
+ return { success: false, cleanedText, error: "No sticker intent found" };
88
72
  }
89
73
 
90
- const intents = this.parseAllMemeIntents(aiResponseText);
91
- if (intents.length === 0) {
92
- return {
93
- success: false,
94
- error: "No meme intent found in response",
95
- };
96
- }
97
-
98
- const chatHistory = this.db.getMessages(sessionId, 20);
99
-
100
- // 获取配置中的角色列表
101
- const configChars = cfg.emoji?.characters || [];
102
-
103
- // 决定使用哪些角色
104
- let targetCharacters: string[];
105
- if (configChars.length > 0) {
106
- targetCharacters = configChars;
107
- logger.debug(
108
- `[emoji-agent] Using configured characters: ${configChars.join(", ")}`,
109
- );
110
- } else {
111
- targetCharacters = this.getAvailableCharacters();
112
- logger.debug(
113
- `[emoji-agent] Using all available characters: ${targetCharacters.join(", ")}`,
114
- );
74
+ const cfg = this.getConfig(extractGroupIdFromSession(context.sessionId));
75
+ if (!cfg.emoji?.enabled) {
76
+ return { success: false, cleanedText, error: "Emoji disabled" };
115
77
  }
116
78
 
117
- // 只处理第一个 meme 标记
118
- const intent = intents[0];
119
- logger.debug(`[emoji-agent] Processing meme intent: ${intent.emotion}`);
120
-
121
- const emojiResult = await this.pickEmoji(
122
- targetCharacters,
123
- intent.emotion,
124
- chatHistory,
125
- cfg,
79
+ const characters = this.getVisibleCharacters(cfg.emoji.characters || []);
80
+ const candidates = this.filterSelectedStickers(
81
+ await this.collectCandidates(characters),
82
+ cfg.emoji.stickers || [],
126
83
  );
127
-
128
- if (!emojiResult.success || !emojiResult.emojiPath) {
129
- return {
130
- success: false,
131
- error: emojiResult.error || "Failed to pick emoji",
132
- };
84
+ if (candidates.length === 0) {
85
+ return { success: false, cleanedText, error: "No stickers available" };
133
86
  }
134
87
 
135
- const cleanedText = this.cleanMemeMarker(aiResponseText);
88
+ const selected =
89
+ candidates.length === 1
90
+ ? candidates[0]
91
+ : await this.selectByAI(candidates, context, cleanedText, cfg);
92
+ if (!selected) {
93
+ return { success: false, cleanedText, error: "Sticker selection failed" };
94
+ }
136
95
 
137
96
  return {
138
97
  success: true,
139
- emojiPath: emojiResult.emojiPath,
140
- emojiDescription: emojiResult.description,
98
+ emojiPath: selected.path,
99
+ emojiDescription: selected.label,
141
100
  cleanedText,
142
101
  };
143
102
  }
144
103
 
145
- async pickEmoji(
146
- characters: string[],
147
- emotion: string,
148
- chatHistory: ChatMessage[],
149
- cfg: ChatConfig = this.getConfig(),
150
- ): Promise<{
151
- success: boolean;
152
- emojiPath?: string;
153
- description?: string;
154
- error?: string;
155
- }> {
156
- try {
157
- const normalizedEmotion = this.normalizeEmotion(emotion);
158
- logger.debug(
159
- `[emoji-agent] pickEmoji: emotion=${normalizedEmotion}, characters=${characters.join(",")}`,
160
- );
161
-
162
- // 收集所有角色对应情绪目录下的表情包
163
- const allEmojis: { path: string; character: string; file: string }[] = [];
164
-
165
- for (const character of characters) {
166
- const emotionDir = path.join(
167
- this.memeBaseDir,
168
- character,
169
- normalizedEmotion,
170
- );
171
-
172
- logger.debug(
173
- `[emoji-agent] Checking dir: ${emotionDir}, exists=${existsSync(emotionDir)}`,
174
- );
175
-
176
- if (existsSync(emotionDir)) {
177
- const files = (await fs.readdir(emotionDir)).filter((f) => {
178
- const ext = path.extname(f).toLowerCase();
179
- return [".jpg", ".jpeg", ".png", ".gif", ".webp"].includes(ext);
180
- });
181
-
182
- logger.debug(
183
- `[emoji-agent] Found ${files.length} files in ${character}/${normalizedEmotion}`,
184
- );
185
-
186
- for (const file of files) {
187
- allEmojis.push({
188
- path: path.join(emotionDir, file),
189
- character,
190
- file,
191
- });
192
- }
193
- }
194
- }
104
+ private getVisibleCharacters(configuredCharacters: string[]): string[] {
105
+ const available = this.getAvailableCharacters();
106
+ if (configuredCharacters.length === 0) return available;
195
107
 
196
- logger.debug(`[emoji-agent] Total emojis collected: ${allEmojis.length}`);
197
-
198
- // 如果没有找到对应情绪的表情包,尝试 default
199
- if (allEmojis.length === 0) {
200
- logger.info(
201
- `[emoji-agent] No emojis found for ${normalizedEmotion}, trying default`,
202
- );
203
- for (const character of characters) {
204
- const defaultDir = path.join(this.memeBaseDir, character, "default");
205
- if (existsSync(defaultDir)) {
206
- const files = (await fs.readdir(defaultDir)).filter((f) => {
207
- const ext = path.extname(f).toLowerCase();
208
- return [".jpg", ".jpeg", ".png", ".gif", ".webp"].includes(ext);
209
- });
210
-
211
- for (const file of files) {
212
- allEmojis.push({
213
- path: path.join(defaultDir, file),
214
- character,
215
- file,
216
- });
217
- }
218
- }
219
- }
220
- logger.debug(
221
- `[emoji-agent] Total emojis from default: ${allEmojis.length}`,
222
- );
223
- }
108
+ const configured = new Set(
109
+ configuredCharacters.map((character) => character.trim().toLowerCase()),
110
+ );
111
+ return available.filter((character) =>
112
+ configured.has(character.toLowerCase()),
113
+ );
114
+ }
224
115
 
225
- if (allEmojis.length === 0) {
226
- return {
227
- success: false,
228
- error: `No memes found for emotion: ${emotion}`,
229
- };
116
+ private directoryContainsImage(dir: string): boolean {
117
+ if (!existsSync(dir)) return false;
118
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
119
+ const entryPath = path.join(dir, entry.name);
120
+ if (
121
+ entry.isFile() &&
122
+ IMAGE_EXTENSIONS.has(path.extname(entry.name).toLowerCase())
123
+ ) {
124
+ return true;
230
125
  }
126
+ if (entry.isDirectory() && this.directoryContainsImage(entryPath))
127
+ return true;
128
+ }
129
+ return false;
130
+ }
231
131
 
232
- // 如果只有一个表情包,直接返回
233
- if (allEmojis.length === 1) {
234
- const emoji = allEmojis[0];
235
- return {
236
- success: true,
237
- emojiPath: emoji.path,
238
- description: path.basename(emoji.file, path.extname(emoji.file)),
239
- };
132
+ private async collectCandidates(
133
+ characters: string[],
134
+ ): Promise<EmojiCandidate[]> {
135
+ const candidates: EmojiCandidate[] = [];
136
+ for (const character of characters) {
137
+ const characterDir = path.join(this.memeBaseDir, character);
138
+ const files = await this.collectImageFiles(characterDir);
139
+ for (const file of files) {
140
+ const relativePath = path.relative(characterDir, file);
141
+ const fileName = path.basename(file, path.extname(file));
142
+ candidates.push({
143
+ id: this.normalizeStickerId(path.join(character, relativePath)),
144
+ path: file,
145
+ character,
146
+ label: fileName.replace(/[_-]+/g, " ").trim(),
147
+ relativePath,
148
+ });
240
149
  }
150
+ }
151
+ return candidates.sort((a, b) =>
152
+ `${a.character}/${a.relativePath}`.localeCompare(
153
+ `${b.character}/${b.relativePath}`,
154
+ ),
155
+ );
156
+ }
241
157
 
242
- // 检查是否使用 AI 选择
243
- const useAI = cfg.emoji?.useAISelection;
244
- if (!useAI) {
245
- // 不使用 AI,直接随机选择
246
- logger.info(`[emoji-agent] useAISelection=false, random pick`);
247
- return this.randomPick(allEmojis);
158
+ private async collectImageFiles(dir: string): Promise<string[]> {
159
+ if (!existsSync(dir)) return [];
160
+ const files: string[] = [];
161
+ for (const entry of await fs.readdir(dir, { withFileTypes: true })) {
162
+ const entryPath = path.join(dir, entry.name);
163
+ if (entry.isDirectory()) {
164
+ files.push(...(await this.collectImageFiles(entryPath)));
165
+ } else if (
166
+ entry.isFile() &&
167
+ IMAGE_EXTENSIONS.has(path.extname(entry.name).toLowerCase())
168
+ ) {
169
+ files.push(entryPath);
248
170
  }
249
-
250
- // 使用 AI 选择最合适的表情包
251
- return this.selectByAI(allEmojis, normalizedEmotion, chatHistory, cfg);
252
- } catch (err) {
253
- logger.error(`[emoji-agent] Failed to pick emoji: ${err}`);
254
- return {
255
- success: false,
256
- error: String(err),
257
- };
258
171
  }
172
+ return files;
259
173
  }
260
174
 
261
- private normalizeEmotion(emotion: string): string {
262
- const normalized = emotion.toLowerCase();
263
- if (AVAILABLE_EMOTIONS.includes(normalized)) {
264
- return normalized;
265
- }
266
- const mapping: Record<string, string> = {
267
- 开心: "happy",
268
- 难过: "sad",
269
- 生气: "angry",
270
- 惊讶: "surprised",
271
- 困惑: "confused",
272
- 兴奋: "excited",
273
- 疲倦: "tired",
274
- 害羞: "shy",
275
- 骄傲: "proud",
276
- 默认: "default",
277
- 有趣: "funny",
278
- 可爱: "cute",
279
- 爱: "love",
280
- 中性: "neutral",
281
- };
282
- return mapping[normalized] || "default";
175
+ private filterSelectedStickers(
176
+ candidates: EmojiCandidate[],
177
+ selectedStickers: string[],
178
+ ): EmojiCandidate[] {
179
+ if (selectedStickers.length === 0) return candidates;
180
+ const selected = new Set(
181
+ selectedStickers.map((sticker) => this.normalizeStickerId(sticker)),
182
+ );
183
+ return candidates.filter((candidate) => selected.has(candidate.id));
184
+ }
185
+
186
+ private normalizeStickerId(value: string): string {
187
+ return String(value || "")
188
+ .replace(/\\/g, "/")
189
+ .replace(/^\/+/, "")
190
+ .replace(/^(?:data\/chat\/)?meme\//i, "");
283
191
  }
284
192
 
285
193
  private async selectByAI(
286
- emojis: { path: string; character: string; file: string }[],
287
- emotion: string,
288
- chatHistory: ChatMessage[],
289
- cfg: ChatConfig = this.getConfig(),
290
- ): Promise<{
291
- success: boolean;
292
- emojiPath?: string;
293
- description?: string;
294
- error?: string;
295
- }> {
296
- const model = cfg.workingModel || cfg.model;
297
-
298
- const systemPrompt = `You are an emoji/sticker selection assistant. Your task is to select the most appropriate emoji/sticker from a given list based on the chat context.
299
-
300
- Instructions:
301
- 1. Analyze the chat history provided
302
- 2. Select the emoji that best matches the current conversation mood and context
303
- 3. Consider the emotional tone of the conversation
304
- 4. Provide your selection in JSON format
305
-
306
- Available emojis (${emotion}):
307
- ${emojis.map((e, i) => `${i + 1}. [${e.character}] ${path.basename(e.file, path.extname(e.file))}`).join("\n")}
308
-
309
- Response format (JSON):
310
- {
311
- "selectedIndex": number (1-based index from the list above),
312
- "reason": "brief reason why this emoji is suitable"
313
- }`;
314
-
315
- const historyText = chatHistory
316
- .slice(-10)
317
- .map((msg) => {
318
- const role = msg.role === "assistant" ? "Bot" : msg.userName || "User";
319
- return `${role}: ${msg.content}`;
194
+ candidates: EmojiCandidate[],
195
+ context: EmojiSelectionContext,
196
+ assistantReply: string,
197
+ cfg: ChatConfig,
198
+ ): Promise<EmojiCandidate | null> {
199
+ const persona = String(cfg.persona || "").trim();
200
+ const candidateList = candidates
201
+ .map(
202
+ (candidate, index) =>
203
+ `${index + 1}. [${candidate.character}] ${candidate.label}`,
204
+ )
205
+ .join("\n");
206
+ const history = context.chatHistory
207
+ .slice(-20)
208
+ .map((message) => {
209
+ const speaker =
210
+ message.role === "assistant"
211
+ ? context.botNickname
212
+ : message.userName || String(message.userId || "User");
213
+ return `${speaker}: ${message.content}`;
320
214
  })
321
215
  .join("\n");
322
216
 
323
- const userPrompt = `Chat history:
324
- ${historyText}
217
+ const systemPrompt = `You are the sticker selection sub-agent for ${context.botNickname}.
218
+ ${persona ? `Identity and persona of ${context.botNickname}:\n${persona}\n` : ""}
219
+ The main chat agent has decided to send one sticker in this turn. Select the single sticker label that best expresses what ${context.botNickname} intends to communicate.
220
+
221
+ Rules:
222
+ - Use only the conversation context, the current draft reply, and the sticker labels below.
223
+ - Sticker labels are text descriptions derived from local filenames. You cannot see the images, so do not invent visual details beyond a label.
224
+ - The available stickers are already filtered by the configured character list.
225
+ - Return exactly one valid 1-based index as JSON.
226
+
227
+ Available sticker labels:
228
+ ${candidateList}
229
+
230
+ Response format:
231
+ {"selectedIndex":1,"reason":"brief reason"}`;
232
+ const userPrompt = `Recent conversation:
233
+ ${history || "(no earlier messages)"}
325
234
 
326
- Select the most appropriate emoji for this conversation. The emoji should match the emotional context "${emotion}".`;
235
+ Current message from ${context.targetMessage.userName}:
236
+ ${context.targetMessage.content}
237
+
238
+ Current draft reply from ${context.botNickname}:
239
+ ${assistantReply || "(sticker only)"}
240
+
241
+ Choose the most contextually appropriate sticker label for ${context.botNickname} to send now.`;
327
242
 
328
243
  try {
329
244
  const response = await this.ai.complete({
330
- model,
245
+ model: cfg.workingModel || cfg.model,
331
246
  messages: [
332
247
  { role: "system", content: systemPrompt },
333
248
  { role: "user", content: userPrompt },
334
249
  ],
335
- temperature: 0.3,
250
+ temperature: 0.2,
336
251
  });
337
-
338
- if (!response.content) {
339
- return this.randomPick(emojis);
340
- }
341
-
342
- const jsonMatch = response.content.match(/\{[\s\S]*\}/);
343
- if (!jsonMatch) {
344
- return this.randomPick(emojis);
345
- }
346
-
347
- const result = JSON.parse(jsonMatch[0]);
348
- const selectedIndex = result.selectedIndex;
349
-
252
+ const result = extractJsonObject<{
253
+ selectedIndex?: unknown;
254
+ reason?: unknown;
255
+ }>(response.content || "");
256
+ const selectedIndex = result?.selectedIndex;
350
257
  if (
351
258
  typeof selectedIndex !== "number" ||
259
+ !Number.isInteger(selectedIndex) ||
352
260
  selectedIndex < 1 ||
353
- selectedIndex > emojis.length
261
+ selectedIndex > candidates.length
354
262
  ) {
355
- return this.randomPick(emojis);
263
+ logger.warn("[emoji-agent] Invalid sticker selection response");
264
+ return null;
356
265
  }
357
266
 
358
- const selected = emojis[selectedIndex - 1];
359
- const description = path.basename(
360
- selected.file,
361
- path.extname(selected.file),
362
- );
363
-
267
+ const selected = candidates[selectedIndex - 1];
364
268
  logger.info(
365
- `[emoji-agent] Selected: [${selected.character}] ${selected.file} (index: ${selectedIndex}, reason: ${result.reason})`,
269
+ `[emoji-agent] Selected [${selected.character}] ${selected.label}: ${String(result?.reason || "")}`,
366
270
  );
367
-
368
- return {
369
- success: true,
370
- emojiPath: selected.path,
371
- description,
372
- };
271
+ return selected;
373
272
  } catch (err) {
374
- logger.warn(`[emoji-agent] AI selection failed, using random: ${err}`);
375
- return this.randomPick(emojis);
273
+ logger.warn(`[emoji-agent] Sticker selection failed: ${err}`);
274
+ return null;
376
275
  }
377
276
  }
378
277
 
379
- private randomPick(
380
- emojis: { path: string; character: string; file: string }[],
381
- ): {
382
- success: boolean;
383
- emojiPath?: string;
384
- description?: string;
385
- error?: string;
386
- } {
387
- const selected = emojis[Math.floor(Math.random() * emojis.length)];
388
- const description = path.basename(
389
- selected.file,
390
- path.extname(selected.file),
391
- );
392
-
393
- return {
394
- success: true,
395
- emojiPath: selected.path,
396
- description,
397
- };
398
- }
399
-
400
- private cleanMemeMarker(text: string): string {
401
- let cleaned = text.replace(/\[meme:[^\]]+\]/gi, "");
402
- cleaned = cleaned.replace(/\n{3,}/g, "\n\n");
403
- cleaned = cleaned
404
- .split("\n")
405
- .map((line) => line.trim())
406
- .join("\n");
407
- cleaned = cleaned.trim();
408
- return cleaned;
278
+ private cleanStickerIntent(text: string): string {
279
+ return String(text || "")
280
+ .split(/\r?\n/)
281
+ .filter((line) => !STICKER_INTENT_LINE.test(line))
282
+ .join("\n")
283
+ .replace(/\n{3,}/g, "\n\n")
284
+ .trim();
409
285
  }
410
286
  }
package/humanize/index.ts CHANGED
@@ -27,7 +27,6 @@ export class HumanizeEngine {
27
27
  readonly expressionLearner: ExpressionLearner;
28
28
 
29
29
  constructor(
30
- mainAI: AIInstance,
31
30
  workAI: AIInstance,
32
31
  db: ChatDatabase,
33
32
  configProvider: ChatConfigProvider,
@@ -36,11 +35,11 @@ export class HumanizeEngine {
36
35
  this.topicTracker = new TopicTracker(workAI, configProvider, db);
37
36
  this.actionPlanner = new ActionPlanner(workAI, configProvider);
38
37
  this.emotionAgent = new EmotionAgent(workAI, configProvider);
39
- this.emojiAgent = new EmojiAgent(workAI, configProvider, db);
38
+ this.emojiAgent = new EmojiAgent(workAI, configProvider);
40
39
  this.expressionLearner = new ExpressionLearner(workAI, configProvider, db);
41
40
  }
42
41
 
43
42
  async init(): Promise<void> {
44
43
  // emojiAgent 不需要初始化,它直接从文件系统读取
45
44
  }
46
- }
45
+ }