SourceAdapter 插件架构详解

0 阅读5分钟

本文面向:想理解插件式数据源架构设计细节的开发者。
预计阅读时间:8 分钟
最终效果:理解 SourceAdapter 三方法契约、注册表模式、导入编排流程,掌握扩展新数据源的完整步骤。

为什么需要插件架构

AI 编程工具的对话数据格式千差万别。Claude Code 输出 JSONL 流式日志,Cursor 和 Trae 把对话存在 SQLite 的 KV 表里,Codex CLI 用事件流记录会话,GitHub Copilot 用 JSONL 快照文件。如果每种格式都写一套独立的导入逻辑,代码会变成一锅粥——扫描、去重、入库的逻辑到处重复,新增数据源的成本极高。

ChatCrystal 的解法是定义一个 SourceAdapter 接口,把"怎么找到对话文件"和"怎么解析对话内容"封装成统一契约。导入服务只需要面向接口编程,不需要知道底层数据长什么样。

接口定义:三个方法的契约

SourceAdapter 接口定义在 server/src/parser/adapter.ts,整个文件不到 40 行:

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

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 五个内置适配器:

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() 函数是整个导入流程的编排者。它的核心逻辑如下:

const allAdapters = getAllAdapters();
const enabledSources = appConfig.enabledSources;
const adapters = allAdapters.filter((a) => enabledSources.includes(a.name));

第一步,从注册表取出所有已启用的适配器。然后逐个调用 detect() + scan() 收集对话清单:

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 });
  }
}

收集完所有元数据后,进入去重+解析循环。去重策略是 文件大小 + 修改时间

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 状态。

解析和入库操作包裹在事务中:

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

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

interface ParsedMessage {
  id: string;
  parentUuid: string | null;
  type: 'user' | 'assistant' | 'system';
  role: string;
  content: string;
  hasToolUse: boolean;
  hasCode: boolean;
  thinking: string | null;
  timestamp: string;
}

hasToolUsehasCode 是布尔标记,供前端的 ToolCallGroup 组件和代码高亮逻辑使用。thinking 字段存储模型的思考过程(如果有的话)。

五个适配器的差异概览

虽然接口统一,但每个适配器的实现差异很大:

适配器数据格式scan 扫描方式parse 核心难点
Claude CodeJSONL 文件递归遍历项目目录流式噪音过滤(SKIP_TYPES 集合)
Codex CLIJSONL 事件流递归遍历 + 文件名正则事件类型路由(session_meta / event_msg / response_item)
CursorSQLite KV读 workspace DB 的 composerData跨两个数据库(workspace + global),孤儿对话发现
TraeSQLite KV读 memento/icube-ai-agent-storageagentTaskContent 嵌套结构提取
CopilotJSONL 快照遍历 workspace + global 目录只读 JSONL 第一行(kind:0 快照),跳过后续 patch

Cursor 适配器内部有 5 秒 TTL 缓存(wsCache),避免 detect()scan() 在短时间内重复扫描文件系统。其他适配器没有独立缓存,依赖导入服务的去重逻辑(文件大小 + 修改时间)避免重复解析。

扩展新数据源

要添加新的数据源,只需要:

  1. 实现 SourceAdapter 接口的三个方法
  2. parser/index.ts 中调用 registerAdapter()

不需要修改导入服务、数据库 schema 或前端代码。这就是插件架构的核心价值——新增数据源的改动被隔离在适配器内部。

总结

ChatCrystal 的 SourceAdapter 架构用一个接口定义了数据源接入的契约。注册表模式让适配器的管理变成简单的 Map 操作,导入服务通过 detect → scan → parse 三步编排实现了统一的导入流程。五个适配器各自处理完全不同的数据格式,但对外暴露相同的 ParsedConversation 结构。这种设计让新增数据源的成本降到最低——实现一个接口,注册一下,就完事了。


项目地址:github.com/ZengLiangYi…

如有疑问欢迎在 GitHub Issues 或私信交流,很乐意解答。