Deleting a character removed its bound lorebook unconditionally, even when other characters, extra lorebooks, the global selection or a persona still used it. The backend no longer deletes lorebooks. deleteCharacter decides from the list the user acted on, before the refresh, and removes a candidate through deleteWorldInfo only when nothing else references it; characters that fail to delete still count as users. Chat-level lorebooks and unreadable cards are not scanned. Fixes #333. Validation: pnpm run check.
8.4 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 找到的历史错误目录。
- 绑定的世界书按 §10 处理。
这意味着 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。
10. 角色世界书
角色使用 data.extensions.world 绑定的那本世界书。卡内的 character_book 只是它的副本,在保存和导出时刷新;两者不一致是正常的,不算冲突。
接管内嵌书:
- 只在卡片到达时处理:导入、替换、“导入角色卡的世界书”。规则在
CharacterService::adopt_embedded_lorebook,前端的询问和刷新在world-info.js的completeCharacterLorebookAdoption。 - 绑定只在接管或用户手动链接时改变。替换时先写回原绑定,再接管;原来没有绑定或旧卡读不出时,写回空绑定。
- 新书以
character_book.name命名,没有名字时用<角色名>'s Lorebook。名字被占用时依次用名字 (1)、名字 (2)……,遇到条目完全相同的书就直接复用。 - 按角色当前在用的书处理。“本地版本”指名字是新书原名或其编号副本的那本书。
- 内容相同,且是本地版本:不改动。
- 内容不同:询问一次;“更新当前”只对本地版本提供。
- 其余情况:链接分配到的书,必要时新建。
删除角色:
- 后端只删角色,不删世界书。
- 前端
deleteCharacter只删除没有其他使用者的绑定世界书。- 使用者包括其他角色的绑定和附加世界书、全局启用的世界书、Persona 的世界书。
- 删除失败的角色仍算使用者。判断在刷新列表之前完成。
- 不检查聊天级世界书,也不检查读不出的角色卡。