@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,128 @@
1
+ /**
2
+ * AI Output 模块测试
3
+ */
4
+ import { describe, it, expect } from 'vitest';
5
+ import { parseOutput, renderToPlainText, renderToSatori } from '../../src/ai/output.js';
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
+ });
@@ -0,0 +1,227 @@
1
+ /**
2
+ * AI Providers 集成测试(真实 API 调用)
3
+ *
4
+ * 使用 test-bot 的环境配置进行真实 API 测试
5
+ *
6
+ * 运行方式(需要网络权限):
7
+ * pnpm test packages/ai/tests/providers.integration.test.ts
8
+ *
9
+ * 注意:此测试需要网络访问,在 sandbox 中会自动跳过
10
+ */
11
+ import { describe, it, expect, beforeAll } from 'vitest';
12
+ import { OllamaProvider } from '../../src/ai/providers/ollama.js';
13
+ import type { ChatMessage } from '@zhin.js/core';
14
+
15
+ // Ollama 配置
16
+ const OLLAMA_CONFIG = {
17
+ baseUrl: 'https://ollama.l2cl.link',
18
+ models: ['qwen2.5:7b', 'qwen3:8b'],
19
+ };
20
+
21
+ // 全局状态
22
+ let canRun = false;
23
+ let provider: OllamaProvider | null = null;
24
+
25
+ // 跳过检查
26
+ const skipIfNoNetwork = (ctx: any) => {
27
+ if (!canRun) {
28
+ ctx.skip();
29
+ return true;
30
+ }
31
+ return false;
32
+ };
33
+
34
+ beforeAll(async () => {
35
+ try {
36
+ const controller = new AbortController();
37
+ const timeout = setTimeout(() => controller.abort(), 5000);
38
+ const response = await globalThis.fetch(`${OLLAMA_CONFIG.baseUrl}/api/tags`, {
39
+ signal: controller.signal,
40
+ });
41
+ clearTimeout(timeout);
42
+ canRun = response.ok;
43
+
44
+ if (canRun) {
45
+ provider = new OllamaProvider({
46
+ baseUrl: OLLAMA_CONFIG.baseUrl,
47
+ models: OLLAMA_CONFIG.models,
48
+ });
49
+ console.log('✅ Ollama 服务可用');
50
+ }
51
+ } catch {
52
+ canRun = false;
53
+ console.log('⚠️ 网络不可用,跳过集成测试');
54
+ }
55
+ });
56
+
57
+ describe('Ollama Provider 集成测试', () => {
58
+ describe('基本聊天', () => {
59
+ it('应该能进行简单对话', async (ctx) => {
60
+ if (skipIfNoNetwork(ctx)) return;
61
+
62
+ const response = await provider!.chat({
63
+ model: OLLAMA_CONFIG.models[0],
64
+ messages: [{ role: 'user', content: '你好,一句话介绍自己' }],
65
+ max_tokens: 100,
66
+ });
67
+
68
+ expect(response.choices[0].message.content).toBeTruthy();
69
+ console.log('📝 AI 回复:', response.choices[0].message.content);
70
+ }, 30000);
71
+
72
+ it('应该能处理多轮对话', async (ctx) => {
73
+ if (skipIfNoNetwork(ctx)) return;
74
+
75
+ const messages: ChatMessage[] = [
76
+ { role: 'user', content: '记住:我叫小明。简短回复。' },
77
+ ];
78
+
79
+ const r1 = await provider!.chat({
80
+ model: OLLAMA_CONFIG.models[0],
81
+ messages,
82
+ max_tokens: 30,
83
+ });
84
+
85
+ messages.push({ role: 'assistant', content: r1.choices[0].message.content as string });
86
+ messages.push({ role: 'user', content: '我叫什么?' });
87
+
88
+ const r2 = await provider!.chat({
89
+ model: OLLAMA_CONFIG.models[0],
90
+ messages,
91
+ max_tokens: 30,
92
+ });
93
+
94
+ const reply = r2.choices[0].message.content as string;
95
+ console.log('📝 多轮对话:', reply);
96
+ expect(reply.toLowerCase()).toContain('小明');
97
+ }, 120000);
98
+ });
99
+
100
+ describe('工具调用', () => {
101
+ it('应该能调用计算器工具', async (ctx) => {
102
+ if (skipIfNoNetwork(ctx)) return;
103
+
104
+ const response = await provider!.chat({
105
+ model: OLLAMA_CONFIG.models[0],
106
+ messages: [
107
+ { role: 'system', content: '使用 calculator 工具计算。' },
108
+ { role: 'user', content: '计算 15 * 8' },
109
+ ],
110
+ tools: [{
111
+ type: 'function',
112
+ function: {
113
+ name: 'calculator',
114
+ description: '计算数学表达式',
115
+ parameters: {
116
+ type: 'object',
117
+ properties: { expression: { type: 'string' } },
118
+ required: ['expression'],
119
+ },
120
+ },
121
+ }],
122
+ tool_choice: 'auto',
123
+ max_tokens: 200,
124
+ });
125
+
126
+ console.log('📝 工具调用:', JSON.stringify(response.choices[0].message, null, 2));
127
+ expect(response.choices[0].message).toBeDefined();
128
+ }, 30000);
129
+
130
+ it('应该能调用天气工具', async (ctx) => {
131
+ if (skipIfNoNetwork(ctx)) return;
132
+
133
+ const response = await provider!.chat({
134
+ model: OLLAMA_CONFIG.models[0],
135
+ messages: [
136
+ { role: 'system', content: '使用 get_weather 工具查天气。' },
137
+ { role: 'user', content: '上海天气?' },
138
+ ],
139
+ tools: [{
140
+ type: 'function',
141
+ function: {
142
+ name: 'get_weather',
143
+ description: '查询天气',
144
+ parameters: {
145
+ type: 'object',
146
+ properties: { city: { type: 'string' } },
147
+ required: ['city'],
148
+ },
149
+ },
150
+ }],
151
+ tool_choice: 'auto',
152
+ max_tokens: 200,
153
+ });
154
+
155
+ console.log('📝 天气工具测试');
156
+ expect(response.choices[0].message).toBeDefined();
157
+ }, 30000);
158
+ });
159
+
160
+ describe('流式输出', () => {
161
+ it('应该能进行流式对话', async (ctx) => {
162
+ if (skipIfNoNetwork(ctx)) return;
163
+
164
+ const stream = await provider!.chatStream({
165
+ model: OLLAMA_CONFIG.models[0],
166
+ messages: [{ role: 'user', content: '写4行诗' }],
167
+ max_tokens: 100,
168
+ stream: true,
169
+ });
170
+
171
+ let content = '';
172
+ let chunks = 0;
173
+
174
+ console.log('📝 流式输出:');
175
+ for await (const chunk of stream) {
176
+ const c = chunk.choices?.[0]?.delta?.content;
177
+ if (c) {
178
+ content += c;
179
+ chunks++;
180
+ process.stdout.write(c);
181
+ }
182
+ }
183
+ console.log(`\n (${chunks} chunks)`);
184
+
185
+ expect(content.length).toBeGreaterThan(0);
186
+ }, 60000);
187
+ });
188
+
189
+ describe('错误处理', () => {
190
+ it('应该处理无效模型', async (ctx) => {
191
+ if (skipIfNoNetwork(ctx)) return;
192
+
193
+ await expect(
194
+ provider!.chat({
195
+ model: 'invalid-model-xyz',
196
+ messages: [{ role: 'user', content: 'test' }],
197
+ })
198
+ ).rejects.toThrow();
199
+ }, 60000); // Ollama 拉取模型可能需要较长时间才会失败
200
+ });
201
+
202
+ describe('健康检查', () => {
203
+ it('应该返回健康状态', async (ctx) => {
204
+ if (skipIfNoNetwork(ctx)) return;
205
+
206
+ const healthy = await provider!.healthCheck();
207
+ console.log('📝 健康:', healthy ? '✅' : '❌');
208
+ expect(typeof healthy).toBe('boolean');
209
+ }, 10000);
210
+ });
211
+ });
212
+
213
+ describe('性能测试', () => {
214
+ it('应该快速响应', async (ctx) => {
215
+ if (skipIfNoNetwork(ctx)) return;
216
+
217
+ const start = Date.now();
218
+ await provider!.chat({
219
+ model: OLLAMA_CONFIG.models[0],
220
+ messages: [{ role: 'user', content: '1+1' }],
221
+ max_tokens: 10,
222
+ });
223
+ const ms = Date.now() - start;
224
+ console.log(`📝 响应: ${ms}ms`);
225
+ expect(ms).toBeLessThan(30000);
226
+ }, 35000);
227
+ });
@@ -0,0 +1,108 @@
1
+ /**
2
+ * RateLimiter 测试
3
+ */
4
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
5
+ import { RateLimiter } from '../../src/ai/rate-limiter.js';
6
+
7
+ describe('RateLimiter', () => {
8
+ let limiter: RateLimiter;
9
+
10
+ beforeEach(() => {
11
+ vi.useFakeTimers();
12
+ });
13
+
14
+ afterEach(() => {
15
+ limiter?.dispose();
16
+ vi.useRealTimers();
17
+ });
18
+
19
+ it('应允许正常请求', () => {
20
+ limiter = new RateLimiter({ maxRequestsPerMinute: 5 });
21
+ const result = limiter.check('user1');
22
+ expect(result.allowed).toBe(true);
23
+ expect(result.message).toBeUndefined();
24
+ });
25
+
26
+ it('应在超过限制后拒绝请求', () => {
27
+ limiter = new RateLimiter({ maxRequestsPerMinute: 3, cooldownSeconds: 5 });
28
+
29
+ // 发送 3 个请求(达到限制)
30
+ limiter.check('user1');
31
+ limiter.check('user1');
32
+ limiter.check('user1');
33
+
34
+ // 第 4 个请求应被拒绝
35
+ const result = limiter.check('user1');
36
+ expect(result.allowed).toBe(false);
37
+ expect(result.message).toBeDefined();
38
+ expect(result.retryAfterSeconds).toBe(5);
39
+ });
40
+
41
+ it('冷却期结束后应允许请求', () => {
42
+ limiter = new RateLimiter({ maxRequestsPerMinute: 2, cooldownSeconds: 5 });
43
+
44
+ limiter.check('user1');
45
+ limiter.check('user1');
46
+ limiter.check('user1'); // 触发冷却
47
+
48
+ // 推进 6 秒
49
+ vi.advanceTimersByTime(6000);
50
+
51
+ // 推进超过 1 分钟让滑动窗口清空旧记录
52
+ vi.advanceTimersByTime(60000);
53
+
54
+ const result = limiter.check('user1');
55
+ expect(result.allowed).toBe(true);
56
+ });
57
+
58
+ it('不同用户应独立计数', () => {
59
+ limiter = new RateLimiter({ maxRequestsPerMinute: 2 });
60
+
61
+ limiter.check('user1');
62
+ limiter.check('user1');
63
+ const user1Result = limiter.check('user1'); // 超限
64
+
65
+ const user2Result = limiter.check('user2'); // 新用户
66
+ expect(user1Result.allowed).toBe(false);
67
+ expect(user2Result.allowed).toBe(true);
68
+ });
69
+
70
+ it('滑动窗口应在 1 分钟后清理旧记录', () => {
71
+ limiter = new RateLimiter({ maxRequestsPerMinute: 2 });
72
+
73
+ limiter.check('user1');
74
+ limiter.check('user1');
75
+
76
+ // 推进 61 秒
77
+ vi.advanceTimersByTime(61000);
78
+
79
+ // 旧记录清理后应允许
80
+ const result = limiter.check('user1');
81
+ expect(result.allowed).toBe(true);
82
+ });
83
+
84
+ it('dispose 应清理所有资源', () => {
85
+ limiter = new RateLimiter();
86
+ limiter.check('user1');
87
+ limiter.dispose();
88
+
89
+ // dispose 后内部 buckets 应清空
90
+ const result = limiter.check('user1');
91
+ expect(result.allowed).toBe(true); // 重新开始计数
92
+ });
93
+
94
+ it('在冷却期内请求应返回等待秒数', () => {
95
+ limiter = new RateLimiter({ maxRequestsPerMinute: 1, cooldownSeconds: 10 });
96
+
97
+ limiter.check('user1');
98
+ limiter.check('user1'); // 触发冷却
99
+
100
+ // 推进 3 秒
101
+ vi.advanceTimersByTime(3000);
102
+
103
+ const result = limiter.check('user1');
104
+ expect(result.allowed).toBe(false);
105
+ expect(result.retryAfterSeconds).toBeLessThanOrEqual(10);
106
+ expect(result.retryAfterSeconds).toBeGreaterThan(0);
107
+ });
108
+ });