@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.
- package/dist/chat/transports/openai.js +18 -17
- package/dist/chat/transports/openai.js.map +1 -1
- package/dist/components/ai-chat/ai-chat.js +1 -1
- package/dist/components/ai-chat/ai-chat.js.map +1 -1
- package/dist/components/ai-chat/message-item.js +2 -7
- package/dist/components/ai-chat/message-item.js.map +1 -1
- package/dist/components/ai-chat/message-list.js +164 -65
- package/dist/components/ai-chat/message-list.js.map +1 -1
- package/dist/components/ai-chat/style2.css +111 -31
- package/dist/components/ai-chat/tool-call.js +2 -6
- package/dist/components/ai-chat/tool-call.js.map +1 -1
- package/dist/components/ai-chat/turn-process.js +56 -0
- package/dist/components/ai-chat/turn-process.js.map +1 -0
- package/dist/components/ai-orb/orb-renderer.js +88 -8
- package/dist/components/ai-orb/orb-renderer.js.map +1 -1
- package/dist/types/ai-chat.d.ts +1 -1
- package/package.json +15 -6
- package/src/components/ai-chat/__test__/ai-chat.test.ts +136 -35
- package/src/components/ai-chat/ai-chat.vue +2 -2
- package/src/components/ai-chat/message-item.vue +3 -3
- package/src/components/ai-chat/message-list.vue +186 -66
- package/src/components/ai-chat/style.scss +149 -41
- package/src/components/ai-chat/tool-call.vue +1 -10
- package/src/components/ai-chat/turn-process.vue +47 -0
- package/src/components/ai-orb/__test__/ai-orb.test.ts +28 -3
- package/src/components/ai-orb/orb-renderer.ts +113 -5
- package/src/tools/ask-question/__test__/ask-question.test.ts +13 -3
- package/src/types/ai-chat.ts +1 -1
|
@@ -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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"}
|
package/dist/types/ai-chat.d.ts
CHANGED
|
@@ -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.
|
|
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": "^
|
|
39
|
+
"markstream-vue": "^2.0.0"
|
|
31
40
|
},
|
|
32
41
|
"peerDependencies": {
|
|
33
|
-
"@veltra/compositions": "^1.6.
|
|
34
|
-
"@veltra/desktop": "^1.6.
|
|
42
|
+
"@veltra/compositions": "^1.6.4",
|
|
43
|
+
"@veltra/desktop": "^1.6.4",
|
|
35
44
|
"@veltra/icons": "^1.5.0",
|
|
36
|
-
"@veltra/styles": "^1.6.
|
|
37
|
-
"@veltra/utils": "^1.6.
|
|
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('
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
})
|
|
149
|
+
// 最终答案输出后,工具卡片收进「已完成」过程块,先展开
|
|
150
|
+
await expandProcess(host)
|
|
142
151
|
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
-
//
|
|
258
|
-
await
|
|
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
|
|
310
|
-
|
|
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
|
-
|
|
337
|
-
|
|
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
|
|
422
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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')).
|
|
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
|
-
|
|
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
|
-
/**
|
|
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(
|