@veltra/ai 1.4.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 (87) hide show
  1. package/dist/chat/index.d.ts +3 -3
  2. package/dist/chat/transports/index.d.ts +2 -2
  3. package/dist/chat/transports/openai.d.ts +16 -12
  4. package/dist/chat/transports/openai.js +56 -15
  5. package/dist/chat/transports/openai.js.map +1 -1
  6. package/dist/chat/types.d.ts +41 -2
  7. package/dist/chat/use-chat.d.ts +26 -1
  8. package/dist/chat/use-chat.js +132 -13
  9. package/dist/chat/use-chat.js.map +1 -1
  10. package/dist/components/ai-chat/ai-chat.js +222 -41
  11. package/dist/components/ai-chat/ai-chat.js.map +1 -1
  12. package/dist/components/ai-chat/ask-question.js +2 -2
  13. package/dist/components/ai-chat/ask-question.js.map +1 -1
  14. package/dist/components/ai-chat/chat-input.js +81 -44
  15. package/dist/components/ai-chat/chat-input.js.map +1 -1
  16. package/dist/components/ai-chat/di.js.map +1 -1
  17. package/dist/components/ai-chat/message-item.js +101 -23
  18. package/dist/components/ai-chat/message-item.js.map +1 -1
  19. package/dist/components/ai-chat/message-list.js +217 -32
  20. package/dist/components/ai-chat/message-list.js.map +1 -1
  21. package/dist/components/ai-chat/model-picker.js +140 -0
  22. package/dist/components/ai-chat/model-picker.js.map +1 -0
  23. package/dist/components/ai-chat/queue-list.js +70 -0
  24. package/dist/components/ai-chat/queue-list.js.map +1 -0
  25. package/dist/components/ai-chat/side-panel.js +96 -0
  26. package/dist/components/ai-chat/side-panel.js.map +1 -0
  27. package/dist/components/ai-chat/style.d.ts +6 -0
  28. package/dist/components/ai-chat/style.js +6 -0
  29. package/dist/components/ai-chat/style2.css +552 -41
  30. package/dist/components/ai-chat/tool-call.js +61 -27
  31. package/dist/components/ai-chat/tool-call.js.map +1 -1
  32. package/dist/components/ai-orb/ai-orb.js +94 -0
  33. package/dist/components/ai-orb/ai-orb.js.map +1 -0
  34. package/dist/components/ai-orb/orb-renderer.js +411 -0
  35. package/dist/components/ai-orb/orb-renderer.js.map +1 -0
  36. package/dist/components/ai-orb/style.d.ts +1 -0
  37. package/dist/components/ai-orb/style.js +1 -0
  38. package/dist/components/ai-orb/style2.css +23 -0
  39. package/dist/index.d.ts +8 -6
  40. package/dist/index.js +2 -2
  41. package/dist/providers/types.d.ts +47 -0
  42. package/dist/style.js +1 -0
  43. package/dist/tools/ask-question/ask-question.d.ts +2 -17
  44. package/dist/tools/ask-question/index.d.ts +2 -2
  45. package/dist/tools/index.d.ts +3 -2
  46. package/dist/tools/index.js +10 -0
  47. package/dist/tools/index.js.map +1 -0
  48. package/dist/types/ai-chat.d.ts +35 -7
  49. package/dist/types/ai-orb.d.ts +27 -0
  50. package/dist/types/index.d.ts +2 -1
  51. package/package.json +8 -8
  52. package/src/chat/__test__/use-chat.test.ts +295 -9
  53. package/src/chat/transports/__test__/openai.test.ts +317 -0
  54. package/src/chat/transports/openai.ts +88 -26
  55. package/src/chat/types.ts +41 -1
  56. package/src/chat/use-chat.ts +177 -14
  57. package/src/components/ai-chat/__test__/ai-chat.test.ts +690 -12
  58. package/src/components/ai-chat/ai-chat.vue +241 -31
  59. package/src/components/ai-chat/chat-input.vue +78 -31
  60. package/src/components/ai-chat/di.ts +2 -0
  61. package/src/components/ai-chat/message-item.vue +98 -11
  62. package/src/components/ai-chat/message-list.vue +261 -21
  63. package/src/components/ai-chat/model-picker.vue +177 -0
  64. package/src/components/ai-chat/queue-list.vue +63 -0
  65. package/src/components/ai-chat/side-panel.vue +76 -0
  66. package/src/components/ai-chat/style.scss +621 -43
  67. package/src/components/ai-chat/style.ts +6 -0
  68. package/src/components/ai-chat/tool-call.vue +86 -42
  69. package/src/components/ai-orb/__test__/ai-orb.test.ts +251 -0
  70. package/src/components/ai-orb/ai-orb.vue +107 -0
  71. package/src/components/ai-orb/index.ts +1 -0
  72. package/src/components/ai-orb/orb-renderer.ts +505 -0
  73. package/src/components/ai-orb/style.scss +10 -0
  74. package/src/components/ai-orb/style.ts +1 -0
  75. package/src/components/index.ts +1 -0
  76. package/src/index.ts +8 -1
  77. package/src/providers/index.ts +1 -0
  78. package/src/providers/types.ts +46 -0
  79. package/src/style.ts +1 -0
  80. package/src/tools/ask-question/__test__/ask-question.test.ts +4 -4
  81. package/src/tools/index.ts +14 -1
  82. package/src/types/ai-chat.ts +35 -6
  83. package/src/types/ai-orb.ts +27 -0
  84. package/src/types/index.ts +1 -0
  85. package/dist/components/ai-chat/ai-chat.vue.d.ts +0 -36
  86. package/dist/components/ai-chat/index.d.ts +0 -2
  87. package/dist/components/index.d.ts +0 -3
@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest'
2
2
  import { createApp, defineComponent, h, nextTick, ref, type PropType } from 'vue'
3
3
 
4
4
  import type { ChatTool, ChatToolCall, ChatTransport } from '../../../chat/types'
5
+ import type { ChatModelOption } from '../../../providers'
5
6
  import type { AiChatExposed } from '../../../types'
6
7
  import UAiChat from '../ai-chat.vue'
7
8
 
@@ -18,7 +19,10 @@ vi.mock('markstream-vue', () => ({
18
19
  function mountAiChat(options: {
19
20
  transport: ChatTransport
20
21
  tools?: ChatTool[]
21
- welcome?: string
22
+ welcome?: string | string[]
23
+ models?: ChatModelOption[]
24
+ model?: string
25
+ reasoningLevel?: string
22
26
  slots?: Record<string, (scope: any) => any>
23
27
  }) {
24
28
  const host = document.createElement('div')
@@ -34,7 +38,10 @@ function mountAiChat(options: {
34
38
  ref: chatRef,
35
39
  transport: options.transport,
36
40
  tools: options.tools,
37
- welcome: options.welcome
41
+ welcome: options.welcome,
42
+ models: options.models,
43
+ model: options.model,
44
+ reasoningLevel: options.reasoningLevel
38
45
  },
39
46
  options.slots
40
47
  )
@@ -58,6 +65,8 @@ describe('UAiChat', () => {
58
65
  const { host, unmount } = mountAiChat({ transport: () => {}, welcome: '有什么可以帮你?' })
59
66
 
60
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')
61
70
  unmount()
62
71
  })
63
72
 
@@ -79,6 +88,37 @@ describe('UAiChat', () => {
79
88
  unmount()
80
89
  })
81
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
+
82
122
  it('工具调用渲染工具卡片,可展开查看结果', async () => {
83
123
  let round = 0
84
124
  const transport: ChatTransport = (_req, handlers) => {
@@ -109,7 +149,7 @@ describe('UAiChat', () => {
109
149
  header.dispatchEvent(new MouseEvent('click', { bubbles: true }))
110
150
  await nextTick()
111
151
 
112
- 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')
113
153
  expect(body?.textContent).toContain('"expression": "1+1"')
114
154
  expect(body?.textContent).toContain('"value": 2')
115
155
  unmount()
@@ -221,7 +261,7 @@ describe('UAiChat', () => {
221
261
 
222
262
  // 完成后保持展开,默认参数/结果区被替换
223
263
  await vi.waitFor(() => {
224
- 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()
225
265
  })
226
266
  expect(host.querySelector('.weather-view')?.textContent).toContain('{"temperature":26}')
227
267
  expect(host.querySelector('.u-ai-chat__tool-call-code')).toBeFalsy()
@@ -252,11 +292,11 @@ describe('UAiChat', () => {
252
292
  chat.value?.send('1+1')
253
293
 
254
294
  await vi.waitFor(() => {
255
- 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()
256
296
  })
257
- expect(host.querySelector('.u-ai-chat__tool-call .u-collapse__content')?.textContent).toContain(
258
- '"value": 2'
259
- )
297
+ expect(
298
+ host.querySelector('.u-ai-chat__tool-call .u-collapse__content-wrapper')?.textContent
299
+ ).toContain('"value": 2')
260
300
  unmount()
261
301
  })
262
302
 
@@ -270,9 +310,101 @@ describe('UAiChat', () => {
270
310
  expect(host.querySelector('.u-ai-chat__tool-call.is-success')).toBeTruthy()
271
311
  })
272
312
  await nextTick()
273
- expect(host.querySelector('.u-ai-chat__tool-call.is-expanded')).toBeFalsy()
274
- const contentWrapper = host.querySelector('.u-ai-chat__tool-call .u-collapse__content-wrapper')
275
- 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()
276
408
  unmount()
277
409
  })
278
410
 
@@ -294,7 +426,205 @@ describe('UAiChat', () => {
294
426
  unmount()
295
427
  })
296
428
 
297
- it('生成中输入区显示停止按钮', async () => {
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
+
619
+ it('输入区使用原生 textarea 与 UFilePicker', () => {
620
+ const { host, unmount } = mountAiChat({ transport: () => {} })
621
+
622
+ expect(host.querySelector('textarea.u-ai-chat__input')).toBeTruthy()
623
+ expect(host.querySelector('.u-ai-chat__input-toolbar-left .u-file-picker')).toBeTruthy()
624
+ unmount()
625
+ })
626
+
627
+ it('生成中空输入显示停止,有内容则显示入队发送', async () => {
298
628
  const transport: ChatTransport = (req) => {
299
629
  return new Promise<void>((resolve) => {
300
630
  req.signal.addEventListener('abort', () => resolve(), { once: true })
@@ -305,6 +635,29 @@ describe('UAiChat', () => {
305
635
  chat.value?.send('hi')
306
636
  await nextTick()
307
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('')
308
661
  expect(host.querySelector('.u-ai-chat__input-stop')).toBeTruthy()
309
662
 
310
663
  chat.value?.abort()
@@ -313,4 +666,329 @@ describe('UAiChat', () => {
313
666
  })
314
667
  unmount()
315
668
  })
669
+
670
+ it('有 models 时渲染模型选择器;当前模型无 reasoningLevels 时不展示推理等级', async () => {
671
+ const { host, unmount } = mountAiChat({
672
+ transport: () => {},
673
+ models: [
674
+ { id: 'gpt-4o', label: 'GPT-4o', providerId: 'openai' },
675
+ {
676
+ id: 'o3-mini',
677
+ label: 'o3-mini',
678
+ providerId: 'openai',
679
+ reasoningLevels: [
680
+ { value: 'low', label: '低' },
681
+ { value: 'high', label: '高' }
682
+ ],
683
+ defaultReasoningLevel: 'low'
684
+ }
685
+ ],
686
+ model: 'gpt-4o'
687
+ })
688
+
689
+ await nextTick()
690
+ // 模型选择器在右簇(发送按钮左侧),不在附件侧
691
+ expect(
692
+ host.querySelector('.u-ai-chat__input-toolbar-right .u-ai-chat__model-trigger')
693
+ ).toBeTruthy()
694
+ expect(
695
+ host.querySelector('.u-ai-chat__input-toolbar-left .u-ai-chat__model-trigger')
696
+ ).toBeFalsy()
697
+ // 当前模型无 reasoningLevels,触发器不展示推理等级
698
+ expect(host.querySelector('.u-ai-chat__model-trigger-reasoning')).toBeFalsy()
699
+ unmount()
700
+ })
701
+
702
+ it('切换到带 reasoningLevels 的模型后触发器展示推理等级', async () => {
703
+ const model = ref('gpt-4o')
704
+ const reasoningLevel = ref<string | undefined>()
705
+ const host = document.createElement('div')
706
+ document.body.appendChild(host)
707
+
708
+ const app = createApp({
709
+ setup() {
710
+ return () =>
711
+ h(UAiChat, {
712
+ transport: () => {},
713
+ models: [
714
+ { id: 'gpt-4o', label: 'GPT-4o', providerId: 'openai' },
715
+ {
716
+ id: 'o3-mini',
717
+ label: 'o3-mini',
718
+ providerId: 'openai',
719
+ reasoningLevels: [
720
+ { value: 'low', label: '低' },
721
+ { value: 'high', label: '高' }
722
+ ],
723
+ defaultReasoningLevel: 'low'
724
+ }
725
+ ],
726
+ model: model.value,
727
+ 'onUpdate:model': (v: string | undefined) => {
728
+ model.value = v ?? ''
729
+ },
730
+ reasoningLevel: reasoningLevel.value,
731
+ 'onUpdate:reasoningLevel': (v: string | undefined) => {
732
+ reasoningLevel.value = v
733
+ }
734
+ })
735
+ }
736
+ })
737
+ app.mount(host)
738
+ await nextTick()
739
+
740
+ expect(host.querySelector('.u-ai-chat__model-trigger-reasoning')).toBeFalsy()
741
+
742
+ model.value = 'o3-mini'
743
+ await nextTick()
744
+
745
+ const reasoning = host.querySelector(
746
+ '.u-ai-chat__input-toolbar-right .u-ai-chat__model-trigger-reasoning'
747
+ )
748
+ expect(reasoning).toBeTruthy()
749
+ expect(reasoning?.textContent).toBe('低')
750
+ expect(reasoningLevel.value).toBe('low')
751
+
752
+ app.unmount()
753
+ host.remove()
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
+ })
316
994
  })