@veltra/ai 2.1.0 → 2.1.2

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 (32) hide show
  1. package/dist/chat/transports/openai.js +18 -17
  2. package/dist/chat/transports/openai.js.map +1 -1
  3. package/dist/chat/use-chat.js +8 -2
  4. package/dist/chat/use-chat.js.map +1 -1
  5. package/dist/components/ai-chat/ai-chat.js +1 -1
  6. package/dist/components/ai-chat/ai-chat.js.map +1 -1
  7. package/dist/components/ai-chat/message-item.js +1 -1
  8. package/dist/components/ai-chat/message-item.js.map +1 -1
  9. package/dist/components/ai-chat/message-list.js +164 -65
  10. package/dist/components/ai-chat/message-list.js.map +1 -1
  11. package/dist/components/ai-chat/style2.css +101 -21
  12. package/dist/components/ai-chat/tool-call.js +2 -6
  13. package/dist/components/ai-chat/tool-call.js.map +1 -1
  14. package/dist/components/ai-chat/turn-process.js +56 -0
  15. package/dist/components/ai-chat/turn-process.js.map +1 -0
  16. package/dist/components/ai-orb/orb-renderer.js +88 -8
  17. package/dist/components/ai-orb/orb-renderer.js.map +1 -1
  18. package/dist/types/ai-chat.d.ts +1 -1
  19. package/package.json +15 -6
  20. package/src/chat/__test__/use-chat.test.ts +42 -4
  21. package/src/chat/use-chat.ts +8 -2
  22. package/src/components/ai-chat/__test__/ai-chat.test.ts +176 -36
  23. package/src/components/ai-chat/ai-chat.vue +2 -2
  24. package/src/components/ai-chat/message-item.vue +3 -2
  25. package/src/components/ai-chat/message-list.vue +186 -66
  26. package/src/components/ai-chat/style.scss +130 -27
  27. package/src/components/ai-chat/tool-call.vue +1 -10
  28. package/src/components/ai-chat/turn-process.vue +47 -0
  29. package/src/components/ai-orb/__test__/ai-orb.test.ts +28 -3
  30. package/src/components/ai-orb/orb-renderer.ts +113 -5
  31. package/src/tools/ask-question/__test__/ask-question.test.ts +13 -3
  32. package/src/types/ai-chat.ts +1 -1
@@ -1 +1 @@
1
- {"version":3,"file":"tool-call.js","names":[],"sources":["../../../src/components/ai-chat/tool-call.vue"],"sourcesContent":["<template>\n <UCollapseItem\n v-model=\"isExpanded\"\n :class=\"[cls.e('tool-call'), bem.is(toolCall.status)]\"\n :destroy-on-collapse=\"destroyOnCollapse\"\n @change=\"markUserToggled\"\n >\n <template #header>\n <UIcon :class=\"[cls.e('tool-call-status'), statusClass]\">\n <component :is=\"headerIcon\" />\n </UIcon>\n <span :class=\"[cls.e('tool-call-name'), isActive && 'u-shine']\">\n {{ tool?.label ?? toolCall.name }}\n </span>\n <span v-if=\"summary\" :class=\"cls.e('tool-call-summary')\">{{ summary }}</span>\n\n <span\n v-if=\"toolCall.status === 'awaiting-confirm'\"\n :class=\"cls.e('tool-call-confirm')\"\n @click.stop\n @keydown.enter.stop\n @keydown.space.stop\n >\n <UButton size=\"small\" type=\"primary\" @click=\"emit('respond', true)\">允许</UButton>\n <UButton size=\"small\" text @click=\"emit('respond', false)\">拒绝</UButton>\n </span>\n\n <UIcon :class=\"cls.e('tool-call-chevron')\"><ArrowDown /></UIcon>\n </template>\n\n <!-- 面板工具:render 展示在右侧侧边面板,卡片 body 仅提供查看入口 -->\n <div v-if=\"isPanelTool\" :class=\"cls.e('tool-call-panel-entry')\">\n <span :class=\"cls.e('tool-call-panel-hint')\">内容展示在右侧面板</span>\n <UButton size=\"small\" text type=\"primary\" @click=\"di?.openPanel(toolCall.id)\">\n 查看面板\n </UButton>\n </div>\n <!-- 自定义渲染:工具定义的 render 优先于 tool-<name> 插槽,均替换整个 body -->\n <component :is=\"tool.render\" v-else-if=\"tool?.render\" :tool-call=\"toolCall\" />\n <component :is=\"slotBody\" v-else-if=\"slotBody && hasResult\" />\n <template v-else>\n <template v-if=\"prettyArguments\">\n <div :class=\"cls.e('tool-call-section')\">参数</div>\n <pre :class=\"cls.e('tool-call-code')\">{{ prettyArguments }}</pre>\n </template>\n\n <template v-if=\"hasResult\">\n <div :class=\"cls.e('tool-call-section')\">\n {{ toolCall.status === 'error' ? '错误' : '结果' }}\n </div>\n <pre :class=\"cls.e('tool-call-code')\">{{ displayResult }}</pre>\n </template>\n </template>\n </UCollapseItem>\n</template>\n\n<script lang=\"ts\" setup>\nimport { UButton, UCollapseItem, UIcon } from '@veltra/desktop'\nimport {\n ArrowDown,\n CircleCheckFilled,\n CircleClose,\n Close,\n Loading,\n WarningFilled\n} from '@veltra/icons/normal'\nimport { bem } from '@veltra/utils'\nimport { computed, inject, ref, watch, type Component } from 'vue'\n\nimport type { ChatToolCall } from '../../chat/types'\nimport { AiChatDIKey } from './di'\n\ndefineOptions({ name: 'UAiChatToolCall' })\n\nconst props = defineProps<{ toolCall: ChatToolCall }>()\n\nconst emit = defineEmits<{ (e: 'respond', approved: boolean): void }>()\n\nconst di = inject(AiChatDIKey)\nconst cls = di?.cls ?? bem('ai-chat')\n\n/** 当前调用对应的工具定义(解析 icon/label/render/autoCollapse/terminal) */\nconst tool = computed(() => di?.tools.value[props.toolCall.name])\n\nconst isExpanded = ref(false)\n/** 用户手动切换过后,不再随 status 自动展开/折叠 */\nlet userToggled: boolean = false\n\n/** UCollapseItem 的 change 仅在用户点击头部时触发(程序化 v-model 变更不触发) */\nconst markUserToggled = () => {\n userToggled = true\n}\n\n/** 面板工具:render 展示在右侧侧边面板,卡片 body 仅保留「查看面板」入口 */\nconst isPanelTool = computed(() => tool.value?.renderTo === 'panel')\n\n/** 完成后是否自动折叠:缺省面板工具折叠;有 render 时不折叠,否则折叠 */\nconst autoCollapse = computed(\n () => tool.value?.autoCollapse ?? (isPanelTool.value || !tool.value?.render)\n)\n\nwatch(\n () => props.toolCall.status,\n (status) => {\n if (userToggled) return\n if (status === 'running' || status === 'awaiting-confirm') {\n isExpanded.value = true\n } else {\n isExpanded.value = !autoCollapse.value\n }\n },\n { immediate: true }\n)\n\nconst STATUS_META: Record<string, { icon: Component; class: string }> = {\n pending: { icon: Loading, class: 'is-active' },\n running: { icon: Loading, class: 'is-active' },\n 'awaiting-confirm': { icon: WarningFilled, class: 'is-warning' },\n success: { icon: CircleCheckFilled, class: 'is-success' },\n error: { icon: CircleClose, class: 'is-danger' },\n rejected: { icon: Close, class: 'is-danger' }\n}\n\nconst statusIcon = computed(() => STATUS_META[props.toolCall.status]?.icon ?? Loading)\nconst statusClass = computed(() => STATUS_META[props.toolCall.status]?.class ?? '')\n\n/** 进行中(pending/running):工具名带扫光效果 */\nconst isActive = computed(() => {\n return props.toolCall.status === 'pending' || props.toolCall.status === 'running'\n})\n\n/** 终态(成功/失败/拒绝) */\nconst isSettled = computed(() => {\n return ['success', 'error', 'rejected'].includes(props.toolCall.status)\n})\n\n/**\n * 折叠后卸载内容 DOM:仅终态且非面板工具时启用——\n * 进行中/待确认要保留内容状态(确认按钮、表单),面板工具的 body 是「查看面板」入口必须可达\n */\nconst destroyOnCollapse = computed(() => isSettled.value && !isPanelTool.value)\n\n/** 头部图标:工具自定义图标优先,缺省用状态图标 */\nconst headerIcon = computed(() => tool.value?.icon ?? statusIcon.value)\n\nconst parsedArguments = computed<Record<string, unknown> | null>(() => {\n if (!props.toolCall.arguments) return null\n try {\n return JSON.parse(props.toolCall.arguments)\n } catch {\n return null\n }\n})\n\n/** 折叠态参数摘要:取第一个字符串参数值 */\nconst summary = computed(() => {\n const args = parsedArguments.value\n if (!args) return ''\n for (const value of Object.values(args)) {\n if (typeof value === 'string' && value) {\n return value.length > 40 ? `${value.slice(0, 40)}…` : value\n }\n }\n return ''\n})\n\nconst prettyArguments = computed(() => {\n const args = parsedArguments.value\n if (args) return JSON.stringify(args, null, 2)\n return props.toolCall.arguments || ''\n})\n\nconst hasResult = computed(() => {\n return props.toolCall.result != null || props.toolCall.error != null\n})\n\nconst displayResult = computed(() => {\n const raw = props.toolCall.error ?? props.toolCall.result ?? ''\n try {\n return JSON.stringify(JSON.parse(raw), null, 2)\n } catch {\n return raw\n }\n})\n\n/** 使用方通过 tool-<name> 插槽自定义该工具的展示(有结果时替换整个 body) */\nconst slotBody = computed(() => {\n const slot = di?.slots[`tool-${props.toolCall.name}`]\n return slot ? () => slot({ toolCall: props.toolCall }) : undefined\n})\n</script>\n"],"mappings":";;;;;;;;;;;;EA0EA,MAAM,QAAQ;EAEd,MAAM,OAAO;EAEb,MAAM,KAAK,OAAO,WAAW;EAC7B,MAAM,MAAM,IAAI,OAAO,IAAI,SAAS;;EAGpC,MAAM,OAAO,eAAe,IAAI,MAAM,MAAM,MAAM,SAAS,KAAK;EAEhE,MAAM,aAAa,IAAI,KAAK;;EAE5B,IAAI,cAAuB;;EAG3B,MAAM,wBAAwB;GAC5B,cAAc;EAChB;;EAGA,MAAM,cAAc,eAAe,KAAK,OAAO,aAAa,OAAO;;EAGnE,MAAM,eAAe,eACb,KAAK,OAAO,iBAAiB,YAAY,SAAS,CAAC,KAAK,OAAO,OACvE;EAEA,YACQ,MAAM,SAAS,SACpB,WAAW;GACV,IAAI,aAAa;GACjB,IAAI,WAAW,aAAa,WAAW,oBACrC,WAAW,QAAQ;QAEnB,WAAW,QAAQ,CAAC,aAAa;EAErC,GACA,EAAE,WAAW,KAAK,CACpB;EAEA,MAAM,cAAkE;GACtE,SAAS;IAAE,MAAM;IAAS,OAAO;GAAY;GAC7C,SAAS;IAAE,MAAM;IAAS,OAAO;GAAY;GAC7C,oBAAoB;IAAE,MAAM;IAAe,OAAO;GAAa;GAC/D,SAAS;IAAE,MAAM;IAAmB,OAAO;GAAa;GACxD,OAAO;IAAE,MAAM;IAAa,OAAO;GAAY;GAC/C,UAAU;IAAE,MAAM;IAAO,OAAO;GAAY;EAC9C;EAEA,MAAM,aAAa,eAAe,YAAY,MAAM,SAAS,OAAO,EAAE,QAAQ,OAAO;EACrF,MAAM,cAAc,eAAe,YAAY,MAAM,SAAS,OAAO,EAAE,SAAS,EAAE;;EAGlF,MAAM,WAAW,eAAe;GAC9B,OAAO,MAAM,SAAS,WAAW,aAAa,MAAM,SAAS,WAAW;EAC1E,CAAC;;EAGD,MAAM,YAAY,eAAe;GAC/B,OAAO;IAAC;IAAW;IAAS;GAAU,CAAC,CAAC,SAAS,MAAM,SAAS,MAAM;EACxE,CAAC;;;;;EAMD,MAAM,oBAAoB,eAAe,UAAU,SAAS,CAAC,YAAY,KAAK;;EAG9E,MAAM,aAAa,eAAe,KAAK,OAAO,QAAQ,WAAW,KAAK;EAEtE,MAAM,kBAAkB,eAA+C;GACrE,IAAI,CAAC,MAAM,SAAS,WAAW,OAAO;GACtC,IAAI;IACF,OAAO,KAAK,MAAM,MAAM,SAAS,SAAS;GAC5C,QAAQ;IACN,OAAO;GACT;EACF,CAAC;;EAGD,MAAM,UAAU,eAAe;GAC7B,MAAM,OAAO,gBAAgB;GAC7B,IAAI,CAAC,MAAM,OAAO;GAClB,KAAK,MAAM,SAAS,OAAO,OAAO,IAAI,GACpC,IAAI,OAAO,UAAU,YAAY,OAC/B,OAAO,MAAM,SAAS,KAAK,GAAG,MAAM,MAAM,GAAG,EAAE,EAAE,KAAK;GAG1D,OAAO;EACT,CAAC;EAED,MAAM,kBAAkB,eAAe;GACrC,MAAM,OAAO,gBAAgB;GAC7B,IAAI,MAAM,OAAO,KAAK,UAAU,MAAM,MAAM,CAAC;GAC7C,OAAO,MAAM,SAAS,aAAa;EACrC,CAAC;EAED,MAAM,YAAY,eAAe;GAC/B,OAAO,MAAM,SAAS,UAAU,QAAQ,MAAM,SAAS,SAAS;EAClE,CAAC;EAED,MAAM,gBAAgB,eAAe;GACnC,MAAM,MAAM,MAAM,SAAS,SAAS,MAAM,SAAS,UAAU;GAC7D,IAAI;IACF,OAAO,KAAK,UAAU,KAAK,MAAM,GAAG,GAAG,MAAM,CAAC;GAChD,QAAQ;IACN,OAAO;GACT;EACF,CAAC;;EAGD,MAAM,WAAW,eAAe;GAC9B,MAAM,OAAO,IAAI,MAAM,QAAQ,MAAM,SAAS;GAC9C,OAAO,aAAa,KAAK,EAAE,UAAU,MAAM,SAAS,CAAC,IAAI,KAAA;EAC3D,CAAC;;GA5LC,OAAA,UAAA,GAAA,YAoDgB,MAAA,aAAA,GAAA;IAnDL,YAAA,WAAA;IAAA,uBAAA,OAAA,OAAA,OAAA,MAAA,WAAA,WAAU,QAAA;IAClB,OAAK,eAAA,CAAG,MAAA,GAAA,CAAG,CAAC,EAAC,WAAA,GAAe,MAAA,GAAA,CAAG,CAAC,GAAG,QAAA,SAAS,MAAM,CAAA,CAAA;IAClD,uBAAqB,kBAAA;IACrB,UAAQ;;IAEE,QAAM,cAGP;KAFR,YAEQ,MAAA,KAAA,GAAA,EAFA,OAAK,eAAA,CAAG,MAAA,GAAA,CAAG,CAAC,EAAC,kBAAA,GAAsB,YAAA,KAAW,CAAA,EAAA,GAAA;MACtB,SAAA,cAAA,EAAA,UAAA,GAA9B,YAA8B,wBAAd,WAAA,KAAU,CAAA,EAAA,CAAA;;;KAE5B,mBAEO,QAAA,EAFA,OAAK,eAAA,CAAG,MAAA,GAAA,CAAG,CAAC,EAAC,gBAAA,GAAoB,SAAA,SAAQ,SAAA,CAAA,EAAA,GAC3C,gBAAA,KAAA,OAAM,SAAS,QAAA,SAAS,IAAI,GAAA,CAAA;KAErB,QAAA,SAAA,UAAA,GAAZ,mBAA6E,QAAA;;MAAvD,OAAK,eAAE,MAAA,GAAA,CAAG,CAAC,EAAC,mBAAA,CAAA;KAA0B,GAAA,gBAAA,QAAA,KAAO,GAAA,CAAA,KAAA,mBAAA,QAAA,IAAA;KAG3D,QAAA,SAAS,WAAM,sBAAA,UAAA,GADvB,mBASO,QAAA;;MAPJ,OAAK,eAAE,MAAA,GAAA,CAAG,CAAC,EAAC,mBAAA,CAAA;MACZ,SAAK,OAAA,OAAA,OAAA,KAAA,oBAAN,CAAA,GAAW,CAAA,MAAA,CAAA;MACV,WAAO,CAAA,OAAA,OAAA,OAAA,KAAA,SAAA,oBAAR,CAAA,GAAmB,CAAA,MAAA,CAAA,GAAA,CAAA,OAAA,CAAA,IAAA,OAAA,OAAA,OAAA,KAAA,SAAA,oBACnB,CAAA,GAAmB,CAAA,MAAA,CAAA,GAAA,CAAA,OAAA,CAAA,EAAA;KAEnB,GAAA,CAAA,YAAgF,MAAA,OAAA,GAAA;MAAvE,MAAK;MAAQ,MAAK;MAAW,SAAK,OAAA,OAAA,OAAA,MAAA,WAAE,KAAI,WAAA,IAAA;;MAAqB,SAAA,cAAA,CAAA,GAAA,OAAA,OAAA,OAAA,KAAA,CAAA,gBAAF,MAAE,EAAA,CAAA,EAAA,CAAA;;KACtE,CAAA,GAAA,YAAuE,MAAA,OAAA,GAAA;MAA9D,MAAK;MAAQ,MAAA;MAAM,SAAK,OAAA,OAAA,OAAA,MAAA,WAAE,KAAI,WAAA,KAAA;;MAAsB,SAAA,cAAA,CAAA,GAAA,OAAA,OAAA,OAAA,KAAA,CAAA,gBAAF,MAAE,EAAA,CAAA,EAAA,CAAA;;;KAG/D,YAAgE,MAAA,KAAA,GAAA,EAAxD,OAAK,eAAE,MAAA,GAAA,CAAG,CAAC,EAAC,mBAAA,CAAA,EAAA,GAAA;MAAoC,SAAA,cAAA,CAAb,YAAa,MAAA,SAAA,CAAA,CAAA,CAAA;;;;IASpD,SAAA,cAAA,CALK,YAAA,SAAA,UAAA,GAAX,mBAKM,OAAA;;KALmB,OAAK,eAAE,MAAA,GAAA,CAAG,CAAC,EAAC,uBAAA,CAAA;IACnC,GAAA,CAAA,mBAA6D,QAAA,EAAtD,OAAK,eAAE,MAAA,GAAA,CAAG,CAAC,EAAC,sBAAA,CAAA,EAAA,GAA0B,aAAS,CAAA,GACtD,YAEU,MAAA,OAAA,GAAA;KAFD,MAAK;KAAQ,MAAA;KAAK,MAAK;KAAW,SAAK,OAAA,OAAA,OAAA,MAAA,WAAE,MAAA,EAAA,CAAE,EAAE,UAAU,QAAA,SAAS,EAAE;;KAE3E,SAAA,cAAA,CAAA,GAAA,OAAA,OAAA,OAAA,KAAA,CAAA,gBAF8E,UAE9E,EAAA,CAAA,EAAA,CAAA;;IAGsC,CAAA,CAAA,GAAA,CAAA,KAAA,KAAA,OAAM,UAAA,UAAA,GAA9C,mBAA8E,UAAA,EAAA,KAAA,EAAA,GAAA,CAD9E,mBAAA,oDAAA,IAAA,UAAA,GACA,YAA8E,wBAA9D,KAAA,MAAK,MAAM,GAAA,EAA4B,aAAW,QAAA,SAAQ,GAAA,MAAA,GAAA,CAAA,WAAA,CAAA,EAAA,GAAA,IACrC,KAAA,SAAA,SAAY,UAAA,SAAA,UAAA,GAAjD,YAA8D,wBAA9C,SAAA,KAAQ,GAAA,EAAA,KAAA,EAAA,CAAA,MAAA,UAAA,GACxB,mBAYW,UAAA,EAAA,KAAA,EAAA,GAAA,CAXO,gBAAA,SAAA,UAAA,GAAhB,mBAGW,UAAA,EAAA,KAAA,EAAA,GAAA,CAFT,mBAAiD,OAAA,EAA3C,OAAK,eAAE,MAAA,GAAA,CAAG,CAAC,EAAC,mBAAA,CAAA,EAAA,GAAuB,MAAE,CAAA,GAC3C,mBAAiE,OAAA,EAA3D,OAAK,eAAE,MAAA,GAAA,CAAG,CAAC,EAAC,gBAAA,CAAA,EAAA,GAAuB,gBAAA,gBAAA,KAAe,GAAA,CAAA,CAAA,GAAA,EAG1C,KAAA,mBAAA,QAAA,IAAA,GAAA,UAAA,SAAA,UAAA,GAAhB,mBAKW,UAAA,EAAA,KAAA,EAAA,GAAA,CAJT,mBAEM,OAAA,EAFA,OAAK,eAAE,MAAA,GAAA,CAAG,CAAC,EAAC,mBAAA,CAAA,EAAA,GACb,gBAAA,QAAA,SAAS,WAAM,UAAA,OAAA,IAAA,GAAA,CAAA,GAEpB,mBAA+D,OAAA,EAAzD,OAAK,eAAE,MAAA,GAAA,CAAG,CAAC,EAAC,gBAAA,CAAA,EAAA,GAAuB,gBAAA,cAAA,KAAa,GAAA,CAAA,CAAA,GAAA,EAAA,KAAA,mBAAA,QAAA,IAAA,CAAA,GAAA,EAAA,EAAA,CAAA"}
1
+ {"version":3,"file":"tool-call.js","names":[],"sources":["../../../src/components/ai-chat/tool-call.vue"],"sourcesContent":["<template>\n <UCollapseItem\n v-model=\"isExpanded\"\n :class=\"[cls.e('tool-call'), bem.is(toolCall.status)]\"\n :destroy-on-collapse=\"destroyOnCollapse\"\n @change=\"markUserToggled\"\n >\n <template #header>\n <UIcon :class=\"[cls.e('tool-call-status'), statusClass]\">\n <component :is=\"headerIcon\" />\n </UIcon>\n <span :class=\"[cls.e('tool-call-name'), isActive && 'u-shine']\">\n {{ tool?.label ?? toolCall.name }}\n </span>\n <span v-if=\"summary\" :class=\"cls.e('tool-call-summary')\">{{ summary }}</span>\n\n <span\n v-if=\"toolCall.status === 'awaiting-confirm'\"\n :class=\"cls.e('tool-call-confirm')\"\n @click.stop\n @keydown.enter.stop\n @keydown.space.stop\n >\n <UButton size=\"small\" type=\"primary\" @click=\"emit('respond', true)\">允许</UButton>\n <UButton size=\"small\" text @click=\"emit('respond', false)\">拒绝</UButton>\n </span>\n </template>\n\n <!-- 面板工具:render 展示在右侧侧边面板,卡片 body 仅提供查看入口 -->\n <div v-if=\"isPanelTool\" :class=\"cls.e('tool-call-panel-entry')\">\n <span :class=\"cls.e('tool-call-panel-hint')\">内容展示在右侧面板</span>\n <UButton size=\"small\" text type=\"primary\" @click=\"di?.openPanel(toolCall.id)\">\n 查看面板\n </UButton>\n </div>\n <!-- 自定义渲染:工具定义的 render 优先于 tool-<name> 插槽,均替换整个 body -->\n <component :is=\"tool.render\" v-else-if=\"tool?.render\" :tool-call=\"toolCall\" />\n <component :is=\"slotBody\" v-else-if=\"slotBody && hasResult\" />\n <template v-else>\n <template v-if=\"prettyArguments\">\n <div :class=\"cls.e('tool-call-section')\">参数</div>\n <pre :class=\"cls.e('tool-call-code')\">{{ prettyArguments }}</pre>\n </template>\n\n <template v-if=\"hasResult\">\n <div :class=\"cls.e('tool-call-section')\">\n {{ toolCall.status === 'error' ? '错误' : '结果' }}\n </div>\n <pre :class=\"cls.e('tool-call-code')\">{{ displayResult }}</pre>\n </template>\n </template>\n </UCollapseItem>\n</template>\n\n<script lang=\"ts\" setup>\nimport { UButton, UCollapseItem, UIcon } from '@veltra/desktop'\nimport { CircleCheckFilled, CircleClose, Close, Loading, WarningFilled } from '@veltra/icons/normal'\nimport { bem } from '@veltra/utils'\nimport { computed, inject, ref, watch, type Component } from 'vue'\n\nimport type { ChatToolCall } from '../../chat/types'\nimport { AiChatDIKey } from './di'\n\ndefineOptions({ name: 'UAiChatToolCall' })\n\nconst props = defineProps<{ toolCall: ChatToolCall }>()\n\nconst emit = defineEmits<{ (e: 'respond', approved: boolean): void }>()\n\nconst di = inject(AiChatDIKey)\nconst cls = di?.cls ?? bem('ai-chat')\n\n/** 当前调用对应的工具定义(解析 icon/label/render/autoCollapse/terminal) */\nconst tool = computed(() => di?.tools.value[props.toolCall.name])\n\nconst isExpanded = ref(false)\n/** 用户手动切换过后,不再随 status 自动展开/折叠 */\nlet userToggled: boolean = false\n\n/** UCollapseItem 的 change 仅在用户点击头部时触发(程序化 v-model 变更不触发) */\nconst markUserToggled = () => {\n userToggled = true\n}\n\n/** 面板工具:render 展示在右侧侧边面板,卡片 body 仅保留「查看面板」入口 */\nconst isPanelTool = computed(() => tool.value?.renderTo === 'panel')\n\n/** 完成后是否自动折叠:缺省面板工具折叠;有 render 时不折叠,否则折叠 */\nconst autoCollapse = computed(\n () => tool.value?.autoCollapse ?? (isPanelTool.value || !tool.value?.render)\n)\n\nwatch(\n () => props.toolCall.status,\n (status) => {\n if (userToggled) return\n if (status === 'running' || status === 'awaiting-confirm') {\n isExpanded.value = true\n } else {\n isExpanded.value = !autoCollapse.value\n }\n },\n { immediate: true }\n)\n\nconst STATUS_META: Record<string, { icon: Component; class: string }> = {\n pending: { icon: Loading, class: 'is-active' },\n running: { icon: Loading, class: 'is-active' },\n 'awaiting-confirm': { icon: WarningFilled, class: 'is-warning' },\n success: { icon: CircleCheckFilled, class: 'is-success' },\n error: { icon: CircleClose, class: 'is-danger' },\n rejected: { icon: Close, class: 'is-danger' }\n}\n\nconst statusIcon = computed(() => STATUS_META[props.toolCall.status]?.icon ?? Loading)\nconst statusClass = computed(() => STATUS_META[props.toolCall.status]?.class ?? '')\n\n/** 进行中(pending/running):工具名带扫光效果 */\nconst isActive = computed(() => {\n return props.toolCall.status === 'pending' || props.toolCall.status === 'running'\n})\n\n/** 终态(成功/失败/拒绝) */\nconst isSettled = computed(() => {\n return ['success', 'error', 'rejected'].includes(props.toolCall.status)\n})\n\n/**\n * 折叠后卸载内容 DOM:仅终态且非面板工具时启用——\n * 进行中/待确认要保留内容状态(确认按钮、表单),面板工具的 body 是「查看面板」入口必须可达\n */\nconst destroyOnCollapse = computed(() => isSettled.value && !isPanelTool.value)\n\n/** 头部图标:工具自定义图标优先,缺省用状态图标 */\nconst headerIcon = computed(() => tool.value?.icon ?? statusIcon.value)\n\nconst parsedArguments = computed<Record<string, unknown> | null>(() => {\n if (!props.toolCall.arguments) return null\n try {\n return JSON.parse(props.toolCall.arguments)\n } catch {\n return null\n }\n})\n\n/** 折叠态参数摘要:取第一个字符串参数值 */\nconst summary = computed(() => {\n const args = parsedArguments.value\n if (!args) return ''\n for (const value of Object.values(args)) {\n if (typeof value === 'string' && value) {\n return value.length > 40 ? `${value.slice(0, 40)}…` : value\n }\n }\n return ''\n})\n\nconst prettyArguments = computed(() => {\n const args = parsedArguments.value\n if (args) return JSON.stringify(args, null, 2)\n return props.toolCall.arguments || ''\n})\n\nconst hasResult = computed(() => {\n return props.toolCall.result != null || props.toolCall.error != null\n})\n\nconst displayResult = computed(() => {\n const raw = props.toolCall.error ?? props.toolCall.result ?? ''\n try {\n return JSON.stringify(JSON.parse(raw), null, 2)\n } catch {\n return raw\n }\n})\n\n/** 使用方通过 tool-<name> 插槽自定义该工具的展示(有结果时替换整个 body) */\nconst slotBody = computed(() => {\n const slot = di?.slots[`tool-${props.toolCall.name}`]\n return slot ? () => slot({ toolCall: props.toolCall }) : undefined\n})\n</script>\n"],"mappings":"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"}
@@ -0,0 +1,56 @@
1
+ import { AiChatDIKey } from "./di.js";
2
+ import _sfc_main$1 from "./message-item.js";
3
+ import { Fragment, createBlock, createCommentVNode, createElementBlock, createElementVNode, createVNode, defineComponent, inject, normalizeClass, openBlock, ref, renderList, unref, withCtx } from "vue";
4
+ import { ArrowRight, CircleCheck } from "@veltra/icons/normal";
5
+ import { UIcon } from "@veltra/desktop";
6
+ import { bem } from "@veltra/utils";
7
+ //#region src/components/ai-chat/turn-process.vue
8
+ const _sfc_main = /*@__PURE__*/ defineComponent({
9
+ name: "UAiChatTurnProcess",
10
+ __name: "turn-process",
11
+ props: {
12
+ messages: {},
13
+ rendererProps: {}
14
+ },
15
+ emits: ["respond"],
16
+ setup(__props, { emit: __emit }) {
17
+ const emit = __emit;
18
+ const cls = inject(AiChatDIKey)?.cls ?? bem("ai-chat");
19
+ const expanded = ref(false);
20
+ return (_ctx, _cache) => {
21
+ return openBlock(), createElementBlock("div", { class: normalizeClass([unref(cls).e("process"), unref(bem).is("expanded", expanded.value)]) }, [
22
+ createElementVNode("div", {
23
+ class: normalizeClass(unref(cls).e("process-header")),
24
+ onClick: _cache[0] || (_cache[0] = ($event) => expanded.value = !expanded.value)
25
+ }, [
26
+ createVNode(unref(UIcon), { class: normalizeClass(unref(cls).e("process-icon")) }, {
27
+ default: withCtx(() => [createVNode(unref(CircleCheck))]),
28
+ _: 1
29
+ }, 8, ["class"]),
30
+ createElementVNode("span", { class: normalizeClass(unref(cls).e("process-title")) }, "已完成", 2),
31
+ createVNode(unref(UIcon), { class: normalizeClass(unref(cls).e("process-chevron")) }, {
32
+ default: withCtx(() => [createVNode(unref(ArrowRight))]),
33
+ _: 1
34
+ }, 8, ["class"])
35
+ ], 2),
36
+ createCommentVNode(" 折叠时完全卸载过程 DOM:历史轮次的思考/工具卡片可能很多,常驻挂载浪费渲染成本 "),
37
+ expanded.value ? (openBlock(), createElementBlock("div", {
38
+ key: 0,
39
+ class: normalizeClass(unref(cls).e("process-content"))
40
+ }, [(openBlock(true), createElementBlock(Fragment, null, renderList(__props.messages, (msg) => {
41
+ return openBlock(), createBlock(_sfc_main$1, {
42
+ key: msg.id,
43
+ message: msg,
44
+ "is-last": false,
45
+ "renderer-props": __props.rendererProps,
46
+ onRespond: _cache[1] || (_cache[1] = (id, approved) => emit("respond", id, approved))
47
+ }, null, 8, ["message", "renderer-props"]);
48
+ }), 128))], 2)) : createCommentVNode("v-if", true)
49
+ ], 2);
50
+ };
51
+ }
52
+ });
53
+ //#endregion
54
+ export { _sfc_main as default };
55
+
56
+ //# sourceMappingURL=turn-process.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"turn-process.js","names":[],"sources":["../../../src/components/ai-chat/turn-process.vue"],"sourcesContent":["<template>\n <div :class=\"[cls.e('process'), bem.is('expanded', expanded)]\">\n <div :class=\"cls.e('process-header')\" @click=\"expanded = !expanded\">\n <UIcon :class=\"cls.e('process-icon')\"><CircleCheck /></UIcon>\n <span :class=\"cls.e('process-title')\">已完成</span>\n <UIcon :class=\"cls.e('process-chevron')\"><ArrowRight /></UIcon>\n </div>\n <!-- 折叠时完全卸载过程 DOM:历史轮次的思考/工具卡片可能很多,常驻挂载浪费渲染成本 -->\n <div v-if=\"expanded\" :class=\"cls.e('process-content')\">\n <MessageItem\n v-for=\"msg in messages\"\n :key=\"msg.id\"\n :message=\"msg\"\n :is-last=\"false\"\n :renderer-props=\"rendererProps\"\n @respond=\"(id, approved) => emit('respond', id, approved)\"\n />\n </div>\n </div>\n</template>\n\n<script lang=\"ts\" setup>\nimport { UIcon } from '@veltra/desktop'\nimport { ArrowRight, CircleCheck } from '@veltra/icons/normal'\nimport { bem } from '@veltra/utils'\nimport { inject, ref } from 'vue'\n\nimport type { ChatMessage } from '../../chat/types'\nimport { AiChatDIKey } from './di'\nimport MessageItem from './message-item.vue'\n\ndefineOptions({ name: 'UAiChatTurnProcess' })\n\ndefineProps<{\n /** 本轮最终答案之前的过程消息(思考 + 中间文本 + 工具卡片) */\n messages: ChatMessage[]\n /** 透传给 MarkdownRender 的属性 */\n rendererProps?: Record<string, unknown>\n}>()\n\nconst emit = defineEmits<{ (e: 'respond', toolCallId: string, approved: boolean): void }>()\n\nconst di = inject(AiChatDIKey)\nconst cls = di?.cls ?? bem('ai-chat')\n\nconst expanded = ref(false)\n</script>\n"],"mappings":";;;;;;;;;;;;;;;;EAwCA,MAAM,OAAO;EAGb,MAAM,MADK,OAAO,WACN,CAAA,EAAI,OAAO,IAAI,SAAS;EAEpC,MAAM,WAAW,IAAI,KAAK;;GA5CxB,OAAA,UAAA,GAAA,mBAiBM,OAAA,EAjBA,OAAK,eAAA,CAAG,MAAA,GAAA,CAAG,CAAC,EAAC,SAAA,GAAa,MAAA,GAAA,CAAG,CAAC,GAAE,YAAa,SAAA,KAAQ,CAAA,CAAA,EAAA,GAAA;IACzD,mBAIM,OAAA;KAJA,OAAK,eAAE,MAAA,GAAA,CAAG,CAAC,EAAC,gBAAA,CAAA;KAAqB,SAAK,OAAA,OAAA,OAAA,MAAA,WAAE,SAAA,QAAQ,CAAI,SAAA;;KACxD,YAA6D,MAAA,KAAA,GAAA,EAArD,OAAK,eAAE,MAAA,GAAA,CAAG,CAAC,EAAC,cAAA,CAAA,EAAA,GAAA;MAAiC,SAAA,cAAA,CAAf,YAAe,MAAA,WAAA,CAAA,CAAA,CAAA;;;KACrD,mBAAgD,QAAA,EAAzC,OAAK,eAAE,MAAA,GAAA,CAAG,CAAC,EAAC,eAAA,CAAA,EAAA,GAAmB,OAAG,CAAA;KACzC,YAA+D,MAAA,KAAA,GAAA,EAAvD,OAAK,eAAE,MAAA,GAAA,CAAG,CAAC,EAAC,iBAAA,CAAA,EAAA,GAAA;MAAmC,SAAA,cAAA,CAAd,YAAc,MAAA,UAAA,CAAA,CAAA,CAAA;;;;IAEzD,mBAAA,6CAAA;IACW,SAAA,SAAA,UAAA,GAAX,mBASM,OAAA;;KATgB,OAAK,eAAE,MAAA,GAAA,CAAG,CAAC,EAAC,iBAAA,CAAA;IAChC,GAAA,EAAA,UAAA,IAAA,GAAA,mBAOE,UAAA,MAAA,WANc,QAAA,WAAP,QAAG;KADZ,OAAA,UAAA,GAAA,YAOE,aAAA;MALC,KAAK,IAAI;MACT,SAAS;MACT,WAAS;MACT,kBAAgB,QAAA;MAChB,WAAO,OAAA,OAAA,OAAA,MAAG,IAAI,aAAa,KAAI,WAAY,IAAI,QAAQ"}
@@ -36,7 +36,8 @@ const REACTIONS = {
36
36
  gaze: {
37
37
  x: 0,
38
38
  y: -.05
39
- }
39
+ },
40
+ earTilt: .14
40
41
  },
41
42
  shock: {
42
43
  duration: 1.2,
@@ -46,7 +47,8 @@ const REACTIONS = {
46
47
  gaze: {
47
48
  x: 0,
48
49
  y: -.02
49
- }
50
+ },
51
+ earTilt: .06
50
52
  },
51
53
  frustrated: {
52
54
  duration: 1.5,
@@ -56,7 +58,8 @@ const REACTIONS = {
56
58
  gaze: {
57
59
  x: 0,
58
60
  y: .02
59
- }
61
+ },
62
+ earTilt: .62
60
63
  }
61
64
  };
62
65
  /** happy 前段先睁大双眼,随后才切换弯眼(占表情时长比例) */
@@ -69,14 +72,25 @@ const COLORS = {
69
72
  body: "#3d9bf0",
70
73
  face: "#ffffff"
71
74
  };
72
- /** 球体横竖比:扁椭圆(mochi 感),呼吸在此之上微调 */
75
+ /** 球体横竖比:扁椭圆(mochi 感,明显压扁),呼吸在此之上微调 */
73
76
  const BODY_SCALE_X = 1.1;
74
- const BODY_SCALE_Y = .92;
77
+ const BODY_SCALE_Y = .84;
75
78
  /** 竖椭圆大眼睛(单位球空间):竖长才像眼睛,正圆容易读成鼻孔 */
76
79
  const EYE_X = .27;
77
80
  const EYE_Y = -.12;
78
81
  const EYE_RX = .13;
79
82
  const EYE_RY = .19;
83
+ /**
84
+ * 耳朵(单位球空间):圆角尖三角(猫耳),根部埋在球体内(先于球体路径绘制,被球遮住),
85
+ * 只露出耳尖;基础外倒角,表情时竖起(角小)或耷拉(角大)
86
+ */
87
+ const EAR_X = .44;
88
+ const EAR_Y = -.8;
89
+ /** 根部半宽 */
90
+ const EAR_W = .17;
91
+ /** 耳高(根部到耳尖) */
92
+ const EAR_H = .46;
93
+ const EAR_TILT = .38;
80
94
  /** 组合圆路径的点数(越大越平滑,36 在小尺寸下已足够) */
81
95
  const SEGMENTS = 36;
82
96
  function clamp(value, min, max) {
@@ -99,6 +113,7 @@ function reactionEnvelope(p) {
99
113
  * 灵动感的核心在眼部状态机:
100
114
  * - 白色大眼睛随机眨眼(约 18% 概率双眨),闭起为白色线条
101
115
  * - 视线游移 / 转头(面部整体平移 + 轻微倾斜,idle 随机、thinking 缓慢扫视)
116
+ * - thinking 时在球体右上角绘制与身体同色的「?」,轻晃
102
117
  * - 瞬时表情({@link AiOrbReaction}):睁大眼、弯眼笑、紧闭眼 ><,配合点头 / 摇头 / 后仰
103
118
  *
104
119
  * 性能设计:
@@ -140,6 +155,42 @@ function createOrbRenderer(canvas, options) {
140
155
  let pointerPos = null;
141
156
  /** Q 弹起始时间,-1 表示未在回弹 */
142
157
  let pokeStart = -1;
158
+ /** thinking 右上角「?」显隐(0-1),随状态平滑过渡 */
159
+ let thinkingMark = statusLabel === "thinking" ? 1 : 0;
160
+ /** 猫耳轮廓:两侧微鼓、耳尖带小圆角、根部微弧朝下埋进球体 */
161
+ function earPath(w, h) {
162
+ if (!ctx) return;
163
+ ctx.beginPath();
164
+ ctx.moveTo(-w, 0);
165
+ ctx.quadraticCurveTo(-w * .92, -h * .68, -.028, -h * .92);
166
+ ctx.quadraticCurveTo(0, -h, .028, -h * .92);
167
+ ctx.quadraticCurveTo(w * .92, -h * .68, w, 0);
168
+ ctx.quadraticCurveTo(0, .09, -w, 0);
169
+ ctx.closePath();
170
+ }
171
+ /** 耳朵:身体色尖耳 + 白色内耳,平时随时间轻微错相摆动 */
172
+ function drawEars(tilt) {
173
+ if (!ctx) return;
174
+ for (const side of [-1, 1]) {
175
+ const swing = .05 * Math.sin(t * 1.9 + side * 1.2);
176
+ ctx.save();
177
+ ctx.translate(side * EAR_X, EAR_Y);
178
+ ctx.rotate(side * (tilt + swing));
179
+ ctx.fillStyle = COLORS.body;
180
+ earPath(EAR_W, EAR_H);
181
+ ctx.fill();
182
+ ctx.fillStyle = COLORS.face;
183
+ const iw = EAR_W * .48;
184
+ ctx.beginPath();
185
+ ctx.moveTo(-.0816, -.46 * .1);
186
+ ctx.quadraticCurveTo(-.07344, -.2024, 0, -.276);
187
+ ctx.quadraticCurveTo(iw * .9, -.2024, iw, -.46 * .1);
188
+ ctx.quadraticCurveTo(0, -.0092, -.0816, -.46 * .1);
189
+ ctx.closePath();
190
+ ctx.fill();
191
+ ctx.restore();
192
+ }
193
+ }
143
194
  /** 面部:白色大眼睛(圆眼 / 弯眼 / 紧闭线条)+ 嘴 */
144
195
  function drawFace(eyeMode, eyeOpen, eyeScale, mouth) {
145
196
  if (!ctx) return;
@@ -175,6 +226,26 @@ function createOrbRenderer(canvas, options) {
175
226
  }
176
227
  drawMouth(mouth);
177
228
  }
229
+ /**
230
+ * thinking 吉祥物右上角的「?」:球体同色,轻晃更像在想问题。
231
+ * 画在单位球变换之外,避免被扁椭圆压扁。
232
+ */
233
+ function drawThinkingMark(weight) {
234
+ if (!ctx || weight <= .01) return;
235
+ const fontSize = size * .3;
236
+ const x = size * .93;
237
+ const y = size * .24 + size * .02 * Math.sin(t * 3.6);
238
+ ctx.save();
239
+ ctx.globalAlpha = weight;
240
+ ctx.translate(x, y);
241
+ ctx.rotate(-.16 + .1 * Math.sin(t * 2.7));
242
+ ctx.fillStyle = COLORS.body;
243
+ ctx.font = `700 ${fontSize}px ui-rounded, system-ui, sans-serif`;
244
+ ctx.textAlign = "center";
245
+ ctx.textBaseline = "middle";
246
+ ctx.fillText("?", 0, 0);
247
+ ctx.restore();
248
+ }
178
249
  function drawMouth(mouth) {
179
250
  if (!ctx || mouth === "none") return;
180
251
  ctx.fillStyle = COLORS.face;
@@ -281,6 +352,9 @@ function createOrbRenderer(canvas, options) {
281
352
  const gazeEase = 1 - Math.exp(-frameDt * 6.5);
282
353
  gazeCur.x += (gazeTarget.x - gazeCur.x) * gazeEase;
283
354
  gazeCur.y += (gazeTarget.y - gazeCur.y) * gazeEase;
355
+ const markTarget = statusLabel === "thinking" ? 1 : 0;
356
+ if (isRunning) thinkingMark += (markTarget - thinkingMark) * ease;
357
+ else thinkingMark = markTarget;
284
358
  let eyeOpen = current.eyeOpen * blinkOpen;
285
359
  let eyeScale = 1;
286
360
  let eyeMode = "round";
@@ -297,9 +371,13 @@ function createOrbRenderer(canvas, options) {
297
371
  let bodyDy = 0;
298
372
  let bodyRot = gazeCur.x * .5;
299
373
  let bodyScale = 1;
300
- if (reactionDef && reaction && reactionW > .01) if (reaction.type === "happy") bodyDy = Math.sin(Math.min(reactionP / .75, 1) * Math.PI) * .05;
301
- else if (reaction.type === "frustrated") bodyRot += Math.sin(reactionP * reactionDef.duration * 18) * .05 * (1 - reactionP);
302
- else bodyScale = 1 - .05 * Math.sin(Math.min(reactionP * 1.6, 1) * Math.PI);
374
+ let earTilt = EAR_TILT;
375
+ if (reactionDef && reaction && reactionW > .01) {
376
+ earTilt = lerp(EAR_TILT, reactionDef.earTilt, reactionW);
377
+ if (reaction.type === "happy") bodyDy = Math.sin(Math.min(reactionP / .75, 1) * Math.PI) * .05;
378
+ else if (reaction.type === "frustrated") bodyRot += Math.sin(reactionP * reactionDef.duration * 18) * .05 * (1 - reactionP);
379
+ else bodyScale = 1 - .05 * Math.sin(Math.min(reactionP * 1.6, 1) * Math.PI);
380
+ }
303
381
  let jellyX = 1;
304
382
  let jellyY = 1;
305
383
  if (pokeStart >= 0) {
@@ -323,6 +401,7 @@ function createOrbRenderer(canvas, options) {
323
401
  ctx.translate(cx, cy);
324
402
  ctx.rotate(bodyRot);
325
403
  ctx.scale(radius * scaleX, radius * scaleY);
404
+ drawEars(earTilt);
326
405
  ctx.beginPath();
327
406
  for (let i = 0; i <= SEGMENTS; i++) {
328
407
  const theta = i / SEGMENTS * Math.PI * 2;
@@ -339,6 +418,7 @@ function createOrbRenderer(canvas, options) {
339
418
  ctx.rotate(gazeCur.x * .35);
340
419
  drawFace(eyeMode, eyeOpen, eyeScale, mouth);
341
420
  ctx.restore();
421
+ drawThinkingMark(thinkingMark);
342
422
  }
343
423
  function tick(now) {
344
424
  if (!isRunning) return;
@@ -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":";AAgBA,MAAM,gBAAgD;CACpD,MAAM;EAAE,SAAS;EAAG,SAAS;EAAM,aAAa;EAAM,WAAW;EAAO,aAAa;CAAE;CACvF,UAAU;EAAE,SAAS;EAAM,SAAS;EAAO,aAAa;EAAK,WAAW;EAAO,aAAa;CAAI;CAChG,UAAU;EAAE,SAAS;EAAG,SAAS;EAAO,aAAa;EAAM,WAAW;EAAO,aAAa;CAAI;AAChG;AAOA,MAAM,eAA+C;CACnD,MAAM;CACN,UAAU;CACV,UAAU;AACZ;AAaA,MAAM,YAAgD;CACpD,OAAO;EAAE,UAAU;EAAK,SAAS;EAAS,UAAU;EAAG,OAAO;EAAQ,MAAM;GAAE,GAAG;GAAG,GAAG;EAAM;CAAE;CAC/F,OAAO;EAAE,UAAU;EAAK,SAAS;EAAS,UAAU;EAAM,OAAO;EAAK,MAAM;GAAE,GAAG;GAAG,GAAG;EAAM;CAAE;CAC/F,YAAY;EACV,UAAU;EACV,SAAS;EACT,UAAU;EACV,OAAO;EACP,MAAM;GAAE,GAAG;GAAG,GAAG;EAAK;CACxB;AACF;;AAGA,MAAM,oBAAoB;;;;;AAM1B,MAAM,SAAS;CAAE,MAAM;CAAW,MAAM;AAAU;;AAGlD,MAAM,eAAe;AACrB,MAAM,eAAe;;AAGrB,MAAM,QAAQ;AACd,MAAM,QAAQ;AACd,MAAM,SAAS;AACf,MAAM,SAAS;;AA0Bf,MAAM,WAAW;AAEjB,SAAS,MAAM,OAAe,KAAa,KAAqB;CAC9D,OAAO,KAAK,IAAI,KAAK,KAAK,IAAI,KAAK,KAAK,CAAC;AAC3C;AAEA,SAAS,KAAK,MAAc,IAAY,GAAmB;CACzD,OAAO,QAAQ,KAAK,QAAQ;AAC9B;AAEA,SAAS,UAAU,GAAmB;CACpC,OAAO,IAAI,KAAK,IAAI,IAAI;AAC1B;;AAGA,SAAS,iBAAiB,GAAmB;CAC3C,OAAO,UAAU,MAAM,KAAK,IAAI,IAAI,MAAO,IAAI,KAAK,GAAI,GAAG,GAAG,CAAC,CAAC;AAClE;;;;;;;;;;;;;;AAeA,SAAgB,kBACd,QACA,SACe;CACf,MAAM,MAAM,OAAO,WAAW,IAAI;CAElC,IAAI,OAAO,QAAQ;CACnB,IAAI,MAAM;CAEV,IAAI,SAAoB,EAAE,GAAG,cAAc,QAAQ,UAAU,QAAQ;CACrE,IAAI,UAAqB,EAAE,GAAG,OAAO;CACrC,IAAI,cAA2B,QAAQ,UAAU;CAEjD,IAAI,QAAQ;CACZ,IAAI,YAAY;;CAEhB,IAAI,IAAI;CACR,IAAI,cAAc;;CAElB,IAAI,UAAU,IAAI;;CAGlB,IAAI,cAAc,MAAM,KAAK,OAAO,IAAI;;CAExC,IAAI,aAAa;;CAGjB,MAAM,UAAU;EAAE,GAAG;EAAG,GAAG;CAAE;CAC7B,IAAI,aAAa;EAAE,GAAG;EAAG,GAAG;CAAE;;CAE9B,IAAI,aAAa,MAAM,KAAK,OAAO,IAAI;;CAGvC,IAAI,WAA4D;;CAGhE,IAAI,aAA8C;;CAElD,IAAI,YAAY;;CAGhB,SAAS,SAAS,SAAkB,SAAiB,UAAkB,OAAkB;EACvF,IAAI,CAAC,KAAK;EAEV,IAAI,YAAY,OAAO;EACvB,IAAI,cAAc,OAAO;EACzB,IAAI,UAAU;EAEd,KAAK,MAAM,QAAQ,CAAC,IAAI,CAAC,GAAG;GAC1B,IAAI,KAAK;GACT,IAAI,UAAU,OAAO,OAAO,KAAK;GAEjC,IAAI,YAAY,SAAS;IAEvB,MAAM,OAAO,MAAM,SAAS,KAAM,CAAC;IACnC,IAAI,KAAK;IACT,IAAI,MAAM,GAAG,IAAI;IACjB,IAAI,UAAU;IACd,IAAI,QAAQ,GAAG,GAAG,SAAS,UAAU,SAAS,UAAU,GAAG,GAAG,KAAK,KAAK,CAAC;IACzE,IAAI,KAAK;IACT,IAAI,QAAQ;GACd,OAAO,IAAI,YAAY,SAAS;IAE9B,IAAI,YAAY;IAChB,IAAI,UAAU;IACd,IAAI,IAAI,GAAG,KAAM,KAAM,KAAK,IAAI,KAAK,KAAK,CAAC;IAC3C,IAAI,OAAO;GACb,OAAO;IAEL,IAAI,YAAY;IAChB,IAAI,WAAW;IACf,IAAI,UAAU;IACd,IAAI,OAAO,OAAQ,MAAM,IAAK;IAC9B,IAAI,OAAO,QAAS,MAAM,CAAC;IAC3B,IAAI,OAAO,OAAQ,MAAM,GAAI;IAC7B,IAAI,OAAO;GACb;GAEA,IAAI,QAAQ;EACd;EAEA,UAAU,KAAK;CACjB;CAEA,SAAS,UAAU,OAAkB;EACnC,IAAI,CAAC,OAAO,UAAU,QAAQ;EAC9B,IAAI,YAAY,OAAO;EACvB,IAAI,cAAc,OAAO;EAEzB,QAAQ,OAAR;GACE,KAAK;IACH,IAAI,YAAY;IAChB,IAAI,UAAU;IACd,IAAI,OAAO,MAAO,GAAI;IACtB,IAAI,iBAAiB,GAAG,KAAM,KAAM,GAAI;IACxC,IAAI,OAAO;IACX;GACF,KAAK;IAEH,IAAI,YAAY;IAChB,IAAI,UAAU;IACd,IAAI,OAAO,MAAO,GAAI;IACtB,IAAI,iBAAiB,GAAG,MAAO,KAAM,GAAI;IACzC,IAAI,OAAO;IACX;GACF,KAAK,QAAQ;IAEX,MAAM,KAAK,OAAQ,MAAO,KAAK,IAAI,KAAK,IAAI,MAAM,CAAC,CAAC;IACpD,IAAI,UAAU;IACd,IAAI,QAAQ,GAAG,IAAK,IAAK,IAAI,GAAG,GAAG,KAAK,KAAK,CAAC;IAC9C,IAAI,KAAK;IACT;GACF;GACA,KAAK;IAEH,IAAI,UAAU;IACd,IAAI,QAAQ,GAAG,KAAM,KAAM,MAAO,GAAG,GAAG,KAAK,EAAE;IAC/C,IAAI,UAAU;IACd,IAAI,KAAK;IACT;GACF,KAAK;IACH,IAAI,YAAY;IAChB,IAAI,UAAU;IACd,IAAI,OAAO,KAAM,GAAI;IACrB,IAAI,iBAAiB,GAAG,IAAK,IAAK,GAAI;IACtC,IAAI,OAAO;IACX;GACF,KAAK;IAEH,IAAI,UAAU;IACd,IAAI,QAAQ,GAAG,KAAM,KAAM,MAAO,GAAG,GAAG,KAAK,KAAK,CAAC;IACnD,IAAI,KAAK;EAEb;CACF;CAEA,SAAS,YAAY;EACnB,MAAM,KAAK,IAAI,WAAW,oBAAoB,GAAG,CAAC;EAClD,MAAM,KAAK,KAAK,IAAI,GAAG,KAAK,MAAM,OAAO,GAAG,CAAC;EAC7C,OAAO,QAAQ;EACf,OAAO,SAAS;CAClB;;CAGA,SAAS,OAAO,OAAe,QAA2B;EACxD,MAAM,EAAE,WAAW,gBAAgB;EACnC,MAAM,KAAK;EACX,OACE,aACC,MAAO,KAAK,IAAI,IAAI,QAAQ,KAAK,CAAC,IACjC,KAAM,KAAK,IAAI,IAAI,QAAQ,MAAM,KAAK,IAAI,GAAG,IAC7C,MAAO,KAAK,IAAI,IAAI,QAAQ,KAAM,KAAK,IAAI,GAAG;CAEpD;CAEA,SAAS,OAAO;EACd,IAAI,CAAC,KAAK;EAGV,MAAM,OAAO,IAAI,KAAK,IAAI,CAAC,UAAU,CAAC;EACtC,KAAK,MAAM,OAAO,OAAO,KAAK,MAAM,GAClC,QAAQ,SAAS,OAAO,OAAO,QAAQ,QAAQ;EAIjD,IAAI,aAAa,KAAK,CAAC,YAAY,KAAK,aAAa,aAAa;EAClE,IAAI,YAAY;EAChB,IAAI,cAAc,GAAG;GACnB,MAAM,SAAS,IAAI,cAAc;GACjC,IAAI,SAAS,GAAG;IACd,aAAa;IACb,cAAc,KAAK,OAAO,IAAI,MAAO,IAAI,MAAO,IAAI,MAAM,KAAK,OAAO,IAAI;GAC5E,OACE,YAAY,IAAI,KAAK,IAAI,KAAK,KAAK,KAAK,IAAI;EAEhD;EAGA,IAAI,cAAkC;EACtC,IAAI,YAAY;EAChB,IAAI,YAAY;EAChB,IAAI,UAAU;GACZ,MAAM,MAAM,UAAU,SAAS;GAC/B,aAAa,IAAI,SAAS,WAAW,IAAI;GACzC,IAAI,aAAa,GACf,WAAW;QACN;IACL,cAAc;IACd,YAAY,iBAAiB,SAAS;GACxC;EACF;EAGA,IAAI,eAAe,YAAY,KAC7B,aAAa,YAAY;OACpB,IAAI,YAET,aAAa;GAAE,GAAG,MAAM,WAAW,GAAG,MAAO,GAAI;GAAG,GAAG,MAAM,WAAW,GAAG,KAAM,GAAI;EAAE;OAClF,IAAI,gBAAgB,YAEzB,aAAa;GAAE,GAAG,MAAO,KAAK,IAAI,IAAI,EAAG;GAAG,GAAG,OAAQ,MAAO,KAAK,IAAI,IAAI,GAAI;EAAE;OAC5E,IAAI,gBAAgB,YACzB,aAAa;GAAE,GAAG,MAAO,KAAK,IAAI,IAAI,GAAG;GAAG,GAAG,MAAO,KAAK,IAAI,IAAI,EAAG;EAAE;OACnE,IAAI,KAAK,YAAY;GAC1B,MAAM,MAAM,KAAK,OAAO,IAAI;GAC5B,aAAa;IACX,IAAI,KAAK,OAAO,IAAI,IAAI,MAAM,MAAM,MAAO;IAC3C,IAAI,KAAK,OAAO,IAAI,IAAI,KAAK,MAAO;GACtC;GACA,aAAa,IAAI,MAAM,KAAK,OAAO,IAAI;EACzC;EACA,MAAM,WAAW,IAAI,KAAK,IAAI,CAAC,UAAU,GAAG;EAC5C,QAAQ,MAAM,WAAW,IAAI,QAAQ,KAAK;EAC1C,QAAQ,MAAM,WAAW,IAAI,QAAQ,KAAK;EAG1C,IAAI,UAAU,QAAQ,UAAU;EAChC,IAAI,WAAW;EACf,IAAI,UAAmB;EACvB,IAAI,QAAmB,aAAa;EACpC,IAAI,eAAe,UAAU;GAE3B,MAAM,aAAa,SAAS,SAAS,WAAW,YAAY;GAC5D,WAAW,KAAK,GAAG,aAAa,MAAM,YAAY,UAAU,SAAS;GACrE,UAAU,KAAK,SAAS,GAAG,SAAS;GACpC,IAAI,YAAY,KAAM;IACpB,UAAU,aAAa,UAAU,YAAY;IAC7C,QAAQ,YAAY;GACtB;EACF;EAGA,IAAI,SAAS;EACb,IAAI,UAAU,QAAQ,IAAI;EAC1B,IAAI,YAAY;EAChB,IAAI,eAAe,YAAY,YAAY,KACzC,IAAI,SAAS,SAAS,SACpB,SAAS,KAAK,IAAI,KAAK,IAAI,YAAY,KAAM,CAAC,IAAI,KAAK,EAAE,IAAI;OACxD,IAAI,SAAS,SAAS,cAC3B,WAAW,KAAK,IAAI,YAAY,YAAY,WAAW,EAAE,IAAI,OAAQ,IAAI;OAEzE,YAAY,IAAI,MAAO,KAAK,IAAI,KAAK,IAAI,YAAY,KAAK,CAAC,IAAI,KAAK,EAAE;EAO1E,IAAI,SAAS;EACb,IAAI,SAAS;EACb,IAAI,aAAa,GAAG;GAClB,MAAM,MAAM,IAAI,aAAa;GAC7B,IAAI,MAAM,GACR,YAAY;QACP;IAEL,MAAM,SADQ,IAAI,KAAK,IAAI,CAAC,KAAK,EAAE,KACb,KAAK,IAAI,OAAO,EAAE,IAAI,KAAK,IAAI,KAAK,KAAK,IAAI,MAAM,EAAE;IAC3E,SAAS,IAAI,MAAO;IACpB,SAAS,IAAI,OAAQ;GACvB;EACF;EACA,MAAM,SAAS,OAAO;EACtB,MAAM,eAAe,IAAI,QAAQ,UAAU,KAAK,IAAI,KAAK,KAAK,IAAI,QAAQ,cAAc,CAAC;EACzF,MAAM,SAAS,eAAe,eAAe,YAAY;EACzD,MAAM,SAAS,eAAe,eAAe,YAAY;EACzD,MAAM,KAAK,OAAO;EAClB,MAAM,KAAK,OAAO,IAAI,SAAS;EAE/B,IAAI,aAAa,KAAK,GAAG,GAAG,KAAK,GAAG,CAAC;EACrC,IAAI,UAAU,GAAG,GAAG,MAAM,IAAI;EAG9B,IAAI,KAAK;EACT,IAAI,UAAU,IAAI,EAAE;EACpB,IAAI,OAAO,OAAO;EAClB,IAAI,MAAM,SAAS,QAAQ,SAAS,MAAM;EAE1C,IAAI,UAAU;EACd,KAAK,IAAI,IAAI,GAAG,KAAK,UAAU,KAAK;GAClC,MAAM,QAAS,IAAI,WAAY,KAAK,KAAK;GACzC,MAAM,IAAI,IAAI,OAAO,OAAO,OAAO;GACnC,MAAM,IAAI,IAAI,KAAK,IAAI,KAAK;GAC5B,MAAM,IAAI,IAAI,KAAK,IAAI,KAAK;GAC5B,IAAI,MAAM,GAAG,IAAI,OAAO,GAAG,CAAC;QACvB,IAAI,OAAO,GAAG,CAAC;EACtB;EACA,IAAI,UAAU;EACd,IAAI,YAAY,OAAO;EACvB,IAAI,KAAK;EAGT,IAAI,UAAU,QAAQ,GAAG,QAAQ,CAAC;EAClC,IAAI,OAAO,QAAQ,IAAI,GAAI;EAC3B,SAAS,SAAS,SAAS,UAAU,KAAK;EAE1C,IAAI,QAAQ;CACd;CAEA,SAAS,KAAK,KAAa;EACzB,IAAI,CAAC,WAAW;EAChB,UAAU,gBAAgB,IAAI,IAAI,KAAK,KAAK,KAAK,MAAM,eAAe,KAAM,GAAI;EAChF,cAAc;EACd,KAAK;EACL,KAAK;EACL,QAAQ,sBAAsB,IAAI;CACpC;CAEA,SAAS,aAAa;EACpB,UAAU,IAAI;EAEd,IAAI,UACF,IAAI,SAAS,UAAU,UAAU,SAAS,KAAK,CAAC,WAAW;OACtD,IAAI,aAAa,GACtB,IAAI,YAAY;OAEhB,IAAI;EAEN,QAAQ,IAAI;EACZ,QAAQ,IAAI;EACZ,aAAa;GAAE,GAAG;GAAG,GAAG;EAAE;EAC1B,KAAK;CACP;CAEA,UAAU;CAEV,OAAO;EACL,IAAI,UAAU;GACZ,OAAO;EACT;EAEA,UAAU,QAAqB;GAC7B,cAAc;GACd,SAAS,EAAE,GAAG,cAAc,QAAQ;EACtC;EAEA,QAAQ,MAAqB;GAC3B,WAAW;IAAE;IAAM,SAAS;GAAE;GAE9B,aAAa;GACb,IAAI,CAAC,WAAW,WAAW;EAC7B;EAEA,WAAW,KAAsC;GAC/C,aAAa;EACf;EAEA,OAAO;GACL,YAAY;GACZ,IAAI,CAAC,WAAW,WAAW;EAC7B;EAEA,OAAO,UAAkB;GACvB,IAAI,aAAa,MAAM;GACvB,OAAO;GACP,UAAU;GACV,IAAI,CAAC,WAAW,KAAK;EACvB;EAEA,QAAQ;GACN,IAAI,aAAa,CAAC,KAAK;GACvB,YAAY;GACZ,cAAc;GACd,QAAQ,sBAAsB,IAAI;EACpC;EAEA,OAAO;GACL,YAAY;GACZ,IAAI,OAAO,qBAAqB,KAAK;GACrC,QAAQ;EACV;EAEA;CACF;AACF"}
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.0",
3
+ "version": "2.1.2",
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.3",
43
+ "@veltra/desktop": "^1.6.3",
35
44
  "@veltra/icons": "^1.5.0",
36
- "@veltra/styles": "^1.6.0",
37
- "@veltra/utils": "^1.6.0",
45
+ "@veltra/styles": "^1.6.3",
46
+ "@veltra/utils": "^1.6.3",
38
47
  "vue": ">=3.5.41"
39
48
  }
40
49
  }
@@ -1,8 +1,14 @@
1
1
  import { describe, expect, it, vi } from 'vitest'
2
- import { nextTick } from 'vue'
3
-
4
- import type { AiChatEmits } from '../../types'
5
- import type { ChatTool, ChatTransport, ChatTransportHandlers, ChatTransportRequest } from '../types'
2
+ import { nextTick, reactive } from 'vue'
3
+
4
+ import type { AiChatEmits, AiChatProps } from '../../types'
5
+ import type {
6
+ ChatMessage,
7
+ ChatTool,
8
+ ChatTransport,
9
+ ChatTransportHandlers,
10
+ ChatTransportRequest
11
+ } from '../types'
6
12
  import { useChat } from '../use-chat'
7
13
 
8
14
  const createEmit = () => vi.fn() as unknown as AiChatEmits & ReturnType<typeof vi.fn>
@@ -529,4 +535,36 @@ describe('useChat', () => {
529
535
  expect(requests).toHaveLength(3)
530
536
  expect(chat.running.value).toBe(false)
531
537
  })
538
+
539
+ it('受控 v-model:messages:父级回显快照不会冲掉流式中的 assistant 消息', async () => {
540
+ // 模拟受控父级:收到 update:messages 后在后续微任务把快照写回 props(v-model 回显路径)。
541
+ // 时序上回显必然晚于本地 push assistant 占位(同一同步块内),还原真实场景的竞态
542
+ const props = reactive<AiChatProps>({
543
+ transport: async (_req, handlers) => {
544
+ // 让回显先于文本到达(与真实父级 patch 时序一致)
545
+ await Promise.resolve()
546
+ handlers.onTextDelta('回答')
547
+ }
548
+ })
549
+ const emit = ((event: string, payload: unknown) => {
550
+ if (event === 'update:messages') {
551
+ const value = payload as ChatMessage[]
552
+ void Promise.resolve().then(() => {
553
+ props.messages = value
554
+ })
555
+ }
556
+ }) as unknown as AiChatEmits
557
+
558
+ const chat = useChat({ props, emit })
559
+ chat.send('hi')
560
+
561
+ await vi.waitFor(() => {
562
+ expect(chat.messages.value[1]?.status).toBe('done')
563
+ })
564
+
565
+ expect(chat.messages.value.map((m) => m.role)).toEqual(['user', 'assistant'])
566
+ expect(chat.messages.value[1].content).toBe('回答')
567
+ // 最终同步给父级的快照也包含 assistant 消息
568
+ expect(props.messages?.some((m) => m.role === 'assistant' && m.content === '回答')).toBe(true)
569
+ })
532
570
  })
@@ -102,9 +102,15 @@ export function useChat(options: UseChatOptions) {
102
102
  { immediate: true }
103
103
  )
104
104
 
105
- /** 向父组件同步消息快照(流式 delta 过于频繁,不同步,只在关键节点同步) */
105
+ /**
106
+ * 向父组件同步消息快照(流式 delta 过于频繁,不同步,只在关键节点同步)。
107
+ * 必须经 setter 赋值触发 emit,而非直接 emit 切片:setter 让本地数组与下发的
108
+ * 快照保持同一引用,受控父级(v-model:messages)把快照回写到 props 时,
109
+ * useModel 同步回来的仍是这个数组(流式期间新增的 assistant 占位已 push 在其上);
110
+ * 若直接 emit 切片,本地数组与快照是两个对象,回显的旧快照会把本地新增的消息冲掉。
111
+ */
106
112
  const snapshot = () => {
107
- emit('update:messages', messages.value.slice())
113
+ messages.value = messages.value.slice()
108
114
  }
109
115
 
110
116
  /**