跳转至

第 3 章:领域模型与持久化 —— Thread 不是一张表

3.1 四个核心对象

Jan 的聊天领域可以先压缩成四个对象:

classDiagram
  Thread "1" o-- "0..*" ThreadMessage : contains
  Thread "1" o-- "0..*" ThreadAssistantInfo : pins
  ThreadAssistantInfo --> ModelInfo : selects
  Model --> ModelArtifact : downloads
  ThreadMessage --> Attachment : references

  class Thread {
    id
    title
    assistants[]
    metadata
    created / updated
  }
  class ThreadMessage {
    id
    thread_id
    role
    content[]
    status
    metadata
  }
  class ThreadAssistantInfo {
    id
    name
    instructions
    model
    tools[]
  }
  class Model {
    id / name / engine
    sources[]
    settings
    parameters
    metadata
  }

这些类型在 core/src/types 中定义,再被 Web、extension 和 Rust JSON 边界共同消费。ThreadMessage.content 不是简单字符串,而是 text、reasoning、image、audio、video、tool_call 等多种 part 的数组;这解释了为什么 UI 需要 convertThreadMessagesToUIMessages 一类适配器。

3.2 Thread 与 Message 的职责分离

Thread 保存会话索引和上下文元数据:标题、当前模型、助手、项目关系、favorite 等。Message 保存可追加的事件结果:角色、内容、状态、错误码、tool call 和附件引用。

这让标题和模型切换可以修改 thread.json,而流式文本不断变化时只需要修改 messages.jsonl。一个长对话不必反复重写所有线程元数据。

3.3 桌面端的文件布局

threads/commands.rsthreads/utils.rs 可以还原出桌面存储的结构:

<Jan data folder>/
├── data/
│   ├── <thread-id>/
│   │   ├── thread.json
│   │   └── messages.jsonl
│   └── ...
├── assistants/
│   └── <assistant-id>/assistant.json
├── models/ / downloads/ ...
├── mcp_config.json
└── logs/

Thread 创建时 Rust 生成 UUID、建立目录并写 thread.json。Message 创建时以 JSON Lines 追加到文件;修改和删除会读取整个文件、替换数组后重新写回。

3.4 为什么 JSONL 需要 per-thread lock

create_message 可以 append,看似不需要锁;但 modify_messagedelete_message 会重写整个文件。如果两个操作并发发生,可能出现如下竞态:

T1 read [m1, m2]                 T2 read [m1, m2]
T1 update m2 → write [m1, m2']
                                 T2 delete m1 → write [m2]

最终 T1 的更新丢失。helpers.rsOnceLock<Mutex<HashMap<thread_id, Arc<Mutex<()>>>> 建立全局按线程锁;每次 create/modify/delete message 都在同一个锁上串行化。

这是一种很实用的折中:不用为每个线程建立数据库事务,也不用把整个应用的所有消息写入全局锁;代价是进程崩溃时仍可能留下半写文件,需要测试和修复策略兜底。

3.5 移动端为什么换 SQLite

threads/db.rs 只在 Android/iOS 编译,初始化 jan.db,建立 threadsmessages 表、外键和索引。桌面端文件布局更容易导入、备份和人工检查;移动端需要更稳定的随机访问、并发和沙盒路径,因此选 SQLite。

桌面:commands → helpers → thread.json / messages.jsonl
移动:commands → db → SQLite pool → threads / messages

上层 command 名称尽量保持一致,平台差异藏在 should_use_sqlite() 和条件编译后面。这是一个“相同领域协议、不同存储适配器”的典型例子。

3.6 前端的乐观持久化

useMessages.addMessage 先更新 Zustand,再异步调用 messages().createMessage()。用户会立即看到自己的消息,不需要等磁盘 IPC 返回;成功后再用后端返回的实体替换本地对象。

这个模式提高了交互速度,也引入了失败可见性问题:如果持久化失败,当前 UI 可能仍然有消息。代码选择记录错误,而不是自动回滚,这说明产品更重视“不要吞掉正在生成的内容”。对恢复逻辑而言,应该在错误提示、重新加载和诊断日志中补上证据链。

3.7 分支不是后端树

Jan 的 message-branching.ts 在 Web 层维护消息分支、parent id、siblings 和 active path。它不会把 Rust 存储变成树形数据库,而是把分支信息写进消息 metadata/ID 关系,再在 UI 中计算当前路径。这样后端只需保存 JSON,复杂的“从哪个节点继续”逻辑集中在聊天页面,易于迭代但也提高了前端状态复杂度。