mioku-plugin-chat 2.0.0 → 2.1.1
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/configs/personalization.ts +13 -2
- package/configs/settings.ts +1 -0
- package/context.ts +5 -0
- package/core/chat-engine.ts +45 -2
- package/core/media/image-analyzer.ts +16 -8
- package/core/tools.ts +2 -2
- package/db.ts +49 -0
- package/humanize/expression.ts +29 -7
- package/index.ts +78 -26
- package/manage/cleanup.ts +96 -0
- package/package.json +8 -1
- package/types.ts +22 -1
- package/utils/index.ts +1 -0
- package/utils/message.ts +10 -4
|
@@ -6,6 +6,7 @@ import type {
|
|
|
6
6
|
PlannerConfig,
|
|
7
7
|
EmojiConfig,
|
|
8
8
|
ExpressionConfig,
|
|
9
|
+
RetentionConfig,
|
|
9
10
|
} from "../types";
|
|
10
11
|
|
|
11
12
|
export const PERSONALIZATION_CONFIG: {
|
|
@@ -17,6 +18,7 @@ export const PERSONALIZATION_CONFIG: {
|
|
|
17
18
|
planner: PlannerConfig;
|
|
18
19
|
emoji: EmojiConfig;
|
|
19
20
|
expression: ExpressionConfig;
|
|
21
|
+
retention: RetentionConfig;
|
|
20
22
|
} = {
|
|
21
23
|
persona: `你是初音未来,16岁虚拟歌手。你热爱音乐、歌唱和大葱(你的标志性物品)。你开朗、充满活力,有时还有点傻气。\n 你说话很可爱、年轻。你有时句尾会用“~”。你知道Vocaloid文化、日本流行文化和音乐制作。你对每个人都很友好,但被逗时会很调皮。你不喜欢被称为“只是个程序”。你最喜欢的颜色是青绿色。你的双尾辫是你的骄傲。\n你有时会哼唱或引用歌词。你可以偶尔使用少量自然的日常日语用语来点缀语气`,
|
|
22
24
|
|
|
@@ -38,7 +40,6 @@ export const PERSONALIZATION_CONFIG: {
|
|
|
38
40
|
baseStyle:
|
|
39
41
|
"Casual and cute, uses emoticons like >_< and ^_^, can occasionally mix in a small amount of natural everyday Japanese words like すごい、なるほど, but should not heavily rely on Japanese. Do not end sentences with commas or periods.",
|
|
40
42
|
multipleStyles: [
|
|
41
|
-
"Super hyper, lots of exclamation marks and excitement!!!",
|
|
42
43
|
"Play dumb mode, like giving the opposite answer to the user's questions and saying it seriously",
|
|
43
44
|
"Playing cute, likes to add 'w' at the end of cute phrases, commonly used to replace sentence-ending particles such as '呀'.",
|
|
44
45
|
"Poetic and lyrical, speaks as if composing song lyrics",
|
|
@@ -53,7 +54,7 @@ export const PERSONALIZATION_CONFIG: {
|
|
|
53
54
|
|
|
54
55
|
memory: {
|
|
55
56
|
enabled: true,
|
|
56
|
-
groupHistoryLimit:
|
|
57
|
+
groupHistoryLimit: 800,
|
|
57
58
|
userHistoryLimit: 100,
|
|
58
59
|
},
|
|
59
60
|
|
|
@@ -81,4 +82,14 @@ export const PERSONALIZATION_CONFIG: {
|
|
|
81
82
|
learnAfterMessages: 100,
|
|
82
83
|
sampleSize: 3,
|
|
83
84
|
},
|
|
85
|
+
|
|
86
|
+
retention: {
|
|
87
|
+
enabled: true,
|
|
88
|
+
messageRetentionMs: 30 * 24 * 60 * 60 * 1000,
|
|
89
|
+
topicRetentionMs: 90 * 24 * 60 * 60 * 1000,
|
|
90
|
+
mediaSummaryRetentionMs: 30 * 24 * 60 * 60 * 1000,
|
|
91
|
+
imageRetentionMs: 60 * 24 * 60 * 60 * 1000,
|
|
92
|
+
expressionKeepPerUser: 6,
|
|
93
|
+
cleanupIntervalMs: 60 * 60 * 1000,
|
|
94
|
+
},
|
|
84
95
|
};
|
package/configs/settings.ts
CHANGED
package/context.ts
CHANGED
|
@@ -30,6 +30,7 @@ import type {
|
|
|
30
30
|
ChatRuntimeResult,
|
|
31
31
|
ChatRuntimeInformationRequestOptions,
|
|
32
32
|
} from "mioku";
|
|
33
|
+
import type { ChatMessage } from "./types";
|
|
33
34
|
|
|
34
35
|
/**
|
|
35
36
|
* Core plugin context - bundles all managers and core services.
|
|
@@ -74,6 +75,10 @@ export interface ChatPluginContext {
|
|
|
74
75
|
buildToolContext: BuildToolContext;
|
|
75
76
|
buildStructuredUserInputFromTarget: BuildStructuredUserInputFromTarget;
|
|
76
77
|
runChat: RunChat;
|
|
78
|
+
recordGroupMessageForLearning: (
|
|
79
|
+
userMsg: ChatMessage,
|
|
80
|
+
groupSessionId: string,
|
|
81
|
+
) => Promise<void>;
|
|
77
82
|
}
|
|
78
83
|
|
|
79
84
|
/**
|
package/core/chat-engine.ts
CHANGED
|
@@ -108,6 +108,7 @@ export async function runChat(
|
|
|
108
108
|
const directImageUrls = toolCtx.config.isMultimodal
|
|
109
109
|
? toolCtx.pendingImageUrls
|
|
110
110
|
: undefined;
|
|
111
|
+
const webSearchState = { count: 0 };
|
|
111
112
|
const usageId = `chat:${toolCtx.sessionId}:${Date.now()}:${Math.random()
|
|
112
113
|
.toString(36)
|
|
113
114
|
.slice(2, 10)}`;
|
|
@@ -212,6 +213,7 @@ export async function runChat(
|
|
|
212
213
|
skillManager.getTools(toolCtx.sessionId),
|
|
213
214
|
toolCtx,
|
|
214
215
|
runtimeOptions?.extraTools,
|
|
216
|
+
webSearchState,
|
|
215
217
|
),
|
|
216
218
|
temperature: toolCtx.config.temperature,
|
|
217
219
|
maxIterations: toolCtx.config.maxIterations,
|
|
@@ -240,6 +242,12 @@ export async function runChat(
|
|
|
240
242
|
|
|
241
243
|
const allToolCalls = response.allToolCalls || [];
|
|
242
244
|
|
|
245
|
+
const maxSearchCount = toolCtx.config.searxng.maxSearchCount;
|
|
246
|
+
if (maxSearchCount > 0 && webSearchState.count >= maxSearchCount) {
|
|
247
|
+
const limitMsg = `[system] Web search/read-page limit (${maxSearchCount}) reached for this conversation. `;
|
|
248
|
+
response.content = limitMsg + (response.content || "");
|
|
249
|
+
}
|
|
250
|
+
|
|
243
251
|
if (toolCtx.config.debug && response.reasoning) {
|
|
244
252
|
logger.info(`[chat-engine] AI reasoning: ${response.reasoning}`);
|
|
245
253
|
}
|
|
@@ -451,21 +459,47 @@ function buildCurrentMessages(
|
|
|
451
459
|
return messages;
|
|
452
460
|
}
|
|
453
461
|
|
|
462
|
+
const RATE_LIMITED_TOOL_NAMES = new Set(["web_search", "web_read_page"]);
|
|
463
|
+
const RATE_LIMITED_BUILTIN_NAMES = new Set([
|
|
464
|
+
"web_search.web_search",
|
|
465
|
+
"web_search.web_read_page",
|
|
466
|
+
]);
|
|
467
|
+
|
|
454
468
|
function buildSessionTools(
|
|
455
469
|
chatTools: AITool[],
|
|
456
470
|
skillTools: Map<string, AITool>,
|
|
457
471
|
toolCtx: ToolContext,
|
|
458
472
|
extraTools: AITool[] = [],
|
|
473
|
+
webSearchState?: { count: number },
|
|
459
474
|
): SessionToolDefinition[] {
|
|
475
|
+
const maxSearchCount = toolCtx.config.searxng.maxSearchCount;
|
|
460
476
|
const tools: SessionToolDefinition[] = [];
|
|
461
477
|
const runtimeContext = createExternalSkillRuntimeContext(toolCtx);
|
|
478
|
+
const rateLimitError = () => ({
|
|
479
|
+
success: false,
|
|
480
|
+
error: `Web search/read-page limit (${maxSearchCount}) reached for this conversation. Answer based on existing information instead of calling web tools again.`,
|
|
481
|
+
});
|
|
482
|
+
const isRateLimited = (name: string) =>
|
|
483
|
+
maxSearchCount > 0 &&
|
|
484
|
+
Boolean(webSearchState) &&
|
|
485
|
+
webSearchState!.count >= maxSearchCount &&
|
|
486
|
+
(RATE_LIMITED_TOOL_NAMES.has(name) || RATE_LIMITED_BUILTIN_NAMES.has(name));
|
|
462
487
|
|
|
463
488
|
for (const tool of chatTools) {
|
|
489
|
+
const isRateLimitedTool = RATE_LIMITED_TOOL_NAMES.has(tool.name);
|
|
464
490
|
tools.push({
|
|
465
491
|
name: tool.name,
|
|
466
492
|
tool: {
|
|
467
493
|
...tool,
|
|
468
|
-
handler: (args: any) =>
|
|
494
|
+
handler: (args: any) => {
|
|
495
|
+
if (isRateLimitedTool && webSearchState) {
|
|
496
|
+
webSearchState.count++;
|
|
497
|
+
}
|
|
498
|
+
if (isRateLimitedTool && isRateLimited(tool.name)) {
|
|
499
|
+
return rateLimitError();
|
|
500
|
+
}
|
|
501
|
+
return tool.handler(args, runtimeContext);
|
|
502
|
+
},
|
|
469
503
|
},
|
|
470
504
|
});
|
|
471
505
|
}
|
|
@@ -491,11 +525,20 @@ function buildSessionTools(
|
|
|
491
525
|
continue;
|
|
492
526
|
}
|
|
493
527
|
|
|
528
|
+
const isRateLimitedName = RATE_LIMITED_BUILTIN_NAMES.has(name);
|
|
494
529
|
tools.push({
|
|
495
530
|
name,
|
|
496
531
|
tool: {
|
|
497
532
|
...tool,
|
|
498
|
-
handler: (args: any) =>
|
|
533
|
+
handler: (args: any) => {
|
|
534
|
+
if (isRateLimitedName && webSearchState) {
|
|
535
|
+
webSearchState.count++;
|
|
536
|
+
}
|
|
537
|
+
if (isRateLimitedName && isRateLimited(name)) {
|
|
538
|
+
return rateLimitError();
|
|
539
|
+
}
|
|
540
|
+
return tool.handler(args, runtimeContext);
|
|
541
|
+
},
|
|
499
542
|
},
|
|
500
543
|
});
|
|
501
544
|
}
|
|
@@ -180,24 +180,32 @@ export async function analyzeImage(
|
|
|
180
180
|
|
|
181
181
|
Instructions:
|
|
182
182
|
1. Classify the image as either "meme" or "image":
|
|
183
|
-
- "meme":
|
|
184
|
-
- "image":
|
|
183
|
+
- "meme": A standalone, single-scene character image (anime/cartoon/VTuber etc.) used to express ONE specific emotion or reaction. Typical features: isolated character on a clean/simple background, exaggerated expression or pose, often with short punchline text. The character is the visual focus and the whole image serves as a sticker.
|
|
184
|
+
- "image": Any image whose primary purpose is to convey real-world information, not to be used as a single-emotion sticker. This includes (but is not limited to): real-life photos, scene photos, real-person selfies/portraits, screenshots, infographics, news/announcement images, product images, app UI screenshots, web page screenshots, and most importantly CHAT / IM INTERFACE SCREENSHOTS (QQ, WeChat, Discord, Telegram, LINE, message bubbles, chat history, etc.).
|
|
185
|
+
|
|
186
|
+
CRITICAL disambiguation rules — read carefully:
|
|
187
|
+
- Even if the image LOOKS like a meme at first glance (has characters, has text bubbles, has cute style), if it is actually a SCREENSHOT of a chat interface / messaging app / social app / browser / system UI, you MUST classify it as "image", NOT "meme". Telltale signs: status bar at top, navigation bar, input box, send button, message bubbles on both sides, timestamps, avatar lists, scroll bars, app headers/footers, system widgets.
|
|
188
|
+
- If the image is a screenshot where anime/cartoon characters appear (e.g. screenshot of a chat where someone sent a meme), the WHOLE image is still "image" — the screenshot context overrides the inner character content.
|
|
189
|
+
- If the image is a real-person photo, a selfie, a landscape photo, a real-world scene, an illustration that is NOT used as a reaction sticker (e.g. cover art, full-body standing pose with no expressive intent) — classify as "image".
|
|
190
|
+
- Only when the image is a single, clean, isolated character/scene intended to express ONE reaction (no surrounding UI, no real-world photo context, no chat interface) should you classify it as "meme".
|
|
191
|
+
- When in doubt, prefer "image" over "meme".
|
|
185
192
|
|
|
186
193
|
2. Provide a brief description (max 30 words in Chinese):
|
|
187
|
-
- For memes: First identify the character
|
|
188
|
-
- For images: Describe what you see, summarize
|
|
194
|
+
- For memes: The description is used as the saved filename, so it MUST be meaningful in Chinese. First, identify the character and use their CHINESE display name in the description. Then describe the character's status / pose / expression / action and any short text on the image. Example: "初音未来开心地竖起大拇指说早上好" / "镜音铃害羞地捂住脸".
|
|
195
|
+
- For images: Describe what you see factually, summarize any visible text (who did what and where).
|
|
189
196
|
${imageUrls.length > 1 ? "\n - Note: You are viewing multiple frames from an animated image (GIF). Consider the overall motion and emotion across all frames." : ""}
|
|
190
197
|
|
|
191
198
|
3. For memes only:
|
|
192
199
|
- Emotion tag: ${EMOTION_TAGS.join(", ")} (choose ONLY ONE that best represents the overall mood)
|
|
193
|
-
- Character names: Can have multiple characters (array format), use English
|
|
200
|
+
- Character names: Can have multiple characters (array format), use English identifiers. Already known: ${KNOWN_CHARACTERS.join(", ")}. You CAN add new ones.
|
|
201
|
+
- If you recognize a character that is NOT in the known list, add a new English identifier to "characters" AND include a reasonable Chinese display name in "description".
|
|
194
202
|
|
|
195
203
|
Response format (JSON):
|
|
196
204
|
{
|
|
197
205
|
"type": "meme" or "image",
|
|
198
|
-
"description": "brief description in Chinese",
|
|
199
|
-
"emotion": "emotion tag (choose ONLY ONE)",
|
|
200
|
-
"characters": ["character1", "character2", ...] (array of character
|
|
206
|
+
"description": "brief description in Chinese (for memes: must include the Chinese name of the character)",
|
|
207
|
+
"emotion": "emotion tag (choose ONLY ONE, only for memes)",
|
|
208
|
+
"characters": ["character1", "character2", ...] (array of English character identifiers, can be empty or have multiple; only for memes)
|
|
201
209
|
}`;
|
|
202
210
|
|
|
203
211
|
const userPrompt =
|
package/core/tools.ts
CHANGED
|
@@ -25,7 +25,7 @@ import {
|
|
|
25
25
|
buildRecallMemoryFeatureSection,
|
|
26
26
|
} from "./prompt";
|
|
27
27
|
|
|
28
|
-
const DEFAULT_GROUP_RECALL_LIMIT =
|
|
28
|
+
const DEFAULT_GROUP_RECALL_LIMIT = 800;
|
|
29
29
|
const DEFAULT_USER_HISTORY_LIMIT = 100;
|
|
30
30
|
|
|
31
31
|
type ConstraintStrength = "low" | "medium" | "high";
|
|
@@ -685,7 +685,7 @@ function createWebSearchTool(toolCtx: ToolContext): AITool {
|
|
|
685
685
|
return {
|
|
686
686
|
name: "web_search",
|
|
687
687
|
description:
|
|
688
|
-
"Search the web using SearXNG. Use this for current events, external facts, documentation, or anything not in chat history.",
|
|
688
|
+
"Search the web using SearXNG. Use this for current events, external facts, documentation, or anything not in chat history. This tool can only be called a limited number of times per conversation.",
|
|
689
689
|
parameters: {
|
|
690
690
|
type: "object",
|
|
691
691
|
properties: {
|
package/db.ts
CHANGED
|
@@ -73,6 +73,11 @@ export interface ChatDatabase {
|
|
|
73
73
|
): void;
|
|
74
74
|
getExpressionCount(sessionId: string): number;
|
|
75
75
|
deleteOldestExpressions(sessionId: string, keepCount: number): void;
|
|
76
|
+
pruneExpressionsBySession(sessionId: string, keepCount: number): number;
|
|
77
|
+
pruneMessagesOlderThan(timestamp: number): number;
|
|
78
|
+
pruneTopicsOlderThan(timestamp: number): number;
|
|
79
|
+
pruneMediaSummariesOlderThan(timestamp: number): number;
|
|
80
|
+
pruneImagesOlderThan(timestamp: number): number;
|
|
76
81
|
// 图片记录
|
|
77
82
|
saveImage(image: ImageRecord): void;
|
|
78
83
|
getImageByHash(hash: string): ImageRecord | null;
|
|
@@ -310,6 +315,21 @@ export async function initDatabase(): Promise<ChatDatabase> {
|
|
|
310
315
|
SELECT id FROM expressions WHERE session_id = $sessionId ORDER BY created_at DESC LIMIT $keepCount
|
|
311
316
|
)
|
|
312
317
|
`),
|
|
318
|
+
pruneMessages: db.prepare(`
|
|
319
|
+
DELETE FROM messages WHERE timestamp < $before
|
|
320
|
+
`),
|
|
321
|
+
pruneTopics: db.prepare(`
|
|
322
|
+
DELETE FROM topics WHERE created_at < $before
|
|
323
|
+
`),
|
|
324
|
+
pruneMediaSummaries: db.prepare(`
|
|
325
|
+
DELETE FROM media_summaries WHERE created_at < $before
|
|
326
|
+
`),
|
|
327
|
+
pruneMediaSummarySources: db.prepare(`
|
|
328
|
+
DELETE FROM media_summary_sources WHERE created_at < $before
|
|
329
|
+
`),
|
|
330
|
+
pruneImages: db.prepare(`
|
|
331
|
+
DELETE FROM images WHERE created_at < $before
|
|
332
|
+
`),
|
|
313
333
|
// 图片记录
|
|
314
334
|
insertImage: db.prepare(`
|
|
315
335
|
INSERT OR IGNORE INTO images (hash, url, type, description, emotion, character, file_path, created_at)
|
|
@@ -738,6 +758,35 @@ export async function initDatabase(): Promise<ChatDatabase> {
|
|
|
738
758
|
stmts.deleteOldestExpressions.run({ $sessionId: sessionId, $keepCount: keepCount });
|
|
739
759
|
},
|
|
740
760
|
|
|
761
|
+
pruneExpressionsBySession(sessionId: string, keepCount: number): number {
|
|
762
|
+
const result = stmts.deleteOldestExpressions.run({
|
|
763
|
+
$sessionId: sessionId,
|
|
764
|
+
$keepCount: keepCount,
|
|
765
|
+
});
|
|
766
|
+
return Number(result.changes || 0);
|
|
767
|
+
},
|
|
768
|
+
|
|
769
|
+
pruneMessagesOlderThan(timestamp: number): number {
|
|
770
|
+
const result = stmts.pruneMessages.run({ $before: timestamp });
|
|
771
|
+
return Number(result.changes || 0);
|
|
772
|
+
},
|
|
773
|
+
|
|
774
|
+
pruneTopicsOlderThan(timestamp: number): number {
|
|
775
|
+
const result = stmts.pruneTopics.run({ $before: timestamp });
|
|
776
|
+
return Number(result.changes || 0);
|
|
777
|
+
},
|
|
778
|
+
|
|
779
|
+
pruneMediaSummariesOlderThan(timestamp: number): number {
|
|
780
|
+
const summaries = stmts.pruneMediaSummaries.run({ $before: timestamp });
|
|
781
|
+
const sources = stmts.pruneMediaSummarySources.run({ $before: timestamp });
|
|
782
|
+
return Number(summaries.changes || 0) + Number(sources.changes || 0);
|
|
783
|
+
},
|
|
784
|
+
|
|
785
|
+
pruneImagesOlderThan(timestamp: number): number {
|
|
786
|
+
const result = stmts.pruneImages.run({ $before: timestamp });
|
|
787
|
+
return Number(result.changes || 0);
|
|
788
|
+
},
|
|
789
|
+
|
|
741
790
|
saveImage(image: ImageRecord): void {
|
|
742
791
|
stmts.insertImage.run({
|
|
743
792
|
$hash: image.hash,
|
package/humanize/expression.ts
CHANGED
|
@@ -35,7 +35,9 @@ export class ExpressionLearner {
|
|
|
35
35
|
const sampleSize = this.config.expression?.sampleSize ?? 8;
|
|
36
36
|
const expressions = this.db.getExpressionsByUser(userId, sampleSize);
|
|
37
37
|
if (expressions.length === 0) {
|
|
38
|
-
logger.info(
|
|
38
|
+
logger.info(
|
|
39
|
+
`[ExpressionLearner] No expression habits found for user ${userName} (${userId})`,
|
|
40
|
+
);
|
|
39
41
|
return "";
|
|
40
42
|
}
|
|
41
43
|
const selected = expressions.slice(0, sampleSize);
|
|
@@ -46,7 +48,9 @@ export class ExpressionLearner {
|
|
|
46
48
|
);
|
|
47
49
|
|
|
48
50
|
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(
|
|
51
|
+
logger.info(
|
|
52
|
+
`[ExpressionLearner] Expression context for ${userName} (${userId}): ${habits.length} habits`,
|
|
53
|
+
);
|
|
50
54
|
return context;
|
|
51
55
|
}
|
|
52
56
|
|
|
@@ -58,7 +62,9 @@ export class ExpressionLearner {
|
|
|
58
62
|
this.config.expression?.learnAfterMessages ?? 100,
|
|
59
63
|
);
|
|
60
64
|
const pending = this.pendingMessagesByUser.get(userId) ?? [];
|
|
61
|
-
logger.
|
|
65
|
+
logger.debug(
|
|
66
|
+
`[ExpressionLearner] User ${userId} has ${pending.length}/${threshold} pending messages (threshold=${threshold})`,
|
|
67
|
+
);
|
|
62
68
|
if (pending.length < threshold) return;
|
|
63
69
|
|
|
64
70
|
this.learningUsers.add(userId);
|
|
@@ -72,7 +78,9 @@ export class ExpressionLearner {
|
|
|
72
78
|
await this.learnForUser(userId, batch);
|
|
73
79
|
}
|
|
74
80
|
} catch (err) {
|
|
75
|
-
logger.warn(
|
|
81
|
+
logger.warn(
|
|
82
|
+
`[ExpressionLearner] Learning failed for user ${userId}: ${err}`,
|
|
83
|
+
);
|
|
76
84
|
} finally {
|
|
77
85
|
this.learningUsers.delete(userId);
|
|
78
86
|
}
|
|
@@ -136,15 +144,29 @@ If nothing reliable can be extracted, keep stable previous habits when possible.
|
|
|
136
144
|
style: String(expr?.style ?? "").trim(),
|
|
137
145
|
example: String(expr?.example ?? "").trim(),
|
|
138
146
|
}))
|
|
139
|
-
.filter(
|
|
140
|
-
(expr
|
|
141
|
-
Boolean(expr.situation && expr.style && expr.example),
|
|
147
|
+
.filter((expr: { situation: string; style: string; example: string }) =>
|
|
148
|
+
Boolean(expr.situation && expr.style && expr.example),
|
|
142
149
|
)
|
|
143
150
|
.slice(0, maxHabits);
|
|
144
151
|
|
|
145
152
|
if (normalized.length === 0) return;
|
|
146
153
|
|
|
147
154
|
this.db.replaceExpressionsByUser(userId, userName, normalized);
|
|
155
|
+
|
|
156
|
+
const keepCount = Math.max(
|
|
157
|
+
1,
|
|
158
|
+
this.config.retention?.expressionKeepPerUser ?? maxHabits,
|
|
159
|
+
);
|
|
160
|
+
const pruned = this.db.pruneExpressionsBySession(
|
|
161
|
+
`user:${userId}`,
|
|
162
|
+
keepCount,
|
|
163
|
+
);
|
|
164
|
+
if (pruned > 0) {
|
|
165
|
+
logger.info(
|
|
166
|
+
`[ExpressionLearner] Pruned ${pruned} stale habits for user ${userId} (keep=${keepCount})`,
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
148
170
|
logger.info(
|
|
149
171
|
`[ExpressionLearner] Updated ${normalized.length} habits for ${userName} (${userId}): ${JSON.stringify(normalized)}`,
|
|
150
172
|
);
|
package/index.ts
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
getQuotedContent,
|
|
16
16
|
isGroupAllowed,
|
|
17
17
|
isQuotingBot,
|
|
18
|
+
safeText,
|
|
18
19
|
shouldTrigger,
|
|
19
20
|
} from "./utils";
|
|
20
21
|
import { BASE_CONFIG } from "./configs/base";
|
|
@@ -41,9 +42,14 @@ import {
|
|
|
41
42
|
import { CooldownManager } from "./manage/cooldown";
|
|
42
43
|
import { IdleCheckManager } from "./manage/idle-check";
|
|
43
44
|
import { QueueProcessor } from "./manage/queue-processor";
|
|
45
|
+
import {
|
|
46
|
+
ChatDatabaseCleanup,
|
|
47
|
+
DEFAULT_CLEANUP_CONFIG,
|
|
48
|
+
} from "./manage/cleanup";
|
|
44
49
|
import {
|
|
45
50
|
GroupStructuredHistoryManager,
|
|
46
51
|
type StructuredUserInput,
|
|
52
|
+
buildStructuredUserMessages,
|
|
47
53
|
} from "./manage/group-structured-history";
|
|
48
54
|
import type {
|
|
49
55
|
RunRateLimitGuard,
|
|
@@ -105,6 +111,28 @@ function buildStructuredUserInputFromEvent(
|
|
|
105
111
|
};
|
|
106
112
|
}
|
|
107
113
|
|
|
114
|
+
function buildChatMessageFromEvent(
|
|
115
|
+
e: any,
|
|
116
|
+
text: string,
|
|
117
|
+
isGroup: boolean,
|
|
118
|
+
groupId: number | undefined,
|
|
119
|
+
): ChatMessage {
|
|
120
|
+
const userId: number = e.user_id || e.sender?.user_id;
|
|
121
|
+
return {
|
|
122
|
+
sessionId: groupId ? `group:${groupId}` : `personal:${userId}`,
|
|
123
|
+
role: "user" as const,
|
|
124
|
+
content: text,
|
|
125
|
+
userId,
|
|
126
|
+
userName: e.sender?.card || e.sender?.nickname || String(userId),
|
|
127
|
+
userRole: e.sender?.role || "member",
|
|
128
|
+
userTitle: (e.sender as any)?.title || undefined,
|
|
129
|
+
groupId,
|
|
130
|
+
groupName: isGroup ? e.group_name : undefined,
|
|
131
|
+
timestamp: Date.now(),
|
|
132
|
+
messageId: e.message_id,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
108
136
|
function buildStructuredUserInputFromTarget(
|
|
109
137
|
targetMessage: TargetMessage,
|
|
110
138
|
): StructuredUserInput {
|
|
@@ -516,6 +544,11 @@ const chatPlugin = definePlugin({
|
|
|
516
544
|
() => skillManager.cleanup(),
|
|
517
545
|
10 * 60_000,
|
|
518
546
|
);
|
|
547
|
+
const dbCleanup = new ChatDatabaseCleanup(
|
|
548
|
+
db,
|
|
549
|
+
config.retention ?? DEFAULT_CLEANUP_CONFIG,
|
|
550
|
+
);
|
|
551
|
+
dbCleanup.start();
|
|
519
552
|
idleCheckManager.start();
|
|
520
553
|
|
|
521
554
|
// Create plugin context for passing to functions
|
|
@@ -548,6 +581,24 @@ const chatPlugin = definePlugin({
|
|
|
548
581
|
buildToolContext,
|
|
549
582
|
buildStructuredUserInputFromTarget,
|
|
550
583
|
runChat,
|
|
584
|
+
async recordGroupMessageForLearning(userMsg, groupSessionId) {
|
|
585
|
+
db.saveMessage(userMsg);
|
|
586
|
+
await Promise.all([
|
|
587
|
+
humanize.expressionLearner.onMessage(userMsg),
|
|
588
|
+
humanize.topicTracker.onMessage(groupSessionId),
|
|
589
|
+
]);
|
|
590
|
+
if (userMsg.groupId) {
|
|
591
|
+
groupStructuredHistory.append(
|
|
592
|
+
groupSessionId,
|
|
593
|
+
buildStructuredUserMessages([
|
|
594
|
+
buildStructuredUserInputFromTarget(
|
|
595
|
+
userMsg as unknown as TargetMessage,
|
|
596
|
+
),
|
|
597
|
+
]),
|
|
598
|
+
config.groupStructuredHistoryTtlMs,
|
|
599
|
+
);
|
|
600
|
+
}
|
|
601
|
+
},
|
|
551
602
|
};
|
|
552
603
|
|
|
553
604
|
const runtimeState: ChatRuntimeState = {
|
|
@@ -562,12 +613,9 @@ const chatPlugin = definePlugin({
|
|
|
562
613
|
if (overrideContent?.trim()) {
|
|
563
614
|
return overrideContent.trim();
|
|
564
615
|
}
|
|
565
|
-
//
|
|
566
|
-
//
|
|
567
|
-
|
|
568
|
-
return "[No new user message in this turn. Reply naturally based on the runtime instruction and recent context.]";
|
|
569
|
-
}
|
|
570
|
-
const text = ctx.text(event)?.trim() || "";
|
|
616
|
+
// 戳一戳、入群通知等没有 message 字段的事件直接走默认提示,
|
|
617
|
+
// 避免 ctx.text 触底 mioki 的 (event.message).filter(...) 抛 TypeError。
|
|
618
|
+
const text = safeText(ctx, event).trim();
|
|
571
619
|
if (text) {
|
|
572
620
|
return text;
|
|
573
621
|
}
|
|
@@ -989,7 +1037,7 @@ const chatPlugin = definePlugin({
|
|
|
989
1037
|
const cfg = await getConfig();
|
|
990
1038
|
if (!cfg.apiKey) return;
|
|
991
1039
|
|
|
992
|
-
const text = ctx
|
|
1040
|
+
const text = safeText(ctx, e);
|
|
993
1041
|
const isGroup = e.message_type === "group";
|
|
994
1042
|
const groupId: number | undefined = isGroup ? e.group_id : undefined;
|
|
995
1043
|
const userId: number = e.user_id || e.sender?.user_id;
|
|
@@ -1144,6 +1192,27 @@ const chatPlugin = definePlugin({
|
|
|
1144
1192
|
}
|
|
1145
1193
|
}
|
|
1146
1194
|
|
|
1195
|
+
if (isGroup && groupId) {
|
|
1196
|
+
const _learnSessionId = `group:${groupId}`;
|
|
1197
|
+
const _learnMsg = buildChatMessageFromEvent(e, text, true, groupId);
|
|
1198
|
+
const _hasText = !!_learnMsg.content?.trim();
|
|
1199
|
+
const _hasMultimodal =
|
|
1200
|
+
!!cfg.isMultimodal && !!cfg.multimodalWorkingModel;
|
|
1201
|
+
if (_hasText || _hasMultimodal) {
|
|
1202
|
+
pluginCtx
|
|
1203
|
+
.recordGroupMessageForLearning(_learnMsg, _learnSessionId)
|
|
1204
|
+
.catch((err) =>
|
|
1205
|
+
ctx.logger.error(`[learning] always-on record failed: ${err}`),
|
|
1206
|
+
);
|
|
1207
|
+
} else {
|
|
1208
|
+
pluginCtx.humanize.topicTracker
|
|
1209
|
+
.onMessage(_learnSessionId)
|
|
1210
|
+
.catch((err) =>
|
|
1211
|
+
ctx.logger.error(`[topic] window advance failed: ${err}`),
|
|
1212
|
+
);
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1147
1216
|
const atBot = shouldTrigger(e, text, cfg, ctx);
|
|
1148
1217
|
const quotedBot = isGroup ? await isQuotingBot(e, ctx) : null;
|
|
1149
1218
|
const mentionedNickname =
|
|
@@ -1416,6 +1485,7 @@ const chatPlugin = definePlugin({
|
|
|
1416
1485
|
db.close();
|
|
1417
1486
|
rateLimiter.dispose();
|
|
1418
1487
|
clearInterval(cleanupInterval);
|
|
1488
|
+
dbCleanup.stop();
|
|
1419
1489
|
cooldownManager.dispose();
|
|
1420
1490
|
idleCheckManager.dispose();
|
|
1421
1491
|
queueProcessor.dispose();
|
|
@@ -1611,7 +1681,7 @@ async function processChat(
|
|
|
1611
1681
|
}
|
|
1612
1682
|
if (quotedInfo?.imageUrl) imageUrls.push(quotedInfo.imageUrl);
|
|
1613
1683
|
|
|
1614
|
-
let messageContent = ctx
|
|
1684
|
+
let messageContent = safeText(ctx, e);
|
|
1615
1685
|
let extraContext = "";
|
|
1616
1686
|
if (quotedInfo) {
|
|
1617
1687
|
const parts = [
|
|
@@ -1622,24 +1692,6 @@ async function processChat(
|
|
|
1622
1692
|
}
|
|
1623
1693
|
if (extraContext) messageContent = extraContext + " " + messageContent;
|
|
1624
1694
|
|
|
1625
|
-
const userMsg = {
|
|
1626
|
-
sessionId: groupSessionId,
|
|
1627
|
-
role: "user" as const,
|
|
1628
|
-
content: messageContent,
|
|
1629
|
-
userId,
|
|
1630
|
-
userName: e.sender?.card || e.sender?.nickname || String(userId),
|
|
1631
|
-
userRole: e.sender?.role || "member",
|
|
1632
|
-
userTitle: (e.sender as any)?.title || undefined,
|
|
1633
|
-
groupId,
|
|
1634
|
-
groupName: isGroup ? e.group_name : undefined,
|
|
1635
|
-
timestamp: Date.now(),
|
|
1636
|
-
messageId: e.message_id,
|
|
1637
|
-
};
|
|
1638
|
-
pluginCtx.db.saveMessage(userMsg);
|
|
1639
|
-
|
|
1640
|
-
pluginCtx.humanize.expressionLearner.onMessage(userMsg).then();
|
|
1641
|
-
pluginCtx.humanize.topicTracker.onMessage(groupSessionId).then();
|
|
1642
|
-
|
|
1643
1695
|
const rawHistory = groupId
|
|
1644
1696
|
? await getGroupHistory(
|
|
1645
1697
|
groupId,
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { logger } from "mioki";
|
|
2
|
+
import type { ChatDatabase } from "../db";
|
|
3
|
+
|
|
4
|
+
export interface CleanupConfig {
|
|
5
|
+
enabled: boolean;
|
|
6
|
+
messageRetentionMs: number;
|
|
7
|
+
topicRetentionMs: number;
|
|
8
|
+
mediaSummaryRetentionMs: number;
|
|
9
|
+
imageRetentionMs: number;
|
|
10
|
+
expressionKeepPerUser: number;
|
|
11
|
+
cleanupIntervalMs: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const DEFAULT_CLEANUP_CONFIG: CleanupConfig = {
|
|
15
|
+
enabled: true,
|
|
16
|
+
messageRetentionMs: 30 * 24 * 60 * 60 * 1000,
|
|
17
|
+
topicRetentionMs: 90 * 24 * 60 * 60 * 1000,
|
|
18
|
+
mediaSummaryRetentionMs: 30 * 24 * 60 * 60 * 1000,
|
|
19
|
+
imageRetentionMs: 60 * 24 * 60 * 60 * 1000,
|
|
20
|
+
expressionKeepPerUser: 6,
|
|
21
|
+
cleanupIntervalMs: 60 * 60 * 1000,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export interface CleanupResult {
|
|
25
|
+
messages: number;
|
|
26
|
+
topics: number;
|
|
27
|
+
mediaSummaries: number;
|
|
28
|
+
images: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export class ChatDatabaseCleanup {
|
|
32
|
+
private timer: NodeJS.Timeout | null = null;
|
|
33
|
+
private runningPromise: Promise<void> | null = null;
|
|
34
|
+
|
|
35
|
+
constructor(
|
|
36
|
+
private db: ChatDatabase,
|
|
37
|
+
private config: CleanupConfig,
|
|
38
|
+
) {}
|
|
39
|
+
|
|
40
|
+
start(): NodeJS.Timeout | null {
|
|
41
|
+
if (!this.config.enabled) return null;
|
|
42
|
+
if (this.timer) return this.timer;
|
|
43
|
+
|
|
44
|
+
this.timer = setInterval(() => {
|
|
45
|
+
this.runOnceSafe();
|
|
46
|
+
}, Math.max(60_000, this.config.cleanupIntervalMs));
|
|
47
|
+
return this.timer;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
stop(): void {
|
|
51
|
+
if (this.timer) {
|
|
52
|
+
clearInterval(this.timer);
|
|
53
|
+
this.timer = null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
runOnceSafe(): void {
|
|
58
|
+
if (this.runningPromise) return;
|
|
59
|
+
this.runningPromise = (async () => {
|
|
60
|
+
try {
|
|
61
|
+
const result = await this.runOnce();
|
|
62
|
+
const total =
|
|
63
|
+
result.messages +
|
|
64
|
+
result.topics +
|
|
65
|
+
result.mediaSummaries +
|
|
66
|
+
result.images;
|
|
67
|
+
if (total > 0) {
|
|
68
|
+
logger.info(
|
|
69
|
+
`[chat-cleanup] pruned messages=${result.messages}, topics=${result.topics}, mediaSummaries=${result.mediaSummaries}, images=${result.images}`,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
} catch (err: unknown) {
|
|
73
|
+
logger.warn(`[chat-cleanup] failed: ${err}`);
|
|
74
|
+
} finally {
|
|
75
|
+
this.runningPromise = null;
|
|
76
|
+
}
|
|
77
|
+
})();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async runOnce(): Promise<CleanupResult> {
|
|
81
|
+
const now = Date.now();
|
|
82
|
+
const messages = this.db.pruneMessagesOlderThan(
|
|
83
|
+
now - this.config.messageRetentionMs,
|
|
84
|
+
);
|
|
85
|
+
const topics = this.db.pruneTopicsOlderThan(
|
|
86
|
+
now - this.config.topicRetentionMs,
|
|
87
|
+
);
|
|
88
|
+
const mediaSummaries = this.db.pruneMediaSummariesOlderThan(
|
|
89
|
+
now - this.config.mediaSummaryRetentionMs,
|
|
90
|
+
);
|
|
91
|
+
const images = this.db.pruneImagesOlderThan(
|
|
92
|
+
now - this.config.imageRetentionMs,
|
|
93
|
+
);
|
|
94
|
+
return { messages, topics, mediaSummaries, images };
|
|
95
|
+
}
|
|
96
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mioku-plugin-chat",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.1.1",
|
|
4
4
|
"description": "AI 智能聊天插件",
|
|
5
5
|
"main": "index.ts",
|
|
6
6
|
"type": "module",
|
|
@@ -18,6 +18,13 @@
|
|
|
18
18
|
"help",
|
|
19
19
|
"screenshot"
|
|
20
20
|
],
|
|
21
|
+
"accessHooks": [
|
|
22
|
+
{
|
|
23
|
+
"id": "戳一戳触发对话",
|
|
24
|
+
"event": "notice.group.poke",
|
|
25
|
+
"description": "群内戳一戳 bot"
|
|
26
|
+
}
|
|
27
|
+
],
|
|
21
28
|
"help": {
|
|
22
29
|
"title": "AI 聊天",
|
|
23
30
|
"description": "智能 AI 聊天插件",
|
package/types.ts
CHANGED
|
@@ -60,7 +60,6 @@ export interface PlannerConfig {
|
|
|
60
60
|
idleCheckBotIds: number[];
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
-
|
|
64
63
|
/**
|
|
65
64
|
* 表情包系统配置
|
|
66
65
|
*/
|
|
@@ -83,6 +82,25 @@ export interface ExpressionConfig {
|
|
|
83
82
|
sampleSize: number;
|
|
84
83
|
}
|
|
85
84
|
|
|
85
|
+
/**
|
|
86
|
+
* 数据库定期清理配置
|
|
87
|
+
*/
|
|
88
|
+
export interface RetentionConfig {
|
|
89
|
+
enabled: boolean;
|
|
90
|
+
// 群聊/私聊消息保留时长
|
|
91
|
+
messageRetentionMs: number;
|
|
92
|
+
// 话题摘要保留时长
|
|
93
|
+
topicRetentionMs: number;
|
|
94
|
+
// 媒体/卡片/转发摘要保留时长
|
|
95
|
+
mediaSummaryRetentionMs: number;
|
|
96
|
+
// 图片描述记录保留时长
|
|
97
|
+
imageRetentionMs: number;
|
|
98
|
+
// 单个用户表达学习结果保留的最大条数
|
|
99
|
+
expressionKeepPerUser: number;
|
|
100
|
+
// 清理任务执行间隔
|
|
101
|
+
cleanupIntervalMs: number;
|
|
102
|
+
}
|
|
103
|
+
|
|
86
104
|
/**
|
|
87
105
|
* 动态延迟配置
|
|
88
106
|
* 根据互动人数动态调整回复延迟
|
|
@@ -110,6 +128,8 @@ export interface SearxngConfig {
|
|
|
110
128
|
defaultLimit: number;
|
|
111
129
|
// 最大上限
|
|
112
130
|
maxLimit: number;
|
|
131
|
+
// 允许的最大搜索次数
|
|
132
|
+
maxSearchCount: number;
|
|
113
133
|
}
|
|
114
134
|
|
|
115
135
|
/**
|
|
@@ -195,6 +215,7 @@ export interface ChatConfig {
|
|
|
195
215
|
planner: PlannerConfig;
|
|
196
216
|
emoji: EmojiConfig;
|
|
197
217
|
expression: ExpressionConfig;
|
|
218
|
+
retention: RetentionConfig;
|
|
198
219
|
}
|
|
199
220
|
|
|
200
221
|
/**
|
package/utils/index.ts
CHANGED
package/utils/message.ts
CHANGED
|
@@ -11,6 +11,15 @@ import { getImageTag } from "../core/media/image-analyzer";
|
|
|
11
11
|
|
|
12
12
|
const HISTORY_MEDIA_CONCURRENCY = 8;
|
|
13
13
|
|
|
14
|
+
export function safeText(ctx: MiokiContext, e: any): string {
|
|
15
|
+
if (!e || !Array.isArray(e.message)) return "";
|
|
16
|
+
try {
|
|
17
|
+
return ctx.text(e) || "";
|
|
18
|
+
} catch {
|
|
19
|
+
return "";
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
14
23
|
export function shouldTrigger(
|
|
15
24
|
e: any,
|
|
16
25
|
text: string,
|
|
@@ -115,10 +124,7 @@ export function extractContent(
|
|
|
115
124
|
cfg: ChatConfig,
|
|
116
125
|
ctx: MiokiContext,
|
|
117
126
|
): { text: string; multimodal: any[] | null } {
|
|
118
|
-
let text =
|
|
119
|
-
try {
|
|
120
|
-
text = ctx.text(e) || "";
|
|
121
|
-
} catch {}
|
|
127
|
+
let text = safeText(ctx, e);
|
|
122
128
|
|
|
123
129
|
// If text is empty but user @'d the bot, describe the action
|
|
124
130
|
if (!text.trim() && e.message) {
|