衣柜(D8)设计
WARDROBE
状态:📐 设计已定案,未开工 · 2026-09-14 定稿 上游:
docs/backlog.md的 D8(那行有 2026-09-12 从 ZOOT 拆出来的四条机制,本文件按它写) 相关:unified-memory-design.md(按线状态 / 覆盖层)·conventions.md(.char五段)
1. 要解决的问题(一句话)
她能说"我穿着那件黑风衣",但说不出"我今天换了件别的" —— 因为那句话在 ## 锚 里,而锚是「不可变 · 永不被修改」(char_file.py 有拦)。
实测(2026-09-12 盘点,391 个 .char):
| 事实 | 数字 |
|---|---|
| 全文含服装描述 | 276 |
写进可变段 ## 物理标签 的 |
54 |
| 其余大部分 | 在 ## 锚 的 身体: 一行里 |
所以这一片落的不是服装数据(数据早就够),是让"当前穿着"变成一个可以改、且只有一个权威的状态。
2. 已定案(别再问)
| 决定 | 选择 |
|---|---|
| 谁触发 | 用户主动:聊天页「更多」菜单里一颗「衣柜」按钮 → 打开面板手选 |
| 写在哪一层 | 按线(与「## 我 / ## 物理标签 跟线」的既定决策一致) |
| 付费方式 | 买断:花 LMD 买一次、永久拥有,之后反复穿不花钱(衣服是资产,与礼物那种消耗品区分开) |
| 槽位 | 多槽位:v1 = 外套 / 内搭 / 配饰 |
| 入口 | 聊天页「更多」菜单(#chat-more-menu) |
⚠️ 入口的语义提醒:「更多」菜单现在装的是轮级 / 一次性动作(收藏、导出、回退、清空)。换装是持久状态,所以按钮文案要说清「这会改她的当前穿着」,别让它看起来和"导出聊天记录"同级。
3. 数据模型:四方,谁是谁的权威
① 目录 GARMENTS id / 名称 / 槽位 / 价格(LMD) / 描述 / 写进标签的文本
② 拥有 per-user 买断集合 {garment_id, ...} ← 全局,跨线共享
③ 穿着 per-line sidecar {外套: id, 内搭: id, 配饰: id} ← 按线
④ 渲染 ## 物理标签 的托管条目 「穿着:…」 ← 从 ③ 生成,不是状态
⚠️ 权威是 ③,不是 ④。 理由:## 物理标签 是「可变 · 追加」的条目列表(char_file.py 的 Section.items: list[str]),深聊的自动检测也会往里追加。所以「读物理标签还原当前穿着」这条路必然会读到别人写的东西 —— 这个仓库已经栽过一次同款(归位标记只活在内容里 → 被覆盖写抹掉)。
因此:
- 写入 ③,然后重渲染 ④;
- ④ 用带前缀的托管条目(
穿着:)标识"这几条归衣柜管"; - 换装时按前缀找到旧行替换,不是往后追加 —— 否则穿十次就在她的物理标签里留十行;
- 空槽位不渲染那一行(不写「配饰:无」)—— 空行会被模型当事实引用;
- 缺
## 物理标签段的.char(如texas.char)要自动补这个段,不能假设它在(五段不是每个文件都齐,同「我段=0行 → 全写」那一类坑)。
3.1 存储位置
按线状态目录(data/users/{u}/lines/{safe(line)}/)加一个 sidecar(如 worn.json)。⚠️ 不要把状态塞进老 char 文件或覆盖层文本里再解析回来。
4. 机制①:一类可变状态只能有一个权威,且明说旧字段作废
这是 backlog 那行点名的硬要求,也是这一片最容易做错的地方。
ZOOT 的 prompt 原文(要抄的就是它):
衣着信息只能以衣柜当前状态为准,不得读取或沿用档案中的旧衣着字段。
⚠️ 只说「新的优先」不够 —— 模型会在两处之间混着引,而这不报错。 ⚠️ 我们的场景比 ZOOT 更危险:ZOOT 那三张表是空的、没有"旧字段"要作废;我们的锚里有 276 份旧衣着描述,而且锚永远不会被改。
所以注入侧必须带一段显式作废声明(措辞可在实现时定,语义必须是这两条):
- 当前穿着以「穿着:」那几行为准;
## 锚的身体:/外观描述是加入我们之前的默认装束,不得当成此刻的穿着;被问到时不要混着引。
5. 接线
| 端点 | 作用 | 抄谁 |
|---|---|---|
GET /api/market/wardrobe |
目录(按槽位分组)+ 我的拥有 + 当前穿着 | market_gifts |
POST /api/market/wardrobe/buy |
扣 LMD → 进拥有集合 | market_buy_gift |
POST /api/market/wardrobe/wear |
装到某个槽位(写 ③ → 重渲染 ④) | — |
POST /api/market/wardrobe/take-off |
卸下某个槽位 | — |
⚠️ 买与穿分开两个动作(和礼物「买 → 送」同形):买是经济行为、穿是状态行为,混成一个端点将来没法回答"她身上这件是什么时候买的"。
6. UI
聊天页「更多」菜单 →「衣柜」(sheet):
- 按槽位分列:外套 / 内搭 / 配饰,每列列出该槽位的衣服;
- 已拥有:点一下 = 穿上(当前穿的那件有选中态);
- 未拥有:显示价格,点了先买(要
confirmDialog确认花钱); - 缺段 / 没有衣柜状态时显示空态,而不是空白。
7. 验收(比「能换」更硬)
- 换装不动
## 锚—— 守卫钉住锚段在换装前后逐字节一致(不可变那段有代码拦,守卫要证明它真的拦住了衣柜这条新路); - 负例(backlog 的 done 定义):造一个「档案说 A、当前穿 B」的场景,断言注入里只有一个权威、且模型不混着引;
- 问她「你今天穿什么」 → 答的是 B;
- 缺
## 物理标签段的干员(如texas)也能正常穿、正常渲染、正常注入; - 空槽位不产生空行;穿十次后物理标签里仍然只有一行「穿着:…」。
8. v1 明确不做(写下来是为了别顺手做)
| 不做 | 为什么 |
|---|---|
从 ## 锚 迁移那 276 份旧衣着描述 |
独立第二批;而且只能迁"当前穿着"那一类,不是所有提到衣服的句子。锚的定义就是"永不被修改",大面积动它是另一件事 |
| LLM 提议 / 审批层(她提议换装 → 待审) | backlog ③ 记着 ZOOT 有这个设计,但它一行数据都没验证过;我们 v1 先只做用户主动 |
| 审计流水(谁/何时/为什么) | backlog ④;等有第二种触发源(她自己换、环境换)再加才有意义 |
| 环境联动(天气 / 世界 / 节日) | 用户明确选"自己点按钮";环境触发是 v2 的候选 |
| 多世界不同穿着 | 穿着按线,已经隐含了世界(世界挂在线上);不需要额外维度 |
9. 分批(每批都能单独上线 + 单独验收)
| 批 | 内容 | 验收 |
|---|---|---|
| D8a | 目录 + 拥有 + 买(GET/POST buy)+ 守卫 |
买一件扣 LMD、余额不足报错、重复买不重复扣 |
| D8b | 按线 sidecar + 渲染托管条目 + 注入声明(机制①) | 验收 1/2/4/5 —— 这一批是风险最高的(碰注入与 prompt) |
| D8c | 衣柜 sheet + 「更多」菜单入口 | 验收 3 |
| D8d(可选) | 她的反应句 | — |
10. 未验证的假设 / 风险
- ✅ 锚的不可变拦在哪(2026-09-14 已核准):
CharSection.immutable(char_file.py:37,锚在:103建为True)+validate_immutability(section)(:812,返回 True = 不可修改)。 ⚠️ 它是个「检查函数」,不是写入口的硬拦 —— 调用方得自己查。所以衣柜的写路径必须显式调用它,而守卫要证明的是「衣柜写不动锚」是因为它自己查了,不是因为碰巧没走到那条路。(backlog 里记的char_file.py:708是旧行号。) - ⚠️ 「穿着:」前缀会不会和深聊的物理标签自动检测撞车(它也会往同一个段追加):开工时要看
deep_chat.py那套检测写出来的条目长什么样,前缀不能撞。 - ⚠️ 注入预算:物理标签本来就在 prompt 里,多一行几乎不增加成本;但作废声明那几句话要算进 token 预算(现有注入是有配额管理的)。
- ⚠️ 经济平衡:衣服价格要和礼物、交易所的物价水平对一下,否则会出现"一件大衣 = 十束花加十点信赖"这种明显不划算的比较。