跟着"用户在聊天框里输入一段提示词,按下回车"这个动作,从零走到尾。
你不是在写一个终端 CLI——你在写一个 Electron 桌面应用,里面跑着一个 Next.js 服务器,连着三条不同的 Agent 引擎,通过 SSE 把 AI 的思考过程实时推到用户的屏幕上。
这份教程以 CodePilot v0.62.0 为蓝本,从产品需求文档(PRD)开始,把所有功能模块按开发顺序罗列,每个模块讲清楚:目的、实现形式、技术栈、功能定义取舍、优劣分析、如何互相配合。
第 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 | 多模型统一接口 |
第 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 | — |
第 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: 打包与分发]
第 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 通信
第 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()在首次调用时初始化 schemamigrateDb()在 schema 变更时自动迁移withMigrationLock()防止并发迁移
第 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 如何互相配合
MessageList从useSSEStreamhook 获取消息数据MessageItem按block.type分发到不同的渲染器MessageInput通过 POST/api/chat发送消息
第 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'),consumeSSEStreamstopStreamWith停止流,先武装 2s force-abort,再调/api/chat/interruptsubscribe让组件订阅流快照
第 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()解析当前应该使用哪个 ProvidercreateModel()根据 Provider 创建 AI SDK 模型toClaudeCodeEnv()把 Provider 配置转换成 Claude Code SDK 的环境变量
第 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 事件
第 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 持久化在每一步之后
第 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 conversationfor await (const message of conversation)逐条消费事件conversation-registry.ts注册活跃会话stream-session-manager.ts管理 SSE 流
第 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 Runtimesrc/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二进制并 spawncodex app-serverthread/resume|thread/start创建会话turn/start开始一轮对话event-mapper.translateCodexNotification翻成 canonical 事件再发 SSE
第 12 章:模块 10——权限系统
12.1 目的
确保 AI 的危险操作(写文件、执行命令)需要用户确认。
12.2 实现形式
src/lib/permission-checker.ts:规则引擎src/lib/permission/profile.ts:会话 Profilesrc/lib/permission/approval-token.ts:HMAC 防伪造
12.3 技术栈
- HMAC(防伪造)
12.4 功能定义取舍
为什么用三层权限: - 规则引擎:第一防线,快速拦截明显危险的操作 - 执行时弹窗:第二防线,让用户确认不确定的操作 - 会话 Profile:第三防线,允许用户设置"全自动"或"全手动"
为什么用 HMAC 防伪造: - 权限请求的 approvalToken 需要防伪造,否则恶意网站可以伪造权限确认
为什么用 5 分钟超时: - 用户可能忘记确认,需要超时自动 deny
12.5 优劣分析
| 优点 | 缺点 |
|---|---|
| 三层防线安全 | 实现复杂 |
| HMAC 防伪造 | 需要管理密钥 |
| 超时自动 deny | 可能误杀 |
12.6 如何互相配合
- 工具执行前
checkPermission ask时写 DBpermission_requests、发permission_requestSSEregisterPendingPermission挂起等/api/chat/permission回包- 5 分钟超时自动 deny
第 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 输出"
第 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 })
第 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 })
第 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 子代理)
第 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
第 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 sessionengine.processMessage()调用 SDKdelivery-layer格式化 + 分片发送回 IMpermission-broker把权限请求转成 IM 内联按钮
第 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 展示生成的图片
第 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
第 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()读取设置
第 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生成修复动作
第 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 servermainWindow.loadURL加载 Next.js 页面contextBridge.exposeInMainWorld暴露系统能力node-pty提供终端模拟器
第 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 + Electronnpm run electron:pack打包成可执行文件electron-builder生成 DMG / NSIS / AppImage
第 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
↓
释放会话锁
第 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 层。
附录:验收清单
完成本教程后,你应该能:
- [ ] 解释 CodePilot 的三条 Runtime 及其本质区别
- [ ] 画出用户输入提示词后的完整工作流
- [ ] 解释 Harness 能力契约的作用
- [ ] 解释 Bridge 权限转发如何打破死锁
- [ ] 解释为什么用 SQLite 而不是云端数据库
- [ ] 解释为什么用 Electron + Next.js 而不是 Tauri 或纯 Web
- [ ] 从零搭建一个最小可用的 CodePilot 原型
本教程基于 CodePilot v0.62.0 源码逐文件分析写成。所有 文件:行号 引用已用 grep -n / sed -n 回验。