TauriTavern/docs/CurrentState/CharacterIdentityContract.md
Darkatse c5fb6da7e4 fix(characters): delete a character's lorebook only when unused
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.
2026-10-16 20:02:45 -04:00

8.4 KiB
Raw Permalink Blame History

角色身份契约现状

本文档记录 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 做以下处理:

  • trim
  • decodeURIComponent / 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.rs
  • src-tauri/crates/tt-adapter-storage-core/src/repositories/file_chat_repository/chat_dir_resolver.rs
  • src-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. 聊天目录解析

当前角色聊天目录解析顺序:

  1. 计算 canonical key。
  2. 如果 alias store 已经记录该 key 的 legacy directory,并且目录仍存在,优先使用 alias。
  3. 如果 canonical directory 已存在,使用 canonical directory。
  4. 如果 canonical 不存在,尝试 safe legacy discovery。
  5. 如果没有 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. 最容易误改的契约

后续开发时尤其不要做这些事:

  1. 把 avatar_url 当成 URL/path 解析。
  2. 在路由或 service 里临时写 trim/decode/basename。
  3. 在 Rust resolver 里把 legacy candidate 当作新规则。
  4. 让 character repository 和 chat repository 在生产路径各自创建 alias store。
  5. 升级时递归扫描并重命名大量聊天目录。
  6. 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 的世界书。
    • 删除失败的角色仍算使用者。判断在刷新列表之前完成。
  • 不检查聊天级世界书,也不检查读不出的角色卡。