@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
@@ -19,7 +19,7 @@ vi.mock('markstream-vue', () => ({
19
19
  function mountAiChat(options: {
20
20
  transport: ChatTransport
21
21
  tools?: ChatTool[]
22
- welcome?: string
22
+ welcome?: string | string[]
23
23
  models?: ChatModelOption[]
24
24
  model?: string
25
25
  reasoningLevel?: string
@@ -65,6 +65,8 @@ describe('UAiChat', () => {
65
65
  const { host, unmount } = mountAiChat({ transport: () => {}, welcome: '有什么可以帮你?' })
66
66
 
67
67
  expect(host.querySelector('.u-ai-chat__welcome')?.textContent).toContain('有什么可以帮你?')
68
+ const orb = host.querySelector('.u-ai-chat__welcome canvas.u-ai-orb') as HTMLElement | null
69
+ expect(orb?.style.width).toBe('64px')
68
70
  unmount()
69
71
  })
70
72
 
@@ -86,6 +88,37 @@ describe('UAiChat', () => {
86
88
  unmount()
87
89
  })
88
90
 
91
+ it('对话结束后展示复制与重新生成操作,复制写入剪贴板', async () => {
92
+ const writeText = vi.fn().mockResolvedValue(undefined)
93
+ vi.stubGlobal('navigator', { ...navigator, clipboard: { writeText } })
94
+
95
+ const transport: ChatTransport = (_req, handlers) => {
96
+ handlers.onTextDelta('可复制的内容')
97
+ }
98
+ const { host, chat, unmount } = mountAiChat({ transport })
99
+
100
+ chat.value?.send('你好')
101
+ await vi.waitFor(() => {
102
+ expect(host.querySelector('.u-ai-chat__message-actions')).toBeTruthy()
103
+ })
104
+
105
+ const actions = host.querySelectorAll('.u-ai-chat__message-actions .u-button')
106
+ expect(actions).toHaveLength(2)
107
+ expect(actions[0]?.classList.contains('is-circle')).toBe(true)
108
+ expect(actions[0]?.getAttribute('title')).toBe('复制')
109
+ expect(actions[1]?.classList.contains('is-circle')).toBe(true)
110
+ expect(actions[1]?.getAttribute('title')).toBe('重新生成')
111
+
112
+ actions[0]?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
113
+ await vi.waitFor(() => {
114
+ expect(writeText).toHaveBeenCalledWith('可复制的内容')
115
+ expect(actions[0]?.getAttribute('title')).toBe('已复制')
116
+ })
117
+
118
+ vi.unstubAllGlobals()
119
+ unmount()
120
+ })
121
+
89
122
  it('工具调用渲染工具卡片,可展开查看结果', async () => {
90
123
  let round = 0
91
124
  const transport: ChatTransport = (_req, handlers) => {
@@ -116,7 +149,7 @@ describe('UAiChat', () => {
116
149
  header.dispatchEvent(new MouseEvent('click', { bubbles: true }))
117
150
  await nextTick()
118
151
 
119
- const body = host.querySelector('.u-ai-chat__tool-call .u-collapse__content')
152
+ const body = host.querySelector('.u-ai-chat__tool-call .u-collapse__content-wrapper')
120
153
  expect(body?.textContent).toContain('"expression": "1+1"')
121
154
  expect(body?.textContent).toContain('"value": 2')
122
155
  unmount()
@@ -228,7 +261,7 @@ describe('UAiChat', () => {
228
261
 
229
262
  // 完成后保持展开,默认参数/结果区被替换
230
263
  await vi.waitFor(() => {
231
- expect(host.querySelector('.u-ai-chat__tool-call.is-success.is-expanded')).toBeTruthy()
264
+ expect(host.querySelector('.u-ai-chat__tool-call.is-success.is-active')).toBeTruthy()
232
265
  })
233
266
  expect(host.querySelector('.weather-view')?.textContent).toContain('{"temperature":26}')
234
267
  expect(host.querySelector('.u-ai-chat__tool-call-code')).toBeFalsy()
@@ -259,11 +292,11 @@ describe('UAiChat', () => {
259
292
  chat.value?.send('1+1')
260
293
 
261
294
  await vi.waitFor(() => {
262
- expect(host.querySelector('.u-ai-chat__tool-call.is-success.is-expanded')).toBeTruthy()
295
+ expect(host.querySelector('.u-ai-chat__tool-call.is-success.is-active')).toBeTruthy()
263
296
  })
264
- expect(host.querySelector('.u-ai-chat__tool-call .u-collapse__content')?.textContent).toContain(
265
- '"value": 2'
266
- )
297
+ expect(
298
+ host.querySelector('.u-ai-chat__tool-call .u-collapse__content-wrapper')?.textContent
299
+ ).toContain('"value": 2')
267
300
  unmount()
268
301
  })
269
302
 
@@ -277,9 +310,101 @@ describe('UAiChat', () => {
277
310
  expect(host.querySelector('.u-ai-chat__tool-call.is-success')).toBeTruthy()
278
311
  })
279
312
  await nextTick()
280
- expect(host.querySelector('.u-ai-chat__tool-call.is-expanded')).toBeFalsy()
281
- const contentWrapper = host.querySelector('.u-ai-chat__tool-call .u-collapse__content-wrapper')
282
- expect(contentWrapper?.getAttribute('aria-hidden')).toBe('true')
313
+ expect(host.querySelector('.u-ai-chat__tool-call.is-active')).toBeFalsy()
314
+ const body = host.querySelector('.u-ai-chat__tool-call .u-collapse__content-wrapper')
315
+ expect(body?.getAttribute('aria-hidden')).toBe('true')
316
+ unmount()
317
+ })
318
+
319
+ it('终态工具卡片折叠后卸载内容 DOM,展开时重新挂载', async () => {
320
+ let round = 0
321
+ const transport: ChatTransport = (_req, handlers) => {
322
+ round++
323
+ if (round === 1) {
324
+ handlers.onToolCall?.({ id: 'c1', name: 'calculate', arguments: '{"expression":"1+1"}' })
325
+ } else {
326
+ handlers.onTextDelta('算好了')
327
+ }
328
+ }
329
+ const tools: ChatTool[] = [
330
+ { name: 'calculate', description: '计算', parameters: {}, execute: () => ({ value: 2 }) }
331
+ ]
332
+ const { host, chat, unmount } = mountAiChat({ transport, tools })
333
+
334
+ chat.value?.send('1+1 等于几')
335
+
336
+ await vi.waitFor(() => {
337
+ expect(host.querySelector('.u-ai-chat__tool-call.is-success')).toBeTruthy()
338
+ })
339
+ await nextTick()
340
+
341
+ // 完成后自动折叠:内容 DOM 已卸载(减少内存与渲染成本),包装容器保留
342
+ expect(host.querySelector('.u-ai-chat__tool-call.is-active')).toBeFalsy()
343
+ expect(host.querySelector('.u-ai-chat__tool-call .u-collapse__content')).toBeFalsy()
344
+ expect(host.querySelector('.u-ai-chat__tool-call .u-collapse__content-wrapper')).toBeTruthy()
345
+
346
+ // 展开后内容重新挂载
347
+ const header = host.querySelector<HTMLElement>('.u-ai-chat__tool-call .u-collapse__header')!
348
+ header.dispatchEvent(new MouseEvent('click', { bubbles: true }))
349
+ await nextTick()
350
+ await nextTick()
351
+ expect(host.querySelector('.u-ai-chat__tool-call .u-collapse__content')?.textContent).toContain(
352
+ '"value": 2'
353
+ )
354
+ unmount()
355
+ })
356
+
357
+ it('思考过程流式结束后自动折叠并卸载内容 DOM', async () => {
358
+ const transport: ChatTransport = (_req, handlers) => {
359
+ handlers.onReasoningDelta?.('先分析一下问题')
360
+ handlers.onTextDelta('答案')
361
+ }
362
+ const { host, chat, unmount } = mountAiChat({ transport })
363
+
364
+ chat.value?.send('hi')
365
+
366
+ await vi.waitFor(() => {
367
+ expect(host.querySelector('.md-stub')?.textContent).toContain('答案')
368
+ })
369
+
370
+ // 结束后自动折叠:头部保留,内容 DOM 完全卸载
371
+ expect(host.querySelector('.u-ai-chat__reasoning-title')?.textContent).toContain('思考过程')
372
+ expect(host.querySelector('.u-ai-chat__reasoning-content')).toBeFalsy()
373
+
374
+ // 展开后内容重新挂载
375
+ host.querySelector<HTMLElement>('.u-ai-chat__reasoning-header')!.click()
376
+ await nextTick()
377
+ expect(host.querySelector('.u-ai-chat__reasoning-text')?.textContent).toContain(
378
+ '先分析一下问题'
379
+ )
380
+ unmount()
381
+ })
382
+
383
+ it('流式期间用户上滚取消吸底,展示「最新消息」入口且不被后续输出重新吸附', async () => {
384
+ const transport: ChatTransport = async (_req, handlers) => {
385
+ handlers.onTextDelta('第一段')
386
+ await new Promise((resolve) => setTimeout(resolve, 30))
387
+ handlers.onTextDelta('第二段')
388
+ }
389
+ const { host, chat, unmount } = mountAiChat({ transport })
390
+
391
+ chat.value?.send('hi')
392
+ await vi.waitFor(() => {
393
+ expect(host.querySelector('.md-stub')?.textContent).toContain('第一段')
394
+ })
395
+ expect(host.querySelector('.u-ai-chat__to-latest')).toBeFalsy()
396
+
397
+ // 用户向上滚动 → 立即取消吸附,展示回底入口
398
+ const container = host.querySelector<HTMLElement>('.u-ai-chat__list .u-scroll__container')!
399
+ container.dispatchEvent(new WheelEvent('wheel', { deltaY: -120, bubbles: true }))
400
+ await nextTick()
401
+ expect(host.querySelector('.u-ai-chat__to-latest')).toBeTruthy()
402
+
403
+ // 后续流式输出不会把用户拉回底部(入口仍在)
404
+ await vi.waitFor(() => {
405
+ expect(host.querySelector('.md-stub')?.textContent).toContain('第二段')
406
+ })
407
+ expect(host.querySelector('.u-ai-chat__to-latest')).toBeTruthy()
283
408
  unmount()
284
409
  })
285
410
 
@@ -301,6 +426,196 @@ describe('UAiChat', () => {
301
426
  unmount()
302
427
  })
303
428
 
429
+ /** 渲染 toolCall.arguments 的自定义视图桩(区分多次调用) */
430
+ const createArgsView = (className: string) =>
431
+ defineComponent({
432
+ props: { toolCall: { type: Object as PropType<ChatToolCall>, required: true } },
433
+ setup(props) {
434
+ return () => h('div', { class: className }, `args:${props.toolCall.arguments}`)
435
+ }
436
+ })
437
+
438
+ /** 第一轮发起 openPage 工具调用,第二轮输出文本 */
439
+ const createPageTransport = (calls: { id: string; arguments: string }[] = []) => {
440
+ let round = 0
441
+ const transport: ChatTransport = (_req, handlers) => {
442
+ round++
443
+ if (round === 1) {
444
+ const list = calls.length ? calls : [{ id: 'c1', arguments: '{"page":"form"}' }]
445
+ for (const call of list) {
446
+ handlers.onToolCall?.({ ...call, name: 'openPage' })
447
+ }
448
+ } else {
449
+ handlers.onTextDelta('已在右侧打开')
450
+ }
451
+ }
452
+ return transport
453
+ }
454
+
455
+ const pageTool = (extra: Partial<ChatTool> = {}): ChatTool => ({
456
+ name: 'openPage',
457
+ label: '后台页面',
458
+ description: '打开后台页面',
459
+ parameters: {},
460
+ execute: () => ({ opened: true }),
461
+ ...extra
462
+ })
463
+
464
+ it('renderTo: panel 工具在右侧面板渲染,卡片 body 仅提供查看入口', async () => {
465
+ const tools = [pageTool({ render: createResultView('page-view'), renderTo: 'panel' })]
466
+ const { host, chat, unmount } = mountAiChat({ transport: createPageTransport(), tools })
467
+
468
+ chat.value?.send('打开表单页')
469
+
470
+ // 面板自动打开,render 组件挂在面板中,面板标题取工具 label
471
+ await vi.waitFor(() => {
472
+ expect(host.querySelector('.u-ai-chat__panel .page-view')).toBeTruthy()
473
+ })
474
+ expect(host.querySelector('.u-ai-chat__panel-title')?.textContent).toBe('后台页面')
475
+
476
+ // 卡片 body 不渲染 render 组件,仅提供「查看面板」入口
477
+ await vi.waitFor(() => {
478
+ expect(host.querySelector('.u-ai-chat__tool-call.is-success')).toBeTruthy()
479
+ })
480
+ expect(host.querySelector('.u-ai-chat__tool-call .page-view')).toBeFalsy()
481
+ expect(host.querySelector('.u-ai-chat__tool-call-panel-entry')).toBeTruthy()
482
+ unmount()
483
+ })
484
+
485
+ it('面板可关闭,点击工具卡片入口重新打开', async () => {
486
+ const tools = [pageTool({ render: createResultView('page-view'), renderTo: 'panel' })]
487
+ const { host, chat, unmount } = mountAiChat({ transport: createPageTransport(), tools })
488
+
489
+ chat.value?.send('打开表单页')
490
+ await vi.waitFor(() => {
491
+ expect(host.querySelector('.u-ai-chat__panel')).toBeTruthy()
492
+ })
493
+
494
+ host.querySelector<HTMLElement>('.u-ai-chat__panel-close')!.click()
495
+ await nextTick()
496
+ expect(host.querySelector('.u-ai-chat__panel')).toBeFalsy()
497
+
498
+ const entryBtn = [
499
+ ...host.querySelectorAll<HTMLElement>('.u-ai-chat__tool-call-panel-entry button')
500
+ ].find((btn) => btn.textContent?.includes('查看面板'))!
501
+ entryBtn.click()
502
+ await nextTick()
503
+ expect(host.querySelector('.u-ai-chat__panel .page-view')).toBeTruthy()
504
+ unmount()
505
+ })
506
+
507
+ it('新的面板调用自动聚焦,卡片入口可切回历史调用', async () => {
508
+ const tools = [pageTool({ render: createArgsView('page-view'), renderTo: 'panel' })]
509
+ const transport = createPageTransport([
510
+ { id: 'c1', arguments: '{"page":"form"}' },
511
+ { id: 'c2', arguments: '{"page":"list"}' }
512
+ ])
513
+ const { host, chat, unmount } = mountAiChat({ transport, tools })
514
+
515
+ chat.value?.send('打开表单页和列表页')
516
+
517
+ // 自动聚焦最近一次面板调用(list)
518
+ await vi.waitFor(() => {
519
+ expect(host.querySelector('.u-ai-chat__panel .page-view')?.textContent).toContain('list')
520
+ })
521
+
522
+ // 点击第一张卡片的「查看面板」切回 form
523
+ const firstCard = host.querySelectorAll<HTMLElement>('.u-ai-chat__tool-call')[0]!
524
+ const entryBtn = [
525
+ ...firstCard.querySelectorAll<HTMLElement>('.u-ai-chat__tool-call-panel-entry button')
526
+ ].find((btn) => btn.textContent?.includes('查看面板'))!
527
+ entryBtn.click()
528
+ await nextTick()
529
+ expect(host.querySelector('.u-ai-chat__panel .page-view')?.textContent).toContain('form')
530
+ unmount()
531
+ })
532
+
533
+ it('面板与会话区经 ULayout 列轨分隔并渲染拖拽手柄', async () => {
534
+ const tools = [pageTool({ render: createResultView('page-view'), renderTo: 'panel' })]
535
+ const { host, chat, unmount } = mountAiChat({ transport: createPageTransport(), tools })
536
+
537
+ chat.value?.send('打开表单页')
538
+ await vi.waitFor(() => {
539
+ expect(host.querySelector('.u-ai-chat__panel')).toBeTruthy()
540
+ })
541
+
542
+ // 面板默认宽:容器宽度未知(测试环境 clientWidth 为 0)时回退 420px,ULayout 渲染列间拖拽手柄
543
+ const layout = host.querySelector<HTMLElement>('.u-ai-chat')!
544
+ expect(layout.style.gridTemplateColumns).toBe('1fr 420px')
545
+ expect(host.querySelector('.u-layout__resizer')).toBeTruthy()
546
+
547
+ // 面板关闭后回到单列,手柄移除
548
+ host.querySelector<HTMLElement>('.u-ai-chat__panel-close')!.click()
549
+ await vi.waitFor(() => {
550
+ expect(layout.style.gridTemplateColumns).toBe('1fr')
551
+ expect(host.querySelector('.u-layout__resizer')).toBeFalsy()
552
+ })
553
+ unmount()
554
+ })
555
+
556
+ it('面板工具可通过 panelWidth 指定默认宽度', async () => {
557
+ const tools = [
558
+ pageTool({ render: createResultView('page-view'), renderTo: 'panel', panelWidth: 480 })
559
+ ]
560
+ const { host, chat, unmount } = mountAiChat({ transport: createPageTransport(), tools })
561
+
562
+ chat.value?.send('打开表单页')
563
+ await vi.waitFor(() => {
564
+ expect(host.querySelector('.u-ai-chat__panel')).toBeTruthy()
565
+ })
566
+ const layout = host.querySelector<HTMLElement>('.u-ai-chat')!
567
+ expect(layout.style.gridTemplateColumns).toBe('1fr 480px')
568
+ unmount()
569
+ })
570
+
571
+ it('不同工具面板切换时应用各自的 panelWidth', async () => {
572
+ const reportTool: ChatTool = {
573
+ name: 'openReport',
574
+ label: '报表页面',
575
+ description: '打开报表',
576
+ parameters: {},
577
+ render: createResultView('report-view'),
578
+ renderTo: 'panel',
579
+ panelWidth: 640,
580
+ execute: () => ({ opened: true })
581
+ }
582
+ const tools = [
583
+ pageTool({ render: createResultView('page-view'), renderTo: 'panel', panelWidth: 480 }),
584
+ reportTool
585
+ ]
586
+ let round = 0
587
+ const transport: ChatTransport = (_req, handlers) => {
588
+ round++
589
+ if (round === 1) {
590
+ handlers.onToolCall?.({ id: 'c1', name: 'openPage', arguments: '{"page":"form"}' })
591
+ handlers.onToolCall?.({ id: 'c2', name: 'openReport', arguments: '{}' })
592
+ } else {
593
+ handlers.onTextDelta('已打开')
594
+ }
595
+ }
596
+ const { host, chat, unmount } = mountAiChat({ transport, tools })
597
+
598
+ chat.value?.send('打开表单页和报表页')
599
+
600
+ // 自动聚焦最近一次调用(openReport → 640px)
601
+ await vi.waitFor(() => {
602
+ expect(host.querySelector('.u-ai-chat__panel .report-view')).toBeTruthy()
603
+ })
604
+ const layout = host.querySelector<HTMLElement>('.u-ai-chat')!
605
+ expect(layout.style.gridTemplateColumns).toBe('1fr 640px')
606
+
607
+ // 点击 openPage 卡片的「查看面板」→ 切换并应用 480px
608
+ const firstCard = host.querySelectorAll<HTMLElement>('.u-ai-chat__tool-call')[0]!
609
+ const entryBtn = [
610
+ ...firstCard.querySelectorAll<HTMLElement>('.u-ai-chat__tool-call-panel-entry button')
611
+ ].find((btn) => btn.textContent?.includes('查看面板'))!
612
+ entryBtn.click()
613
+ await nextTick()
614
+ expect(host.querySelector('.u-ai-chat__panel .page-view')).toBeTruthy()
615
+ expect(layout.style.gridTemplateColumns).toBe('1fr 480px')
616
+ unmount()
617
+ })
618
+
304
619
  it('输入区使用原生 textarea 与 UFilePicker', () => {
305
620
  const { host, unmount } = mountAiChat({ transport: () => {} })
306
621
 
@@ -309,7 +624,7 @@ describe('UAiChat', () => {
309
624
  unmount()
310
625
  })
311
626
 
312
- it('生成中输入区显示停止按钮', async () => {
627
+ it('生成中空输入显示停止,有内容则显示入队发送', async () => {
313
628
  const transport: ChatTransport = (req) => {
314
629
  return new Promise<void>((resolve) => {
315
630
  req.signal.addEventListener('abort', () => resolve(), { once: true })
@@ -320,6 +635,29 @@ describe('UAiChat', () => {
320
635
  chat.value?.send('hi')
321
636
  await nextTick()
322
637
 
638
+ const actionButtons = () => host.querySelectorAll('.u-ai-chat__input-toolbar-right > .u-button')
639
+
640
+ expect(host.querySelector('.u-ai-chat__input-stop')).toBeTruthy()
641
+ expect(actionButtons()).toHaveLength(1)
642
+
643
+ const textarea = host.querySelector<HTMLTextAreaElement>('textarea.u-ai-chat__input')!
644
+ textarea.value = '下一条'
645
+ textarea.dispatchEvent(new Event('input'))
646
+ await nextTick()
647
+
648
+ expect(host.querySelector('.u-ai-chat__input-stop')).toBeFalsy()
649
+ expect(actionButtons()).toHaveLength(1)
650
+ const sendBtn = actionButtons()[0]!
651
+ expect(sendBtn.classList.contains('is-disabled')).toBe(false)
652
+ expect(sendBtn.getAttribute('title')).toBe('加入待发送队列')
653
+
654
+ sendBtn.click()
655
+ await nextTick()
656
+
657
+ expect(
658
+ [...host.querySelectorAll('.u-ai-chat__queue-text')].map((el) => el.textContent)
659
+ ).toEqual(['下一条'])
660
+ expect(textarea.value).toBe('')
323
661
  expect(host.querySelector('.u-ai-chat__input-stop')).toBeTruthy()
324
662
 
325
663
  chat.value?.abort()
@@ -414,4 +752,243 @@ describe('UAiChat', () => {
414
752
  app.unmount()
415
753
  host.remove()
416
754
  })
755
+
756
+ it('生成中提交的消息进入队列并渲染队列 UI', async () => {
757
+ const transport: ChatTransport = (req) => {
758
+ return new Promise<void>((resolve) => {
759
+ req.signal.addEventListener('abort', () => resolve(), { once: true })
760
+ })
761
+ }
762
+ const { host, chat, unmount } = mountAiChat({ transport })
763
+
764
+ chat.value?.send('第一条')
765
+ await nextTick()
766
+ chat.value?.send('第二条')
767
+ chat.value?.send('第三条')
768
+ await nextTick()
769
+
770
+ const items = [...host.querySelectorAll('.u-ai-chat__queue-text')].map((el) => el.textContent)
771
+ expect(items).toEqual(['第二条', '第三条'])
772
+ expect(host.querySelector('.u-ai-chat__queue-head')?.textContent).toContain('2')
773
+ unmount()
774
+ })
775
+
776
+ it('队列「立即开始」中断当前会话并插队执行', async () => {
777
+ const transport: ChatTransport = (req, handlers) => {
778
+ const lastUser = [...req.messages].reverse().find((m) => m.role === 'user')
779
+ if (lastUser?.content === '第一条') {
780
+ return new Promise<void>((resolve) => {
781
+ req.signal.addEventListener('abort', () => resolve(), { once: true })
782
+ })
783
+ }
784
+ handlers.onTextDelta(`回答:${lastUser?.content}`)
785
+ }
786
+ const { host, chat, unmount } = mountAiChat({ transport })
787
+
788
+ chat.value?.send('第一条')
789
+ await nextTick()
790
+ chat.value?.send('第二条')
791
+ await vi.waitFor(() => {
792
+ expect(host.querySelector('.u-ai-chat__queue-item')).toBeTruthy()
793
+ })
794
+
795
+ const startBtn = [...host.querySelectorAll<HTMLElement>('.u-ai-chat__queue-action')].find(
796
+ (btn) => btn.textContent?.includes('立即开始')
797
+ )!
798
+ startBtn.click()
799
+
800
+ await vi.waitFor(() => {
801
+ expect(host.querySelector('.md-stub')?.textContent).toContain('回答:第二条')
802
+ })
803
+ // 第一条被中断,队列已清空
804
+ expect(host.textContent).toContain('已停止生成')
805
+ expect(host.querySelector('.u-ai-chat__queue')).toBeFalsy()
806
+ unmount()
807
+ })
808
+
809
+ it('队列「编辑」取回输入框,重新提交后插回原位置', async () => {
810
+ const transport: ChatTransport = (req) => {
811
+ return new Promise<void>((resolve) => {
812
+ req.signal.addEventListener('abort', () => resolve(), { once: true })
813
+ })
814
+ }
815
+ const { host, chat, unmount } = mountAiChat({ transport })
816
+
817
+ chat.value?.send('第一条')
818
+ await nextTick()
819
+ chat.value?.send('A')
820
+ chat.value?.send('B')
821
+ await nextTick()
822
+
823
+ // 点击 A 的编辑按钮
824
+ const itemA = [...host.querySelectorAll<HTMLElement>('.u-ai-chat__queue-item')].find((el) =>
825
+ el.textContent?.includes('A')
826
+ )!
827
+ itemA.querySelector<HTMLElement>('[title="取回输入框编辑"]')!.click()
828
+ await nextTick()
829
+
830
+ const textarea = host.querySelector<HTMLTextAreaElement>('textarea.u-ai-chat__input')!
831
+ expect(textarea.value).toBe('A')
832
+ expect(
833
+ [...host.querySelectorAll('.u-ai-chat__queue-text')].map((el) => el.textContent)
834
+ ).toEqual(['B'])
835
+
836
+ // 修改内容并提交(仍在生成中)→ 回到队列原位置(B 之前)
837
+ textarea.value = 'A2'
838
+ textarea.dispatchEvent(new Event('input', { bubbles: true }))
839
+ textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
840
+ await nextTick()
841
+
842
+ expect(
843
+ [...host.querySelectorAll('.u-ai-chat__queue-text')].map((el) => el.textContent)
844
+ ).toEqual(['A2', 'B'])
845
+ unmount()
846
+ })
847
+
848
+ it('welcome 逐条展示:点击文案发送,点击球立即换下一条', async () => {
849
+ const transport: ChatTransport = (_req, handlers) => {
850
+ handlers.onTextDelta('收到')
851
+ }
852
+ const { host, unmount } = mountAiChat({ transport, welcome: ['第一条建议', '第二条建议'] })
853
+ await nextTick()
854
+
855
+ // 一次只展示一条(初始为第一条)
856
+ const items = host.querySelectorAll<HTMLElement>('.u-ai-chat__welcome-item')
857
+ expect(items.length).toBe(1)
858
+ expect(items[0]!.textContent).toBe('第一条建议')
859
+
860
+ // 点击球 → 不等自动轮换,立即换下一条
861
+ host.querySelector<HTMLElement>('.u-ai-chat__welcome canvas.u-ai-orb')!.click()
862
+ await vi.waitFor(() => {
863
+ expect(host.querySelector('.u-ai-chat__welcome-item')?.textContent).toBe('第二条建议')
864
+ })
865
+
866
+ // 点击文案 → 以当前展示的文案发送,欢迎区消失
867
+ host.querySelector<HTMLElement>('.u-ai-chat__welcome-item')!.click()
868
+ await nextTick()
869
+
870
+ expect(host.querySelector('.u-ai-chat__welcome')).toBeFalsy()
871
+ const userBubble = host.querySelector('.u-ai-chat__message--user .u-ai-chat__message-bubble')
872
+ expect(userBubble?.textContent).toContain('第二条建议')
873
+ unmount()
874
+ })
875
+
876
+ it('welcome 多条时按 4s 间隔自动轮换', async () => {
877
+ vi.useFakeTimers()
878
+ try {
879
+ const transport: ChatTransport = (_req, handlers) => {
880
+ handlers.onTextDelta('收到')
881
+ }
882
+ const { host, unmount } = mountAiChat({ transport, welcome: ['第一条建议', '第二条建议'] })
883
+ await nextTick()
884
+ expect(host.querySelector('.u-ai-chat__welcome-item')?.textContent).toBe('第一条建议')
885
+
886
+ await vi.advanceTimersByTimeAsync(4100)
887
+ expect(host.querySelector('.u-ai-chat__welcome-item')?.textContent).toBe('第二条建议')
888
+
889
+ // 轮换回第一条
890
+ await vi.advanceTimersByTimeAsync(4100)
891
+ expect(host.querySelector('.u-ai-chat__welcome-item')?.textContent).toBe('第一条建议')
892
+ unmount()
893
+ } finally {
894
+ vi.useRealTimers()
895
+ }
896
+ })
897
+
898
+ it('生成中在列表底部展示「工作中…」指示,中断后停留再消失', async () => {
899
+ vi.useFakeTimers({ toFake: ['setTimeout', 'setInterval', 'clearTimeout', 'clearInterval'] })
900
+ try {
901
+ const transport: ChatTransport = (req) => {
902
+ return new Promise<void>((resolve) => {
903
+ req.signal.addEventListener('abort', () => resolve(), { once: true })
904
+ })
905
+ }
906
+ const { host, chat, unmount } = mountAiChat({ transport })
907
+
908
+ expect(host.querySelector('.u-ai-chat__working')).toBeFalsy()
909
+
910
+ chat.value?.send('hi')
911
+ await nextTick()
912
+
913
+ const working = host.querySelector('.u-ai-chat__working')
914
+ expect(working).toBeTruthy()
915
+ expect(working?.textContent).toContain('工作中')
916
+ const orb = working?.querySelector('canvas.u-ai-orb') as HTMLElement | null
917
+ expect(orb).toBeTruthy()
918
+ expect(orb?.style.width).toBe('48px')
919
+
920
+ chat.value?.abort()
921
+ await nextTick()
922
+ await Promise.resolve()
923
+ await nextTick()
924
+
925
+ const lingering = host.querySelector('.u-ai-chat__working')
926
+ expect(lingering).toBeTruthy()
927
+ expect(lingering?.textContent).not.toContain('工作中')
928
+
929
+ await vi.advanceTimersByTimeAsync(2499)
930
+ expect(host.querySelector('.u-ai-chat__working')).toBeTruthy()
931
+ await vi.advanceTimersByTimeAsync(2)
932
+ await nextTick()
933
+ expect(host.querySelector('.u-ai-chat__working')).toBeFalsy()
934
+ unmount()
935
+ } finally {
936
+ vi.useRealTimers()
937
+ }
938
+ })
939
+
940
+ it('回答完毕后工作球停留约 2.5s 再消失', async () => {
941
+ vi.useFakeTimers({ toFake: ['setTimeout', 'setInterval', 'clearTimeout', 'clearInterval'] })
942
+ try {
943
+ const transport: ChatTransport = (_req, handlers) => {
944
+ handlers.onTextDelta('完成')
945
+ }
946
+ const { host, chat, unmount } = mountAiChat({ transport })
947
+
948
+ chat.value?.send('hi')
949
+ await nextTick()
950
+ await Promise.resolve()
951
+ await nextTick()
952
+
953
+ const working = host.querySelector('.u-ai-chat__working')
954
+ expect(working).toBeTruthy()
955
+ expect(working?.textContent).not.toContain('工作中')
956
+ expect(working?.querySelector('canvas.u-ai-orb')).toBeTruthy()
957
+
958
+ await vi.advanceTimersByTimeAsync(2499)
959
+ expect(host.querySelector('.u-ai-chat__working')).toBeTruthy()
960
+ await vi.advanceTimersByTimeAsync(2)
961
+ await nextTick()
962
+ expect(host.querySelector('.u-ai-chat__working')).toBeFalsy()
963
+ unmount()
964
+ } finally {
965
+ vi.useRealTimers()
966
+ }
967
+ })
968
+
969
+ it('对话出错后工作球停留约 2.5s 再消失', async () => {
970
+ vi.useFakeTimers({ toFake: ['setTimeout', 'setInterval', 'clearTimeout', 'clearInterval'] })
971
+ try {
972
+ const transport: ChatTransport = (_req, handlers) => {
973
+ handlers.onError?.(new Error('网络错误'))
974
+ }
975
+ const { host, chat, unmount } = mountAiChat({ transport })
976
+
977
+ chat.value?.send('hi')
978
+ await nextTick()
979
+ await Promise.resolve()
980
+ await nextTick()
981
+
982
+ const working = host.querySelector('.u-ai-chat__working')
983
+ expect(working).toBeTruthy()
984
+ expect(working?.textContent).not.toContain('工作中')
985
+
986
+ await vi.advanceTimersByTimeAsync(2500)
987
+ await nextTick()
988
+ expect(host.querySelector('.u-ai-chat__working')).toBeFalsy()
989
+ unmount()
990
+ } finally {
991
+ vi.useRealTimers()
992
+ }
993
+ })
417
994
  })