@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.
- package/dist/chat/transports/openai.js +18 -17
- package/dist/chat/transports/openai.js.map +1 -1
- package/dist/chat/use-chat.js +8 -2
- package/dist/chat/use-chat.js.map +1 -1
- package/dist/components/ai-chat/ai-chat.js +1 -1
- package/dist/components/ai-chat/ai-chat.js.map +1 -1
- package/dist/components/ai-chat/message-item.js +1 -1
- package/dist/components/ai-chat/message-item.js.map +1 -1
- package/dist/components/ai-chat/message-list.js +164 -65
- package/dist/components/ai-chat/message-list.js.map +1 -1
- package/dist/components/ai-chat/style2.css +101 -21
- package/dist/components/ai-chat/tool-call.js +2 -6
- package/dist/components/ai-chat/tool-call.js.map +1 -1
- package/dist/components/ai-chat/turn-process.js +56 -0
- package/dist/components/ai-chat/turn-process.js.map +1 -0
- package/dist/components/ai-orb/orb-renderer.js +88 -8
- package/dist/components/ai-orb/orb-renderer.js.map +1 -1
- package/dist/types/ai-chat.d.ts +1 -1
- package/package.json +15 -6
- package/src/chat/__test__/use-chat.test.ts +42 -4
- package/src/chat/use-chat.ts +8 -2
- package/src/components/ai-chat/__test__/ai-chat.test.ts +176 -36
- package/src/components/ai-chat/ai-chat.vue +2 -2
- package/src/components/ai-chat/message-item.vue +3 -2
- package/src/components/ai-chat/message-list.vue +186 -66
- package/src/components/ai-chat/style.scss +130 -27
- package/src/components/ai-chat/tool-call.vue +1 -10
- package/src/components/ai-chat/turn-process.vue +47 -0
- package/src/components/ai-orb/__test__/ai-orb.test.ts +28 -3
- package/src/components/ai-orb/orb-renderer.ts +113 -5
- package/src/tools/ask-question/__test__/ask-question.test.ts +13 -3
- 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 = {
|
|
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
|
-
// 球体 + 双眼 =
|
|
83
|
-
expect(ctx.calls.fill).toBe(
|
|
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: {
|
|
47
|
-
|
|
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.
|
|
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
|
-
|
|
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('需要深色模式')
|
package/src/types/ai-chat.ts
CHANGED
|
@@ -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
|