@zhin.js/core 1.0.36 → 1.0.38
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/CHANGELOG.md +18 -0
- package/README.md +57 -3
- package/lib/adapter.d.ts +11 -0
- package/lib/adapter.d.ts.map +1 -1
- package/lib/adapter.js +61 -0
- package/lib/adapter.js.map +1 -1
- package/lib/ai/index.d.ts +3 -39
- package/lib/ai/index.d.ts.map +1 -1
- package/lib/ai/index.js +2 -44
- package/lib/ai/index.js.map +1 -1
- package/lib/ai/types.d.ts +4 -3
- package/lib/ai/types.d.ts.map +1 -1
- package/lib/built/ai-trigger.js.map +1 -1
- package/lib/built/common-adapter-tools.d.ts +55 -0
- package/lib/built/common-adapter-tools.d.ts.map +1 -0
- package/lib/built/common-adapter-tools.js +158 -0
- package/lib/built/common-adapter-tools.js.map +1 -0
- package/lib/built/dispatcher.d.ts.map +1 -1
- package/lib/built/dispatcher.js +50 -46
- package/lib/built/dispatcher.js.map +1 -1
- package/lib/built/skill.d.ts.map +1 -1
- package/lib/built/skill.js +0 -1
- package/lib/built/skill.js.map +1 -1
- package/lib/built/tool.d.ts +3 -3
- package/lib/built/tool.d.ts.map +1 -1
- package/lib/built/tool.js.map +1 -1
- package/lib/feature.d.ts +16 -1
- package/lib/feature.d.ts.map +1 -1
- package/lib/feature.js +41 -2
- package/lib/feature.js.map +1 -1
- package/lib/index.d.ts +1 -0
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +2 -0
- package/lib/index.js.map +1 -1
- package/lib/plugin.d.ts +38 -1
- package/lib/plugin.d.ts.map +1 -1
- package/lib/plugin.js +73 -22
- package/lib/plugin.js.map +1 -1
- package/lib/scheduler/scheduler.js +1 -1
- package/lib/scheduler/scheduler.js.map +1 -1
- package/lib/types.d.ts +43 -28
- package/lib/types.d.ts.map +1 -1
- package/lib/utils.d.ts +12 -3
- package/lib/utils.d.ts.map +1 -1
- package/lib/utils.js +64 -54
- package/lib/utils.js.map +1 -1
- package/package.json +5 -5
- package/src/adapter.ts +85 -5
- package/src/ai/index.ts +8 -186
- package/src/ai/types.ts +5 -4
- package/src/built/ai-trigger.ts +2 -2
- package/src/built/common-adapter-tools.ts +207 -0
- package/src/built/dispatcher.ts +51 -52
- package/src/built/skill.ts +3 -4
- package/src/built/tool.ts +3 -3
- package/src/feature.ts +45 -2
- package/src/index.ts +2 -0
- package/src/plugin.ts +92 -31
- package/src/scheduler/scheduler.ts +1 -1
- package/src/types.ts +39 -28
- package/src/utils.ts +63 -52
- package/tests/ai/setup.ts +2 -2
- package/tests/utils.test.ts +1 -3
- package/lib/ai/agent.d.ts +0 -130
- package/lib/ai/agent.d.ts.map +0 -1
- package/lib/ai/agent.js +0 -684
- package/lib/ai/agent.js.map +0 -1
- package/lib/ai/bootstrap.d.ts +0 -91
- package/lib/ai/bootstrap.d.ts.map +0 -1
- package/lib/ai/bootstrap.js +0 -243
- package/lib/ai/bootstrap.js.map +0 -1
- package/lib/ai/builtin-tools.d.ts +0 -59
- package/lib/ai/builtin-tools.d.ts.map +0 -1
- package/lib/ai/builtin-tools.js +0 -777
- package/lib/ai/builtin-tools.js.map +0 -1
- package/lib/ai/compaction.d.ts +0 -132
- package/lib/ai/compaction.d.ts.map +0 -1
- package/lib/ai/compaction.js +0 -370
- package/lib/ai/compaction.js.map +0 -1
- package/lib/ai/context-manager.d.ts +0 -213
- package/lib/ai/context-manager.d.ts.map +0 -1
- package/lib/ai/context-manager.js +0 -313
- package/lib/ai/context-manager.js.map +0 -1
- package/lib/ai/conversation-memory.d.ts +0 -181
- package/lib/ai/conversation-memory.d.ts.map +0 -1
- package/lib/ai/conversation-memory.js +0 -581
- package/lib/ai/conversation-memory.js.map +0 -1
- package/lib/ai/cron-engine.d.ts +0 -92
- package/lib/ai/cron-engine.d.ts.map +0 -1
- package/lib/ai/cron-engine.js +0 -278
- package/lib/ai/cron-engine.js.map +0 -1
- package/lib/ai/follow-up.d.ts +0 -131
- package/lib/ai/follow-up.d.ts.map +0 -1
- package/lib/ai/follow-up.js +0 -265
- package/lib/ai/follow-up.js.map +0 -1
- package/lib/ai/hooks.d.ts +0 -143
- package/lib/ai/hooks.d.ts.map +0 -1
- package/lib/ai/hooks.js +0 -108
- package/lib/ai/hooks.js.map +0 -1
- package/lib/ai/init.d.ts +0 -30
- package/lib/ai/init.d.ts.map +0 -1
- package/lib/ai/init.js +0 -686
- package/lib/ai/init.js.map +0 -1
- package/lib/ai/output.d.ts +0 -93
- package/lib/ai/output.d.ts.map +0 -1
- package/lib/ai/output.js +0 -176
- package/lib/ai/output.js.map +0 -1
- package/lib/ai/rate-limiter.d.ts +0 -38
- package/lib/ai/rate-limiter.d.ts.map +0 -1
- package/lib/ai/rate-limiter.js +0 -86
- package/lib/ai/rate-limiter.js.map +0 -1
- package/lib/ai/service.d.ts +0 -88
- package/lib/ai/service.d.ts.map +0 -1
- package/lib/ai/service.js +0 -285
- package/lib/ai/service.js.map +0 -1
- package/lib/ai/session.d.ts +0 -186
- package/lib/ai/session.d.ts.map +0 -1
- package/lib/ai/session.js +0 -443
- package/lib/ai/session.js.map +0 -1
- package/lib/ai/subagent.d.ts +0 -50
- package/lib/ai/subagent.d.ts.map +0 -1
- package/lib/ai/subagent.js +0 -144
- package/lib/ai/subagent.js.map +0 -1
- package/lib/ai/tone-detector.d.ts +0 -19
- package/lib/ai/tone-detector.d.ts.map +0 -1
- package/lib/ai/tone-detector.js +0 -72
- package/lib/ai/tone-detector.js.map +0 -1
- package/lib/ai/tools.d.ts +0 -45
- package/lib/ai/tools.d.ts.map +0 -1
- package/lib/ai/tools.js +0 -206
- package/lib/ai/tools.js.map +0 -1
- package/lib/ai/user-profile.d.ts +0 -56
- package/lib/ai/user-profile.d.ts.map +0 -1
- package/lib/ai/user-profile.js +0 -130
- package/lib/ai/user-profile.js.map +0 -1
- package/lib/ai/zhin-agent/builtin-tools.d.ts +0 -17
- package/lib/ai/zhin-agent/builtin-tools.d.ts.map +0 -1
- package/lib/ai/zhin-agent/builtin-tools.js +0 -220
- package/lib/ai/zhin-agent/builtin-tools.js.map +0 -1
- package/lib/ai/zhin-agent/config.d.ts +0 -54
- package/lib/ai/zhin-agent/config.d.ts.map +0 -1
- package/lib/ai/zhin-agent/config.js +0 -76
- package/lib/ai/zhin-agent/config.js.map +0 -1
- package/lib/ai/zhin-agent/exec-policy.d.ts +0 -20
- package/lib/ai/zhin-agent/exec-policy.d.ts.map +0 -1
- package/lib/ai/zhin-agent/exec-policy.js +0 -71
- package/lib/ai/zhin-agent/exec-policy.js.map +0 -1
- package/lib/ai/zhin-agent/index.d.ts +0 -70
- package/lib/ai/zhin-agent/index.d.ts.map +0 -1
- package/lib/ai/zhin-agent/index.js +0 -404
- package/lib/ai/zhin-agent/index.js.map +0 -1
- package/lib/ai/zhin-agent/prompt.d.ts +0 -21
- package/lib/ai/zhin-agent/prompt.d.ts.map +0 -1
- package/lib/ai/zhin-agent/prompt.js +0 -111
- package/lib/ai/zhin-agent/prompt.js.map +0 -1
- package/lib/ai/zhin-agent/tool-collector.d.ts +0 -22
- package/lib/ai/zhin-agent/tool-collector.d.ts.map +0 -1
- package/lib/ai/zhin-agent/tool-collector.js +0 -218
- package/lib/ai/zhin-agent/tool-collector.js.map +0 -1
- package/src/ai/agent.ts +0 -812
- package/src/ai/bootstrap.ts +0 -309
- package/src/ai/builtin-tools.ts +0 -849
- package/src/ai/compaction.ts +0 -529
- package/src/ai/context-manager.ts +0 -440
- package/src/ai/conversation-memory.ts +0 -774
- package/src/ai/cron-engine.ts +0 -337
- package/src/ai/follow-up.ts +0 -357
- package/src/ai/hooks.ts +0 -223
- package/src/ai/init.ts +0 -762
- package/src/ai/output.ts +0 -261
- package/src/ai/rate-limiter.ts +0 -129
- package/src/ai/service.ts +0 -331
- package/src/ai/session.ts +0 -544
- package/src/ai/subagent.ts +0 -209
- package/src/ai/tone-detector.ts +0 -89
- package/src/ai/tools.ts +0 -218
- package/src/ai/user-profile.ts +0 -181
- package/src/ai/zhin-agent/builtin-tools.ts +0 -247
- package/src/ai/zhin-agent/config.ts +0 -113
- package/src/ai/zhin-agent/exec-policy.ts +0 -78
- package/src/ai/zhin-agent/index.ts +0 -512
- package/src/ai/zhin-agent/prompt.ts +0 -131
- package/src/ai/zhin-agent/tool-collector.ts +0 -243
- package/tests/ai/agent.test.ts +0 -614
- package/tests/ai/context-manager.test.ts +0 -413
- package/tests/ai/conversation-memory.test.ts +0 -128
- package/tests/ai/follow-up.test.ts +0 -175
- package/tests/ai/integration.test.ts +0 -584
- package/tests/ai/output.test.ts +0 -128
- package/tests/ai/rate-limiter.test.ts +0 -108
- package/tests/ai/session.test.ts +0 -375
- package/tests/ai/subagent.test.ts +0 -270
- package/tests/ai/tone-detector.test.ts +0 -80
- package/tests/ai/tools-builtin.test.ts +0 -346
- package/tests/ai/user-profile.test.ts +0 -73
- package/tests/ai/zhin-agent.test.ts +0 -177
|
@@ -1,774 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ConversationMemory — 基于数据库的会话记忆(话题感知 + 链式摘要)
|
|
3
|
-
*
|
|
4
|
-
* 两张表分离存储:
|
|
5
|
-
*
|
|
6
|
-
* ai_messages 表(逐条记录):
|
|
7
|
-
* ┌──────────────────────────────────────────────────────────┐
|
|
8
|
-
* │ session_id | role | content | round | time │
|
|
9
|
-
* │ s1 | user | 你好 | 1 | ... │
|
|
10
|
-
* │ s1 | assistant | 你好呀! | 1 | ... │
|
|
11
|
-
* │ ... | ... | ... | ... | ... │
|
|
12
|
-
* └──────────────────────────────────────────────────────────┘
|
|
13
|
-
*
|
|
14
|
-
* ai_summaries 表(链式摘要):
|
|
15
|
-
* ┌────────────────────────────────────────────────────────────────────┐
|
|
16
|
-
* │ id | session_id | parent_id | from_round | to_round | summary │
|
|
17
|
-
* │ 1 | s1 | null | 1 | 7 | 用户讨论了… │
|
|
18
|
-
* │ 2 | s1 | 1 | 8 | 15 | 之前...又… │
|
|
19
|
-
* └────────────────────────────────────────────────────────────────────┘
|
|
20
|
-
*
|
|
21
|
-
* 摘要触发规则:
|
|
22
|
-
* 1. 检测到话题切换(当前消息与最近消息的关键词重合度低)
|
|
23
|
-
* 2. 且上一个话题持续 ≥ minTopicRounds 轮
|
|
24
|
-
* → 异步生成摘要,覆盖上一话题的全部消息
|
|
25
|
-
*
|
|
26
|
-
* 上下文构建规则:
|
|
27
|
-
* 1. 取最新 summary + 最近 slidingWindowSize 轮消息(滑动窗口)
|
|
28
|
-
* 2. 检查连续性:summary.to_round === window第一条round - 1
|
|
29
|
-
* → 连续:[summary] + [window]
|
|
30
|
-
* → 不连续:丢弃 summary,仅用 [window]
|
|
31
|
-
*/
|
|
32
|
-
|
|
33
|
-
import { Logger } from '@zhin.js/logger';
|
|
34
|
-
import type { AIProvider, ChatMessage } from './types.js';
|
|
35
|
-
|
|
36
|
-
const logger = new Logger(null, 'ConvMemory');
|
|
37
|
-
|
|
38
|
-
// ============================================================================
|
|
39
|
-
// 数据库模型定义
|
|
40
|
-
// ============================================================================
|
|
41
|
-
|
|
42
|
-
/** ai_messages 表结构 */
|
|
43
|
-
export const AI_MESSAGE_MODEL = {
|
|
44
|
-
session_id: { type: 'text' as const, nullable: false },
|
|
45
|
-
role: { type: 'text' as const, nullable: false },
|
|
46
|
-
content: { type: 'text' as const, nullable: false },
|
|
47
|
-
round: { type: 'integer' as const, nullable: false },
|
|
48
|
-
created_at: { type: 'integer' as const, default: 0 },
|
|
49
|
-
};
|
|
50
|
-
|
|
51
|
-
/** ai_summaries 表结构(链式) */
|
|
52
|
-
export const AI_SUMMARY_MODEL = {
|
|
53
|
-
session_id: { type: 'text' as const, nullable: false },
|
|
54
|
-
parent_id: { type: 'integer' as const, nullable: true },
|
|
55
|
-
from_round: { type: 'integer' as const, nullable: false },
|
|
56
|
-
to_round: { type: 'integer' as const, nullable: false },
|
|
57
|
-
summary: { type: 'text' as const, nullable: false },
|
|
58
|
-
created_at: { type: 'integer' as const, default: 0 },
|
|
59
|
-
};
|
|
60
|
-
|
|
61
|
-
// ============================================================================
|
|
62
|
-
// 类型
|
|
63
|
-
// ============================================================================
|
|
64
|
-
|
|
65
|
-
interface MessageRecord {
|
|
66
|
-
id?: number;
|
|
67
|
-
session_id: string;
|
|
68
|
-
role: string;
|
|
69
|
-
content: string;
|
|
70
|
-
round: number;
|
|
71
|
-
created_at: number;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
interface SummaryRecord {
|
|
75
|
-
id?: number;
|
|
76
|
-
session_id: string;
|
|
77
|
-
parent_id: number | null;
|
|
78
|
-
from_round: number;
|
|
79
|
-
to_round: number;
|
|
80
|
-
summary: string;
|
|
81
|
-
created_at: number;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/**
|
|
85
|
-
* 数据库模型接口(与 RelatedModel 的链式查询 API 对齐)
|
|
86
|
-
*
|
|
87
|
-
* select(...fields) → Selection (thenable, 支持 .where().orderBy().limit())
|
|
88
|
-
* create(data) → Promise<any>
|
|
89
|
-
*/
|
|
90
|
-
interface DbModel {
|
|
91
|
-
select(...fields: string[]): any; // 返回 Selection (thenable query builder)
|
|
92
|
-
create(data: Record<string, any>): Promise<any>;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
export interface ConversationMemoryConfig {
|
|
96
|
-
/** 一个话题至少持续多少轮才触发摘要(默认 5) */
|
|
97
|
-
minTopicRounds?: number;
|
|
98
|
-
/** 滑动窗口大小:最近 N 轮消息(默认 5) */
|
|
99
|
-
slidingWindowSize?: number;
|
|
100
|
-
/** 话题切换检测阈值(0-1,值越低越敏感,默认 0.15) */
|
|
101
|
-
topicChangeThreshold?: number;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
const DEFAULT_CONFIG: Required<ConversationMemoryConfig> = {
|
|
105
|
-
minTopicRounds: 5,
|
|
106
|
-
slidingWindowSize: 5,
|
|
107
|
-
topicChangeThreshold: 0.15,
|
|
108
|
-
};
|
|
109
|
-
|
|
110
|
-
// ============================================================================
|
|
111
|
-
// Store 接口
|
|
112
|
-
// ============================================================================
|
|
113
|
-
|
|
114
|
-
interface IStore {
|
|
115
|
-
getMessages(sessionId: string): Promise<MessageRecord[]>;
|
|
116
|
-
addMessage(record: MessageRecord): Promise<void>;
|
|
117
|
-
getMaxRound(sessionId: string): Promise<number>;
|
|
118
|
-
getMessagesAfterRound(sessionId: string, afterRound: number): Promise<MessageRecord[]>;
|
|
119
|
-
getMessagesByRoundRange(sessionId: string, fromRound: number, toRound: number): Promise<MessageRecord[]>;
|
|
120
|
-
searchMessages(sessionId: string, keyword: string): Promise<MessageRecord[]>;
|
|
121
|
-
getLatestSummary(sessionId: string): Promise<SummaryRecord | null>;
|
|
122
|
-
getSummaryById(sessionId: string, summaryId: number): Promise<SummaryRecord | null>;
|
|
123
|
-
addSummary(record: Omit<SummaryRecord, 'id'>): Promise<SummaryRecord>;
|
|
124
|
-
dispose(): void;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
// ============================================================================
|
|
128
|
-
// 内存实现
|
|
129
|
-
// ============================================================================
|
|
130
|
-
|
|
131
|
-
class MemoryStore implements IStore {
|
|
132
|
-
private messages: Map<string, MessageRecord[]> = new Map();
|
|
133
|
-
private summaries: Map<string, SummaryRecord[]> = new Map();
|
|
134
|
-
private nextSummaryId = 1;
|
|
135
|
-
|
|
136
|
-
async getMessages(sessionId: string): Promise<MessageRecord[]> {
|
|
137
|
-
return this.messages.get(sessionId) || [];
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
async addMessage(record: MessageRecord): Promise<void> {
|
|
141
|
-
const list = this.messages.get(record.session_id) || [];
|
|
142
|
-
list.push(record);
|
|
143
|
-
this.messages.set(record.session_id, list);
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
async getMaxRound(sessionId: string): Promise<number> {
|
|
147
|
-
const msgs = this.messages.get(sessionId) || [];
|
|
148
|
-
if (msgs.length === 0) return 0;
|
|
149
|
-
return Math.max(...msgs.map(m => m.round));
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
async getMessagesAfterRound(sessionId: string, afterRound: number): Promise<MessageRecord[]> {
|
|
153
|
-
const msgs = this.messages.get(sessionId) || [];
|
|
154
|
-
return msgs.filter(m => m.round > afterRound);
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
async getMessagesByRoundRange(sessionId: string, fromRound: number, toRound: number): Promise<MessageRecord[]> {
|
|
158
|
-
const msgs = this.messages.get(sessionId) || [];
|
|
159
|
-
return msgs.filter(m => m.round >= fromRound && m.round <= toRound);
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
async searchMessages(sessionId: string, keyword: string): Promise<MessageRecord[]> {
|
|
163
|
-
const msgs = this.messages.get(sessionId) || [];
|
|
164
|
-
const kw = keyword.toLowerCase();
|
|
165
|
-
return msgs.filter(m => m.content.toLowerCase().includes(kw));
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
async getLatestSummary(sessionId: string): Promise<SummaryRecord | null> {
|
|
169
|
-
const list = this.summaries.get(sessionId) || [];
|
|
170
|
-
if (list.length === 0) return null;
|
|
171
|
-
return list.reduce((a, b) => a.to_round > b.to_round ? a : b);
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
async getSummaryById(sessionId: string, summaryId: number): Promise<SummaryRecord | null> {
|
|
175
|
-
const list = this.summaries.get(sessionId) || [];
|
|
176
|
-
return list.find(s => s.id === summaryId) ?? null;
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
async addSummary(record: Omit<SummaryRecord, 'id'>): Promise<SummaryRecord> {
|
|
180
|
-
const full: SummaryRecord = { ...record, id: this.nextSummaryId++ };
|
|
181
|
-
const list = this.summaries.get(record.session_id) || [];
|
|
182
|
-
list.push(full);
|
|
183
|
-
this.summaries.set(record.session_id, list);
|
|
184
|
-
return full;
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
dispose(): void {
|
|
188
|
-
this.messages.clear();
|
|
189
|
-
this.summaries.clear();
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
// ============================================================================
|
|
194
|
-
// 数据库实现
|
|
195
|
-
// ============================================================================
|
|
196
|
-
|
|
197
|
-
class DatabaseStore implements IStore {
|
|
198
|
-
constructor(private msgModel: DbModel, private sumModel: DbModel) {}
|
|
199
|
-
|
|
200
|
-
async getMessages(sessionId: string): Promise<MessageRecord[]> {
|
|
201
|
-
return (await this.msgModel.select().where({ session_id: sessionId })) as MessageRecord[];
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
async addMessage(record: MessageRecord): Promise<void> {
|
|
205
|
-
await this.msgModel.create(record);
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
async getMaxRound(sessionId: string): Promise<number> {
|
|
209
|
-
const msgs = await this.getMessages(sessionId);
|
|
210
|
-
if (msgs.length === 0) return 0;
|
|
211
|
-
return Math.max(...msgs.map(m => m.round));
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
async getMessagesAfterRound(sessionId: string, afterRound: number): Promise<MessageRecord[]> {
|
|
215
|
-
const msgs = await this.getMessages(sessionId);
|
|
216
|
-
return msgs.filter(m => m.round > afterRound);
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
async getMessagesByRoundRange(sessionId: string, fromRound: number, toRound: number): Promise<MessageRecord[]> {
|
|
220
|
-
const msgs = await this.getMessages(sessionId);
|
|
221
|
-
return msgs.filter(m => m.round >= fromRound && m.round <= toRound);
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
async searchMessages(sessionId: string, keyword: string): Promise<MessageRecord[]> {
|
|
225
|
-
const msgs = await this.getMessages(sessionId);
|
|
226
|
-
const kw = keyword.toLowerCase();
|
|
227
|
-
return msgs.filter(m => m.content.toLowerCase().includes(kw));
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
async getLatestSummary(sessionId: string): Promise<SummaryRecord | null> {
|
|
231
|
-
const records = (await this.sumModel.select().where({ session_id: sessionId })) as SummaryRecord[];
|
|
232
|
-
if (records.length === 0) return null;
|
|
233
|
-
return records.reduce((a, b) => a.to_round > b.to_round ? a : b);
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
async getSummaryById(sessionId: string, summaryId: number): Promise<SummaryRecord | null> {
|
|
237
|
-
const records = (await this.sumModel.select().where({ session_id: sessionId })) as SummaryRecord[];
|
|
238
|
-
return records.find(s => s.id === summaryId) ?? null;
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
async addSummary(record: Omit<SummaryRecord, 'id'>): Promise<SummaryRecord> {
|
|
242
|
-
const created = await this.sumModel.create(record);
|
|
243
|
-
return { ...record, id: created?.id ?? created?.lastID ?? 0 } as SummaryRecord;
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
dispose(): void {}
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
// ============================================================================
|
|
250
|
-
// 话题检测工具
|
|
251
|
-
// ============================================================================
|
|
252
|
-
|
|
253
|
-
/**
|
|
254
|
-
* 提取文本中的关键词集合(中文按字/词,英文按空格分词)
|
|
255
|
-
*
|
|
256
|
-
* 简单但高效:不依赖分词库,用字符 bigram + 英文单词作为特征
|
|
257
|
-
*/
|
|
258
|
-
function extractTokens(text: string): Set<string> {
|
|
259
|
-
const tokens = new Set<string>();
|
|
260
|
-
const cleaned = text.toLowerCase().replace(/[^\u4e00-\u9fff\w]/g, ' ');
|
|
261
|
-
|
|
262
|
-
// 中文字符 bigram
|
|
263
|
-
const chars = cleaned.replace(/[^\u4e00-\u9fff]/g, '');
|
|
264
|
-
for (let i = 0; i < chars.length - 1; i++) {
|
|
265
|
-
tokens.add(chars.slice(i, i + 2));
|
|
266
|
-
}
|
|
267
|
-
// 单个中文字也加入(短消息可能只有单字关键词)
|
|
268
|
-
for (const ch of chars) {
|
|
269
|
-
tokens.add(ch);
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
// 英文单词(≥2字符)
|
|
273
|
-
const words = cleaned.match(/[a-z]{2,}/g);
|
|
274
|
-
if (words) {
|
|
275
|
-
for (const w of words) tokens.add(w);
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
return tokens;
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
/**
|
|
282
|
-
* 计算两个 token 集合的 Jaccard 相似度
|
|
283
|
-
*/
|
|
284
|
-
function jaccardSimilarity(a: Set<string>, b: Set<string>): number {
|
|
285
|
-
if (a.size === 0 && b.size === 0) return 1;
|
|
286
|
-
if (a.size === 0 || b.size === 0) return 0;
|
|
287
|
-
|
|
288
|
-
let intersection = 0;
|
|
289
|
-
for (const token of a) {
|
|
290
|
-
if (b.has(token)) intersection++;
|
|
291
|
-
}
|
|
292
|
-
const union = a.size + b.size - intersection;
|
|
293
|
-
return union === 0 ? 0 : intersection / union;
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
// ============================================================================
|
|
297
|
-
// ConversationMemory
|
|
298
|
-
// ============================================================================
|
|
299
|
-
|
|
300
|
-
/** 话题跟踪状态(per-session,仅内存) */
|
|
301
|
-
interface TopicState {
|
|
302
|
-
/** 当前话题的起始轮次 */
|
|
303
|
-
topicStartRound: number;
|
|
304
|
-
/** 最近几轮用户消息的合并 token 集合(用于 Jaccard 快检) */
|
|
305
|
-
recentTokens: Set<string>;
|
|
306
|
-
/** 最近几轮用户消息原文(用于 LLM 话题判断,保留最近 3 条) */
|
|
307
|
-
recentUserMessages: string[];
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
export class ConversationMemory {
|
|
311
|
-
private store: IStore;
|
|
312
|
-
private provider: AIProvider | null = null;
|
|
313
|
-
private config: Required<ConversationMemoryConfig>;
|
|
314
|
-
private summarizing: Set<string> = new Set();
|
|
315
|
-
/** per-session 话题跟踪 */
|
|
316
|
-
private topicStates: Map<string, TopicState> = new Map();
|
|
317
|
-
/** per-session 轮次缓存(避免每次查数据库) */
|
|
318
|
-
private roundCache: Map<string, number> = new Map();
|
|
319
|
-
|
|
320
|
-
constructor(config?: ConversationMemoryConfig) {
|
|
321
|
-
this.store = new MemoryStore();
|
|
322
|
-
this.config = { ...DEFAULT_CONFIG, ...config };
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
// ── 依赖注入 ──
|
|
326
|
-
|
|
327
|
-
setProvider(provider: AIProvider): void {
|
|
328
|
-
this.provider = provider;
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
upgradeToDatabase(msgModel: DbModel, sumModel: DbModel): void {
|
|
332
|
-
const old = this.store;
|
|
333
|
-
this.store = new DatabaseStore(msgModel, sumModel);
|
|
334
|
-
old.dispose();
|
|
335
|
-
logger.debug('ConversationMemory: upgraded to database storage');
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
// ── 写入 ──
|
|
339
|
-
|
|
340
|
-
/**
|
|
341
|
-
* 保存一轮对话,并检测话题切换来触发摘要
|
|
342
|
-
*/
|
|
343
|
-
async saveRound(
|
|
344
|
-
sessionId: string,
|
|
345
|
-
userContent: string,
|
|
346
|
-
assistantContent: string,
|
|
347
|
-
): Promise<void> {
|
|
348
|
-
// 优先用缓存,首次才查数据库
|
|
349
|
-
const cached = this.roundCache.get(sessionId);
|
|
350
|
-
let currentRound = cached != null ? cached + 1 : (await this.store.getMaxRound(sessionId)) + 1;
|
|
351
|
-
// 防御性检查:确保 round 始终是有效正整数
|
|
352
|
-
if (!Number.isFinite(currentRound) || currentRound < 1) {
|
|
353
|
-
logger.warn(`[saveRound] round 异常 (${currentRound}), 重置为 1`);
|
|
354
|
-
currentRound = 1;
|
|
355
|
-
}
|
|
356
|
-
this.roundCache.set(sessionId, currentRound);
|
|
357
|
-
const ts = Date.now();
|
|
358
|
-
|
|
359
|
-
await this.store.addMessage({
|
|
360
|
-
session_id: sessionId,
|
|
361
|
-
role: 'user',
|
|
362
|
-
content: userContent,
|
|
363
|
-
round: currentRound,
|
|
364
|
-
created_at: ts,
|
|
365
|
-
});
|
|
366
|
-
await this.store.addMessage({
|
|
367
|
-
session_id: sessionId,
|
|
368
|
-
role: 'assistant',
|
|
369
|
-
content: assistantContent,
|
|
370
|
-
round: currentRound,
|
|
371
|
-
created_at: ts,
|
|
372
|
-
});
|
|
373
|
-
|
|
374
|
-
// 话题切换检测 + 异步摘要
|
|
375
|
-
this.handleTopicAndSummary(sessionId, userContent, currentRound);
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
// ── 话题检测 + 摘要触发 ──
|
|
379
|
-
|
|
380
|
-
/**
|
|
381
|
-
* 话题检测流程(全程异步,不阻塞对话):
|
|
382
|
-
*
|
|
383
|
-
* 1. 短消息(token ≤ 3)→ 跳过检测,视为延续话题
|
|
384
|
-
* 2. Jaccard 快检 → 高相似(≥ 0.5) → 肯定同话题,跳过 LLM
|
|
385
|
-
* 3. Jaccard 不确定(< 0.5) → 调 LLM 裁决
|
|
386
|
-
* 4. LLM 判定切换 + 旧话题 ≥ minTopicRounds → 触发摘要
|
|
387
|
-
*/
|
|
388
|
-
private handleTopicAndSummary(
|
|
389
|
-
sessionId: string,
|
|
390
|
-
userContent: string,
|
|
391
|
-
currentRound: number,
|
|
392
|
-
): void {
|
|
393
|
-
let state = this.topicStates.get(sessionId);
|
|
394
|
-
|
|
395
|
-
// 首次对话 → 初始化
|
|
396
|
-
if (!state) {
|
|
397
|
-
state = {
|
|
398
|
-
topicStartRound: currentRound,
|
|
399
|
-
recentTokens: extractTokens(userContent),
|
|
400
|
-
recentUserMessages: [userContent],
|
|
401
|
-
};
|
|
402
|
-
this.topicStates.set(sessionId, state);
|
|
403
|
-
return;
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
const currentTokens = extractTokens(userContent);
|
|
407
|
-
|
|
408
|
-
// 短消息跳过检测
|
|
409
|
-
if (currentTokens.size <= 3) {
|
|
410
|
-
for (const t of currentTokens) state.recentTokens.add(t);
|
|
411
|
-
state.recentUserMessages.push(userContent);
|
|
412
|
-
if (state.recentUserMessages.length > 3) state.recentUserMessages.shift();
|
|
413
|
-
return;
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
// Jaccard 快检
|
|
417
|
-
const similarity = jaccardSimilarity(currentTokens, state.recentTokens);
|
|
418
|
-
|
|
419
|
-
if (similarity >= 0.5) {
|
|
420
|
-
// 高相似 → 肯定同话题,无需 LLM
|
|
421
|
-
this.updateTopicState(state, currentTokens, userContent);
|
|
422
|
-
return;
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
// 不确定 → 异步调 LLM 裁决
|
|
426
|
-
logger.debug(`[话题检测] Jaccard=${similarity.toFixed(2)} < 0.5, 交由 LLM 判断`);
|
|
427
|
-
|
|
428
|
-
const recentMsgs = [...state.recentUserMessages];
|
|
429
|
-
const topicStart = state.topicStartRound;
|
|
430
|
-
|
|
431
|
-
// 先乐观更新(假设同话题),LLM 判定后可能回滚
|
|
432
|
-
this.updateTopicState(state, currentTokens, userContent);
|
|
433
|
-
|
|
434
|
-
// 异步 LLM 判断
|
|
435
|
-
this.detectTopicChangeByLLM(recentMsgs, userContent).then((isChange) => {
|
|
436
|
-
if (!isChange) {
|
|
437
|
-
logger.debug(`[话题检测] LLM 判定: 同话题`);
|
|
438
|
-
return;
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
// LLM 确认话题切换
|
|
442
|
-
const topicDuration = currentRound - topicStart;
|
|
443
|
-
logger.debug(
|
|
444
|
-
`[话题检测] LLM 判定: 切换! ` +
|
|
445
|
-
`旧话题: 第${topicStart}-${currentRound - 1}轮 (${topicDuration}轮)`,
|
|
446
|
-
);
|
|
447
|
-
|
|
448
|
-
// 回滚话题状态 → 重置为新话题
|
|
449
|
-
const currentState = this.topicStates.get(sessionId);
|
|
450
|
-
if (currentState) {
|
|
451
|
-
currentState.topicStartRound = currentRound;
|
|
452
|
-
currentState.recentTokens = currentTokens;
|
|
453
|
-
currentState.recentUserMessages = [userContent];
|
|
454
|
-
}
|
|
455
|
-
|
|
456
|
-
// 旧话题够长 → 生成摘要
|
|
457
|
-
if (topicDuration >= this.config.minTopicRounds) {
|
|
458
|
-
this.generateSummaryAsync(sessionId, topicStart, currentRound - 1);
|
|
459
|
-
}
|
|
460
|
-
}).catch((err) => {
|
|
461
|
-
logger.warn('[话题检测] LLM 调用失败,保持当前话题', err);
|
|
462
|
-
});
|
|
463
|
-
}
|
|
464
|
-
|
|
465
|
-
/** 更新话题状态(同话题情况) */
|
|
466
|
-
private updateTopicState(state: TopicState, tokens: Set<string>, message: string): void {
|
|
467
|
-
for (const t of tokens) state.recentTokens.add(t);
|
|
468
|
-
if (state.recentTokens.size > 500) state.recentTokens = tokens;
|
|
469
|
-
state.recentUserMessages.push(message);
|
|
470
|
-
if (state.recentUserMessages.length > 3) state.recentUserMessages.shift();
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
/**
|
|
474
|
-
* 调用 LLM 判断话题是否切换
|
|
475
|
-
*
|
|
476
|
-
* 输入: 最近几条用户消息 + 当前用户消息
|
|
477
|
-
* 输出: true = 话题切换, false = 同话题
|
|
478
|
-
*/
|
|
479
|
-
private async detectTopicChangeByLLM(
|
|
480
|
-
recentMessages: string[],
|
|
481
|
-
currentMessage: string,
|
|
482
|
-
): Promise<boolean> {
|
|
483
|
-
if (!this.provider) return false;
|
|
484
|
-
|
|
485
|
-
const recentText = recentMessages.map((m, i) => `${i + 1}. ${m}`).join('\n');
|
|
486
|
-
const model = this.provider.models[0];
|
|
487
|
-
|
|
488
|
-
const response = await this.provider.chat({
|
|
489
|
-
model,
|
|
490
|
-
messages: [
|
|
491
|
-
{
|
|
492
|
-
role: 'system',
|
|
493
|
-
content: '你是一个话题分析助手。判断用户最新发送的消息相比之前的消息,是否切换到了一个全新的话题。只回答一个字:是 或 否。',
|
|
494
|
-
},
|
|
495
|
-
{
|
|
496
|
-
role: 'user',
|
|
497
|
-
content: `之前的消息:\n${recentText}\n\n最新消息:\n${currentMessage}`,
|
|
498
|
-
},
|
|
499
|
-
],
|
|
500
|
-
temperature: 0.1,
|
|
501
|
-
});
|
|
502
|
-
|
|
503
|
-
const raw = response.choices[0]?.message?.content;
|
|
504
|
-
const content = (typeof raw === 'string' ? raw : '').trim();
|
|
505
|
-
// 解析回答:包含"是"→ 切换,包含"否"或其他 → 未切换
|
|
506
|
-
return content.includes('是') && !content.includes('否');
|
|
507
|
-
}
|
|
508
|
-
|
|
509
|
-
/**
|
|
510
|
-
* 异步生成链式摘要(不阻塞对话)
|
|
511
|
-
*/
|
|
512
|
-
private generateSummaryAsync(
|
|
513
|
-
sessionId: string,
|
|
514
|
-
fromRound: number,
|
|
515
|
-
toRound: number,
|
|
516
|
-
): void {
|
|
517
|
-
if (!this.provider) return;
|
|
518
|
-
if (this.summarizing.has(sessionId)) return;
|
|
519
|
-
|
|
520
|
-
this.summarizing.add(sessionId);
|
|
521
|
-
|
|
522
|
-
this.store.getLatestSummary(sessionId).then(async (parentSummary) => {
|
|
523
|
-
try {
|
|
524
|
-
const messages = await this.store.getMessagesByRoundRange(sessionId, fromRound, toRound);
|
|
525
|
-
if (messages.length === 0) {
|
|
526
|
-
this.summarizing.delete(sessionId);
|
|
527
|
-
return;
|
|
528
|
-
}
|
|
529
|
-
|
|
530
|
-
logger.debug(
|
|
531
|
-
`[摘要] 开始: session=${sessionId}, 轮次 ${fromRound}-${toRound}, ` +
|
|
532
|
-
`parent=${parentSummary?.id ?? 'null'}, ${messages.length}条消息`,
|
|
533
|
-
);
|
|
534
|
-
|
|
535
|
-
const summaryText = await this.callLLMSummarize(
|
|
536
|
-
parentSummary?.summary ?? null,
|
|
537
|
-
messages,
|
|
538
|
-
);
|
|
539
|
-
|
|
540
|
-
// 质量兜底:字数异常的摘要丢弃
|
|
541
|
-
if (summaryText && summaryText.length >= 20 && summaryText.length <= 1000) {
|
|
542
|
-
const created = await this.store.addSummary({
|
|
543
|
-
session_id: sessionId,
|
|
544
|
-
parent_id: parentSummary?.id ?? null,
|
|
545
|
-
from_round: fromRound,
|
|
546
|
-
to_round: toRound,
|
|
547
|
-
summary: summaryText,
|
|
548
|
-
created_at: Date.now(),
|
|
549
|
-
});
|
|
550
|
-
logger.info(
|
|
551
|
-
`[摘要] 完成: id=${created.id}, session=${sessionId}, ` +
|
|
552
|
-
`轮次 ${fromRound}-${toRound}, parent=${parentSummary?.id ?? 'null'}, ` +
|
|
553
|
-
`${summaryText.length}字`,
|
|
554
|
-
);
|
|
555
|
-
} else {
|
|
556
|
-
logger.warn(
|
|
557
|
-
`[摘要] 质量异常,丢弃: session=${sessionId}, ` +
|
|
558
|
-
`长度=${summaryText?.length ?? 0}`,
|
|
559
|
-
);
|
|
560
|
-
}
|
|
561
|
-
} catch (err) {
|
|
562
|
-
logger.warn(`[摘要] 失败: session=${sessionId}`, err);
|
|
563
|
-
} finally {
|
|
564
|
-
this.summarizing.delete(sessionId);
|
|
565
|
-
}
|
|
566
|
-
});
|
|
567
|
-
}
|
|
568
|
-
|
|
569
|
-
// ── 读取(构建上下文) ──
|
|
570
|
-
|
|
571
|
-
/**
|
|
572
|
-
* 构建 LLM 上下文消息列表
|
|
573
|
-
*
|
|
574
|
-
* 规则:
|
|
575
|
-
* 1. 取滑动窗口(最近 slidingWindowSize 轮)
|
|
576
|
-
* 2. 取最新 summary
|
|
577
|
-
* 3. 检查连续性:summary.to_round === window第一轮 - 1
|
|
578
|
-
* → 连续:[summary] + [window 消息]
|
|
579
|
-
* → 不连续:仅 [window 消息]
|
|
580
|
-
*/
|
|
581
|
-
async buildContext(sessionId: string): Promise<ChatMessage[]> {
|
|
582
|
-
const currentRound = this.roundCache.get(sessionId) ?? await this.store.getMaxRound(sessionId);
|
|
583
|
-
if (currentRound === 0) return [];
|
|
584
|
-
|
|
585
|
-
// 1. 滑动窗口
|
|
586
|
-
const windowStart = Math.max(1, currentRound - this.config.slidingWindowSize + 1);
|
|
587
|
-
const windowMessages = await this.store.getMessagesByRoundRange(sessionId, windowStart, currentRound);
|
|
588
|
-
windowMessages.sort((a, b) => a.round - b.round || a.created_at - b.created_at);
|
|
589
|
-
|
|
590
|
-
if (windowMessages.length === 0) return [];
|
|
591
|
-
|
|
592
|
-
const firstWindowRound = windowMessages[0].round;
|
|
593
|
-
|
|
594
|
-
// 2. 取最新 summary
|
|
595
|
-
const latest = await this.store.getLatestSummary(sessionId);
|
|
596
|
-
|
|
597
|
-
const result: ChatMessage[] = [];
|
|
598
|
-
|
|
599
|
-
// 3. 连续性校验
|
|
600
|
-
// 连续: summary.to_round >= firstWindowRound - 1
|
|
601
|
-
// → 紧挨(to_round === first-1)或重叠(to_round >= first)都算连续
|
|
602
|
-
// 不连续: summary.to_round < firstWindowRound - 1
|
|
603
|
-
// → 中间有间隔,summary 与当前话题无关,丢弃
|
|
604
|
-
if (latest && latest.to_round >= firstWindowRound - 1) {
|
|
605
|
-
result.push({
|
|
606
|
-
role: 'system',
|
|
607
|
-
content: `[对话记忆] 之前的对话摘要(覆盖第${latest.from_round}-${latest.to_round}轮):\n${latest.summary}`,
|
|
608
|
-
});
|
|
609
|
-
|
|
610
|
-
// 重叠时去重:只注入 summary 未覆盖的窗口消息
|
|
611
|
-
const dedupStart = latest.to_round + 1;
|
|
612
|
-
const dedupMessages = windowMessages.filter(m => m.round >= dedupStart);
|
|
613
|
-
for (const msg of dedupMessages) {
|
|
614
|
-
result.push({ role: msg.role as 'user' | 'assistant', content: msg.content });
|
|
615
|
-
}
|
|
616
|
-
logger.debug(
|
|
617
|
-
`[上下文] summary(${latest.from_round}-${latest.to_round}) + ` +
|
|
618
|
-
`window(${dedupStart}-${currentRound}), 去重后${dedupMessages.length}条`,
|
|
619
|
-
);
|
|
620
|
-
} else {
|
|
621
|
-
if (latest) {
|
|
622
|
-
logger.debug(
|
|
623
|
-
`[上下文] summary 不连续 (to_round=${latest.to_round}, window_start=${firstWindowRound}), 仅用窗口`,
|
|
624
|
-
);
|
|
625
|
-
}
|
|
626
|
-
// 无 summary 或不连续 → 仅窗口
|
|
627
|
-
for (const msg of windowMessages) {
|
|
628
|
-
result.push({ role: msg.role as 'user' | 'assistant', content: msg.content });
|
|
629
|
-
}
|
|
630
|
-
}
|
|
631
|
-
|
|
632
|
-
return result;
|
|
633
|
-
}
|
|
634
|
-
|
|
635
|
-
// ── LLM 摘要 ──
|
|
636
|
-
|
|
637
|
-
private async callLLMSummarize(
|
|
638
|
-
parentSummary: string | null,
|
|
639
|
-
messages: MessageRecord[],
|
|
640
|
-
): Promise<string | null> {
|
|
641
|
-
if (!this.provider || messages.length === 0) return null;
|
|
642
|
-
|
|
643
|
-
const transcript = messages
|
|
644
|
-
.sort((a, b) => a.round - b.round || a.created_at - b.created_at)
|
|
645
|
-
.map(m => {
|
|
646
|
-
const role = m.role === 'user' ? '用户' : '助手';
|
|
647
|
-
return `${role}: ${m.content}`;
|
|
648
|
-
})
|
|
649
|
-
.join('\n');
|
|
650
|
-
|
|
651
|
-
const maxChars = 3000;
|
|
652
|
-
const trimmedTranscript = transcript.length > maxChars
|
|
653
|
-
? '...\n' + transcript.slice(-maxChars)
|
|
654
|
-
: transcript;
|
|
655
|
-
|
|
656
|
-
let userContent: string;
|
|
657
|
-
if (parentSummary) {
|
|
658
|
-
userContent =
|
|
659
|
-
`## 之前的对话摘要\n${parentSummary}\n\n` +
|
|
660
|
-
`## 最近的对话记录\n${trimmedTranscript}`;
|
|
661
|
-
} else {
|
|
662
|
-
userContent = trimmedTranscript;
|
|
663
|
-
}
|
|
664
|
-
|
|
665
|
-
const model = this.provider.models[0];
|
|
666
|
-
const response = await this.provider.chat({
|
|
667
|
-
model,
|
|
668
|
-
messages: [
|
|
669
|
-
{
|
|
670
|
-
role: 'system',
|
|
671
|
-
content: parentSummary
|
|
672
|
-
? '你是一个对话摘要助手。请将「之前的摘要」和「最近的对话记录」合并为一段新的综合摘要(150-300字)。保留关键信息、用户偏好和重要结论,让新摘要完整覆盖所有历史。只输出摘要内容,不要添加任何前缀。'
|
|
673
|
-
: '你是一个对话摘要助手。请将以下对话压缩为一段简洁的中文摘要(100-200字),保留关键信息、用户偏好和重要结论。只输出摘要内容,不要添加任何前缀。',
|
|
674
|
-
},
|
|
675
|
-
{ role: 'user', content: userContent },
|
|
676
|
-
],
|
|
677
|
-
temperature: 0.3,
|
|
678
|
-
});
|
|
679
|
-
|
|
680
|
-
const content = response.choices[0]?.message?.content;
|
|
681
|
-
return typeof content === 'string' ? content.trim() : null;
|
|
682
|
-
}
|
|
683
|
-
|
|
684
|
-
// ── 查询 API(供 AI 工具调用) ──
|
|
685
|
-
|
|
686
|
-
async searchMessages(
|
|
687
|
-
sessionId: string,
|
|
688
|
-
keyword: string,
|
|
689
|
-
limit = 20,
|
|
690
|
-
): Promise<{ round: number; role: string; content: string; time: number }[]> {
|
|
691
|
-
const results = await this.store.searchMessages(sessionId, keyword);
|
|
692
|
-
return results
|
|
693
|
-
.sort((a, b) => b.round - a.round || b.created_at - a.created_at)
|
|
694
|
-
.slice(0, limit)
|
|
695
|
-
.map(m => ({ round: m.round, role: m.role, content: m.content, time: m.created_at }));
|
|
696
|
-
}
|
|
697
|
-
|
|
698
|
-
async getMessagesByRound(
|
|
699
|
-
sessionId: string,
|
|
700
|
-
fromRound: number,
|
|
701
|
-
toRound: number,
|
|
702
|
-
): Promise<{ round: number; role: string; content: string; time: number }[]> {
|
|
703
|
-
const results = await this.store.getMessagesByRoundRange(sessionId, fromRound, toRound);
|
|
704
|
-
return results
|
|
705
|
-
.sort((a, b) => a.round - b.round || a.created_at - b.created_at)
|
|
706
|
-
.map(m => ({ round: m.round, role: m.role, content: m.content, time: m.created_at }));
|
|
707
|
-
}
|
|
708
|
-
|
|
709
|
-
async getCurrentRound(sessionId: string): Promise<number> {
|
|
710
|
-
return this.roundCache.get(sessionId) ?? this.store.getMaxRound(sessionId);
|
|
711
|
-
}
|
|
712
|
-
|
|
713
|
-
async traceByKeyword(
|
|
714
|
-
sessionId: string,
|
|
715
|
-
keyword: string,
|
|
716
|
-
limit = 30,
|
|
717
|
-
): Promise<{
|
|
718
|
-
summary: { id: number; fromRound: number; toRound: number; summary: string } | null;
|
|
719
|
-
messages: { round: number; role: string; content: string; time: number }[];
|
|
720
|
-
}> {
|
|
721
|
-
const kw = keyword.toLowerCase();
|
|
722
|
-
|
|
723
|
-
let current = await this.store.getLatestSummary(sessionId);
|
|
724
|
-
let matchedSummary: SummaryRecord | null = null;
|
|
725
|
-
|
|
726
|
-
while (current) {
|
|
727
|
-
if (current.summary.toLowerCase().includes(kw)) {
|
|
728
|
-
matchedSummary = current;
|
|
729
|
-
break;
|
|
730
|
-
}
|
|
731
|
-
if (current.parent_id != null) {
|
|
732
|
-
current = await this.store.getSummaryById(sessionId, current.parent_id);
|
|
733
|
-
} else {
|
|
734
|
-
break;
|
|
735
|
-
}
|
|
736
|
-
}
|
|
737
|
-
|
|
738
|
-
if (matchedSummary) {
|
|
739
|
-
const messages = await this.store.getMessagesByRoundRange(
|
|
740
|
-
sessionId, matchedSummary.from_round, matchedSummary.to_round,
|
|
741
|
-
);
|
|
742
|
-
return {
|
|
743
|
-
summary: {
|
|
744
|
-
id: matchedSummary.id!,
|
|
745
|
-
fromRound: matchedSummary.from_round,
|
|
746
|
-
toRound: matchedSummary.to_round,
|
|
747
|
-
summary: matchedSummary.summary,
|
|
748
|
-
},
|
|
749
|
-
messages: messages
|
|
750
|
-
.sort((a, b) => a.round - b.round || a.created_at - b.created_at)
|
|
751
|
-
.slice(0, limit)
|
|
752
|
-
.map(m => ({ round: m.round, role: m.role, content: m.content, time: m.created_at })),
|
|
753
|
-
};
|
|
754
|
-
}
|
|
755
|
-
|
|
756
|
-
const directResults = await this.store.searchMessages(sessionId, keyword);
|
|
757
|
-
return {
|
|
758
|
-
summary: null,
|
|
759
|
-
messages: directResults
|
|
760
|
-
.sort((a, b) => b.round - a.round)
|
|
761
|
-
.slice(0, limit)
|
|
762
|
-
.map(m => ({ round: m.round, role: m.role, content: m.content, time: m.created_at })),
|
|
763
|
-
};
|
|
764
|
-
}
|
|
765
|
-
|
|
766
|
-
// ── 生命周期 ──
|
|
767
|
-
|
|
768
|
-
dispose(): void {
|
|
769
|
-
this.store.dispose();
|
|
770
|
-
this.summarizing.clear();
|
|
771
|
-
this.topicStates.clear();
|
|
772
|
-
this.roundCache.clear();
|
|
773
|
-
}
|
|
774
|
-
}
|