@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
@@ -6,7 +6,14 @@ import { createOrbRenderer } from '../orb-renderer'
6
6
 
7
7
  /** 记录调用的 2D context 桩(happy-dom 无真实 canvas 实现) */
8
8
  function createStubContext() {
9
- const calls = { fill: 0, clip: 0, gradient: 0, stroke: 0 }
9
+ const calls = {
10
+ fill: 0,
11
+ clip: 0,
12
+ gradient: 0,
13
+ stroke: 0,
14
+ fillText: 0,
15
+ fillTextColor: null as unknown
16
+ }
10
17
  const gradient = { addColorStop: () => {} }
11
18
  const ctx = {
12
19
  calls,
@@ -32,12 +39,19 @@ function createStubContext() {
32
39
  clearRect: () => {},
33
40
  fillRect: () => {},
34
41
  setTransform: () => {},
42
+ font: '',
43
+ textAlign: 'start' as CanvasTextAlign,
44
+ textBaseline: 'alphabetic' as CanvasTextBaseline,
35
45
  stroke: () => {
36
46
  calls.stroke++
37
47
  },
38
48
  fill: () => {
39
49
  calls.fill++
40
50
  },
51
+ fillText() {
52
+ calls.fillText++
53
+ calls.fillTextColor = ctx.fillStyle
54
+ },
41
55
  clip: () => {
42
56
  calls.clip++
43
57
  },
@@ -79,12 +93,23 @@ describe('createOrbRenderer', () => {
79
93
 
80
94
  renderer.renderOnce()
81
95
 
82
- // 球体 + 双眼 = 3 次填充,常态无嘴无描边
83
- expect(ctx.calls.fill).toBe(3)
96
+ // 球体 + 双耳(外耳 + 内耳各 2)+ 双眼 = 7 次填充,常态无嘴无描边
97
+ expect(ctx.calls.fill).toBe(7)
84
98
  expect(ctx.calls.stroke).toBe(0)
99
+ expect(ctx.calls.fillText).toBe(0)
85
100
  expect(ctx.calls.gradient).toBe(0)
86
101
  })
87
102
 
103
+ it('thinking 在球体右上角绘制同色问号', () => {
104
+ const ctx = createStubContext()
105
+ const renderer = createOrbRenderer(createStubCanvas(ctx), { size: 48, status: 'thinking' })
106
+
107
+ renderer.renderOnce()
108
+
109
+ expect(ctx.calls.fillText).toBe(1)
110
+ expect(ctx.calls.fillTextColor).toBe('#3d9bf0')
111
+ })
112
+
88
113
  it('start/stop 控制 rAF 循环', () => {
89
114
  const ctx = createStubContext()
90
115
  const frames: ((now: number) => void)[] = []
@@ -40,17 +40,34 @@ interface ReactionDef {
40
40
  mouth: MouthMode
41
41
  /** 表情期间视线落点(单位球空间) */
42
42
  gaze: { x: number; y: number }
43
+ /** 表情期间耳朵外倒角(弧度;小 = 竖起,大 = 耷拉) */
44
+ earTilt: number
43
45
  }
44
46
 
45
47
  const REACTIONS: Record<AiOrbReaction, ReactionDef> = {
46
- happy: { duration: 1.7, eyeMode: 'happy', eyeScale: 1, mouth: 'grin', gaze: { x: 0, y: -0.05 } },
47
- shock: { duration: 1.2, eyeMode: 'round', eyeScale: 1.22, mouth: 'o', gaze: { x: 0, y: -0.02 } },
48
+ happy: {
49
+ duration: 1.7,
50
+ eyeMode: 'happy',
51
+ eyeScale: 1,
52
+ mouth: 'grin',
53
+ gaze: { x: 0, y: -0.05 },
54
+ earTilt: 0.14
55
+ },
56
+ shock: {
57
+ duration: 1.2,
58
+ eyeMode: 'round',
59
+ eyeScale: 1.22,
60
+ mouth: 'o',
61
+ gaze: { x: 0, y: -0.02 },
62
+ earTilt: 0.06
63
+ },
48
64
  frustrated: {
49
65
  duration: 1.5,
50
66
  eyeMode: 'squint',
51
67
  eyeScale: 1,
52
68
  mouth: 'frown',
53
- gaze: { x: 0, y: 0.02 }
69
+ gaze: { x: 0, y: 0.02 },
70
+ earTilt: 0.62
54
71
  }
55
72
  }
56
73
 
@@ -63,9 +80,9 @@ const HAPPY_WIDEN_RATIO = 0.2
63
80
  */
64
81
  const COLORS = { body: '#3d9bf0', face: '#ffffff' } as const
65
82
 
66
- /** 球体横竖比:扁椭圆(mochi 感),呼吸在此之上微调 */
83
+ /** 球体横竖比:扁椭圆(mochi 感,明显压扁),呼吸在此之上微调 */
67
84
  const BODY_SCALE_X = 1.1
68
- const BODY_SCALE_Y = 0.92
85
+ const BODY_SCALE_Y = 0.84
69
86
 
70
87
  /** 竖椭圆大眼睛(单位球空间):竖长才像眼睛,正圆容易读成鼻孔 */
71
88
  const EYE_X = 0.27
@@ -73,6 +90,18 @@ const EYE_Y = -0.12
73
90
  const EYE_RX = 0.13
74
91
  const EYE_RY = 0.19
75
92
 
93
+ /**
94
+ * 耳朵(单位球空间):圆角尖三角(猫耳),根部埋在球体内(先于球体路径绘制,被球遮住),
95
+ * 只露出耳尖;基础外倒角,表情时竖起(角小)或耷拉(角大)
96
+ */
97
+ const EAR_X = 0.44
98
+ const EAR_Y = -0.8
99
+ /** 根部半宽 */
100
+ const EAR_W = 0.17
101
+ /** 耳高(根部到耳尖) */
102
+ const EAR_H = 0.46
103
+ const EAR_TILT = 0.38
104
+
76
105
  export interface AiOrbRendererOptions {
77
106
  /** css 像素尺寸(画布为正方形) */
78
107
  size: number
@@ -123,6 +152,7 @@ function reactionEnvelope(p: number): number {
123
152
  * 灵动感的核心在眼部状态机:
124
153
  * - 白色大眼睛随机眨眼(约 18% 概率双眨),闭起为白色线条
125
154
  * - 视线游移 / 转头(面部整体平移 + 轻微倾斜,idle 随机、thinking 缓慢扫视)
155
+ * - thinking 时在球体右上角绘制与身体同色的「?」,轻晃
126
156
  * - 瞬时表情({@link AiOrbReaction}):睁大眼、弯眼笑、紧闭眼 ><,配合点头 / 摇头 / 后仰
127
157
  *
128
158
  * 性能设计:
@@ -168,6 +198,50 @@ export function createOrbRenderer(
168
198
  let pointerPos: { x: number; y: number } | null = null
169
199
  /** Q 弹起始时间,-1 表示未在回弹 */
170
200
  let pokeStart = -1
201
+ /** thinking 右上角「?」显隐(0-1),随状态平滑过渡 */
202
+ let thinkingMark = statusLabel === 'thinking' ? 1 : 0
203
+
204
+ /** 猫耳轮廓:两侧微鼓、耳尖带小圆角、根部微弧朝下埋进球体 */
205
+ function earPath(w: number, h: number) {
206
+ if (!ctx) return
207
+ ctx.beginPath()
208
+ ctx.moveTo(-w, 0)
209
+ ctx.quadraticCurveTo(-w * 0.92, -h * 0.68, -0.028, -h * 0.92)
210
+ // 耳尖小圆角,避免锐利折点
211
+ ctx.quadraticCurveTo(0, -h, 0.028, -h * 0.92)
212
+ ctx.quadraticCurveTo(w * 0.92, -h * 0.68, w, 0)
213
+ ctx.quadraticCurveTo(0, 0.09, -w, 0)
214
+ ctx.closePath()
215
+ }
216
+
217
+ /** 耳朵:身体色尖耳 + 白色内耳,平时随时间轻微错相摆动 */
218
+ function drawEars(tilt: number) {
219
+ if (!ctx) return
220
+
221
+ for (const side of [-1, 1]) {
222
+ const swing = 0.05 * Math.sin(t * 1.9 + side * 1.2)
223
+ ctx.save()
224
+ ctx.translate(side * EAR_X, EAR_Y)
225
+ ctx.rotate(side * (tilt + swing))
226
+
227
+ ctx.fillStyle = COLORS.body
228
+ earPath(EAR_W, EAR_H)
229
+ ctx.fill()
230
+
231
+ // 内耳为相似缩小的尖三角,偏向耳尖,与白色五官呼应
232
+ ctx.fillStyle = COLORS.face
233
+ const iw = EAR_W * 0.48
234
+ ctx.beginPath()
235
+ ctx.moveTo(-iw, -EAR_H * 0.1)
236
+ ctx.quadraticCurveTo(-iw * 0.9, -EAR_H * 0.44, 0, -EAR_H * 0.6)
237
+ ctx.quadraticCurveTo(iw * 0.9, -EAR_H * 0.44, iw, -EAR_H * 0.1)
238
+ ctx.quadraticCurveTo(0, -EAR_H * 0.02, -iw, -EAR_H * 0.1)
239
+ ctx.closePath()
240
+ ctx.fill()
241
+
242
+ ctx.restore()
243
+ }
244
+ }
171
245
 
172
246
  /** 面部:白色大眼睛(圆眼 / 弯眼 / 紧闭线条)+ 嘴 */
173
247
  function drawFace(eyeMode: EyeMode, eyeOpen: number, eyeScale: number, mouth: MouthMode) {
@@ -213,6 +287,28 @@ export function createOrbRenderer(
213
287
  drawMouth(mouth)
214
288
  }
215
289
 
290
+ /**
291
+ * thinking 吉祥物右上角的「?」:球体同色,轻晃更像在想问题。
292
+ * 画在单位球变换之外,避免被扁椭圆压扁。
293
+ */
294
+ function drawThinkingMark(weight: number) {
295
+ if (!ctx || weight <= 0.01) return
296
+ const fontSize = size * 0.3
297
+ // 顶在画布右缘:主体在该高度大约只到 0.70,右侧空位给问号
298
+ const x = size * 0.93
299
+ const y = size * 0.24 + size * 0.02 * Math.sin(t * 3.6)
300
+ ctx.save()
301
+ ctx.globalAlpha = weight
302
+ ctx.translate(x, y)
303
+ ctx.rotate(-0.16 + 0.1 * Math.sin(t * 2.7))
304
+ ctx.fillStyle = COLORS.body
305
+ ctx.font = `700 ${fontSize}px ui-rounded, system-ui, sans-serif`
306
+ ctx.textAlign = 'center'
307
+ ctx.textBaseline = 'middle'
308
+ ctx.fillText('?', 0, 0)
309
+ ctx.restore()
310
+ }
311
+
216
312
  function drawMouth(mouth: MouthMode) {
217
313
  if (!ctx || mouth === 'none') return
218
314
  ctx.fillStyle = COLORS.face
@@ -344,6 +440,10 @@ export function createOrbRenderer(
344
440
  gazeCur.x += (gazeTarget.x - gazeCur.x) * gazeEase
345
441
  gazeCur.y += (gazeTarget.y - gazeCur.y) * gazeEase
346
442
 
443
+ const markTarget = statusLabel === 'thinking' ? 1 : 0
444
+ if (isRunning) thinkingMark += (markTarget - thinkingMark) * ease
445
+ else thinkingMark = markTarget
446
+
347
447
  // 眼部参数:常态 × 眨眼,再向表情目标混合
348
448
  let eyeOpen = current.eyeOpen * blinkOpen
349
449
  let eyeScale = 1
@@ -364,7 +464,10 @@ export function createOrbRenderer(
364
464
  let bodyDy = 0
365
465
  let bodyRot = gazeCur.x * 0.5
366
466
  let bodyScale = 1
467
+ // 耳朵外倒角:表情时向目标角度混合(开心 / 惊讶竖起,沮丧耷拉)
468
+ let earTilt = EAR_TILT
367
469
  if (reactionDef && reaction && reactionW > 0.01) {
470
+ earTilt = lerp(EAR_TILT, reactionDef.earTilt, reactionW)
368
471
  if (reaction.type === 'happy') {
369
472
  bodyDy = Math.sin(Math.min(reactionP / 0.75, 1) * Math.PI) * 0.05
370
473
  } else if (reaction.type === 'frustrated') {
@@ -406,6 +509,9 @@ export function createOrbRenderer(
406
509
  ctx.rotate(bodyRot)
407
510
  ctx.scale(radius * scaleX, radius * scaleY)
408
511
 
512
+ // 耳朵先于球体绘制:根部被球体遮住,像从头顶长出来,并随球体一起形变
513
+ drawEars(earTilt)
514
+
409
515
  ctx.beginPath()
410
516
  for (let i = 0; i <= SEGMENTS; i++) {
411
517
  const theta = (i / SEGMENTS) * Math.PI * 2
@@ -425,6 +531,8 @@ export function createOrbRenderer(
425
531
  drawFace(eyeMode, eyeOpen, eyeScale, mouth)
426
532
 
427
533
  ctx.restore()
534
+
535
+ drawThinkingMark(thinkingMark)
428
536
  }
429
537
 
430
538
  function tick(now: number) {
@@ -67,6 +67,15 @@ function createAskTransport(rounds: ChatMessage[][]) {
67
67
 
68
68
  const click = (el: HTMLElement) => el.dispatchEvent(new MouseEvent('click', { bubbles: true }))
69
69
 
70
+ /** 最终答案输出后过程消息收进「已完成」折叠块:点击展开并等待过程 DOM 挂载 */
71
+ async function expandProcess(host: HTMLElement) {
72
+ await vi.waitFor(() => {
73
+ expect(host.querySelector('.u-ai-chat__process-header')).toBeTruthy()
74
+ })
75
+ click(host.querySelector<HTMLElement>('.u-ai-chat__process-header')!)
76
+ await nextTick()
77
+ }
78
+
70
79
  /** 底部导航按钮(UButton disabled 体现为 is-disabled class) */
71
80
  const actionButton = (host: HTMLElement, text: string) => {
72
81
  return [...host.querySelectorAll<HTMLElement>('.u-ai-chat__ask-question-actions button')].find(
@@ -160,10 +169,11 @@ describe('内置提问工具', () => {
160
169
  ]
161
170
  })
162
171
 
172
+ // 第二轮文本输出后,提问工具卡片收进「已完成」过程块,先展开
173
+ await expandProcess(host)
174
+
163
175
  // 问答摘要视图:命中选项的回答渲染为 chip,完成后保持展开
164
- await vi.waitFor(() => {
165
- expect(host.querySelectorAll('.u-ai-chat__ask-question-result').length).toBe(3)
166
- })
176
+ expect(host.querySelectorAll('.u-ai-chat__ask-question-result').length).toBe(3)
167
177
  const summary = host.querySelector('.u-ai-chat__ask-question')!.textContent!
168
178
  expect(summary).toContain('目标用户是谁?')
169
179
  expect(summary).toContain('需要深色模式')
@@ -34,7 +34,7 @@ export interface AiChatProps {
34
34
  model?: string
35
35
  /** 当前推理等级,支持 v-model:reasoning-level */
36
36
  reasoningLevel?: string
37
- /** 空状态欢迎项(快捷提问,点击即发送);字符串按单项处理 */
37
+ /** 空闲时输入框上方的快捷提问(点击即发送);字符串按单项处理 */
38
38
  welcome?: string | string[]
39
39
  /** 输入框占位文本 */
40
40
  placeholder?: string