---
title: SourceAdapter 设计：把源发现、扫描和解析分开
description: 阅读 ChatCrystal 的适配器接口和导入路径，用假数据验证按需解析，再检查注册、启用配置、身份与版本的接入边界。
image: "https://zengliangyi.com/og/blog/source-adapter-design.png"
---

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

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

[adapter.ts](https://github.com/ZengLiangYi/ChatCrystal/blob/764276814e5c5a33ecbfeae63754f74b9261a3a8/server/src/parser/adapter.ts)定义 name、displayName、可选 parserVersion，以及三个异步方法。它依赖共享类型；下面是接口核心片段，**不能单独编译**。



```ts
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 适配器](https://github.com/ZengLiangYi/ChatCrystal/blob/764276814e5c5a33ecbfeae63754f74b9261a3a8/server/src/parser/adapters/claude-code.ts)的 detect 会枚举项目目录和 JSONL 文件来统计数量；scan 还会跳过小于 100 字节的文件。方法的责任与它实际产生的 I/O 要分别阅读。

## 注册不等于启用

[registry.ts](https://github.com/ZengLiangYi/ChatCrystal/blob/764276814e5c5a33ecbfeae63754f74b9261a3a8/server/src/parser/registry.ts)使用 Map 保存适配器，同名注册会警告并覆盖。当前 [模块入口](https://github.com/ZengLiangYi/ChatCrystal/blob/764276814e5c5a33ecbfeae63754f74b9261a3a8/server/src/parser/index.ts)注册五个内置源：Claude Code、Codex、Cursor、Trae、Copilot。这是模块导入时的静态注册，不是用户在界面中上传并动态加载任意插件。

`detectAllSources` 并行发现所有注册源；[importAll](https://github.com/ZengLiangYi/ChatCrystal/blob/764276814e5c5a33ecbfeae63754f74b9261a3a8/server/src/services/import.ts)则先按 enabledSources 过滤，再顺序发现与扫描。两条路径不同。把新适配器加入 Map 后，还要检查启用配置，否则导入路径可能根本不会调用它。

## 用假适配器验证按需解析

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



```js
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 用于记录解析契约，也不能从接口的可选字段推导“修改版本号自动强制重导”。保存和替换的细节见[批量导入测量](/blog/conversation-import-memory-budget/)。

## 接入新源时检查整个路径

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

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

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

分享：

##  相关文章

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

从 Claude Code、Codex、Cursor、Trae 和 Copilot 的实际解析器建立验收矩阵，检查工具标记、思考文本、顺序与快照覆盖范围。          曾亮懿       2026年10月7日      阅读约 4 分钟

#ChatCrystal

#TypeScript

#Node.js](/blog/ai-conversation-parser-boundaries)    [##  AI SDK Provider 抽象：能力表与协议边界

核对 ChatCrystal 的七项 Provider 注册、AI SDK 7 模型工厂和结构化输出，用无网络 mock 验证 Responses、Chat Completions、Embedding 与 schema 失败。          曾亮懿       2026年10月7日      阅读约 4 分钟

#ChatCrystal

#Node.js

#TypeScript](/blog/ai-sdk-provider-abstraction)    [##  SourceAdapter 内部链路：身份、内容哈希与重新导入

对照 ChatCrystal 的注册表、导入器和远端载荷，验证会话身份、内容哈希、元数据捷径与 parserVersion 的实际边界。          曾亮懿       2026年10月7日      阅读约 4 分钟

#ChatCrystal

#TypeScript

#Node.js](/blog/source-adapter-internals)

##  从文章到代码

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

[访问我的 GitHub](https://github.com/ZengLiangYi)

```json
{
  "@context": "https://schema.org",
  "@type": "WebSite",
  "name": "ZengLiangYi Blog",
  "alternateName": [
    "Zeng Liangyi",
    "ZengLiangYi",
    "zengliangyi.com"
  ],
  "url": "https://zengliangyi.com",
  "description": "曾亮懿的个人网站。记录 AI Native 探索、开发者工具与全栈产品实践。"
}
{
  "@context": "https://schema.org",
  "@type": "BlogPosting",
  "headline": "SourceAdapter 设计：把源发现、扫描和解析分开",
  "description": "阅读 ChatCrystal 的适配器接口和导入路径，用假数据验证按需解析，再检查注册、启用配置、身份与版本的接入边界。",
  "url": "https://zengliangyi.com/blog/source-adapter-design/",
  "image": "https://zengliangyi.com/images/blog/source-adapter-design.svg",
  "datePublished": "2026-10-06T00:00:00.000Z",
  "dateModified": "2026-10-06T00:00:00.000Z",
  "author": {
    "@type": "Person",
    "name": "曾亮懿",
    "url": "https://zengliangyi.com/about"
  },
  "publisher": {
    "@type": "Organization",
    "name": "ZengLiangYi Blog",
    "logo": {
      "@type": "ImageObject",
      "url": "https://zengliangyi.com/favicon-96x96.png"
    }
  },
  "mainEntityOfPage": {
    "@type": "WebPage",
    "@id": "https://zengliangyi.com/blog/source-adapter-design/"
  }
}
{
  "@context": "https://schema.org",
  "@type": "BreadcrumbList",
  "itemListElement": [
    {
      "@type": "ListItem",
      "position": 1,
      "name": "首页",
      "item": "https://zengliangyi.com/"
    },
    {
      "@type": "ListItem",
      "position": 2,
      "name": "文章",
      "item": "https://zengliangyi.com/blog"
    },
    {
      "@type": "ListItem",
      "position": 3,
      "name": "SourceAdapter 设计：把源发现、扫描和解析分开",
      "item": "https://zengliangyi.com/blog/source-adapter-design/"
    }
  ]
}
```
