@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
@@ -0,0 +1,505 @@
1
+ import type { AiOrbReaction, AiOrbStatus } from '../../types/ai-orb'
2
+
3
+ /** 常态动画参数(数值均为无量纲,随尺寸缩放) */
4
+ interface OrbParams {
5
+ /** 常态睁眼度(0-1,1 全开;thinking 微微眯起) */
6
+ eyeOpen: number
7
+ /** 呼吸缩振幅(相对球半径) */
8
+ breathe: number
9
+ /** 呼吸频率(Hz) */
10
+ breatheFreq: number
11
+ /** 表面扰动幅度(相对球半径) */
12
+ wobbleAmp: number
13
+ /** 表面扰动速度 */
14
+ wobbleSpeed: number
15
+ }
16
+
17
+ const STATUS_PARAMS: Record<AiOrbStatus, OrbParams> = {
18
+ idle: { eyeOpen: 1, breathe: 0.02, breatheFreq: 0.28, wobbleAmp: 0.012, wobbleSpeed: 1 },
19
+ thinking: { eyeOpen: 0.72, breathe: 0.012, breatheFreq: 0.2, wobbleAmp: 0.016, wobbleSpeed: 1.6 },
20
+ speaking: { eyeOpen: 1, breathe: 0.026, breatheFreq: 0.45, wobbleAmp: 0.018, wobbleSpeed: 2.2 }
21
+ }
22
+
23
+ /** 眼睛形态:圆眼(可眨眼 / 睁大)/ 开心弯眼 / 紧闭 >< */
24
+ type EyeMode = 'round' | 'happy' | 'squint'
25
+ /** 嘴部形态;none 表示无嘴(常态只有大眼睛,说话 / 表情时才出现嘴) */
26
+ type MouthMode = 'none' | 'smile' | 'flat' | 'talk' | 'grin' | 'frown' | 'o'
27
+
28
+ const STATUS_MOUTH: Record<AiOrbStatus, MouthMode> = {
29
+ idle: 'none',
30
+ thinking: 'none',
31
+ speaking: 'talk'
32
+ }
33
+
34
+ interface ReactionDef {
35
+ /** 表情总时长(秒) */
36
+ duration: number
37
+ eyeMode: EyeMode
38
+ /** 睁眼倍率(>1 为睁大) */
39
+ eyeScale: number
40
+ mouth: MouthMode
41
+ /** 表情期间视线落点(单位球空间) */
42
+ gaze: { x: number; y: number }
43
+ }
44
+
45
+ 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
+ frustrated: {
49
+ duration: 1.5,
50
+ eyeMode: 'squint',
51
+ eyeScale: 1,
52
+ mouth: 'frown',
53
+ gaze: { x: 0, y: 0.02 }
54
+ }
55
+ }
56
+
57
+ /** happy 前段先睁大双眼,随后才切换弯眼(占表情时长比例) */
58
+ const HAPPY_WIDEN_RATIO = 0.2
59
+
60
+ /**
61
+ * 固定配色:扁平纯色、无打光,饱满的天然蔚蓝球体 + 白色五官,
62
+ * 不跟随宿主主题,保证任何场景下观感一致。
63
+ */
64
+ const COLORS = { body: '#3d9bf0', face: '#ffffff' } as const
65
+
66
+ /** 球体横竖比:扁椭圆(mochi 感),呼吸在此之上微调 */
67
+ const BODY_SCALE_X = 1.1
68
+ const BODY_SCALE_Y = 0.92
69
+
70
+ /** 竖椭圆大眼睛(单位球空间):竖长才像眼睛,正圆容易读成鼻孔 */
71
+ const EYE_X = 0.27
72
+ const EYE_Y = -0.12
73
+ const EYE_RX = 0.13
74
+ const EYE_RY = 0.19
75
+
76
+ export interface AiOrbRendererOptions {
77
+ /** css 像素尺寸(画布为正方形) */
78
+ size: number
79
+ status?: AiOrbStatus
80
+ }
81
+
82
+ export interface AiOrbRenderer {
83
+ /** 目标状态切换(参数平滑过渡,无跳变) */
84
+ setStatus(status: AiOrbStatus): void
85
+ /** 播放一次瞬时表情(happy / shock / frustrated),播完自动回到常态 */
86
+ trigger(reaction: AiOrbReaction): void
87
+ /** 指针位置(单位球空间,可超出球体范围),null 表示指针离开;用于视线跟随 */
88
+ setPointer(pos: { x: number; y: number } | null): void
89
+ /** 戳一下:Q 弹挤压回弹(约 0.95s) */
90
+ poke(): void
91
+ resize(size: number): void
92
+ start(): void
93
+ stop(): void
94
+ /** 绘制一帧静态画面(prefers-reduced-motion 降级用;有未播完的表情时定格表情中段) */
95
+ renderOnce(): void
96
+ readonly running: boolean
97
+ }
98
+
99
+ /** 组合圆路径的点数(越大越平滑,36 在小尺寸下已足够) */
100
+ const SEGMENTS = 36
101
+
102
+ function clamp(value: number, min: number, max: number): number {
103
+ return Math.min(max, Math.max(min, value))
104
+ }
105
+
106
+ function lerp(from: number, to: number, k: number): number {
107
+ return from + (to - from) * k
108
+ }
109
+
110
+ function easeInOut(k: number): number {
111
+ return k * k * (3 - 2 * k)
112
+ }
113
+
114
+ /** 表情权重包络:快速淡入(前 12%)→ 保持 → 淡出(后 22%) */
115
+ function reactionEnvelope(p: number): number {
116
+ return easeInOut(clamp(Math.min(p / 0.12, (1 - p) / 0.22), 0, 1))
117
+ }
118
+
119
+ /**
120
+ * 纯 canvas 2D 活体球渲染器(框架无关)。
121
+ *
122
+ * 造型:原地呼吸的扁椭圆纯色球(无弹跳、无打光),固定配色。
123
+ * 灵动感的核心在眼部状态机:
124
+ * - 白色大眼睛随机眨眼(约 18% 概率双眨),闭起为白色线条
125
+ * - 视线游移 / 转头(面部整体平移 + 轻微倾斜,idle 随机、thinking 缓慢扫视)
126
+ * - 瞬时表情({@link AiOrbReaction}):睁大眼、弯眼笑、紧闭眼 ><,配合点头 / 摇头 / 后仰
127
+ *
128
+ * 性能设计:
129
+ * - 单位球空间(R=1)绘制,单条 36 段多边形路径,纯色填充,无渐变 / 滤镜 / shadowBlur
130
+ * - rAF 循环由组件侧按可见性启停;dt 截断避免后台切回时的跳变
131
+ */
132
+ export function createOrbRenderer(
133
+ canvas: HTMLCanvasElement,
134
+ options: AiOrbRendererOptions
135
+ ): AiOrbRenderer {
136
+ const ctx = canvas.getContext('2d')
137
+
138
+ let size = options.size
139
+ let dpr = 1
140
+
141
+ let target: OrbParams = { ...STATUS_PARAMS[options.status ?? 'idle'] }
142
+ let current: OrbParams = { ...target }
143
+ let statusLabel: AiOrbStatus = options.status ?? 'idle'
144
+
145
+ let rafId = 0
146
+ let isRunning = false
147
+ /** 动画时间(秒),只随 rAF 累积,暂停恢复无跳变 */
148
+ let t = 0
149
+ let lastFrameAt = 0
150
+ /** 上一帧距当前的间隔(秒),截断防止后台切回时暴冲 */
151
+ let frameDt = 1 / 60
152
+
153
+ /** 下一次眨眼时间点(秒,随机间隔) */
154
+ let nextBlinkAt = 1.6 + Math.random() * 3
155
+ /** 眨眼起始时间,-1 表示未在眨眼 */
156
+ let blinkStart = -1
157
+
158
+ /** 视线(单位球空间):current 缓动逼近 target */
159
+ const gazeCur = { x: 0, y: 0 }
160
+ let gazeTarget = { x: 0, y: 0 }
161
+ /** idle 态下一次游移视线的时间点 */
162
+ let nextGazeAt = 1.2 + Math.random() * 2
163
+
164
+ /** 进行中的瞬时表情 */
165
+ let reaction: { type: AiOrbReaction; startAt: number } | null = null
166
+
167
+ /** 指针悬停位置(单位球空间);null 表示未悬停,此时按 status 驱动视线 */
168
+ let pointerPos: { x: number; y: number } | null = null
169
+ /** Q 弹起始时间,-1 表示未在回弹 */
170
+ let pokeStart = -1
171
+
172
+ /** 面部:白色大眼睛(圆眼 / 弯眼 / 紧闭线条)+ 嘴 */
173
+ function drawFace(eyeMode: EyeMode, eyeOpen: number, eyeScale: number, mouth: MouthMode) {
174
+ if (!ctx) return
175
+
176
+ ctx.fillStyle = COLORS.face
177
+ ctx.strokeStyle = COLORS.face
178
+ ctx.lineCap = 'round'
179
+
180
+ for (const side of [-1, 1]) {
181
+ ctx.save()
182
+ ctx.translate(side * EYE_X, EYE_Y)
183
+
184
+ if (eyeMode === 'round') {
185
+ // 圆眼:竖直方向缩放表达睁眼度;闭起时压成白色线条
186
+ const open = clamp(eyeOpen, 0.07, 1)
187
+ ctx.save()
188
+ ctx.scale(1, open)
189
+ ctx.beginPath()
190
+ ctx.ellipse(0, 0, EYE_RX * eyeScale, EYE_RY * eyeScale, 0, 0, Math.PI * 2)
191
+ ctx.fill()
192
+ ctx.restore()
193
+ } else if (eyeMode === 'happy') {
194
+ // 开心弯眼:上半圆白色线条(∩)
195
+ ctx.lineWidth = 0.06
196
+ ctx.beginPath()
197
+ ctx.arc(0, 0.05, 0.14, Math.PI, Math.PI * 2)
198
+ ctx.stroke()
199
+ } else {
200
+ // 紧闭眼:>< 白色折线(顶点朝向鼻梁)
201
+ ctx.lineWidth = 0.055
202
+ ctx.lineJoin = 'round'
203
+ ctx.beginPath()
204
+ ctx.moveTo(0.085 * side, -0.08)
205
+ ctx.lineTo(-0.065 * side, 0)
206
+ ctx.lineTo(0.085 * side, 0.08)
207
+ ctx.stroke()
208
+ }
209
+
210
+ ctx.restore()
211
+ }
212
+
213
+ drawMouth(mouth)
214
+ }
215
+
216
+ function drawMouth(mouth: MouthMode) {
217
+ if (!ctx || mouth === 'none') return
218
+ ctx.fillStyle = COLORS.face
219
+ ctx.strokeStyle = COLORS.face
220
+
221
+ switch (mouth) {
222
+ case 'smile':
223
+ ctx.lineWidth = 0.05
224
+ ctx.beginPath()
225
+ ctx.moveTo(-0.15, 0.18)
226
+ ctx.quadraticCurveTo(0, 0.32, 0.15, 0.18)
227
+ ctx.stroke()
228
+ break
229
+ case 'flat':
230
+ // 思考:近乎平直的小嘴
231
+ ctx.lineWidth = 0.045
232
+ ctx.beginPath()
233
+ ctx.moveTo(-0.09, 0.24)
234
+ ctx.quadraticCurveTo(0, 0.255, 0.09, 0.24)
235
+ ctx.stroke()
236
+ break
237
+ case 'talk': {
238
+ // 说话:嘴随节奏开合
239
+ const ry = 0.045 + 0.05 * Math.abs(Math.sin(6.5 * t))
240
+ ctx.beginPath()
241
+ ctx.ellipse(0, 0.2, 0.1, ry, 0, 0, Math.PI * 2)
242
+ ctx.fill()
243
+ break
244
+ }
245
+ case 'grin':
246
+ // 大笑:张开的半圆嘴
247
+ ctx.beginPath()
248
+ ctx.ellipse(0, 0.15, 0.15, 0.115, 0, 0, Math.PI)
249
+ ctx.closePath()
250
+ ctx.fill()
251
+ break
252
+ case 'frown':
253
+ ctx.lineWidth = 0.05
254
+ ctx.beginPath()
255
+ ctx.moveTo(-0.1, 0.28)
256
+ ctx.quadraticCurveTo(0, 0.2, 0.1, 0.28)
257
+ ctx.stroke()
258
+ break
259
+ case 'o':
260
+ // 惊讶:小嘴微张
261
+ ctx.beginPath()
262
+ ctx.ellipse(0, 0.21, 0.06, 0.078, 0, 0, Math.PI * 2)
263
+ ctx.fill()
264
+ break
265
+ }
266
+ }
267
+
268
+ function applySize() {
269
+ dpr = Math.min(globalThis.devicePixelRatio || 1, 2)
270
+ const px = Math.max(1, Math.round(size * dpr))
271
+ canvas.width = px
272
+ canvas.height = px
273
+ }
274
+
275
+ /** 球面半径扰动:三层不同角频率的正弦叠加,营造有机感 */
276
+ function wobble(theta: number, params: OrbParams): number {
277
+ const { wobbleAmp, wobbleSpeed } = params
278
+ const w1 = wobbleSpeed
279
+ return (
280
+ wobbleAmp *
281
+ (0.58 * Math.sin(2 * theta + w1 * t) +
282
+ 0.3 * Math.sin(3 * theta - 1.6 * w1 * t + 1.7) +
283
+ 0.24 * Math.sin(5 * theta + 0.7 * w1 * t + 4.2))
284
+ )
285
+ }
286
+
287
+ function draw() {
288
+ if (!ctx) return
289
+
290
+ // 常态参数向目标平滑过渡(约 200ms 时间常数)
291
+ const ease = 1 - Math.exp(-frameDt * 5)
292
+ for (const key of Object.keys(target) as (keyof OrbParams)[]) {
293
+ current[key] += (target[key] - current[key]) * ease
294
+ }
295
+
296
+ // 眨眼:随机触发,结束时有概率紧接着补一次形成双眨;表情期间不插播
297
+ if (blinkStart < 0 && !reaction && t >= nextBlinkAt) blinkStart = t
298
+ let blinkOpen = 1
299
+ if (blinkStart >= 0) {
300
+ const phase = (t - blinkStart) / 0.16
301
+ if (phase >= 1) {
302
+ blinkStart = -1
303
+ nextBlinkAt = Math.random() < 0.18 ? t + 0.26 : t + 2.2 + Math.random() * 2.6
304
+ } else {
305
+ blinkOpen = 1 - Math.sin(Math.PI * phase) * 0.94
306
+ }
307
+ }
308
+
309
+ // 瞬时表情进度(播完自动清除)
310
+ let reactionDef: ReactionDef | null = null
311
+ let reactionP = 0
312
+ let reactionW = 0
313
+ if (reaction) {
314
+ const def = REACTIONS[reaction.type]
315
+ reactionP = (t - reaction.startAt) / def.duration
316
+ if (reactionP >= 1) {
317
+ reaction = null
318
+ } else {
319
+ reactionDef = def
320
+ reactionW = reactionEnvelope(reactionP)
321
+ }
322
+ }
323
+
324
+ // 视线目标:表情 > 指针跟随 > thinking 扫视 > speaking 微动 > idle 随机游移(转头 / 转眼睛)
325
+ if (reactionDef && reactionW > 0.45) {
326
+ gazeTarget = reactionDef.gaze
327
+ } else if (pointerPos) {
328
+ // 悬停互动:看向指针所在方位
329
+ gazeTarget = { x: clamp(pointerPos.x, -0.18, 0.18), y: clamp(pointerPos.y, -0.1, 0.12) }
330
+ } else if (statusLabel === 'thinking') {
331
+ // 思考:视线略上扬并缓慢左右扫视
332
+ gazeTarget = { x: 0.13 * Math.sin(t * 0.7), y: -0.05 + 0.03 * Math.sin(t * 0.47) }
333
+ } else if (statusLabel === 'speaking') {
334
+ gazeTarget = { x: 0.03 * Math.sin(t * 1.2), y: 0.02 * Math.sin(t * 0.8) }
335
+ } else if (t >= nextGazeAt) {
336
+ const far = Math.random() < 0.3
337
+ gazeTarget = {
338
+ x: (Math.random() * 2 - 1) * (far ? 0.16 : 0.07),
339
+ y: (Math.random() * 2 - 1) * 0.06 - 0.01
340
+ }
341
+ nextGazeAt = t + 1.4 + Math.random() * 2.2
342
+ }
343
+ const gazeEase = 1 - Math.exp(-frameDt * 6.5)
344
+ gazeCur.x += (gazeTarget.x - gazeCur.x) * gazeEase
345
+ gazeCur.y += (gazeTarget.y - gazeCur.y) * gazeEase
346
+
347
+ // 眼部参数:常态 × 眨眼,再向表情目标混合
348
+ let eyeOpen = current.eyeOpen * blinkOpen
349
+ let eyeScale = 1
350
+ let eyeMode: EyeMode = 'round'
351
+ let mouth: MouthMode = STATUS_MOUTH[statusLabel]
352
+ if (reactionDef && reaction) {
353
+ // happy 前段先睁大双眼(round),随后才切换弯眼
354
+ const widenPhase = reaction.type === 'happy' && reactionP < HAPPY_WIDEN_RATIO
355
+ eyeScale = lerp(1, widenPhase ? 1.2 : reactionDef.eyeScale, reactionW)
356
+ eyeOpen = lerp(eyeOpen, 1, reactionW)
357
+ if (reactionW > 0.45) {
358
+ eyeMode = widenPhase ? 'round' : reactionDef.eyeMode
359
+ mouth = reactionDef.mouth
360
+ }
361
+ }
362
+
363
+ // 表情肢体动作:点头 / 摇头 / 后仰;平时视线侧移带一点头部倾斜(转头感)
364
+ let bodyDy = 0
365
+ let bodyRot = gazeCur.x * 0.5
366
+ let bodyScale = 1
367
+ if (reactionDef && reaction && reactionW > 0.01) {
368
+ if (reaction.type === 'happy') {
369
+ bodyDy = Math.sin(Math.min(reactionP / 0.75, 1) * Math.PI) * 0.05
370
+ } else if (reaction.type === 'frustrated') {
371
+ bodyRot += Math.sin(reactionP * reactionDef.duration * 18) * 0.05 * (1 - reactionP)
372
+ } else {
373
+ bodyScale = 1 - 0.05 * Math.sin(Math.min(reactionP * 1.6, 1) * Math.PI)
374
+ }
375
+ }
376
+
377
+ // 扁椭圆 + 呼吸(原地,无弹跳);Q 弹:挤压后阻尼振荡回弹(体积近似守恒)
378
+ // press 包络让压下也走一个短渐入(约 80ms),避免按下瞬间球体跳变压扁
379
+ // 低阻尼 + 约 1.6 周期 / 0.95s(≈1.7Hz):回弹带过冲和一次可见的二次晃动,Q 而不抖
380
+ let jellyX = 1
381
+ let jellyY = 1
382
+ if (pokeStart >= 0) {
383
+ const pp = (t - pokeStart) / 0.95
384
+ if (pp >= 1) {
385
+ pokeStart = -1
386
+ } else {
387
+ const press = 1 - Math.exp(-pp * 14)
388
+ const jelly = press * Math.exp(-2.6 * pp) * Math.cos(Math.PI * 2 * 1.6 * pp)
389
+ jellyY = 1 - 0.14 * jelly
390
+ jellyX = 1 + 0.085 * jelly
391
+ }
392
+ }
393
+ const radius = size * 0.36
394
+ const breatheScale = 1 + current.breathe * Math.sin(Math.PI * 2 * current.breatheFreq * t)
395
+ const scaleX = BODY_SCALE_X * breatheScale * bodyScale * jellyX
396
+ const scaleY = BODY_SCALE_Y * breatheScale * bodyScale * jellyY
397
+ const cx = size / 2
398
+ const cy = size / 2 + bodyDy * radius
399
+
400
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
401
+ ctx.clearRect(0, 0, size, size)
402
+
403
+ // 纯色球体:单条扰动路径填充,无渐变与打光
404
+ ctx.save()
405
+ ctx.translate(cx, cy)
406
+ ctx.rotate(bodyRot)
407
+ ctx.scale(radius * scaleX, radius * scaleY)
408
+
409
+ ctx.beginPath()
410
+ for (let i = 0; i <= SEGMENTS; i++) {
411
+ const theta = (i / SEGMENTS) * Math.PI * 2
412
+ const r = 1 + wobble(theta, current)
413
+ const x = r * Math.cos(theta)
414
+ const y = r * Math.sin(theta)
415
+ if (i === 0) ctx.moveTo(x, y)
416
+ else ctx.lineTo(x, y)
417
+ }
418
+ ctx.closePath()
419
+ ctx.fillStyle = COLORS.body
420
+ ctx.fill()
421
+
422
+ // 面部随球体形变,并叠加视线平移 / 头部微倾
423
+ ctx.translate(gazeCur.x, gazeCur.y)
424
+ ctx.rotate(gazeCur.x * 0.35)
425
+ drawFace(eyeMode, eyeOpen, eyeScale, mouth)
426
+
427
+ ctx.restore()
428
+ }
429
+
430
+ function tick(now: number) {
431
+ if (!isRunning) return
432
+ frameDt = lastFrameAt === 0 ? 1 / 60 : Math.min((now - lastFrameAt) / 1000, 0.05)
433
+ lastFrameAt = now
434
+ t += frameDt
435
+ draw()
436
+ rafId = requestAnimationFrame(tick)
437
+ }
438
+
439
+ function renderOnce() {
440
+ frameDt = 1 / 60
441
+ // 静态帧优先级:进行中的表情定格在中段 > Q 弹定格在挤压段 > 常态安静帧
442
+ if (reaction) {
443
+ t = reaction.startAt + REACTIONS[reaction.type].duration * 0.45
444
+ } else if (pokeStart >= 0) {
445
+ t = pokeStart + 0.2
446
+ } else {
447
+ t = 0.35
448
+ }
449
+ gazeCur.x = 0
450
+ gazeCur.y = 0
451
+ gazeTarget = { x: 0, y: 0 }
452
+ draw()
453
+ }
454
+
455
+ applySize()
456
+
457
+ return {
458
+ get running() {
459
+ return isRunning
460
+ },
461
+
462
+ setStatus(status: AiOrbStatus) {
463
+ statusLabel = status
464
+ target = { ...STATUS_PARAMS[status] }
465
+ },
466
+
467
+ trigger(type: AiOrbReaction) {
468
+ reaction = { type, startAt: t }
469
+ // 表情接管眼部,中断进行中的眨眼
470
+ blinkStart = -1
471
+ if (!isRunning) renderOnce()
472
+ },
473
+
474
+ setPointer(pos: { x: number; y: number } | null) {
475
+ pointerPos = pos
476
+ },
477
+
478
+ poke() {
479
+ pokeStart = t
480
+ if (!isRunning) renderOnce()
481
+ },
482
+
483
+ resize(nextSize: number) {
484
+ if (nextSize === size) return
485
+ size = nextSize
486
+ applySize()
487
+ if (!isRunning) draw()
488
+ },
489
+
490
+ start() {
491
+ if (isRunning || !ctx) return
492
+ isRunning = true
493
+ lastFrameAt = 0
494
+ rafId = requestAnimationFrame(tick)
495
+ },
496
+
497
+ stop() {
498
+ isRunning = false
499
+ if (rafId) cancelAnimationFrame(rafId)
500
+ rafId = 0
501
+ },
502
+
503
+ renderOnce
504
+ }
505
+ }
@@ -0,0 +1,10 @@
1
+ @use 'pkg:@veltra/styles/mixins' as m;
2
+
3
+ $root-name: ai-orb;
4
+
5
+ @include m.b($root-name) {
6
+ display: block;
7
+ flex-shrink: 0;
8
+ cursor: pointer;
9
+ -webkit-tap-highlight-color: transparent;
10
+ }
@@ -0,0 +1 @@
1
+ import './style.scss'
@@ -1 +1,2 @@
1
1
  export * from './ai-chat'
2
+ export * from './ai-orb'
package/src/style.ts CHANGED
@@ -1 +1,2 @@
1
1
  import './components/ai-chat/style'
2
+ import './components/ai-orb/style'
@@ -168,7 +168,7 @@ describe('内置提问工具', () => {
168
168
  expect(summary).toContain('目标用户是谁?')
169
169
  expect(summary).toContain('需要深色模式')
170
170
  expect(host.querySelector('.u-ai-chat__ask-question-chip')?.textContent).toBe('开发者')
171
- expect(host.querySelector('.u-ai-chat__tool-call.is-success.is-expanded')).toBeTruthy()
171
+ expect(host.querySelector('.u-ai-chat__tool-call.is-success.is-active')).toBeTruthy()
172
172
  unmount()
173
173
  })
174
174
 
@@ -1,8 +1,10 @@
1
1
  import type { DeconstructValue } from '@veltra/utils'
2
+ import type { Ref } from 'vue'
2
3
 
3
4
  import type {
4
5
  ChatAttachment,
5
6
  ChatMessage,
7
+ ChatQueuedMessage,
6
8
  ChatTool,
7
9
  ChatToolCall,
8
10
  ChatTransport
@@ -16,6 +18,11 @@ export interface AiChatProps {
16
18
  tools?: ChatTool[]
17
19
  /** 系统提示词 */
18
20
  systemPrompt?: string
21
+ /**
22
+ * 单次发送允许的最大生成轮次(一轮 = 一次模型生成 + 可能的工具执行),默认 10。
23
+ * 模型持续调用工具不收敛时,达到上限即停止继续请求并发出 finish,防止失控循环。
24
+ */
25
+ maxToolRounds?: number
19
26
  /** 消息列表,支持 v-model:messages 受控 */
20
27
  messages?: ChatMessage[]
21
28
  /**
@@ -27,8 +34,8 @@ export interface AiChatProps {
27
34
  model?: string
28
35
  /** 当前推理等级,支持 v-model:reasoning-level */
29
36
  reasoningLevel?: string
30
- /** 空状态欢迎语 */
31
- welcome?: string
37
+ /** 空状态欢迎项(快捷提问,点击即发送);字符串按单项处理 */
38
+ welcome?: string | string[]
32
39
  /** 输入框占位文本 */
33
40
  placeholder?: string
34
41
  /** 附件 accept 类型,默认 image/* */
@@ -48,7 +55,7 @@ export interface AiChatEmits {
48
55
  (e: 'update:reasoningLevel', reasoningLevel: string | undefined): void
49
56
  /** 用户发送消息 */
50
57
  (e: 'send', message: ChatMessage): void
51
- /** 一轮对话完成(无更多工具调用) */
58
+ /** 一轮对话完成(无更多工具调用、命中 terminal 工具或达到 maxToolRounds 上限) */
52
59
  (e: 'finish', message: ChatMessage): void
53
60
  /** 对话出错 */
54
61
  (e: 'error', error: Error): void
@@ -57,14 +64,22 @@ export interface AiChatEmits {
57
64
  }
58
65
 
59
66
  export interface _AiChatExposed {
60
- /** 发送一条用户消息 */
67
+ /** 发送一条用户消息(会话进行中时进入待发送队列) */
61
68
  send: (content: string, attachments?: ChatAttachment[]) => void
62
- /** 中断当前生成 */
69
+ /** 中断当前生成(保留待发送队列) */
63
70
  abort: () => void
64
71
  /** 重新生成最后一条 assistant 回复 */
65
72
  regenerate: () => void
66
- /** 清空消息 */
73
+ /** 清空消息与待发送队列 */
67
74
  clear: () => void
75
+ /** 待发送消息队列(会话进行中提交的消息按序排队,收尾后 FIFO 自动接续) */
76
+ queue: Ref<ChatQueuedMessage[]>
77
+ /** 立即执行队列中的某条:中断当前会话并插队为下一条 */
78
+ startQueued: (id: string) => void
79
+ /** 从队列移除某条(返回被移除项) */
80
+ removeQueued: (id: string) => ChatQueuedMessage | undefined
81
+ /** 向队列插入一条消息(beforeId 插到某条之前,缺省追加尾部;空闲时自动消耗队首) */
82
+ enqueue: (content: string, attachments?: ChatAttachment[], beforeId?: string) => ChatQueuedMessage
68
83
  }
69
84
 
70
85
  export type AiChatExposed = DeconstructValue<_AiChatExposed>
@@ -0,0 +1,27 @@
1
+ /** 活体球生命状态:驱动常态动画(平静 / 思考 / 输出) */
2
+ export type AiOrbStatus = 'idle' | 'thinking' | 'speaking'
3
+
4
+ /**
5
+ * 瞬时表情:播放约 1-2s 后自动回到 status 驱动的常态。
6
+ * - happy:成功 / 回答完毕 —— 先睁大眼睛,再弯眼大笑 + 点头
7
+ * - shock:惊讶 —— 睁大眼睛 + 小嘴微张 + 轻微后仰
8
+ * - frustrated:受挫(如工具调用失败)—— 闭紧眼睛 + 摇头 + 嘴角下撇
9
+ */
10
+ export type AiOrbReaction = 'happy' | 'shock' | 'frustrated'
11
+
12
+ export interface AiOrbProps {
13
+ /** 球体直径(px),默认 48 */
14
+ size?: number
15
+ /** 生命状态,默认 idle */
16
+ status?: AiOrbStatus
17
+ }
18
+
19
+ export interface AiOrbEmits {
20
+ /** 点击球体(球体同时会做 Q 弹反馈) */
21
+ (e: 'click'): void
22
+ }
23
+
24
+ export interface AiOrbExposed {
25
+ /** 播放一次瞬时表情(对应阶段性事件,如回答完毕 / 工具调用失败) */
26
+ react: (reaction: AiOrbReaction) => void
27
+ }
@@ -1 +1,2 @@
1
1
  export * from './ai-chat'
2
+ export * from './ai-orb'