跳到正文
曾亮懿 ZengLiangYi Blog

SourceAdapter 设计:把源发现、扫描和解析分开

阅读 ChatCrystal 的适配器接口和导入路径,用假数据验证按需解析,再检查注册、启用配置、身份与版本的接入边界。

曾亮懿

曾亮懿

阅读约 4 分钟
目录 展开 收起

不同 AI 工具的对话可以存成 JSONL、事件流或 SQLite 键值数据。统一它们的导入流程,需要明确哪一层发现数据、哪一层判断是否更新、哪一层理解格式。本文阅读 ChatCrystal 的 SourceAdapter 接口,再用假数据验证“未变化时不解析”的控制流程。

接口统一输出,保留格式差异

adapter.ts定义 name、displayName、可选 parserVersion,以及三个异步方法。它依赖共享类型;下面是接口核心片段,不能单独编译。

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 返回可用源信息或 null;scan 返回身份、路径、大小和修改时间等候选元数据;parse 产出统一对话与消息。共同的输出契约让导入服务复用保存逻辑,但 TypeScript 接口本身不会在运行时检查外部文件的 JSON。

也不能把 detect 的实现都理解成“一次目录存在性检查”。例如 Claude Code 适配器的 detect 会枚举项目目录和 JSONL 文件来统计数量;scan 还会跳过小于 100 字节的文件。方法的责任与它实际产生的 I/O 要分别阅读。

注册不等于启用

registry.ts使用 Map 保存适配器,同名注册会警告并覆盖。当前 模块入口注册五个内置源:Claude Code、Codex、Cursor、Trae、Copilot。这是模块导入时的静态注册,不是用户在界面中上传并动态加载任意插件。

detectAllSources 并行发现所有注册源;importAll则先按 enabledSources 过滤,再顺序发现与扫描。两条路径不同。把新适配器加入 Map 后,还要检查启用配置,否则导入路径可能根本不会调用它。

用假适配器验证按需解析

保存以下 lesson.mjs,用 Node 24+ 运行,无需依赖。它是教学控制流程,只模拟启用、发现、元数据比较和解析计数;不是完整 importAll,没有数据库、内容哈希或替换策略。

import assert from 'node:assert/strict';

const calls = [];
const registry = new Map();
registry.set('fixture', {
  name: 'fixture',
  async detect() {
    calls.push('detect');
    return { name: 'fixture' };
  },
  async scan() {
    calls.push('scan');
    return [
      { id: 'unchanged', fileSize: 120, fileMtime: 't1' },
      { id: 'changed', fileSize: 240, fileMtime: 't2' },
    ];
  },
  async parse(meta) {
    calls.push('parse:' + meta.id);
    return { id: meta.id };
  },
});
const previous = new Map([['unchanged', { fileSize: 120, fileMtime: 't1' }]]);

async function collect(enabled) {
  const results = [];
  for (const adapter of registry.values()) {
    if (!enabled.includes(adapter.name)) continue;
    if (!(await adapter.detect())) continue;
    for (const meta of await adapter.scan()) {
      const old = previous.get(meta.id);
      if (old?.fileSize === meta.fileSize && old?.fileMtime === meta.fileMtime) continue;
      results.push(await adapter.parse(meta));
    }
  }
  return results;
}

assert.deepEqual(await collect([]), []);
assert.deepEqual(calls, []);
assert.deepEqual(await collect(['fixture']), [{ id: 'changed' }]);
assert.deepEqual(calls, ['detect', 'scan', 'parse:changed']);
console.log('enabled source and parse boundaries pass');

把 previous 中的 120 改成 121,未变化的候选会进入解析,结果断言随之失败;恢复后通过。这个反例验证元数据短路,不能证明“大小和时间一样的内容永远没有变化”。

当前导入还有第二层比较

真正的 importAll 先用文件大小与修改时间跳过未变候选,解析后再计算标准化内容哈希。文件元数据变化、内容哈希相同的已有对话只更新元数据;内容确实改变时走替换策略。少于两条解析消息的对话会跳过。

失败的范围也不相同:源发现与扫描在逐条候选的 try/catch 外,适配器的 detect 或 scan 抛错会终止这次 importAll;候选解析位于逐条保护内,parse 抛错会记入错误计数并继续后续候选。不能把逐条解析的容错推及整个数据源。

快速路径仍然有边界:大小和时间完全相同就不会重新解析,因此它不是逐次内容校验。parserVersion 用于记录解析契约,也不能从接口的可选字段推导“修改版本号自动强制重导”。保存和替换的细节见批量导入测量。

接入新源时检查整个路径

一个可靠的新源接入需要可公开复现的格式样本、身份规则、顺序与噪声处理,以及缺失和损坏数据的测试。实现三个方法并注册只是其中一部分。还要检查 配置的默认源和迁移、路径设置、界面入口、远端传输与监视器是否识别这个名称。

现有配置会在载入持久化配置时补回内置源,不能据此承诺禁用选择在所有重启场景都保持。新增源也不能只复制某个工具的默认目录并宣布已经兼容它;应先用合成或授权样本分别检查 detect、scan、parse,再检查统一导入的保存和失败路径。

接口减少了不同格式对主流程的影响,但不会自动消除身份碰撞、配置遗漏或解析错误。完成接入的证据,应来自完整导入路径的样本与反例,而不是仅仅通过接口类型检查。

分享:

相关文章

五种 AI 对话解析器:字段保留与损失边界

从 Claude Code、Codex、Cursor、Trae 和 Copilot 的实际解析器建立验收矩阵,检查工具标记、思考文本、顺序与快照覆盖范围。

曾亮懿 曾亮懿
阅读约 4 分钟
#ChatCrystal #TypeScript #Node.js

AI SDK Provider 抽象:能力表与协议边界

核对 ChatCrystal 的七项 Provider 注册、AI SDK 7 模型工厂和结构化输出,用无网络 mock 验证 Responses、Chat Completions、Embedding 与 schema 失败。

曾亮懿 曾亮懿
阅读约 4 分钟
#ChatCrystal #Node.js #TypeScript

SourceAdapter 内部链路:身份、内容哈希与重新导入

对照 ChatCrystal 的注册表、导入器和远端载荷,验证会话身份、内容哈希、元数据捷径与 parserVersion 的实际边界。

曾亮懿 曾亮懿
阅读约 4 分钟
#ChatCrystal #TypeScript #Node.js

从文章到代码

到我的 GitHub 看看 ChatCrystal、开发者工具和日常学习记录,欢迎交流实现细节。