@zhin.js/core 1.0.25 → 1.0.27

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.
Files changed (202) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +84 -342
  3. package/lib/adapter.d.ts +17 -0
  4. package/lib/adapter.d.ts.map +1 -1
  5. package/lib/adapter.js +84 -2
  6. package/lib/adapter.js.map +1 -1
  7. package/lib/ai/agent.d.ts +126 -0
  8. package/lib/ai/agent.d.ts.map +1 -0
  9. package/lib/ai/agent.js +645 -0
  10. package/lib/ai/agent.js.map +1 -0
  11. package/lib/ai/context-manager.d.ts +213 -0
  12. package/lib/ai/context-manager.d.ts.map +1 -0
  13. package/lib/ai/context-manager.js +313 -0
  14. package/lib/ai/context-manager.js.map +1 -0
  15. package/lib/ai/conversation-memory.d.ts +181 -0
  16. package/lib/ai/conversation-memory.d.ts.map +1 -0
  17. package/lib/ai/conversation-memory.js +581 -0
  18. package/lib/ai/conversation-memory.js.map +1 -0
  19. package/lib/ai/follow-up.d.ts +131 -0
  20. package/lib/ai/follow-up.d.ts.map +1 -0
  21. package/lib/ai/follow-up.js +265 -0
  22. package/lib/ai/follow-up.js.map +1 -0
  23. package/lib/ai/index.d.ts +29 -0
  24. package/lib/ai/index.d.ts.map +1 -0
  25. package/lib/ai/index.js +34 -0
  26. package/lib/ai/index.js.map +1 -0
  27. package/lib/ai/init.d.ts +30 -0
  28. package/lib/ai/init.d.ts.map +1 -0
  29. package/lib/ai/init.js +424 -0
  30. package/lib/ai/init.js.map +1 -0
  31. package/lib/ai/output.d.ts +93 -0
  32. package/lib/ai/output.d.ts.map +1 -0
  33. package/lib/ai/output.js +176 -0
  34. package/lib/ai/output.js.map +1 -0
  35. package/lib/ai/providers/anthropic.d.ts +23 -0
  36. package/lib/ai/providers/anthropic.d.ts.map +1 -0
  37. package/lib/ai/providers/anthropic.js +322 -0
  38. package/lib/ai/providers/anthropic.js.map +1 -0
  39. package/lib/ai/providers/base.d.ts +43 -0
  40. package/lib/ai/providers/base.d.ts.map +1 -0
  41. package/lib/ai/providers/base.js +135 -0
  42. package/lib/ai/providers/base.js.map +1 -0
  43. package/lib/ai/providers/index.d.ts +12 -0
  44. package/lib/ai/providers/index.d.ts.map +1 -0
  45. package/lib/ai/providers/index.js +9 -0
  46. package/lib/ai/providers/index.js.map +1 -0
  47. package/lib/ai/providers/ollama.d.ts +25 -0
  48. package/lib/ai/providers/ollama.d.ts.map +1 -0
  49. package/lib/ai/providers/ollama.js +243 -0
  50. package/lib/ai/providers/ollama.js.map +1 -0
  51. package/lib/ai/providers/openai.d.ts +46 -0
  52. package/lib/ai/providers/openai.d.ts.map +1 -0
  53. package/lib/ai/providers/openai.js +132 -0
  54. package/lib/ai/providers/openai.js.map +1 -0
  55. package/lib/ai/rate-limiter.d.ts +38 -0
  56. package/lib/ai/rate-limiter.d.ts.map +1 -0
  57. package/lib/ai/rate-limiter.js +86 -0
  58. package/lib/ai/rate-limiter.js.map +1 -0
  59. package/lib/ai/service.d.ts +81 -0
  60. package/lib/ai/service.d.ts.map +1 -0
  61. package/lib/ai/service.js +274 -0
  62. package/lib/ai/service.js.map +1 -0
  63. package/lib/ai/session.d.ts +186 -0
  64. package/lib/ai/session.d.ts.map +1 -0
  65. package/lib/ai/session.js +443 -0
  66. package/lib/ai/session.js.map +1 -0
  67. package/lib/ai/tone-detector.d.ts +19 -0
  68. package/lib/ai/tone-detector.d.ts.map +1 -0
  69. package/lib/ai/tone-detector.js +72 -0
  70. package/lib/ai/tone-detector.js.map +1 -0
  71. package/lib/ai/tools.d.ts +45 -0
  72. package/lib/ai/tools.d.ts.map +1 -0
  73. package/lib/ai/tools.js +206 -0
  74. package/lib/ai/tools.js.map +1 -0
  75. package/lib/ai/types.d.ts +264 -0
  76. package/lib/ai/types.d.ts.map +1 -0
  77. package/lib/ai/types.js +6 -0
  78. package/lib/ai/types.js.map +1 -0
  79. package/lib/ai/user-profile.d.ts +56 -0
  80. package/lib/ai/user-profile.d.ts.map +1 -0
  81. package/lib/ai/user-profile.js +130 -0
  82. package/lib/ai/user-profile.js.map +1 -0
  83. package/lib/ai/zhin-agent.d.ts +165 -0
  84. package/lib/ai/zhin-agent.d.ts.map +1 -0
  85. package/lib/ai/zhin-agent.js +707 -0
  86. package/lib/ai/zhin-agent.js.map +1 -0
  87. package/lib/built/ai-trigger.d.ts.map +1 -1
  88. package/lib/built/ai-trigger.js +7 -3
  89. package/lib/built/ai-trigger.js.map +1 -1
  90. package/lib/built/command.d.ts +33 -17
  91. package/lib/built/command.d.ts.map +1 -1
  92. package/lib/built/command.js +71 -44
  93. package/lib/built/command.js.map +1 -1
  94. package/lib/built/component.d.ts +42 -15
  95. package/lib/built/component.d.ts.map +1 -1
  96. package/lib/built/component.js +84 -52
  97. package/lib/built/component.js.map +1 -1
  98. package/lib/built/config.d.ts +64 -5
  99. package/lib/built/config.d.ts.map +1 -1
  100. package/lib/built/config.js +129 -12
  101. package/lib/built/config.js.map +1 -1
  102. package/lib/built/cron.d.ts +41 -18
  103. package/lib/built/cron.d.ts.map +1 -1
  104. package/lib/built/cron.js +106 -63
  105. package/lib/built/cron.js.map +1 -1
  106. package/lib/built/database.d.ts +55 -6
  107. package/lib/built/database.d.ts.map +1 -1
  108. package/lib/built/database.js +93 -22
  109. package/lib/built/database.js.map +1 -1
  110. package/lib/built/dispatcher.d.ts +118 -0
  111. package/lib/built/dispatcher.d.ts.map +1 -0
  112. package/lib/built/dispatcher.js +196 -0
  113. package/lib/built/dispatcher.js.map +1 -0
  114. package/lib/built/permission.d.ts +45 -5
  115. package/lib/built/permission.d.ts.map +1 -1
  116. package/lib/built/permission.js +56 -11
  117. package/lib/built/permission.js.map +1 -1
  118. package/lib/built/skill.d.ts +117 -0
  119. package/lib/built/skill.d.ts.map +1 -0
  120. package/lib/built/skill.js +191 -0
  121. package/lib/built/skill.js.map +1 -0
  122. package/lib/built/tool.d.ts +71 -164
  123. package/lib/built/tool.d.ts.map +1 -1
  124. package/lib/built/tool.js +212 -297
  125. package/lib/built/tool.js.map +1 -1
  126. package/lib/feature.d.ts +75 -0
  127. package/lib/feature.d.ts.map +1 -0
  128. package/lib/feature.js +69 -0
  129. package/lib/feature.js.map +1 -0
  130. package/lib/index.d.ts +4 -0
  131. package/lib/index.d.ts.map +1 -1
  132. package/lib/index.js +7 -0
  133. package/lib/index.js.map +1 -1
  134. package/lib/plugin.d.ts +25 -17
  135. package/lib/plugin.d.ts.map +1 -1
  136. package/lib/plugin.js +180 -20
  137. package/lib/plugin.js.map +1 -1
  138. package/lib/types.d.ts +4 -9
  139. package/lib/types.d.ts.map +1 -1
  140. package/package.json +6 -6
  141. package/src/adapter.ts +101 -2
  142. package/src/ai/agent.ts +772 -0
  143. package/src/ai/context-manager.ts +440 -0
  144. package/src/ai/conversation-memory.ts +774 -0
  145. package/src/ai/follow-up.ts +357 -0
  146. package/src/ai/index.ts +128 -0
  147. package/src/ai/init.ts +502 -0
  148. package/src/ai/output.ts +261 -0
  149. package/src/ai/providers/anthropic.ts +375 -0
  150. package/src/ai/providers/base.ts +173 -0
  151. package/src/ai/providers/index.ts +13 -0
  152. package/src/ai/providers/ollama.ts +292 -0
  153. package/src/ai/providers/openai.ts +167 -0
  154. package/src/ai/rate-limiter.ts +129 -0
  155. package/src/ai/service.ts +319 -0
  156. package/src/ai/session.ts +544 -0
  157. package/src/ai/tone-detector.ts +89 -0
  158. package/src/ai/tools.ts +218 -0
  159. package/src/ai/types.ts +296 -0
  160. package/src/ai/user-profile.ts +181 -0
  161. package/src/ai/zhin-agent.ts +845 -0
  162. package/src/built/ai-trigger.ts +6 -3
  163. package/src/built/command.ts +75 -69
  164. package/src/built/component.ts +94 -76
  165. package/src/built/config.ts +288 -128
  166. package/src/built/cron.ts +117 -101
  167. package/src/built/database.ts +128 -33
  168. package/src/built/dispatcher.ts +332 -0
  169. package/src/built/permission.ts +146 -54
  170. package/src/built/skill.ts +280 -0
  171. package/src/built/tool.ts +245 -366
  172. package/src/feature.ts +113 -0
  173. package/src/index.ts +7 -0
  174. package/src/plugin.ts +198 -33
  175. package/src/types.ts +6 -10
  176. package/tests/adapter.test.ts +153 -1
  177. package/tests/ai/agent.test.ts +614 -0
  178. package/tests/ai/ai-trigger.test.ts +368 -0
  179. package/tests/ai/context-manager.test.ts +413 -0
  180. package/tests/ai/conversation-memory.test.ts +128 -0
  181. package/tests/ai/follow-up.test.ts +175 -0
  182. package/tests/ai/integration.test.ts +584 -0
  183. package/tests/ai/output.test.ts +128 -0
  184. package/tests/ai/providers.integration.test.ts +227 -0
  185. package/tests/ai/rate-limiter.test.ts +108 -0
  186. package/tests/ai/session.test.ts +375 -0
  187. package/tests/ai/setup.ts +308 -0
  188. package/tests/ai/tone-detector.test.ts +80 -0
  189. package/tests/ai/tool.test.ts +800 -0
  190. package/tests/ai/tools-builtin.test.ts +346 -0
  191. package/tests/ai/user-profile.test.ts +73 -0
  192. package/tests/ai/zhin-agent.test.ts +177 -0
  193. package/tests/config.test.ts +46 -0
  194. package/tests/cron.test.ts +94 -5
  195. package/tests/dispatcher.test.ts +146 -0
  196. package/tests/feature.test.ts +145 -0
  197. package/tests/features-builtin.test.ts +191 -0
  198. package/tests/plugin.test.ts +88 -14
  199. package/tests/skill-feature.test.ts +179 -0
  200. package/tests/tool-feature.test.ts +254 -0
  201. package/test/minimal-bot.ts +0 -31
  202. package/test/stress-test.ts +0 -123
@@ -0,0 +1,413 @@
1
+ /**
2
+ * Context Manager 测试
3
+ *
4
+ * 测试上下文管理功能:
5
+ * - 消息记录
6
+ * - 上下文构建
7
+ * - Token 估算
8
+ * - 自动总结
9
+ */
10
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
11
+
12
+ // Mock Logger
13
+ vi.mock('@zhin.js/core', async (importOriginal) => {
14
+ const original = await importOriginal() as any;
15
+ return {
16
+ ...original,
17
+ Logger: class {
18
+ debug = vi.fn();
19
+ info = vi.fn();
20
+ warn = vi.fn();
21
+ error = vi.fn();
22
+ },
23
+ };
24
+ });
25
+
26
+ import { ContextManager, createContextManager, CHAT_MESSAGE_MODEL, CONTEXT_SUMMARY_MODEL } from '../../src/ai/context-manager.js';
27
+ import type { MessageRecord } from '../../src/ai/context-manager.js';
28
+
29
+ describe('ContextManager', () => {
30
+ let manager: ContextManager;
31
+ let mockMessageModel: any;
32
+ let mockSummaryModel: any;
33
+
34
+ beforeEach(() => {
35
+ // Mock 数据库模型
36
+ const messageStore: MessageRecord[] = [];
37
+ const summaryStore: any[] = [];
38
+
39
+ // 链式查询构建器 mock:select().where({...}).orderBy('field', 'DIR').limit(n)
40
+ function createChainableMock(store: any[], filterKey?: string) {
41
+ return vi.fn((..._fields: string[]) => {
42
+ let whereFilter: any = null;
43
+ let orderField: string | null = null;
44
+ let orderDir: string = 'ASC';
45
+ let limitNum: number | null = null;
46
+
47
+ const chain: any = {
48
+ where(condition: any) {
49
+ whereFilter = condition;
50
+ return chain;
51
+ },
52
+ orderBy(field: string, dir: string = 'ASC') {
53
+ orderField = field;
54
+ orderDir = dir;
55
+ return chain;
56
+ },
57
+ limit(n: number) {
58
+ limitNum = n;
59
+ return chain;
60
+ },
61
+ then(resolve: (v: any) => any, reject?: (e: any) => any) {
62
+ try {
63
+ let results = [...store];
64
+ if (whereFilter && filterKey) {
65
+ results = results.filter(r => r[filterKey] === whereFilter[filterKey]);
66
+ }
67
+ if (orderField) {
68
+ results.sort((a, b) =>
69
+ orderDir.toUpperCase() === 'DESC'
70
+ ? (b[orderField!] ?? 0) - (a[orderField!] ?? 0)
71
+ : (a[orderField!] ?? 0) - (b[orderField!] ?? 0)
72
+ );
73
+ }
74
+ if (limitNum !== null) {
75
+ results = results.slice(0, limitNum);
76
+ }
77
+ return resolve(results);
78
+ } catch (e) {
79
+ return reject ? reject(e) : Promise.reject(e);
80
+ }
81
+ },
82
+ };
83
+ return chain;
84
+ });
85
+ }
86
+
87
+ mockMessageModel = {
88
+ create: vi.fn((record: MessageRecord) => {
89
+ messageStore.push({ ...record, id: messageStore.length + 1 });
90
+ return Promise.resolve();
91
+ }),
92
+ select: createChainableMock(messageStore, 'scene_id'),
93
+ delete: vi.fn(() => Promise.resolve(0)),
94
+ };
95
+
96
+ mockSummaryModel = {
97
+ create: vi.fn((record: any) => {
98
+ summaryStore.push({ ...record, id: summaryStore.length + 1 });
99
+ return Promise.resolve();
100
+ }),
101
+ select: createChainableMock(summaryStore, 'scene_id'),
102
+ };
103
+
104
+ manager = new ContextManager(mockMessageModel, mockSummaryModel, {
105
+ enabled: true,
106
+ maxRecentMessages: 100,
107
+ summaryThreshold: 50,
108
+ });
109
+ });
110
+
111
+ describe('消息记录', () => {
112
+ it('应该记录消息', async () => {
113
+ await manager.recordMessage({
114
+ platform: 'qq',
115
+ scene_id: 'group-123',
116
+ scene_type: 'group',
117
+ scene_name: '测试群',
118
+ sender_id: 'user-1',
119
+ sender_name: '张三',
120
+ message: '你好',
121
+ time: Date.now(),
122
+ });
123
+
124
+ expect(mockMessageModel.create).toHaveBeenCalledWith(
125
+ expect.objectContaining({
126
+ platform: 'qq',
127
+ scene_id: 'group-123',
128
+ message: '你好',
129
+ })
130
+ );
131
+ });
132
+
133
+ it('记录失败时不应抛出错误', async () => {
134
+ mockMessageModel.create.mockRejectedValue(new Error('数据库错误'));
135
+
136
+ await expect(manager.recordMessage({
137
+ platform: 'qq',
138
+ scene_id: 'group-123',
139
+ scene_type: 'group',
140
+ scene_name: '',
141
+ sender_id: 'user-1',
142
+ sender_name: '',
143
+ message: '测试',
144
+ time: Date.now(),
145
+ })).resolves.toBeUndefined();
146
+ });
147
+ });
148
+
149
+ describe('获取最近消息', () => {
150
+ it('应该获取场景的最近消息', async () => {
151
+ // 添加一些消息
152
+ await manager.recordMessage({
153
+ platform: 'qq',
154
+ scene_id: 'group-123',
155
+ scene_type: 'group',
156
+ scene_name: '测试群',
157
+ sender_id: 'user-1',
158
+ sender_name: '张三',
159
+ message: '消息1',
160
+ time: 1000,
161
+ });
162
+
163
+ await manager.recordMessage({
164
+ platform: 'qq',
165
+ scene_id: 'group-123',
166
+ scene_type: 'group',
167
+ scene_name: '测试群',
168
+ sender_id: 'user-2',
169
+ sender_name: '李四',
170
+ message: '消息2',
171
+ time: 2000,
172
+ });
173
+
174
+ const messages = await manager.getRecentMessages('group-123');
175
+
176
+ expect(messages.length).toBe(2);
177
+ // 链式 API: select() 无参数调用
178
+ expect(mockMessageModel.select).toHaveBeenCalled();
179
+ });
180
+
181
+ it('空场景应返回空数组', async () => {
182
+ const messages = await manager.getRecentMessages('nonexistent');
183
+ expect(messages).toEqual([]);
184
+ });
185
+
186
+ it('应该限制返回数量', async () => {
187
+ const messages = await manager.getRecentMessages('group-123', 10);
188
+
189
+ // 链式 API: select() 无参数调用,limit 通过链式传递
190
+ expect(mockMessageModel.select).toHaveBeenCalled();
191
+ });
192
+ });
193
+
194
+ describe('构建上下文', () => {
195
+ it('应该构建场景上下文', async () => {
196
+ await manager.recordMessage({
197
+ platform: 'qq',
198
+ scene_id: 'group-123',
199
+ scene_type: 'group',
200
+ scene_name: '测试群',
201
+ sender_id: 'user-1',
202
+ sender_name: '张三',
203
+ message: '你好',
204
+ time: Date.now(),
205
+ });
206
+
207
+ const context = await manager.buildContext('group-123', 'qq');
208
+
209
+ expect(context.sceneId).toBe('group-123');
210
+ expect(context.platform).toBe('qq');
211
+ expect(context.chatMessages).toBeDefined();
212
+ });
213
+
214
+ it('空场景应返回有效的空上下文', async () => {
215
+ const context = await manager.buildContext('empty-scene', 'qq');
216
+
217
+ expect(context.sceneId).toBe('empty-scene');
218
+ expect(context.recentMessages).toEqual([]);
219
+ expect(context.summaries).toEqual([]);
220
+ });
221
+ });
222
+
223
+ describe('格式化消息', () => {
224
+ it('应该将消息格式化为 ChatMessage', () => {
225
+ const messages: MessageRecord[] = [
226
+ {
227
+ id: 1,
228
+ platform: 'qq',
229
+ scene_id: 'group-123',
230
+ scene_type: 'group',
231
+ scene_name: '测试群',
232
+ sender_id: 'user-1',
233
+ sender_name: '张三',
234
+ message: '你好',
235
+ time: Date.now(),
236
+ },
237
+ ];
238
+
239
+ const chatMessages = manager.formatToChatMessages([], messages);
240
+
241
+ expect(chatMessages.length).toBe(1);
242
+ expect(chatMessages[0].role).toBe('user');
243
+ expect(chatMessages[0].content).toContain('张三');
244
+ expect(chatMessages[0].content).toContain('你好');
245
+ });
246
+
247
+ it('应该将总结作为系统消息添加', () => {
248
+ const summaries = ['这是之前的总结'];
249
+ const messages: MessageRecord[] = [];
250
+
251
+ const chatMessages = manager.formatToChatMessages(summaries, messages);
252
+
253
+ expect(chatMessages.length).toBe(1);
254
+ expect(chatMessages[0].role).toBe('system');
255
+ expect(chatMessages[0].content).toContain('这是之前的总结');
256
+ });
257
+
258
+ it('应该识别机器人消息', () => {
259
+ const messages: MessageRecord[] = [
260
+ {
261
+ id: 1,
262
+ platform: 'qq',
263
+ scene_id: 'group-123',
264
+ scene_type: 'group',
265
+ scene_name: '',
266
+ sender_id: 'bot-123',
267
+ sender_name: 'MyBot',
268
+ message: '我是机器人',
269
+ time: Date.now(),
270
+ },
271
+ ];
272
+
273
+ const chatMessages = manager.formatToChatMessages([], messages);
274
+
275
+ expect(chatMessages[0].role).toBe('assistant');
276
+ });
277
+ });
278
+
279
+ describe('Token 估算', () => {
280
+ it('应该估算中文文本的 token 数量', () => {
281
+ const text = '你好世界'; // 4 个中文字符
282
+ const tokens = manager.estimateTokens(text);
283
+
284
+ // 中文约 2 tokens/字
285
+ expect(tokens).toBeGreaterThanOrEqual(8);
286
+ });
287
+
288
+ it('应该估算英文文本的 token 数量', () => {
289
+ const text = 'hello world'; // 11 个字符
290
+ const tokens = manager.estimateTokens(text);
291
+
292
+ // 英文约 0.25 tokens/字符
293
+ expect(tokens).toBeGreaterThanOrEqual(2);
294
+ });
295
+
296
+ it('应该估算混合文本的 token 数量', () => {
297
+ const text = 'Hello 世界';
298
+ const tokens = manager.estimateTokens(text);
299
+
300
+ expect(tokens).toBeGreaterThan(0);
301
+ });
302
+ });
303
+
304
+ describe('判断是否需要总结', () => {
305
+ it('消息少于阈值时不需要总结', async () => {
306
+ // 只添加少量消息
307
+ for (let i = 0; i < 5; i++) {
308
+ await manager.recordMessage({
309
+ platform: 'qq',
310
+ scene_id: 'group-123',
311
+ scene_type: 'group',
312
+ scene_name: '',
313
+ sender_id: 'user-1',
314
+ sender_name: 'User',
315
+ message: `消息 ${i}`,
316
+ time: Date.now() + i,
317
+ });
318
+ }
319
+
320
+ const shouldSummarize = await manager.shouldSummarize('group-123');
321
+ expect(shouldSummarize).toBe(false);
322
+ });
323
+ });
324
+
325
+ describe('总结功能', () => {
326
+ it('没有 AI 提供商时应返回 null', async () => {
327
+ const result = await manager.summarize('group-123');
328
+ expect(result).toBeNull();
329
+ });
330
+
331
+ it('设置 AI 提供商后应该能总结', async () => {
332
+ // 添加足够多的消息
333
+ for (let i = 0; i < 60; i++) {
334
+ await manager.recordMessage({
335
+ platform: 'qq',
336
+ scene_id: 'group-123',
337
+ scene_type: 'group',
338
+ scene_name: '',
339
+ sender_id: 'user-1',
340
+ sender_name: 'User',
341
+ message: `消息 ${i}`,
342
+ time: Date.now() + i,
343
+ });
344
+ }
345
+
346
+ const mockProvider = {
347
+ models: ['test-model'],
348
+ chat: vi.fn().mockResolvedValue({
349
+ choices: [{ message: { content: '这是总结' } }],
350
+ }),
351
+ };
352
+
353
+ manager.setAIProvider(mockProvider as any);
354
+
355
+ const result = await manager.summarize('group-123');
356
+
357
+ expect(mockProvider.chat).toHaveBeenCalled();
358
+ });
359
+ });
360
+
361
+ describe('场景统计', () => {
362
+ it('应该返回场景统计信息', async () => {
363
+ await manager.recordMessage({
364
+ platform: 'qq',
365
+ scene_id: 'group-123',
366
+ scene_type: 'group',
367
+ scene_name: '',
368
+ sender_id: 'user-1',
369
+ sender_name: 'User',
370
+ message: '测试',
371
+ time: Date.now(),
372
+ });
373
+
374
+ const stats = await manager.getSceneStats('group-123');
375
+
376
+ expect(stats.messageCount).toBeGreaterThan(0);
377
+ expect(stats.summaryCount).toBe(0);
378
+ });
379
+
380
+ it('空场景应返回零统计', async () => {
381
+ const stats = await manager.getSceneStats('empty');
382
+
383
+ expect(stats.messageCount).toBe(0);
384
+ expect(stats.summaryCount).toBe(0);
385
+ });
386
+ });
387
+ });
388
+
389
+ describe('模型定义', () => {
390
+ it('CHAT_MESSAGE_MODEL 应该有正确的字段', () => {
391
+ expect(CHAT_MESSAGE_MODEL.platform).toBeDefined();
392
+ expect(CHAT_MESSAGE_MODEL.scene_id).toBeDefined();
393
+ expect(CHAT_MESSAGE_MODEL.message).toBeDefined();
394
+ expect(CHAT_MESSAGE_MODEL.time).toBeDefined();
395
+ });
396
+
397
+ it('CONTEXT_SUMMARY_MODEL 应该有正确的字段', () => {
398
+ expect(CONTEXT_SUMMARY_MODEL.scene_id).toBeDefined();
399
+ expect(CONTEXT_SUMMARY_MODEL.summary).toBeDefined();
400
+ expect(CONTEXT_SUMMARY_MODEL.created_at).toBeDefined();
401
+ });
402
+ });
403
+
404
+ describe('createContextManager', () => {
405
+ it('应该创建 ContextManager 实例', () => {
406
+ const mockModel = { create: vi.fn(), select: vi.fn() };
407
+ const manager = createContextManager(mockModel, mockModel, {
408
+ maxRecentMessages: 50,
409
+ });
410
+
411
+ expect(manager).toBeInstanceOf(ContextManager);
412
+ });
413
+ });
@@ -0,0 +1,128 @@
1
+ /**
2
+ * ConversationMemory 测试
3
+ */
4
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
5
+ import { ConversationMemory } from '../../src/ai/conversation-memory.js';
6
+
7
+ describe('ConversationMemory(内存模式)', () => {
8
+ let memory: ConversationMemory;
9
+
10
+ beforeEach(() => {
11
+ memory = new ConversationMemory({
12
+ minTopicRounds: 5,
13
+ slidingWindowSize: 3,
14
+ topicChangeThreshold: 0.15,
15
+ });
16
+ });
17
+
18
+ afterEach(() => {
19
+ memory.dispose();
20
+ });
21
+
22
+ describe('saveRound / buildContext', () => {
23
+ it('应保存对话并构建上下文', async () => {
24
+ await memory.saveRound('s1', '你好', '你好呀!');
25
+ await memory.saveRound('s1', '天气怎么样', '今天晴天');
26
+
27
+ const context = await memory.buildContext('s1');
28
+ expect(context.length).toBeGreaterThan(0);
29
+ expect(context.some(m => m.content === '你好')).toBe(true);
30
+ expect(context.some(m => m.content === '今天晴天')).toBe(true);
31
+ });
32
+
33
+ it('空会话应返回空上下文', async () => {
34
+ const context = await memory.buildContext('nonexistent');
35
+ expect(context).toEqual([]);
36
+ });
37
+
38
+ it('轮次应递增', async () => {
39
+ await memory.saveRound('s1', '第一轮', '回复1');
40
+ await memory.saveRound('s1', '第二轮', '回复2');
41
+ await memory.saveRound('s1', '第三轮', '回复3');
42
+
43
+ const round = await memory.getCurrentRound('s1');
44
+ expect(round).toBe(3);
45
+ });
46
+ });
47
+
48
+ describe('滑动窗口', () => {
49
+ it('应限制上下文到窗口大小', async () => {
50
+ // slidingWindowSize = 3
51
+ await memory.saveRound('s1', 'm1', 'r1');
52
+ await memory.saveRound('s1', 'm2', 'r2');
53
+ await memory.saveRound('s1', 'm3', 'r3');
54
+ await memory.saveRound('s1', 'm4', 'r4');
55
+ await memory.saveRound('s1', 'm5', 'r5');
56
+
57
+ const context = await memory.buildContext('s1');
58
+ // 窗口大小 3 → 最多 6 条消息 (3轮 × 2)
59
+ expect(context.length).toBeLessThanOrEqual(6);
60
+ // 不应包含最早的消息
61
+ expect(context.some(m => m.content === 'm1')).toBe(false);
62
+ });
63
+ });
64
+
65
+ describe('searchMessages', () => {
66
+ it('应能搜索消息', async () => {
67
+ await memory.saveRound('s1', '今天去打篮球', '好主意');
68
+ await memory.saveRound('s1', '明天去游泳', '注意安全');
69
+
70
+ const results = await memory.searchMessages('s1', '篮球');
71
+ expect(results.length).toBeGreaterThan(0);
72
+ expect(results[0].content).toContain('篮球');
73
+ });
74
+
75
+ it('搜索不到应返回空数组', async () => {
76
+ await memory.saveRound('s1', '你好', '你好');
77
+ const results = await memory.searchMessages('s1', '不存在的内容xyz');
78
+ expect(results).toHaveLength(0);
79
+ });
80
+ });
81
+
82
+ describe('getMessagesByRound', () => {
83
+ it('应按轮次范围获取消息', async () => {
84
+ await memory.saveRound('s1', 'm1', 'r1');
85
+ await memory.saveRound('s1', 'm2', 'r2');
86
+ await memory.saveRound('s1', 'm3', 'r3');
87
+
88
+ const results = await memory.getMessagesByRound('s1', 1, 2);
89
+ // 第 1-2 轮,每轮 2 条
90
+ expect(results.length).toBe(4);
91
+ });
92
+ });
93
+
94
+ describe('traceByKeyword', () => {
95
+ it('无摘要时应直接搜索消息', async () => {
96
+ await memory.saveRound('s1', '讨论编程', '很好');
97
+ await memory.saveRound('s1', '继续编程', '继续');
98
+
99
+ const result = await memory.traceByKeyword('s1', '编程');
100
+ expect(result.summary).toBeNull();
101
+ expect(result.messages.length).toBeGreaterThan(0);
102
+ });
103
+ });
104
+
105
+ describe('不同会话隔离', () => {
106
+ it('不同 sessionId 的数据应隔离', async () => {
107
+ await memory.saveRound('s1', '会话1', '回复1');
108
+ await memory.saveRound('s2', '会话2', '回复2');
109
+
110
+ const ctx1 = await memory.buildContext('s1');
111
+ const ctx2 = await memory.buildContext('s2');
112
+
113
+ expect(ctx1.some(m => m.content === '会话1')).toBe(true);
114
+ expect(ctx1.some(m => m.content === '会话2')).toBe(false);
115
+ expect(ctx2.some(m => m.content === '会话2')).toBe(true);
116
+ });
117
+ });
118
+
119
+ describe('dispose', () => {
120
+ it('应清理所有数据', async () => {
121
+ await memory.saveRound('s1', '测试', '回复');
122
+ memory.dispose();
123
+
124
+ const context = await memory.buildContext('s1');
125
+ expect(context).toEqual([]);
126
+ });
127
+ });
128
+ });