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,131 @@
|
|
|
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 interface MemoryUserHistoryChunk {
|
|
7
|
+
userId: number;
|
|
8
|
+
messages: ChatMessage[];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface MemoryRecallInput {
|
|
12
|
+
sessionId: string;
|
|
13
|
+
question: string;
|
|
14
|
+
groupHistoryMessages?: ChatMessage[];
|
|
15
|
+
userHistories?: MemoryUserHistoryChunk[];
|
|
16
|
+
nowTimestamp?: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export class MemoryRetrieval {
|
|
20
|
+
private ai: AIInstance;
|
|
21
|
+
private config: ChatConfig;
|
|
22
|
+
private db: ChatDatabase;
|
|
23
|
+
|
|
24
|
+
constructor(ai: AIInstance, config: ChatConfig, db: ChatDatabase) {
|
|
25
|
+
this.ai = ai;
|
|
26
|
+
this.config = config;
|
|
27
|
+
this.db = db;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async retrieveByQuestion(input: MemoryRecallInput): Promise<string | null> {
|
|
31
|
+
if (!this.config.memory?.enabled) return null;
|
|
32
|
+
|
|
33
|
+
const question = String(input.question || "").trim();
|
|
34
|
+
if (!question) return null;
|
|
35
|
+
|
|
36
|
+
const nowTs = input.nowTimestamp ?? Date.now();
|
|
37
|
+
const nowText = this.formatTimestamp(nowTs);
|
|
38
|
+
const recentContext = this.formatSessionMessages(
|
|
39
|
+
this.db.getMessages(input.sessionId, 30),
|
|
40
|
+
);
|
|
41
|
+
const groupHistory = this.formatGroupHistory(input.groupHistoryMessages || []);
|
|
42
|
+
const userHistory = this.formatUserHistories(input.userHistories || []);
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
const result = await this.ai.generateText({
|
|
46
|
+
prompt: `You are the memory worker model. You receive a recall question from the main chat model.
|
|
47
|
+
Current time: ${nowText}
|
|
48
|
+
|
|
49
|
+
Recall question:
|
|
50
|
+
${question}
|
|
51
|
+
|
|
52
|
+
Important:
|
|
53
|
+
- All provided records are historical logs, NOT messages sent just now.
|
|
54
|
+
- Every log already contains its send time/date. Use those timestamps carefully.
|
|
55
|
+
- If evidence is insufficient, clearly say what is missing instead of guessing.
|
|
56
|
+
|
|
57
|
+
Recent in-context chat snapshot (for orientation only):
|
|
58
|
+
${recentContext}
|
|
59
|
+
|
|
60
|
+
Group history fetched via message_id pagination (historical):
|
|
61
|
+
${groupHistory}
|
|
62
|
+
|
|
63
|
+
User history chunks (historical):
|
|
64
|
+
${userHistory}
|
|
65
|
+
|
|
66
|
+
Output requirements:
|
|
67
|
+
- Give one concise recall result for the main chat model.
|
|
68
|
+
- If useful evidence exists, summarize key facts with timestamps.
|
|
69
|
+
- If no useful memory exists, output exactly: NO_USEFUL_MEMORY_FOUND`,
|
|
70
|
+
messages: [],
|
|
71
|
+
model: this.config.workingModel || this.config.model,
|
|
72
|
+
temperature: 0.2,
|
|
73
|
+
max_tokens: 700,
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const text = String(result || "").trim();
|
|
77
|
+
if (!text || text.includes("NO_USEFUL_MEMORY_FOUND")) {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
return text;
|
|
81
|
+
} catch (err) {
|
|
82
|
+
logger.warn(`[MemoryRetrieval] Worker retrieval failed: ${err}`);
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
private formatSessionMessages(messages: ChatMessage[]): string {
|
|
88
|
+
if (!messages.length) return "(none)";
|
|
89
|
+
return messages
|
|
90
|
+
.map((m) => {
|
|
91
|
+
const name = m.userName || "unknown";
|
|
92
|
+
return `[历史消息 | ${this.formatTimestamp(m.timestamp)}] ${name}: ${m.content}(不是刚刚发送)`;
|
|
93
|
+
})
|
|
94
|
+
.join("\n");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
private formatGroupHistory(messages: ChatMessage[]): string {
|
|
98
|
+
if (!messages.length) return "(none)";
|
|
99
|
+
return messages
|
|
100
|
+
.map((m) => {
|
|
101
|
+
const name = m.userName || "unknown";
|
|
102
|
+
const messageId = m.messageId ?? "unknown";
|
|
103
|
+
return `[历史群聊 | ${this.formatTimestamp(m.timestamp)} | message_id:${messageId}] ${name}: ${m.content}(不是刚刚发送)`;
|
|
104
|
+
})
|
|
105
|
+
.join("\n");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
private formatUserHistories(chunks: MemoryUserHistoryChunk[]): string {
|
|
109
|
+
if (!chunks.length) return "(none)";
|
|
110
|
+
|
|
111
|
+
const sections = chunks.map((chunk) => {
|
|
112
|
+
if (!chunk.messages.length) {
|
|
113
|
+
return `User ${chunk.userId}: (no historical messages)`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const lines = chunk.messages.map((m) => {
|
|
117
|
+
const name = m.userName || `user:${chunk.userId}`;
|
|
118
|
+
const messageId = m.messageId ?? "unknown";
|
|
119
|
+
return `[历史用户消息 | ${this.formatTimestamp(m.timestamp)} | message_id:${messageId}] ${name}: ${m.content}(不是刚刚发送)`;
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
return `User ${chunk.userId} history:\n${lines.join("\n")}`;
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
return sections.join("\n\n");
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
private formatTimestamp(timestamp: number): string {
|
|
129
|
+
return new Date(timestamp).toLocaleString("zh-CN", { hour12: false });
|
|
130
|
+
}
|
|
131
|
+
}
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import type { AIInstance } from "mioku";
|
|
2
|
+
import { logger } from "mioki";
|
|
3
|
+
import type {
|
|
4
|
+
ChatConfig,
|
|
5
|
+
ChatMessage,
|
|
6
|
+
PlannerAction,
|
|
7
|
+
PlannerResult,
|
|
8
|
+
} from "../types";
|
|
9
|
+
|
|
10
|
+
export class ActionPlanner {
|
|
11
|
+
private ai: AIInstance;
|
|
12
|
+
private config: ChatConfig;
|
|
13
|
+
private actionHistory: Map<
|
|
14
|
+
string,
|
|
15
|
+
{ action: PlannerAction; time: number }[]
|
|
16
|
+
> = new Map();
|
|
17
|
+
private pendingPlans: Map<string, Promise<PlannerResult>> = new Map();
|
|
18
|
+
|
|
19
|
+
constructor(ai: AIInstance, config: ChatConfig) {
|
|
20
|
+
this.ai = ai;
|
|
21
|
+
this.config = config;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async plan(
|
|
25
|
+
sessionId: string,
|
|
26
|
+
botName: string,
|
|
27
|
+
recentHistory: ChatMessage[],
|
|
28
|
+
lastTriggerMessage: string,
|
|
29
|
+
isIdleCheck: boolean = false,
|
|
30
|
+
): Promise<PlannerResult> {
|
|
31
|
+
if (!this.config.planner?.enabled) {
|
|
32
|
+
return { action: "reply", reason: "planner disabled" };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Guard: if a plan call is already in-flight for this session, wait for it
|
|
36
|
+
const existing = this.pendingPlans.get(sessionId);
|
|
37
|
+
if (existing) {
|
|
38
|
+
logger.info(`[ActionPlanner] Session ${sessionId} has a plan already in-flight, waiting...`);
|
|
39
|
+
return existing;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const planPromise = this.doPlan(sessionId, botName, recentHistory, lastTriggerMessage, isIdleCheck);
|
|
43
|
+
this.pendingPlans.set(sessionId, planPromise);
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
return await planPromise;
|
|
47
|
+
} finally {
|
|
48
|
+
this.pendingPlans.delete(sessionId);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
private async doPlan(
|
|
53
|
+
sessionId: string,
|
|
54
|
+
botName: string,
|
|
55
|
+
recentHistory: ChatMessage[],
|
|
56
|
+
lastTriggerMessage: string,
|
|
57
|
+
isIdleCheck: boolean,
|
|
58
|
+
): Promise<PlannerResult> {
|
|
59
|
+
if (!this.config.planner?.enabled) {
|
|
60
|
+
return { action: "reply", reason: "planner disabled" };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const history = this.actionHistory.get(sessionId) ?? [];
|
|
64
|
+
const recentActions = history.slice(-10);
|
|
65
|
+
const actionsBlock = recentActions
|
|
66
|
+
.map((a) => `[${new Date(a.time).toLocaleTimeString()}] ${a.action}`)
|
|
67
|
+
.join("\n");
|
|
68
|
+
|
|
69
|
+
const chatBlock = recentHistory
|
|
70
|
+
.slice(-100)
|
|
71
|
+
.map((m) => {
|
|
72
|
+
const time = new Date(m.timestamp);
|
|
73
|
+
const timeStr = `${String(time.getHours()).padStart(2, "0")}:${String(time.getMinutes()).padStart(2, "0")}`;
|
|
74
|
+
return `[${timeStr}] ${m.userName || (m.role === "assistant" ? botName : "unknown")}: ${m.content}`;
|
|
75
|
+
})
|
|
76
|
+
.join("\n");
|
|
77
|
+
|
|
78
|
+
const now = new Date();
|
|
79
|
+
const timeStr = `${now.getFullYear()}/${String(now.getMonth() + 1).padStart(2, "0")}/${String(now.getDate()).padStart(2, "0")} ${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`;
|
|
80
|
+
|
|
81
|
+
let prompt: string;
|
|
82
|
+
|
|
83
|
+
if (isIdleCheck) {
|
|
84
|
+
// 空闲检测模式:没有明确触发消息,观察群友聊天并决定是否要融入对话
|
|
85
|
+
prompt = `It is ${timeStr}. Your name is ${botName}.
|
|
86
|
+
|
|
87
|
+
Here is the recent chat content (from oldest to most recent):
|
|
88
|
+
${chatBlock || "(no chat history)"}
|
|
89
|
+
|
|
90
|
+
IMPORTANT: There is no specific trigger message this time. You are being proactively invited to observe the group chat and decide whether to speak up.
|
|
91
|
+
|
|
92
|
+
REPLY when ANY of these is true:
|
|
93
|
+
- The conversation mentions ${botName} (you), AI, or any topic you know about
|
|
94
|
+
- There's any question unanswered
|
|
95
|
+
- The conversation is about school, life, tech, or anything you can contribute to
|
|
96
|
+
- Even fragmented/random messages - you can start a new topic or comment on something!
|
|
97
|
+
|
|
98
|
+
WAIT only when:
|
|
99
|
+
- The conversation is clearly done and everyone left
|
|
100
|
+
- There's absolutely nothing you can think of to say
|
|
101
|
+
|
|
102
|
+
IMPORTANT: When in doubt, REPLY! Group chats are often casual and fragmented - that's okay! Your message doesn't need to be perfect, just be natural and friendly.
|
|
103
|
+
|
|
104
|
+
Available actions:
|
|
105
|
+
|
|
106
|
+
reply - Send a message to naturally join the conversation (1-2 sentences max, be concise!)
|
|
107
|
+
|
|
108
|
+
wait - Stay silent and continue observing
|
|
109
|
+
|
|
110
|
+
complete - The chat is dead, no one is talking
|
|
111
|
+
|
|
112
|
+
IMPORTANT: You MUST output ONLY valid JSON, no other text. The JSON must be in this exact format:
|
|
113
|
+
{"action": "reply", "reason": "your reason here", "wait_seconds": 0}
|
|
114
|
+
|
|
115
|
+
OR for wait:
|
|
116
|
+
{"action": "wait", "reason": "your reason here", "wait_seconds": 60}
|
|
117
|
+
|
|
118
|
+
OR for complete:
|
|
119
|
+
{"action": "complete", "reason": "your reason here", "wait_seconds": 0}
|
|
120
|
+
|
|
121
|
+
DO NOT include any explanation, markdown formatting, or additional text. Only output the JSON.`;
|
|
122
|
+
} else {
|
|
123
|
+
// 正常模式:由触发消息驱动的 planner
|
|
124
|
+
prompt = `It is ${timeStr}. Your name is ${botName}.
|
|
125
|
+
|
|
126
|
+
Here is the chat content:
|
|
127
|
+
${chatBlock}
|
|
128
|
+
|
|
129
|
+
Action history:
|
|
130
|
+
${actionsBlock || "(none)"}
|
|
131
|
+
|
|
132
|
+
Message that triggered you: ${lastTriggerMessage}
|
|
133
|
+
|
|
134
|
+
Available actions:
|
|
135
|
+
|
|
136
|
+
reply - Respond ONLY when truly necessary:
|
|
137
|
+
- Someone directly asked you a question
|
|
138
|
+
- Someone mentioned you specifically
|
|
139
|
+
- Something needs explanation or clarification
|
|
140
|
+
- There's an obvious opportunity to add real value
|
|
141
|
+
|
|
142
|
+
wait - DEFAULT choice. Stay silent when:
|
|
143
|
+
- You have nothing meaningful to add
|
|
144
|
+
- The conversation doesn't need you
|
|
145
|
+
- Someone else is already handling it
|
|
146
|
+
- You're just being polite but have nothing to say
|
|
147
|
+
|
|
148
|
+
complete - The chat is over, no activity for a while
|
|
149
|
+
|
|
150
|
+
IMPORTANT: Silence is golden. When in doubt, WAIT. Don't speak just because you can.
|
|
151
|
+
|
|
152
|
+
IMPORTANT: You MUST output ONLY valid JSON, no other text. The JSON must be in this exact format:
|
|
153
|
+
{"action": "reply", "reason": "your reason here", "wait_seconds": 0}
|
|
154
|
+
|
|
155
|
+
OR for wait:
|
|
156
|
+
{"action": "wait", "reason": "your reason here", "wait_seconds": 30}
|
|
157
|
+
|
|
158
|
+
OR for complete:
|
|
159
|
+
{"action": "complete", "reason": "your reason here", "wait_seconds": 0}
|
|
160
|
+
|
|
161
|
+
DO NOT include any explanation, markdown formatting, or additional text. Only output the JSON.`;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
try {
|
|
165
|
+
logger.info(
|
|
166
|
+
`[ActionPlanner] Planning action for session ${sessionId}, last message: "${lastTriggerMessage.substring(0, 50)}...", isIdleCheck: ${isIdleCheck}`,
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
const content = await this.ai.generateText({
|
|
170
|
+
prompt,
|
|
171
|
+
messages: [],
|
|
172
|
+
model: this.config.workingModel || this.config.model,
|
|
173
|
+
temperature: isIdleCheck ? 0.3 : 0.2,
|
|
174
|
+
max_tokens: isIdleCheck ? 300 : 500,
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// 如果返回内容为空,使用默认值
|
|
178
|
+
if (!content || !content.trim()) {
|
|
179
|
+
logger.warn(
|
|
180
|
+
`[ActionPlanner] Empty response from AI, using default reply`,
|
|
181
|
+
);
|
|
182
|
+
return { action: "reply", reason: "empty response" };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// 尝试提取 JSON 块
|
|
186
|
+
let jsonStr = "";
|
|
187
|
+
const jsonMatch = content.match(/\{[\s\S]*\}/);
|
|
188
|
+
if (jsonMatch) {
|
|
189
|
+
jsonStr = jsonMatch[0];
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (!jsonStr) {
|
|
193
|
+
logger.warn(
|
|
194
|
+
`[ActionPlanner] Failed to find JSON in response: ${content.substring(0, 100)}`,
|
|
195
|
+
);
|
|
196
|
+
return { action: "reply", reason: "parse failed" };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// 尝试解析 JSON
|
|
200
|
+
let parsed: any;
|
|
201
|
+
try {
|
|
202
|
+
parsed = JSON.parse(jsonStr);
|
|
203
|
+
} catch {
|
|
204
|
+
// 尝试修复常见的 JSON 错误
|
|
205
|
+
try {
|
|
206
|
+
// 移除可能存在的尾随逗号
|
|
207
|
+
jsonStr = jsonStr.replace(/,\s*}/g, "}").replace(/,\s*]/g, "]");
|
|
208
|
+
parsed = JSON.parse(jsonStr);
|
|
209
|
+
} catch (e) {
|
|
210
|
+
logger.warn(
|
|
211
|
+
`[ActionPlanner] Failed to parse JSON: ${jsonStr.substring(0, 100)}`,
|
|
212
|
+
);
|
|
213
|
+
return { action: "reply", reason: "parse failed" };
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
const action: PlannerAction =
|
|
217
|
+
parsed.action === "wait"
|
|
218
|
+
? "wait"
|
|
219
|
+
: parsed.action === "complete"
|
|
220
|
+
? "complete"
|
|
221
|
+
: "reply";
|
|
222
|
+
|
|
223
|
+
const result: PlannerResult = {
|
|
224
|
+
action,
|
|
225
|
+
reason: parsed.reason || "",
|
|
226
|
+
waitMs:
|
|
227
|
+
action === "wait"
|
|
228
|
+
? Math.min(
|
|
229
|
+
Math.max((parsed.wait_seconds || 30) * 1000, 10000),
|
|
230
|
+
300000,
|
|
231
|
+
)
|
|
232
|
+
: undefined,
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
const actions = this.actionHistory.get(sessionId) ?? [];
|
|
236
|
+
actions.push({ action, time: Date.now() });
|
|
237
|
+
if (actions.length > 20) actions.splice(0, actions.length - 20);
|
|
238
|
+
this.actionHistory.set(sessionId, actions);
|
|
239
|
+
|
|
240
|
+
logger.info(
|
|
241
|
+
`[ActionPlanner] Session ${sessionId}: action=${action}, reason="${result.reason}"${result.waitMs ? `, waitMs=${result.waitMs}` : ""}`,
|
|
242
|
+
);
|
|
243
|
+
return result;
|
|
244
|
+
} catch (err) {
|
|
245
|
+
logger.error(`[ActionPlanner] Error: ${err}`);
|
|
246
|
+
return { action: "reply", reason: "error fallback" };
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import type { AIInstance } from "mioku";
|
|
2
|
+
import { logger } from "mioki";
|
|
3
|
+
import type { ChatDatabase } from "../db";
|
|
4
|
+
import type { ChatConfig } from "../types";
|
|
5
|
+
|
|
6
|
+
export class TopicTracker {
|
|
7
|
+
private ai: AIInstance;
|
|
8
|
+
private config: ChatConfig;
|
|
9
|
+
private db: ChatDatabase;
|
|
10
|
+
private lastAnalyzedWindowEnd: Map<string, number> = new Map();
|
|
11
|
+
|
|
12
|
+
constructor(ai: AIInstance, config: ChatConfig, db: ChatDatabase) {
|
|
13
|
+
this.ai = ai;
|
|
14
|
+
this.config = config;
|
|
15
|
+
this.db = db;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async onMessage(sessionId: string): Promise<void> {
|
|
19
|
+
if (!this.config.topic?.enabled) return;
|
|
20
|
+
if (!sessionId.startsWith("group:")) return;
|
|
21
|
+
|
|
22
|
+
const windowMs = this.getWindowMs();
|
|
23
|
+
const completedWindowEnd = Math.floor(Date.now() / windowMs) * windowMs;
|
|
24
|
+
if (completedWindowEnd <= 0) return;
|
|
25
|
+
|
|
26
|
+
const lastAnalyzedEnd = this.lastAnalyzedWindowEnd.get(sessionId) ?? 0;
|
|
27
|
+
if (completedWindowEnd <= lastAnalyzedEnd) {
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
this.lastAnalyzedWindowEnd.set(sessionId, completedWindowEnd);
|
|
32
|
+
const windowStartAt = completedWindowEnd - windowMs;
|
|
33
|
+
|
|
34
|
+
this.analyzeWindow(sessionId, windowStartAt, completedWindowEnd).catch(
|
|
35
|
+
(err) => logger.warn(`[TopicTracker] Analysis failed: ${err}`),
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
getTopicContext(sessionId: string, historyStartAt?: number): string {
|
|
40
|
+
if (!this.config.topic?.enabled) return "";
|
|
41
|
+
if (!sessionId.startsWith("group:")) return "";
|
|
42
|
+
if (!historyStartAt) return "";
|
|
43
|
+
|
|
44
|
+
const windowMs = this.getWindowMs();
|
|
45
|
+
const windowCount = this.getHistoryWindowCount();
|
|
46
|
+
const firstWindowEnd = Math.floor(historyStartAt / windowMs) * windowMs;
|
|
47
|
+
if (firstWindowEnd <= 0) return "";
|
|
48
|
+
|
|
49
|
+
const lines: string[] = [];
|
|
50
|
+
for (let i = 0; i < windowCount; i++) {
|
|
51
|
+
const windowEndAt = firstWindowEnd - i * windowMs;
|
|
52
|
+
const windowStartAt = windowEndAt - windowMs;
|
|
53
|
+
if (windowStartAt <= 0) break;
|
|
54
|
+
|
|
55
|
+
const topic = this.db.getTopicByWindow(sessionId, windowStartAt, windowEndAt);
|
|
56
|
+
if (!topic?.summary) continue;
|
|
57
|
+
|
|
58
|
+
const keywords = this.parseKeywords(topic.keywords);
|
|
59
|
+
const timeRange = `${this.formatTime(windowStartAt)} ~ ${this.formatTime(windowEndAt)}`;
|
|
60
|
+
const keywordsLine =
|
|
61
|
+
keywords.length > 0 ? ` | 关键词: ${keywords.join(", ")}` : "";
|
|
62
|
+
lines.push(
|
|
63
|
+
`- ${timeRange}: ${topic.summary.trim()}${keywordsLine}`,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (lines.length === 0) return "";
|
|
68
|
+
|
|
69
|
+
return [
|
|
70
|
+
"## Background Topics Outside Visible History (Reference Only)",
|
|
71
|
+
"These summaries are rough references about older group discussions.",
|
|
72
|
+
"Do not proactively bring them up unless users explicitly ask.",
|
|
73
|
+
...lines,
|
|
74
|
+
].join("\n");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
private async analyzeWindow(
|
|
78
|
+
sessionId: string,
|
|
79
|
+
windowStartAt: number,
|
|
80
|
+
windowEndAt: number,
|
|
81
|
+
): Promise<void> {
|
|
82
|
+
if (windowEndAt <= windowStartAt) return;
|
|
83
|
+
|
|
84
|
+
const userMessages = this.db
|
|
85
|
+
.getMessagesByTimeRange(sessionId, windowStartAt, windowEndAt)
|
|
86
|
+
.filter((message) => message.role === "user" && message.content.trim());
|
|
87
|
+
|
|
88
|
+
if (userMessages.length < 5) return;
|
|
89
|
+
|
|
90
|
+
const existing = this.db.getTopicByWindow(sessionId, windowStartAt, windowEndAt);
|
|
91
|
+
const messagesBlock = userMessages
|
|
92
|
+
.map((message, index) => {
|
|
93
|
+
const time = this.formatTime(message.timestamp);
|
|
94
|
+
return `[${index + 1}][${time}] ${message.userName || "unknown"}: ${message.content}`;
|
|
95
|
+
})
|
|
96
|
+
.join("\n");
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
const content = await this.ai.generateText({
|
|
100
|
+
prompt: `You are a concise topic summarization assistant.
|
|
101
|
+
|
|
102
|
+
Only summarize the provided chat log within this exact time window:
|
|
103
|
+
${this.formatTime(windowStartAt)} ~ ${this.formatTime(windowEndAt)}
|
|
104
|
+
|
|
105
|
+
Rules:
|
|
106
|
+
1. Use ONLY messages from this log. Do not infer anything outside this 5-hour window.
|
|
107
|
+
2. The topic must be high-level, abstract, and concise.
|
|
108
|
+
3. The summary must be brief, not detailed.
|
|
109
|
+
4. Keep the output in the same language as the chat log.
|
|
110
|
+
|
|
111
|
+
Chat log:
|
|
112
|
+
${messagesBlock}
|
|
113
|
+
|
|
114
|
+
Output strictly in JSON format:
|
|
115
|
+
{
|
|
116
|
+
"title": "short topic title",
|
|
117
|
+
"keywords": ["keyword1", "keyword2"],
|
|
118
|
+
"summary": "one concise abstract summary sentence"
|
|
119
|
+
}`,
|
|
120
|
+
messages: [],
|
|
121
|
+
model: this.config.workingModel || this.config.model,
|
|
122
|
+
temperature: 0.2,
|
|
123
|
+
max_tokens: 300,
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
const jsonMatch = content.match(/\{[\s\S]*\}/);
|
|
127
|
+
if (!jsonMatch) return;
|
|
128
|
+
|
|
129
|
+
const parsed = JSON.parse(jsonMatch[0]);
|
|
130
|
+
const summary = String(parsed.summary || "").trim();
|
|
131
|
+
if (!summary) return;
|
|
132
|
+
|
|
133
|
+
const keywords = this.normalizeKeywords(parsed.keywords);
|
|
134
|
+
const title =
|
|
135
|
+
String(parsed.title || "").trim() ||
|
|
136
|
+
`${this.formatTime(windowStartAt)} 话题概览`;
|
|
137
|
+
|
|
138
|
+
if (existing?.id) {
|
|
139
|
+
this.db.updateTopic(existing.id, {
|
|
140
|
+
summary,
|
|
141
|
+
keywords: JSON.stringify(keywords),
|
|
142
|
+
messageCount: userMessages.length,
|
|
143
|
+
updatedAt: windowEndAt,
|
|
144
|
+
});
|
|
145
|
+
} else {
|
|
146
|
+
this.db.saveTopic({
|
|
147
|
+
sessionId,
|
|
148
|
+
title,
|
|
149
|
+
keywords: JSON.stringify(keywords),
|
|
150
|
+
summary,
|
|
151
|
+
messageCount: userMessages.length,
|
|
152
|
+
windowStartAt,
|
|
153
|
+
windowEndAt,
|
|
154
|
+
createdAt: windowEndAt,
|
|
155
|
+
updatedAt: windowEndAt,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
logger.info(
|
|
160
|
+
`[TopicTracker] Session ${sessionId}: summarized window ${this.formatTime(windowStartAt)} ~ ${this.formatTime(windowEndAt)} (${userMessages.length} user messages)`,
|
|
161
|
+
);
|
|
162
|
+
} catch (err) {
|
|
163
|
+
logger.warn(`[TopicTracker] Analysis failed: ${err}`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
private getWindowMs(): number {
|
|
168
|
+
const hours = Number(this.config.topic?.windowHours);
|
|
169
|
+
const safeHours = Number.isFinite(hours) && hours > 0 ? hours : 5;
|
|
170
|
+
return Math.max(1, Math.floor(safeHours)) * 3600_000;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
private getHistoryWindowCount(): number {
|
|
174
|
+
const count = Number(this.config.topic?.historyWindowCount);
|
|
175
|
+
if (!Number.isFinite(count) || count <= 0) return 3;
|
|
176
|
+
return Math.max(1, Math.floor(count));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
private normalizeKeywords(input: unknown): string[] {
|
|
180
|
+
if (!Array.isArray(input)) return [];
|
|
181
|
+
const cleaned = input
|
|
182
|
+
.map((item) => String(item || "").trim())
|
|
183
|
+
.filter(Boolean);
|
|
184
|
+
return [...new Set(cleaned)].slice(0, 8);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
private parseKeywords(raw: string): string[] {
|
|
188
|
+
try {
|
|
189
|
+
const parsed = JSON.parse(raw);
|
|
190
|
+
return this.normalizeKeywords(parsed);
|
|
191
|
+
} catch {
|
|
192
|
+
return [];
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
private formatTime(timestamp: number): string {
|
|
197
|
+
const date = new Date(timestamp);
|
|
198
|
+
const year = date.getFullYear();
|
|
199
|
+
const month = String(date.getMonth() + 1).padStart(2, "0");
|
|
200
|
+
const day = String(date.getDate()).padStart(2, "0");
|
|
201
|
+
const hour = String(date.getHours()).padStart(2, "0");
|
|
202
|
+
const minute = String(date.getMinutes()).padStart(2, "0");
|
|
203
|
+
return `${year}-${month}-${day} ${hour}:${minute}`;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { ChatConfig } from "../types";
|
|
2
|
+
|
|
3
|
+
export function pickReplyStyle(config: ChatConfig): string {
|
|
4
|
+
const { replyStyle } = config;
|
|
5
|
+
if (!replyStyle) return "";
|
|
6
|
+
|
|
7
|
+
const base = replyStyle.baseStyle || "";
|
|
8
|
+
const styles = replyStyle.multipleStyles || [];
|
|
9
|
+
const prob = replyStyle.multipleProbability ?? 0;
|
|
10
|
+
|
|
11
|
+
if (styles.length > 0 && prob > 0 && Math.random() < prob) {
|
|
12
|
+
return styles[Math.floor(Math.random() * styles.length)];
|
|
13
|
+
}
|
|
14
|
+
return base;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function pickPersonalityState(config: ChatConfig): string | null {
|
|
18
|
+
const { personality } = config;
|
|
19
|
+
if (!personality) return null;
|
|
20
|
+
|
|
21
|
+
const states = personality.states || [];
|
|
22
|
+
const prob = personality.stateProbability ?? 0;
|
|
23
|
+
|
|
24
|
+
if (states.length > 0 && prob > 0 && Math.random() < prob) {
|
|
25
|
+
return states[Math.floor(Math.random() * states.length)];
|
|
26
|
+
}
|
|
27
|
+
return null;
|
|
28
|
+
}
|