Skip to content

feat(mascot): 吉祥物「堇喵」形象实验室与展馆重构 - #245

Merged
xunrua merged 35 commits into
release/2.0from
feat/lab-mascot
Aug 20, 2026
Merged

feat(mascot): 吉祥物「堇喵」形象实验室与展馆重构#245
xunrua merged 35 commits into
release/2.0from
feat/lab-mascot

Conversation

@xunrua

@xunrua xunrua commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator

概要

新增 violet 吉祥物「堇喵」(Cat-Mochi) 形象实验室 /lab/mascot,并按人性化体验要求重构展馆排版。

  • Mascot 引擎:参数化眼环几何(48 点闭合眼环库)、Catmull-Rom 身体轮廓、pose 合成(base/sequence/anims/过渡)、临界阻尼弹簧、自旋时间线(easeInOutCubic + 整圈位归零)、眨眼关键帧过冲、撒花粒子
  • 36 套表情体系:生命周期 (00-07) / 情绪反应 (10-25) / 代理工作 (30-42)
  • 展馆布局:桌面双栏——左侧深色剧场舞台 sticky 常驻视口(锥形顶光 + 台面光斑 + 台口线 + 暗角),右侧 Segmented 分段目录,hover 缩略图即实时预览、点击固定;移动端舞台在站点 header 下吸顶
  • AI 消息协议落地:handleAIMessage({emotionId, tips})——舞台内 JSON 输入框可直接实测,未知 emotionId 平滑回退待机,tips 覆盖对白气泡;原生实例化示例与实际 API 一致(移除编造的 idle 参数)
  • 组件架构拆分:MascotStage(引擎 React 宿主 + handle 桥)/ MascotTheater(剧场舞台卡)/ EmotionGridItem(陈列卡)/ MascotSdkSection(协议说明)/ useMascotExhibit(pinned+preview+巡演+台词代际状态机)

验证

  • pnpm typecheck / biome lint / biome format 全绿
  • 浏览器实测:AI 协议发送(合法 ID 切换、tips 覆盖气泡、未知 ID 回退待机)、目录点击固定同步高亮、sticky 舞台滚动常驻、聚光光效像素采样确认(光束内外 7.5 倍亮度差、光斑 4 倍、台口线清晰)

xunrua added 2 commits August 20, 2026 00:05
- 新增 mascot 引擎,支持参数化眼环生成、Catmull-Rom 身体几何与物理姿态机
- 实现 36 套动作与情绪体系,覆盖生命周期、情绪反应与代理状态
- 新增 /lab/mascot 页面与展示馆组件,支持互动舞台、自旋与表情陈列
- 注册 mascot 实验室条目并生成路由定义
- 舞台改为深色剧场布景(锥形顶光、台面光斑、台口线、暗角),sticky 常驻视口
- 布局改双栏:左侧舞台 + 右侧 Segmented 分段目录,hover 即预览、点击固定
- 新增 AI 消息协议入口:舞台内 JSON 输入实测,未知 emotionId 回退待机,tips 覆盖对白气泡
- 组件拆分为 MascotStage/MascotTheater/EmotionGridItem/MascotSdkSection 与 useMascotExhibit 状态机
- 对白气泡 keyframes 入 styles.css,SDK 协议区移至舞台布局下方全宽收尾

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI Code Review · MiniMax-M3

评审结论

新增 agent-status 与 mascot 工作区包,协议/状态机/几何引擎整体实现质量较高,但 inject 行为与类级 docstring 描述的"独立 seq 轴"存在事实性冲突。

private buildDOM(): void {
const svg = this.mk("svg", { viewBox: "0 0 260 260" }) as SVGSVGElement;
svg.style.width = "100%";
svg.style.height = "100%";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[轻微] pointerMoveHandler 形参类型为 MouseEvent,但 addEventListener 监听的是 pointermove 事件,触发时实际传入 PointerEvent。PointerEvent 继承自 MouseEvent 故运行时无影响,但类型标注与监听事件不一致。

💡 修复计划 (Coding Plan)
将形参类型改为 PointerEvent:this.pointerMoveHandler = (e: PointerEvent) => {

interface MascotSdkSectionProps {
pinnedDef: EmotionDef;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[中等] 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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[轻微] render() 中相邻两条注释属于「为什么这样做」的设计论证,违反仓库「注释只写代码无法自表达的信息」规则:

  • // 脸部正面化:五官只随注视微移,不随自旋 yaw 滑动
  • // 整脸统一淡出:转过侧面时脸渐隐、背面隐藏,杜绝「单眼+错位嘴」的残缺中间帧

💡 修复计划 (Coding Plan)
删除这两条设计意图注释;如确需保留请合并到模块顶部 JSDoc 或独立设计文档,避免在函数体内嵌入 why/历史决策说明。

xunrua added 3 commits August 20, 2026 08:14
- MascotLab 与 MascotTheater 的 import 顺序不符合 organizeImports 规则,导致 CI Biome 检查失败
- 按 Biome safe fix 调整 import 分组与命名导入排序
- 舞台卡拆为场景区与观众席控制区,光斑不再落在按钮与输入框区
- 顶光光束改为 conic 楔形软边渐变,替代 clip-path 硬边梯形,近台面渐隐
- 台面光斑双层(热核+柔边)正对猫脚,新增台口溢光与沿口发丝线
- 后墙冷紫环境光、地板受光层与两侧台翼暗化,替代全卡暗角
- 光束内新增上浮微尘动效(mascot-dust),motion-reduce 下隐藏
- 对白气泡改暖纸色并加暖描边,融入光环境
- 移除 previewId 覆盖链路,舞台只认点击固定;悬停仅起动缩略卡本地动画
- 消除扫过目录时主舞台连切表情与对白气泡重播的噪声
- 同步展馆简介与注释文案(悬停即预览改为点击即演)

Release-As: v2.8.16
const mascotRef = useRef<Mascot | null>(null);
const tipsRef = useRef(onTips);
tipsRef.current = onTips;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[中等] 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 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[轻微] 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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[轻微] 构造 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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[轻微] 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 ----- */

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[轻微] 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; }

xunrua added 13 commits August 20, 2026 09:59
- 新增 #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 主色设计系统
xunrua added 17 commits August 20, 2026 11:03
- 展馆级控制(重播/巡演)上提到标题栏右侧,与当前内容状态绑定
- 中央操作栏收敛为纯粹的 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
ts: this.now(),
emotionId: input.emotionId,
...(input.tips !== undefined && { tips: input.tips }),
ttlMs: 0,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[中等] 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)
两种修法任选其一,先与作者确认意图:

  1. 若 inject 真要独立 seq 轴(只跟自己争、不污染 accept),删掉第 190 行 this.lastSeqByAgent.set(agent, msg.seq),lastSeqByAgent.set 只在 accept 路径里更新;同时为 inject 单独加一个 'agent 已被 inject 占用,后续 accept 同 agent 且 seq < injectSeq 走 stale-seq' 的单元测试,确认预期。
  2. 若 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"}。

@xunrua
xunrua merged commit 1cf598e into release/2.0 Aug 20, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant