@veltra/ai 2.0.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 (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 +83 -8
  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 +224 -9
  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 +112 -9
  47. package/src/components/ai-chat/__test__/ai-chat.test.ts +588 -11
  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
@@ -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,22 +304,71 @@ 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('第二条')
241
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
+ }
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)
245
372
  })
246
373
 
247
374
  it('请求携带 model 与 reasoningLevel', async () => {
@@ -314,4 +441,92 @@ describe('useChat', () => {
314
441
  await nextTick()
315
442
  expect(chat.reasoningLevel.value).toBe('low')
316
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)
531
+ })
317
532
  })
@@ -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) => {
@@ -94,11 +107,67 @@ export function useChat(options: UseChatOptions) {
94
107
  emit('update:messages', messages.value.slice())
95
108
  }
96
109
 
97
- /** 发送一条用户消息并启动对话循环 */
110
+ /**
111
+ * 发送一条用户消息。会话进行中时进入待发送队列(不丢失),
112
+ * 会话自然结束后按 FIFO 自动接续;空闲时立即开始新会话。
113
+ */
98
114
  const send = (content: string, attachments?: ChatAttachment[]) => {
99
- if (running.value) return
100
115
  if (!content.trim() && !attachments?.length) return
116
+ enqueue(content, attachments)
117
+ }
118
+
119
+ /**
120
+ * 向队列插入一条消息;beforeId 指定插到某条之前(缺省追加到尾部)。
121
+ * 空闲时立即消耗队首(保持先来先发的合理顺序)。
122
+ */
123
+ const enqueue = (
124
+ content: string,
125
+ attachments?: ChatAttachment[],
126
+ beforeId?: string
127
+ ): ChatQueuedMessage => {
128
+ const item: ChatQueuedMessage = { id: uid(), content, attachments }
129
+ const anchorIndex = beforeId ? queue.value.findIndex((q) => q.id === beforeId) : -1
130
+ if (anchorIndex === -1) {
131
+ queue.value.push(item)
132
+ } else {
133
+ queue.value.splice(anchorIndex, 0, item)
134
+ }
135
+ if (!running.value) drainQueue()
136
+ return item
137
+ }
138
+
139
+ /** 消耗队首并开始新一轮会话 */
140
+ const drainQueue = () => {
141
+ const next = queue.value.shift()
142
+ if (next) startUserTurn(next.content, next.attachments)
143
+ }
144
+
145
+ /** 立即执行队列中的某条:中断当前会话,该条插队为下一条(其余保持原顺序) */
146
+ const startQueued = (id: string) => {
147
+ const item = removeQueued(id)
148
+ if (!item) return
101
149
 
150
+ if (!running.value) {
151
+ startUserTurn(item.content, item.attachments)
152
+ return
153
+ }
154
+
155
+ // 插回队首并中断当前会话,收尾逻辑会自动接上它
156
+ queue.value.unshift(item)
157
+ resumeAfterAbort = true
158
+ abort()
159
+ }
160
+
161
+ /** 从队列移除某条(返回被移除项;编辑场景由 UI 取回内容) */
162
+ const removeQueued = (id: string): ChatQueuedMessage | undefined => {
163
+ const index = queue.value.findIndex((item) => item.id === id)
164
+ if (index === -1) return undefined
165
+ const [item] = queue.value.splice(index, 1)
166
+ return item
167
+ }
168
+
169
+ /** 推送用户消息并启动对话循环 */
170
+ const startUserTurn = (content: string, attachments?: ChatAttachment[]) => {
102
171
  const message = reactive<ChatMessage>({ id: uid(), role: 'user', content, attachments })
103
172
  messages.value.push(message)
104
173
  snapshot()
@@ -162,20 +231,28 @@ export function useChat(options: UseChatOptions) {
162
231
  if (!props.transport) return
163
232
 
164
233
  running.value = true
234
+ finishedNaturally = false
165
235
  const controller = new AbortController()
166
236
  abortController = controller
167
237
 
168
238
  try {
169
- await runRound(controller)
239
+ await runRound(controller, 0)
170
240
  } finally {
171
241
  running.value = false
172
242
  abortController = null
173
243
  snapshot()
244
+
245
+ // 队列接续:自然完成或 startQueued 中断插队时自动发下一条;
246
+ // 手动停止 / 出错时保留队列,交由用户处置
247
+ const shouldResume = finishedNaturally || resumeAfterAbort
248
+ finishedNaturally = false
249
+ resumeAfterAbort = false
250
+ if (shouldResume) drainQueue()
174
251
  }
175
252
  }
176
253
 
177
254
  /** 单轮生成;存在工具调用时执行后递归进入下一轮(递归避免 await-in-loop) */
178
- const runRound = async (controller: AbortController): Promise<void> => {
255
+ const runRound = async (controller: AbortController, depth: number): Promise<void> => {
179
256
  if (!props.transport) return
180
257
  const { signal } = controller
181
258
  const tools = resolveTools(props.tools)
@@ -238,7 +315,7 @@ export function useChat(options: UseChatOptions) {
238
315
 
239
316
  const toolCalls = assistant.toolCalls ?? []
240
317
  if (!toolCalls.length) {
241
- emit('finish', assistant)
318
+ completeAsFinish(assistant)
242
319
  return
243
320
  }
244
321
 
@@ -252,7 +329,28 @@ export function useChat(options: UseChatOptions) {
252
329
 
253
330
  if (signal.aborted) return
254
331
 
255
- return runRound(controller)
332
+ // 终结工具执行成功:工具 UI 即最终答复,不再回灌模型生成文字
333
+ const hitTerminal = toolCalls.some(
334
+ (call) => call.status === 'success' && tools.some((t) => t.name === call.name && t.terminal)
335
+ )
336
+ if (hitTerminal) {
337
+ completeAsFinish(assistant)
338
+ return
339
+ }
340
+
341
+ // 达到最大轮次上限:停止继续请求,防止模型失控循环调用工具
342
+ if (depth + 1 >= (props.maxToolRounds ?? 10)) {
343
+ completeAsFinish(assistant)
344
+ return
345
+ }
346
+
347
+ return runRound(controller, depth + 1)
348
+ }
349
+
350
+ /** 标记本轮对话自然完成(收尾后据此自动消耗待发送队列) */
351
+ const completeAsFinish = (assistant: ChatMessage) => {
352
+ finishedNaturally = true
353
+ emit('finish', assistant)
256
354
  }
257
355
 
258
356
  /** 中断当前生成,挂起的工具确认按拒绝处理 */
@@ -284,9 +382,10 @@ export function useChat(options: UseChatOptions) {
284
382
  void runConversation()
285
383
  }
286
384
 
287
- /** 清空消息,生成中则先中断 */
385
+ /** 清空消息与待发送队列,生成中则先中断 */
288
386
  const clear = () => {
289
387
  abort()
388
+ queue.value = []
290
389
  messages.value = []
291
390
  snapshot()
292
391
  }
@@ -296,10 +395,14 @@ export function useChat(options: UseChatOptions) {
296
395
  model,
297
396
  reasoningLevel,
298
397
  running,
398
+ queue,
299
399
  send,
300
400
  abort,
301
401
  regenerate,
302
402
  clear,
303
- respondToolCall
403
+ respondToolCall,
404
+ enqueue,
405
+ startQueued,
406
+ removeQueued
304
407
  }
305
408
  }