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.
- package/README.md +24 -0
- package/configs/base.ts +12 -0
- package/configs/personalization.ts +84 -0
- package/configs/settings.ts +54 -0
- package/context.ts +98 -0
- package/core/base.ts +936 -0
- package/core/chat-engine.ts +779 -0
- package/core/external-skills.ts +94 -0
- package/core/feature-prompts.ts +47 -0
- package/core/media/audio.ts +73 -0
- package/core/media/gif-extractor.ts +118 -0
- package/core/media/history-media.ts +751 -0
- package/core/media/image-analyzer.ts +447 -0
- package/core/media/markdown-message.ts +190 -0
- package/core/multimodal.ts +180 -0
- package/core/prompt.ts +827 -0
- package/core/tools.ts +862 -0
- package/core/web/searxng.ts +184 -0
- package/core/web/web-reader.ts +792 -0
- package/db.ts +849 -0
- package/humanize/emoji-agent.ts +397 -0
- package/humanize/expression.ts +152 -0
- package/humanize/index.ts +40 -0
- package/humanize/memory.ts +131 -0
- package/humanize/planner.ts +249 -0
- package/humanize/topic.ts +205 -0
- package/humanize/utils.ts +28 -0
- package/index.ts +1830 -0
- package/manage/cooldown.ts +445 -0
- package/manage/group-structured-history.ts +195 -0
- package/manage/idle-check.ts +186 -0
- package/manage/queue-processor.ts +228 -0
- package/manage/rate-limiter.ts +312 -0
- package/manage/session.ts +96 -0
- package/manage/skill-session.ts +173 -0
- package/manage/types.ts +138 -0
- package/package.json +51 -0
- package/tsconfig.json +7 -0
- package/types.ts +381 -0
- package/utils/index.ts +9 -0
- package/utils/message.ts +496 -0
- package/utils/queue.ts +186 -0
|
@@ -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
|
+
}
|