@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.
- 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 +91 -10
- 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 +266 -13
- 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 +120 -11
- package/src/components/ai-chat/__test__/ai-chat.test.ts +628 -12
- 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
|
@@ -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 {
|
|
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('
|
|
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
|
-
|
|
313
|
+
chat.abort()
|
|
230
314
|
|
|
231
|
-
await
|
|
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('
|
|
323
|
+
it('enqueue 支持 beforeId 锚点插入,保持前后顺序', async () => {
|
|
236
324
|
const emit = createEmit()
|
|
237
|
-
const chat = useChat({ props: { transport: textTransport('x') }, emit })
|
|
238
325
|
|
|
239
|
-
|
|
240
|
-
|
|
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
|
|
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) => {
|
|
@@ -89,16 +102,78 @@ export function useChat(options: UseChatOptions) {
|
|
|
89
102
|
{ immediate: true }
|
|
90
103
|
)
|
|
91
104
|
|
|
92
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|