@zhin.js/ai 0.0.2 → 1.0.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/CHANGELOG.md +2 -7
- package/lib/agent.d.ts +54 -6
- package/lib/agent.d.ts.map +1 -1
- package/lib/agent.js +468 -116
- package/lib/agent.js.map +1 -1
- package/lib/compaction.d.ts +132 -0
- package/lib/compaction.d.ts.map +1 -0
- package/lib/compaction.js +370 -0
- package/lib/compaction.js.map +1 -0
- package/lib/context-manager.d.ts.map +1 -1
- package/lib/context-manager.js +10 -3
- package/lib/context-manager.js.map +1 -1
- package/lib/conversation-memory.d.ts +192 -0
- package/lib/conversation-memory.d.ts.map +1 -0
- package/lib/conversation-memory.js +619 -0
- package/lib/conversation-memory.js.map +1 -0
- package/lib/index.d.ts +25 -163
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +24 -1122
- package/lib/index.js.map +1 -1
- package/lib/output.d.ts +93 -0
- package/lib/output.d.ts.map +1 -0
- package/lib/output.js +176 -0
- package/lib/output.js.map +1 -0
- package/lib/providers/anthropic.d.ts +7 -0
- package/lib/providers/anthropic.d.ts.map +1 -1
- package/lib/providers/anthropic.js +5 -0
- package/lib/providers/anthropic.js.map +1 -1
- package/lib/providers/ollama.d.ts +10 -0
- package/lib/providers/ollama.d.ts.map +1 -1
- package/lib/providers/ollama.js +19 -4
- package/lib/providers/ollama.js.map +1 -1
- package/lib/providers/openai.d.ts +7 -0
- package/lib/providers/openai.d.ts.map +1 -1
- package/lib/providers/openai.js +11 -0
- package/lib/providers/openai.js.map +1 -1
- package/lib/rate-limiter.d.ts +38 -0
- package/lib/rate-limiter.d.ts.map +1 -0
- package/lib/rate-limiter.js +86 -0
- package/lib/rate-limiter.js.map +1 -0
- package/lib/session.d.ts +7 -0
- package/lib/session.d.ts.map +1 -1
- package/lib/session.js +47 -18
- package/lib/session.js.map +1 -1
- package/lib/storage.d.ts +68 -0
- package/lib/storage.d.ts.map +1 -0
- package/lib/storage.js +105 -0
- package/lib/storage.js.map +1 -0
- package/lib/tone-detector.d.ts +19 -0
- package/lib/tone-detector.d.ts.map +1 -0
- package/lib/tone-detector.js +72 -0
- package/lib/tone-detector.js.map +1 -0
- package/lib/types.d.ts +84 -8
- package/lib/types.d.ts.map +1 -1
- package/package.json +13 -42
- package/src/agent.ts +518 -135
- package/src/compaction.ts +529 -0
- package/src/context-manager.ts +10 -9
- package/src/conversation-memory.ts +816 -0
- package/src/index.ts +121 -1406
- package/src/output.ts +261 -0
- package/src/providers/anthropic.ts +4 -0
- package/src/providers/ollama.ts +23 -4
- package/src/providers/openai.ts +8 -1
- package/src/rate-limiter.ts +129 -0
- package/src/session.ts +47 -18
- package/src/storage.ts +135 -0
- package/src/tone-detector.ts +89 -0
- package/src/types.ts +95 -6
- package/tests/agent.test.ts +123 -70
- package/tests/compaction.test.ts +310 -0
- package/tests/context-manager.test.ts +73 -47
- package/tests/conversation-memory.test.ts +128 -0
- package/tests/output.test.ts +128 -0
- package/tests/providers.test.ts +574 -0
- package/tests/rate-limiter.test.ts +108 -0
- package/tests/session.test.ts +139 -48
- package/tests/setup.ts +82 -240
- package/tests/storage.test.ts +224 -0
- package/tests/tone-detector.test.ts +80 -0
- package/tsconfig.json +4 -5
- package/vitest.setup.ts +1 -0
- package/README.md +0 -564
- package/TOOLS.md +0 -294
- package/lib/tools.d.ts +0 -45
- package/lib/tools.d.ts.map +0 -1
- package/lib/tools.js +0 -194
- package/lib/tools.js.map +0 -1
- package/src/tools.ts +0 -205
- package/tests/ai-trigger.test.ts +0 -369
- package/tests/integration.test.ts +0 -596
- package/tests/providers.integration.test.ts +0 -227
- package/tests/tool.test.ts +0 -800
- package/tests/tools-builtin.test.ts +0 -346
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Context Manager 测试
|
|
3
|
-
*
|
|
3
|
+
*
|
|
4
4
|
* 测试上下文管理功能:
|
|
5
5
|
* - 消息记录
|
|
6
6
|
* - 上下文构建
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
11
11
|
|
|
12
12
|
// Mock Logger
|
|
13
|
-
vi.mock('@zhin.js/
|
|
13
|
+
vi.mock('@zhin.js/logger', async (importOriginal) => {
|
|
14
14
|
const original = await importOriginal() as any;
|
|
15
15
|
return {
|
|
16
16
|
...original,
|
|
@@ -23,8 +23,8 @@ vi.mock('@zhin.js/core', async (importOriginal) => {
|
|
|
23
23
|
};
|
|
24
24
|
});
|
|
25
25
|
|
|
26
|
-
import { ContextManager, createContextManager, CHAT_MESSAGE_MODEL, CONTEXT_SUMMARY_MODEL } from '
|
|
27
|
-
import type { MessageRecord } from '
|
|
26
|
+
import { ContextManager, createContextManager, CHAT_MESSAGE_MODEL, CONTEXT_SUMMARY_MODEL } from '@zhin.js/ai';
|
|
27
|
+
import type { MessageRecord } from '@zhin.js/ai';
|
|
28
28
|
|
|
29
29
|
describe('ContextManager', () => {
|
|
30
30
|
let manager: ContextManager;
|
|
@@ -36,24 +36,60 @@ describe('ContextManager', () => {
|
|
|
36
36
|
const messageStore: MessageRecord[] = [];
|
|
37
37
|
const summaryStore: any[] = [];
|
|
38
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
|
+
|
|
39
87
|
mockMessageModel = {
|
|
40
88
|
create: vi.fn((record: MessageRecord) => {
|
|
41
89
|
messageStore.push({ ...record, id: messageStore.length + 1 });
|
|
42
90
|
return Promise.resolve();
|
|
43
91
|
}),
|
|
44
|
-
select:
|
|
45
|
-
let results = [...messageStore];
|
|
46
|
-
if (where?.scene_id) {
|
|
47
|
-
results = results.filter(m => m.scene_id === where.scene_id);
|
|
48
|
-
}
|
|
49
|
-
if (options?.orderBy?.time === 'desc') {
|
|
50
|
-
results.sort((a, b) => b.time - a.time);
|
|
51
|
-
}
|
|
52
|
-
if (options?.limit) {
|
|
53
|
-
results = results.slice(0, options.limit);
|
|
54
|
-
}
|
|
55
|
-
return Promise.resolve(results);
|
|
56
|
-
}),
|
|
92
|
+
select: createChainableMock(messageStore, 'scene_id'),
|
|
57
93
|
delete: vi.fn(() => Promise.resolve(0)),
|
|
58
94
|
};
|
|
59
95
|
|
|
@@ -62,13 +98,7 @@ describe('ContextManager', () => {
|
|
|
62
98
|
summaryStore.push({ ...record, id: summaryStore.length + 1 });
|
|
63
99
|
return Promise.resolve();
|
|
64
100
|
}),
|
|
65
|
-
select:
|
|
66
|
-
let results = [...summaryStore];
|
|
67
|
-
if (where?.scene_id) {
|
|
68
|
-
results = results.filter(s => s.scene_id === where.scene_id);
|
|
69
|
-
}
|
|
70
|
-
return Promise.resolve(results);
|
|
71
|
-
}),
|
|
101
|
+
select: createChainableMock(summaryStore, 'scene_id'),
|
|
72
102
|
};
|
|
73
103
|
|
|
74
104
|
manager = new ContextManager(mockMessageModel, mockSummaryModel, {
|
|
@@ -142,12 +172,10 @@ describe('ContextManager', () => {
|
|
|
142
172
|
});
|
|
143
173
|
|
|
144
174
|
const messages = await manager.getRecentMessages('group-123');
|
|
145
|
-
|
|
175
|
+
|
|
146
176
|
expect(messages.length).toBe(2);
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
expect.objectContaining({ orderBy: { time: 'desc' } })
|
|
150
|
-
);
|
|
177
|
+
// 链式 API: select() 无参数调用
|
|
178
|
+
expect(mockMessageModel.select).toHaveBeenCalled();
|
|
151
179
|
});
|
|
152
180
|
|
|
153
181
|
it('空场景应返回空数组', async () => {
|
|
@@ -157,11 +185,9 @@ describe('ContextManager', () => {
|
|
|
157
185
|
|
|
158
186
|
it('应该限制返回数量', async () => {
|
|
159
187
|
const messages = await manager.getRecentMessages('group-123', 10);
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
expect.objectContaining({ limit: 10 })
|
|
164
|
-
);
|
|
188
|
+
|
|
189
|
+
// 链式 API: select() 无参数调用,limit 通过链式传递
|
|
190
|
+
expect(mockMessageModel.select).toHaveBeenCalled();
|
|
165
191
|
});
|
|
166
192
|
});
|
|
167
193
|
|
|
@@ -179,7 +205,7 @@ describe('ContextManager', () => {
|
|
|
179
205
|
});
|
|
180
206
|
|
|
181
207
|
const context = await manager.buildContext('group-123', 'qq');
|
|
182
|
-
|
|
208
|
+
|
|
183
209
|
expect(context.sceneId).toBe('group-123');
|
|
184
210
|
expect(context.platform).toBe('qq');
|
|
185
211
|
expect(context.chatMessages).toBeDefined();
|
|
@@ -187,7 +213,7 @@ describe('ContextManager', () => {
|
|
|
187
213
|
|
|
188
214
|
it('空场景应返回有效的空上下文', async () => {
|
|
189
215
|
const context = await manager.buildContext('empty-scene', 'qq');
|
|
190
|
-
|
|
216
|
+
|
|
191
217
|
expect(context.sceneId).toBe('empty-scene');
|
|
192
218
|
expect(context.recentMessages).toEqual([]);
|
|
193
219
|
expect(context.summaries).toEqual([]);
|
|
@@ -211,7 +237,7 @@ describe('ContextManager', () => {
|
|
|
211
237
|
];
|
|
212
238
|
|
|
213
239
|
const chatMessages = manager.formatToChatMessages([], messages);
|
|
214
|
-
|
|
240
|
+
|
|
215
241
|
expect(chatMessages.length).toBe(1);
|
|
216
242
|
expect(chatMessages[0].role).toBe('user');
|
|
217
243
|
expect(chatMessages[0].content).toContain('张三');
|
|
@@ -223,7 +249,7 @@ describe('ContextManager', () => {
|
|
|
223
249
|
const messages: MessageRecord[] = [];
|
|
224
250
|
|
|
225
251
|
const chatMessages = manager.formatToChatMessages(summaries, messages);
|
|
226
|
-
|
|
252
|
+
|
|
227
253
|
expect(chatMessages.length).toBe(1);
|
|
228
254
|
expect(chatMessages[0].role).toBe('system');
|
|
229
255
|
expect(chatMessages[0].content).toContain('这是之前的总结');
|
|
@@ -245,7 +271,7 @@ describe('ContextManager', () => {
|
|
|
245
271
|
];
|
|
246
272
|
|
|
247
273
|
const chatMessages = manager.formatToChatMessages([], messages);
|
|
248
|
-
|
|
274
|
+
|
|
249
275
|
expect(chatMessages[0].role).toBe('assistant');
|
|
250
276
|
});
|
|
251
277
|
});
|
|
@@ -254,7 +280,7 @@ describe('ContextManager', () => {
|
|
|
254
280
|
it('应该估算中文文本的 token 数量', () => {
|
|
255
281
|
const text = '你好世界'; // 4 个中文字符
|
|
256
282
|
const tokens = manager.estimateTokens(text);
|
|
257
|
-
|
|
283
|
+
|
|
258
284
|
// 中文约 2 tokens/字
|
|
259
285
|
expect(tokens).toBeGreaterThanOrEqual(8);
|
|
260
286
|
});
|
|
@@ -262,7 +288,7 @@ describe('ContextManager', () => {
|
|
|
262
288
|
it('应该估算英文文本的 token 数量', () => {
|
|
263
289
|
const text = 'hello world'; // 11 个字符
|
|
264
290
|
const tokens = manager.estimateTokens(text);
|
|
265
|
-
|
|
291
|
+
|
|
266
292
|
// 英文约 0.25 tokens/字符
|
|
267
293
|
expect(tokens).toBeGreaterThanOrEqual(2);
|
|
268
294
|
});
|
|
@@ -270,7 +296,7 @@ describe('ContextManager', () => {
|
|
|
270
296
|
it('应该估算混合文本的 token 数量', () => {
|
|
271
297
|
const text = 'Hello 世界';
|
|
272
298
|
const tokens = manager.estimateTokens(text);
|
|
273
|
-
|
|
299
|
+
|
|
274
300
|
expect(tokens).toBeGreaterThan(0);
|
|
275
301
|
});
|
|
276
302
|
});
|
|
@@ -325,9 +351,9 @@ describe('ContextManager', () => {
|
|
|
325
351
|
};
|
|
326
352
|
|
|
327
353
|
manager.setAIProvider(mockProvider as any);
|
|
328
|
-
|
|
354
|
+
|
|
329
355
|
const result = await manager.summarize('group-123');
|
|
330
|
-
|
|
356
|
+
|
|
331
357
|
expect(mockProvider.chat).toHaveBeenCalled();
|
|
332
358
|
});
|
|
333
359
|
});
|
|
@@ -346,14 +372,14 @@ describe('ContextManager', () => {
|
|
|
346
372
|
});
|
|
347
373
|
|
|
348
374
|
const stats = await manager.getSceneStats('group-123');
|
|
349
|
-
|
|
375
|
+
|
|
350
376
|
expect(stats.messageCount).toBeGreaterThan(0);
|
|
351
377
|
expect(stats.summaryCount).toBe(0);
|
|
352
378
|
});
|
|
353
379
|
|
|
354
380
|
it('空场景应返回零统计', async () => {
|
|
355
381
|
const stats = await manager.getSceneStats('empty');
|
|
356
|
-
|
|
382
|
+
|
|
357
383
|
expect(stats.messageCount).toBe(0);
|
|
358
384
|
expect(stats.summaryCount).toBe(0);
|
|
359
385
|
});
|
|
@@ -381,7 +407,7 @@ describe('createContextManager', () => {
|
|
|
381
407
|
const manager = createContextManager(mockModel, mockModel, {
|
|
382
408
|
maxRecentMessages: 50,
|
|
383
409
|
});
|
|
384
|
-
|
|
410
|
+
|
|
385
411
|
expect(manager).toBeInstanceOf(ContextManager);
|
|
386
412
|
});
|
|
387
413
|
});
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ConversationMemory 测试
|
|
3
|
+
*/
|
|
4
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
5
|
+
import { ConversationMemory } from '@zhin.js/ai';
|
|
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
|
+
});
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AI Output 模块测试
|
|
3
|
+
*/
|
|
4
|
+
import { describe, it, expect } from 'vitest';
|
|
5
|
+
import { parseOutput, renderToPlainText, renderToSatori } from '@zhin.js/ai';
|
|
6
|
+
|
|
7
|
+
describe('parseOutput', () => {
|
|
8
|
+
it('纯文本应返回单个 TextElement', () => {
|
|
9
|
+
const result = parseOutput('Hello, world!');
|
|
10
|
+
expect(result).toHaveLength(1);
|
|
11
|
+
expect(result[0]).toMatchObject({ type: 'text', content: 'Hello, world!', format: 'markdown' });
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('空字符串应返回空白 TextElement', () => {
|
|
15
|
+
const result = parseOutput('');
|
|
16
|
+
expect(result).toHaveLength(1);
|
|
17
|
+
expect(result[0]).toMatchObject({ type: 'text', content: '', format: 'plain' });
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('应解析图片 ', () => {
|
|
21
|
+
const result = parseOutput('看看这张图 ');
|
|
22
|
+
expect(result).toHaveLength(2);
|
|
23
|
+
expect(result[0]).toMatchObject({ type: 'text', content: '看看这张图' });
|
|
24
|
+
expect(result[1]).toMatchObject({ type: 'image', url: 'https://example.com/cat.png', alt: 'cat' });
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('应解析音频 [audio](url)', () => {
|
|
28
|
+
const result = parseOutput('[audio](https://example.com/song.mp3)');
|
|
29
|
+
expect(result).toHaveLength(1);
|
|
30
|
+
expect(result[0]).toMatchObject({ type: 'audio', url: 'https://example.com/song.mp3' });
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('应解析视频 [video](url)', () => {
|
|
34
|
+
const result = parseOutput('这是视频 [video](https://example.com/video.mp4)');
|
|
35
|
+
expect(result).toHaveLength(2);
|
|
36
|
+
expect(result[1]).toMatchObject({ type: 'video', url: 'https://example.com/video.mp4' });
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('应解析文件 [file:name](url)', () => {
|
|
40
|
+
const result = parseOutput('[file:report.pdf](https://example.com/report.pdf)');
|
|
41
|
+
expect(result).toHaveLength(1);
|
|
42
|
+
expect(result[0]).toMatchObject({ type: 'file', url: 'https://example.com/report.pdf', name: 'report.pdf' });
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('应解析 card 代码块', () => {
|
|
46
|
+
const raw = '```card\n{"title":"天气","description":"晴天"}\n```';
|
|
47
|
+
const result = parseOutput(raw);
|
|
48
|
+
expect(result.some(el => el.type === 'card')).toBe(true);
|
|
49
|
+
const card = result.find(el => el.type === 'card')!;
|
|
50
|
+
expect(card).toMatchObject({ type: 'card', title: '天气', description: '晴天' });
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('无效 card JSON 应降级为文本', () => {
|
|
54
|
+
const raw = '```card\n{invalid json}\n```';
|
|
55
|
+
const result = parseOutput(raw);
|
|
56
|
+
expect(result.some(el => el.type === 'text')).toBe(true);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('应处理混合内容', () => {
|
|
60
|
+
const raw = '看看这个  更多文字';
|
|
61
|
+
const result = parseOutput(raw);
|
|
62
|
+
expect(result.length).toBeGreaterThanOrEqual(2);
|
|
63
|
+
expect(result.some(el => el.type === 'image')).toBe(true);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe('renderToPlainText', () => {
|
|
68
|
+
it('应渲染文本元素', () => {
|
|
69
|
+
expect(renderToPlainText([{ type: 'text', content: 'Hello' }])).toBe('Hello');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('应渲染图片为 [图片: alt]', () => {
|
|
73
|
+
const result = renderToPlainText([{ type: 'image', url: 'url', alt: '猫' }]);
|
|
74
|
+
expect(result).toBe('[图片: 猫]');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('应渲染音频的 fallbackText', () => {
|
|
78
|
+
const result = renderToPlainText([{ type: 'audio', url: 'url', fallbackText: '一段音乐' }]);
|
|
79
|
+
expect(result).toBe('一段音乐');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('应渲染卡片标题和字段', () => {
|
|
83
|
+
const result = renderToPlainText([{
|
|
84
|
+
type: 'card',
|
|
85
|
+
title: '天气',
|
|
86
|
+
description: '晴天',
|
|
87
|
+
fields: [{ label: '温度', value: '25°C' }],
|
|
88
|
+
}]);
|
|
89
|
+
expect(result).toContain('天气');
|
|
90
|
+
expect(result).toContain('晴天');
|
|
91
|
+
expect(result).toContain('温度: 25°C');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('应渲染文件为 [文件: name]', () => {
|
|
95
|
+
const result = renderToPlainText([{ type: 'file', url: 'url', name: 'report.pdf' }]);
|
|
96
|
+
expect(result).toContain('[文件: report.pdf]');
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe('renderToSatori', () => {
|
|
101
|
+
it('应将文本包裹在 <p> 标签中', () => {
|
|
102
|
+
const result = renderToSatori([{ type: 'text', content: 'Hello' }]);
|
|
103
|
+
expect(result).toBe('<p>Hello</p>');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('应生成 <img> 标签', () => {
|
|
107
|
+
const result = renderToSatori([{ type: 'image', url: 'https://img.png', alt: '猫' }]);
|
|
108
|
+
expect(result).toContain('<img');
|
|
109
|
+
expect(result).toContain('alt="猫"');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('应转义 HTML 特殊字符', () => {
|
|
113
|
+
const result = renderToSatori([{ type: 'text', content: '<script>alert("xss")</script>' }]);
|
|
114
|
+
expect(result).not.toContain('<script>');
|
|
115
|
+
expect(result).toContain('<script>');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('应渲染卡片结构', () => {
|
|
119
|
+
const result = renderToSatori([{
|
|
120
|
+
type: 'card',
|
|
121
|
+
title: '标题',
|
|
122
|
+
description: '描述',
|
|
123
|
+
}]);
|
|
124
|
+
expect(result).toContain('<div class="card">');
|
|
125
|
+
expect(result).toContain('<h3>标题</h3>');
|
|
126
|
+
expect(result).toContain('<p>描述</p>');
|
|
127
|
+
});
|
|
128
|
+
});
|