6.8 KiB
角色身份契约现状
本文档记录 TauriTavern 当前已经落地的角色身份契约,重点覆盖角色头像文件名、聊天目录、旧错误目录兼容、以及后续开发时不能误改的边界。
目标读者:后续继续修改角色导入、编辑、聊天保存、聊天导出、Agent profile/skill 导入链路的开发者。
1. 范围与结论
TauriTavern 前端来源于 SillyTavern。角色相关接口必须遵循上游契约:
/api/characters/get返回和前端传回的avatar_url是一个精确的头像文件名,不是 URL,不是路径,也不是可被解析的资源地址。
例如:
- 合法身份值:
Alice#1.png - 对应 Rust 内部 character stem:
Alice#1 - 对应 canonical chat directory key:
Alice#1
因此任何角色身份链路都不能再对 avatar_url 做以下处理:
trimdecodeURIComponent/ percent decode- 去掉
?query或#hash - 按
/或\取 basename - 把它当成
/thumbnail?file=...一类资源 URL
这些变换只允许出现在后端 legacy resolver 内部,用来寻找历史版本已经错误创建的聊天目录。
2. 前端/API 边界
JS 侧的边界规则:
avatar_url表示 exact avatar filename。- 当前只接受
.png角色卡文件名。 - 文件名不能包含路径分隔符、查询/片段语义、控制字符或平台非法字符。
- 从
avatar_url得到 character stem 时,只移除最后一个.png扩展名。
核心实现位置:
src/tauri/main/services/characters/character-identity.js
持续开发约束:
- 路由、service、adapter、Agent 导入链路都应共用该身份模块。
- 不要在调用点重新写一套“宽松解析”。
- 如果调用方拿到的是资源 URL,应先回到产生该 URL 的业务上下文,不能把 URL 传进角色身份模块。
3. Rust 内部 key
Rust chat repository 当前接收的是 character stem,而不是 avatar filename。
也就是说:
- JS/API 边界:
Alice#1.png - Rust chat repository 参数:
Alice#1 - 磁盘 canonical chat directory:
default-user/chats/Alice#1/
核心实现位置:
src-tauri/crates/tt-adapter-storage-core/src/chat_directory_identity.rssrc-tauri/crates/tt-adapter-storage-core/src/repositories/file_chat_repository/chat_dir_resolver.rssrc-tauri/crates/tt-adapter-storage-userdata/src/repositories/file_character_repository/helpers.rs
持续开发约束:
- Rust resolver 的输入名虽然叫
character_name,实际语义是内部 stem/key。 - Rust resolver 不负责解析 avatar filename,也不负责解析 URL/path。
- Rust resolver 内部的 legacy candidate 生成,只是为了读取旧错误目录,不是新身份规则。
4. 聊天目录解析
当前角色聊天目录解析顺序:
- 计算 canonical key。
- 如果 alias store 已经记录该 key 的 legacy directory,并且目录仍存在,优先使用 alias。
- 如果 canonical directory 已存在,使用 canonical directory。
- 如果 canonical 不存在,尝试 safe legacy discovery。
- 如果没有 legacy 命中,返回 canonical directory。
alias 优先于 canonical 是有意设计:
- 一旦系统确认
Alice#1 -> Alice是旧错误目录,后续读写要继续落到同一个物理目录。 - 这样可以避免 canonical directory 后来出现时把历史聊天分裂成两份。
5. Legacy Discovery
legacy discovery 只服务于历史兼容。
它复现的是旧错误 normalizer 可能造成的目录名:
trim- 去掉
?或#后的内容 - percent decode 后再去 query/hash
- percent decode 后再取 basename
命中条件:
- candidate directory 必须存在。
- candidate directory 至少包含一个
.jsonl聊天文件。 - candidate 不能等于 canonical。
- 如果
characters/<candidate>.png存在,则认为该目录属于另一个真实角色,不能抢占。 - 多个 legacy candidate 同时命中时 fail-fast,返回错误,不猜测。
这条规则避免了升级时全量扫描和批量迁移,对低端 Android/iOS 更安全:只有用户实际访问相关角色时才做一次小范围解析。
6. Alias Store
alias 文件位置:
default-user/user/cache/chat_aliases_v1.json
alias key 是 Rust 内部 character stem,不是 avatar filename。
示例:
{
"version": 1,
"aliases": {
"Alice#1": {
"dir": "Alice",
"reason": "legacy-avatar-url-normalizer",
"created_at": "2026-06-06T00:00:00Z"
}
}
}
当前生产 bootstrap 会把同一个 shared alias store 注入 character repository 和 chat repository:
FileCharacterRepository::with_chat_repository(...)FileChatRepository::with_chat_aliases(...)
这样两个 repository 不再各自持有独立 alias cache,避免并发懒发现时互相覆盖 alias 文件。
生产 bootstrap 会显式创建 shared alias store 和 shared FileChatRepository,再通过 with_chat_repository(...) 注入给 character repository。new(...) 只是 isolated/single-repository 便捷构造;当同一运行时同时创建 character/chat repositories 时,必须共享同一个 FileChatRepository。
7. Rename / Delete 当前语义
角色 rename:
- 新身份使用新 canonical key。
- 如果旧聊天目录通过 canonical/alias/legacy resolver 找到,并且新 canonical 目录不存在,则尝试把旧目录 rename 到新 canonical 目录。
- 如果文件系统 rename 失败,错误直接暴露。
角色 delete:
- 删除角色卡。
- 当
delete_chats=true时,通过 resolver 找到当前身份对应的物理聊天目录并删除。 - 这包括已经确认的 alias 目录,也包括 safe legacy discovery 找到的历史错误目录。
这意味着 delete 语义偏向用户意图:“删除这个角色及其聊天”,而不是只删除 canonical directory。
8. 不支持的行为
当前不做启动期全量迁移:
- 不扫描全部
characters/和chats/。 - 不在升级时批量 rename 目录。
- 不自动合并多个可能相关的聊天目录。
- 不在 ambiguous legacy 情况下静默选择一个目录。
如需批量清理或显式迁移,应单独实现维护工具,并展示冲突项让用户确认。
9. 最容易误改的契约
后续开发时尤其不要做这些事:
- 把
avatar_url当成 URL/path 解析。 - 在路由或 service 里临时写
trim/decode/basename。 - 在 Rust resolver 里把 legacy candidate 当作新规则。
- 让 character repository 和 chat repository 在生产路径各自创建 alias store。
- 升级时递归扫描并重命名大量聊天目录。
- ambiguous legacy 时静默降级到某个目录。
正确方向是:
- 新数据严格使用 exact avatar filename -> stem -> canonical directory。
- 旧错误目录只通过 alias/lazy resolver 兼容。
- 发现歧义时 fail-fast。