@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.
- package/dist/chat/index.d.ts +2 -2
- package/dist/chat/transports/openai.js +1 -1
- package/dist/chat/transports/openai.js.map +1 -1
- package/dist/chat/types.d.ts +37 -2
- package/dist/chat/use-chat.d.ts +23 -1
- package/dist/chat/use-chat.js +83 -8
- package/dist/chat/use-chat.js.map +1 -1
- package/dist/components/ai-chat/ai-chat.js +207 -46
- package/dist/components/ai-chat/ai-chat.js.map +1 -1
- package/dist/components/ai-chat/chat-input.js +37 -12
- package/dist/components/ai-chat/chat-input.js.map +1 -1
- package/dist/components/ai-chat/di.js.map +1 -1
- package/dist/components/ai-chat/message-item.js +101 -23
- package/dist/components/ai-chat/message-item.js.map +1 -1
- package/dist/components/ai-chat/message-list.js +217 -32
- package/dist/components/ai-chat/message-list.js.map +1 -1
- package/dist/components/ai-chat/model-picker.js +69 -46
- package/dist/components/ai-chat/model-picker.js.map +1 -1
- package/dist/components/ai-chat/queue-list.js +70 -0
- package/dist/components/ai-chat/queue-list.js.map +1 -0
- package/dist/components/ai-chat/side-panel.js +96 -0
- package/dist/components/ai-chat/side-panel.js.map +1 -0
- package/dist/components/ai-chat/style.d.ts +4 -0
- package/dist/components/ai-chat/style.js +4 -0
- package/dist/components/ai-chat/style2.css +457 -74
- package/dist/components/ai-chat/tool-call.js +60 -26
- package/dist/components/ai-chat/tool-call.js.map +1 -1
- package/dist/components/ai-orb/ai-orb.js +94 -0
- package/dist/components/ai-orb/ai-orb.js.map +1 -0
- package/dist/components/ai-orb/orb-renderer.js +411 -0
- package/dist/components/ai-orb/orb-renderer.js.map +1 -0
- package/dist/components/ai-orb/style.d.ts +1 -0
- package/dist/components/ai-orb/style.js +1 -0
- package/dist/components/ai-orb/style2.css +23 -0
- package/dist/index.d.ts +4 -2
- package/dist/index.js +2 -1
- package/dist/style.js +1 -0
- package/dist/types/ai-chat.d.ts +21 -7
- package/dist/types/ai-orb.d.ts +27 -0
- package/dist/types/index.d.ts +2 -1
- package/package.json +5 -5
- package/src/chat/__test__/use-chat.test.ts +224 -9
- package/src/chat/transports/__test__/openai.test.ts +78 -0
- package/src/chat/transports/openai.ts +2 -2
- package/src/chat/types.ts +37 -1
- package/src/chat/use-chat.ts +112 -9
- package/src/components/ai-chat/__test__/ai-chat.test.ts +588 -11
- package/src/components/ai-chat/ai-chat.vue +227 -31
- package/src/components/ai-chat/chat-input.vue +41 -11
- package/src/components/ai-chat/di.ts +2 -0
- package/src/components/ai-chat/message-item.vue +98 -11
- package/src/components/ai-chat/message-list.vue +261 -21
- package/src/components/ai-chat/model-picker.vue +110 -59
- package/src/components/ai-chat/queue-list.vue +63 -0
- package/src/components/ai-chat/side-panel.vue +76 -0
- package/src/components/ai-chat/style.scss +527 -74
- package/src/components/ai-chat/style.ts +4 -0
- package/src/components/ai-chat/tool-call.vue +86 -42
- package/src/components/ai-orb/__test__/ai-orb.test.ts +251 -0
- package/src/components/ai-orb/ai-orb.vue +107 -0
- package/src/components/ai-orb/index.ts +1 -0
- package/src/components/ai-orb/orb-renderer.ts +505 -0
- package/src/components/ai-orb/style.scss +10 -0
- package/src/components/ai-orb/style.ts +1 -0
- package/src/components/index.ts +1 -0
- package/src/style.ts +1 -0
- package/src/tools/ask-question/__test__/ask-question.test.ts +1 -1
- package/src/types/ai-chat.ts +21 -6
- package/src/types/ai-orb.ts +27 -0
- 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('
|
|
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
|
-
|
|
307
|
+
chat.abort()
|
|
230
308
|
|
|
231
|
-
await
|
|
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('
|
|
317
|
+
it('enqueue 支持 beforeId 锚点插入,保持前后顺序', async () => {
|
|
236
318
|
const emit = createEmit()
|
|
237
|
-
const chat = useChat({ props: { transport: textTransport('x') }, emit })
|
|
238
319
|
|
|
239
|
-
|
|
240
|
-
|
|
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
|
|
139
|
-
if (index
|
|
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
|
-
/**
|
|
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
|
}
|
package/src/chat/use-chat.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|