不同 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,再检查统一导入的保存和失败路径。
接口减少了不同格式对主流程的影响,但不会自动消除身份碰撞、配置遗漏或解析错误。完成接入的证据,应来自完整导入路径的样本与反例,而不是仅仅通过接口类型检查。