@veltra/ai 2.1.1 → 2.1.3

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 (28) hide show
  1. package/dist/chat/transports/openai.js +18 -17
  2. package/dist/chat/transports/openai.js.map +1 -1
  3. package/dist/components/ai-chat/ai-chat.js +1 -1
  4. package/dist/components/ai-chat/ai-chat.js.map +1 -1
  5. package/dist/components/ai-chat/message-item.js +2 -7
  6. package/dist/components/ai-chat/message-item.js.map +1 -1
  7. package/dist/components/ai-chat/message-list.js +164 -65
  8. package/dist/components/ai-chat/message-list.js.map +1 -1
  9. package/dist/components/ai-chat/style2.css +111 -31
  10. package/dist/components/ai-chat/tool-call.js +2 -6
  11. package/dist/components/ai-chat/tool-call.js.map +1 -1
  12. package/dist/components/ai-chat/turn-process.js +56 -0
  13. package/dist/components/ai-chat/turn-process.js.map +1 -0
  14. package/dist/components/ai-orb/orb-renderer.js +88 -8
  15. package/dist/components/ai-orb/orb-renderer.js.map +1 -1
  16. package/dist/types/ai-chat.d.ts +1 -1
  17. package/package.json +15 -6
  18. package/src/components/ai-chat/__test__/ai-chat.test.ts +136 -35
  19. package/src/components/ai-chat/ai-chat.vue +2 -2
  20. package/src/components/ai-chat/message-item.vue +3 -3
  21. package/src/components/ai-chat/message-list.vue +186 -66
  22. package/src/components/ai-chat/style.scss +149 -41
  23. package/src/components/ai-chat/tool-call.vue +1 -10
  24. package/src/components/ai-chat/turn-process.vue +47 -0
  25. package/src/components/ai-orb/__test__/ai-orb.test.ts +28 -3
  26. package/src/components/ai-orb/orb-renderer.ts +113 -5
  27. package/src/tools/ask-question/__test__/ask-question.test.ts +13 -3
  28. package/src/types/ai-chat.ts +1 -1
@@ -1 +1 @@
1
- {"version":3,"file":"orb-renderer.js","names":[],"sources":["../../../src/components/ai-orb/orb-renderer.ts"],"sourcesContent":["import type { AiOrbReaction, AiOrbStatus } from '../../types/ai-orb'\n\n/** 常态动画参数(数值均为无量纲,随尺寸缩放) */\ninterface OrbParams {\n /** 常态睁眼度(0-1,1 全开;thinking 微微眯起) */\n eyeOpen: number\n /** 呼吸缩振幅(相对球半径) */\n breathe: number\n /** 呼吸频率(Hz) */\n breatheFreq: number\n /** 表面扰动幅度(相对球半径) */\n wobbleAmp: number\n /** 表面扰动速度 */\n wobbleSpeed: number\n}\n\nconst STATUS_PARAMS: Record<AiOrbStatus, OrbParams> = {\n idle: { eyeOpen: 1, breathe: 0.02, breatheFreq: 0.28, wobbleAmp: 0.012, wobbleSpeed: 1 },\n thinking: { eyeOpen: 0.72, breathe: 0.012, breatheFreq: 0.2, wobbleAmp: 0.016, wobbleSpeed: 1.6 },\n speaking: { eyeOpen: 1, breathe: 0.026, breatheFreq: 0.45, wobbleAmp: 0.018, wobbleSpeed: 2.2 }\n}\n\n/** 眼睛形态:圆眼(可眨眼 / 睁大)/ 开心弯眼 / 紧闭 >< */\ntype EyeMode = 'round' | 'happy' | 'squint'\n/** 嘴部形态;none 表示无嘴(常态只有大眼睛,说话 / 表情时才出现嘴) */\ntype MouthMode = 'none' | 'smile' | 'flat' | 'talk' | 'grin' | 'frown' | 'o'\n\nconst STATUS_MOUTH: Record<AiOrbStatus, MouthMode> = {\n idle: 'none',\n thinking: 'none',\n speaking: 'talk'\n}\n\ninterface ReactionDef {\n /** 表情总时长(秒) */\n duration: number\n eyeMode: EyeMode\n /** 睁眼倍率(>1 为睁大) */\n eyeScale: number\n mouth: MouthMode\n /** 表情期间视线落点(单位球空间) */\n gaze: { x: number; y: number }\n}\n\nconst REACTIONS: Record<AiOrbReaction, ReactionDef> = {\n happy: { duration: 1.7, eyeMode: 'happy', eyeScale: 1, mouth: 'grin', gaze: { x: 0, y: -0.05 } },\n shock: { duration: 1.2, eyeMode: 'round', eyeScale: 1.22, mouth: 'o', gaze: { x: 0, y: -0.02 } },\n frustrated: {\n duration: 1.5,\n eyeMode: 'squint',\n eyeScale: 1,\n mouth: 'frown',\n gaze: { x: 0, y: 0.02 }\n }\n}\n\n/** happy 前段先睁大双眼,随后才切换弯眼(占表情时长比例) */\nconst HAPPY_WIDEN_RATIO = 0.2\n\n/**\n * 固定配色:扁平纯色、无打光,饱满的天然蔚蓝球体 + 白色五官,\n * 不跟随宿主主题,保证任何场景下观感一致。\n */\nconst COLORS = { body: '#3d9bf0', face: '#ffffff' } as const\n\n/** 球体横竖比:扁椭圆(mochi 感),呼吸在此之上微调 */\nconst BODY_SCALE_X = 1.1\nconst BODY_SCALE_Y = 0.92\n\n/** 竖椭圆大眼睛(单位球空间):竖长才像眼睛,正圆容易读成鼻孔 */\nconst EYE_X = 0.27\nconst EYE_Y = -0.12\nconst EYE_RX = 0.13\nconst EYE_RY = 0.19\n\nexport interface AiOrbRendererOptions {\n /** css 像素尺寸(画布为正方形) */\n size: number\n status?: AiOrbStatus\n}\n\nexport interface AiOrbRenderer {\n /** 目标状态切换(参数平滑过渡,无跳变) */\n setStatus(status: AiOrbStatus): void\n /** 播放一次瞬时表情(happy / shock / frustrated),播完自动回到常态 */\n trigger(reaction: AiOrbReaction): void\n /** 指针位置(单位球空间,可超出球体范围),null 表示指针离开;用于视线跟随 */\n setPointer(pos: { x: number; y: number } | null): void\n /** 戳一下:Q 弹挤压回弹(约 0.95s) */\n poke(): void\n resize(size: number): void\n start(): void\n stop(): void\n /** 绘制一帧静态画面(prefers-reduced-motion 降级用;有未播完的表情时定格表情中段) */\n renderOnce(): void\n readonly running: boolean\n}\n\n/** 组合圆路径的点数(越大越平滑,36 在小尺寸下已足够) */\nconst SEGMENTS = 36\n\nfunction clamp(value: number, min: number, max: number): number {\n return Math.min(max, Math.max(min, value))\n}\n\nfunction lerp(from: number, to: number, k: number): number {\n return from + (to - from) * k\n}\n\nfunction easeInOut(k: number): number {\n return k * k * (3 - 2 * k)\n}\n\n/** 表情权重包络:快速淡入(前 12%)→ 保持 → 淡出(后 22%) */\nfunction reactionEnvelope(p: number): number {\n return easeInOut(clamp(Math.min(p / 0.12, (1 - p) / 0.22), 0, 1))\n}\n\n/**\n * 纯 canvas 2D 活体球渲染器(框架无关)。\n *\n * 造型:原地呼吸的扁椭圆纯色球(无弹跳、无打光),固定配色。\n * 灵动感的核心在眼部状态机:\n * - 白色大眼睛随机眨眼(约 18% 概率双眨),闭起为白色线条\n * - 视线游移 / 转头(面部整体平移 + 轻微倾斜,idle 随机、thinking 缓慢扫视)\n * - 瞬时表情({@link AiOrbReaction}):睁大眼、弯眼笑、紧闭眼 ><,配合点头 / 摇头 / 后仰\n *\n * 性能设计:\n * - 单位球空间(R=1)绘制,单条 36 段多边形路径,纯色填充,无渐变 / 滤镜 / shadowBlur\n * - rAF 循环由组件侧按可见性启停;dt 截断避免后台切回时的跳变\n */\nexport function createOrbRenderer(\n canvas: HTMLCanvasElement,\n options: AiOrbRendererOptions\n): AiOrbRenderer {\n const ctx = canvas.getContext('2d')\n\n let size = options.size\n let dpr = 1\n\n let target: OrbParams = { ...STATUS_PARAMS[options.status ?? 'idle'] }\n let current: OrbParams = { ...target }\n let statusLabel: AiOrbStatus = options.status ?? 'idle'\n\n let rafId = 0\n let isRunning = false\n /** 动画时间(秒),只随 rAF 累积,暂停恢复无跳变 */\n let t = 0\n let lastFrameAt = 0\n /** 上一帧距当前的间隔(秒),截断防止后台切回时暴冲 */\n let frameDt = 1 / 60\n\n /** 下一次眨眼时间点(秒,随机间隔) */\n let nextBlinkAt = 1.6 + Math.random() * 3\n /** 眨眼起始时间,-1 表示未在眨眼 */\n let blinkStart = -1\n\n /** 视线(单位球空间):current 缓动逼近 target */\n const gazeCur = { x: 0, y: 0 }\n let gazeTarget = { x: 0, y: 0 }\n /** idle 态下一次游移视线的时间点 */\n let nextGazeAt = 1.2 + Math.random() * 2\n\n /** 进行中的瞬时表情 */\n let reaction: { type: AiOrbReaction; startAt: number } | null = null\n\n /** 指针悬停位置(单位球空间);null 表示未悬停,此时按 status 驱动视线 */\n let pointerPos: { x: number; y: number } | null = null\n /** Q 弹起始时间,-1 表示未在回弹 */\n let pokeStart = -1\n\n /** 面部:白色大眼睛(圆眼 / 弯眼 / 紧闭线条)+ 嘴 */\n function drawFace(eyeMode: EyeMode, eyeOpen: number, eyeScale: number, mouth: MouthMode) {\n if (!ctx) return\n\n ctx.fillStyle = COLORS.face\n ctx.strokeStyle = COLORS.face\n ctx.lineCap = 'round'\n\n for (const side of [-1, 1]) {\n ctx.save()\n ctx.translate(side * EYE_X, EYE_Y)\n\n if (eyeMode === 'round') {\n // 圆眼:竖直方向缩放表达睁眼度;闭起时压成白色线条\n const open = clamp(eyeOpen, 0.07, 1)\n ctx.save()\n ctx.scale(1, open)\n ctx.beginPath()\n ctx.ellipse(0, 0, EYE_RX * eyeScale, EYE_RY * eyeScale, 0, 0, Math.PI * 2)\n ctx.fill()\n ctx.restore()\n } else if (eyeMode === 'happy') {\n // 开心弯眼:上半圆白色线条(∩)\n ctx.lineWidth = 0.06\n ctx.beginPath()\n ctx.arc(0, 0.05, 0.14, Math.PI, Math.PI * 2)\n ctx.stroke()\n } else {\n // 紧闭眼:>< 白色折线(顶点朝向鼻梁)\n ctx.lineWidth = 0.055\n ctx.lineJoin = 'round'\n ctx.beginPath()\n ctx.moveTo(0.085 * side, -0.08)\n ctx.lineTo(-0.065 * side, 0)\n ctx.lineTo(0.085 * side, 0.08)\n ctx.stroke()\n }\n\n ctx.restore()\n }\n\n drawMouth(mouth)\n }\n\n function drawMouth(mouth: MouthMode) {\n if (!ctx || mouth === 'none') return\n ctx.fillStyle = COLORS.face\n ctx.strokeStyle = COLORS.face\n\n switch (mouth) {\n case 'smile':\n ctx.lineWidth = 0.05\n ctx.beginPath()\n ctx.moveTo(-0.15, 0.18)\n ctx.quadraticCurveTo(0, 0.32, 0.15, 0.18)\n ctx.stroke()\n break\n case 'flat':\n // 思考:近乎平直的小嘴\n ctx.lineWidth = 0.045\n ctx.beginPath()\n ctx.moveTo(-0.09, 0.24)\n ctx.quadraticCurveTo(0, 0.255, 0.09, 0.24)\n ctx.stroke()\n break\n case 'talk': {\n // 说话:嘴随节奏开合\n const ry = 0.045 + 0.05 * Math.abs(Math.sin(6.5 * t))\n ctx.beginPath()\n ctx.ellipse(0, 0.2, 0.1, ry, 0, 0, Math.PI * 2)\n ctx.fill()\n break\n }\n case 'grin':\n // 大笑:张开的半圆嘴\n ctx.beginPath()\n ctx.ellipse(0, 0.15, 0.15, 0.115, 0, 0, Math.PI)\n ctx.closePath()\n ctx.fill()\n break\n case 'frown':\n ctx.lineWidth = 0.05\n ctx.beginPath()\n ctx.moveTo(-0.1, 0.28)\n ctx.quadraticCurveTo(0, 0.2, 0.1, 0.28)\n ctx.stroke()\n break\n case 'o':\n // 惊讶:小嘴微张\n ctx.beginPath()\n ctx.ellipse(0, 0.21, 0.06, 0.078, 0, 0, Math.PI * 2)\n ctx.fill()\n break\n }\n }\n\n function applySize() {\n dpr = Math.min(globalThis.devicePixelRatio || 1, 2)\n const px = Math.max(1, Math.round(size * dpr))\n canvas.width = px\n canvas.height = px\n }\n\n /** 球面半径扰动:三层不同角频率的正弦叠加,营造有机感 */\n function wobble(theta: number, params: OrbParams): number {\n const { wobbleAmp, wobbleSpeed } = params\n const w1 = wobbleSpeed\n return (\n wobbleAmp *\n (0.58 * Math.sin(2 * theta + w1 * t) +\n 0.3 * Math.sin(3 * theta - 1.6 * w1 * t + 1.7) +\n 0.24 * Math.sin(5 * theta + 0.7 * w1 * t + 4.2))\n )\n }\n\n function draw() {\n if (!ctx) return\n\n // 常态参数向目标平滑过渡(约 200ms 时间常数)\n const ease = 1 - Math.exp(-frameDt * 5)\n for (const key of Object.keys(target) as (keyof OrbParams)[]) {\n current[key] += (target[key] - current[key]) * ease\n }\n\n // 眨眼:随机触发,结束时有概率紧接着补一次形成双眨;表情期间不插播\n if (blinkStart < 0 && !reaction && t >= nextBlinkAt) blinkStart = t\n let blinkOpen = 1\n if (blinkStart >= 0) {\n const phase = (t - blinkStart) / 0.16\n if (phase >= 1) {\n blinkStart = -1\n nextBlinkAt = Math.random() < 0.18 ? t + 0.26 : t + 2.2 + Math.random() * 2.6\n } else {\n blinkOpen = 1 - Math.sin(Math.PI * phase) * 0.94\n }\n }\n\n // 瞬时表情进度(播完自动清除)\n let reactionDef: ReactionDef | null = null\n let reactionP = 0\n let reactionW = 0\n if (reaction) {\n const def = REACTIONS[reaction.type]\n reactionP = (t - reaction.startAt) / def.duration\n if (reactionP >= 1) {\n reaction = null\n } else {\n reactionDef = def\n reactionW = reactionEnvelope(reactionP)\n }\n }\n\n // 视线目标:表情 > 指针跟随 > thinking 扫视 > speaking 微动 > idle 随机游移(转头 / 转眼睛)\n if (reactionDef && reactionW > 0.45) {\n gazeTarget = reactionDef.gaze\n } else if (pointerPos) {\n // 悬停互动:看向指针所在方位\n gazeTarget = { x: clamp(pointerPos.x, -0.18, 0.18), y: clamp(pointerPos.y, -0.1, 0.12) }\n } else if (statusLabel === 'thinking') {\n // 思考:视线略上扬并缓慢左右扫视\n gazeTarget = { x: 0.13 * Math.sin(t * 0.7), y: -0.05 + 0.03 * Math.sin(t * 0.47) }\n } else if (statusLabel === 'speaking') {\n gazeTarget = { x: 0.03 * Math.sin(t * 1.2), y: 0.02 * Math.sin(t * 0.8) }\n } else if (t >= nextGazeAt) {\n const far = Math.random() < 0.3\n gazeTarget = {\n x: (Math.random() * 2 - 1) * (far ? 0.16 : 0.07),\n y: (Math.random() * 2 - 1) * 0.06 - 0.01\n }\n nextGazeAt = t + 1.4 + Math.random() * 2.2\n }\n const gazeEase = 1 - Math.exp(-frameDt * 6.5)\n gazeCur.x += (gazeTarget.x - gazeCur.x) * gazeEase\n gazeCur.y += (gazeTarget.y - gazeCur.y) * gazeEase\n\n // 眼部参数:常态 × 眨眼,再向表情目标混合\n let eyeOpen = current.eyeOpen * blinkOpen\n let eyeScale = 1\n let eyeMode: EyeMode = 'round'\n let mouth: MouthMode = STATUS_MOUTH[statusLabel]\n if (reactionDef && reaction) {\n // happy 前段先睁大双眼(round),随后才切换弯眼\n const widenPhase = reaction.type === 'happy' && reactionP < HAPPY_WIDEN_RATIO\n eyeScale = lerp(1, widenPhase ? 1.2 : reactionDef.eyeScale, reactionW)\n eyeOpen = lerp(eyeOpen, 1, reactionW)\n if (reactionW > 0.45) {\n eyeMode = widenPhase ? 'round' : reactionDef.eyeMode\n mouth = reactionDef.mouth\n }\n }\n\n // 表情肢体动作:点头 / 摇头 / 后仰;平时视线侧移带一点头部倾斜(转头感)\n let bodyDy = 0\n let bodyRot = gazeCur.x * 0.5\n let bodyScale = 1\n if (reactionDef && reaction && reactionW > 0.01) {\n if (reaction.type === 'happy') {\n bodyDy = Math.sin(Math.min(reactionP / 0.75, 1) * Math.PI) * 0.05\n } else if (reaction.type === 'frustrated') {\n bodyRot += Math.sin(reactionP * reactionDef.duration * 18) * 0.05 * (1 - reactionP)\n } else {\n bodyScale = 1 - 0.05 * Math.sin(Math.min(reactionP * 1.6, 1) * Math.PI)\n }\n }\n\n // 扁椭圆 + 呼吸(原地,无弹跳);Q 弹:挤压后阻尼振荡回弹(体积近似守恒)\n // press 包络让压下也走一个短渐入(约 80ms),避免按下瞬间球体跳变压扁\n // 低阻尼 + 约 1.6 周期 / 0.95s(≈1.7Hz):回弹带过冲和一次可见的二次晃动,Q 而不抖\n let jellyX = 1\n let jellyY = 1\n if (pokeStart >= 0) {\n const pp = (t - pokeStart) / 0.95\n if (pp >= 1) {\n pokeStart = -1\n } else {\n const press = 1 - Math.exp(-pp * 14)\n const jelly = press * Math.exp(-2.6 * pp) * Math.cos(Math.PI * 2 * 1.6 * pp)\n jellyY = 1 - 0.14 * jelly\n jellyX = 1 + 0.085 * jelly\n }\n }\n const radius = size * 0.36\n const breatheScale = 1 + current.breathe * Math.sin(Math.PI * 2 * current.breatheFreq * t)\n const scaleX = BODY_SCALE_X * breatheScale * bodyScale * jellyX\n const scaleY = BODY_SCALE_Y * breatheScale * bodyScale * jellyY\n const cx = size / 2\n const cy = size / 2 + bodyDy * radius\n\n ctx.setTransform(dpr, 0, 0, dpr, 0, 0)\n ctx.clearRect(0, 0, size, size)\n\n // 纯色球体:单条扰动路径填充,无渐变与打光\n ctx.save()\n ctx.translate(cx, cy)\n ctx.rotate(bodyRot)\n ctx.scale(radius * scaleX, radius * scaleY)\n\n ctx.beginPath()\n for (let i = 0; i <= SEGMENTS; i++) {\n const theta = (i / SEGMENTS) * Math.PI * 2\n const r = 1 + wobble(theta, current)\n const x = r * Math.cos(theta)\n const y = r * Math.sin(theta)\n if (i === 0) ctx.moveTo(x, y)\n else ctx.lineTo(x, y)\n }\n ctx.closePath()\n ctx.fillStyle = COLORS.body\n ctx.fill()\n\n // 面部随球体形变,并叠加视线平移 / 头部微倾\n ctx.translate(gazeCur.x, gazeCur.y)\n ctx.rotate(gazeCur.x * 0.35)\n drawFace(eyeMode, eyeOpen, eyeScale, mouth)\n\n ctx.restore()\n }\n\n function tick(now: number) {\n if (!isRunning) return\n frameDt = lastFrameAt === 0 ? 1 / 60 : Math.min((now - lastFrameAt) / 1000, 0.05)\n lastFrameAt = now\n t += frameDt\n draw()\n rafId = requestAnimationFrame(tick)\n }\n\n function renderOnce() {\n frameDt = 1 / 60\n // 静态帧优先级:进行中的表情定格在中段 > Q 弹定格在挤压段 > 常态安静帧\n if (reaction) {\n t = reaction.startAt + REACTIONS[reaction.type].duration * 0.45\n } else if (pokeStart >= 0) {\n t = pokeStart + 0.2\n } else {\n t = 0.35\n }\n gazeCur.x = 0\n gazeCur.y = 0\n gazeTarget = { x: 0, y: 0 }\n draw()\n }\n\n applySize()\n\n return {\n get running() {\n return isRunning\n },\n\n setStatus(status: AiOrbStatus) {\n statusLabel = status\n target = { ...STATUS_PARAMS[status] }\n },\n\n trigger(type: AiOrbReaction) {\n reaction = { type, startAt: t }\n // 表情接管眼部,中断进行中的眨眼\n blinkStart = -1\n if (!isRunning) renderOnce()\n },\n\n setPointer(pos: { x: number; y: number } | null) {\n pointerPos = pos\n },\n\n poke() {\n pokeStart = t\n if (!isRunning) renderOnce()\n },\n\n resize(nextSize: number) {\n if (nextSize === size) return\n size = nextSize\n applySize()\n if (!isRunning) draw()\n },\n\n start() {\n if (isRunning || !ctx) return\n isRunning = true\n lastFrameAt = 0\n rafId = requestAnimationFrame(tick)\n },\n\n stop() {\n isRunning = false\n if (rafId) cancelAnimationFrame(rafId)\n rafId = 0\n },\n\n renderOnce\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"orb-renderer.js","names":[],"sources":["../../../src/components/ai-orb/orb-renderer.ts"],"sourcesContent":["import type { AiOrbReaction, AiOrbStatus } from '../../types/ai-orb'\n\n/** 常态动画参数(数值均为无量纲,随尺寸缩放) */\ninterface OrbParams {\n /** 常态睁眼度(0-1,1 全开;thinking 微微眯起) */\n eyeOpen: number\n /** 呼吸缩振幅(相对球半径) */\n breathe: number\n /** 呼吸频率(Hz) */\n breatheFreq: number\n /** 表面扰动幅度(相对球半径) */\n wobbleAmp: number\n /** 表面扰动速度 */\n wobbleSpeed: number\n}\n\nconst STATUS_PARAMS: Record<AiOrbStatus, OrbParams> = {\n idle: { eyeOpen: 1, breathe: 0.02, breatheFreq: 0.28, wobbleAmp: 0.012, wobbleSpeed: 1 },\n thinking: { eyeOpen: 0.72, breathe: 0.012, breatheFreq: 0.2, wobbleAmp: 0.016, wobbleSpeed: 1.6 },\n speaking: { eyeOpen: 1, breathe: 0.026, breatheFreq: 0.45, wobbleAmp: 0.018, wobbleSpeed: 2.2 }\n}\n\n/** 眼睛形态:圆眼(可眨眼 / 睁大)/ 开心弯眼 / 紧闭 >< */\ntype EyeMode = 'round' | 'happy' | 'squint'\n/** 嘴部形态;none 表示无嘴(常态只有大眼睛,说话 / 表情时才出现嘴) */\ntype MouthMode = 'none' | 'smile' | 'flat' | 'talk' | 'grin' | 'frown' | 'o'\n\nconst STATUS_MOUTH: Record<AiOrbStatus, MouthMode> = {\n idle: 'none',\n thinking: 'none',\n speaking: 'talk'\n}\n\ninterface ReactionDef {\n /** 表情总时长(秒) */\n duration: number\n eyeMode: EyeMode\n /** 睁眼倍率(>1 为睁大) */\n eyeScale: number\n mouth: MouthMode\n /** 表情期间视线落点(单位球空间) */\n gaze: { x: number; y: number }\n /** 表情期间耳朵外倒角(弧度;小 = 竖起,大 = 耷拉) */\n earTilt: number\n}\n\nconst REACTIONS: Record<AiOrbReaction, ReactionDef> = {\n happy: {\n duration: 1.7,\n eyeMode: 'happy',\n eyeScale: 1,\n mouth: 'grin',\n gaze: { x: 0, y: -0.05 },\n earTilt: 0.14\n },\n shock: {\n duration: 1.2,\n eyeMode: 'round',\n eyeScale: 1.22,\n mouth: 'o',\n gaze: { x: 0, y: -0.02 },\n earTilt: 0.06\n },\n frustrated: {\n duration: 1.5,\n eyeMode: 'squint',\n eyeScale: 1,\n mouth: 'frown',\n gaze: { x: 0, y: 0.02 },\n earTilt: 0.62\n }\n}\n\n/** happy 前段先睁大双眼,随后才切换弯眼(占表情时长比例) */\nconst HAPPY_WIDEN_RATIO = 0.2\n\n/**\n * 固定配色:扁平纯色、无打光,饱满的天然蔚蓝球体 + 白色五官,\n * 不跟随宿主主题,保证任何场景下观感一致。\n */\nconst COLORS = { body: '#3d9bf0', face: '#ffffff' } as const\n\n/** 球体横竖比:扁椭圆(mochi 感,明显压扁),呼吸在此之上微调 */\nconst BODY_SCALE_X = 1.1\nconst BODY_SCALE_Y = 0.84\n\n/** 竖椭圆大眼睛(单位球空间):竖长才像眼睛,正圆容易读成鼻孔 */\nconst EYE_X = 0.27\nconst EYE_Y = -0.12\nconst EYE_RX = 0.13\nconst EYE_RY = 0.19\n\n/**\n * 耳朵(单位球空间):圆角尖三角(猫耳),根部埋在球体内(先于球体路径绘制,被球遮住),\n * 只露出耳尖;基础外倒角,表情时竖起(角小)或耷拉(角大)\n */\nconst EAR_X = 0.44\nconst EAR_Y = -0.8\n/** 根部半宽 */\nconst EAR_W = 0.17\n/** 耳高(根部到耳尖) */\nconst EAR_H = 0.46\nconst EAR_TILT = 0.38\n\nexport interface AiOrbRendererOptions {\n /** css 像素尺寸(画布为正方形) */\n size: number\n status?: AiOrbStatus\n}\n\nexport interface AiOrbRenderer {\n /** 目标状态切换(参数平滑过渡,无跳变) */\n setStatus(status: AiOrbStatus): void\n /** 播放一次瞬时表情(happy / shock / frustrated),播完自动回到常态 */\n trigger(reaction: AiOrbReaction): void\n /** 指针位置(单位球空间,可超出球体范围),null 表示指针离开;用于视线跟随 */\n setPointer(pos: { x: number; y: number } | null): void\n /** 戳一下:Q 弹挤压回弹(约 0.95s) */\n poke(): void\n resize(size: number): void\n start(): void\n stop(): void\n /** 绘制一帧静态画面(prefers-reduced-motion 降级用;有未播完的表情时定格表情中段) */\n renderOnce(): void\n readonly running: boolean\n}\n\n/** 组合圆路径的点数(越大越平滑,36 在小尺寸下已足够) */\nconst SEGMENTS = 36\n\nfunction clamp(value: number, min: number, max: number): number {\n return Math.min(max, Math.max(min, value))\n}\n\nfunction lerp(from: number, to: number, k: number): number {\n return from + (to - from) * k\n}\n\nfunction easeInOut(k: number): number {\n return k * k * (3 - 2 * k)\n}\n\n/** 表情权重包络:快速淡入(前 12%)→ 保持 → 淡出(后 22%) */\nfunction reactionEnvelope(p: number): number {\n return easeInOut(clamp(Math.min(p / 0.12, (1 - p) / 0.22), 0, 1))\n}\n\n/**\n * 纯 canvas 2D 活体球渲染器(框架无关)。\n *\n * 造型:原地呼吸的扁椭圆纯色球(无弹跳、无打光),固定配色。\n * 灵动感的核心在眼部状态机:\n * - 白色大眼睛随机眨眼(约 18% 概率双眨),闭起为白色线条\n * - 视线游移 / 转头(面部整体平移 + 轻微倾斜,idle 随机、thinking 缓慢扫视)\n * - thinking 时在球体右上角绘制与身体同色的「?」,轻晃\n * - 瞬时表情({@link AiOrbReaction}):睁大眼、弯眼笑、紧闭眼 ><,配合点头 / 摇头 / 后仰\n *\n * 性能设计:\n * - 单位球空间(R=1)绘制,单条 36 段多边形路径,纯色填充,无渐变 / 滤镜 / shadowBlur\n * - rAF 循环由组件侧按可见性启停;dt 截断避免后台切回时的跳变\n */\nexport function createOrbRenderer(\n canvas: HTMLCanvasElement,\n options: AiOrbRendererOptions\n): AiOrbRenderer {\n const ctx = canvas.getContext('2d')\n\n let size = options.size\n let dpr = 1\n\n let target: OrbParams = { ...STATUS_PARAMS[options.status ?? 'idle'] }\n let current: OrbParams = { ...target }\n let statusLabel: AiOrbStatus = options.status ?? 'idle'\n\n let rafId = 0\n let isRunning = false\n /** 动画时间(秒),只随 rAF 累积,暂停恢复无跳变 */\n let t = 0\n let lastFrameAt = 0\n /** 上一帧距当前的间隔(秒),截断防止后台切回时暴冲 */\n let frameDt = 1 / 60\n\n /** 下一次眨眼时间点(秒,随机间隔) */\n let nextBlinkAt = 1.6 + Math.random() * 3\n /** 眨眼起始时间,-1 表示未在眨眼 */\n let blinkStart = -1\n\n /** 视线(单位球空间):current 缓动逼近 target */\n const gazeCur = { x: 0, y: 0 }\n let gazeTarget = { x: 0, y: 0 }\n /** idle 态下一次游移视线的时间点 */\n let nextGazeAt = 1.2 + Math.random() * 2\n\n /** 进行中的瞬时表情 */\n let reaction: { type: AiOrbReaction; startAt: number } | null = null\n\n /** 指针悬停位置(单位球空间);null 表示未悬停,此时按 status 驱动视线 */\n let pointerPos: { x: number; y: number } | null = null\n /** Q 弹起始时间,-1 表示未在回弹 */\n let pokeStart = -1\n /** thinking 右上角「?」显隐(0-1),随状态平滑过渡 */\n let thinkingMark = statusLabel === 'thinking' ? 1 : 0\n\n /** 猫耳轮廓:两侧微鼓、耳尖带小圆角、根部微弧朝下埋进球体 */\n function earPath(w: number, h: number) {\n if (!ctx) return\n ctx.beginPath()\n ctx.moveTo(-w, 0)\n ctx.quadraticCurveTo(-w * 0.92, -h * 0.68, -0.028, -h * 0.92)\n // 耳尖小圆角,避免锐利折点\n ctx.quadraticCurveTo(0, -h, 0.028, -h * 0.92)\n ctx.quadraticCurveTo(w * 0.92, -h * 0.68, w, 0)\n ctx.quadraticCurveTo(0, 0.09, -w, 0)\n ctx.closePath()\n }\n\n /** 耳朵:身体色尖耳 + 白色内耳,平时随时间轻微错相摆动 */\n function drawEars(tilt: number) {\n if (!ctx) return\n\n for (const side of [-1, 1]) {\n const swing = 0.05 * Math.sin(t * 1.9 + side * 1.2)\n ctx.save()\n ctx.translate(side * EAR_X, EAR_Y)\n ctx.rotate(side * (tilt + swing))\n\n ctx.fillStyle = COLORS.body\n earPath(EAR_W, EAR_H)\n ctx.fill()\n\n // 内耳为相似缩小的尖三角,偏向耳尖,与白色五官呼应\n ctx.fillStyle = COLORS.face\n const iw = EAR_W * 0.48\n ctx.beginPath()\n ctx.moveTo(-iw, -EAR_H * 0.1)\n ctx.quadraticCurveTo(-iw * 0.9, -EAR_H * 0.44, 0, -EAR_H * 0.6)\n ctx.quadraticCurveTo(iw * 0.9, -EAR_H * 0.44, iw, -EAR_H * 0.1)\n ctx.quadraticCurveTo(0, -EAR_H * 0.02, -iw, -EAR_H * 0.1)\n ctx.closePath()\n ctx.fill()\n\n ctx.restore()\n }\n }\n\n /** 面部:白色大眼睛(圆眼 / 弯眼 / 紧闭线条)+ 嘴 */\n function drawFace(eyeMode: EyeMode, eyeOpen: number, eyeScale: number, mouth: MouthMode) {\n if (!ctx) return\n\n ctx.fillStyle = COLORS.face\n ctx.strokeStyle = COLORS.face\n ctx.lineCap = 'round'\n\n for (const side of [-1, 1]) {\n ctx.save()\n ctx.translate(side * EYE_X, EYE_Y)\n\n if (eyeMode === 'round') {\n // 圆眼:竖直方向缩放表达睁眼度;闭起时压成白色线条\n const open = clamp(eyeOpen, 0.07, 1)\n ctx.save()\n ctx.scale(1, open)\n ctx.beginPath()\n ctx.ellipse(0, 0, EYE_RX * eyeScale, EYE_RY * eyeScale, 0, 0, Math.PI * 2)\n ctx.fill()\n ctx.restore()\n } else if (eyeMode === 'happy') {\n // 开心弯眼:上半圆白色线条(∩)\n ctx.lineWidth = 0.06\n ctx.beginPath()\n ctx.arc(0, 0.05, 0.14, Math.PI, Math.PI * 2)\n ctx.stroke()\n } else {\n // 紧闭眼:>< 白色折线(顶点朝向鼻梁)\n ctx.lineWidth = 0.055\n ctx.lineJoin = 'round'\n ctx.beginPath()\n ctx.moveTo(0.085 * side, -0.08)\n ctx.lineTo(-0.065 * side, 0)\n ctx.lineTo(0.085 * side, 0.08)\n ctx.stroke()\n }\n\n ctx.restore()\n }\n\n drawMouth(mouth)\n }\n\n /**\n * thinking 吉祥物右上角的「?」:球体同色,轻晃更像在想问题。\n * 画在单位球变换之外,避免被扁椭圆压扁。\n */\n function drawThinkingMark(weight: number) {\n if (!ctx || weight <= 0.01) return\n const fontSize = size * 0.3\n // 顶在画布右缘:主体在该高度大约只到 0.70,右侧空位给问号\n const x = size * 0.93\n const y = size * 0.24 + size * 0.02 * Math.sin(t * 3.6)\n ctx.save()\n ctx.globalAlpha = weight\n ctx.translate(x, y)\n ctx.rotate(-0.16 + 0.1 * Math.sin(t * 2.7))\n ctx.fillStyle = COLORS.body\n ctx.font = `700 ${fontSize}px ui-rounded, system-ui, sans-serif`\n ctx.textAlign = 'center'\n ctx.textBaseline = 'middle'\n ctx.fillText('?', 0, 0)\n ctx.restore()\n }\n\n function drawMouth(mouth: MouthMode) {\n if (!ctx || mouth === 'none') return\n ctx.fillStyle = COLORS.face\n ctx.strokeStyle = COLORS.face\n\n switch (mouth) {\n case 'smile':\n ctx.lineWidth = 0.05\n ctx.beginPath()\n ctx.moveTo(-0.15, 0.18)\n ctx.quadraticCurveTo(0, 0.32, 0.15, 0.18)\n ctx.stroke()\n break\n case 'flat':\n // 思考:近乎平直的小嘴\n ctx.lineWidth = 0.045\n ctx.beginPath()\n ctx.moveTo(-0.09, 0.24)\n ctx.quadraticCurveTo(0, 0.255, 0.09, 0.24)\n ctx.stroke()\n break\n case 'talk': {\n // 说话:嘴随节奏开合\n const ry = 0.045 + 0.05 * Math.abs(Math.sin(6.5 * t))\n ctx.beginPath()\n ctx.ellipse(0, 0.2, 0.1, ry, 0, 0, Math.PI * 2)\n ctx.fill()\n break\n }\n case 'grin':\n // 大笑:张开的半圆嘴\n ctx.beginPath()\n ctx.ellipse(0, 0.15, 0.15, 0.115, 0, 0, Math.PI)\n ctx.closePath()\n ctx.fill()\n break\n case 'frown':\n ctx.lineWidth = 0.05\n ctx.beginPath()\n ctx.moveTo(-0.1, 0.28)\n ctx.quadraticCurveTo(0, 0.2, 0.1, 0.28)\n ctx.stroke()\n break\n case 'o':\n // 惊讶:小嘴微张\n ctx.beginPath()\n ctx.ellipse(0, 0.21, 0.06, 0.078, 0, 0, Math.PI * 2)\n ctx.fill()\n break\n }\n }\n\n function applySize() {\n dpr = Math.min(globalThis.devicePixelRatio || 1, 2)\n const px = Math.max(1, Math.round(size * dpr))\n canvas.width = px\n canvas.height = px\n }\n\n /** 球面半径扰动:三层不同角频率的正弦叠加,营造有机感 */\n function wobble(theta: number, params: OrbParams): number {\n const { wobbleAmp, wobbleSpeed } = params\n const w1 = wobbleSpeed\n return (\n wobbleAmp *\n (0.58 * Math.sin(2 * theta + w1 * t) +\n 0.3 * Math.sin(3 * theta - 1.6 * w1 * t + 1.7) +\n 0.24 * Math.sin(5 * theta + 0.7 * w1 * t + 4.2))\n )\n }\n\n function draw() {\n if (!ctx) return\n\n // 常态参数向目标平滑过渡(约 200ms 时间常数)\n const ease = 1 - Math.exp(-frameDt * 5)\n for (const key of Object.keys(target) as (keyof OrbParams)[]) {\n current[key] += (target[key] - current[key]) * ease\n }\n\n // 眨眼:随机触发,结束时有概率紧接着补一次形成双眨;表情期间不插播\n if (blinkStart < 0 && !reaction && t >= nextBlinkAt) blinkStart = t\n let blinkOpen = 1\n if (blinkStart >= 0) {\n const phase = (t - blinkStart) / 0.16\n if (phase >= 1) {\n blinkStart = -1\n nextBlinkAt = Math.random() < 0.18 ? t + 0.26 : t + 2.2 + Math.random() * 2.6\n } else {\n blinkOpen = 1 - Math.sin(Math.PI * phase) * 0.94\n }\n }\n\n // 瞬时表情进度(播完自动清除)\n let reactionDef: ReactionDef | null = null\n let reactionP = 0\n let reactionW = 0\n if (reaction) {\n const def = REACTIONS[reaction.type]\n reactionP = (t - reaction.startAt) / def.duration\n if (reactionP >= 1) {\n reaction = null\n } else {\n reactionDef = def\n reactionW = reactionEnvelope(reactionP)\n }\n }\n\n // 视线目标:表情 > 指针跟随 > thinking 扫视 > speaking 微动 > idle 随机游移(转头 / 转眼睛)\n if (reactionDef && reactionW > 0.45) {\n gazeTarget = reactionDef.gaze\n } else if (pointerPos) {\n // 悬停互动:看向指针所在方位\n gazeTarget = { x: clamp(pointerPos.x, -0.18, 0.18), y: clamp(pointerPos.y, -0.1, 0.12) }\n } else if (statusLabel === 'thinking') {\n // 思考:视线略上扬并缓慢左右扫视\n gazeTarget = { x: 0.13 * Math.sin(t * 0.7), y: -0.05 + 0.03 * Math.sin(t * 0.47) }\n } else if (statusLabel === 'speaking') {\n gazeTarget = { x: 0.03 * Math.sin(t * 1.2), y: 0.02 * Math.sin(t * 0.8) }\n } else if (t >= nextGazeAt) {\n const far = Math.random() < 0.3\n gazeTarget = {\n x: (Math.random() * 2 - 1) * (far ? 0.16 : 0.07),\n y: (Math.random() * 2 - 1) * 0.06 - 0.01\n }\n nextGazeAt = t + 1.4 + Math.random() * 2.2\n }\n const gazeEase = 1 - Math.exp(-frameDt * 6.5)\n gazeCur.x += (gazeTarget.x - gazeCur.x) * gazeEase\n gazeCur.y += (gazeTarget.y - gazeCur.y) * gazeEase\n\n const markTarget = statusLabel === 'thinking' ? 1 : 0\n if (isRunning) thinkingMark += (markTarget - thinkingMark) * ease\n else thinkingMark = markTarget\n\n // 眼部参数:常态 × 眨眼,再向表情目标混合\n let eyeOpen = current.eyeOpen * blinkOpen\n let eyeScale = 1\n let eyeMode: EyeMode = 'round'\n let mouth: MouthMode = STATUS_MOUTH[statusLabel]\n if (reactionDef && reaction) {\n // happy 前段先睁大双眼(round),随后才切换弯眼\n const widenPhase = reaction.type === 'happy' && reactionP < HAPPY_WIDEN_RATIO\n eyeScale = lerp(1, widenPhase ? 1.2 : reactionDef.eyeScale, reactionW)\n eyeOpen = lerp(eyeOpen, 1, reactionW)\n if (reactionW > 0.45) {\n eyeMode = widenPhase ? 'round' : reactionDef.eyeMode\n mouth = reactionDef.mouth\n }\n }\n\n // 表情肢体动作:点头 / 摇头 / 后仰;平时视线侧移带一点头部倾斜(转头感)\n let bodyDy = 0\n let bodyRot = gazeCur.x * 0.5\n let bodyScale = 1\n // 耳朵外倒角:表情时向目标角度混合(开心 / 惊讶竖起,沮丧耷拉)\n let earTilt = EAR_TILT\n if (reactionDef && reaction && reactionW > 0.01) {\n earTilt = lerp(EAR_TILT, reactionDef.earTilt, reactionW)\n if (reaction.type === 'happy') {\n bodyDy = Math.sin(Math.min(reactionP / 0.75, 1) * Math.PI) * 0.05\n } else if (reaction.type === 'frustrated') {\n bodyRot += Math.sin(reactionP * reactionDef.duration * 18) * 0.05 * (1 - reactionP)\n } else {\n bodyScale = 1 - 0.05 * Math.sin(Math.min(reactionP * 1.6, 1) * Math.PI)\n }\n }\n\n // 扁椭圆 + 呼吸(原地,无弹跳);Q 弹:挤压后阻尼振荡回弹(体积近似守恒)\n // press 包络让压下也走一个短渐入(约 80ms),避免按下瞬间球体跳变压扁\n // 低阻尼 + 约 1.6 周期 / 0.95s(≈1.7Hz):回弹带过冲和一次可见的二次晃动,Q 而不抖\n let jellyX = 1\n let jellyY = 1\n if (pokeStart >= 0) {\n const pp = (t - pokeStart) / 0.95\n if (pp >= 1) {\n pokeStart = -1\n } else {\n const press = 1 - Math.exp(-pp * 14)\n const jelly = press * Math.exp(-2.6 * pp) * Math.cos(Math.PI * 2 * 1.6 * pp)\n jellyY = 1 - 0.14 * jelly\n jellyX = 1 + 0.085 * jelly\n }\n }\n const radius = size * 0.36\n const breatheScale = 1 + current.breathe * Math.sin(Math.PI * 2 * current.breatheFreq * t)\n const scaleX = BODY_SCALE_X * breatheScale * bodyScale * jellyX\n const scaleY = BODY_SCALE_Y * breatheScale * bodyScale * jellyY\n const cx = size / 2\n const cy = size / 2 + bodyDy * radius\n\n ctx.setTransform(dpr, 0, 0, dpr, 0, 0)\n ctx.clearRect(0, 0, size, size)\n\n // 纯色球体:单条扰动路径填充,无渐变与打光\n ctx.save()\n ctx.translate(cx, cy)\n ctx.rotate(bodyRot)\n ctx.scale(radius * scaleX, radius * scaleY)\n\n // 耳朵先于球体绘制:根部被球体遮住,像从头顶长出来,并随球体一起形变\n drawEars(earTilt)\n\n ctx.beginPath()\n for (let i = 0; i <= SEGMENTS; i++) {\n const theta = (i / SEGMENTS) * Math.PI * 2\n const r = 1 + wobble(theta, current)\n const x = r * Math.cos(theta)\n const y = r * Math.sin(theta)\n if (i === 0) ctx.moveTo(x, y)\n else ctx.lineTo(x, y)\n }\n ctx.closePath()\n ctx.fillStyle = COLORS.body\n ctx.fill()\n\n // 面部随球体形变,并叠加视线平移 / 头部微倾\n ctx.translate(gazeCur.x, gazeCur.y)\n ctx.rotate(gazeCur.x * 0.35)\n drawFace(eyeMode, eyeOpen, eyeScale, mouth)\n\n ctx.restore()\n\n drawThinkingMark(thinkingMark)\n }\n\n function tick(now: number) {\n if (!isRunning) return\n frameDt = lastFrameAt === 0 ? 1 / 60 : Math.min((now - lastFrameAt) / 1000, 0.05)\n lastFrameAt = now\n t += frameDt\n draw()\n rafId = requestAnimationFrame(tick)\n }\n\n function renderOnce() {\n frameDt = 1 / 60\n // 静态帧优先级:进行中的表情定格在中段 > Q 弹定格在挤压段 > 常态安静帧\n if (reaction) {\n t = reaction.startAt + REACTIONS[reaction.type].duration * 0.45\n } else if (pokeStart >= 0) {\n t = pokeStart + 0.2\n } else {\n t = 0.35\n }\n gazeCur.x = 0\n gazeCur.y = 0\n gazeTarget = { x: 0, y: 0 }\n draw()\n }\n\n applySize()\n\n return {\n get running() {\n return isRunning\n },\n\n setStatus(status: AiOrbStatus) {\n statusLabel = status\n target = { ...STATUS_PARAMS[status] }\n },\n\n trigger(type: AiOrbReaction) {\n reaction = { type, startAt: t }\n // 表情接管眼部,中断进行中的眨眼\n blinkStart = -1\n if (!isRunning) renderOnce()\n },\n\n setPointer(pos: { x: number; y: number } | null) {\n pointerPos = pos\n },\n\n poke() {\n pokeStart = t\n if (!isRunning) renderOnce()\n },\n\n resize(nextSize: number) {\n if (nextSize === size) return\n size = nextSize\n applySize()\n if (!isRunning) draw()\n },\n\n start() {\n if (isRunning || !ctx) return\n isRunning = true\n lastFrameAt = 0\n rafId = requestAnimationFrame(tick)\n },\n\n stop() {\n isRunning = false\n if (rafId) cancelAnimationFrame(rafId)\n rafId = 0\n },\n\n renderOnce\n }\n}\n"],"mappings":"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"}
@@ -26,7 +26,7 @@ interface AiChatProps {
26
26
  model?: string;
27
27
  /** 当前推理等级,支持 v-model:reasoning-level */
28
28
  reasoningLevel?: string;
29
- /** 空状态欢迎项(快捷提问,点击即发送);字符串按单项处理 */
29
+ /** 空闲时输入框上方的快捷提问(点击即发送);字符串按单项处理 */
30
30
  welcome?: string | string[];
31
31
  /** 输入框占位文本 */
32
32
  placeholder?: string;
package/package.json CHANGED
@@ -1,6 +1,15 @@
1
1
  {
2
2
  "name": "@veltra/ai",
3
- "version": "2.1.1",
3
+ "version": "2.1.3",
4
+ "homepage": "https://github.com/cabinet-fe/ultra-ui#readme",
5
+ "bugs": {
6
+ "url": "https://github.com/cabinet-fe/ultra-ui/issues"
7
+ },
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://github.com/cabinet-fe/ultra-ui",
11
+ "directory": "packages/ai"
12
+ },
4
13
  "files": [
5
14
  "dist",
6
15
  "src"
@@ -27,14 +36,14 @@
27
36
  }
28
37
  },
29
38
  "dependencies": {
30
- "markstream-vue": "^1.0.9"
39
+ "markstream-vue": "^2.0.0"
31
40
  },
32
41
  "peerDependencies": {
33
- "@veltra/compositions": "^1.6.0",
34
- "@veltra/desktop": "^1.6.0",
42
+ "@veltra/compositions": "^1.6.4",
43
+ "@veltra/desktop": "^1.6.4",
35
44
  "@veltra/icons": "^1.5.0",
36
- "@veltra/styles": "^1.6.0",
37
- "@veltra/utils": "^1.6.0",
45
+ "@veltra/styles": "^1.6.4",
46
+ "@veltra/utils": "^1.6.4",
38
47
  "vue": ">=3.5.41"
39
48
  }
40
49
  }
@@ -60,13 +60,23 @@ function mountAiChat(options: {
60
60
  }
61
61
  }
62
62
 
63
+ /** 最终答案输出后过程消息收进「已完成」折叠块:点击展开并等待过程 DOM 挂载 */
64
+ async function expandProcess(host: HTMLElement) {
65
+ await vi.waitFor(() => {
66
+ expect(host.querySelector('.u-ai-chat__process-header')).toBeTruthy()
67
+ })
68
+ host.querySelector<HTMLElement>('.u-ai-chat__process-header')!.click()
69
+ await nextTick()
70
+ }
71
+
63
72
  describe('UAiChat', () => {
64
73
  it('空状态显示欢迎语', () => {
65
74
  const { host, unmount } = mountAiChat({ transport: () => {}, welcome: '有什么可以帮你?' })
66
75
 
67
76
  expect(host.querySelector('.u-ai-chat__welcome')?.textContent).toContain('有什么可以帮你?')
68
77
  const orb = host.querySelector('.u-ai-chat__welcome canvas.u-ai-orb') as HTMLElement | null
69
- expect(orb?.style.width).toBe('64px')
78
+ expect(orb?.style.width).toBe('48px')
79
+ expect(host.querySelector('.u-ai-chat__list .u-ai-chat__welcome')).toBeFalsy()
70
80
  unmount()
71
81
  })
72
82
 
@@ -136,18 +146,17 @@ describe('UAiChat', () => {
136
146
 
137
147
  chat.value?.send('1+1 等于几')
138
148
 
139
- await vi.waitFor(() => {
140
- expect(host.querySelector('.u-ai-chat__tool-call-name')?.textContent).toBe('calculate')
141
- })
149
+ // 最终答案输出后,工具卡片收进「已完成」过程块,先展开
150
+ await expandProcess(host)
142
151
 
143
- // 执行完成后自动折叠,点击头部展开
144
- await vi.waitFor(() => {
145
- expect(host.querySelector('.u-ai-chat__tool-call.is-success')).toBeTruthy()
146
- })
152
+ expect(host.querySelector('.u-ai-chat__tool-call-name')?.textContent).toBe('calculate')
153
+ expect(host.querySelector('.u-ai-chat__tool-call.is-success')).toBeTruthy()
147
154
 
155
+ // 执行完成后自动折叠,点击头部展开
148
156
  const header = host.querySelector<HTMLElement>('.u-ai-chat__tool-call .u-collapse__header')!
149
157
  header.dispatchEvent(new MouseEvent('click', { bubbles: true }))
150
158
  await nextTick()
159
+ await nextTick()
151
160
 
152
161
  const body = host.querySelector('.u-ai-chat__tool-call .u-collapse__content-wrapper')
153
162
  expect(body?.textContent).toContain('"expression": "1+1"')
@@ -210,10 +219,11 @@ describe('UAiChat', () => {
210
219
 
211
220
  chat.value?.send('搜一下 vue')
212
221
 
213
- await vi.waitFor(() => {
214
- expect(host.querySelector('.u-ai-chat__tool-call-name')?.textContent).toBe('搜索')
215
- expect(host.querySelector('.custom-tool-icon')).toBeTruthy()
216
- })
222
+ // 最终答案输出后工具卡片收进「已完成」过程块,展开后断言
223
+ await expandProcess(host)
224
+
225
+ expect(host.querySelector('.u-ai-chat__tool-call-name')?.textContent).toBe('搜索')
226
+ expect(host.querySelector('.custom-tool-icon')).toBeTruthy()
217
227
  unmount()
218
228
  })
219
229
 
@@ -254,10 +264,8 @@ describe('UAiChat', () => {
254
264
 
255
265
  chat.value?.send('北京天气')
256
266
 
257
- // running 时 render 即渲染(render 工具默认不折叠)
258
- await vi.waitFor(() => {
259
- expect(host.querySelector('.weather-view')).toBeTruthy()
260
- })
267
+ // 最终答案输出后工具卡片收进「已完成」过程块,展开后 render 保持展开态
268
+ await expandProcess(host)
261
269
 
262
270
  // 完成后保持展开,默认参数/结果区被替换
263
271
  await vi.waitFor(() => {
@@ -291,6 +299,8 @@ describe('UAiChat', () => {
291
299
 
292
300
  chat.value?.send('1+1')
293
301
 
302
+ await expandProcess(host)
303
+
294
304
  await vi.waitFor(() => {
295
305
  expect(host.querySelector('.u-ai-chat__tool-call.is-success.is-active')).toBeTruthy()
296
306
  })
@@ -306,10 +316,9 @@ describe('UAiChat', () => {
306
316
 
307
317
  chat.value?.send('北京天气')
308
318
 
309
- await vi.waitFor(() => {
310
- expect(host.querySelector('.u-ai-chat__tool-call.is-success')).toBeTruthy()
311
- })
312
- await nextTick()
319
+ await expandProcess(host)
320
+
321
+ expect(host.querySelector('.u-ai-chat__tool-call.is-success')).toBeTruthy()
313
322
  expect(host.querySelector('.u-ai-chat__tool-call.is-active')).toBeFalsy()
314
323
  const body = host.querySelector('.u-ai-chat__tool-call .u-collapse__content-wrapper')
315
324
  expect(body?.getAttribute('aria-hidden')).toBe('true')
@@ -333,10 +342,8 @@ describe('UAiChat', () => {
333
342
 
334
343
  chat.value?.send('1+1 等于几')
335
344
 
336
- await vi.waitFor(() => {
337
- expect(host.querySelector('.u-ai-chat__tool-call.is-success')).toBeTruthy()
338
- })
339
- await nextTick()
345
+ // 过程块展开后,工具卡片重挂载为完成态(自动折叠)
346
+ await expandProcess(host)
340
347
 
341
348
  // 完成后自动折叠:内容 DOM 已卸载(减少内存与渲染成本),包装容器保留
342
349
  expect(host.querySelector('.u-ai-chat__tool-call.is-active')).toBeFalsy()
@@ -380,6 +387,63 @@ describe('UAiChat', () => {
380
387
  unmount()
381
388
  })
382
389
 
390
+ it('最终答案输出后过程消息收进「已完成」折叠块,可展开钻取', async () => {
391
+ let round = 0
392
+ const transport: ChatTransport = (_req, handlers) => {
393
+ round++
394
+ if (round === 1) {
395
+ handlers.onReasoningDelta?.('先分析一下')
396
+ handlers.onToolCall?.({ id: 'c1', name: 'calculate', arguments: '{"expression":"1+1"}' })
397
+ } else {
398
+ handlers.onTextDelta('答案是 2')
399
+ }
400
+ }
401
+ const tools: ChatTool[] = [
402
+ { name: 'calculate', description: '计算', parameters: {}, execute: () => ({ value: 2 }) }
403
+ ]
404
+ const { host, chat, unmount } = mountAiChat({ transport, tools })
405
+
406
+ chat.value?.send('1+1 等于几')
407
+
408
+ // 最终答案输出后:出现「已完成」折叠头,过程 DOM(思考块/工具卡片)卸载,答案仍在块外
409
+ await vi.waitFor(() => {
410
+ expect(host.querySelector('.u-ai-chat__process-title')?.textContent).toBe('已完成')
411
+ })
412
+ expect(host.querySelector('.u-ai-chat__process-content')).toBeFalsy()
413
+ expect(host.querySelector('.u-ai-chat__tool-call')).toBeFalsy()
414
+ expect(host.querySelector('.u-ai-chat__reasoning')).toBeFalsy()
415
+ expect(host.querySelector('.md-stub')?.textContent).toContain('答案是 2')
416
+
417
+ // 点击展开:过程消息可见,思考块默认折叠(可逐层钻取)
418
+ host.querySelector<HTMLElement>('.u-ai-chat__process-header')!.click()
419
+ await nextTick()
420
+ expect(host.querySelector('.u-ai-chat__reasoning-title')?.textContent).toContain('思考过程')
421
+ expect(host.querySelector('.u-ai-chat__reasoning-content')).toBeFalsy()
422
+ expect(host.querySelector('.u-ai-chat__tool-call-name')?.textContent).toBe('calculate')
423
+
424
+ // 再次收起:过程 DOM 重新卸载
425
+ host.querySelector<HTMLElement>('.u-ai-chat__process-header')!.click()
426
+ await nextTick()
427
+ expect(host.querySelector('.u-ai-chat__process-content')).toBeFalsy()
428
+ expect(host.querySelector('.u-ai-chat__tool-call')).toBeFalsy()
429
+ unmount()
430
+ })
431
+
432
+ it('单轮无工具对话不出现「已完成」过程块', async () => {
433
+ const transport: ChatTransport = (_req, handlers) => {
434
+ handlers.onReasoningDelta?.('想一下')
435
+ handlers.onTextDelta('你好')
436
+ }
437
+ const { host, chat, unmount } = mountAiChat({ transport })
438
+
439
+ chat.value?.send('hi')
440
+ await vi.waitFor(() => {
441
+ expect(host.querySelector('.md-stub')?.textContent).toContain('你好')
442
+ })
443
+ expect(host.querySelector('.u-ai-chat__process')).toBeFalsy()
444
+ unmount()
445
+ })
446
+
383
447
  it('流式期间用户上滚取消吸底,展示「最新消息」入口且不被后续输出重新吸附', async () => {
384
448
  const transport: ChatTransport = async (_req, handlers) => {
385
449
  handlers.onTextDelta('第一段')
@@ -418,9 +482,9 @@ describe('UAiChat', () => {
418
482
 
419
483
  chat.value?.send('北京天气')
420
484
 
421
- await vi.waitFor(() => {
422
- expect(host.querySelector('.u-ai-chat__tool-call.is-success')).toBeTruthy()
423
- })
485
+ await expandProcess(host)
486
+
487
+ expect(host.querySelector('.u-ai-chat__tool-call.is-success')).toBeTruthy()
424
488
  expect(host.querySelector('.weather-view')).toBeTruthy()
425
489
  expect(host.querySelector('.slot-view')).toBeFalsy()
426
490
  unmount()
@@ -473,10 +537,11 @@ describe('UAiChat', () => {
473
537
  })
474
538
  expect(host.querySelector('.u-ai-chat__panel-title')?.textContent).toBe('后台页面')
475
539
 
540
+ // 最终答案输出后工具卡片收进「已完成」过程块,展开后断言
541
+ await expandProcess(host)
542
+
476
543
  // 卡片 body 不渲染 render 组件,仅提供「查看面板」入口
477
- await vi.waitFor(() => {
478
- expect(host.querySelector('.u-ai-chat__tool-call.is-success')).toBeTruthy()
479
- })
544
+ expect(host.querySelector('.u-ai-chat__tool-call.is-success')).toBeTruthy()
480
545
  expect(host.querySelector('.u-ai-chat__tool-call .page-view')).toBeFalsy()
481
546
  expect(host.querySelector('.u-ai-chat__tool-call-panel-entry')).toBeTruthy()
482
547
  unmount()
@@ -495,6 +560,9 @@ describe('UAiChat', () => {
495
560
  await nextTick()
496
561
  expect(host.querySelector('.u-ai-chat__panel')).toBeFalsy()
497
562
 
563
+ // 卡片已收进「已完成」过程块,展开后点击入口
564
+ await expandProcess(host)
565
+
498
566
  const entryBtn = [
499
567
  ...host.querySelectorAll<HTMLElement>('.u-ai-chat__tool-call-panel-entry button')
500
568
  ].find((btn) => btn.textContent?.includes('查看面板'))!
@@ -519,7 +587,9 @@ describe('UAiChat', () => {
519
587
  expect(host.querySelector('.u-ai-chat__panel .page-view')?.textContent).toContain('list')
520
588
  })
521
589
 
522
- // 点击第一张卡片的「查看面板」切回 form
590
+ // 卡片已收进「已完成」过程块,展开后点击第一张卡片的「查看面板」切回 form
591
+ await expandProcess(host)
592
+
523
593
  const firstCard = host.querySelectorAll<HTMLElement>('.u-ai-chat__tool-call')[0]!
524
594
  const entryBtn = [
525
595
  ...firstCard.querySelectorAll<HTMLElement>('.u-ai-chat__tool-call-panel-entry button')
@@ -604,7 +674,9 @@ describe('UAiChat', () => {
604
674
  const layout = host.querySelector<HTMLElement>('.u-ai-chat')!
605
675
  expect(layout.style.gridTemplateColumns).toBe('1fr 640px')
606
676
 
607
- // 点击 openPage 卡片的「查看面板」→ 切换并应用 480px
677
+ // 卡片已收进「已完成」过程块,展开后点击 openPage 卡片的「查看面板」→ 切换并应用 480px
678
+ await expandProcess(host)
679
+
608
680
  const firstCard = host.querySelectorAll<HTMLElement>('.u-ai-chat__tool-call')[0]!
609
681
  const entryBtn = [
610
682
  ...firstCard.querySelectorAll<HTMLElement>('.u-ai-chat__tool-call-panel-entry button')
@@ -902,11 +974,12 @@ describe('UAiChat', () => {
902
974
  expect(host.querySelector('.u-ai-chat__welcome-item')?.textContent).toBe('第二条建议')
903
975
  })
904
976
 
905
- // 点击文案 → 以当前展示的文案发送,欢迎区消失
977
+ // 点击文案 → 以当前展示的文案发送;空闲欢迎立即缩小离开,列表末尾出现工作球
906
978
  host.querySelector<HTMLElement>('.u-ai-chat__welcome-item')!.click()
907
979
  await nextTick()
908
980
 
909
- expect(host.querySelector('.u-ai-chat__welcome')).toBeFalsy()
981
+ expect(host.querySelector('.u-ai-chat__welcome')?.classList.contains('is-leaving')).toBe(true)
982
+ expect(host.querySelector('.u-ai-chat__list .u-ai-chat__working')).toBeTruthy()
910
983
  const userBubble = host.querySelector('.u-ai-chat__message--user .u-ai-chat__message-bubble')
911
984
  expect(userBubble?.textContent).toContain('第二条建议')
912
985
  unmount()
@@ -945,11 +1018,14 @@ describe('UAiChat', () => {
945
1018
  const { host, chat, unmount } = mountAiChat({ transport })
946
1019
 
947
1020
  expect(host.querySelector('.u-ai-chat__working')).toBeFalsy()
1021
+ expect(host.querySelector('.u-ai-chat__welcome')).toBeTruthy()
1022
+ expect(host.querySelector('.u-ai-chat__list .u-ai-chat__welcome')).toBeFalsy()
948
1023
 
949
1024
  chat.value?.send('hi')
950
1025
  await nextTick()
951
1026
 
952
- const working = host.querySelector('.u-ai-chat__working')
1027
+ expect(host.querySelector('.u-ai-chat__welcome')?.classList.contains('is-leaving')).toBe(true)
1028
+ const working = host.querySelector('.u-ai-chat__list .u-ai-chat__working')
953
1029
  expect(working).toBeTruthy()
954
1030
  expect(working?.textContent).toContain('工作中')
955
1031
  const orb = working?.querySelector('canvas.u-ai-orb') as HTMLElement | null
@@ -969,7 +1045,16 @@ describe('UAiChat', () => {
969
1045
  expect(host.querySelector('.u-ai-chat__working')).toBeTruthy()
970
1046
  await vi.advanceTimersByTimeAsync(2)
971
1047
  await nextTick()
1048
+ // 停留结束 → 进入缩放退出过渡(is-leaving),元素仍在
1049
+ const leaving = host.querySelector('.u-ai-chat__working')
1050
+ expect(leaving).toBeTruthy()
1051
+ expect(leaving?.classList.contains('is-leaving')).toBe(true)
1052
+ // 过渡播完才卸载
1053
+ await vi.advanceTimersByTimeAsync(380)
1054
+ await nextTick()
972
1055
  expect(host.querySelector('.u-ai-chat__working')).toBeFalsy()
1056
+ expect(host.querySelector('.u-ai-chat__welcome')).toBeTruthy()
1057
+ expect(host.querySelector('.u-ai-chat__list .u-ai-chat__welcome')).toBeFalsy()
973
1058
  unmount()
974
1059
  } finally {
975
1060
  vi.useRealTimers()
@@ -998,7 +1083,15 @@ describe('UAiChat', () => {
998
1083
  expect(host.querySelector('.u-ai-chat__working')).toBeTruthy()
999
1084
  await vi.advanceTimersByTimeAsync(2)
1000
1085
  await nextTick()
1086
+ // 停留结束 → 进入缩放退出过渡(is-leaving),元素仍在
1087
+ const leaving = host.querySelector('.u-ai-chat__working')
1088
+ expect(leaving).toBeTruthy()
1089
+ expect(leaving?.classList.contains('is-leaving')).toBe(true)
1090
+ // 过渡播完才卸载
1091
+ await vi.advanceTimersByTimeAsync(380)
1092
+ await nextTick()
1001
1093
  expect(host.querySelector('.u-ai-chat__working')).toBeFalsy()
1094
+ expect(host.querySelector('.u-ai-chat__welcome')).toBeTruthy()
1002
1095
  unmount()
1003
1096
  } finally {
1004
1097
  vi.useRealTimers()
@@ -1024,7 +1117,15 @@ describe('UAiChat', () => {
1024
1117
 
1025
1118
  await vi.advanceTimersByTimeAsync(2500)
1026
1119
  await nextTick()
1120
+ // 停留结束 → 进入缩放退出过渡(is-leaving),元素仍在
1121
+ const leaving = host.querySelector('.u-ai-chat__working')
1122
+ expect(leaving).toBeTruthy()
1123
+ expect(leaving?.classList.contains('is-leaving')).toBe(true)
1124
+ // 过渡播完才卸载
1125
+ await vi.advanceTimersByTimeAsync(380)
1126
+ await nextTick()
1027
1127
  expect(host.querySelector('.u-ai-chat__working')).toBeFalsy()
1128
+ expect(host.querySelector('.u-ai-chat__welcome')).toBeTruthy()
1028
1129
  unmount()
1029
1130
  } finally {
1030
1131
  vi.useRealTimers()
@@ -78,7 +78,7 @@ const props = defineProps<AiChatProps>()
78
78
  const emit = defineEmits<AiChatEmits>()
79
79
 
80
80
  defineSlots<{
81
- /** 空状态欢迎区插槽 */
81
+ /** 空闲欢迎区插槽(输入框上方;工作中跳到列表末尾) */
82
82
  welcome(): any
83
83
  /** 按工具名自定义工具结果展示,如 tool-getWeather */
84
84
  [name: `tool-${string}`]: (scope: { toolCall: ChatToolCall }) => any
@@ -112,7 +112,7 @@ const {
112
112
  removeQueued
113
113
  } = useChat({ props, emit })
114
114
 
115
- /** 空会话(无可见消息):输入区垂直居中,欢迎区(orb)贴于输入区上方并与之左对齐 */
115
+ /** 空会话(无可见消息):输入区垂直居中;欢迎区钉在滚动容器外、贴于输入框上方 */
116
116
  const isEmpty = computed(() => !messages.value.some((msg) => msg.role !== 'tool'))
117
117
 
118
118
  /** 所有 renderTo: 'panel' 的工具调用(按消息顺序,面板内容的来源) */
@@ -50,7 +50,6 @@
50
50
  <MarkdownRender
51
51
  v-if="message.content || isStreaming"
52
52
  mode="chat"
53
- :class="cls.e('message-content')"
54
53
  :content="message.content"
55
54
  :final="!isStreaming"
56
55
  :fade="false"
@@ -153,7 +152,7 @@ const handleReasoningScroll = (position: Required<ScrollPosition>) => {
153
152
  if (position.sh - position.ch - position.y < 8) reasoningStick.value = true
154
153
  }
155
154
 
156
- // 流式结束后自动折叠思考过程
155
+ // 流式结束后自动折叠思考过程;immediate 保证终态消息重挂载(过程块展开/收起、载入历史会话)时呈折叠态
157
156
  watch(
158
157
  () => props.message.status,
159
158
  (status) => {
@@ -163,7 +162,8 @@ watch(
163
162
  } else if (status) {
164
163
  reasoningExpanded.value = false
165
164
  }
166
- }
165
+ },
166
+ { immediate: true }
167
167
  )
168
168
 
169
169
  watch(