本文面向:想理解插件式数据源架构设计细节的开发者。
预计阅读时间:8 分钟
最终效果:理解 SourceAdapter 三方法契约、注册表模式、导入编排流程,掌握扩展新数据源的完整步骤。
为什么需要插件架构
AI 编程工具的对话数据格式千差万别。Claude Code 输出 JSONL 流式日志,Cursor 和 Trae 把对话存在 SQLite 的 KV 表里,Codex CLI 用事件流记录会话,GitHub Copilot 用 JSONL 快照文件。如果每种格式都写一套独立的导入逻辑,代码会变成一锅粥------扫描、去重、入库的逻辑到处重复,新增数据源的成本极高。
ChatCrystal 的解法是定义一个 SourceAdapter 接口,把"怎么找到对话文件"和"怎么解析对话内容"封装成统一契约。导入服务只需要面向接口编程,不需要知道底层数据长什么样。
接口定义:三个方法的契约
SourceAdapter 接口定义在 server/src/parser/adapter.ts,整个文件不到 40 行:
typescript
export interface SourceAdapter {
readonly name: string;
readonly displayName: string;
readonly parserVersion?: string;
detect(): Promise<SourceInfo | null>;
scan(): Promise<ConversationMeta[]>;
parse(meta: ConversationMeta): Promise<ParsedConversation>;
}
三个方法对应三个阶段,职责边界非常清晰:
detect()--- 探测。当前机器上有没有这个数据源?返回SourceInfo(包含数据目录路径和对话数量)或null。这是一个轻量级检查,通常只做文件/目录存在性判断。scan()--- 扫描。遍历数据目录,返回所有对话文件的元数据(ID、文件路径、文件大小、修改时间)。注意这里不解析文件内容,只收集"有哪些对话"的清单。parse()--- 解析。接收一条ConversationMeta,把原始文件解析成统一的ParsedConversation结构。这是最重的一步,涉及文件读取、噪音过滤、内容提取。
为什么要拆成三步?因为 detect() 和 scan() 在导入流程中被高频调用(文件监听、状态展示),而 parse() 只在真正导入时才需要执行。拆开之后,扫描阶段不会产生不必要的 I/O 开销。
注册表模式:Map 驱动的适配器管理
适配器通过注册表(Registry)管理,实现在 server/src/parser/registry.ts:
javascript
const adapters = new Map<string, SourceAdapter>();
export function registerAdapter(adapter: SourceAdapter): void {
if (adapters.has(adapter.name)) {
console.warn(`[Parser] Adapter "${adapter.name}" already registered, overwriting.`);
}
adapters.set(adapter.name, adapter);
}
export function getAdapter(name: string): SourceAdapter | undefined {
return adapters.get(name);
}
注册发生在模块加载时。server/src/parser/index.ts 在顶层依次 registerAdapter 五个内置适配器:
scss
registerAdapter(claudeCodeAdapter);
registerAdapter(codexAdapter);
registerAdapter(cursorAdapter);
registerAdapter(traeAdapter);
registerAdapter(copilotAdapter);
当 server/src/index.ts 执行 import './parser/index.js' 时,五个适配器就自动注册好了。这种"导入即注册"的模式在 Node.js 生态中很常见------模块的副作用就是注册自己。
注册表还暴露了 detectAllSources() 方法,它并行调用所有适配器的 detect(),返回当前机器上可用的数据源列表。前端设置页面用这个方法展示"已检测到的数据源"。
导入服务如何编排适配器
server/src/services/import.ts 中的 importAll() 函数是整个导入流程的编排者。它的核心逻辑如下:
ini
const allAdapters = getAllAdapters();
const enabledSources = appConfig.enabledSources;
const adapters = allAdapters.filter((a) => enabledSources.includes(a.name));
第一步,从注册表取出所有已启用的适配器。然后逐个调用 detect() + scan() 收集对话清单:
ini
for (const adapter of adapters) {
const info = await adapter.detect();
if (!info) continue;
const metas = await adapter.scan();
for (const meta of metas) {
allMetas.push({ ...meta, adapterName: adapter.name });
}
}
收集完所有元数据后,进入去重+解析循环。去重策略是 文件大小 + 修改时间:
scss
const existing = db.exec(
"SELECT file_size, file_mtime FROM conversations WHERE id = ? AND source = ?",
[meta.id, meta.source],
);
if (existing.length > 0) {
const [existingSize, existingMtime] = existing[0].values[0];
if (Number(existingSize) === meta.fileSize && existingMtime === meta.fileMtime) {
progress.skipped++;
continue;
}
}
只有文件发生变化(大小或时间不同)才会重新解析。对于已存在的对话,走 replaceImportedConversation 路径------先清理旧的笔记和消息,再重新插入,同时保留用户的 experience gate 状态。
解析和入库操作包裹在事务中:
scss
withTransaction(db, () => {
if (existing.length > 0) {
replaceImportedConversation(db, parsed, meta);
} else {
insertConversation(db, parsed, meta);
insertMessages(db, parsed);
}
db.run(`INSERT INTO import_log ...`);
});
这个设计保证了单条对话的导入是原子的------要么全部成功,要么全部回滚。
统一的输出格式
不管底层数据源是什么格式,所有适配器的 parse() 都必须返回 ParsedConversation:
yaml
interface ParsedConversation {
id: string;
slug: string | null;
source: string;
projectDir: string;
projectName: string;
cwd: string | null;
gitBranch: string | null;
messages: ParsedMessage[];
firstMessageAt: string;
lastMessageAt: string;
}
每条消息统一为 ParsedMessage:
php
interface ParsedMessage {
id: string;
parentUuid: string | null;
type: 'user' | 'assistant' | 'system';
role: string;
content: string;
hasToolUse: boolean;
hasCode: boolean;
thinking: string | null;
timestamp: string;
}
hasToolUse 和 hasCode 是布尔标记,供前端的 ToolCallGroup 组件和代码高亮逻辑使用。thinking 字段存储模型的思考过程(如果有的话)。
五个适配器的差异概览
虽然接口统一,但每个适配器的实现差异很大:
| 适配器 | 数据格式 | scan 扫描方式 | parse 核心难点 |
|---|---|---|---|
| Claude Code | JSONL 文件 | 递归遍历项目目录 | 流式噪音过滤(SKIP_TYPES 集合) |
| Codex CLI | JSONL 事件流 | 递归遍历 + 文件名正则 | 事件类型路由(session_meta / event_msg / response_item) |
| Cursor | SQLite KV | 读 workspace DB 的 composerData | 跨两个数据库(workspace + global),孤儿对话发现 |
| Trae | SQLite KV | 读 memento/icube-ai-agent-storage | agentTaskContent 嵌套结构提取 |
| Copilot | JSONL 快照 | 遍历 workspace + global 目录 | 只读 JSONL 第一行(kind:0 快照),跳过后续 patch |
Cursor 适配器内部有 5 秒 TTL 缓存(wsCache),避免 detect() 和 scan() 在短时间内重复扫描文件系统。其他适配器没有独立缓存,依赖导入服务的去重逻辑(文件大小 + 修改时间)避免重复解析。
扩展新数据源
要添加新的数据源,只需要:
- 实现
SourceAdapter接口的三个方法 - 在
parser/index.ts中调用registerAdapter()
不需要修改导入服务、数据库 schema 或前端代码。这就是插件架构的核心价值------新增数据源的改动被隔离在适配器内部。
总结
ChatCrystal 的 SourceAdapter 架构用一个接口定义了数据源接入的契约。注册表模式让适配器的管理变成简单的 Map 操作,导入服务通过 detect → scan → parse 三步编排实现了统一的导入流程。五个适配器各自处理完全不同的数据格式,但对外暴露相同的 ParsedConversation 结构。这种设计让新增数据源的成本降到最低------实现一个接口,注册一下,就完事了。
项目地址:github.com/ZengLiangYi...
如有疑问欢迎在 GitHub Issues 或私信交流,很乐意解答。