@veltra/ai 2.1.0 → 2.1.2

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 (32) hide show
  1. package/dist/chat/transports/openai.js +18 -17
  2. package/dist/chat/transports/openai.js.map +1 -1
  3. package/dist/chat/use-chat.js +8 -2
  4. package/dist/chat/use-chat.js.map +1 -1
  5. package/dist/components/ai-chat/ai-chat.js +1 -1
  6. package/dist/components/ai-chat/ai-chat.js.map +1 -1
  7. package/dist/components/ai-chat/message-item.js +1 -1
  8. package/dist/components/ai-chat/message-item.js.map +1 -1
  9. package/dist/components/ai-chat/message-list.js +164 -65
  10. package/dist/components/ai-chat/message-list.js.map +1 -1
  11. package/dist/components/ai-chat/style2.css +101 -21
  12. package/dist/components/ai-chat/tool-call.js +2 -6
  13. package/dist/components/ai-chat/tool-call.js.map +1 -1
  14. package/dist/components/ai-chat/turn-process.js +56 -0
  15. package/dist/components/ai-chat/turn-process.js.map +1 -0
  16. package/dist/components/ai-orb/orb-renderer.js +88 -8
  17. package/dist/components/ai-orb/orb-renderer.js.map +1 -1
  18. package/dist/types/ai-chat.d.ts +1 -1
  19. package/package.json +15 -6
  20. package/src/chat/__test__/use-chat.test.ts +42 -4
  21. package/src/chat/use-chat.ts +8 -2
  22. package/src/components/ai-chat/__test__/ai-chat.test.ts +176 -36
  23. package/src/components/ai-chat/ai-chat.vue +2 -2
  24. package/src/components/ai-chat/message-item.vue +3 -2
  25. package/src/components/ai-chat/message-list.vue +186 -66
  26. package/src/components/ai-chat/style.scss +130 -27
  27. package/src/components/ai-chat/tool-call.vue +1 -10
  28. package/src/components/ai-chat/turn-process.vue +47 -0
  29. package/src/components/ai-orb/__test__/ai-orb.test.ts +28 -3
  30. package/src/components/ai-orb/orb-renderer.ts +113 -5
  31. package/src/tools/ask-question/__test__/ask-question.test.ts +13 -3
  32. package/src/types/ai-chat.ts +1 -1
@@ -1,7 +1,7 @@
1
1
  import { describe, expect, it, vi } from 'vitest'
2
2
  import { createApp, defineComponent, h, nextTick, ref, type PropType } from 'vue'
3
3
 
4
- import type { ChatTool, ChatToolCall, ChatTransport } from '../../../chat/types'
4
+ import type { ChatMessage, ChatTool, ChatToolCall, ChatTransport } from '../../../chat/types'
5
5
  import type { ChatModelOption } from '../../../providers'
6
6
  import type { AiChatExposed } from '../../../types'
7
7
  import UAiChat from '../ai-chat.vue'
@@ -60,13 +60,23 @@ function mountAiChat(options: {
60
60
  }
61
61
  }
62
62
 
63
+ /** 最终答案输出后过程消息收进「已完成」折叠块:点击展开并等待过程 DOM 挂载 */
64
+ async function expandProcess(host: HTMLElement) {
65
+ await vi.waitFor(() => {
66
+ expect(host.querySelector('.u-ai-chat__process-header')).toBeTruthy()
67
+ })
68
+ host.querySelector<HTMLElement>('.u-ai-chat__process-header')!.click()
69
+ await nextTick()
70
+ }
71
+
63
72
  describe('UAiChat', () => {
64
73
  it('空状态显示欢迎语', () => {
65
74
  const { host, unmount } = mountAiChat({ transport: () => {}, welcome: '有什么可以帮你?' })
66
75
 
67
76
  expect(host.querySelector('.u-ai-chat__welcome')?.textContent).toContain('有什么可以帮你?')
68
77
  const orb = host.querySelector('.u-ai-chat__welcome canvas.u-ai-orb') as HTMLElement | null
69
- expect(orb?.style.width).toBe('64px')
78
+ expect(orb?.style.width).toBe('48px')
79
+ expect(host.querySelector('.u-ai-chat__list .u-ai-chat__welcome')).toBeFalsy()
70
80
  unmount()
71
81
  })
72
82
 
@@ -136,18 +146,17 @@ describe('UAiChat', () => {
136
146
 
137
147
  chat.value?.send('1+1 等于几')
138
148
 
139
- await vi.waitFor(() => {
140
- expect(host.querySelector('.u-ai-chat__tool-call-name')?.textContent).toBe('calculate')
141
- })
149
+ // 最终答案输出后,工具卡片收进「已完成」过程块,先展开
150
+ await expandProcess(host)
142
151
 
143
- // 执行完成后自动折叠,点击头部展开
144
- await vi.waitFor(() => {
145
- expect(host.querySelector('.u-ai-chat__tool-call.is-success')).toBeTruthy()
146
- })
152
+ expect(host.querySelector('.u-ai-chat__tool-call-name')?.textContent).toBe('calculate')
153
+ expect(host.querySelector('.u-ai-chat__tool-call.is-success')).toBeTruthy()
147
154
 
155
+ // 执行完成后自动折叠,点击头部展开
148
156
  const header = host.querySelector<HTMLElement>('.u-ai-chat__tool-call .u-collapse__header')!
149
157
  header.dispatchEvent(new MouseEvent('click', { bubbles: true }))
150
158
  await nextTick()
159
+ await nextTick()
151
160
 
152
161
  const body = host.querySelector('.u-ai-chat__tool-call .u-collapse__content-wrapper')
153
162
  expect(body?.textContent).toContain('"expression": "1+1"')
@@ -210,10 +219,11 @@ describe('UAiChat', () => {
210
219
 
211
220
  chat.value?.send('搜一下 vue')
212
221
 
213
- await vi.waitFor(() => {
214
- expect(host.querySelector('.u-ai-chat__tool-call-name')?.textContent).toBe('搜索')
215
- expect(host.querySelector('.custom-tool-icon')).toBeTruthy()
216
- })
222
+ // 最终答案输出后工具卡片收进「已完成」过程块,展开后断言
223
+ await expandProcess(host)
224
+
225
+ expect(host.querySelector('.u-ai-chat__tool-call-name')?.textContent).toBe('搜索')
226
+ expect(host.querySelector('.custom-tool-icon')).toBeTruthy()
217
227
  unmount()
218
228
  })
219
229
 
@@ -254,10 +264,8 @@ describe('UAiChat', () => {
254
264
 
255
265
  chat.value?.send('北京天气')
256
266
 
257
- // running 时 render 即渲染(render 工具默认不折叠)
258
- await vi.waitFor(() => {
259
- expect(host.querySelector('.weather-view')).toBeTruthy()
260
- })
267
+ // 最终答案输出后工具卡片收进「已完成」过程块,展开后 render 保持展开态
268
+ await expandProcess(host)
261
269
 
262
270
  // 完成后保持展开,默认参数/结果区被替换
263
271
  await vi.waitFor(() => {
@@ -291,6 +299,8 @@ describe('UAiChat', () => {
291
299
 
292
300
  chat.value?.send('1+1')
293
301
 
302
+ await expandProcess(host)
303
+
294
304
  await vi.waitFor(() => {
295
305
  expect(host.querySelector('.u-ai-chat__tool-call.is-success.is-active')).toBeTruthy()
296
306
  })
@@ -306,10 +316,9 @@ describe('UAiChat', () => {
306
316
 
307
317
  chat.value?.send('北京天气')
308
318
 
309
- await vi.waitFor(() => {
310
- expect(host.querySelector('.u-ai-chat__tool-call.is-success')).toBeTruthy()
311
- })
312
- await nextTick()
319
+ await expandProcess(host)
320
+
321
+ expect(host.querySelector('.u-ai-chat__tool-call.is-success')).toBeTruthy()
313
322
  expect(host.querySelector('.u-ai-chat__tool-call.is-active')).toBeFalsy()
314
323
  const body = host.querySelector('.u-ai-chat__tool-call .u-collapse__content-wrapper')
315
324
  expect(body?.getAttribute('aria-hidden')).toBe('true')
@@ -333,10 +342,8 @@ describe('UAiChat', () => {
333
342
 
334
343
  chat.value?.send('1+1 等于几')
335
344
 
336
- await vi.waitFor(() => {
337
- expect(host.querySelector('.u-ai-chat__tool-call.is-success')).toBeTruthy()
338
- })
339
- await nextTick()
345
+ // 过程块展开后,工具卡片重挂载为完成态(自动折叠)
346
+ await expandProcess(host)
340
347
 
341
348
  // 完成后自动折叠:内容 DOM 已卸载(减少内存与渲染成本),包装容器保留
342
349
  expect(host.querySelector('.u-ai-chat__tool-call.is-active')).toBeFalsy()
@@ -380,6 +387,63 @@ describe('UAiChat', () => {
380
387
  unmount()
381
388
  })
382
389
 
390
+ it('最终答案输出后过程消息收进「已完成」折叠块,可展开钻取', async () => {
391
+ let round = 0
392
+ const transport: ChatTransport = (_req, handlers) => {
393
+ round++
394
+ if (round === 1) {
395
+ handlers.onReasoningDelta?.('先分析一下')
396
+ handlers.onToolCall?.({ id: 'c1', name: 'calculate', arguments: '{"expression":"1+1"}' })
397
+ } else {
398
+ handlers.onTextDelta('答案是 2')
399
+ }
400
+ }
401
+ const tools: ChatTool[] = [
402
+ { name: 'calculate', description: '计算', parameters: {}, execute: () => ({ value: 2 }) }
403
+ ]
404
+ const { host, chat, unmount } = mountAiChat({ transport, tools })
405
+
406
+ chat.value?.send('1+1 等于几')
407
+
408
+ // 最终答案输出后:出现「已完成」折叠头,过程 DOM(思考块/工具卡片)卸载,答案仍在块外
409
+ await vi.waitFor(() => {
410
+ expect(host.querySelector('.u-ai-chat__process-title')?.textContent).toBe('已完成')
411
+ })
412
+ expect(host.querySelector('.u-ai-chat__process-content')).toBeFalsy()
413
+ expect(host.querySelector('.u-ai-chat__tool-call')).toBeFalsy()
414
+ expect(host.querySelector('.u-ai-chat__reasoning')).toBeFalsy()
415
+ expect(host.querySelector('.md-stub')?.textContent).toContain('答案是 2')
416
+
417
+ // 点击展开:过程消息可见,思考块默认折叠(可逐层钻取)
418
+ host.querySelector<HTMLElement>('.u-ai-chat__process-header')!.click()
419
+ await nextTick()
420
+ expect(host.querySelector('.u-ai-chat__reasoning-title')?.textContent).toContain('思考过程')
421
+ expect(host.querySelector('.u-ai-chat__reasoning-content')).toBeFalsy()
422
+ expect(host.querySelector('.u-ai-chat__tool-call-name')?.textContent).toBe('calculate')
423
+
424
+ // 再次收起:过程 DOM 重新卸载
425
+ host.querySelector<HTMLElement>('.u-ai-chat__process-header')!.click()
426
+ await nextTick()
427
+ expect(host.querySelector('.u-ai-chat__process-content')).toBeFalsy()
428
+ expect(host.querySelector('.u-ai-chat__tool-call')).toBeFalsy()
429
+ unmount()
430
+ })
431
+
432
+ it('单轮无工具对话不出现「已完成」过程块', async () => {
433
+ const transport: ChatTransport = (_req, handlers) => {
434
+ handlers.onReasoningDelta?.('想一下')
435
+ handlers.onTextDelta('你好')
436
+ }
437
+ const { host, chat, unmount } = mountAiChat({ transport })
438
+
439
+ chat.value?.send('hi')
440
+ await vi.waitFor(() => {
441
+ expect(host.querySelector('.md-stub')?.textContent).toContain('你好')
442
+ })
443
+ expect(host.querySelector('.u-ai-chat__process')).toBeFalsy()
444
+ unmount()
445
+ })
446
+
383
447
  it('流式期间用户上滚取消吸底,展示「最新消息」入口且不被后续输出重新吸附', async () => {
384
448
  const transport: ChatTransport = async (_req, handlers) => {
385
449
  handlers.onTextDelta('第一段')
@@ -418,9 +482,9 @@ describe('UAiChat', () => {
418
482
 
419
483
  chat.value?.send('北京天气')
420
484
 
421
- await vi.waitFor(() => {
422
- expect(host.querySelector('.u-ai-chat__tool-call.is-success')).toBeTruthy()
423
- })
485
+ await expandProcess(host)
486
+
487
+ expect(host.querySelector('.u-ai-chat__tool-call.is-success')).toBeTruthy()
424
488
  expect(host.querySelector('.weather-view')).toBeTruthy()
425
489
  expect(host.querySelector('.slot-view')).toBeFalsy()
426
490
  unmount()
@@ -473,10 +537,11 @@ describe('UAiChat', () => {
473
537
  })
474
538
  expect(host.querySelector('.u-ai-chat__panel-title')?.textContent).toBe('后台页面')
475
539
 
540
+ // 最终答案输出后工具卡片收进「已完成」过程块,展开后断言
541
+ await expandProcess(host)
542
+
476
543
  // 卡片 body 不渲染 render 组件,仅提供「查看面板」入口
477
- await vi.waitFor(() => {
478
- expect(host.querySelector('.u-ai-chat__tool-call.is-success')).toBeTruthy()
479
- })
544
+ expect(host.querySelector('.u-ai-chat__tool-call.is-success')).toBeTruthy()
480
545
  expect(host.querySelector('.u-ai-chat__tool-call .page-view')).toBeFalsy()
481
546
  expect(host.querySelector('.u-ai-chat__tool-call-panel-entry')).toBeTruthy()
482
547
  unmount()
@@ -495,6 +560,9 @@ describe('UAiChat', () => {
495
560
  await nextTick()
496
561
  expect(host.querySelector('.u-ai-chat__panel')).toBeFalsy()
497
562
 
563
+ // 卡片已收进「已完成」过程块,展开后点击入口
564
+ await expandProcess(host)
565
+
498
566
  const entryBtn = [
499
567
  ...host.querySelectorAll<HTMLElement>('.u-ai-chat__tool-call-panel-entry button')
500
568
  ].find((btn) => btn.textContent?.includes('查看面板'))!
@@ -519,7 +587,9 @@ describe('UAiChat', () => {
519
587
  expect(host.querySelector('.u-ai-chat__panel .page-view')?.textContent).toContain('list')
520
588
  })
521
589
 
522
- // 点击第一张卡片的「查看面板」切回 form
590
+ // 卡片已收进「已完成」过程块,展开后点击第一张卡片的「查看面板」切回 form
591
+ await expandProcess(host)
592
+
523
593
  const firstCard = host.querySelectorAll<HTMLElement>('.u-ai-chat__tool-call')[0]!
524
594
  const entryBtn = [
525
595
  ...firstCard.querySelectorAll<HTMLElement>('.u-ai-chat__tool-call-panel-entry button')
@@ -604,7 +674,9 @@ describe('UAiChat', () => {
604
674
  const layout = host.querySelector<HTMLElement>('.u-ai-chat')!
605
675
  expect(layout.style.gridTemplateColumns).toBe('1fr 640px')
606
676
 
607
- // 点击 openPage 卡片的「查看面板」→ 切换并应用 480px
677
+ // 卡片已收进「已完成」过程块,展开后点击 openPage 卡片的「查看面板」→ 切换并应用 480px
678
+ await expandProcess(host)
679
+
608
680
  const firstCard = host.querySelectorAll<HTMLElement>('.u-ai-chat__tool-call')[0]!
609
681
  const entryBtn = [
610
682
  ...firstCard.querySelectorAll<HTMLElement>('.u-ai-chat__tool-call-panel-entry button')
@@ -624,6 +696,45 @@ describe('UAiChat', () => {
624
696
  unmount()
625
697
  })
626
698
 
699
+ it('受控 v-model:messages 绑定下流式回复正常渲染', async () => {
700
+ const messages = ref<ChatMessage[]>([])
701
+ const chatRef = ref<AiChatExposed>()
702
+ const transport: ChatTransport = async (_req, handlers) => {
703
+ // 留出父级 patch props 的时间窗,还原真实的回显时序
704
+ await new Promise((resolve) => setTimeout(resolve, 10))
705
+ handlers.onTextDelta('受控回答')
706
+ }
707
+ const host = document.createElement('div')
708
+ document.body.appendChild(host)
709
+
710
+ const app = createApp({
711
+ setup() {
712
+ return () =>
713
+ h(UAiChat, {
714
+ ref: chatRef,
715
+ transport,
716
+ messages: messages.value,
717
+ 'onUpdate:messages': (value: ChatMessage[]) => {
718
+ messages.value = value
719
+ }
720
+ })
721
+ }
722
+ })
723
+ app.mount(host)
724
+
725
+ chatRef.value?.send('你好')
726
+ await vi.waitFor(() => {
727
+ expect(host.querySelector('.md-stub')?.textContent).toContain('受控回答')
728
+ })
729
+ // 父级持有的快照最终也包含 assistant 回复
730
+ expect(messages.value.some((m) => m.role === 'assistant' && m.content === '受控回答')).toBe(
731
+ true
732
+ )
733
+
734
+ app.unmount()
735
+ host.remove()
736
+ })
737
+
627
738
  it('生成中空输入显示停止,有内容则显示入队发送', async () => {
628
739
  const transport: ChatTransport = (req) => {
629
740
  return new Promise<void>((resolve) => {
@@ -863,11 +974,12 @@ describe('UAiChat', () => {
863
974
  expect(host.querySelector('.u-ai-chat__welcome-item')?.textContent).toBe('第二条建议')
864
975
  })
865
976
 
866
- // 点击文案 → 以当前展示的文案发送,欢迎区消失
977
+ // 点击文案 → 以当前展示的文案发送;空闲欢迎立即缩小离开,列表末尾出现工作球
867
978
  host.querySelector<HTMLElement>('.u-ai-chat__welcome-item')!.click()
868
979
  await nextTick()
869
980
 
870
- expect(host.querySelector('.u-ai-chat__welcome')).toBeFalsy()
981
+ expect(host.querySelector('.u-ai-chat__welcome')?.classList.contains('is-leaving')).toBe(true)
982
+ expect(host.querySelector('.u-ai-chat__list .u-ai-chat__working')).toBeTruthy()
871
983
  const userBubble = host.querySelector('.u-ai-chat__message--user .u-ai-chat__message-bubble')
872
984
  expect(userBubble?.textContent).toContain('第二条建议')
873
985
  unmount()
@@ -906,11 +1018,14 @@ describe('UAiChat', () => {
906
1018
  const { host, chat, unmount } = mountAiChat({ transport })
907
1019
 
908
1020
  expect(host.querySelector('.u-ai-chat__working')).toBeFalsy()
1021
+ expect(host.querySelector('.u-ai-chat__welcome')).toBeTruthy()
1022
+ expect(host.querySelector('.u-ai-chat__list .u-ai-chat__welcome')).toBeFalsy()
909
1023
 
910
1024
  chat.value?.send('hi')
911
1025
  await nextTick()
912
1026
 
913
- const working = host.querySelector('.u-ai-chat__working')
1027
+ expect(host.querySelector('.u-ai-chat__welcome')?.classList.contains('is-leaving')).toBe(true)
1028
+ const working = host.querySelector('.u-ai-chat__list .u-ai-chat__working')
914
1029
  expect(working).toBeTruthy()
915
1030
  expect(working?.textContent).toContain('工作中')
916
1031
  const orb = working?.querySelector('canvas.u-ai-orb') as HTMLElement | null
@@ -930,7 +1045,16 @@ describe('UAiChat', () => {
930
1045
  expect(host.querySelector('.u-ai-chat__working')).toBeTruthy()
931
1046
  await vi.advanceTimersByTimeAsync(2)
932
1047
  await nextTick()
1048
+ // 停留结束 → 进入缩放退出过渡(is-leaving),元素仍在
1049
+ const leaving = host.querySelector('.u-ai-chat__working')
1050
+ expect(leaving).toBeTruthy()
1051
+ expect(leaving?.classList.contains('is-leaving')).toBe(true)
1052
+ // 过渡播完才卸载
1053
+ await vi.advanceTimersByTimeAsync(380)
1054
+ await nextTick()
933
1055
  expect(host.querySelector('.u-ai-chat__working')).toBeFalsy()
1056
+ expect(host.querySelector('.u-ai-chat__welcome')).toBeTruthy()
1057
+ expect(host.querySelector('.u-ai-chat__list .u-ai-chat__welcome')).toBeFalsy()
934
1058
  unmount()
935
1059
  } finally {
936
1060
  vi.useRealTimers()
@@ -959,7 +1083,15 @@ describe('UAiChat', () => {
959
1083
  expect(host.querySelector('.u-ai-chat__working')).toBeTruthy()
960
1084
  await vi.advanceTimersByTimeAsync(2)
961
1085
  await nextTick()
1086
+ // 停留结束 → 进入缩放退出过渡(is-leaving),元素仍在
1087
+ const leaving = host.querySelector('.u-ai-chat__working')
1088
+ expect(leaving).toBeTruthy()
1089
+ expect(leaving?.classList.contains('is-leaving')).toBe(true)
1090
+ // 过渡播完才卸载
1091
+ await vi.advanceTimersByTimeAsync(380)
1092
+ await nextTick()
962
1093
  expect(host.querySelector('.u-ai-chat__working')).toBeFalsy()
1094
+ expect(host.querySelector('.u-ai-chat__welcome')).toBeTruthy()
963
1095
  unmount()
964
1096
  } finally {
965
1097
  vi.useRealTimers()
@@ -985,7 +1117,15 @@ describe('UAiChat', () => {
985
1117
 
986
1118
  await vi.advanceTimersByTimeAsync(2500)
987
1119
  await nextTick()
1120
+ // 停留结束 → 进入缩放退出过渡(is-leaving),元素仍在
1121
+ const leaving = host.querySelector('.u-ai-chat__working')
1122
+ expect(leaving).toBeTruthy()
1123
+ expect(leaving?.classList.contains('is-leaving')).toBe(true)
1124
+ // 过渡播完才卸载
1125
+ await vi.advanceTimersByTimeAsync(380)
1126
+ await nextTick()
988
1127
  expect(host.querySelector('.u-ai-chat__working')).toBeFalsy()
1128
+ expect(host.querySelector('.u-ai-chat__welcome')).toBeTruthy()
989
1129
  unmount()
990
1130
  } finally {
991
1131
  vi.useRealTimers()
@@ -78,7 +78,7 @@ const props = defineProps<AiChatProps>()
78
78
  const emit = defineEmits<AiChatEmits>()
79
79
 
80
80
  defineSlots<{
81
- /** 空状态欢迎区插槽 */
81
+ /** 空闲欢迎区插槽(输入框上方;工作中跳到列表末尾) */
82
82
  welcome(): any
83
83
  /** 按工具名自定义工具结果展示,如 tool-getWeather */
84
84
  [name: `tool-${string}`]: (scope: { toolCall: ChatToolCall }) => any
@@ -112,7 +112,7 @@ const {
112
112
  removeQueued
113
113
  } = useChat({ props, emit })
114
114
 
115
- /** 空会话(无可见消息):输入区垂直居中,欢迎区(orb)贴于输入区上方并与之左对齐 */
115
+ /** 空会话(无可见消息):输入区垂直居中;欢迎区钉在滚动容器外、贴于输入框上方 */
116
116
  const isEmpty = computed(() => !messages.value.some((msg) => msg.role !== 'tool'))
117
117
 
118
118
  /** 所有 renderTo: 'panel' 的工具调用(按消息顺序,面板内容的来源) */
@@ -153,7 +153,7 @@ const handleReasoningScroll = (position: Required<ScrollPosition>) => {
153
153
  if (position.sh - position.ch - position.y < 8) reasoningStick.value = true
154
154
  }
155
155
 
156
- // 流式结束后自动折叠思考过程
156
+ // 流式结束后自动折叠思考过程;immediate 保证终态消息重挂载(过程块展开/收起、载入历史会话)时呈折叠态
157
157
  watch(
158
158
  () => props.message.status,
159
159
  (status) => {
@@ -163,7 +163,8 @@ watch(
163
163
  } else if (status) {
164
164
  reasoningExpanded.value = false
165
165
  }
166
- }
166
+ },
167
+ { immediate: true }
167
168
  )
168
169
 
169
170
  watch(