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