@veltra/ai 2.0.0 → 2.1.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 (70) hide show
  1. package/dist/chat/index.d.ts +2 -2
  2. package/dist/chat/transports/openai.js +1 -1
  3. package/dist/chat/transports/openai.js.map +1 -1
  4. package/dist/chat/types.d.ts +37 -2
  5. package/dist/chat/use-chat.d.ts +23 -1
  6. package/dist/chat/use-chat.js +91 -10
  7. package/dist/chat/use-chat.js.map +1 -1
  8. package/dist/components/ai-chat/ai-chat.js +207 -46
  9. package/dist/components/ai-chat/ai-chat.js.map +1 -1
  10. package/dist/components/ai-chat/chat-input.js +37 -12
  11. package/dist/components/ai-chat/chat-input.js.map +1 -1
  12. package/dist/components/ai-chat/di.js.map +1 -1
  13. package/dist/components/ai-chat/message-item.js +101 -23
  14. package/dist/components/ai-chat/message-item.js.map +1 -1
  15. package/dist/components/ai-chat/message-list.js +217 -32
  16. package/dist/components/ai-chat/message-list.js.map +1 -1
  17. package/dist/components/ai-chat/model-picker.js +69 -46
  18. package/dist/components/ai-chat/model-picker.js.map +1 -1
  19. package/dist/components/ai-chat/queue-list.js +70 -0
  20. package/dist/components/ai-chat/queue-list.js.map +1 -0
  21. package/dist/components/ai-chat/side-panel.js +96 -0
  22. package/dist/components/ai-chat/side-panel.js.map +1 -0
  23. package/dist/components/ai-chat/style.d.ts +4 -0
  24. package/dist/components/ai-chat/style.js +4 -0
  25. package/dist/components/ai-chat/style2.css +457 -74
  26. package/dist/components/ai-chat/tool-call.js +60 -26
  27. package/dist/components/ai-chat/tool-call.js.map +1 -1
  28. package/dist/components/ai-orb/ai-orb.js +94 -0
  29. package/dist/components/ai-orb/ai-orb.js.map +1 -0
  30. package/dist/components/ai-orb/orb-renderer.js +411 -0
  31. package/dist/components/ai-orb/orb-renderer.js.map +1 -0
  32. package/dist/components/ai-orb/style.d.ts +1 -0
  33. package/dist/components/ai-orb/style.js +1 -0
  34. package/dist/components/ai-orb/style2.css +23 -0
  35. package/dist/index.d.ts +4 -2
  36. package/dist/index.js +2 -1
  37. package/dist/style.js +1 -0
  38. package/dist/types/ai-chat.d.ts +21 -7
  39. package/dist/types/ai-orb.d.ts +27 -0
  40. package/dist/types/index.d.ts +2 -1
  41. package/package.json +5 -5
  42. package/src/chat/__test__/use-chat.test.ts +266 -13
  43. package/src/chat/transports/__test__/openai.test.ts +78 -0
  44. package/src/chat/transports/openai.ts +2 -2
  45. package/src/chat/types.ts +37 -1
  46. package/src/chat/use-chat.ts +120 -11
  47. package/src/components/ai-chat/__test__/ai-chat.test.ts +628 -12
  48. package/src/components/ai-chat/ai-chat.vue +227 -31
  49. package/src/components/ai-chat/chat-input.vue +41 -11
  50. package/src/components/ai-chat/di.ts +2 -0
  51. package/src/components/ai-chat/message-item.vue +98 -11
  52. package/src/components/ai-chat/message-list.vue +261 -21
  53. package/src/components/ai-chat/model-picker.vue +110 -59
  54. package/src/components/ai-chat/queue-list.vue +63 -0
  55. package/src/components/ai-chat/side-panel.vue +76 -0
  56. package/src/components/ai-chat/style.scss +527 -74
  57. package/src/components/ai-chat/style.ts +4 -0
  58. package/src/components/ai-chat/tool-call.vue +86 -42
  59. package/src/components/ai-orb/__test__/ai-orb.test.ts +251 -0
  60. package/src/components/ai-orb/ai-orb.vue +107 -0
  61. package/src/components/ai-orb/index.ts +1 -0
  62. package/src/components/ai-orb/orb-renderer.ts +505 -0
  63. package/src/components/ai-orb/style.scss +10 -0
  64. package/src/components/ai-orb/style.ts +1 -0
  65. package/src/components/index.ts +1 -0
  66. package/src/style.ts +1 -0
  67. package/src/tools/ask-question/__test__/ask-question.test.ts +1 -1
  68. package/src/types/ai-chat.ts +21 -6
  69. package/src/types/ai-orb.ts +27 -0
  70. package/src/types/index.ts +1 -0
@@ -1,8 +1,14 @@
1
1
  import { describe, expect, it, vi } from 'vitest'
2
- import { nextTick } from 'vue'
3
-
4
- import type { AiChatEmits } from '../../types'
5
- import type { ChatTool, ChatTransport, ChatTransportHandlers, ChatTransportRequest } from '../types'
2
+ import { nextTick, reactive } from 'vue'
3
+
4
+ import type { AiChatEmits, AiChatProps } from '../../types'
5
+ import type {
6
+ ChatMessage,
7
+ ChatTool,
8
+ ChatTransport,
9
+ ChatTransportHandlers,
10
+ ChatTransportRequest
11
+ } from '../types'
6
12
  import { useChat } from '../use-chat'
7
13
 
8
14
  const createEmit = () => vi.fn() as unknown as AiChatEmits & ReturnType<typeof vi.fn>
@@ -212,13 +218,91 @@ describe('useChat', () => {
212
218
  expect(chat.messages.value).toHaveLength(2)
213
219
  })
214
220
 
215
- it('生成中忽略重复 send', async () => {
221
+ it('生成中 send 进入队列,会话结束后按 FIFO 自动接续', async () => {
222
+ const emit = createEmit()
223
+ const resolvers: (() => void)[] = []
224
+
225
+ const transport: ChatTransport = (req, handlers) => {
226
+ const lastUser = [...req.messages].reverse().find((m) => m.role === 'user')
227
+ return new Promise<void>((resolve) => {
228
+ resolvers.push(() => {
229
+ handlers.onTextDelta(`回复:${lastUser?.content}`)
230
+ resolve()
231
+ })
232
+ })
233
+ }
234
+
235
+ const chat = useChat({ props: { transport }, emit })
236
+ chat.send('第一条')
237
+ chat.send('第二条')
238
+ chat.send('第三条')
239
+
240
+ // 进行中提交的消息进入队列,不直接追加为用户消息
241
+ expect(chat.queue.value.map((q) => q.content)).toEqual(['第二条', '第三条'])
242
+ expect(chat.messages.value.filter((m) => m.role === 'user')).toHaveLength(1)
243
+
244
+ resolvers[0]!()
245
+ await vi.waitFor(() => {
246
+ expect(chat.messages.value.filter((m) => m.role === 'user')).toHaveLength(2)
247
+ })
248
+ expect(chat.queue.value.map((q) => q.content)).toEqual(['第三条'])
249
+ expect(chat.messages.value[1]?.content).toBe('回复:第一条')
250
+
251
+ resolvers[1]!()
252
+ await vi.waitFor(() => {
253
+ expect(chat.messages.value.filter((m) => m.role === 'user')).toHaveLength(3)
254
+ })
255
+
256
+ resolvers[2]!()
257
+ await vi.waitFor(() => {
258
+ expect(chat.running.value).toBe(false)
259
+ })
260
+ expect(chat.queue.value).toHaveLength(0)
261
+ expect(chat.messages.value.at(-1)?.content).toBe('回复:第三条')
262
+ })
263
+
264
+ it('startQueued 中断当前会话并插队执行该条,其余保持顺序', async () => {
265
+ const emit = createEmit()
266
+ const resolvers: (() => void)[] = []
267
+
268
+ const transport: ChatTransport = (req, handlers) => {
269
+ return new Promise<void>((resolve) => {
270
+ resolvers.push(() => {
271
+ handlers.onTextDelta('ok')
272
+ resolve()
273
+ })
274
+ req.signal.addEventListener('abort', () => resolve(), { once: true })
275
+ })
276
+ }
277
+
278
+ const chat = useChat({ props: { transport }, emit })
279
+ chat.send('第一条')
280
+ chat.send('第二条')
281
+ chat.send('第三条')
282
+
283
+ const second = chat.queue.value[0]!
284
+ chat.startQueued(second.id)
285
+
286
+ // 当前会话中断,第二条插队开始执行,第三条仍在队列
287
+ await vi.waitFor(() => {
288
+ const users = chat.messages.value.filter((m) => m.role === 'user').map((m) => m.content)
289
+ expect(users).toEqual(['第一条', '第二条'])
290
+ })
291
+ expect(chat.messages.value[1]?.status).toBe('aborted')
292
+ expect(chat.queue.value.map((q) => q.content)).toEqual(['第三条'])
293
+
294
+ // 第二条完成后自动接续第三条
295
+ resolvers[1]!()
296
+ await vi.waitFor(() => {
297
+ expect(chat.messages.value.filter((m) => m.role === 'user')).toHaveLength(3)
298
+ })
299
+ })
300
+
301
+ it('手动 abort 后队列保留且不自动接续', async () => {
216
302
  const emit = createEmit()
217
- let resolveStream: (() => void) | undefined
218
303
 
219
304
  const transport: ChatTransport = (req) => {
220
305
  return new Promise<void>((resolve) => {
221
- resolveStream = resolve
222
306
  req.signal.addEventListener('abort', () => resolve(), { once: true })
223
307
  })
224
308
  }
@@ -226,22 +310,71 @@ describe('useChat', () => {
226
310
  const chat = useChat({ props: { transport }, emit })
227
311
  chat.send('第一条')
228
312
  chat.send('第二条')
229
- resolveStream?.()
313
+ chat.abort()
230
314
 
231
- await waitFinish(emit)
315
+ await vi.waitFor(() => {
316
+ expect(chat.running.value).toBe(false)
317
+ })
318
+ expect(chat.queue.value.map((q) => q.content)).toEqual(['第二条'])
232
319
  expect(chat.messages.value.filter((m) => m.role === 'user')).toHaveLength(1)
320
+ expect(emit).not.toHaveBeenCalledWith('finish', expect.anything())
233
321
  })
234
322
 
235
- it('clear 清空消息', async () => {
323
+ it('enqueue 支持 beforeId 锚点插入,保持前后顺序', async () => {
236
324
  const emit = createEmit()
237
- const chat = useChat({ props: { transport: textTransport('x') }, emit })
238
325
 
239
- chat.send('hi')
240
- await waitFinish(emit)
326
+ const transport: ChatTransport = (req) => {
327
+ return new Promise<void>((resolve) => {
328
+ req.signal.addEventListener('abort', () => resolve(), { once: true })
329
+ })
330
+ }
331
+
332
+ const chat = useChat({ props: { transport }, emit })
333
+ chat.send('第一条')
334
+ chat.enqueue('A')
335
+ chat.enqueue('C')
336
+ const cId = chat.queue.value[1]!.id
337
+ chat.enqueue('B', undefined, cId)
338
+
339
+ expect(chat.queue.value.map((q) => q.content)).toEqual(['A', 'B', 'C'])
340
+ chat.abort()
341
+ })
342
+
343
+ it('会话出错时不自动消耗队列', async () => {
344
+ const emit = createEmit()
345
+
346
+ const transport: ChatTransport = (_req, handlers) => {
347
+ handlers.onError?.(new Error('网络错误'))
348
+ }
349
+
350
+ const chat = useChat({ props: { transport }, emit })
351
+ chat.send('第一条')
352
+ chat.send('第二条')
353
+
354
+ await vi.waitFor(() => {
355
+ expect(emit).toHaveBeenCalledWith('error', expect.anything())
356
+ })
357
+ expect(chat.running.value).toBe(false)
358
+ expect(chat.queue.value.map((q) => q.content)).toEqual(['第二条'])
359
+ })
241
360
 
361
+ it('clear 同时清空待发送队列', async () => {
362
+ const emit = createEmit()
363
+
364
+ const transport: ChatTransport = (req) => {
365
+ return new Promise<void>((resolve) => {
366
+ req.signal.addEventListener('abort', () => resolve(), { once: true })
367
+ })
368
+ }
369
+
370
+ const chat = useChat({ props: { transport }, emit })
371
+ chat.send('第一条')
372
+ chat.send('第二条')
242
373
  chat.clear()
243
374
  await nextTick()
375
+
244
376
  expect(chat.messages.value).toHaveLength(0)
377
+ expect(chat.queue.value).toHaveLength(0)
245
378
  })
246
379
 
247
380
  it('请求携带 model 与 reasoningLevel', async () => {
@@ -314,4 +447,124 @@ describe('useChat', () => {
314
447
  await nextTick()
315
448
  expect(chat.reasoningLevel.value).toBe('low')
316
449
  })
450
+
451
+ it('terminal 工具执行成功后结束对话,不再请求模型生成文字', async () => {
452
+ const emit = createEmit()
453
+ const requests: ChatTransportRequest[] = []
454
+
455
+ const transport: ChatTransport = (req, handlers) => {
456
+ requests.push(req)
457
+ handlers.onToolCall?.({ id: 'call-1', name: 'getWeather', arguments: '{"city":"北京"}' })
458
+ }
459
+
460
+ const tools: ChatTool[] = [
461
+ {
462
+ name: 'getWeather',
463
+ description: '查天气',
464
+ parameters: {},
465
+ terminal: true,
466
+ execute: () => ({ temperature: 26 })
467
+ }
468
+ ]
469
+
470
+ const chat = useChat({ props: { transport, tools }, emit })
471
+ chat.send('北京天气')
472
+ await waitFinish(emit)
473
+
474
+ // 只请求一轮;工具结果仍进入消息历史
475
+ expect(requests).toHaveLength(1)
476
+ const messages = chat.messages.value
477
+ expect(messages.map((m) => m.role)).toEqual(['user', 'assistant', 'tool'])
478
+ expect(messages[1].toolCalls?.[0]).toMatchObject({ status: 'success' })
479
+ })
480
+
481
+ it('terminal 工具执行失败时仍回灌模型', async () => {
482
+ const emit = createEmit()
483
+ let round = 0
484
+
485
+ const transport: ChatTransport = (_req, handlers) => {
486
+ round++
487
+ if (round === 1) {
488
+ handlers.onToolCall?.({ id: 'call-1', name: 'getWeather', arguments: '{}' })
489
+ } else {
490
+ handlers.onTextDelta('没查到,换个城市试试')
491
+ }
492
+ }
493
+
494
+ const tools: ChatTool[] = [
495
+ {
496
+ name: 'getWeather',
497
+ description: '查天气',
498
+ parameters: {},
499
+ terminal: true,
500
+ execute: () => {
501
+ throw new Error('城市不存在')
502
+ }
503
+ }
504
+ ]
505
+
506
+ const chat = useChat({ props: { transport, tools }, emit })
507
+ chat.send('火星天气')
508
+ await waitFinish(emit)
509
+
510
+ expect(round).toBe(2)
511
+ expect(chat.messages.value[1].toolCalls?.[0]).toMatchObject({ status: 'error' })
512
+ expect(chat.messages.value[3]).toMatchObject({
513
+ role: 'assistant',
514
+ content: '没查到,换个城市试试'
515
+ })
516
+ })
517
+
518
+ it('模型持续调用工具时达到 maxToolRounds 上限即停止', async () => {
519
+ const emit = createEmit()
520
+ const requests: ChatTransportRequest[] = []
521
+
522
+ const transport: ChatTransport = (req, handlers) => {
523
+ requests.push(req)
524
+ handlers.onToolCall?.({ id: `call-${requests.length}`, name: 'noop', arguments: '{}' })
525
+ }
526
+
527
+ const tools: ChatTool[] = [
528
+ { name: 'noop', description: '空操作', parameters: {}, execute: () => ({}) }
529
+ ]
530
+
531
+ const chat = useChat({ props: { transport, tools, maxToolRounds: 3 }, emit })
532
+ chat.send('hi')
533
+ await waitFinish(emit)
534
+
535
+ expect(requests).toHaveLength(3)
536
+ expect(chat.running.value).toBe(false)
537
+ })
538
+
539
+ it('受控 v-model:messages:父级回显快照不会冲掉流式中的 assistant 消息', async () => {
540
+ // 模拟受控父级:收到 update:messages 后在后续微任务把快照写回 props(v-model 回显路径)。
541
+ // 时序上回显必然晚于本地 push assistant 占位(同一同步块内),还原真实场景的竞态
542
+ const props = reactive<AiChatProps>({
543
+ transport: async (_req, handlers) => {
544
+ // 让回显先于文本到达(与真实父级 patch 时序一致)
545
+ await Promise.resolve()
546
+ handlers.onTextDelta('回答')
547
+ }
548
+ })
549
+ const emit = ((event: string, payload: unknown) => {
550
+ if (event === 'update:messages') {
551
+ const value = payload as ChatMessage[]
552
+ void Promise.resolve().then(() => {
553
+ props.messages = value
554
+ })
555
+ }
556
+ }) as unknown as AiChatEmits
557
+
558
+ const chat = useChat({ props, emit })
559
+ chat.send('hi')
560
+
561
+ await vi.waitFor(() => {
562
+ expect(chat.messages.value[1]?.status).toBe('done')
563
+ })
564
+
565
+ expect(chat.messages.value.map((m) => m.role)).toEqual(['user', 'assistant'])
566
+ expect(chat.messages.value[1].content).toBe('回答')
567
+ // 最终同步给父级的快照也包含 assistant 消息
568
+ expect(props.messages?.some((m) => m.role === 'assistant' && m.content === '回答')).toBe(true)
569
+ })
317
570
  })
@@ -236,4 +236,82 @@ describe('createOpenAITransport', () => {
236
236
  expect.objectContaining({ message: expect.stringContaining('unknown') })
237
237
  )
238
238
  })
239
+
240
+ it('工具调用参数分片跨 chunk 累积为一次完整 onToolCall', async () => {
241
+ // 同一工具调用的多个分片共享 index:0,不得被误判为"新一轮"而提前 flush
242
+ const sse = String.raw`data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"calculate","arguments":""}}]}}]}
243
+
244
+ data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"ex"}}]}}]}
245
+
246
+ data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"pression\":\"1+1\"}"}}]}}]}
247
+
248
+ data: [DONE]
249
+ `
250
+ vi.stubGlobal(
251
+ 'fetch',
252
+ vi.fn(async () => new Response(sse, { status: 200 }))
253
+ )
254
+
255
+ const transport = createOpenAITransport({
256
+ providers: [{ id: 'a', endpoint: 'https://a.example/chat', models: [{ id: 'm' }] }]
257
+ })
258
+ const handlers = baseHandlers()
259
+
260
+ await transport(
261
+ {
262
+ messages: [{ id: '1', role: 'user', content: 'hi' }],
263
+ signal: new AbortController().signal
264
+ },
265
+ handlers
266
+ )
267
+
268
+ expect(handlers.onToolCall).toHaveBeenCalledTimes(1)
269
+ expect(handlers.onToolCall).toHaveBeenCalledWith({
270
+ id: 'call_1',
271
+ name: 'calculate',
272
+ arguments: '{"expression":"1+1"}'
273
+ })
274
+ })
275
+
276
+ it('多个工具调用按 index 顺序完整抛出', async () => {
277
+ const sse = String.raw`data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"alpha","arguments":""}}]}}]}
278
+
279
+ data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"x\":1}"}}]}}]}
280
+
281
+ data: {"choices":[{"delta":{"tool_calls":[{"index":1,"id":"call_2","function":{"name":"beta","arguments":""}}]}}]}
282
+
283
+ data: {"choices":[{"delta":{"tool_calls":[{"index":1,"function":{"arguments":"{\"y\":2}"}}]}}]}
284
+
285
+ data: [DONE]
286
+ `
287
+ vi.stubGlobal(
288
+ 'fetch',
289
+ vi.fn(async () => new Response(sse, { status: 200 }))
290
+ )
291
+
292
+ const transport = createOpenAITransport({
293
+ providers: [{ id: 'a', endpoint: 'https://a.example/chat', models: [{ id: 'm' }] }]
294
+ })
295
+ const handlers = baseHandlers()
296
+
297
+ await transport(
298
+ {
299
+ messages: [{ id: '1', role: 'user', content: 'hi' }],
300
+ signal: new AbortController().signal
301
+ },
302
+ handlers
303
+ )
304
+
305
+ expect(handlers.onToolCall).toHaveBeenCalledTimes(2)
306
+ expect(handlers.onToolCall).toHaveBeenNthCalledWith(1, {
307
+ id: 'call_1',
308
+ name: 'alpha',
309
+ arguments: '{"x":1}'
310
+ })
311
+ expect(handlers.onToolCall).toHaveBeenNthCalledWith(2, {
312
+ id: 'call_2',
313
+ name: 'beta',
314
+ arguments: '{"y":2}'
315
+ })
316
+ })
239
317
  })
@@ -135,8 +135,8 @@ async function parseSSE(
135
135
  if (Array.isArray(delta.tool_calls)) {
136
136
  for (const tc of delta.tool_calls) {
137
137
  const index: number = tc.index ?? 0
138
- // index 回退说明新的一轮 tool_calls 开始,先把已累积的抛出去
139
- if (index <= lastIndex && accumulated.size > 0) flushToolCalls()
138
+ // 同一调用的参数分片共享 index;index 变大才说明前一个调用已完整,先抛出去
139
+ if (index > lastIndex && accumulated.size > 0) flushToolCalls()
140
140
  lastIndex = Math.max(lastIndex, index)
141
141
 
142
142
  let call = accumulated.get(index)
package/src/chat/types.ts CHANGED
@@ -57,6 +57,16 @@ export interface ChatMessage {
57
57
  status?: 'streaming' | 'done' | 'error' | 'aborted'
58
58
  }
59
59
 
60
+ /** 队列中的待发送消息(会话进行中提交的消息按序排队) */
61
+ export interface ChatQueuedMessage {
62
+ /** 队列项 id */
63
+ id: string
64
+ /** 消息内容 */
65
+ content: string
66
+ /** 携带的附件 */
67
+ attachments?: ChatAttachment[]
68
+ }
69
+
60
70
  /** 工具执行上下文 */
61
71
  export interface ChatToolContext {
62
72
  /** 本次工具调用 */
@@ -85,10 +95,36 @@ export interface ChatTool<A = any> {
85
95
  /**
86
96
  * 自定义工具卡片内容渲染(组件或渲染函数),props 为 ChatToolRenderProps。
87
97
  * 设置后替换卡片 body 的默认参数/结果展示;优先级高于 tool-<name> 插槽。
98
+ * renderTo 为 'panel' 时改为在右侧侧边面板中渲染。
88
99
  */
89
100
  render?: Component
90
- /** 执行完成后是否自动折叠。缺省:设置了 render 时为 false,否则为 true */
101
+ /**
102
+ * 渲染位置。缺省 'inline':render 组件展示在会话内的工具卡片中;
103
+ * 'panel':render 组件展示在对话区右侧的侧边面板(新调用自动打开面板,
104
+ * 工具卡片内仅保留「查看面板」入口,面板与会话区宽度可拖拽调节)。
105
+ * 适合打开后台页面、表单、图表、列表等需要较大交互区域的工具。
106
+ */
107
+ renderTo?: 'inline' | 'panel'
108
+ /**
109
+ * 侧边面板默认宽度(px,最小 320),仅 renderTo: 'panel' 时生效。
110
+ * 聚焦到该工具的调用时应用;缺省时面板打开取「容器宽 - 860」(即默认尽可能大,
111
+ * 给会话区保留 860px);面板已打开时切换聚焦保持当前宽度(含用户拖拽结果)。
112
+ */
113
+ panelWidth?: number
114
+ /**
115
+ * 侧边面板标题,仅 renderTo: 'panel' 时生效。
116
+ * 面板标题通常是「业务对象 + 动作」(如「编辑用户 · 张三」)而非工具名,
117
+ * 可传固定字符串,或传函数按本次调用的参数动态生成。缺省取 label ?? name。
118
+ */
119
+ panelTitle?: string | ((toolCall: ChatToolCall) => string)
120
+ /** 执行完成后是否自动折叠。缺省:renderTo 为 'panel' 时为 true;否则设置了 render 时为 false,否则为 true */
91
121
  autoCollapse?: boolean
122
+ /**
123
+ * 终结工具:执行成功后对话即结束,结果不再回灌模型生成额外文字(工具 UI 即最终答复)。
124
+ * 适合天气卡片这类"UI 即答案"的工具,需配合 render 或 tool-<name> 插槽提供完整结果 UI。
125
+ * 工具结果仍会记录到消息历史供后续轮次使用;执行失败/被拒绝时错误照常回灌模型。
126
+ */
127
+ terminal?: boolean
92
128
  /** 工具实现,返回值(或 Promise 返回值)会被 JSON 序列化后回灌给模型 */
93
129
  execute: (args: A, ctx: ChatToolContext) => unknown
94
130
  }
@@ -3,7 +3,13 @@ import { reactive, ref, watch, type Ref } from 'vue'
3
3
 
4
4
  import { createBuiltinTools } from '../tools'
5
5
  import type { AiChatEmits, AiChatProps } from '../types'
6
- import type { ChatAttachment, ChatMessage, ChatTool, ChatToolCall } from './types'
6
+ import type {
7
+ ChatAttachment,
8
+ ChatMessage,
9
+ ChatQueuedMessage,
10
+ ChatTool,
11
+ ChatToolCall
12
+ } from './types'
7
13
 
8
14
  export interface UseChatOptions {
9
15
  props: AiChatProps
@@ -57,9 +63,16 @@ export function useChat(options: UseChatOptions) {
57
63
  /** 是否正在生成中(含工具执行与多轮循环) */
58
64
  const running = ref(false)
59
65
 
66
+ /** 待发送队列:会话进行中提交的新消息按序排队,收尾后先进先出自动接续 */
67
+ const queue = ref<ChatQueuedMessage[]>([])
68
+
60
69
  let abortController: AbortController | null = null
61
70
  /** needsConfirm 工具的挂起确认器,key 为 toolCallId */
62
71
  const confirmResolvers = new Map<string, (approved: boolean) => void>()
72
+ /** 当前会话是否自然完成(finish);决定收尾后是否自动消耗队列 */
73
+ let finishedNaturally = false
74
+ /** startQueued 触发的中断需要在收尾后立即接续队首 */
75
+ let resumeAfterAbort = false
63
76
 
64
77
  /** 按当前模型校正推理等级(无 levels 则清空;值不合法则落到默认/首项) */
65
78
  const syncReasoningForModel = (modelId: string | undefined) => {
@@ -89,16 +102,78 @@ export function useChat(options: UseChatOptions) {
89
102
  { immediate: true }
90
103
  )
91
104
 
92
- /** 向父组件同步消息快照(流式 delta 过于频繁,不同步,只在关键节点同步) */
105
+ /**
106
+ * 向父组件同步消息快照(流式 delta 过于频繁,不同步,只在关键节点同步)。
107
+ * 必须经 setter 赋值触发 emit,而非直接 emit 切片:setter 让本地数组与下发的
108
+ * 快照保持同一引用,受控父级(v-model:messages)把快照回写到 props 时,
109
+ * useModel 同步回来的仍是这个数组(流式期间新增的 assistant 占位已 push 在其上);
110
+ * 若直接 emit 切片,本地数组与快照是两个对象,回显的旧快照会把本地新增的消息冲掉。
111
+ */
93
112
  const snapshot = () => {
94
- emit('update:messages', messages.value.slice())
113
+ messages.value = messages.value.slice()
95
114
  }
96
115
 
97
- /** 发送一条用户消息并启动对话循环 */
116
+ /**
117
+ * 发送一条用户消息。会话进行中时进入待发送队列(不丢失),
118
+ * 会话自然结束后按 FIFO 自动接续;空闲时立即开始新会话。
119
+ */
98
120
  const send = (content: string, attachments?: ChatAttachment[]) => {
99
- if (running.value) return
100
121
  if (!content.trim() && !attachments?.length) return
122
+ enqueue(content, attachments)
123
+ }
124
+
125
+ /**
126
+ * 向队列插入一条消息;beforeId 指定插到某条之前(缺省追加到尾部)。
127
+ * 空闲时立即消耗队首(保持先来先发的合理顺序)。
128
+ */
129
+ const enqueue = (
130
+ content: string,
131
+ attachments?: ChatAttachment[],
132
+ beforeId?: string
133
+ ): ChatQueuedMessage => {
134
+ const item: ChatQueuedMessage = { id: uid(), content, attachments }
135
+ const anchorIndex = beforeId ? queue.value.findIndex((q) => q.id === beforeId) : -1
136
+ if (anchorIndex === -1) {
137
+ queue.value.push(item)
138
+ } else {
139
+ queue.value.splice(anchorIndex, 0, item)
140
+ }
141
+ if (!running.value) drainQueue()
142
+ return item
143
+ }
144
+
145
+ /** 消耗队首并开始新一轮会话 */
146
+ const drainQueue = () => {
147
+ const next = queue.value.shift()
148
+ if (next) startUserTurn(next.content, next.attachments)
149
+ }
150
+
151
+ /** 立即执行队列中的某条:中断当前会话,该条插队为下一条(其余保持原顺序) */
152
+ const startQueued = (id: string) => {
153
+ const item = removeQueued(id)
154
+ if (!item) return
101
155
 
156
+ if (!running.value) {
157
+ startUserTurn(item.content, item.attachments)
158
+ return
159
+ }
160
+
161
+ // 插回队首并中断当前会话,收尾逻辑会自动接上它
162
+ queue.value.unshift(item)
163
+ resumeAfterAbort = true
164
+ abort()
165
+ }
166
+
167
+ /** 从队列移除某条(返回被移除项;编辑场景由 UI 取回内容) */
168
+ const removeQueued = (id: string): ChatQueuedMessage | undefined => {
169
+ const index = queue.value.findIndex((item) => item.id === id)
170
+ if (index === -1) return undefined
171
+ const [item] = queue.value.splice(index, 1)
172
+ return item
173
+ }
174
+
175
+ /** 推送用户消息并启动对话循环 */
176
+ const startUserTurn = (content: string, attachments?: ChatAttachment[]) => {
102
177
  const message = reactive<ChatMessage>({ id: uid(), role: 'user', content, attachments })
103
178
  messages.value.push(message)
104
179
  snapshot()
@@ -162,20 +237,28 @@ export function useChat(options: UseChatOptions) {
162
237
  if (!props.transport) return
163
238
 
164
239
  running.value = true
240
+ finishedNaturally = false
165
241
  const controller = new AbortController()
166
242
  abortController = controller
167
243
 
168
244
  try {
169
- await runRound(controller)
245
+ await runRound(controller, 0)
170
246
  } finally {
171
247
  running.value = false
172
248
  abortController = null
173
249
  snapshot()
250
+
251
+ // 队列接续:自然完成或 startQueued 中断插队时自动发下一条;
252
+ // 手动停止 / 出错时保留队列,交由用户处置
253
+ const shouldResume = finishedNaturally || resumeAfterAbort
254
+ finishedNaturally = false
255
+ resumeAfterAbort = false
256
+ if (shouldResume) drainQueue()
174
257
  }
175
258
  }
176
259
 
177
260
  /** 单轮生成;存在工具调用时执行后递归进入下一轮(递归避免 await-in-loop) */
178
- const runRound = async (controller: AbortController): Promise<void> => {
261
+ const runRound = async (controller: AbortController, depth: number): Promise<void> => {
179
262
  if (!props.transport) return
180
263
  const { signal } = controller
181
264
  const tools = resolveTools(props.tools)
@@ -238,7 +321,7 @@ export function useChat(options: UseChatOptions) {
238
321
 
239
322
  const toolCalls = assistant.toolCalls ?? []
240
323
  if (!toolCalls.length) {
241
- emit('finish', assistant)
324
+ completeAsFinish(assistant)
242
325
  return
243
326
  }
244
327
 
@@ -252,7 +335,28 @@ export function useChat(options: UseChatOptions) {
252
335
 
253
336
  if (signal.aborted) return
254
337
 
255
- return runRound(controller)
338
+ // 终结工具执行成功:工具 UI 即最终答复,不再回灌模型生成文字
339
+ const hitTerminal = toolCalls.some(
340
+ (call) => call.status === 'success' && tools.some((t) => t.name === call.name && t.terminal)
341
+ )
342
+ if (hitTerminal) {
343
+ completeAsFinish(assistant)
344
+ return
345
+ }
346
+
347
+ // 达到最大轮次上限:停止继续请求,防止模型失控循环调用工具
348
+ if (depth + 1 >= (props.maxToolRounds ?? 10)) {
349
+ completeAsFinish(assistant)
350
+ return
351
+ }
352
+
353
+ return runRound(controller, depth + 1)
354
+ }
355
+
356
+ /** 标记本轮对话自然完成(收尾后据此自动消耗待发送队列) */
357
+ const completeAsFinish = (assistant: ChatMessage) => {
358
+ finishedNaturally = true
359
+ emit('finish', assistant)
256
360
  }
257
361
 
258
362
  /** 中断当前生成,挂起的工具确认按拒绝处理 */
@@ -284,9 +388,10 @@ export function useChat(options: UseChatOptions) {
284
388
  void runConversation()
285
389
  }
286
390
 
287
- /** 清空消息,生成中则先中断 */
391
+ /** 清空消息与待发送队列,生成中则先中断 */
288
392
  const clear = () => {
289
393
  abort()
394
+ queue.value = []
290
395
  messages.value = []
291
396
  snapshot()
292
397
  }
@@ -296,10 +401,14 @@ export function useChat(options: UseChatOptions) {
296
401
  model,
297
402
  reasoningLevel,
298
403
  running,
404
+ queue,
299
405
  send,
300
406
  abort,
301
407
  regenerate,
302
408
  clear,
303
- respondToolCall
409
+ respondToolCall,
410
+ enqueue,
411
+ startQueued,
412
+ removeQueued
304
413
  }
305
414
  }