@veltra/ai 1.4.0 → 2.1.0

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 (87) hide show
  1. package/dist/chat/index.d.ts +3 -3
  2. package/dist/chat/transports/index.d.ts +2 -2
  3. package/dist/chat/transports/openai.d.ts +16 -12
  4. package/dist/chat/transports/openai.js +56 -15
  5. package/dist/chat/transports/openai.js.map +1 -1
  6. package/dist/chat/types.d.ts +41 -2
  7. package/dist/chat/use-chat.d.ts +26 -1
  8. package/dist/chat/use-chat.js +132 -13
  9. package/dist/chat/use-chat.js.map +1 -1
  10. package/dist/components/ai-chat/ai-chat.js +222 -41
  11. package/dist/components/ai-chat/ai-chat.js.map +1 -1
  12. package/dist/components/ai-chat/ask-question.js +2 -2
  13. package/dist/components/ai-chat/ask-question.js.map +1 -1
  14. package/dist/components/ai-chat/chat-input.js +81 -44
  15. package/dist/components/ai-chat/chat-input.js.map +1 -1
  16. package/dist/components/ai-chat/di.js.map +1 -1
  17. package/dist/components/ai-chat/message-item.js +101 -23
  18. package/dist/components/ai-chat/message-item.js.map +1 -1
  19. package/dist/components/ai-chat/message-list.js +217 -32
  20. package/dist/components/ai-chat/message-list.js.map +1 -1
  21. package/dist/components/ai-chat/model-picker.js +140 -0
  22. package/dist/components/ai-chat/model-picker.js.map +1 -0
  23. package/dist/components/ai-chat/queue-list.js +70 -0
  24. package/dist/components/ai-chat/queue-list.js.map +1 -0
  25. package/dist/components/ai-chat/side-panel.js +96 -0
  26. package/dist/components/ai-chat/side-panel.js.map +1 -0
  27. package/dist/components/ai-chat/style.d.ts +6 -0
  28. package/dist/components/ai-chat/style.js +6 -0
  29. package/dist/components/ai-chat/style2.css +552 -41
  30. package/dist/components/ai-chat/tool-call.js +61 -27
  31. package/dist/components/ai-chat/tool-call.js.map +1 -1
  32. package/dist/components/ai-orb/ai-orb.js +94 -0
  33. package/dist/components/ai-orb/ai-orb.js.map +1 -0
  34. package/dist/components/ai-orb/orb-renderer.js +411 -0
  35. package/dist/components/ai-orb/orb-renderer.js.map +1 -0
  36. package/dist/components/ai-orb/style.d.ts +1 -0
  37. package/dist/components/ai-orb/style.js +1 -0
  38. package/dist/components/ai-orb/style2.css +23 -0
  39. package/dist/index.d.ts +8 -6
  40. package/dist/index.js +2 -2
  41. package/dist/providers/types.d.ts +47 -0
  42. package/dist/style.js +1 -0
  43. package/dist/tools/ask-question/ask-question.d.ts +2 -17
  44. package/dist/tools/ask-question/index.d.ts +2 -2
  45. package/dist/tools/index.d.ts +3 -2
  46. package/dist/tools/index.js +10 -0
  47. package/dist/tools/index.js.map +1 -0
  48. package/dist/types/ai-chat.d.ts +35 -7
  49. package/dist/types/ai-orb.d.ts +27 -0
  50. package/dist/types/index.d.ts +2 -1
  51. package/package.json +8 -8
  52. package/src/chat/__test__/use-chat.test.ts +295 -9
  53. package/src/chat/transports/__test__/openai.test.ts +317 -0
  54. package/src/chat/transports/openai.ts +88 -26
  55. package/src/chat/types.ts +41 -1
  56. package/src/chat/use-chat.ts +177 -14
  57. package/src/components/ai-chat/__test__/ai-chat.test.ts +690 -12
  58. package/src/components/ai-chat/ai-chat.vue +241 -31
  59. package/src/components/ai-chat/chat-input.vue +78 -31
  60. package/src/components/ai-chat/di.ts +2 -0
  61. package/src/components/ai-chat/message-item.vue +98 -11
  62. package/src/components/ai-chat/message-list.vue +261 -21
  63. package/src/components/ai-chat/model-picker.vue +177 -0
  64. package/src/components/ai-chat/queue-list.vue +63 -0
  65. package/src/components/ai-chat/side-panel.vue +76 -0
  66. package/src/components/ai-chat/style.scss +621 -43
  67. package/src/components/ai-chat/style.ts +6 -0
  68. package/src/components/ai-chat/tool-call.vue +86 -42
  69. package/src/components/ai-orb/__test__/ai-orb.test.ts +251 -0
  70. package/src/components/ai-orb/ai-orb.vue +107 -0
  71. package/src/components/ai-orb/index.ts +1 -0
  72. package/src/components/ai-orb/orb-renderer.ts +505 -0
  73. package/src/components/ai-orb/style.scss +10 -0
  74. package/src/components/ai-orb/style.ts +1 -0
  75. package/src/components/index.ts +1 -0
  76. package/src/index.ts +8 -1
  77. package/src/providers/index.ts +1 -0
  78. package/src/providers/types.ts +46 -0
  79. package/src/style.ts +1 -0
  80. package/src/tools/ask-question/__test__/ask-question.test.ts +4 -4
  81. package/src/tools/index.ts +14 -1
  82. package/src/types/ai-chat.ts +35 -6
  83. package/src/types/ai-orb.ts +27 -0
  84. package/src/types/index.ts +1 -0
  85. package/dist/components/ai-chat/ai-chat.vue.d.ts +0 -36
  86. package/dist/components/ai-chat/index.d.ts +0 -2
  87. package/dist/components/index.d.ts +0 -3
@@ -1,4 +1,6 @@
1
- import { ChatAttachment, ChatMessage, ChatTool, ChatToolCall, ChatTransport } from "../chat/types.js";
1
+ import { ChatAttachment, ChatMessage, ChatQueuedMessage, ChatTool, ChatToolCall, ChatTransport } from "../chat/types.js";
2
+ import { ChatModelOption } from "../providers/types.js";
3
+ import { Ref } from "vue";
2
4
  import { DeconstructValue } from "@veltra/utils";
3
5
  //#region src/types/ai-chat.d.ts
4
6
  interface AiChatProps {
@@ -8,10 +10,24 @@ interface AiChatProps {
8
10
  tools?: ChatTool[];
9
11
  /** 系统提示词 */
10
12
  systemPrompt?: string;
13
+ /**
14
+ * 单次发送允许的最大生成轮次(一轮 = 一次模型生成 + 可能的工具执行),默认 10。
15
+ * 模型持续调用工具不收敛时,达到上限即停止继续请求并发出 finish,防止失控循环。
16
+ */
17
+ maxToolRounds?: number;
11
18
  /** 消息列表,支持 v-model:messages 受控 */
12
19
  messages?: ChatMessage[];
13
- /** 空状态欢迎语 */
14
- welcome?: string;
20
+ /**
21
+ * 可选模型列表;有值则在输入栏展示模型选择器。
22
+ * 可从 createOpenAITransport() 返回值的 `.models` 直接传入。
23
+ */
24
+ models?: ChatModelOption[];
25
+ /** 当前模型 id,支持 v-model:model */
26
+ model?: string;
27
+ /** 当前推理等级,支持 v-model:reasoning-level */
28
+ reasoningLevel?: string;
29
+ /** 空状态欢迎项(快捷提问,点击即发送);字符串按单项处理 */
30
+ welcome?: string | string[];
15
31
  /** 输入框占位文本 */
16
32
  placeholder?: string;
17
33
  /** 附件 accept 类型,默认 image/* */
@@ -24,9 +40,13 @@ interface AiChatProps {
24
40
  interface AiChatEmits {
25
41
  /** 消息列表变化 */
26
42
  (e: 'update:messages', messages: ChatMessage[]): void;
43
+ /** 当前模型变化 */
44
+ (e: 'update:model', model: string | undefined): void;
45
+ /** 当前推理等级变化 */
46
+ (e: 'update:reasoningLevel', reasoningLevel: string | undefined): void;
27
47
  /** 用户发送消息 */
28
48
  (e: 'send', message: ChatMessage): void;
29
- /** 一轮对话完成(无更多工具调用) */
49
+ /** 一轮对话完成(无更多工具调用、命中 terminal 工具或达到 maxToolRounds 上限) */
30
50
  (e: 'finish', message: ChatMessage): void;
31
51
  /** 对话出错 */
32
52
  (e: 'error', error: Error): void;
@@ -34,14 +54,22 @@ interface AiChatEmits {
34
54
  (e: 'tool-call', toolCall: ChatToolCall): void;
35
55
  }
36
56
  interface _AiChatExposed {
37
- /** 发送一条用户消息 */
57
+ /** 发送一条用户消息(会话进行中时进入待发送队列) */
38
58
  send: (content: string, attachments?: ChatAttachment[]) => void;
39
- /** 中断当前生成 */
59
+ /** 中断当前生成(保留待发送队列) */
40
60
  abort: () => void;
41
61
  /** 重新生成最后一条 assistant 回复 */
42
62
  regenerate: () => void;
43
- /** 清空消息 */
63
+ /** 清空消息与待发送队列 */
44
64
  clear: () => void;
65
+ /** 待发送消息队列(会话进行中提交的消息按序排队,收尾后 FIFO 自动接续) */
66
+ queue: Ref<ChatQueuedMessage[]>;
67
+ /** 立即执行队列中的某条:中断当前会话并插队为下一条 */
68
+ startQueued: (id: string) => void;
69
+ /** 从队列移除某条(返回被移除项) */
70
+ removeQueued: (id: string) => ChatQueuedMessage | undefined;
71
+ /** 向队列插入一条消息(beforeId 插到某条之前,缺省追加尾部;空闲时自动消耗队首) */
72
+ enqueue: (content: string, attachments?: ChatAttachment[], beforeId?: string) => ChatQueuedMessage;
45
73
  }
46
74
  type AiChatExposed = DeconstructValue<_AiChatExposed>;
47
75
  //#endregion
@@ -0,0 +1,27 @@
1
+ //#region src/types/ai-orb.d.ts
2
+ /** 活体球生命状态:驱动常态动画(平静 / 思考 / 输出) */
3
+ type AiOrbStatus = 'idle' | 'thinking' | 'speaking';
4
+ /**
5
+ * 瞬时表情:播放约 1-2s 后自动回到 status 驱动的常态。
6
+ * - happy:成功 / 回答完毕 —— 先睁大眼睛,再弯眼大笑 + 点头
7
+ * - shock:惊讶 —— 睁大眼睛 + 小嘴微张 + 轻微后仰
8
+ * - frustrated:受挫(如工具调用失败)—— 闭紧眼睛 + 摇头 + 嘴角下撇
9
+ */
10
+ type AiOrbReaction = 'happy' | 'shock' | 'frustrated';
11
+ interface AiOrbProps {
12
+ /** 球体直径(px),默认 48 */
13
+ size?: number;
14
+ /** 生命状态,默认 idle */
15
+ status?: AiOrbStatus;
16
+ }
17
+ interface AiOrbEmits {
18
+ /** 点击球体(球体同时会做 Q 弹反馈) */
19
+ (e: 'click'): void;
20
+ }
21
+ interface AiOrbExposed {
22
+ /** 播放一次瞬时表情(对应阶段性事件,如回答完毕 / 工具调用失败) */
23
+ react: (reaction: AiOrbReaction) => void;
24
+ }
25
+ //#endregion
26
+ export { AiOrbEmits, AiOrbExposed, AiOrbProps, AiOrbReaction, AiOrbStatus };
27
+ //# sourceMappingURL=ai-orb.d.ts.map
@@ -1,2 +1,3 @@
1
1
  import { AiChatEmits, AiChatExposed, AiChatProps, _AiChatExposed } from "./ai-chat.js";
2
- export { AiChatEmits, AiChatExposed, AiChatProps, _AiChatExposed };
2
+ import { AiOrbEmits, AiOrbExposed, AiOrbProps, AiOrbReaction, AiOrbStatus } from "./ai-orb.js";
3
+ export { AiChatEmits, AiChatExposed, AiChatProps, AiOrbEmits, AiOrbExposed, AiOrbProps, AiOrbReaction, AiOrbStatus, _AiChatExposed };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@veltra/ai",
3
- "version": "1.4.0",
3
+ "version": "2.1.0",
4
4
  "files": [
5
5
  "dist",
6
6
  "src"
@@ -27,14 +27,14 @@
27
27
  }
28
28
  },
29
29
  "dependencies": {
30
- "markstream-vue": "^1.0.8"
30
+ "markstream-vue": "^1.0.9"
31
31
  },
32
32
  "peerDependencies": {
33
- "@veltra/compositions": "1.4.0",
34
- "@veltra/desktop": "1.4.0",
35
- "@veltra/icons": "1.4.0",
36
- "@veltra/styles": "1.4.0",
37
- "@veltra/utils": "1.4.0",
38
- "vue": ">=3.5.40"
33
+ "@veltra/compositions": "^1.6.0",
34
+ "@veltra/desktop": "^1.6.0",
35
+ "@veltra/icons": "^1.5.0",
36
+ "@veltra/styles": "^1.6.0",
37
+ "@veltra/utils": "^1.6.0",
38
+ "vue": ">=3.5.41"
39
39
  }
40
40
  }
@@ -212,13 +212,91 @@ describe('useChat', () => {
212
212
  expect(chat.messages.value).toHaveLength(2)
213
213
  })
214
214
 
215
- it('生成中忽略重复 send', async () => {
215
+ it('生成中 send 进入队列,会话结束后按 FIFO 自动接续', async () => {
216
+ const emit = createEmit()
217
+ const resolvers: (() => void)[] = []
218
+
219
+ const transport: ChatTransport = (req, handlers) => {
220
+ const lastUser = [...req.messages].reverse().find((m) => m.role === 'user')
221
+ return new Promise<void>((resolve) => {
222
+ resolvers.push(() => {
223
+ handlers.onTextDelta(`回复:${lastUser?.content}`)
224
+ resolve()
225
+ })
226
+ })
227
+ }
228
+
229
+ const chat = useChat({ props: { transport }, emit })
230
+ chat.send('第一条')
231
+ chat.send('第二条')
232
+ chat.send('第三条')
233
+
234
+ // 进行中提交的消息进入队列,不直接追加为用户消息
235
+ expect(chat.queue.value.map((q) => q.content)).toEqual(['第二条', '第三条'])
236
+ expect(chat.messages.value.filter((m) => m.role === 'user')).toHaveLength(1)
237
+
238
+ resolvers[0]!()
239
+ await vi.waitFor(() => {
240
+ expect(chat.messages.value.filter((m) => m.role === 'user')).toHaveLength(2)
241
+ })
242
+ expect(chat.queue.value.map((q) => q.content)).toEqual(['第三条'])
243
+ expect(chat.messages.value[1]?.content).toBe('回复:第一条')
244
+
245
+ resolvers[1]!()
246
+ await vi.waitFor(() => {
247
+ expect(chat.messages.value.filter((m) => m.role === 'user')).toHaveLength(3)
248
+ })
249
+
250
+ resolvers[2]!()
251
+ await vi.waitFor(() => {
252
+ expect(chat.running.value).toBe(false)
253
+ })
254
+ expect(chat.queue.value).toHaveLength(0)
255
+ expect(chat.messages.value.at(-1)?.content).toBe('回复:第三条')
256
+ })
257
+
258
+ it('startQueued 中断当前会话并插队执行该条,其余保持顺序', async () => {
259
+ const emit = createEmit()
260
+ const resolvers: (() => void)[] = []
261
+
262
+ const transport: ChatTransport = (req, handlers) => {
263
+ return new Promise<void>((resolve) => {
264
+ resolvers.push(() => {
265
+ handlers.onTextDelta('ok')
266
+ resolve()
267
+ })
268
+ req.signal.addEventListener('abort', () => resolve(), { once: true })
269
+ })
270
+ }
271
+
272
+ const chat = useChat({ props: { transport }, emit })
273
+ chat.send('第一条')
274
+ chat.send('第二条')
275
+ chat.send('第三条')
276
+
277
+ const second = chat.queue.value[0]!
278
+ chat.startQueued(second.id)
279
+
280
+ // 当前会话中断,第二条插队开始执行,第三条仍在队列
281
+ await vi.waitFor(() => {
282
+ const users = chat.messages.value.filter((m) => m.role === 'user').map((m) => m.content)
283
+ expect(users).toEqual(['第一条', '第二条'])
284
+ })
285
+ expect(chat.messages.value[1]?.status).toBe('aborted')
286
+ expect(chat.queue.value.map((q) => q.content)).toEqual(['第三条'])
287
+
288
+ // 第二条完成后自动接续第三条
289
+ resolvers[1]!()
290
+ await vi.waitFor(() => {
291
+ expect(chat.messages.value.filter((m) => m.role === 'user')).toHaveLength(3)
292
+ })
293
+ })
294
+
295
+ it('手动 abort 后队列保留且不自动接续', async () => {
216
296
  const emit = createEmit()
217
- let resolveStream: (() => void) | undefined
218
297
 
219
298
  const transport: ChatTransport = (req) => {
220
299
  return new Promise<void>((resolve) => {
221
- resolveStream = resolve
222
300
  req.signal.addEventListener('abort', () => resolve(), { once: true })
223
301
  })
224
302
  }
@@ -226,21 +304,229 @@ describe('useChat', () => {
226
304
  const chat = useChat({ props: { transport }, emit })
227
305
  chat.send('第一条')
228
306
  chat.send('第二条')
229
- resolveStream?.()
307
+ chat.abort()
230
308
 
231
- await waitFinish(emit)
309
+ await vi.waitFor(() => {
310
+ expect(chat.running.value).toBe(false)
311
+ })
312
+ expect(chat.queue.value.map((q) => q.content)).toEqual(['第二条'])
232
313
  expect(chat.messages.value.filter((m) => m.role === 'user')).toHaveLength(1)
314
+ expect(emit).not.toHaveBeenCalledWith('finish', expect.anything())
233
315
  })
234
316
 
235
- it('clear 清空消息', async () => {
317
+ it('enqueue 支持 beforeId 锚点插入,保持前后顺序', async () => {
236
318
  const emit = createEmit()
237
- const chat = useChat({ props: { transport: textTransport('x') }, emit })
238
319
 
239
- chat.send('hi')
240
- await waitFinish(emit)
320
+ const transport: ChatTransport = (req) => {
321
+ return new Promise<void>((resolve) => {
322
+ req.signal.addEventListener('abort', () => resolve(), { once: true })
323
+ })
324
+ }
325
+
326
+ const chat = useChat({ props: { transport }, emit })
327
+ chat.send('第一条')
328
+ chat.enqueue('A')
329
+ chat.enqueue('C')
330
+ const cId = chat.queue.value[1]!.id
331
+ chat.enqueue('B', undefined, cId)
332
+
333
+ expect(chat.queue.value.map((q) => q.content)).toEqual(['A', 'B', 'C'])
334
+ chat.abort()
335
+ })
336
+
337
+ it('会话出错时不自动消耗队列', async () => {
338
+ const emit = createEmit()
339
+
340
+ const transport: ChatTransport = (_req, handlers) => {
341
+ handlers.onError?.(new Error('网络错误'))
342
+ }
343
+
344
+ const chat = useChat({ props: { transport }, emit })
345
+ chat.send('第一条')
346
+ chat.send('第二条')
347
+
348
+ await vi.waitFor(() => {
349
+ expect(emit).toHaveBeenCalledWith('error', expect.anything())
350
+ })
351
+ expect(chat.running.value).toBe(false)
352
+ expect(chat.queue.value.map((q) => q.content)).toEqual(['第二条'])
353
+ })
354
+
355
+ it('clear 同时清空待发送队列', async () => {
356
+ const emit = createEmit()
357
+
358
+ const transport: ChatTransport = (req) => {
359
+ return new Promise<void>((resolve) => {
360
+ req.signal.addEventListener('abort', () => resolve(), { once: true })
361
+ })
362
+ }
241
363
 
364
+ const chat = useChat({ props: { transport }, emit })
365
+ chat.send('第一条')
366
+ chat.send('第二条')
242
367
  chat.clear()
243
368
  await nextTick()
369
+
244
370
  expect(chat.messages.value).toHaveLength(0)
371
+ expect(chat.queue.value).toHaveLength(0)
372
+ })
373
+
374
+ it('请求携带 model 与 reasoningLevel', async () => {
375
+ const emit = createEmit()
376
+ const requests: ChatTransportRequest[] = []
377
+
378
+ const transport: ChatTransport = (req, handlers) => {
379
+ requests.push(req)
380
+ handlers.onTextDelta('ok')
381
+ }
382
+
383
+ const chat = useChat({
384
+ props: {
385
+ transport,
386
+ models: [
387
+ {
388
+ id: 'gpt-4o',
389
+ providerId: 'openai',
390
+ reasoningLevels: [
391
+ { value: 'low', label: '低' },
392
+ { value: 'high', label: '高' }
393
+ ],
394
+ defaultReasoningLevel: 'low'
395
+ }
396
+ ],
397
+ model: 'gpt-4o',
398
+ reasoningLevel: 'high'
399
+ },
400
+ emit
401
+ })
402
+
403
+ chat.send('hi')
404
+ await waitFinish(emit)
405
+
406
+ expect(requests[0]).toMatchObject({ model: 'gpt-4o', reasoningLevel: 'high' })
407
+ })
408
+
409
+ it('切换模型时校正推理等级', async () => {
410
+ const emit = createEmit()
411
+ const chat = useChat({
412
+ props: {
413
+ transport: textTransport('x'),
414
+ models: [
415
+ {
416
+ id: 'with-reason',
417
+ providerId: 'a',
418
+ reasoningLevels: [
419
+ { value: 'low', label: '低' },
420
+ { value: 'high', label: '高' }
421
+ ],
422
+ defaultReasoningLevel: 'low'
423
+ },
424
+ { id: 'no-reason', providerId: 'a' }
425
+ ],
426
+ model: 'with-reason',
427
+ reasoningLevel: 'high'
428
+ },
429
+ emit
430
+ })
431
+
432
+ await nextTick()
433
+ expect(chat.model.value).toBe('with-reason')
434
+ expect(chat.reasoningLevel.value).toBe('high')
435
+
436
+ chat.model.value = 'no-reason'
437
+ await nextTick()
438
+ expect(chat.reasoningLevel.value).toBeUndefined()
439
+
440
+ chat.model.value = 'with-reason'
441
+ await nextTick()
442
+ expect(chat.reasoningLevel.value).toBe('low')
443
+ })
444
+
445
+ it('terminal 工具执行成功后结束对话,不再请求模型生成文字', async () => {
446
+ const emit = createEmit()
447
+ const requests: ChatTransportRequest[] = []
448
+
449
+ const transport: ChatTransport = (req, handlers) => {
450
+ requests.push(req)
451
+ handlers.onToolCall?.({ id: 'call-1', name: 'getWeather', arguments: '{"city":"北京"}' })
452
+ }
453
+
454
+ const tools: ChatTool[] = [
455
+ {
456
+ name: 'getWeather',
457
+ description: '查天气',
458
+ parameters: {},
459
+ terminal: true,
460
+ execute: () => ({ temperature: 26 })
461
+ }
462
+ ]
463
+
464
+ const chat = useChat({ props: { transport, tools }, emit })
465
+ chat.send('北京天气')
466
+ await waitFinish(emit)
467
+
468
+ // 只请求一轮;工具结果仍进入消息历史
469
+ expect(requests).toHaveLength(1)
470
+ const messages = chat.messages.value
471
+ expect(messages.map((m) => m.role)).toEqual(['user', 'assistant', 'tool'])
472
+ expect(messages[1].toolCalls?.[0]).toMatchObject({ status: 'success' })
473
+ })
474
+
475
+ it('terminal 工具执行失败时仍回灌模型', async () => {
476
+ const emit = createEmit()
477
+ let round = 0
478
+
479
+ const transport: ChatTransport = (_req, handlers) => {
480
+ round++
481
+ if (round === 1) {
482
+ handlers.onToolCall?.({ id: 'call-1', name: 'getWeather', arguments: '{}' })
483
+ } else {
484
+ handlers.onTextDelta('没查到,换个城市试试')
485
+ }
486
+ }
487
+
488
+ const tools: ChatTool[] = [
489
+ {
490
+ name: 'getWeather',
491
+ description: '查天气',
492
+ parameters: {},
493
+ terminal: true,
494
+ execute: () => {
495
+ throw new Error('城市不存在')
496
+ }
497
+ }
498
+ ]
499
+
500
+ const chat = useChat({ props: { transport, tools }, emit })
501
+ chat.send('火星天气')
502
+ await waitFinish(emit)
503
+
504
+ expect(round).toBe(2)
505
+ expect(chat.messages.value[1].toolCalls?.[0]).toMatchObject({ status: 'error' })
506
+ expect(chat.messages.value[3]).toMatchObject({
507
+ role: 'assistant',
508
+ content: '没查到,换个城市试试'
509
+ })
510
+ })
511
+
512
+ it('模型持续调用工具时达到 maxToolRounds 上限即停止', async () => {
513
+ const emit = createEmit()
514
+ const requests: ChatTransportRequest[] = []
515
+
516
+ const transport: ChatTransport = (req, handlers) => {
517
+ requests.push(req)
518
+ handlers.onToolCall?.({ id: `call-${requests.length}`, name: 'noop', arguments: '{}' })
519
+ }
520
+
521
+ const tools: ChatTool[] = [
522
+ { name: 'noop', description: '空操作', parameters: {}, execute: () => ({}) }
523
+ ]
524
+
525
+ const chat = useChat({ props: { transport, tools, maxToolRounds: 3 }, emit })
526
+ chat.send('hi')
527
+ await waitFinish(emit)
528
+
529
+ expect(requests).toHaveLength(3)
530
+ expect(chat.running.value).toBe(false)
245
531
  })
246
532
  })