@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.
- package/dist/chat/index.d.ts +2 -2
- package/dist/chat/transports/openai.js +1 -1
- package/dist/chat/transports/openai.js.map +1 -1
- package/dist/chat/types.d.ts +37 -2
- package/dist/chat/use-chat.d.ts +23 -1
- package/dist/chat/use-chat.js +83 -8
- package/dist/chat/use-chat.js.map +1 -1
- package/dist/components/ai-chat/ai-chat.js +207 -46
- package/dist/components/ai-chat/ai-chat.js.map +1 -1
- package/dist/components/ai-chat/chat-input.js +37 -12
- package/dist/components/ai-chat/chat-input.js.map +1 -1
- package/dist/components/ai-chat/di.js.map +1 -1
- package/dist/components/ai-chat/message-item.js +101 -23
- package/dist/components/ai-chat/message-item.js.map +1 -1
- package/dist/components/ai-chat/message-list.js +217 -32
- package/dist/components/ai-chat/message-list.js.map +1 -1
- package/dist/components/ai-chat/model-picker.js +69 -46
- package/dist/components/ai-chat/model-picker.js.map +1 -1
- package/dist/components/ai-chat/queue-list.js +70 -0
- package/dist/components/ai-chat/queue-list.js.map +1 -0
- package/dist/components/ai-chat/side-panel.js +96 -0
- package/dist/components/ai-chat/side-panel.js.map +1 -0
- package/dist/components/ai-chat/style.d.ts +4 -0
- package/dist/components/ai-chat/style.js +4 -0
- package/dist/components/ai-chat/style2.css +457 -74
- package/dist/components/ai-chat/tool-call.js +60 -26
- package/dist/components/ai-chat/tool-call.js.map +1 -1
- package/dist/components/ai-orb/ai-orb.js +94 -0
- package/dist/components/ai-orb/ai-orb.js.map +1 -0
- package/dist/components/ai-orb/orb-renderer.js +411 -0
- package/dist/components/ai-orb/orb-renderer.js.map +1 -0
- package/dist/components/ai-orb/style.d.ts +1 -0
- package/dist/components/ai-orb/style.js +1 -0
- package/dist/components/ai-orb/style2.css +23 -0
- package/dist/index.d.ts +4 -2
- package/dist/index.js +2 -1
- package/dist/style.js +1 -0
- package/dist/types/ai-chat.d.ts +21 -7
- package/dist/types/ai-orb.d.ts +27 -0
- package/dist/types/index.d.ts +2 -1
- package/package.json +5 -5
- package/src/chat/__test__/use-chat.test.ts +224 -9
- package/src/chat/transports/__test__/openai.test.ts +78 -0
- package/src/chat/transports/openai.ts +2 -2
- package/src/chat/types.ts +37 -1
- package/src/chat/use-chat.ts +112 -9
- package/src/components/ai-chat/__test__/ai-chat.test.ts +588 -11
- package/src/components/ai-chat/ai-chat.vue +227 -31
- package/src/components/ai-chat/chat-input.vue +41 -11
- package/src/components/ai-chat/di.ts +2 -0
- package/src/components/ai-chat/message-item.vue +98 -11
- package/src/components/ai-chat/message-list.vue +261 -21
- package/src/components/ai-chat/model-picker.vue +110 -59
- package/src/components/ai-chat/queue-list.vue +63 -0
- package/src/components/ai-chat/side-panel.vue +76 -0
- package/src/components/ai-chat/style.scss +527 -74
- package/src/components/ai-chat/style.ts +4 -0
- package/src/components/ai-chat/tool-call.vue +86 -42
- package/src/components/ai-orb/__test__/ai-orb.test.ts +251 -0
- package/src/components/ai-orb/ai-orb.vue +107 -0
- package/src/components/ai-orb/index.ts +1 -0
- package/src/components/ai-orb/orb-renderer.ts +505 -0
- package/src/components/ai-orb/style.scss +10 -0
- package/src/components/ai-orb/style.ts +1 -0
- package/src/components/index.ts +1 -0
- package/src/style.ts +1 -0
- package/src/tools/ask-question/__test__/ask-question.test.ts +1 -1
- package/src/types/ai-chat.ts +21 -6
- package/src/types/ai-orb.ts +27 -0
- 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 @@
|
|
|
1
|
+
import './style.scss'
|
package/src/components/index.ts
CHANGED
package/src/style.ts
CHANGED
|
@@ -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-
|
|
171
|
+
expect(host.querySelector('.u-ai-chat__tool-call.is-success.is-active')).toBeTruthy()
|
|
172
172
|
unmount()
|
|
173
173
|
})
|
|
174
174
|
|
package/src/types/ai-chat.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/types/index.ts
CHANGED