@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
@@ -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/index.ts CHANGED
@@ -1,4 +1,11 @@
1
1
  export * from './chat'
2
2
  export * from './components'
3
- export * from './tools'
3
+ export type * from './providers'
4
+ // 仅导出 AskQuestion* 类型;createBuiltinTools 等工厂不对外公开
5
+ export type {
6
+ AskQuestionItem,
7
+ AskQuestionAnswer,
8
+ AskQuestionArgs,
9
+ AskQuestionResult
10
+ } from './tools'
4
11
  export type * from './types'
@@ -0,0 +1 @@
1
+ export type * from './types'
@@ -0,0 +1,46 @@
1
+ /** 推理等级选项(值不透明,由宿主/服务商约定) */
2
+ export interface ChatReasoningLevel {
3
+ /** 写入请求的值 */
4
+ value: string
5
+ /** UI 文案 */
6
+ label: string
7
+ }
8
+
9
+ /** 单个模型配置 */
10
+ export interface ChatModel {
11
+ /** 模型 id(跨 Provider 全局唯一) */
12
+ id: string
13
+ /** UI 显示名,缺省取 id */
14
+ label?: string
15
+ /** 模型一句话描述(选择器面板副标题) */
16
+ description?: string
17
+ /** 未设或空数组 → 不展示推理选择器 */
18
+ reasoningLevels?: ChatReasoningLevel[]
19
+ /** 默认推理等级(须落在 reasoningLevels 内) */
20
+ defaultReasoningLevel?: string
21
+ }
22
+
23
+ /** 模型服务商配置(内嵌 models) */
24
+ export interface ChatProvider {
25
+ id: string
26
+ /** UI 显示名 */
27
+ label?: string
28
+ /** 完整 http(s) URL 或相对路径,如 /api/ai/chat */
29
+ endpoint: string
30
+ /** API Key(有则带 Bearer;相对路径场景可省略,走 cookie/`headers`) */
31
+ apiKey?: string
32
+ /** Provider 级额外请求头 */
33
+ headers?: Record<string, string>
34
+ /**
35
+ * 将选中的推理等级写入 body。
36
+ * 缺省:若 level 存在则 `body.reasoning_effort = level`(OpenAI 兼容约定)。
37
+ */
38
+ applyReasoning?: (level: string, body: Record<string, unknown>) => void
39
+ models: ChatModel[]
40
+ }
41
+
42
+ /** UI / useChat 使用的扁平模型项(带 providerId) */
43
+ export interface ChatModelOption extends ChatModel {
44
+ providerId: string
45
+ providerLabel?: string
46
+ }
package/src/style.ts CHANGED
@@ -1 +1,2 @@
1
1
  import './components/ai-chat/style'
2
+ import './components/ai-orb/style'
@@ -4,7 +4,6 @@ import { createApp, defineComponent, h, nextTick, ref } from 'vue'
4
4
  import type { ChatMessage, ChatTransport } from '../../../chat/types'
5
5
  import UAiChat from '../../../components/ai-chat/ai-chat.vue'
6
6
  import type { AiChatExposed } from '../../../types'
7
- import { createAskQuestionTool } from '../ask-question'
8
7
 
9
8
  // happy-dom 环境下用桩组件替换 MarkdownRender,仅验证组件自身逻辑
10
9
  vi.mock('markstream-vue', () => ({
@@ -24,7 +23,8 @@ function mountAiChat(transport: ChatTransport) {
24
23
 
25
24
  const app = createApp({
26
25
  render() {
27
- return h(UAiChat, { ref: chatRef, transport, tools: [createAskQuestionTool()] })
26
+ // 不传 tools:验证提问工具由 UAiChat 内置自动注入
27
+ return h(UAiChat, { ref: chatRef, transport })
28
28
  }
29
29
  })
30
30
 
@@ -81,7 +81,7 @@ const optionButton = (host: HTMLElement, text: string) => {
81
81
  )
82
82
  }
83
83
 
84
- describe('createAskQuestionTool', () => {
84
+ describe('内置提问工具', () => {
85
85
  it('分页提问:导航、作答、提交后展示问答摘要', async () => {
86
86
  const rounds: ChatMessage[][] = []
87
87
  const { host, chat, unmount } = mountAiChat(createAskTransport(rounds))
@@ -168,7 +168,7 @@ describe('createAskQuestionTool', () => {
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 +1,14 @@
1
- export * from './ask-question'
1
+ import type { ChatTool } from '../chat/types'
2
+ import { createAskQuestionTool } from './ask-question'
3
+
4
+ export type {
5
+ AskQuestionItem,
6
+ AskQuestionAnswer,
7
+ AskQuestionArgs,
8
+ AskQuestionResult
9
+ } from './ask-question'
10
+
11
+ /** 内置工具列表(useChat 始终注入,不对外导出;新增内置工具在此注册) */
12
+ export function createBuiltinTools(): ChatTool[] {
13
+ return [createAskQuestionTool()]
14
+ }