@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.
Files changed (94) hide show
  1. package/CHANGELOG.md +2 -7
  2. package/lib/agent.d.ts +54 -6
  3. package/lib/agent.d.ts.map +1 -1
  4. package/lib/agent.js +468 -116
  5. package/lib/agent.js.map +1 -1
  6. package/lib/compaction.d.ts +132 -0
  7. package/lib/compaction.d.ts.map +1 -0
  8. package/lib/compaction.js +370 -0
  9. package/lib/compaction.js.map +1 -0
  10. package/lib/context-manager.d.ts.map +1 -1
  11. package/lib/context-manager.js +10 -3
  12. package/lib/context-manager.js.map +1 -1
  13. package/lib/conversation-memory.d.ts +192 -0
  14. package/lib/conversation-memory.d.ts.map +1 -0
  15. package/lib/conversation-memory.js +619 -0
  16. package/lib/conversation-memory.js.map +1 -0
  17. package/lib/index.d.ts +25 -163
  18. package/lib/index.d.ts.map +1 -1
  19. package/lib/index.js +24 -1122
  20. package/lib/index.js.map +1 -1
  21. package/lib/output.d.ts +93 -0
  22. package/lib/output.d.ts.map +1 -0
  23. package/lib/output.js +176 -0
  24. package/lib/output.js.map +1 -0
  25. package/lib/providers/anthropic.d.ts +7 -0
  26. package/lib/providers/anthropic.d.ts.map +1 -1
  27. package/lib/providers/anthropic.js +5 -0
  28. package/lib/providers/anthropic.js.map +1 -1
  29. package/lib/providers/ollama.d.ts +10 -0
  30. package/lib/providers/ollama.d.ts.map +1 -1
  31. package/lib/providers/ollama.js +19 -4
  32. package/lib/providers/ollama.js.map +1 -1
  33. package/lib/providers/openai.d.ts +7 -0
  34. package/lib/providers/openai.d.ts.map +1 -1
  35. package/lib/providers/openai.js +11 -0
  36. package/lib/providers/openai.js.map +1 -1
  37. package/lib/rate-limiter.d.ts +38 -0
  38. package/lib/rate-limiter.d.ts.map +1 -0
  39. package/lib/rate-limiter.js +86 -0
  40. package/lib/rate-limiter.js.map +1 -0
  41. package/lib/session.d.ts +7 -0
  42. package/lib/session.d.ts.map +1 -1
  43. package/lib/session.js +47 -18
  44. package/lib/session.js.map +1 -1
  45. package/lib/storage.d.ts +68 -0
  46. package/lib/storage.d.ts.map +1 -0
  47. package/lib/storage.js +105 -0
  48. package/lib/storage.js.map +1 -0
  49. package/lib/tone-detector.d.ts +19 -0
  50. package/lib/tone-detector.d.ts.map +1 -0
  51. package/lib/tone-detector.js +72 -0
  52. package/lib/tone-detector.js.map +1 -0
  53. package/lib/types.d.ts +84 -8
  54. package/lib/types.d.ts.map +1 -1
  55. package/package.json +13 -42
  56. package/src/agent.ts +518 -135
  57. package/src/compaction.ts +529 -0
  58. package/src/context-manager.ts +10 -9
  59. package/src/conversation-memory.ts +816 -0
  60. package/src/index.ts +121 -1406
  61. package/src/output.ts +261 -0
  62. package/src/providers/anthropic.ts +4 -0
  63. package/src/providers/ollama.ts +23 -4
  64. package/src/providers/openai.ts +8 -1
  65. package/src/rate-limiter.ts +129 -0
  66. package/src/session.ts +47 -18
  67. package/src/storage.ts +135 -0
  68. package/src/tone-detector.ts +89 -0
  69. package/src/types.ts +95 -6
  70. package/tests/agent.test.ts +123 -70
  71. package/tests/compaction.test.ts +310 -0
  72. package/tests/context-manager.test.ts +73 -47
  73. package/tests/conversation-memory.test.ts +128 -0
  74. package/tests/output.test.ts +128 -0
  75. package/tests/providers.test.ts +574 -0
  76. package/tests/rate-limiter.test.ts +108 -0
  77. package/tests/session.test.ts +139 -48
  78. package/tests/setup.ts +82 -240
  79. package/tests/storage.test.ts +224 -0
  80. package/tests/tone-detector.test.ts +80 -0
  81. package/tsconfig.json +4 -5
  82. package/vitest.setup.ts +1 -0
  83. package/README.md +0 -564
  84. package/TOOLS.md +0 -294
  85. package/lib/tools.d.ts +0 -45
  86. package/lib/tools.d.ts.map +0 -1
  87. package/lib/tools.js +0 -194
  88. package/lib/tools.js.map +0 -1
  89. package/src/tools.ts +0 -205
  90. package/tests/ai-trigger.test.ts +0 -369
  91. package/tests/integration.test.ts +0 -596
  92. package/tests/providers.integration.test.ts +0 -227
  93. package/tests/tool.test.ts +0 -800
  94. 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/core', async (importOriginal) => {
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 '../src/context-manager.js';
27
- import type { MessageRecord } from '../src/context-manager.js';
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: vi.fn((where?: any, options?: any) => {
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: vi.fn((where?: any) => {
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
- expect(mockMessageModel.select).toHaveBeenCalledWith(
148
- { scene_id: 'group-123' },
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
- expect(mockMessageModel.select).toHaveBeenCalledWith(
162
- { scene_id: 'group-123' },
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('应解析图片 ![alt](url)', () => {
21
+ const result = parseOutput('看看这张图 ![cat](https://example.com/cat.png)');
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 = '看看这个 ![](https://img.png) 更多文字';
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('&lt;script&gt;');
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
+ });