从零构建 · 第六套 · 桌面 Agent 客户端

从 PRD 到
三条 Runtime

跟着「用户在聊天框里输入一段提示词,按下回车」这个动作,从零走到尾。22 个功能模块按开发顺序罗列,每个模块讲清楚目的、实现形式、技术栈、功能定义取舍、优劣分析、如何互相配合。

22
功能模块
5
开发阶段
3
Runtime
完整
工作流详解

跟着"用户在聊天框里输入一段提示词,按下回车"这个动作,从零走到尾。

你不是在写一个终端 CLI——你在写一个 Electron 桌面应用,里面跑着一个 Next.js 服务器,连着三条不同的 Agent 引擎,通过 SSE 把 AI 的思考过程实时推到用户的屏幕上。

这份教程以 CodePilot v0.62.0 为蓝本,从产品需求文档(PRD)开始,把所有功能模块按开发顺序罗列,每个模块讲清楚:目的、实现形式、技术栈、功能定义取舍、优劣分析、如何互相配合。


Part 1

第 0 章:PRD——产品需求文档

0.1 产品定位

CodePilot 是什么:一个多模型 AI Agent 桌面客户端。用户在桌面上打开一个窗口,选择任意 AI 模型(Claude / GPT / GLM / Kimi / DeepSeek…),开始对话。AI 不仅能聊天,还能读写文件、执行命令、生成图片、定时任务、甚至通过 Telegram/飞书远程控制。

它不是什么: - 不是又一个编程 Agent(Claude Code / Codex 已经做得很好了) - 不是又一个聊天客户端(ChatGPT 桌面版已经够用了) - 不是又一个 IDE 插件(Cline / Cursor 已经占领了那个市场)

它是:把"AI Agent"从终端搬到桌面,同时把"编程 Agent"扩展成"通用 Agent"——聊天、编程、任务、媒体、远程控制,全在一个窗口里。

0.2 目标用户

用户画像 需求 痛点
开发者 用 AI 辅助编程 不想在终端里敲命令,想要 GUI
非开发者 用 AI 完成日常任务 不会用终端,但需要 Agent 能力
多模型用户 想对比不同模型 每个模型一个客户端太麻烦
远程用户 想通过手机控制桌面 Agent 没有现成的桥接方案

0.3 核心功能需求

优先级 功能 描述
P0 多模型聊天 支持 17+ AI Provider,切换不丢上下文
P0 三条 Runtime Claude Code SDK / Native / Codex,可替换
P0 工具调用 AI 能读写文件、执行命令
P0 权限控制 危险操作需要用户确认
P1 会话管理 SQLite 持久化、rewind 到任意 checkpoint
P1 子 Agent 并行执行子任务
P1 MCP 集成 接入外部工具
P2 IM Bridge Telegram/飞书/Discord/QQ/微信远程控制
P2 媒体生成 Gemini 图片生成
P2 任务调度 cron 定时任务
P3 技能系统 可复用的 Agent 技能

0.4 非功能需求

需求 指标
跨平台 macOS / Windows / Linux
离线可用 本地 SQLite,不依赖云端
响应速度 首 token < 2s
内存占用 < 500MB
可扩展 MCP / Skills / Plugins

0.5 技术约束

约束 原因
Electron 跨平台桌面 + 系统能力(pty/通知/文件对话框)
Next.js App Router 同时做前端和 API 层
SQLite 本地持久化,零配置
TypeScript 类型安全
Vercel AI SDK 多模型统一接口

Part 2

第 1 章:技术选型

1.1 为什么是 Electron + Next.js

Electron: - ✅ 跨平台桌面(macOS/Windows/Linux) - ✅ 系统能力(pty/通知/文件对话框/Tray) - ✅ 成熟的生态(VS Code / Slack / Discord 都在用) - ❌ 内存占用高 - ❌ 打包体积大

Next.js: - ✅ App Router 同时做前端和 API 层 - ✅ React 19 生态 - ✅ SSR/SSG - ✅ 可以打包成 standalone server 被 Electron utilityProcess 调用 - ❌ 学习曲线

为什么不选 Tauri: - Tauri 更轻量,但生态不如 Electron 成熟 - CodePilot 需要 pty(终端模拟器),Electron 的 node-pty 更成熟 - CodePilot 需要 utilityProcess 跑 Next.js server,Electron 原生支持

为什么不选纯 Web: - 需要本地文件系统访问(读写文件) - 需要本地 SQLite - 需要系统通知 - 需要 pty

1.2 为什么是 SQLite

  • ✅ 零配置
  • ✅ 同步 API(better-sqlite3)
  • ✅ WAL 模式(并发读写)
  • ✅ 本地优先(不依赖云端)
  • ❌ 不支持多机同步

1.3 为什么是三条 Runtime

这是 CodePilot 最独特的架构决策:

Runtime 为什么需要 为什么不能替代
Claude Code SDK Claude 模型的最佳体验 锁 Anthropic
Native 非 Claude 模型需要自己的循环 不能复制 Claude Code 的全部能力
Codex OpenAI 模型需要自己的引擎 锁 OpenAI

为什么不能只用一条 Runtime: - 只用 Claude SDK:锁 Anthropic,无法跑其他模型 - 只用 Native:无法复制 Claude Code 的全部能力(如 apply_patch、子 Agent、权限细节) - 只用 Codex:锁 OpenAI,无法跑其他模型

三条 Runtime 共享什么:前端、DB、权限、Bridge、Harness——但主循环完全不同。

1.4 技术栈总览

技术 版本
桌面外壳 Electron 40
前端框架 Next.js 16 (App Router)
UI 库 React 19
样式 Tailwind CSS 4
组件库 Radix UI
数据库 better-sqlite3 12
AI SDK Vercel AI SDK 4
Claude SDK @anthropic-ai/claude-agent-sdk 0.2
代码高亮 Shiki 3
Markdown react-markdown / streamdown
打包 electron-builder 26
测试 Playwright / tsx + node:test
IM 集成 Telegram Bot API / 飞书 SDK / Discord.js

Part 3

第 2 章:功能模块开发顺序

2.1 开发顺序总览

按依赖关系,功能模块的开发顺序如下:

第 1 阶段:基础设施
  ├── 模块 1:项目脚手架(Electron + Next.js + SQLite)
  ├── 模块 2:数据库层(db.ts)
  └── 模块 3:聊天 UI 基础(MessageList + MessageInput)

第 2 阶段:核心引擎
  ├── 模块 4:SSE 流管理(stream-session-manager)
  ├── 模块 5:Provider 系统(provider-catalog + provider-resolver)
  ├── 模块 6:Runtime 注册表(runtime/registry)
  ├── 模块 7:Native Runtime(agent-loop + tools + permission)
  ├── 模块 8:Claude SDK Runtime(claude-client)
  └── 模块 9:Codex Runtime(codex/app-server-manager)

第 3 阶段:工具与权限
  ├── 模块 10:权限系统(permission)
  ├── 模块 11:Harness 能力契约(harness/context-compiler)
  ├── 模块 12:工具系统(tools + builtin-tools)
  └── 模块 13:MCP 集成(mcp-tool-adapter)

第 4 阶段:高级功能
  ├── 模块 14:技能系统(skill-parser + skill-discovery + skill-executor)
  ├── 模块 15:子 Agent 系统(subagent-orchestration)
  ├── 模块 16:Bridge 子系统(bridge-manager + adapters + channels)
  ├── 模块 17:媒体生成(image-generator + job-executor)
  ├── 模块 18:任务调度(tasks + scheduler)
  └── 模块 19:设置系统(settings)

第 5 阶段:完善与发布
  ├── 模块 20:错误分类与诊断(error-classifier + provider-doctor)
  ├── 模块 21:Electron 主进程(electron/main)
  └── 模块 22:打包与分发(electron-builder)

2.2 依赖关系图

graph TD
    M1[模块1: 项目脚手架] --> M2[模块2: 数据库层]
    M1 --> M3[模块3: 聊天UI基础]
    M2 --> M4[模块4: SSE流管理]
    M2 --> M5[模块5: Provider系统]
    M5 --> M6[模块6: Runtime注册表]
    M6 --> M7[模块7: Native Runtime]
    M6 --> M8[模块8: Claude SDK Runtime]
    M6 --> M9[模块9: Codex Runtime]
    M7 --> M10[模块10: 权限系统]
    M8 --> M10
    M9 --> M10
    M10 --> M11[模块11: Harness能力契约]
    M11 --> M12[模块12: 工具系统]
    M12 --> M13[模块13: MCP集成]
    M7 --> M14[模块14: 技能系统]
    M7 --> M15[模块15: 子Agent系统]
    M4 --> M16[模块16: Bridge子系统]
    M7 --> M17[模块17: 媒体生成]
    M2 --> M18[模块18: 任务调度]
    M2 --> M19[模块19: 设置系统]
    M5 --> M20[模块20: 错误分类与诊断]
    M1 --> M21[模块21: Electron主进程]
    M21 --> M22[模块22: 打包与分发]

Part 4

第 3 章:模块 1——项目脚手架

3.1 目的

搭建 Electron + Next.js + SQLite 的开发环境,让 npm run electron:dev 能同时启动 Next.js dev server 和 Electron 窗口。

3.2 实现形式

  • package.json:定义依赖和脚本
  • electron/main.ts:Electron 主进程入口
  • electron/preload.ts:contextBridge 暴露
  • next.config.ts:Next.js 配置
  • tsconfig.json:TypeScript 配置

3.3 技术栈

  • Electron 40
  • Next.js 16 (App Router)
  • TypeScript
  • better-sqlite3

3.4 功能定义取舍

为什么用 utilityProcess.fork 跑 Next.js server: - ✅ 主进程只做壳(窗口/终端/通知),Next.js server 跑业务逻辑 - ✅ 进程隔离,Next.js 崩溃不影响主进程 - ✅ 可以独立重启 Next.js server - ❌ 多了一个进程,内存占用略高

为什么不直接在主进程跑 Next.js: - 主进程是 Node.js 环境,Next.js 需要自己的运行时 - utilityProcess 是 Electron 专门为这种场景设计的

3.5 优劣分析

优点 缺点
跨平台桌面 内存占用高
系统能力完整 打包体积大
生态成熟 学习曲线

3.6 如何互相配合

  • Electron 主进程通过 utilityProcess.fork 启动 Next.js server
  • Next.js server 监听空闲端口
  • 主进程通过 mainWindow.loadURL 加载 Next.js 页面
  • 主进程和 Next.js server 通过 HTTP/SSE 通信

Part 5

第 4 章:模块 2——数据库层

4.1 目的

用 SQLite 持久化所有数据:聊天会话、消息、设置、任务、Provider 配置、媒体、Bridge 绑定。

4.2 实现形式

  • src/lib/db.ts:数据库 schema 定义 + CRUD + 迁移逻辑

4.3 技术栈

  • better-sqlite3(同步 API、WAL 模式)

4.4 功能定义取舍

为什么用 better-sqlite3 而不是 Prisma/Drizzle: - ✅ 同步 API,不需要 async/await - ✅ 零依赖,不需要额外的 ORM - ✅ 性能好 - ❌ 没有类型安全(需要手写类型) - ❌ 没有迁移工具(需要手写迁移逻辑)

为什么用 WAL 模式: - ✅ 并发读写(读不阻塞写,写不阻塞读) - ✅ 更好的性能 - ❌ 需要额外的 -wal 和 -shm 文件

为什么用文件锁做迁移: - 多 Next.js worker 可能同时启动,需要防止并发迁移 - O_CREAT|O_EXCL 文件锁 + 10s 重试

4.5 优劣分析

优点 缺点
零配置 不支持多机同步
同步 API 没有类型安全
WAL 模式 需要手写迁移
本地优先 单点故障

4.6 如何互相配合

  • 所有模块通过 getDb() 获取数据库连接
  • initDb() 在首次调用时初始化 schema
  • migrateDb() 在 schema 变更时自动迁移
  • withMigrationLock() 防止并发迁移

Part 6

第 5 章:模块 3——聊天 UI 基础

5.1 目的

实现聊天界面的基础组件:消息列表(MessageList)和消息输入框(MessageInput)。

5.2 实现形式

  • src/components/chat/MessageList.tsx:消息列表(虚拟化)
  • src/components/chat/MessageItem.tsx:单条消息(按 block.type 分发渲染)
  • src/components/chat/MessageInput.tsx:消息输入框
  • src/components/chat/StreamingMessage.tsx:流式消息

5.3 技术栈

  • React 19
  • Tailwind CSS 4
  • Radix UI

5.4 功能定义取舍

为什么用虚拟化: - 长会话可能有几百条消息,全部渲染会卡 - 虚拟化只渲染可见区域的消息

为什么按 block.type 分发渲染: - 一条消息可能包含多种内容:text / thinking / tool_use / tool_result - 每种内容需要不同的渲染方式

5.5 优劣分析

优点 缺点
虚拟化性能好 实现复杂
分发渲染灵活 需要维护多种渲染器

5.6 如何互相配合

  • MessageListuseSSEStream hook 获取消息数据
  • MessageItemblock.type 分发到不同的渲染器
  • MessageInput 通过 POST /api/chat 发送消息

Part 7

第 6 章:模块 4——SSE 流管理

6.1 目的

管理 SSE 流的生命周期:创建、暂停、恢复、销毁。确保即使用户刷新页面或切换会话,流也能正确恢复。

6.2 实现形式

  • src/lib/stream-session-manager.ts:SSE 流生命周期管理(1439 行)

6.3 技术栈

  • Fetch API(ReadableStream)
  • globalThis 单例(抗 HMR)

6.4 功能定义取舍

为什么用 globalThis 单例: - Next.js HMR 会重新加载模块,导致流丢失 - globalThis 单例确保流在 HMR 后仍然存在

为什么用两级 idle 预算: - 首 token 前 10min:模型可能需要很长时间才能返回第一个 token - 之后 5.5min:如果 5.5min 没有新 token,说明流可能卡死了

为什么用 2s force-abort 安全网: - #578 修复:防止 interrupt 请求挂起导致流永远卡在 active

6.5 优劣分析

优点 缺点
流独立于组件存活 实现复杂
两级 idle 预算 需要调参
force-abort 安全网 可能误杀

6.6 如何互相配合

  • startStream 创建流,fetch('/api/chat')consumeSSEStream
  • stopStreamWith 停止流,先武装 2s force-abort,再调 /api/chat/interrupt
  • subscribe 让组件订阅流快照

Part 8

第 7 章:模块 5——Provider 系统

7.1 目的

管理 17+ AI Provider 的配置、切换、解析。确保用户可以在对话中切换模型而不丢失上下文。

7.2 实现形式

  • src/lib/provider-catalog.ts:Provider 预设(2400 行)
  • src/lib/provider-resolver.ts:Provider 解析(1793 行)
  • src/lib/ai-provider.ts:AI SDK 多模型封装

7.3 技术栈

  • Zod(校验)
  • Vercel AI SDK

7.4 功能定义取舍

为什么用预设目录: - 每个 Provider 有自己的 baseUrl、authStyle、defaultModels、roleModels - 预设目录避免用户手动配置

为什么用五级解析优先级: - providerId > sessionProviderId > default_provider_id > active - 确保用户显式选择的 Provider 优先级最高

为什么用虚拟 Provider: - env / openai-oauth / xai-oauth / codex_account 不是真实的 Provider,但需要在解析时特殊处理

7.5 优劣分析

优点 缺点
17+ Provider 开箱即用 预设目录需要维护
切换不丢上下文 解析逻辑复杂
虚拟 Provider 灵活 需要特殊处理

7.6 如何互相配合

  • resolveProvider() 解析当前应该使用哪个 Provider
  • createModel() 根据 Provider 创建 AI SDK 模型
  • toClaudeCodeEnv() 把 Provider 配置转换成 Claude Code SDK 的环境变量

Part 9

第 8 章:模块 6——Runtime 注册表

8.1 目的

管理三条 Runtime 的注册、选择、切换。确保同一个前端可以跑不同的 Agent 引擎。

8.2 实现形式

  • src/lib/runtime/registry.ts:Runtime 注册表(152 行)
  • src/lib/runtime/types.ts:Runtime 类型定义
  • src/lib/runtime/contract.ts:跨 Runtime 契约

8.3 技术栈

  • Map(注册表)

8.4 功能定义取舍

为什么用五级选择: - Codex 显式 → 显式 override → cli_enabled=false → 全局设置 → auto - 确保用户显式选择的 Runtime 优先级最高

为什么用 canonical run 事件: - 三条 Runtime 的事件格式不同,需要统一成 canonical 事件 - UI 只消费 canonical 事件,不需要关心具体 Runtime

8.5 优劣分析

优点 缺点
Runtime 可替换 需要维护三套实现
canonical 事件统一 需要翻译层

8.6 如何互相配合

  • resolveRuntime() 选择当前应该使用哪个 Runtime
  • 每条 Runtime 的 adapter 把自己的事件翻译成 canonical 事件
  • UI 只消费 canonical 事件

Part 10

第 9 章:模块 7——Native Runtime

9.1 目的

实现自研的 Agent 主循环,用于跑非 Claude 模型。

9.2 实现形式

  • src/lib/agent-loop.ts:Native 循环(1019 行)
  • src/lib/tools/:8 个编码工具
  • src/lib/builtin-tools/:平台工具

9.3 技术栈

  • Vercel AI SDK streamText

9.4 功能定义取舍

为什么用手动 while 循环: - AI SDK 的自动模式无法在步骤之间插入权限检查、DB 持久化、超时预算 - 手动循环可以在每一步之间做这些事情

为什么用 pruneOldToolResults: - 上下文会无限增长,需要裁剪旧的 tool_result

为什么用 doom-loop 检测: - 模型可能陷入死循环(同一个工具调用 N 次) - 需要在循环中检测并终止

9.5 优劣分析

优点 缺点
完全自主可控 需要维护循环逻辑
可以在步骤之间插入护栏 实现复杂

9.6 如何互相配合

  • runAgentLoop() 是入口,返回 ReadableStream<string>
  • 每一步调用 streamText(),逐事件翻译为 SSE
  • 权限检查在工具执行前
  • DB 持久化在每一步之后

Part 11

第 10 章:模块 8——Claude SDK Runtime

10.1 目的

封装 Claude Agent SDK,把 SDK 子进程的事件翻译成 SSE。

10.2 实现形式

  • src/lib/claude-client.ts:SDK 封装(3628 行)

10.3 技术栈

  • @anthropic-ai/claude-agent-sdk

10.4 功能定义取舍

为什么用事件泵而不是真循环: - SDK 子进程内部已经有完整的 Agent 循环 - CodePilot 只需要把事件翻译成 SSE

为什么用 lockId 门控: - 防止被取代的旧 turn 的迟到 teardown 驱逐新 turn 注册的 Query(I1 竞态)

为什么用 PTL 压缩重试: - CONTEXT_TOO_LONG 触发自动压缩重试 - ptlRetryAttempted 防死循环

10.5 优劣分析

优点 缺点
复用 Claude Code 的完整能力 锁 Anthropic
不需要自己实现循环 无法控制循环细节

10.6 如何互相配合

  • streamClaudeSdk() 创建 SDK conversation
  • for await (const message of conversation) 逐条消费事件
  • conversation-registry.ts 注册活跃会话
  • stream-session-manager.ts 管理 SSE 流

Part 12

第 11 章:模块 9——Codex Runtime

11.1 目的

集成 OpenAI Codex,让 Codex 模型也能跑在 CodePilot 里。

11.2 实现形式

  • src/lib/codex/app-server-manager.ts:Codex app-server 管理(603 行)
  • src/lib/codex/runtime.ts:Codex Runtime
  • src/lib/codex/proxy/:Codex proxy(本地 Responses API 代理)

11.3 技术栈

  • codex app-server(stdio JSON-RPC)

11.4 功能定义取舍

为什么用 app-server 而不是直接调 API: - Codex 的 Agent 循环在 app-server 内部 - 直接调 API 无法复制 Codex 的完整能力

为什么用 proxy: - 让 Codex CLI 引擎跑在用户自配的任意 provider 上 - Codex 账号模型不经代理,直接走 app-server

11.5 优劣分析

优点 缺点
复用 Codex 的完整能力 锁 OpenAI
proxy 可以跑任意 provider 需要维护 proxy

11.6 如何互相配合

  • getCodexAppServer() 查找 codex 二进制并 spawn codex app-server
  • thread/resume|thread/start 创建会话
  • turn/start 开始一轮对话
  • event-mapper.translateCodexNotification 翻成 canonical 事件再发 SSE

Part 13

第 12 章:模块 10——权限系统

12.1 目的

确保 AI 的危险操作(写文件、执行命令)需要用户确认。

12.2 实现形式

  • src/lib/permission-checker.ts:规则引擎
  • src/lib/permission/profile.ts:会话 Profile
  • src/lib/permission/approval-token.ts:HMAC 防伪造

12.3 技术栈

  • HMAC(防伪造)

12.4 功能定义取舍

为什么用三层权限: - 规则引擎:第一防线,快速拦截明显危险的操作 - 执行时弹窗:第二防线,让用户确认不确定的操作 - 会话 Profile:第三防线,允许用户设置"全自动"或"全手动"

为什么用 HMAC 防伪造: - 权限请求的 approvalToken 需要防伪造,否则恶意网站可以伪造权限确认

为什么用 5 分钟超时: - 用户可能忘记确认,需要超时自动 deny

12.5 优劣分析

优点 缺点
三层防线安全 实现复杂
HMAC 防伪造 需要管理密钥
超时自动 deny 可能误杀

12.6 如何互相配合

  • 工具执行前 checkPermission
  • ask 时写 DB permission_requests、发 permission_request SSE
  • registerPendingPermission 挂起等 /api/chat/permission 回包
  • 5 分钟超时自动 deny

Part 14

第 13 章:模块 11——Harness 能力契约

13.1 目的

保证三条 Runtime 给模型看的能力描述不漂移。

13.2 实现形式

  • src/lib/harness/capability-contract.ts:能力契约
  • src/lib/harness/context-compiler.ts:上下文编译
  • src/lib/harness/mutation-level.ts:工具变更级别分类

13.3 技术栈

  • 纯函数

13.4 功能定义取舍

为什么用纯函数: - 不 IO、不执行工具、不做权限决策 - 只产出编译后的上下文

为什么用 canonical prompt 片段: - 三条 Runtime 需要给模型看一致的能力描述 - canonical 片段确保不漂移

13.5 优劣分析

优点 缺点
三条 Runtime 一致 需要维护 canonical 片段
纯函数可测试 实现复杂

13.6 如何互相配合

  • compileContext(input) 产出 system prompt + 工具面 + artifact 契约
  • 三条 Runtime 的 adapter 各自只"适配 compiler 输出"

Part 15

第 14 章:模块 12——工具系统

14.1 目的

实现 AI 可调用的工具:读写文件、执行命令、搜索、子 Agent。

14.2 实现形式

  • src/lib/tools/:8 个编码工具
  • src/lib/builtin-tools/:平台工具

14.3 技术栈

  • Vercel AI SDK ToolSet
  • Zod

14.4 功能定义取舍

为什么用标准 ToolSet: - 可自由组合、gating、运行时包裹 - 与 AI SDK 无缝集成

为什么用两层工具源: - 编码工具:Read / Write / Edit / Bash / Glob / Grep / Skill / Agent - 平台工具:notification / memory-search / dashboard / media / widget-guidelines / session-search / cli-tools / ask-user-question

14.5 优劣分析

优点 缺点
标准 ToolSet 灵活 需要维护两层
平台工具丰富 实现复杂

14.6 如何互相配合

  • assembleTools() 合并编码工具 + 平台工具 + MCP 工具
  • wrapWithPermissions() 包一层 execute 拦截
  • 交给 streamText({ tools })

Part 16

第 15 章:模块 13——MCP 集成

15.1 目的

接入外部 MCP server,让 AI 可以调用外部工具。

15.2 实现形式

  • src/lib/mcp-tool-adapter.ts:MCP 工具适配器

15.3 技术栈

  • MCP 协议(stdio / sse / http)

15.4 功能定义取舍

为什么支持三种传输: - stdio:本地 MCP server - sse:远程 MCP server - http:远程 MCP server

15.5 优劣分析

优点 缺点
标准化协议 需要维护适配器
三种传输灵活 实现复杂

15.6 如何互相配合

  • buildMcpToolSet() 把外部 MCP server 的工具聚合进 ToolSet
  • 与编码工具、平台工具一起交给 streamText({ tools })

Part 17

第 16 章:模块 14——技能系统

16.1 目的

让用户可以定义可复用的 Agent 技能。

16.2 实现形式

  • src/lib/skill-parser.ts:技能解析
  • src/lib/skill-discovery.ts:技能发现
  • src/lib/skill-executor.ts:技能执行

16.3 技术栈

  • YAML frontmatter + Markdown body

16.4 功能定义取舍

为什么兼容 Claude Code 格式: - 用户可能已经有一些 Claude Code 技能 - 格式兼容降低迁移成本

为什么用 fork 模式: - 技能可以作为子 Agent 运行 - 落到 subagent_runs durable 体系

16.5 优劣分析

优点 缺点
兼容 Claude Code 需要维护解析器
fork 模式灵活 实现复杂

16.6 如何互相配合

  • skill-discovery.ts 扫描技能目录
  • skill-parser.ts 解析技能文件
  • skill-executor.ts 执行技能(inline 注入或 fork 子代理)

Part 18

第 17 章:模块 15——子 Agent 系统

17.1 目的

让 AI 可以 spawn 子 Agent 并行执行任务。

17.2 实现形式

  • src/lib/subagent-orchestration.ts:依赖编排
  • src/lib/subagent-models.ts:多模型路由
  • src/lib/db.ts:subagent_runs 表

17.3 技术栈

  • SQLite(durable lifecycle)

17.4 功能定义取舍

为什么用 durable lifecycle: - 子 Agent 可能崩溃,需要持久化状态 - 用户可能刷新页面,需要恢复子 Agent

为什么用声明式 DAG: - 子 Agent 之间可能有依赖关系 - 声明式 DAG 让依赖关系显式化

17.5 优劣分析

优点 缺点
可靠性高 实现复杂
依赖编排灵活 需要维护 DAG

17.6 如何互相配合

  • 启动 child 前先写 subagent_runs.running
  • 结束后 settling → terminal
  • resolver 轮询 durable 表等上游 terminal

Part 19

第 18 章:模块 16——Bridge 子系统

18.1 目的

让用户可以通过 Telegram/飞书/Discord/QQ/微信远程控制桌面 Agent。

18.2 实现形式

  • src/lib/bridge/bridge-manager.ts:Bridge 管理器(1371 行)
  • src/lib/bridge/channel-adapter.ts:渠道适配器基类
  • src/lib/bridge/adapters/:5 个渠道适配器
  • src/lib/channels/:渠道插件

18.3 技术栈

  • Telegram Bot API
  • 飞书 SDK
  • Discord.js

18.4 功能定义取舍

为什么用适配器模式: - 每个 IM 平台的 API 不同,需要适配器统一接口 - 新平台只需要实现适配器

为什么用权限转发: - Claude 流会阻塞等权限,需要把权限请求转成 IM 内联按钮 - 用户在 IM 里点按钮就能确认权限

18.5 优劣分析

优点 缺点
远程控制 实现复杂
权限转发 需要处理渠道差异

18.6 如何互相配合

  • adapter.consumeOne() 接收 IM 消息
  • router.resolve() 路由到 CodePilot session
  • engine.processMessage() 调用 SDK
  • delivery-layer 格式化 + 分片发送回 IM
  • permission-broker 把权限请求转成 IM 内联按钮

Part 20

第 19 章:模块 17——媒体生成

19.1 目的

让 AI 可以生成图片。

19.2 实现形式

  • src/lib/image-generator.ts:图片生成
  • src/lib/job-executor.ts:批量任务执行器

19.3 技术栈

  • Gemini API
  • Anthropic API

19.4 功能定义取舍

为什么用批量任务: - 用户可能需要一次生成多张图片 - 批量任务可以并行执行

19.5 优劣分析

优点 缺点
批量任务 需要管理任务队列
Gallery 展示 需要存储图片

19.6 如何互相配合

  • generateSingleImage() 生成单张图片
  • job-executor 执行批量任务
  • Gallery 展示生成的图片

Part 21

第 20 章:模块 18——任务调度

20.1 目的

让用户可以定时执行任务。

20.2 实现形式

  • src/lib/tasks.ts:任务管理
  • src/lib/scheduler.ts:任务调度

20.3 技术栈

  • cron 表达式

20.4 功能定义取舍

为什么用 cron: - 标准的时间表达式 - 用户可能已经熟悉

20.5 优劣分析

优点 缺点
标准 cron 需要解析 cron 表达式
定时任务 需要管理调度器

20.6 如何互相配合

  • ensureSchedulerRunning 在首个 chat 请求时启动
  • cron 表达式或间隔调度
  • 任务执行结果存入 DB

Part 22

第 21 章:模块 19——设置系统

21.1 目的

让用户可以配置应用设置。

21.2 实现形式

  • src/app/settings/:设置页面
  • src/lib/settings.ts:设置管理

21.3 技术栈

  • React 19
  • Tailwind CSS 4

21.4 功能定义取舍

为什么用键值存储: - 设置是简单的键值对 - 不需要复杂的 schema

21.5 优劣分析

优点 缺点
简单 不支持复杂设置
键值存储 需要手动管理

21.6 如何互相配合

  • settings 表存储键值对
  • 设置页面读取/写入设置
  • 其他模块通过 getSetting() 读取设置

Part 23

第 22 章:模块 20——错误分类与诊断

22.1 目的

让用户可以快速诊断和修复问题。

22.2 实现形式

  • src/lib/error-classifier.ts:错误分类(589 行)
  • src/lib/provider-doctor.ts:Provider 诊断(1093 行)

22.3 技术栈

  • 正则表达式
  • 探针模式

22.4 功能定义取舍

为什么用 28 类结构化错误: - 不同错误需要不同的处理方式 - 结构化错误让 UI 可以显示具体的修复建议

为什么用 5 个快探针: - 并行执行,快速诊断 - 每个探针只关注一个方面

22.5 优劣分析

优点 缺点
结构化错误 需要维护错误模式
快速诊断 需要维护探针

22.6 如何互相配合

  • classifyError 分类错误
  • buildRecoveryActions 生成修复建议
  • runDiagnosis 并行执行 5 个探针
  • computeRepairs 生成修复动作

Part 24

第 23 章:模块 21——Electron 主进程

23.1 目的

管理 Electron 窗口、终端、通知、文件对话框。

23.2 实现形式

  • electron/main.ts:Electron 主进程(2566 行)
  • electron/preload.ts:contextBridge 暴露

23.3 技术栈

  • Electron 40
  • node-pty

23.4 功能定义取舍

为什么用 utilityProcess.fork: - 主进程只做壳,Next.js server 跑业务逻辑 - 进程隔离,Next.js 崩溃不影响主进程

为什么用 node-pty: - 需要终端模拟器 - node-pty 是 Electron 生态最成熟的 pty 库

23.5 优劣分析

优点 缺点
系统能力完整 内存占用高
进程隔离 打包体积大

23.6 如何互相配合

  • utilityProcess.fork 启动 Next.js server
  • mainWindow.loadURL 加载 Next.js 页面
  • contextBridge.exposeInMainWorld 暴露系统能力
  • node-pty 提供终端模拟器

Part 25

第 24 章:模块 22——打包与分发

24.1 目的

把应用打包成 macOS / Windows / Linux 可执行文件。

24.2 实现形式

  • electron-builder.yml:打包配置
  • scripts/build-electron.mjs:打包脚本

24.3 技术栈

  • electron-builder 26

24.4 功能定义取舍

为什么用 electron-builder: - 成熟的打包工具 - 支持 macOS / Windows / Linux

24.5 优劣分析

优点 缺点
成熟 打包体积大
跨平台 打包时间长

24.6 如何互相配合

  • npm run electron:build 构建 Next.js + Electron
  • npm run electron:pack 打包成可执行文件
  • electron-builder 生成 DMG / NSIS / AppImage

Part 26

第 25 章:用户输入提示词后的完整工作流

这是整个教程最重要的一章。我们跟着"用户在聊天框里输入一段提示词,按下回车"这个动作,从最前端的 React 组件,一直到最深的 Agent 引擎,再回到最前端的屏幕渲染,完整走一遍。

25.1 全景流程图

sequenceDiagram
    participant U as 用户
    participant UI as MessageInput
    participant API as /api/chat
    participant Lock as 会话锁
    participant Ctx as assembleContext
    participant RT as Runtime Registry
    participant SDK as Claude SDK Runtime
    participant NAT as Native Runtime
    participant CDX as Codex Runtime
    participant SSE as stream-session-manager
    participant Hook as useSSEStream
    participant ML as MessageList
    participant DB as SQLite

    U->>UI: 输入提示词,按回车
    UI->>API: POST /api/chat
    API->>Lock: acquireSessionLock
    Lock-->>API: 409 SESSION_BUSY / 200 OK
    API->>DB: addMessage
    API->>Ctx: assembleContext
    Ctx-->>API: system prompt + tools + messages
    API->>RT: resolveRuntime
    RT-->>API: claude-code-sdk / native / codex

    alt Claude SDK Runtime
        API->>SDK: streamClaudeSdk
        SDK->>SDK: SDK query() 子进程
        SDK-->>API: SSE 事件流
    else Native Runtime
        API->>NAT: runAgentLoop
        NAT->>NAT: while (step < maxSteps)
        NAT-->>API: SSE 事件流
    else Codex Runtime
        API->>CDX: codex app-server
        CDX->>CDX: thread/start + turn/start
        CDX-->>API: SSE 事件流
    end

    API-->>SSE: SSE 流式返回
    SSE->>Hook: consumeSSEStream
    Hook->>ML: 渲染消息
    ML-->>U: 显示 AI 响应
    SSE->>DB: collectStreamResponse 持久化
    API->>Lock: 释放锁

25.2 第一步:用户输入

用户在 MessageInput 组件里输入提示词,按下回车。

// src/components/chat/MessageInput.tsx
const handleSubmit = async (content: string) => {
  // 1. 乐观更新 UI(立即显示用户消息)
  addUserMessage(content)

  // 2. 发送 POST 请求
  await fetch('/api/chat', {
    method: 'POST',
    body: JSON.stringify({
      session_id: currentSessionId,
      content,
      model: currentModel,
      provider_id: currentProviderId,
    })
  })
}

关键点: - 乐观更新:用户消息立即显示,不等服务器响应 - POST 请求包含 session_id、content、model、provider_id

25.3 第二步:API 路由处理

/api/chat 路由(src/app/api/chat/route.ts)处理请求并触发 Agent。另有 /api/chat/messages 仅持久化消息、不跑模型。

// src/app/api/chat/route.ts
export async function POST(req: Request) {
  // 1. 校验 body
  const { session_id, content, model, provider_id } = await req.json()

  // 2. 前置检查:是否有可用的 Provider
  if (!hasCodePilotProvider()) {
    return new Response('NEEDS_PROVIDER_SETUP', { status: 412 })
  }

  // 3. 会话锁:防止同一会话并发请求
  const lock = await acquireSessionLock(session_id)
  if (!lock) {
    return new Response('SESSION_BUSY', { status: 409 })
  }

  try {
    // 4. 解析 Provider + Runtime
    const provider = await resolveProvider(provider_id)
    const runtime = await resolveRuntime(provider)

    // 5. 消息入库
    await addMessage(session_id, 'user', content)

    // 6. 上下文组装
    const context = await assembleContext(session_id, provider, runtime)

    // 7. 分流到 Runtime
    let stream: ReadableStream<string>
    switch (runtime) {
      case 'claude-code-sdk':
        stream = await streamClaudeSdk(context)
        break
      case 'native':
        stream = await runAgentLoop(context)
        break
      case 'codex':
        stream = await runCodexRuntime(context)
        break
    }

    // 8. SSE 流式返回
    return new Response(stream, {
      headers: { 'Content-Type': 'text/event-stream' }
    })
  } finally {
    // 9. 释放锁
    await releaseSessionLock(session_id)
  }
}

关键点: - 会话锁防止同一会话并发请求 - Runtime 选择是五级优先级 - 上下文组装包括 system prompt、tools、messages

25.4 第三步:上下文组装

assembleContext 把用户输入、历史消息、系统提示词、工具描述打包成发给模型的请求。

// src/lib/agent-tools.ts
const assembleContext = async (session_id, provider, runtime) => {
  // 1. 获取历史消息
  const messages = await getMessages(session_id)

  // 2. 构建系统提示词
  const systemPrompt = await buildSystemPrompt(provider, runtime)

  // 3. 装工具
  const tools = await assembleTools(provider, runtime)

  // 4. 上下文压缩
  const compressedMessages = await contextCompressor(messages)

  return {
    systemPrompt,
    tools,
    messages: compressedMessages,
  }
}

关键点: - Harness 能力契约保证三条 Runtime 的 system prompt 不漂移 - 上下文压缩防止超出模型窗口

25.5 第四步:Runtime 分流

resolveRuntime() 按五级优先级选择 Runtime:

// src/lib/runtime/registry.ts
const resolveRuntime = async (provider) => {
  // 1. Codex 显式
  if (provider.type === 'codex') return 'codex'

  // 2. 显式 override
  if (sessionRuntimeOverride) return sessionRuntimeOverride

  // 3. cli_enabled=false
  if (!cliEnabled) return 'native'

  // 4. 全局设置
  if (globalSettings.runtime) return globalSettings.runtime

  // 5. auto:装了 Claude CLI 用 SDK,否则 Native
  return hasClaudeCli ? 'claude-code-sdk' : 'native'
}

25.6 第五步 A:Claude SDK Runtime

如果选择了 Claude SDK Runtime,streamClaudeSdk() 创建 SDK conversation。

// src/lib/claude-client.ts
const streamClaudeSdk = async (context) => {
  // 1. 创建 SDK conversation
  const conversation = await query({
    prompt: context.messages,
    options: {
      systemPrompt: context.systemPrompt,
      tools: context.tools,
      cwd: context.workingDirectory,
      env: context.env,
    }
  })

  // 2. 注册到会话注册表
  registerConversation(session_id, conversation, lockId)

  // 3. 事件泵:逐条消费 SDK 事件
  const stream = new ReadableStream({
    async start(controller) {
      for await (const message of conversation) {
        switch (message.type) {
          case 'assistant':
            // 提取 tool_use → SSE
            controller.enqueue(formatToolUse(message))
            break
          case 'user':
            // tool_result / 媒体块 / TodoWrite 同步
            controller.enqueue(formatToolResult(message))
            break
          case 'stream_event':
            // 文本 delta
            controller.enqueue(formatTextDelta(message))
            break
          case 'result':
            // extractTokenUsage + terminal_reason
            controller.enqueue(formatResult(message))
            break
        }
      }
      controller.close()
    }
  })

  return stream
}

关键点: - 真正的 Agent 循环在 SDK 子进程内部 - CodePilot 只做事件转发 + 护栏 - 护栏包括:两级 idle 预算、PTL 压缩重试、lockId 门控

25.7 第五步 B:Native Runtime

如果选择了 Native Runtime,runAgentLoop() 启动手动 while 循环。

// src/lib/agent-loop.ts
const runAgentLoop = async (context) => {
  const stream = new ReadableStream({
    async start(controller) {
      let step = 0
      let messages = context.messages

      while (step < maxSteps) {
        // 1. 清洗模型参数
        const modelOptions = sanitizeClaudeModelOptions(context.provider)

        // 2. 上下文裁剪
        messages = pruneOldToolResults(messages)

        // 3. 超时预算上膛
        timeoutCtl.onStepRequest()

        // 4. 单次 streamText()
        const result = streamText({
          model: context.model,
          tools: context.tools,
          messages,
          ...modelOptions,
        })

        // 5. 逐事件翻译为 SSE
        let hasToolCalls = false
        for await (const event of timeoutCtl.guardStream(result.fullStream)) {
          switch (event.type) {
            case 'text-delta':
              controller.enqueue(formatText(event))
              break
            case 'reasoning-delta':
              controller.enqueue(formatThinking(event))
              break
            case 'tool-call':
              hasToolCalls = true
              controller.enqueue(formatToolUse(event))
              break
            case 'tool-result':
              controller.enqueue(formatToolResult(event))
              break
          }
        }

        // 6. 终止判断
        if (!hasToolCalls) break
        if (doomLoopDetected(messages)) break

        // 7. 进入下一轮
        messages = [...messages, ...result.response.messages]
        step++
      }

      controller.close()
    }
  })

  return stream
}

关键点: - 手动循环可以在步骤之间插入权限检查、DB 持久化、超时预算 - doom-loop 检测防止死循环

25.8 第五步 C:Codex Runtime

如果选择了 Codex Runtime,runCodexRuntime() 与 Codex app-server 通信。

// src/lib/codex/runtime.ts
const runCodexRuntime = async (context) => {
  // 1. 获取 Codex app-server
  const appServer = await getCodexAppServer()

  // 2. 创建会话
  const thread = await appServer.threadStart({
    model: context.model,
    instructions: context.systemPrompt,
  })

  // 3. 开始一轮对话
  const turn = await thread.turnStart({
    input: context.messages,
  })

  // 4. 订阅通知
  const stream = new ReadableStream({
    async start(controller) {
      for await (const notification of turn.notifications()) {
        const event = translateCodexNotification(notification)
        controller.enqueue(formatSSE(event))
      }
      controller.close()
    }
  })

  return stream
}

25.9 第六步:SSE 流管理

stream-session-manager.ts 管理 SSE 流的生命周期。

// src/lib/stream-session-manager.ts
const startStream = async (session_id) => {
  // 1. 创建流
  const response = await fetch('/api/chat', {
    method: 'POST',
    body: JSON.stringify({ session_id, content, model, provider_id })
  })

  // 2. 消费 SSE 流
  const reader = response.body.getReader()
  const decoder = new TextDecoder()

  while (true) {
    const { done, value } = await reader.read()
    if (done) break

    const chunk = decoder.decode(value)
    const events = parseSSE(chunk)

    for (const event of events) {
      // 写入累积器
      accumulator.write(event)

      // 100ms 节流 emit 快照
      throttledEmit(accumulator.snapshot())
    }
  }
}

关键点: - 流独立于 React 组件存活(globalThis 单例) - 两级 idle 预算(首 token 前 10min,之后 5.5min) - 2s force-abort 安全网

25.10 第七步:前端渲染

useSSEStream hook 订阅流快照,MessageList 渲染消息。

// src/hooks/useSSEStream.ts
const useSSEStream = (session_id) => {
  const [messages, setMessages] = useState([])

  useEffect(() => {
    const unsubscribe = streamSessionManager.subscribe(session_id, (snapshot) => {
      setMessages(snapshot.messages)
    })

    return unsubscribe
  }, [session_id])

  return messages
}

// src/components/chat/MessageList.tsx
const MessageList = ({ session_id }) => {
  const messages = useSSEStream(session_id)

  return (
    <div>
      {messages.map(msg => (
        <MessageItem key={msg.id} message={msg} />
      ))}
    </div>
  )
}

// src/components/chat/MessageItem.tsx
const MessageItem = ({ message }) => {
  return (
    <div>
      {message.content.map(block => {
        switch (block.type) {
          case 'text':
            return <MarkdownRenderer content={block.text} />
          case 'thinking':
            return <ThinkingBlock content={block.thinking} />
          case 'tool_use':
            return <ToolUseCard tool={block.tool} />
          case 'tool_result':
            return <ToolResultCard result={block.result} />
        }
      })}
    </div>
  )
}

关键点: - 虚拟化渲染长会话 - 按 block.type 分发渲染

25.11 第八步:持久化

collectStreamResponse 在后台持久化消息,即使用户刷新页面也能恢复。

// src/lib/stream-session-manager.ts
const collectStreamResponse = async (session_id, stream) => {
  const reader = stream.getReader()
  const decoder = new TextDecoder()

  while (true) {
    const { done, value } = await reader.read()
    if (done) break

    const chunk = decoder.decode(value)
    const events = parseSSE(chunk)

    for (const event of events) {
      // 持久化到 SQLite
      await addMessage(session_id, event.role, event.content)
    }
  }
}

25.12 完整时序图

用户输入 "帮我写一个函数"
  ↓
MessageInput 乐观更新 UI
  ↓
POST /api/chat
  ↓
会话锁 acquireSessionLock
  ↓
addMessage 入库
  ↓
assembleContext
  ├── 获取历史消息
  ├── 构建系统提示词
  ├── 装工具
  └── 上下文压缩
  ↓
resolveRuntime → claude-code-sdk
  ↓
streamClaudeSdk
  ├── 创建 SDK conversation
  ├── 注册到 conversation-registry
  └── 事件泵:for await (message of conversation)
      ├── assistant → tool_use → SSE
      ├── user → tool_result → SSE
      ├── stream_event → text delta → SSE
      └── result → token usage → SSE
  ↓
SSE 流式返回
  ↓
stream-session-manager
  ├── consumeSSEStream
  ├── 两级 idle 预算
  └── 100ms 节流 emit 快照
  ↓
useSSEStream hook
  ↓
MessageList 渲染
  ├── text → MarkdownRenderer
  ├── thinking → ThinkingBlock
  ├── tool_use → ToolUseCard
  └── tool_result → ToolResultCard
  ↓
用户看到 AI 响应
  ↓
collectStreamResponse 持久化到 SQLite
  ↓
释放会话锁

Part 27

第 26 章:关键设计决策总结

26.1 为什么做三条 Runtime

CodePilot 最核心的架构决策是Runtime 可替换。这不是"多 Provider"(模型层面的开放),而是"多引擎"(循环层面的开放)。

26.2 为什么用 Harness 能力契约

harness/context-compiler.ts 是纯函数编译层,保证三条 Runtime 给模型看的能力描述不漂移。

26.3 为什么用 Bridge 权限转发

permission-broker.ts 把权限请求转成 IM 内联按钮,打破"Claude 流阻塞等权限"的死锁。

26.4 为什么用 SQLite

本地优先、零配置、同步 API、WAL 模式。

26.5 为什么用 Electron + Next.js

跨平台桌面 + 系统能力 + App Router 同时做前端和 API 层。


Part 28

附录:验收清单

完成本教程后,你应该能:

  • [ ] 解释 CodePilot 的三条 Runtime 及其本质区别
  • [ ] 画出用户输入提示词后的完整工作流
  • [ ] 解释 Harness 能力契约的作用
  • [ ] 解释 Bridge 权限转发如何打破死锁
  • [ ] 解释为什么用 SQLite 而不是云端数据库
  • [ ] 解释为什么用 Electron + Next.js 而不是 Tauri 或纯 Web
  • [ ] 从零搭建一个最小可用的 CodePilot 原型

本教程基于 CodePilot v0.62.0 源码逐文件分析写成。所有 文件:行号 引用已用 grep -n / sed -n 回验。