第 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.rs 与 threads/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_message 和 delete_message 会重写整个文件。如果两个操作并发发生,可能出现如下竞态:
最终 T1 的更新丢失。helpers.rs 用 OnceLock<Mutex<HashMap<thread_id, Arc<Mutex<()>>>> 建立全局按线程锁;每次 create/modify/delete message 都在同一个锁上串行化。
这是一种很实用的折中:不用为每个线程建立数据库事务,也不用把整个应用的所有消息写入全局锁;代价是进程崩溃时仍可能留下半写文件,需要测试和修复策略兜底。
3.5 移动端为什么换 SQLite¶
threads/db.rs 只在 Android/iOS 编译,初始化 jan.db,建立 threads 与 messages 表、外键和索引。桌面端文件布局更容易导入、备份和人工检查;移动端需要更稳定的随机访问、并发和沙盒路径,因此选 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,复杂的“从哪个节点继续”逻辑集中在聊天页面,易于迭代但也提高了前端状态复杂度。