本文面向:想理解插件式数据源适配架构的开发者。
预计阅读时间:10 分钟
最终效果:掌握 SourceAdapter 接口设计、注册表机制、detect → scan → parse 三层漏斗,能独立添加新数据源。
为什么需要插件架构
ChatCrystal 需要从 5 种不同的 AI 编程工具中导入对话数据:
| 工具 | 存储格式 | 特点 |
|---|---|---|
| Claude Code | JSONL 文本流 | 逐行解析,含 XML 噪声标签 |
| Codex CLI | JSONL 事件流 | 从 event_msg/response_item 重建对话 |
| Cursor | SQLite 数据库 | 查询 workspaceStorage KV 表 |
| Trae | SQLite 数据库 | 查询 memento 存储的 agent 任务 |
| GitHub Copilot | JSONL 会话快照 | 解析 request/response 数组 |
这五种格式差异巨大------有的是文本文件,有的是数据库;有的是流式读取,有的是随机查询。如果在同一个函数里用 if/else 处理每种格式,代码会迅速膨胀成一团无法维护的意大利面条。
插件式架构的核心思想是:定义一个统一接口,让每种数据源各自实现,主流程不关心具体实现细节。 这就是 SourceAdapter 接口做的事情。
接口定义
php
export interface SourceAdapter {
/** 唯一标识,如 'claude-code' */
readonly name: string;
/** UI 显示名称,如 'Claude Code' */
readonly displayName: string;
/** 解析器版本,用于远程导入去重和审计(可选) */
readonly parserVersion?: string;
/** 检测当前机器上是否存在该数据源 */
detect(): Promise<SourceInfo | null>;
/** 扫描数据目录,返回所有对话的元信息(不含内容) */
scan(): Promise<ConversationMeta[]>;
/** 解析单个对话文件为结构化数据 */
parse(meta: ConversationMeta): Promise<ParsedConversation>;
}
整个接口只有三个方法,但每个都经过深思熟虑。下面逐一拆解。
三个方法的职责分离
detect():存在性检测
detect() 回答一个问题:"当前机器上有没有这个工具的数据?"
它不扫描具体内容,只做轻量级检查------目录是否存在、数据库文件是否可读。返回 SourceInfo(包含数据目录路径和对话数量)或 null。
这一步的价值是让系统自动发现可用数据源,用户无需手动配置。安装了 Claude Code 就自动检测到,没装 Cursor 就跳过。
scan():元信息收集
scan() 遍历数据目录,返回所有对话的元信息------ID、文件路径、文件大小、修改时间。它不解析对话内容。
为什么把 scan 和 parse 分开?因为导入流程需要做去重判断:如果一个对话文件的大小和修改时间跟上次导入时一样,就跳过它。这个判断只需要元信息,不需要花时间解析完整内容。
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;
}
}
parse():内容解析
parse() 接收一条元信息,完整解析对话内容,返回标准化的 ParsedConversation。这是每个适配器最复杂的部分,也是差异最大的部分。
三个方法形成了一个漏斗:detect 过滤不存在的源,scan 收集候选列表,parse 只处理需要更新的对话。层层递进,避免不必要的计算。
实现示例:Claude Code Adapter(JSONL 流式解析)
Claude Code 的对话以 JSONL 格式存储,每行一个 JSON 对象。适配器需要处理几个挑战:
1. 流式读取避免内存爆炸
大对话文件可能有几十 MB,不能一次性读入内存。适配器用 readline 逐行读取:
arduino
async parse(meta: ConversationMeta): Promise<ParsedConversation> {
const fileStream = createReadStream(meta.filePath, { encoding: 'utf-8' });
const rl = createInterface({ input: fileStream, crlfDelay: Infinity });
for await (const line of rl) {
if (!line.trim()) continue;
const parsed = JSON.parse(line);
// ...
}
}
2. 噪声过滤
JSONL 中混杂着大量非对话数据:流式增量(streaming delta)、文件历史快照、进度事件等。适配器定义了一个跳过集合:
ini
const SKIP_TYPES = new Set([
'file-history-snapshot', 'last-prompt', 'progress',
'agent_progress', 'hook_progress', 'queue-operation',
'message', 'tool_use', 'tool_result', 'thinking', 'text', 'tool_reference',
]);
只有 type 为 user 或 assistant 且带有 uuid 的行才会被保留。
3. 内容清洗
Claude Code 的消息内容中嵌入了 XML 标签(系统提醒、命令回显等),需要正则清除:
ini
function sanitizeContent(text: string): string {
let result = text;
result = result.replace(/<system-reminder>[\s\S]*?</system-reminder>/g, '');
result = result.replace(/<command-name>[^<]*</command-name>/g, '');
// ... 更多标签
return result.trim();
}
实现示例:Cursor Adapter(SQLite 查询)
Cursor 的数据存储完全不同------它用 SQLite 的键值表。适配器需要:
1. 跨平台路径发现
Cursor 的数据目录因操作系统而异:
csharp
function getCursorBasePath(): string {
const p = platform();
if (p === 'win32') return resolve(process.env.APPDATA || '', 'Cursor', 'User');
if (p === 'darwin') return resolve(homedir(), 'Library', 'Application Support', 'Cursor', 'User');
return resolve(homedir(), '.config', 'Cursor', 'User');
}
2. 工作区扫描
Cursor 按工作区组织数据,每个工作区有自己的 state.vscdb。适配器遍历所有工作区目录,从 workspace.json 读取项目路径,从 SQLite 读取 composer 元数据。
3. 孤立对话发现
有些对话的工作区已被删除,但全局数据库中还保留着 bubble 数据。适配器通过查询 cursorDiskKV 表中所有 bubbleId: 前缀的键,找出这些"孤立"对话:
ini
db.exec(
"SELECT DISTINCT SUBSTR([key], 10, INSTR(SUBSTR([key], 10), ':') - 1) FROM cursorDiskKV WHERE [key] LIKE 'bubbleId:%'"
);
4. Bubble 数据解析
Cursor 的对话消息叫 "Bubble",通过 type 字段区分角色(1 = 用户,2 = AI),还需要处理 thinking 块、tool results、代码块等嵌套数据。
对比两个适配器可以看到:同样是 parse() 方法,Claude Code 做的是流式文本处理,Cursor 做的是数据库查询和 JSON 解析。接口相同,实现完全不同------这正是插件架构的威力。
适配器注册机制
适配器通过注册表(Registry)集中管理。注册表是一个简单的 Map:
javascript
// registry.ts
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);
}
export function getAllAdapters(): SourceAdapter[] {
return Array.from(adapters.values());
}
所有内置适配器在模块入口处一次性注册:
javascript
// index.ts
import { claudeCodeAdapter } from './adapters/claude-code.js';
import { codexAdapter } from './adapters/codex.js';
import { cursorAdapter } from './adapters/cursor.js';
import { traeAdapter } from './adapters/trae.js';
import { copilotAdapter } from './adapters/copilot.js';
registerAdapter(claudeCodeAdapter);
registerAdapter(codexAdapter);
registerAdapter(cursorAdapter);
registerAdapter(traeAdapter);
registerAdapter(copilotAdapter);
注册表还提供 detectAllSources() 方法,并行调用所有适配器的 detect(),快速找出当前机器上可用的数据源。
导入流程:detect → scan → parse
导入服务 importAll() 把三个方法串成一条流水线:
scss
1. 遍历所有已注册的适配器
2. 对每个适配器调用 detect(),跳过不存在的数据源
3. 调用 scan() 获取对话元信息列表
4. 对每条元信息:
a. 查询数据库,比较 fileSize 和 fileMtime
b. 如果没变化,跳过(skipped++)
c. 如果有变化,调用 parse() 解析
d. 将解析结果写入数据库
5. 批量保存数据库
关键设计:scan 和 parse 的分离使得去重判断(步骤 4a)不需要解析完整内容。对于几百个对话的批量导入,这个优化能节省大量时间。
添加新数据源的步骤
假设要添加对 Windsurf 的支持,只需三步:
第一步:创建适配器文件
typescript
// server/src/parser/adapters/windsurf.ts
import type { SourceAdapter } from '../adapter.js';
export const windsurfAdapter: SourceAdapter = {
name: 'windsurf',
displayName: 'Windsurf',
async detect() {
// 检查 Windsurf 数据目录是否存在
},
async scan() {
// 遍历数据目录,返回 ConversationMeta[]
},
async parse(meta) {
// 解析对话内容,返回 ParsedConversation
},
};
第二步:注册适配器
在 index.ts 中添加两行:
javascript
import { windsurfAdapter } from './adapters/windsurf.js';
registerAdapter(windsurfAdapter);
第三步:配置数据源路径
在 config.ts 中添加 windsurfDataDir 配置项。
完成。导入服务、去重逻辑、数据库写入------这些都不需要改动。系统会自动发现新适配器并纳入导入流程。
接口隔离的好处
回到开头的问题:五种完全不同的数据格式,如何用统一的流程处理?
SourceAdapter 接口做到了几件事:
关注点分离。 每个适配器只关心自己的数据格式。Claude Code 适配器不需要知道 SQLite 的存在,Cursor 适配器不需要理解 JSONL。修改一个适配器不会影响其他任何适配器。
主流程稳定。 导入服务的代码不包含任何数据源特定的逻辑。无论将来添加第 6 个还是第 10 个数据源,importAll() 的代码一行都不用改。
可测试性。 每个适配器可以独立测试------构造一个 mock 的 ConversationMeta,调用 parse(),验证输出。不需要启动整个应用。
渐进式开发。 可以先实现 detect() 让系统识别数据源,再实现 scan() 列出对话,最后实现 parse() 完成导入。每一步都能独立交付。
这就是接口抽象的价值:它不是为了炫技,而是为了让复杂系统保持可演化。当需求变化时(新增数据源、修改解析逻辑、优化性能),变化被限制在单个适配器内部,不会波及整个系统。
下一步
- 阅读
server/src/parser/adapters/目录下的 5 个适配器实现,体会同一接口的不同诠释 - 尝试为自己使用的 AI 工具编写一个 SourceAdapter
- 思考:如果要支持增量解析(只解析新增的消息行),接口需要怎么调整?
项目地址:github.com/ZengLiangYi...
如有疑问欢迎在 GitHub Issues 或私信交流,很乐意解答。