mioku-plugin-chat 2.0.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.
@@ -0,0 +1,397 @@
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
+ import { existsSync, readdirSync } from "fs";
7
+ import * as path from "path";
8
+
9
+ export interface EmojiPickResult {
10
+ success: boolean;
11
+ emojiPath?: string;
12
+ emojiDescription?: string;
13
+ cleanedText?: string;
14
+ error?: string;
15
+ }
16
+
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
+ ];
33
+
34
+ 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
+ }
46
+
47
+ getAvailableCharacters(): string[] {
48
+ if (!existsSync(this.memeBaseDir)) {
49
+ return [];
50
+ }
51
+
52
+ const entries = readdirSync(this.memeBaseDir, { withFileTypes: true });
53
+ return entries
54
+ .filter((entry) => entry.isDirectory())
55
+ .map((entry) => entry.name);
56
+ }
57
+
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);
68
+ }
69
+
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() }));
74
+ }
75
+
76
+ async processMemeResponse(
77
+ aiResponseText: string,
78
+ sessionId: string,
79
+ ): 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
+ };
86
+ }
87
+
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
+ );
105
+ }
106
+
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,
115
+ );
116
+
117
+ if (!emojiResult.success || !emojiResult.emojiPath) {
118
+ return {
119
+ success: false,
120
+ error: emojiResult.error || "Failed to pick emoji",
121
+ };
122
+ }
123
+
124
+ const cleanedText = this.cleanMemeMarker(aiResponseText);
125
+
126
+ return {
127
+ success: true,
128
+ emojiPath: emojiResult.emojiPath,
129
+ emojiDescription: emojiResult.description,
130
+ cleanedText,
131
+ };
132
+ }
133
+
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
+ );
149
+
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
+ }
212
+
213
+ if (allEmojis.length === 0) {
214
+ return {
215
+ success: false,
216
+ error: `No memes found for emotion: ${emotion}`,
217
+ };
218
+ }
219
+
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
+ };
228
+ }
229
+
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);
236
+ }
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
+ }
247
+ }
248
+
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";
271
+ }
272
+
273
+ 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}`;
307
+ })
308
+ .join("\n");
309
+
310
+ const userPrompt = `Chat history:
311
+ ${historyText}
312
+
313
+ Select the most appropriate emoji for this conversation. The emoji should match the emotional context "${emotion}".`;
314
+
315
+ try {
316
+ const response = await this.ai.complete({
317
+ model,
318
+ messages: [
319
+ { role: "system", content: systemPrompt },
320
+ { role: "user", content: userPrompt },
321
+ ],
322
+ temperature: 0.3,
323
+ });
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
+
337
+ if (
338
+ typeof selectedIndex !== "number" ||
339
+ selectedIndex < 1 ||
340
+ selectedIndex > emojis.length
341
+ ) {
342
+ return this.randomPick(emojis);
343
+ }
344
+
345
+ const selected = emojis[selectedIndex - 1];
346
+ const description = path.basename(
347
+ selected.file,
348
+ path.extname(selected.file),
349
+ );
350
+
351
+ logger.info(
352
+ `[emoji-agent] Selected: [${selected.character}] ${selected.file} (index: ${selectedIndex}, reason: ${result.reason})`,
353
+ );
354
+
355
+ return {
356
+ success: true,
357
+ emojiPath: selected.path,
358
+ description,
359
+ };
360
+ } catch (err) {
361
+ logger.warn(`[emoji-agent] AI selection failed, using random: ${err}`);
362
+ return this.randomPick(emojis);
363
+ }
364
+ }
365
+
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;
396
+ }
397
+ }
@@ -0,0 +1,152 @@
1
+ import type { AIInstance } from "mioku";
2
+ import { logger } from "mioki";
3
+ import type { ChatDatabase } from "../db";
4
+ import type { ChatConfig, ChatMessage } from "../types";
5
+
6
+ export class ExpressionLearner {
7
+ private ai: AIInstance;
8
+ private config: ChatConfig;
9
+ private db: ChatDatabase;
10
+ private pendingMessagesByUser: Map<number, ChatMessage[]> = new Map();
11
+ private learningUsers: Set<number> = new Set();
12
+
13
+ constructor(ai: AIInstance, config: ChatConfig, db: ChatDatabase) {
14
+ this.ai = ai;
15
+ this.config = config;
16
+ this.db = db;
17
+ }
18
+
19
+ async onMessage(message: ChatMessage): Promise<void> {
20
+ if (!this.config.expression?.enabled) return;
21
+ if (message.role !== "user") return;
22
+ if (!message.content || message.content.length < 4) return;
23
+ if (!message.userId) return;
24
+
25
+ const pending = this.pendingMessagesByUser.get(message.userId) ?? [];
26
+ pending.push(message);
27
+ this.pendingMessagesByUser.set(message.userId, pending);
28
+
29
+ await this.tryLearn(message.userId);
30
+ }
31
+
32
+ getExpressionContextForUser(userId: number, userName: string): string {
33
+ if (!this.config.expression?.enabled) return "";
34
+
35
+ const sampleSize = this.config.expression?.sampleSize ?? 8;
36
+ const expressions = this.db.getExpressionsByUser(userId, sampleSize);
37
+ if (expressions.length === 0) {
38
+ logger.info(`[ExpressionLearner] No expression habits found for user ${userName} (${userId})`);
39
+ return "";
40
+ }
41
+ const selected = expressions.slice(0, sampleSize);
42
+
43
+ const habits = selected.map(
44
+ (expr) =>
45
+ `- When ${expr.situation}: ${expr.style} (e.g. "${expr.example}")`,
46
+ );
47
+
48
+ const context = `## Expression Habits\nExpression habits learned from ${userName}. If you are replying to this user, you may naturally reference these habits:\n${habits.join("\n")}`;
49
+ logger.info(`[ExpressionLearner] Expression context for ${userName} (${userId}): ${habits.length} habits`);
50
+ return context;
51
+ }
52
+
53
+ private async tryLearn(userId: number): Promise<void> {
54
+ if (this.learningUsers.has(userId)) return;
55
+
56
+ const threshold = Math.max(
57
+ 1,
58
+ this.config.expression?.learnAfterMessages ?? 100,
59
+ );
60
+ const pending = this.pendingMessagesByUser.get(userId) ?? [];
61
+ logger.info(`[ExpressionLearner] User ${userId} has ${pending.length}/${threshold} pending messages (threshold=${threshold})`);
62
+ if (pending.length < threshold) return;
63
+
64
+ this.learningUsers.add(userId);
65
+ try {
66
+ while (true) {
67
+ const current = this.pendingMessagesByUser.get(userId) ?? [];
68
+ if (current.length < threshold) break;
69
+
70
+ const batch = current.slice(0, threshold);
71
+ this.pendingMessagesByUser.set(userId, current.slice(threshold));
72
+ await this.learnForUser(userId, batch);
73
+ }
74
+ } catch (err) {
75
+ logger.warn(`[ExpressionLearner] Learning failed for user ${userId}: ${err}`);
76
+ } finally {
77
+ this.learningUsers.delete(userId);
78
+ }
79
+ }
80
+
81
+ private async learnForUser(
82
+ userId: number,
83
+ messages: ChatMessage[],
84
+ ): Promise<void> {
85
+ if (messages.length === 0) return;
86
+
87
+ const maxHabits = Math.max(1, this.config.expression?.sampleSize ?? 8);
88
+ const userName = messages[messages.length - 1].userName || `User${userId}`;
89
+ const msgTexts = messages.map((m) => m.content).join("\n");
90
+
91
+ const previousExpressions = this.db.getExpressionsByUser(userId, maxHabits);
92
+ const previousText =
93
+ previousExpressions.length > 0
94
+ ? previousExpressions
95
+ .map(
96
+ (expr, idx) =>
97
+ `${idx + 1}. situation=${expr.situation}; style=${expr.style}; example=${expr.example}`,
98
+ )
99
+ .join("\n")
100
+ : "None";
101
+
102
+ const content = await this.ai.generateText({
103
+ prompt: `You are refining expression habits for a single user named "${userName}".
104
+
105
+ New messages from this user:
106
+ ${msgTexts}
107
+
108
+ Previously learned habits:
109
+ ${previousText}
110
+
111
+ Task:
112
+ 1. Merge previous habits and new evidence.
113
+ 2. Remove weak or duplicated habits.
114
+ 3. Output a revised list with at most ${maxHabits} habits.
115
+ 4. situation/style/example must be in the SAME LANGUAGE as this user's messages.
116
+
117
+ Output strictly in JSON:
118
+ {"expressions":[{"situation":"...","style":"...","example":"..."}]}
119
+
120
+ If nothing reliable can be extracted, keep stable previous habits when possible. If still nothing, output {"expressions":[]}.`,
121
+ messages: [],
122
+ model: this.config.workingModel || this.config.model,
123
+ temperature: 0.2,
124
+ max_tokens: 600,
125
+ });
126
+
127
+ const jsonMatch = content.match(/\{[\s\S]*\}/);
128
+ if (!jsonMatch) return;
129
+
130
+ const parsed = JSON.parse(jsonMatch[0]);
131
+ if (!parsed.expressions || !Array.isArray(parsed.expressions)) return;
132
+
133
+ const normalized = parsed.expressions
134
+ .map((expr: any) => ({
135
+ situation: String(expr?.situation ?? "").trim(),
136
+ style: String(expr?.style ?? "").trim(),
137
+ example: String(expr?.example ?? "").trim(),
138
+ }))
139
+ .filter(
140
+ (expr: { situation: string; style: string; example: string }) =>
141
+ Boolean(expr.situation && expr.style && expr.example),
142
+ )
143
+ .slice(0, maxHabits);
144
+
145
+ if (normalized.length === 0) return;
146
+
147
+ this.db.replaceExpressionsByUser(userId, userName, normalized);
148
+ logger.info(
149
+ `[ExpressionLearner] Updated ${normalized.length} habits for ${userName} (${userId}): ${JSON.stringify(normalized)}`,
150
+ );
151
+ }
152
+ }
@@ -0,0 +1,40 @@
1
+ export { EmojiAgent } from "./emoji-agent";
2
+ export { MemoryRetrieval } from "./memory";
3
+ export { TopicTracker } from "./topic";
4
+ export { ActionPlanner } from "./planner";
5
+ export { ExpressionLearner } from "./expression";
6
+ export { pickReplyStyle, pickPersonalityState } from "./utils";
7
+
8
+ import type { AIInstance } from "mioku";
9
+ import type { ChatDatabase } from "../db";
10
+ import type { ChatConfig } from "../types";
11
+ import { EmojiAgent } from "./emoji-agent";
12
+ import { MemoryRetrieval } from "./memory";
13
+ import { TopicTracker } from "./topic";
14
+ import { ActionPlanner } from "./planner";
15
+ import { ExpressionLearner } from "./expression";
16
+
17
+ export class HumanizeEngine {
18
+ readonly memoryRetrieval: MemoryRetrieval;
19
+ readonly topicTracker: TopicTracker;
20
+ readonly actionPlanner: ActionPlanner;
21
+ readonly emojiAgent: EmojiAgent;
22
+ readonly expressionLearner: ExpressionLearner;
23
+
24
+ constructor(
25
+ mainAI: AIInstance,
26
+ workAI: AIInstance,
27
+ config: ChatConfig,
28
+ db: ChatDatabase,
29
+ ) {
30
+ this.memoryRetrieval = new MemoryRetrieval(workAI, config, db);
31
+ this.topicTracker = new TopicTracker(workAI, config, db);
32
+ this.actionPlanner = new ActionPlanner(workAI, config);
33
+ this.emojiAgent = new EmojiAgent(workAI, config, db);
34
+ this.expressionLearner = new ExpressionLearner(workAI, config, db);
35
+ }
36
+
37
+ async init(): Promise<void> {
38
+ // emojiAgent 不需要初始化,它直接从文件系统读取
39
+ }
40
+ }