@zhin.js/ai 0.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.
@@ -0,0 +1,596 @@
1
+ /**
2
+ * AI 模块集成测试
3
+ *
4
+ * 完整测试环境,包括:
5
+ * 1. AI 服务初始化
6
+ * 2. 工具服务功能
7
+ * 3. AI 触发中间件
8
+ * 4. 内置工具
9
+ */
10
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
11
+
12
+ // Mock Logger first
13
+ vi.mock('@zhin.js/core', async (importOriginal) => {
14
+ const original = await importOriginal() as any;
15
+ return {
16
+ ...original,
17
+ defineModel: vi.fn(), // Mock defineModel
18
+ getPlugin: vi.fn(() => ({
19
+ name: 'test-plugin',
20
+ root: { inject: vi.fn() },
21
+ logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
22
+ onDispose: vi.fn(),
23
+ collectAllTools: vi.fn(() => []),
24
+ })),
25
+ usePlugin: vi.fn(() => ({
26
+ name: 'test-plugin',
27
+ root: {
28
+ inject: vi.fn(),
29
+ addMiddleware: vi.fn(),
30
+ },
31
+ provide: vi.fn(),
32
+ useContext: vi.fn(),
33
+ addMiddleware: vi.fn(),
34
+ defineModel: vi.fn(),
35
+ logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
36
+ })),
37
+ Logger: class {
38
+ debug = vi.fn();
39
+ info = vi.fn();
40
+ warn = vi.fn();
41
+ error = vi.fn();
42
+ },
43
+ segment: {
44
+ toString: (elements: any[]) => {
45
+ if (!Array.isArray(elements)) return String(elements);
46
+ return elements.map(el => {
47
+ if (typeof el === 'string') return el;
48
+ if (el.type === 'text') return el.data?.text || '';
49
+ if (el.type === 'at') return `<at user_id="${el.data?.user_id || el.data?.qq}"/>`;
50
+ if (el.type === 'image') return `<image url="${el.data?.url}"/>`;
51
+ return '';
52
+ }).join('');
53
+ },
54
+ from: (str: string) => {
55
+ if (!str) return [];
56
+ return [{ type: 'text', data: { text: str } }];
57
+ },
58
+ },
59
+ };
60
+ });
61
+
62
+ // Import after mocking
63
+ import { AIService } from '../src/index.js';
64
+ import { createToolService, ZhinTool, shouldTriggerAI, inferSenderPermissions } from '@zhin.js/core';
65
+ import { calculatorTool, timeTool, getBuiltinTools } from '../src/tools.js';
66
+ import type { Tool, ToolContext } from '@zhin.js/core';
67
+
68
+ // ============================================================================
69
+ // AI Service 测试
70
+ // ============================================================================
71
+
72
+ describe('AI Service 集成测试', () => {
73
+ let aiService: AIService;
74
+
75
+ beforeEach(() => {
76
+ vi.clearAllMocks();
77
+ aiService = new AIService({
78
+ defaultProvider: 'mock',
79
+ sessions: { maxHistory: 10 },
80
+ });
81
+ });
82
+
83
+ afterEach(() => {
84
+ aiService?.dispose();
85
+ });
86
+
87
+ describe('服务初始化', () => {
88
+ it('应该创建 AI 服务实例', () => {
89
+ expect(aiService).toBeDefined();
90
+ expect(aiService.sessions).toBeDefined();
91
+ });
92
+
93
+ it('没有配置提供商时 isReady 应返回 false', () => {
94
+ expect(aiService.isReady()).toBe(false);
95
+ });
96
+
97
+ it('应该返回空的提供商列表', () => {
98
+ expect(aiService.listProviders()).toEqual([]);
99
+ });
100
+
101
+ it('获取不存在的提供商应抛出错误', () => {
102
+ expect(() => aiService.getProvider('nonexistent')).toThrow();
103
+ });
104
+
105
+ it('应该根据配置初始化所有 Provider', () => {
106
+ const fullService = new AIService({
107
+ providers: {
108
+ openai: { apiKey: 'sk-test' },
109
+ anthropic: { apiKey: 'sk-ant-test' },
110
+ deepseek: { apiKey: 'sk-deepseek' },
111
+ moonshot: { apiKey: 'sk-moonshot' },
112
+ zhipu: { apiKey: 'sk-zhipu' },
113
+ ollama: { baseUrl: 'http://localhost:11434' },
114
+ },
115
+ });
116
+
117
+ const providers = fullService.listProviders();
118
+ expect(providers).toContain('openai');
119
+ expect(providers).toContain('anthropic');
120
+ expect(providers).toContain('deepseek');
121
+ expect(providers).toContain('moonshot');
122
+ expect(providers).toContain('zhipu');
123
+ expect(providers).toContain('ollama');
124
+ expect(providers).toHaveLength(6);
125
+
126
+ fullService.dispose();
127
+ });
128
+
129
+ it('应该只初始化有 apiKey 的 Provider', () => {
130
+ const partialService = new AIService({
131
+ providers: {
132
+ openai: { apiKey: 'sk-test' },
133
+ deepseek: {}, // 没有 apiKey
134
+ moonshot: { apiKey: 'sk-moonshot' },
135
+ },
136
+ });
137
+
138
+ const providers = partialService.listProviders();
139
+ expect(providers).toContain('openai');
140
+ expect(providers).toContain('moonshot');
141
+ expect(providers).not.toContain('deepseek');
142
+ expect(providers).toHaveLength(2);
143
+
144
+ partialService.dispose();
145
+ });
146
+ });
147
+
148
+ describe('配置管理', () => {
149
+ it('应该返回会话配置', () => {
150
+ const config = aiService.getSessionConfig();
151
+ expect(config.maxHistory).toBe(10);
152
+ });
153
+
154
+ it('应该返回上下文配置', () => {
155
+ const config = aiService.getContextConfig();
156
+ expect(config).toBeDefined();
157
+ });
158
+
159
+ it('应该返回触发器配置', () => {
160
+ const config = aiService.getTriggerConfig();
161
+ expect(config).toBeDefined();
162
+ });
163
+ });
164
+
165
+ describe('工具管理', () => {
166
+ it('应该收集内置工具', () => {
167
+ const tools = aiService.collectAllTools();
168
+ expect(Array.isArray(tools)).toBe(true);
169
+ const names = tools.map(t => t.name);
170
+ expect(names).toContain('calculator');
171
+ expect(names).toContain('get_time');
172
+ });
173
+
174
+ it('应该注册自定义工具', () => {
175
+ const customTool: Tool = {
176
+ name: 'custom_tool',
177
+ description: '自定义工具',
178
+ parameters: { type: 'object', properties: {} },
179
+ execute: async () => 'result',
180
+ };
181
+
182
+ const dispose = aiService.registerTool(customTool);
183
+
184
+ const tools = aiService.collectAllTools();
185
+ expect(tools.some(t => t.name === 'custom_tool')).toBe(true);
186
+
187
+ dispose();
188
+ const toolsAfter = aiService.collectAllTools();
189
+ expect(toolsAfter.some(t => t.name === 'custom_tool')).toBe(false);
190
+ });
191
+ });
192
+
193
+ describe('dispose', () => {
194
+ it('应该正确清理资源', () => {
195
+ aiService.dispose();
196
+ expect(aiService.listProviders()).toEqual([]);
197
+ });
198
+ });
199
+ });
200
+
201
+ // ============================================================================
202
+ // Tool Service 测试
203
+ // ============================================================================
204
+
205
+ describe('Tool Service 集成测试', () => {
206
+ let toolService: ReturnType<typeof createToolService>;
207
+
208
+ beforeEach(() => {
209
+ vi.clearAllMocks();
210
+ toolService = createToolService();
211
+ });
212
+
213
+ describe('Context 创建', () => {
214
+ it('应该创建正确的 Context', () => {
215
+ expect(toolService.name).toBe('tool');
216
+ expect(toolService.description).toBeDefined();
217
+ expect(toolService.value).toBeDefined();
218
+ });
219
+ });
220
+
221
+ describe('工具注册', () => {
222
+ it('应该注册 Tool 对象', () => {
223
+ const service = toolService.value!;
224
+ const tool: Tool = {
225
+ name: 'test_tool',
226
+ description: '测试工具',
227
+ parameters: { type: 'object', properties: {} },
228
+ execute: async () => 'result',
229
+ };
230
+
231
+ const dispose = service.add(tool, 'test-plugin', false);
232
+
233
+ expect(service.get('test_tool')).toBeDefined();
234
+ expect(service.getAll()).toHaveLength(1);
235
+
236
+ dispose();
237
+ expect(service.get('test_tool')).toBeUndefined();
238
+ });
239
+
240
+ it('应该注册 ZhinTool 实例', () => {
241
+ const service = toolService.value!;
242
+ const zhinTool = new ZhinTool('zhin_tool')
243
+ .desc('ZhinTool 测试')
244
+ .execute(async () => 'result');
245
+
246
+ service.add(zhinTool.toTool(), 'test-plugin', false);
247
+
248
+ const registered = service.get('zhin_tool');
249
+ expect(registered).toBeDefined();
250
+ expect(registered?.description).toBe('ZhinTool 测试');
251
+ });
252
+
253
+ it('应该正确移除工具', () => {
254
+ const service = toolService.value!;
255
+ const tool: Tool = {
256
+ name: 'removable',
257
+ description: '',
258
+ parameters: { type: 'object', properties: {} },
259
+ execute: async () => '',
260
+ };
261
+
262
+ service.add(tool, 'test', false);
263
+ expect(service.remove('removable')).toBe(true);
264
+ expect(service.remove('removable')).toBe(false);
265
+ });
266
+
267
+ it('应该自动添加来源标识', () => {
268
+ const service = toolService.value!;
269
+ const tool: Tool = {
270
+ name: 'sourced_tool',
271
+ description: '',
272
+ parameters: { type: 'object', properties: {} },
273
+ execute: async () => '',
274
+ };
275
+
276
+ service.add(tool, 'my-plugin', false);
277
+
278
+ const registered = service.get('sourced_tool');
279
+ expect(registered?.source).toContain('my-plugin');
280
+ expect(registered?.tags).toContain('my-plugin');
281
+ });
282
+ });
283
+
284
+ describe('工具执行', () => {
285
+ it('应该执行已注册的工具', async () => {
286
+ const service = toolService.value!;
287
+ const tool: Tool = {
288
+ name: 'executable',
289
+ description: '',
290
+ parameters: { type: 'object', properties: {} },
291
+ execute: async (args) => `received: ${JSON.stringify(args)}`,
292
+ };
293
+
294
+ service.add(tool, 'test', false);
295
+
296
+ const result = await service.execute('executable', { key: 'value' });
297
+ expect(result).toBe('received: {"key":"value"}');
298
+ });
299
+
300
+ it('执行不存在的工具应抛出错误', async () => {
301
+ const service = toolService.value!;
302
+
303
+ await expect(service.execute('nonexistent', {})).rejects.toThrow('not found');
304
+ });
305
+ });
306
+
307
+ describe('标签过滤', () => {
308
+ it('应该按标签过滤工具', () => {
309
+ const service = toolService.value!;
310
+
311
+ service.add({
312
+ name: 'tagged1',
313
+ description: '',
314
+ parameters: { type: 'object', properties: {} },
315
+ tags: ['utility'],
316
+ execute: async () => '',
317
+ }, 'test', false);
318
+
319
+ service.add({
320
+ name: 'tagged2',
321
+ description: '',
322
+ parameters: { type: 'object', properties: {} },
323
+ tags: ['helper'],
324
+ execute: async () => '',
325
+ }, 'test', false);
326
+
327
+ const utilityTools = service.getByTags(['utility']);
328
+ expect(utilityTools).toHaveLength(1);
329
+ expect(utilityTools[0].name).toBe('tagged1');
330
+ });
331
+ });
332
+
333
+ describe('上下文过滤', () => {
334
+ it('应该按平台过滤工具', () => {
335
+ const service = toolService.value!;
336
+
337
+ service.add({
338
+ name: 'qq_only',
339
+ description: '',
340
+ parameters: { type: 'object', properties: {} },
341
+ platforms: ['qq'],
342
+ execute: async () => '',
343
+ }, 'test', false);
344
+
345
+ service.add({
346
+ name: 'all_platforms',
347
+ description: '',
348
+ parameters: { type: 'object', properties: {} },
349
+ execute: async () => '',
350
+ }, 'test', false);
351
+
352
+ const qqContext: ToolContext = { platform: 'qq' };
353
+ const telegramContext: ToolContext = { platform: 'telegram' };
354
+
355
+ const allTools = service.getAll();
356
+
357
+ const qqFiltered = service.filterByContext(allTools, qqContext);
358
+ expect(qqFiltered.some(t => t.name === 'qq_only')).toBe(true);
359
+ expect(qqFiltered.some(t => t.name === 'all_platforms')).toBe(true);
360
+
361
+ const telegramFiltered = service.filterByContext(allTools, telegramContext);
362
+ expect(telegramFiltered.some(t => t.name === 'qq_only')).toBe(false);
363
+ expect(telegramFiltered.some(t => t.name === 'all_platforms')).toBe(true);
364
+ });
365
+
366
+ it('应该按权限过滤工具', () => {
367
+ const service = toolService.value!;
368
+
369
+ service.add({
370
+ name: 'admin_tool',
371
+ description: '',
372
+ parameters: { type: 'object', properties: {} },
373
+ permissionLevel: 'bot_admin',
374
+ execute: async () => '',
375
+ }, 'test', false);
376
+
377
+ const allTools = service.getAll();
378
+
379
+ const userContext: ToolContext = {};
380
+ const adminContext: ToolContext = { isBotAdmin: true };
381
+
382
+ const userFiltered = service.filterByContext(allTools, userContext);
383
+ expect(userFiltered.some(t => t.name === 'admin_tool')).toBe(false);
384
+
385
+ const adminFiltered = service.filterByContext(allTools, adminContext);
386
+ expect(adminFiltered.some(t => t.name === 'admin_tool')).toBe(true);
387
+ });
388
+ });
389
+ });
390
+
391
+ // ============================================================================
392
+ // AI Trigger 工具函数测试
393
+ // ============================================================================
394
+
395
+ describe('AI Trigger 工具函数测试', () => {
396
+ function createMockMessage(options: {
397
+ content: string | any[];
398
+ bot?: string;
399
+ channelType?: 'private' | 'group' | 'channel';
400
+ senderId?: string;
401
+ senderPermissions?: string[];
402
+ }) {
403
+ const content = typeof options.content === 'string'
404
+ ? [{ type: 'text', data: { text: options.content } }]
405
+ : options.content;
406
+
407
+ return {
408
+ $content: content,
409
+ $bot: options.bot || 'bot123',
410
+ $channel: options.channelType ? { type: options.channelType, id: 'channel1' } : null,
411
+ $sender: {
412
+ id: options.senderId || 'user1',
413
+ permissions: options.senderPermissions || [],
414
+ },
415
+ $adapter: 'test',
416
+ };
417
+ }
418
+
419
+ describe('shouldTriggerAI', () => {
420
+ it('应该检测前缀触发', () => {
421
+ const message = createMockMessage({ content: '# 你好' });
422
+ const result = shouldTriggerAI(message as any, { prefixes: ['#'] });
423
+
424
+ expect(result.triggered).toBe(true);
425
+ expect(result.content).toBe('你好');
426
+ });
427
+
428
+ it('没有匹配前缀时不应触发', () => {
429
+ const message = createMockMessage({ content: '普通消息' });
430
+ const result = shouldTriggerAI(message as any, { prefixes: ['#'] });
431
+
432
+ expect(result.triggered).toBe(false);
433
+ });
434
+
435
+ it('私聊应该直接触发', () => {
436
+ const message = createMockMessage({ content: '你好', channelType: 'private' });
437
+ const result = shouldTriggerAI(message as any, { respondToPrivate: true });
438
+
439
+ expect(result.triggered).toBe(true);
440
+ expect(result.content).toBe('你好');
441
+ });
442
+
443
+ it('禁用时不应触发', () => {
444
+ const message = createMockMessage({ content: '# 你好' });
445
+ const result = shouldTriggerAI(message as any, { enabled: false, prefixes: ['#'] });
446
+
447
+ expect(result.triggered).toBe(false);
448
+ });
449
+ });
450
+
451
+ describe('inferSenderPermissions', () => {
452
+ it('应该正确推断 owner 权限', () => {
453
+ const message = createMockMessage({ content: 'test', senderId: 'owner1' });
454
+ const result = inferSenderPermissions(message as any, { owners: ['owner1'] });
455
+
456
+ expect(result.isOwner).toBe(true);
457
+ expect(result.permissionLevel).toBe('owner');
458
+ });
459
+
460
+ it('默认应该是 user 权限', () => {
461
+ const message = createMockMessage({ content: 'test' });
462
+ const result = inferSenderPermissions(message as any, {});
463
+
464
+ expect(result.permissionLevel).toBe('user');
465
+ });
466
+ });
467
+ });
468
+
469
+ // ============================================================================
470
+ // 内置工具测试
471
+ // ============================================================================
472
+
473
+ describe('内置工具完整测试', () => {
474
+ describe('计算器工具', () => {
475
+ const calculator = calculatorTool.toTool();
476
+
477
+ it('应该正确处理嵌套括号', async () => {
478
+ const result = await calculator.execute({ expression: '((2 + 3) * (4 - 1))' });
479
+ expect(result.result).toBe(15);
480
+ });
481
+
482
+ it('应该处理负数', async () => {
483
+ const result = await calculator.execute({ expression: '-5 + 3' });
484
+ expect(result.result).toBe(-2);
485
+ });
486
+
487
+ it('应该处理小数', async () => {
488
+ const result = await calculator.execute({ expression: '1.5 + 2.5' });
489
+ expect(result.result).toBe(4);
490
+ });
491
+
492
+ it('应该处理科学计数法', async () => {
493
+ const result = await calculator.execute({ expression: '1e2 + 50' });
494
+ expect(result.result).toBe(150);
495
+ });
496
+ });
497
+
498
+ describe('时间工具', () => {
499
+ const timeToolObj = timeTool.toTool();
500
+
501
+ it('应该返回 ISO 格式时间', async () => {
502
+ const result = await timeToolObj.execute({ format: 'timestamp' });
503
+ expect(result.iso).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/);
504
+ });
505
+
506
+ it('时间戳应该是合理的', async () => {
507
+ const result = await timeToolObj.execute({});
508
+ const now = Date.now();
509
+ expect(Math.abs(result.timestamp - now)).toBeLessThan(1000);
510
+ });
511
+ });
512
+
513
+ describe('getBuiltinTools', () => {
514
+ it('应该返回内置工具列表', () => {
515
+ const tools = getBuiltinTools();
516
+ expect(Array.isArray(tools)).toBe(true);
517
+ expect(tools.length).toBeGreaterThan(0);
518
+
519
+ const names = tools.map(t => t.name);
520
+ expect(names).toContain('calculator');
521
+ expect(names).toContain('get_time');
522
+ });
523
+ });
524
+ });
525
+
526
+ // ============================================================================
527
+ // ZhinTool 完整流程测试
528
+ // ============================================================================
529
+
530
+ describe('ZhinTool 完整流程', () => {
531
+ it('应该支持完整的工具定义流程', async () => {
532
+ const tool = new ZhinTool('complete_tool')
533
+ .desc('完整测试工具')
534
+ .param('required_param', { type: 'string', description: '必填参数' }, true)
535
+ .param('optional_param', { type: 'number', description: '可选参数' }, false)
536
+ .platform('qq', 'telegram')
537
+ .scope('group', 'private')
538
+ .permission('user')
539
+ .tag('test', 'example')
540
+ .usage('这是使用说明')
541
+ .examples('/complete_tool arg1', '/complete_tool arg1 123')
542
+ .alias('ct')
543
+ .execute(async (args, ctx) => {
544
+ return {
545
+ received: args,
546
+ platform: ctx?.platform,
547
+ };
548
+ })
549
+ .action(async (message, result) => {
550
+ return `Command executed: ${result.params.required_param}`;
551
+ });
552
+
553
+ // 转换为 Tool
554
+ const toolObj = tool.toTool();
555
+
556
+ // 验证基本属性
557
+ expect(toolObj.name).toBe('complete_tool');
558
+ expect(toolObj.description).toBe('完整测试工具');
559
+ expect(toolObj.platforms).toEqual(['qq', 'telegram']);
560
+ expect(toolObj.scopes).toEqual(['group', 'private']);
561
+ expect(toolObj.tags).toContain('test');
562
+ expect(toolObj.tags).toContain('example');
563
+
564
+ // 验证参数
565
+ expect(toolObj.parameters.properties).toHaveProperty('required_param');
566
+ expect(toolObj.parameters.properties).toHaveProperty('optional_param');
567
+ expect(toolObj.parameters.required).toContain('required_param');
568
+
569
+ // 验证命令配置
570
+ expect(toolObj.command).not.toBe(false);
571
+ expect((toolObj.command as any).usage).toContain('这是使用说明');
572
+ expect((toolObj.command as any).examples).toContain('/complete_tool arg1');
573
+ expect((toolObj.command as any).alias).toContain('ct');
574
+
575
+ // 验证执行
576
+ const result = await toolObj.execute(
577
+ { required_param: 'test', optional_param: 42 },
578
+ { platform: 'qq' }
579
+ );
580
+
581
+ expect(result.received.required_param).toBe('test');
582
+ expect(result.received.optional_param).toBe(42);
583
+ expect(result.platform).toBe('qq');
584
+
585
+ // 验证 JSON 输出
586
+ const json = tool.toJSON();
587
+ expect(json.name).toBe('complete_tool');
588
+ expect(json).not.toHaveProperty('execute');
589
+
590
+ // 验证帮助信息
591
+ const help = tool.help;
592
+ expect(help).toContain('complete_tool');
593
+ expect(help).toContain('必填参数');
594
+ expect(help).toContain('可选参数');
595
+ });
596
+ });