SourceAdapter 插件架构详解

本文面向:想理解插件式数据源架构设计细节的开发者。

预计阅读时间: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;
}

hasToolUsehasCode 是布尔标记,供前端的 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() 在短时间内重复扫描文件系统。其他适配器没有独立缓存,依赖导入服务的去重逻辑(文件大小 + 修改时间)避免重复解析。

扩展新数据源

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

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

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

总结

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


项目地址:github.com/ZengLiangYi...

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

相关推荐
AI周红伟1 小时前
长鑫科技存储之王:存储三强对比:三星、SK海力士 vs 长鑫科技
数据库·人工智能·科技·react.js·架构·langchain
妄想出头的工业炼药师1 小时前
特征检测和特征筛选
算法·开源
cxr8281 小时前
高分子复合材料 AI 逆向设计合——学证明、算法实现、验证数据与学术资源全集
人工智能·线性代数·算法
nvd111 小时前
重新认识 OpenAPI:当接口文档变成网关“交通指挥牌”
架构
闪闪发光得欧1 小时前
Harness Engineering 到 Trellis:AI 编程助手的落地实践
架构
ZengLiangYi1 小时前
如何解析 5 种完全不同格式的 AI 对话
javascript·人工智能·算法
计算机安禾2 小时前
【算法设计与分析】第29篇:启发式与元启发式搜索方法综述
java·数据库·算法
我叫袁小陌2 小时前
数据结构详解与算法关联指南
算法