feat(mascot): 吉祥物「堇喵」形象实验室与展馆重构 - #245
Conversation
- 新增 mascot 引擎,支持参数化眼环生成、Catmull-Rom 身体几何与物理姿态机 - 实现 36 套动作与情绪体系,覆盖生命周期、情绪反应与代理状态 - 新增 /lab/mascot 页面与展示馆组件,支持互动舞台、自旋与表情陈列 - 注册 mascot 实验室条目并生成路由定义
- 舞台改为深色剧场布景(锥形顶光、台面光斑、台口线、暗角),sticky 常驻视口 - 布局改双栏:左侧舞台 + 右侧 Segmented 分段目录,hover 即预览、点击固定 - 新增 AI 消息协议入口:舞台内 JSON 输入实测,未知 emotionId 回退待机,tips 覆盖对白气泡 - 组件拆分为 MascotStage/MascotTheater/EmotionGridItem/MascotSdkSection 与 useMascotExhibit 状态机 - 对白气泡 keyframes 入 styles.css,SDK 协议区移至舞台布局下方全宽收尾
| private buildDOM(): void { | ||
| const svg = this.mk("svg", { viewBox: "0 0 260 260" }) as SVGSVGElement; | ||
| svg.style.width = "100%"; | ||
| svg.style.height = "100%"; |
There was a problem hiding this comment.
[轻微] pointerMoveHandler 形参类型为 MouseEvent,但 addEventListener 监听的是 pointermove 事件,触发时实际传入 PointerEvent。PointerEvent 继承自 MouseEvent 故运行时无影响,但类型标注与监听事件不一致。
💡 修复计划 (Coding Plan)
将形参类型改为 PointerEvent:this.pointerMoveHandler = (e: PointerEvent) => {。
| interface MascotSdkSectionProps { | ||
| pinnedDef: EmotionDef; | ||
| } | ||
|
|
There was a problem hiding this comment.
[中等] handleCopyJson 内的 setTimeout 未保存 id、亦未在卸载时清理。1) 用户在 1.5s 内连续点击,第一次的 timeout 会提前把 copied 重置为 false,导致第二次点击的反馈时长被截断;2) 组件卸载后该 setTimeout 仍会触发 setState(React 18 静默忽略但仍是隐患)。
💡 修复计划 (Coding Plan)
用 useRef 保存 timeout id,并在新一轮点击与组件卸载时 clearTimeout:const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); const handleCopyJson = () => { if (timeoutRef.current) clearTimeout(timeoutRef.current); copyText(JSON.stringify({ emotionId: pinnedDef.id, tips: pinnedDef.desc })); setCopied(true); timeoutRef.current = setTimeout(() => setCopied(false), 1500); }; useEffect(() => () => { if (timeoutRef.current) clearTimeout(timeoutRef.current); }, []);
|
|
||
| if (def.gaze !== false) { | ||
| const w = now / 1000; | ||
| pose.left.lookX += 1.4 * Math.sin(0.42 * w) + 0.5 * Math.sin(1.0 * w); |
There was a problem hiding this comment.
[轻微] render() 中相邻两条注释属于「为什么这样做」的设计论证,违反仓库「注释只写代码无法自表达的信息」规则:
// 脸部正面化:五官只随注视微移,不随自旋 yaw 滑动// 整脸统一淡出:转过侧面时脸渐隐、背面隐藏,杜绝「单眼+错位嘴」的残缺中间帧
💡 修复计划 (Coding Plan)
删除这两条设计意图注释;如确需保留请合并到模块顶部 JSDoc 或独立设计文档,避免在函数体内嵌入 why/历史决策说明。
- MascotLab 与 MascotTheater 的 import 顺序不符合 organizeImports 规则,导致 CI Biome 检查失败 - 按 Biome safe fix 调整 import 分组与命名导入排序
- 舞台卡拆为场景区与观众席控制区,光斑不再落在按钮与输入框区 - 顶光光束改为 conic 楔形软边渐变,替代 clip-path 硬边梯形,近台面渐隐 - 台面光斑双层(热核+柔边)正对猫脚,新增台口溢光与沿口发丝线 - 后墙冷紫环境光、地板受光层与两侧台翼暗化,替代全卡暗角 - 光束内新增上浮微尘动效(mascot-dust),motion-reduce 下隐藏 - 对白气泡改暖纸色并加暖描边,融入光环境
- 移除 previewId 覆盖链路,舞台只认点击固定;悬停仅起动缩略卡本地动画 - 消除扫过目录时主舞台连切表情与对白气泡重播的噪声 - 同步展馆简介与注释文案(悬停即预览改为点击即演) Release-As: v2.8.16
10a08f2 to
99730ba
Compare
| const mascotRef = useRef<Mascot | null>(null); | ||
| const tipsRef = useRef(onTips); | ||
| tipsRef.current = onTips; | ||
|
|
There was a problem hiding this comment.
[中等] tipsRef 在 setEmotion/handleAIMessage 全流程中从未被读取,导致 onTips 回调永远不会触发;上方组件注释承诺的「onTips 在 handleAIMessage 带台词时回调」并未生效。
💡 修复计划 (Coding Plan)
在 handleAIMessage 内部通过 tipsRef 调用回调:
handleAIMessage: (msg) => {
const id = msg.emotionId?.trim();
const resolved = id && id.length > 0 ? id : "00";
mascotRef.current?.setEmotion(resolved);
tipsRef.current?.(msg.tips);
return { emotionId: resolved, ...(msg.tips !== undefined && { tips: msg.tips }) };
},
若不需要 onTips 则直接删除 tipsRef 相关两行。
| } | ||
|
|
||
| /** 当前自旋偏航角 (rad);无自旋时为 0 */ | ||
| private spinYaw(now: number): number { |
There was a problem hiding this comment.
[轻微] destroy() 仅从 DOM 移除 svg 与事件监听,未清空 confettiPieces 数组;数组中保存的 SVGElement 引用阻止 GC,导致脱离 DOM 的节点引用残留形成内存泄漏。
💡 修复计划 (Coding Plan)
在 destroy() 中先移除并清空粒子再继续清理:
destroy(): void {
this.stop();
for (const p of this.confettiPieces) p.el.remove();
this.confettiPieces.length = 0;
if (this.clickHandler) this.svg.removeEventListener("click", this.clickHandler);
if (this.pointerMoveHandler) this.svg.removeEventListener("pointermove", this.pointerMoveHandler);
this.svg.remove();
this.destroyed = true;
}
| ref, | ||
| className, | ||
| }: MascotStageProps) { | ||
| const hostRef = useRef<HTMLDivElement>(null); |
There was a problem hiding this comment.
[轻微] 构造 useEffect 依赖数组为空,闭包内的 frozen/onClick/onPet 仅在挂载时生效。biome-ignore 注释解释了 emotion 走 setEmotion,但 frozen/onClick/onPet 同样存在陈旧闭包问题,父组件后续 prop 变更不会反映到 Mascot 实例。
💡 修复计划 (Coding Plan)
要么在 Mascot 类上新增 setFrozen/setOnClick/setOnPet 等 setter 后用独立 effect 驱动;要么把 frozen/onClick/onPet 加入依赖数组并在清理时 destroy 旧实例重建:
useEffect(() => {
if (!hostRef.current) return;
mascotRef.current?.destroy();
const m = new Mascot(hostRef.current, { emotion, frozen, onClick, onPet });
mascotRef.current = m;
return () => { m.destroy(); mascotRef.current = null; };
}, [frozen, onClick, onPet]);
| this.def = EMOTION_MAP.get(opts.emotion ?? DEFAULT_EMOTION_ID) ?? Mascot.FALLBACK; | ||
| this.onPetHandler = opts.onPet; | ||
| this.buildDOM(); | ||
| this.setEmotion(this.def.id); |
There was a problem hiding this comment.
[轻微] setEmotion() 切换 emotion 时不清空 blinkQ,前一个 emotion 排队的眨眼关键帧仍按原 at 时间触发;切换到 blinkMs 为 null 的 emotion(如 Sleep/Standby)后仍可能出现不合时机的眨眼。
💡 修复计划 (Coding Plan)
在 setEmotion 起始处清空 blinkQ:
setEmotion(id: string): void {
const def = EMOTION_MAP.get(id) ?? Mascot.FALLBACK;
const now = performance.now();
const prevId = this.def.id;
this.blinkQ.length = 0;
// ...existing code
}
| } | ||
|
|
||
| /* ----- 对外 SDK ----- */ | ||
|
|
There was a problem hiding this comment.
[轻微] initInteractions 中 lastPointerPos 初始为 {0,0}、lastPetCheck 初始为 0;用户首次将指针移入头部区域 (vx 75-185, vy 30-125) 的 pointermove 会以原点计算出超过 35 的累计距离,并因 now - 0 > 250 恒为真触发一次计划外的 pet() 与 onPet 回调。
💡 修复计划 (Coding Plan)
在构造时初始化 lastPetCheck 为当前时间,阻止首次 move 命中阈值;或者在 handler 中跳过首帧:
// 构造里
this.lastPetCheck = performance.now();
// 或在 handler 起始:
if (this.lastPetCheck === 0) { this.lastPetCheck = now; this.lastPointerPos = { x: vx, y: vy }; return; }
- 新增 #26 Ciallo 情绪动作,右爪高举额角对齐 (∠ 经典敬礼手势 - 眼型与嘴形精准对齐 (∠・ω< )⌒★ 颜文字(左星眸 · + 右眨眼 < + 猫猫 ω 嘴) - 对白气泡接入经典 Ciallo~(∠・ω< )⌒★! 问候语 - 更新吉祥物表情总数至 37 套
- 移除标题中括号补语与冗余描述 - 示例变量 ball 更名为 mascot,与类名一致
- 示例卡接入 useShikiHighlight 语法高亮与逐卡复制按钮 - 标题从编号列表改为卡片头标题栏,对齐全站代码块视觉
- FencedCodeBlock 实现上提到 code-preview 模块并更名为 CodeCard,支持 title 与 className - 原 FencedCodeBlock 保留为懒加载入口薄壳,三个消费方引用路径不变 - mascot SDK 示例弃用私有复制实现接入公共组件
- 删除私有 CodeCard 复制实现,改用 code-preview 公共组件
- markdown-components、admin-mcp、diagram 三处消费方直接引用 CodeCard - 外边距 my-6 改由调用方经 className 传入 - 同步更新测试 mock 路径与注释中的组件名
- SDK 全宽区移到舞台与目录的两列容器之外,sticky 作用域随之收敛 - 目录滚完舞台随容器滚出,页面底部 SDK 代码卡不再被钉住的舞台压住 - 移除 SDK 区失去宿主后的 col-span-2 残留类
- 引擎包化为 workspace 包 @violet/mascot(引擎/React/Web Component 三入口) - AI 消息协议定型 JSON Schema 并扩三种驱动通道(JS ref/CustomEvent/postMessage) - 分期 T1 包化、T2 协议与通道、T3 分发文档、P2 表情包数据化与独立仓库
- 按钮 active 态与发送按钮、焦点环统一改为舞台暖金色系 - AI 输入区加协议小签标题,容器化边框聚焦反馈与非法 JSON 红框 - SVG 设 overflow visible,confetti 粒子飞出 viewBox 不再被拦腰裁断
- 分组顺序调整为情绪反应首位,默认激活情绪反应与 Ciallo~ - 重构观众席控制区为沉浸式工具栏与现代 AI 命令行控制台 - 支持 Enter 回车直接发送 AI 协议驱动指令
- 观众席控制区锁定标题、描述、工具栏与输入栏行高,切换表情 0px 抖动 - 目录网格添加稳定 min-height 容器,消除切换不同条目数分组时页面高度骤缩 - 单卡锁定 h-34 统一尺寸,优化高亮激活色调
- 动作按钮文案统一为 2 字动词(转一圈改为转圈、摸头、撒花、弹跳) - 巡演按钮文案固定为 2 字(巡演),消除激活时字数变化引起的布局微抖 - 新增重播按钮(重置表情入场动画与重新弹出对白气泡)
- 移除刺眼的琥珀黄色调,改用纯净高级的中性暗色玻璃拟态 - AI 发送按钮改用高对比度白底胶囊,输入框聚焦采用柔和中性微光 - 目录卡片激活态统一回归全站 primary 主色设计系统
- 展馆级控制(重播/巡演)上提到标题栏右侧,与当前内容状态绑定 - 中央操作栏收敛为纯粹的 4 个猫咪物理互动手势(摸头/转圈/撒花/弹跳) - 消除播放控制与瞬态动作混杂导致的交互心智混乱与视觉失衡
- 支持格式化多行 JSON 与长台词输入,提供 max-h-28 内部平滑滚动 - 底部集成操作栏与独立发送按钮,明确 Enter 发送与 Shift+Enter 换行心智
- 巡演按钮激活态以 ring-inset + 背景替代 border,开关切换不再产生 2px 宽度跳变 - 全部按钮 transition-all 收窄为 color/background-color/box-shadow 精确过渡,去除按压缩放反馈 - hover 过渡即时化(hover:duration-0):悬停即亮、移出缓退,快速划过不再半程渐变闪烁 - 顶栏胶囊按钮间距收窄为 gap-0,重播与巡演间 hover 死区从 5px 降到 1px - 按钮统一显式高度并补 focus-visible 焦点环,巡演按钮补 aria-pressed 语义 - 删除 StageButton 无调用方的 border 型 active 分支,防复刻复发
- 分组标题定为 喜怒哀乐 / 猫猫日常 / 工作模式 - 目录分段器与舞台 HUD 状态签同源同步(GROUP_LABEL)
- 38 套表情按展示顺序重排为 #00-#37 连续编号,消除历史插入留下的断档 - Ciallo 置顶固定序号同步更新 - 实验室入口描述修正表情计数为 38 套
- 五段链路缺口分析:现状仅有注入面,采集/适配/传输全缺 - 协议 v2 双层设计:语义状态层为主、emotionId 覆盖逃生舱、ttlMs 防 zombie、seq/ts 支持重连去重 - Codex 接入方案:hooks 过程粒度为主、notify 回合级兜底提供 tips 台词 - 本地传输形态:hook 脚本写状态文件 + Vite SSE 中间件 + EventSource 消费 - 交付分期 T1 协议/T2 传输/T3 文档,远程形态与 MCP 主动汇报列入 Out of Scope
- 新建 workspace 包 @violet/agent-status(subpath: 协议与状态机 / transport SPI) - AgentStatusMessage 协议 v2:语义状态层(thinking/executing/error/done/idle) + emotionId 覆盖逃生舱 + seq/ts 可靠性字段 - AgentStatusStore:per-agent seq 去重、ts 过期判定、TTL 到期自动回 idle(防 agent 崩溃挂死) - v1 兼容注入(inject):emotionId+tips 输入继续有效,独立 seq 轴 - 25 项单测覆盖映射/TTL/去重/过期/v1 兼容,node 环境运行证明零 UI 依赖
- 新增 subpath ./transport/sse:订阅 endpoint JSON 流,合法 v2 消息分发订阅者 - 多订阅者广播,最后一个退订关闭连接 - 去重与过期不在通道层(EventSource 原生重连由 store 的 seq/ts 兜底) - 注入 fake EventSource 单测,node 环境可测不依赖浏览器全局
- vite-plugins/agent-status-dev:watchFile 轮询读 .agent-status.json(容器 bind mount 下 fs 事件不可靠,轮询兜底) - SSE endpoint /api/dev/agent-status 挂在 proxy 之前拦截,仅 dev server 生效不进生产路由 - 连接即推当前快照(重连方恢复最新状态,旧消息消费端 seq 去重),25s 心跳注释行 - .gitignore 排除状态文件(dev 工作区,适配器写入)
- useAgentStatus hook:订阅 dev SSE,经 AgentStatusStore 去重/过期/TTL,system 合成初始态不接管 - 舞台表情由 agent 消息接管(语义 state 走默认映射,emotionId 可覆盖),无流量时保持用户固定表情 - HUD 顶签显示 agent 徽标(来源+状态),data-emotion 暴露当前表情供可观测
- engine 四文件(expressions/mascot/body/eyes)与 MascotStage 迁入 workspace 包,git mv 保留历史 - subpath exports:"." 引擎与表情目录、"./react" MascotStage;lab feature 改消费包,本地 engine 目录净移除 - v1 协议类型独立 protocol.ts(AIMessage/AIMessageResult)并补全 TSDoc,MascotHandle/MascotStageProps 同步补齐 - GROUP_LABEL 留 lab 落 useMascotExhibit,目录分段器与舞台 HUD 状态签共用单源,消除双份文案
- DEFAULT_EMOTION_BY_STATE 与 resolveEmotionId 移出 agent-status(协议包不应持有具体 mascot 表情编号知识) - mascot 包新增 agent-state.ts 承载映射(idle 回退锚定 DEFAULT_EMOTION_ID 单源),对应用例随迁 - 依赖方向收敛为 mascot → agent-status 单向
- expressions.ts 拆为 types.ts(类型契约)/palette.ts(主题资产)/emotions.ts(38 套数据+索引),净迁移不留壳 - 类型契约与内容资产解耦:adapter 侧 import type 不再拖入千行数据,P2 表情包自定义获得替换缝 - 修正头部过期分段注释(重排前旧编号)并补全 Anim 族/EmotionDef/公开方法 TSDoc,单位与取值语义从实现反推
- mascot.ts/MascotStage/agent-state/index 的 expressions 导入按类型/数据/配色三路切换 - index.ts 公开面不变(类型与常量全量再导出),lab 消费方零改动
- mascot.ts 拆出 math.ts(数学与弹簧物理)/color.ts(颜色工具)/pose.ts(Pose 运行时态与合成纯函数),类体保留 - types.ts 升格 types/ 目录按域分文件:animation(波形契约)/pose(静态姿态层)/emotion(表情定义) - 全部导出符号补 TSDoc:函数带 @param/@returns(单位/区间/物理语义),常量与接口带职责注释 - 单文件单一变更理由:数值算法/颜色/姿态合成/渲染状态机各自独立可测
- math.ts/color.ts 自 engine/ 移入 lib/(无猫猫语义的通用工具与引擎域文件分置,对齐 features/*/lib 惯例) - engine/ 仅留渲染域文件:状态机/姿态合成/几何/契约
- export-spritesheet:headless Chrome 页面内驱动包引擎 tick 时间轴,按 9 行契约采样合成 1536x1872 atlas 回传 webp - 引擎 import 走 Vite 包源路径;两步就绪探针(先等导航落 origin,再 import 本身作模块链探针) - 调试端口预清理(僵尸 headless Chrome 会让 CDP 永不就绪),导出后立即退出 - install.sh 一键导出+安装到 ~/.codex/pets/jin-miao,支持 --uninstall;产物目录入 gitignore - 已实测:契约校验(尺寸/未用格全透明)与本地安装均通过 Release-As: v2.8.16
be78c1d to
249c31e
Compare
| ts: this.now(), | ||
| emotionId: input.emotionId, | ||
| ...(input.tips !== undefined && { tips: input.tips }), | ||
| ttlMs: 0, |
There was a problem hiding this comment.
[中等] inject() 调用 this.lastSeqByAgent.set(agent, msg.seq),把注入 seq 写入 accept 的去重轴,与类级 docstring(第 123 行)"(内部独立 seq 轴,不与任何 agent 的外部 seq 冲突)"的事实描述相矛盾:同 agent 下,先 inject 再 accept 的真实消息若 seq <= injectSeq 会被 stale-seq 拒绝。现有测试 "inject 不阻塞其他 agent 的 v2 消息" 仅覆盖不同 agent 路径,未覆盖同 agent 场景,留给调用方一个与文档不符的陷阱。
💡 修复计划 (Coding Plan)
两种修法任选其一,先与作者确认意图:
- 若 inject 真要独立 seq 轴(只跟自己争、不污染 accept),删掉第 190 行 this.lastSeqByAgent.set(agent, msg.seq),lastSeqByAgent.set 只在 accept 路径里更新;同时为 inject 单独加一个 'agent 已被 inject 占用,后续 accept 同 agent 且 seq < injectSeq 走 stale-seq' 的单元测试,确认预期。
- 若 inject 故意要"占用"同 agent 的去重轴,把第 123 行的类级 docstring 改为"inject 与同 agent 的 accept 共享去重轴,会拒绝 seq <= injectSeq 的真实消息",同时把 inject 方法级 JSDoc 的 "@PARAM agent" 提示调用者使用默认 "manual" agent 以避免冲突,并补一个 store.test.ts 用例:先 inject({emotionId:"x"}, "codex"),再 accept({agent:"codex", seq:1, ...}) 必须返回 {applied:false, reason:"stale-seq"}。
概要
新增 violet 吉祥物「堇喵」(Cat-Mochi) 形象实验室
/lab/mascot,并按人性化体验要求重构展馆排版。handleAIMessage({emotionId, tips})——舞台内 JSON 输入框可直接实测,未知 emotionId 平滑回退待机,tips 覆盖对白气泡;原生实例化示例与实际 API 一致(移除编造的idle参数)MascotStage(引擎 React 宿主 + handle 桥)/MascotTheater(剧场舞台卡)/EmotionGridItem(陈列卡)/MascotSdkSection(协议说明)/useMascotExhibit(pinned+preview+巡演+台词代际状态机)验证
pnpm typecheck/biome lint/biome format全绿