From 879f732ef5c8b816ca210da3492e01c38582e00a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Mar 2026 09:11:25 +0000 Subject: [PATCH 1/7] =?UTF-8?q?docs:=20add=20chapter=2001=20'=E5=85=A8?= =?UTF-8?q?=E6=99=AF=E8=A7=86=E9=87=8E'=20for=20Chinese=20technical=20book?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add three comprehensive files for the navigation chapter: - 01-一图看懂OpenCode.md: Architecture overview with 6-layer diagram - 02-核心概念关系图谱.md: ER diagrams and concept relationships - 03-Monorepo全景-19个包的关系.md: Package dependency topology Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...33\276\347\234\213\346\207\202OpenCode.md" | 291 ++++++++++++ ...63\347\263\273\345\233\276\350\260\261.md" | 404 ++++++++++++++++ ...05\347\232\204\345\205\263\347\263\273.md" | 441 ++++++++++++++++++ 3 files changed, 1136 insertions(+) create mode 100644 "all-in-one-book/01-\345\205\250\346\231\257\350\247\206\351\207\216/01-\344\270\200\345\233\276\347\234\213\346\207\202OpenCode.md" create mode 100644 "all-in-one-book/01-\345\205\250\346\231\257\350\247\206\351\207\216/02-\346\240\270\345\277\203\346\246\202\345\277\265\345\205\263\347\263\273\345\233\276\350\260\261.md" create mode 100644 "all-in-one-book/01-\345\205\250\346\231\257\350\247\206\351\207\216/03-Monorepo\345\205\250\346\231\257-19\344\270\252\345\214\205\347\232\204\345\205\263\347\263\273.md" diff --git "a/all-in-one-book/01-\345\205\250\346\231\257\350\247\206\351\207\216/01-\344\270\200\345\233\276\347\234\213\346\207\202OpenCode.md" "b/all-in-one-book/01-\345\205\250\346\231\257\350\247\206\351\207\216/01-\344\270\200\345\233\276\347\234\213\346\207\202OpenCode.md" new file mode 100644 index 000000000000..69f71d8e21e1 --- /dev/null +++ "b/all-in-one-book/01-\345\205\250\346\231\257\350\247\206\351\207\216/01-\344\270\200\345\233\276\347\234\213\346\207\202OpenCode.md" @@ -0,0 +1,291 @@ +# 一图看懂 OpenCode + +> 📌 一句话总结:OpenCode 是一个六层架构的 AI 编程助手——从用户界面到基础设施,每一层都可插拔、可替换。 +> 🗺️ 本节在全景中的位置:这是全书的**导航地图**,后续所有章节都会回到这张图上标注"你在这里"。 + +--- + +## 全景架构图 + +我们先来看 OpenCode 的完整架构。这张图从上到下分为六层,每一层解决一类问题: + +```mermaid +graph TB + subgraph User["👤 用户层(User)"] + CLI["命令行
CLI Entry"] + WebUI["网页界面
Web UI"] + Desktop["桌面应用
Desktop App"] + VSCode["VS Code 插件
Extension"] + Slack["Slack Bot"] + GHAction["GitHub Action"] + end + + subgraph Interface["🖥️ 接口层(Interface)"] + TUI["终端界面
packages/opencode/src/tui/"] + Server["HTTP 服务
packages/opencode/src/server/server.ts"] + Hono["Hono 路由
REST API + OpenAPI"] + Event["事件流
SSE /event"] + end + + subgraph Engine["⚙️ 引擎层(Engine)"] + Agent["智能体
packages/opencode/src/agent/agent.ts"] + Session["会话
packages/opencode/src/session/index.ts"] + Provider["模型提供者
packages/opencode/src/provider/provider.ts"] + Message["消息
packages/opencode/src/session/message-v2.ts"] + Permission["权限
packages/opencode/src/permission/"] + end + + subgraph Capability["🔧 能力层(Capability)"] + Tool["工具
packages/opencode/src/tool/"] + Skill["技能
packages/opencode/src/skill/index.ts"] + MCP["MCP 协议
packages/opencode/src/mcp/index.ts"] + LSP["语言服务
packages/opencode/src/lsp/"] + Command["命令
packages/opencode/src/command/index.ts"] + Plugin["插件
packages/opencode/src/plugin/"] + end + + subgraph Infra["🏗️ 基础设施层(Infrastructure)"] + Storage["存储
packages/opencode/src/storage/"] + Git["Git 操作
packages/opencode/src/git/"] + Snapshot["快照
packages/opencode/src/snapshot/"] + FileOps["文件操作
packages/opencode/src/file/"] + Auth["认证
packages/opencode/src/auth/"] + Config["配置
packages/opencode/src/config/config.ts"] + end + + subgraph External["☁️ 外部服务层(External)"] + LLM["大语言模型
OpenAI / Anthropic / Google ..."] + MCPServer["MCP 服务器
外部工具服务"] + GitRemote["Git 远程仓库
GitHub / GitLab"] + end + + CLI --> TUI + WebUI --> Server + Desktop --> Server + VSCode --> Server + Slack --> Server + GHAction --> Server + + TUI --> Agent + Server --> Hono + Hono --> Agent + Hono --> Session + Hono --> Provider + Event --> Session + + Agent --> Session + Agent --> Provider + Agent --> Permission + Session --> Message + Provider --> LLM + + Agent --> Tool + Agent --> Skill + Agent --> Command + Tool --> MCP + Tool --> LSP + Tool --> Plugin + + Tool --> FileOps + Tool --> Git + Tool --> Snapshot + Session --> Storage + Agent --> Config + Provider --> Auth + + MCP --> MCPServer + Git --> GitRemote +``` + +--- + +## 餐巾纸架构图 + +如果只用 5 个概念解释 OpenCode,那就是这张"餐巾纸图(Napkin Diagram)": + +```mermaid +graph LR + U["🧑 用户"] -->|"提问"| A["🤖 Agent
智能体"] + A -->|"选择模型"| P["🧠 Provider
模型提供者"] + A -->|"调用工具"| T["🔧 Tool
工具"] + A -->|"读写记录"| S["💬 Session
会话"] + T -->|"操作代码"| F["📁 文件系统
& Git"] + + style U fill:#e1f5fe + style A fill:#fff3e0 + style P fill:#f3e5f5 + style T fill:#e8f5e9 + style S fill:#fce4ec + style F fill:#f5f5f5 +``` + +**用一句话串起来**:用户向**智能体(Agent)**提问,智能体通过**模型提供者(Provider)**调用大语言模型生成回答,过程中使用**工具(Tool)**来读写文件、执行命令,所有对话记录保存在**会话(Session)**中。 + +--- + +## 每个模块一句话 + +下表按架构分层列出 OpenCode 的核心模块,用一句话 + 一个生活类比帮助理解: + +| 层级 | 模块 | 一句话描述 | 生活类比 | +|------|------|-----------|---------| +| 接口层 | **Server 服务器** | 基于 Hono 框架的 HTTP 服务,暴露 REST API 和 SSE 事件流 | 餐厅的前台接待 | +| 接口层 | **TUI 终端界面** | 基于 Ink(React)的终端交互界面 | 餐厅的点餐屏幕 | +| 引擎层 | **Agent 智能体** | 编排模型调用和工具使用的核心决策单元,内置 `build`、`plan`、`explore` 等角色 | 项目经理 | +| 引擎层 | **Session 会话** | 管理对话历史、消息和上下文窗口 | 会议纪要 | +| 引擎层 | **Provider 模型提供者** | 统一封装 18+ 家大模型服务商(OpenAI、Anthropic、Google 等) | 翻译中介 | +| 引擎层 | **Message 消息** | 结构化的对话消息,包含文本(Text)、推理(Reasoning)、工具调用(ToolCall)等多种部件(Part) | 信封里的多页信件 | +| 引擎层 | **Permission 权限** | 细粒度的操作权限控制:允许(allow)/ 询问(ask)/ 拒绝(deny) | 公司审批流 | +| 能力层 | **Tool 工具** | 40+ 个内置工具:bash、read、edit、write、grep、glob 等 | 工人的工具箱 | +| 能力层 | **Skill 技能** | 以 `SKILL.md` 文件定义的可复用提示词模板 | 操作手册 | +| 能力层 | **MCP 协议** | 模型上下文协议(Model Context Protocol)客户端,连接外部工具服务 | USB 接口 | +| 能力层 | **LSP 语言服务** | 集成语言服务器提供代码智能:悬停(hover)、跳转定义(definition)、引用(references) | 代码的 GPS 导航 | +| 能力层 | **Command 命令** | 可绑定快捷键的预定义操作模板,来源于配置(Config)、技能(Skill)或 MCP | 快捷指令 | +| 能力层 | **Plugin 插件** | 外部扩展包,可注册新工具和新功能 | 浏览器扩展 | +| 基础设施 | **Storage 存储** | 基于 SQLite + Drizzle ORM 的持久化层,支持 Bun 和 Node.js 两种运行时 | 档案室 | +| 基础设施 | **Config 配置** | 8 级优先级的配置系统:远程默认 → 全局 → 项目 → 环境变量 → 企业托管 | 公司规章制度 | +| 基础设施 | **Git 版本控制** | 封装 Git 命令行操作:分支、状态、差异、合并基准 | 时间机器 | +| 基础设施 | **Snapshot 快照** | 用独立的 Git 仓库追踪工作区文件变更,支持回滚 | 游戏存档 | +| 基础设施 | **File 文件操作** | 文件读写、二进制检测、gitignore 过滤、模糊搜索 | 文件管理器 | +| 基础设施 | **Auth 认证** | 管理 API 密钥(API Key)、OAuth 令牌(Token)和 Well-Known 凭证 | 钥匙串 | + +--- + +## 关键设计决策 + +### 1. Effect 架构:函数式依赖注入 + +OpenCode 使用 [Effect](https://effect.website/) 库进行依赖注入和错误处理。每个模块都定义为 Effect 的 `Service`,通过 `Layer` 组合。 + +``` +// packages/opencode/src/agent/agent.ts 中的典型模式 +Agent.Service = Effect.Tag() +``` + +**为什么这样设计?** Effect 让每个服务都可测试、可替换,且所有错误路径都在类型系统中显式表达——不会有"忘记 catch"的情况。 + +### 2. 多智能体系统:专业分工 + +OpenCode 不是单一 Agent,而是一个多智能体系统(Multi-Agent System): + +```mermaid +graph TD + User["用户"] --> Build["build 智能体
默认执行者"] + User --> Plan["plan 智能体
只读规划者"] + Build --> General["general 子智能体
多步骤任务"] + Build --> Explore["explore 子智能体
快速探索"] + Build --> Compaction["compaction 智能体
上下文压缩"] + Build --> Title["title 智能体
标题生成"] + Build --> Summary["summary 智能体
摘要生成"] + + style Build fill:#4CAF50,color:#fff + style Plan fill:#2196F3,color:#fff + style General fill:#FF9800,color:#fff + style Explore fill:#9C27B0,color:#fff +``` + +- **`build`**(构建者):默认智能体,拥有完整工具权限,可以读写文件、执行命令 +- **`plan`**(规划者):只读智能体,只能查看代码不能修改,用于分析和规划 +- **`general`**(通用者):子智能体,用于复杂多步骤任务的委派 +- **`explore`**(探索者):子智能体,擅长快速搜索代码库(grep、glob、bash) + +### 3. 消息的"零件化"设计 + +消息(Message)不是一个单一文本,而是由多个**部件(Part)**组成的结构体: + +| 部件类型 | 用途 | +|---------|------| +| `TextPart` | 文本输出 | +| `ReasoningPart` | LLM 的推理过程(如 Claude 的 thinking) | +| `ToolPart` | 工具调用及其结果,含状态机(pending → running → completed/error) | +| `FilePart` | 文件附件 | +| `SnapshotPart` | 工作区快照 | +| `PatchPart` | 代码差异补丁 | +| `SubtaskPart` | 委派给子智能体的任务 | +| `StepStartPart` / `StepFinishPart` | 多步骤执行的开始和结束 | + +这种设计让前端可以**流式渲染**每个部件,而不需要等整条消息完成。 + +### 4. 工具的权限网关 + +每个工具调用都经过权限系统(Permission)的网关: + +```mermaid +sequenceDiagram + participant A as Agent 智能体 + participant T as Tool 工具 + participant P as Permission 权限 + participant U as User 用户 + + A->>T: 调用 bash("rm -rf node_modules") + T->>P: 请求权限 {name: "bash", patterns: ["rm -rf *"]} + P->>P: 评估规则集 + alt 规则为 allow + P-->>T: ✅ 通过 + else 规则为 ask + P->>U: 🔔 请确认此操作 + U-->>P: once / always / reject + P-->>T: 返回决定 + else 规则为 deny + P-->>T: ❌ 拒绝 + end + T-->>A: 返回结果 +``` + +### 5. 配置的 8 级优先级 + +配置系统(`packages/opencode/src/config/config.ts`)支持 8 级层叠覆盖,优先级从低到高: + +```mermaid +graph BT + A["1️⃣ 远程 .well-known/opencode
组织默认配置"] --> B + B["2️⃣ 全局 ~/.config/opencode/opencode.json"] --> C + C["3️⃣ OPENCODE_CONFIG 环境变量指定的文件"] --> D + D["4️⃣ 项目 opencode.json / opencode.jsonc"] --> E + E["5️⃣ .opencode/ 目录"] --> F + F["6️⃣ OPENCODE_CONFIG_CONTENT 环境变量"] --> G + G["7️⃣ 远程账户配置(已登录时)"] --> H + H["8️⃣ 企业托管配置(最高优先级)"] + + style H fill:#f44336,color:#fff + style A fill:#9E9E9E,color:#fff +``` + +### 6. 统一的提供者抽象 + +`Provider`(`packages/opencode/src/provider/provider.ts`)为 18+ 家大模型服务商提供统一接口: + +| 提供者 | 说明 | +|--------|------| +| OpenAI | GPT 系列 | +| Anthropic | Claude 系列 | +| Google | Gemini / Vertex AI | +| Azure | Azure OpenAI | +| AWS Bedrock | Amazon 托管模型 | +| xAI | Grok 系列 | +| Groq / Mistral / Cohere / Cerebras | 其他云端模型 | +| GitHub Copilot | GitHub 模型服务 | +| OpenRouter / Together AI / DeepInfra | 模型聚合平台 | +| Gateway | 自定义网关 | + +每个模型都声明自己的**能力(Capabilities)**——推理(reasoning)、结构化输出(structuredOutput)、视觉(vision)——Agent 据此选择合适的模型。 + +--- + +## 图解说明 + +让我们回到全景图,理解数据是如何流动的: + +1. **用户请求进入**:通过 CLI/Web/Desktop 等界面到达接口层 +2. **路由分发**:Hono 服务器将请求路由到对应的引擎模块 +3. **智能体编排**:Agent 创建或恢复 Session,选择 Provider 和模型 +4. **模型调用**:Provider 将请求发送到外部 LLM 服务 +5. **工具执行**:LLM 返回的工具调用请求经权限检查后执行 +6. **结果回写**:工具执行结果作为新的消息部件追加到会话中 +7. **持久化**:所有状态通过 Storage 持久化到 SQLite 数据库 + +--- + +## 与下一节的衔接 + +这张全景图告诉了我们"有什么",但还没回答"它们之间是什么关系"。在下一节 [02-核心概念关系图谱](./02-核心概念关系图谱.md) 中,我们将深入每个核心概念之间的实体关系(Entity Relationship),弄清楚一个 Session 里有多少个 Message,一个 Message 里有多少种 Part,以及 Agent、Tool、Skill、Command 这四个容易混淆的概念到底有什么区别。 diff --git "a/all-in-one-book/01-\345\205\250\346\231\257\350\247\206\351\207\216/02-\346\240\270\345\277\203\346\246\202\345\277\265\345\205\263\347\263\273\345\233\276\350\260\261.md" "b/all-in-one-book/01-\345\205\250\346\231\257\350\247\206\351\207\216/02-\346\240\270\345\277\203\346\246\202\345\277\265\345\205\263\347\263\273\345\233\276\350\260\261.md" new file mode 100644 index 000000000000..8bf970baf6e6 --- /dev/null +++ "b/all-in-one-book/01-\345\205\250\346\231\257\350\247\206\351\207\216/02-\346\240\270\345\277\203\346\246\202\345\277\265\345\205\263\347\263\273\345\233\276\350\260\261.md" @@ -0,0 +1,404 @@ +# 核心概念关系图谱 + +> 📌 一句话总结:OpenCode 的核心概念之间存在精确的一对多、多对多关系——理解这些关系,就理解了整个系统的骨架。 +> 🗺️ 本节在全景中的位置:上一节展示了六层架构的"全景图",本节深入到**概念层**,弄清楚每个核心实体的内部结构和相互关系。 + +--- + +## 实体关系图 + +我们先用 ER 图展示 OpenCode 中最重要的数据实体及其关系。这些类型定义在 `packages/opencode/src/session/message-v2.ts` 和 `packages/opencode/src/session/index.ts` 中。 + +```mermaid +erDiagram + Project ||--o{ Session : "拥有" + Session ||--o{ Message : "包含" + Session ||--o| Session : "fork 自" + Session }o--|| Workspace : "属于" + Message ||--o{ Part : "由多个部件组成" + Message }o--|| Agent : "由 Agent 生成" + Message }o--|| Model : "使用模型" + + Part ||--o| ToolCall : "ToolPart 包含" + Part ||--o| Snapshot : "SnapshotPart 引用" + Part ||--o| Patch : "PatchPart 包含" + Part ||--o| Subtask : "SubtaskPart 委派" + + ToolCall }o--|| Tool : "调用" + Agent ||--o{ Tool : "可使用" + Agent }o--|| Permission_Ruleset : "受权限约束" + Agent }o--o| Model : "绑定模型" + + Provider ||--o{ Model : "提供" + Config ||--o{ Provider : "配置" + Config ||--o{ MCP_Server : "配置" + MCP_Server ||--o{ Tool : "暴露" + + Project { + string id PK + string path + } + + Session { + string id PK + string slug + string projectID FK + string title + string version + datetime created + datetime updated + } + + Message { + string id PK + string sessionID FK + string parentID FK "助手消息指向用户消息" + string role "user | assistant" + string agent + number cost + } + + Part { + string id PK + string messageID FK + string type "text | reasoning | tool | file | snapshot | patch | subtask | step_start | step_finish | compaction | retry | agent" + } + + Agent { + string name PK + string mode "primary | subagent" + boolean hidden + number temperature + } + + Provider { + string id PK + string name + } + + Model { + string id PK + string providerID FK + boolean reasoning + boolean vision + } + + Tool { + string id PK + string description + } +``` + +### 关键关系解读 + +| 关系 | 基数 | 说明 | +|------|------|------| +| Project → Session | 1:N | 一个项目下可以有多个会话 | +| Session → Message | 1:N | 一个会话包含多条消息(用户消息和助手消息交替) | +| Message → Part | 1:N | 一条消息由多个部件组成——这是 OpenCode 的核心设计 | +| Agent → Tool | 1:N | 每个智能体根据权限规则集可使用不同的工具子集 | +| Provider → Model | 1:N | 一个提供者(如 Anthropic)包含多个模型(如 claude-sonnet-4、claude-opus-4) | +| Session → Session | 0:1 | 会话可以 fork 自另一个会话(`parentID`) | +| ToolPart → Tool | N:1 | 一个工具部件对应一次工具调用 | + +--- + +## 消息部件(Part)类型体系 + +`Part` 是 OpenCode 消息系统中最精巧的设计。每种 Part 都是一个带有 `type` 判别字段的联合类型(Discriminated Union): + +```mermaid +graph TD + Msg["Message 消息"] + + Msg --> Text["TextPart
📝 文本输出"] + Msg --> Reasoning["ReasoningPart
🧠 推理过程"] + Msg --> ToolP["ToolPart
🔧 工具调用"] + Msg --> FileP["FilePart
📎 文件附件"] + Msg --> SnapP["SnapshotPart
📸 工作区快照"] + Msg --> PatchP["PatchPart
🩹 代码补丁"] + Msg --> SubP["SubtaskPart
👥 子任务委派"] + Msg --> StepS["StepStartPart
▶️ 步骤开始"] + Msg --> StepF["StepFinishPart
⏹️ 步骤结束"] + Msg --> Compact["CompactionPart
📦 上下文压缩"] + Msg --> RetryP["RetryPart
🔄 重试"] + Msg --> AgentP["AgentPart
🤖 智能体引用"] + + ToolP --> State["状态机"] + State --> Pending["⏳ pending
输入已收到"] + Pending --> Running["🔄 running
正在执行"] + Running --> Completed["✅ completed
执行成功"] + Running --> Error["❌ error
执行失败"] + + style Msg fill:#1565C0,color:#fff + style ToolP fill:#E65100,color:#fff + style State fill:#FFF3E0 +``` + +ToolPart 内部包含一个**状态机(State Machine)**,定义在 `packages/opencode/src/session/message-v2.ts` 中: + +| 状态 | 包含的数据 | 说明 | +|------|-----------|------| +| `pending` | `input`(工具参数) | LLM 发出工具调用请求,但还未执行 | +| `running` | `title`、`metadata` | 工具正在执行中,可实时更新元数据 | +| `completed` | `output`、`attachments` | 执行成功,返回文本输出和可选的文件附件 | +| `error` | `error`(错误信息) | 执行失败,包含错误原因 | + +--- + +## 消息错误类型体系 + +助手消息可以携带错误信息,OpenCode 定义了丰富的错误类型层次结构: + +```mermaid +graph TD + E["Message Error 消息错误"] + E --> OL["OutputLengthError
输出超长"] + E --> AB["AbortedError
用户中断"] + E --> SO["StructuredOutputError
结构化输出格式错误"] + E --> AU["AuthError
认证失败"] + E --> API["APIError
API 调用错误"] + E --> CO["ContextOverflowError
上下文窗口溢出"] + + API --> R["isRetryable: boolean
statusCode: number"] + + style E fill:#c62828,color:#fff + style API fill:#e53935,color:#fff +``` + +--- + +## 配置层级图 + +配置系统(`packages/opencode/src/config/config.ts`)采用多层叠覆盖策略。理解这个层级对于调试配置问题至关重要: + +```mermaid +graph TB + subgraph Remote["☁️ 远程层"] + WK["远程 .well-known/opencode
组织默认配置"] + AC["远程账户配置
已登录用户"] + MG["企业托管配置
Managed Config"] + end + + subgraph Local["💻 本地层"] + GL["全局配置
~/.config/opencode/opencode.json"] + ENV1["OPENCODE_CONFIG 环境变量
指向配置文件路径"] + PJ["项目配置
./opencode.json 或 ./opencode.jsonc"] + DIR[".opencode/ 目录
项目级配置目录"] + ENV2["OPENCODE_CONFIG_CONTENT
环境变量内联配置"] + end + + WK -->|"优先级 1(最低)"| GL + GL -->|"优先级 2"| ENV1 + ENV1 -->|"优先级 3"| PJ + PJ -->|"优先级 4"| DIR + DIR -->|"优先级 5"| ENV2 + ENV2 -->|"优先级 6"| AC + AC -->|"优先级 7"| MG + + MG -->|"优先级 8(最高)"| Final["最终合并配置
Config.get()"] + + style MG fill:#f44336,color:#fff + style Final fill:#4CAF50,color:#fff + style WK fill:#9E9E9E,color:#fff +``` + +### 配置内容结构 + +配置文件中可以配置的核心内容(`Config.Info` 类型): + +```mermaid +graph TD + Config["Config 配置"] + + Config --> AgentCfg["agent
自定义智能体配置"] + Config --> ProvCfg["provider
模型提供者配置"] + Config --> MCPCfg["mcp
MCP 服务器配置"] + Config --> CmdCfg["command
自定义命令"] + Config --> PermCfg["permission
权限规则"] + Config --> SkillCfg["skills
技能路径 & URL"] + Config --> ServerCfg["server
端口 & 主机名"] + Config --> CompCfg["compaction
自动压缩策略"] + Config --> ShareCfg["share
会话分享设置"] + Config --> KeyCfg["keybinds
快捷键绑定"] + Config --> PluginCfg["plugin
插件列表"] + + MCPCfg --> Local["McpLocal
type: local
command + args"] + MCPCfg --> Remote["McpRemote
type: remote
url + oauth"] + + style Config fill:#1565C0,color:#fff +``` + +--- + +## Agent / Tool / Skill / Command 关系辨析 + +这四个概念是初学者最容易混淆的。我们用一张图来厘清它们的边界: + +```mermaid +graph TB + subgraph AgentBox["🤖 Agent(智能体)— 决策者"] + AgentDesc["定义在 packages/opencode/src/agent/agent.ts
拥有:名称、模型绑定、权限规则、温度参数
职责:编排模型调用,决定何时使用工具
内置:build / plan / general / explore"] + end + + subgraph ToolBox["🔧 Tool(工具)— 执行者"] + ToolDesc["定义在 packages/opencode/src/tool/tool.ts
拥有:ID、参数 Schema(Zod)、execute 函数
职责:执行具体操作(读文件、写文件、运行命令)
40+ 内置工具 + MCP 暴露的外部工具"] + end + + subgraph SkillBox["📋 Skill(技能)— 知识库"] + SkillDesc["定义在 packages/opencode/src/skill/index.ts
存在形式:SKILL.md 文件(Markdown)
职责:为 Agent 提供领域知识和最佳实践
来源:项目目录 / ~/.claude/skills / 配置路径"] + end + + subgraph CmdBox["⌨️ Command(命令)— 快捷入口"] + CmdDesc["定义在 packages/opencode/src/command/index.ts
拥有:名称、模板(template)、参数占位符
职责:用户可直接触发的预定义操作
来源:配置文件 / MCP prompts / Skill 自动生成"] + end + + AgentBox -->|"调用"| ToolBox + AgentBox -->|"参考"| SkillBox + CmdBox -->|"触发"| AgentBox + SkillBox -->|"自动生成"| CmdBox + ToolBox -.->|"MCP 工具也注册为"| ToolBox + + style AgentBox fill:#fff3e0 + style ToolBox fill:#e8f5e9 + style SkillBox fill:#e3f2fd + style CmdBox fill:#fce4ec +``` + +### 四者对比表 + +| 维度 | Agent 智能体 | Tool 工具 | Skill 技能 | Command 命令 | +|------|-------------|----------|-----------|-------------| +| **是什么** | 决策编排者 | 具体执行者 | 知识文档 | 用户快捷方式 | +| **定义方式** | 代码 + 配置 | `Tool.define()` | `SKILL.md` 文件 | 配置 / MCP / Skill | +| **包含什么** | 模型绑定、权限、提示词 | Zod 参数、execute 函数 | Markdown 正文 | 模板字符串(`$1`, `$ARGUMENTS`) | +| **谁调用它** | 用户 / 命令 | Agent | Agent(注入 system prompt) | 用户(快捷键 / 斜杠命令) | +| **运行时行为** | 多轮循环调用模型 | 单次执行返回结果 | 注入到提示词中 | 展开模板后交给 Agent | +| **可扩展性** | 配置自定义 Agent | 插件注册新工具 | 添加 SKILL.md 文件 | 配置 `command` 字段 | +| **源码位置** | `src/agent/agent.ts` | `src/tool/*.ts` | `src/skill/index.ts` | `src/command/index.ts` | + +### Command 的三种来源 + +```mermaid +graph LR + subgraph Sources["Command 来源"] + ConfigCmd["配置文件
config.command"] + MCPPrompt["MCP Prompts
MCP 服务器的 prompt"] + SkillAuto["Skill 自动生成
每个 Skill 自动成为 Command"] + end + + ConfigCmd --> Cmd["Command 命令"] + MCPPrompt --> Cmd + SkillAuto --> Cmd + + Cmd --> Template["模板展开
$1, $2, $ARGUMENTS"] + Template --> Agent["交给 Agent 执行"] + + style Cmd fill:#E91E63,color:#fff +``` + +内置命令(`packages/opencode/src/command/index.ts`): +- **`init`**:初始化或更新项目的 `AGENTS.md` 文件 +- **`review`**:审查代码变更(支持 commit / branch / PR) + +--- + +## Permission 权限模型 + +权限系统(`packages/opencode/src/permission/`)控制着工具能做什么、不能做什么: + +```mermaid +graph TD + subgraph Rule["权限规则 Permission.Ruleset"] + Allow["✅ allow
自动允许"] + Ask["🔔 ask
需要用户确认"] + Deny["❌ deny
自动拒绝"] + end + + subgraph Request["权限请求 Permission.Request"] + ReqInfo["name: 权限名称
patterns: glob 模式
tool: 工具上下文"] + end + + subgraph Reply["用户回复 Permission.Reply"] + Once["once
仅本次允许"] + Always["always
永久允许"] + Reject["reject
拒绝"] + end + + Request --> Evaluate["Permission.evaluate()"] + Rule --> Evaluate + Evaluate -->|"匹配到 allow"| Pass["通过 ✅"] + Evaluate -->|"匹配到 deny"| Block["阻止 ❌"] + Evaluate -->|"匹配到 ask"| Prompt["弹出确认"] + Prompt --> Reply + Reply -->|"once"| Pass + Reply -->|"always"| SaveRule["保存规则到数据库"] + Reply -->|"reject"| Block + + style Allow fill:#4CAF50,color:#fff + style Ask fill:#FF9800,color:#fff + style Deny fill:#f44336,color:#fff +``` + +每个 Agent 都绑定一个权限规则集。例如 `plan` 智能体的规则集只允许只读操作,而 `build` 智能体允许读写但某些危险操作需要确认。 + +--- + +## Provider-Model 关系 + +```mermaid +graph LR + subgraph Providers["Provider 提供者"] + Anthropic["Anthropic"] + OpenAI["OpenAI"] + Google["Google"] + Others["...18+ 家"] + end + + subgraph Models["Model 模型"] + Sonnet["claude-sonnet-4
reasoning ✅
vision ✅"] + Opus["claude-opus-4
reasoning ✅
vision ✅"] + GPT4["gpt-4.1
vision ✅"] + Gemini["gemini-2.5-pro
reasoning ✅"] + end + + subgraph Caps["Capabilities 能力"] + R["🧠 reasoning
推理"] + S["📊 structuredOutput
结构化输出"] + V["👁️ vision
视觉"] + end + + Anthropic --> Sonnet + Anthropic --> Opus + OpenAI --> GPT4 + Google --> Gemini + + Sonnet --> R + Sonnet --> V + GPT4 --> V + Gemini --> R + + style Anthropic fill:#1a1a2e,color:#fff + style OpenAI fill:#412991,color:#fff + style Google fill:#4285F4,color:#fff +``` + +每个模型都声明自己支持的能力(`capabilities`),Agent 可以据此选择合适的模型。例如需要"推理"能力时会优先选择支持 `reasoning` 的模型。 + +--- + +## 图解说明 + +回顾本节的核心要点: + +1. **Session → Message → Part** 是三级嵌套结构,Part 的多态设计是 OpenCode 消息系统的灵魂 +2. **ToolPart** 内部是一个状态机(pending → running → completed/error),支持流式更新 +3. **Agent / Tool / Skill / Command** 四个概念职责分明:Agent 决策、Tool 执行、Skill 提供知识、Command 提供快捷入口 +4. **Permission** 是横切关注点,贯穿 Agent 和 Tool 之间的每一次交互 +5. **Config** 的 8 级优先级确保了从组织到个人的灵活配置 + +--- + +## 与下一节的衔接 + +理解了核心概念之间的关系后,我们需要"退后一步"看看这些代码是如何组织在一起的。OpenCode 是一个包含 19 个包的 Monorepo(单体仓库),每个包承担不同的职责。在下一节 [03-Monorepo 全景:19 个包的关系](./03-Monorepo全景-19个包的关系.md) 中,我们将展开整个仓库的包依赖拓扑图。 diff --git "a/all-in-one-book/01-\345\205\250\346\231\257\350\247\206\351\207\216/03-Monorepo\345\205\250\346\231\257-19\344\270\252\345\214\205\347\232\204\345\205\263\347\263\273.md" "b/all-in-one-book/01-\345\205\250\346\231\257\350\247\206\351\207\216/03-Monorepo\345\205\250\346\231\257-19\344\270\252\345\214\205\347\232\204\345\205\263\347\263\273.md" new file mode 100644 index 000000000000..45dff290a88a --- /dev/null +++ "b/all-in-one-book/01-\345\205\250\346\231\257\350\247\206\351\207\216/03-Monorepo\345\205\250\346\231\257-19\344\270\252\345\214\205\347\232\204\345\205\263\347\263\273.md" @@ -0,0 +1,441 @@ +# Monorepo 全景:19 个包的关系 + +> 📌 一句话总结:OpenCode 采用 Monorepo(单体仓库)架构,19 个包按"基础层 → 核心层 → 界面层 → 集成层"自下而上依赖,使用 Turborepo 编排构建。 +> 🗺️ 本节在全景中的位置:前两节分别展示了架构分层和概念关系,本节聚焦于**代码组织**——这些模块是如何被拆分为独立的包并通过依赖关系连接在一起的。 + +--- + +## 全景图:包依赖拓扑 + +下图展示了 OpenCode 全部 19 个包之间的 `workspace:*` 依赖关系。箭头方向为"依赖于": + +```mermaid +graph BT + subgraph Foundation["🧱 基础层(Foundation)"] + SDK["@opencode-ai/sdk
packages/sdk/js/"] + Util["@opencode-ai/util
packages/util/"] + Script["@opencode-ai/script
packages/script/"] + Function["@opencode-ai/function
packages/function/"] + ConsoleMail["@opencode-ai/console-mail
packages/console/mail/"] + ConsoleRes["@opencode-ai/console-resource
packages/console/resource/"] + end + + subgraph Core["⚙️ 核心层(Core)"] + Plugin["@opencode-ai/plugin
packages/plugin/"] + OpenCode["opencode
packages/opencode/"] + UI["@opencode-ai/ui
packages/ui/"] + ConsoleCore["@opencode-ai/console-core
packages/console/core/"] + end + + subgraph Interface["🖥️ 界面层(Interface)"] + App["@opencode-ai/app
packages/app/"] + Web["@opencode-ai/web
packages/web/"] + Enterprise["@opencode-ai/enterprise
packages/enterprise/"] + Storybook["@opencode-ai/storybook
packages/storybook/"] + ConsoleApp["@opencode-ai/console-app
packages/console/app/"] + ConsoleFn["@opencode-ai/console-function
packages/console/function/"] + end + + subgraph Endpoint["🚀 终端层(Endpoint)"] + Desktop["@opencode-ai/desktop
packages/desktop/"] + Electron["@opencode-ai/desktop-electron
packages/desktop-electron/"] + Slack["@opencode-ai/slack
packages/slack/"] + end + + %% 基础层 → 核心层 + Plugin -->|"workspace:*"| SDK + OpenCode -->|"workspace:*"| SDK + OpenCode -->|"workspace:*"| Util + OpenCode -->|"workspace:*"| Plugin + OpenCode -->|"workspace:*"| Script + UI -->|"workspace:*"| SDK + UI -->|"workspace:*"| Util + ConsoleCore -->|"workspace:*"| ConsoleMail + ConsoleCore -->|"workspace:*"| ConsoleRes + + %% 核心层 → 界面层 + App -->|"workspace:*"| SDK + App -->|"workspace:*"| UI + App -->|"workspace:*"| Util + Web -->|"workspace:*"| OpenCode + Enterprise -->|"workspace:*"| UI + Enterprise -->|"workspace:*"| Util + Storybook -->|"workspace:*"| UI + ConsoleApp -->|"workspace:*"| UI + ConsoleApp -->|"workspace:*"| ConsoleCore + ConsoleApp -->|"workspace:*"| ConsoleMail + ConsoleApp -->|"workspace:*"| ConsoleRes + ConsoleFn -->|"workspace:*"| ConsoleCore + ConsoleFn -->|"workspace:*"| ConsoleRes + + %% 界面层 → 终端层 + Desktop -->|"workspace:*"| App + Desktop -->|"workspace:*"| UI + Electron -->|"workspace:*"| App + Electron -->|"workspace:*"| UI + Slack -->|"workspace:*"| SDK + + style SDK fill:#1565C0,color:#fff + style OpenCode fill:#E65100,color:#fff + style UI fill:#2E7D32,color:#fff + style App fill:#6A1B9A,color:#fff +``` + +> 💡 **阅读技巧**:图中越靠下的包越"基础",被依赖的次数越多。`@opencode-ai/sdk` 是被依赖最多的包——它是整个生态的"通用语言"。 + +--- + +## 仓库构建工具 + +OpenCode 使用 [Turborepo](https://turbo.build/)(`turbo.json` 配置)来编排多包构建: + +```mermaid +graph LR + subgraph Pipeline["Turbo 构建管线"] + TC["typecheck
类型检查"] + Build["build
构建"] + Dev["dev
开发模式"] + Test["test
测试"] + end + + TC -->|"dependsOn: [^typecheck]"| Build + Build -->|"dependsOn: [^build]"| Dev + + style TC fill:#00897B,color:#fff + style Build fill:#F57C00,color:#fff +``` + +- **包管理器(Package Manager)**:Bun 1.3.11 +- **工作区协议(Workspace Protocol)**:`workspace:*` 链接内部依赖 +- **构建编排**:Turborepo 自动推导构建顺序,只重建变更的包 + +--- + +## 包分类矩阵 + +我们按**角色**和**面向的用户**对 19 个包进行分类: + +| 分类 | 包名 | 路径 | 一句话描述 | 面向 | +|------|------|------|-----------|------| +| **基础/SDK** | `@opencode-ai/sdk` | `packages/sdk/js/` | TypeScript SDK,定义与 OpenCode 服务器交互的客户端类型和方法 | 开发者 | +| **基础/工具** | `@opencode-ai/util` | `packages/util/` | 共享工具函数库 | 内部 | +| **基础/脚本** | `@opencode-ai/script` | `packages/script/` | 构建和发布脚本 | 维护者 | +| **基础/插件** | `@opencode-ai/plugin` | `packages/plugin/` | 插件系统的公共接口,定义插件如何注册工具和扩展功能 | 插件开发者 | +| **基础/函数** | `@opencode-ai/function` | `packages/function/` | 云函数(Serverless Function)定义 | 内部 | +| **核心引擎** | `opencode` | `packages/opencode/` | **核心包**——包含 Agent、Session、Provider、Tool、MCP 等全部核心逻辑 | 终端用户 | +| **界面/组件库** | `@opencode-ai/ui` | `packages/ui/` | 基于 React 的 UI 组件库,使用 Tailwind CSS | 前端开发者 | +| **界面/Web 应用** | `@opencode-ai/app` | `packages/app/` | Web 前端应用,对接 SDK 提供图形化交互界面 | 终端用户 | +| **界面/官网** | `@opencode-ai/web` | `packages/web/` | 基于 Astro 的官方网站 | 访客 | +| **界面/企业版** | `@opencode-ai/enterprise` | `packages/enterprise/` | 企业版管理界面 | 企业管理员 | +| **界面/Storybook** | `@opencode-ai/storybook` | `packages/storybook/` | UI 组件的可视化文档和测试环境 | 前端开发者 | +| **终端/桌面 Tauri** | `@opencode-ai/desktop` | `packages/desktop/` | 基于 Tauri 的桌面应用(轻量级) | 终端用户 | +| **终端/桌面 Electron** | `@opencode-ai/desktop-electron` | `packages/desktop-electron/` | 基于 Electron 的桌面应用(跨平台兼容) | 终端用户 | +| **集成/Slack** | `@opencode-ai/slack` | `packages/slack/` | Slack 机器人集成,在 Slack 中使用 OpenCode | 团队 | +| **控制台/应用** | `@opencode-ai/console-app` | `packages/console/app/` | 管理控制台前端 | 运维 | +| **控制台/核心** | `@opencode-ai/console-core` | `packages/console/core/` | 管理控制台后端逻辑、数据库 | 内部 | +| **控制台/函数** | `@opencode-ai/console-function` | `packages/console/function/` | 管理控制台的无服务器函数 | 内部 | +| **控制台/邮件** | `@opencode-ai/console-mail` | `packages/console/mail/` | 邮件发送服务 | 内部 | +| **控制台/资源** | `@opencode-ai/console-resource` | `packages/console/resource/` | 基础设施资源定义(SST / IaC) | 内部 | + +> 📝 另外还有两个仓库内的独立项目(不在 `packages/` 工作区内): +> - `github/` — GitHub Actions 集成,依赖 `@opencode-ai/sdk` +> - `sdks/vscode/` — VS Code 扩展 + +--- + +## 每个包的详细说明 + +### 🧱 基础层 + +#### `@opencode-ai/sdk`(SDK) + +``` +packages/sdk/js/ +``` + +这是整个生态的**通用语言**。它定义了客户端与 OpenCode 服务器之间的类型契约:Session、Message、Part、Agent、Provider 等。所有需要与 OpenCode 交互的包都依赖它。 + +**被依赖次数**:6 次(最高) + +#### `@opencode-ai/util`(工具库) + +``` +packages/util/ +``` + +共享的工具函数集合,提供跨包复用的通用逻辑。被 `opencode` 核心包、`ui` 组件库和其他界面包使用。 + +#### `@opencode-ai/plugin`(插件接口) + +``` +packages/plugin/ +``` + +定义插件的公共 API。插件开发者通过这个包的接口来注册自定义工具、扩展 Agent 能力。依赖 `@opencode-ai/sdk` 获取类型定义。 + +#### `@opencode-ai/script`(构建脚本) + +``` +packages/script/ +``` + +内部构建和发布脚本,不对外发布。 + +#### `@opencode-ai/function`(云函数) + +``` +packages/function/ +``` + +定义 Serverless 函数的入口和逻辑,无内部依赖。 + +--- + +### ⚙️ 核心层 + +#### `opencode`(核心引擎) + +``` +packages/opencode/ +``` + +这是整个项目的**心脏**,包含所有核心逻辑: + +| 子模块 | 路径 | 职责 | +|--------|------|------| +| Agent | `src/agent/` | 智能体定义与编排 | +| Session | `src/session/` | 会话和消息管理 | +| Provider | `src/provider/` | 模型提供者抽象 | +| Tool | `src/tool/` | 40+ 内置工具 | +| Skill | `src/skill/` | SKILL.md 技能系统 | +| MCP | `src/mcp/` | MCP 协议客户端 | +| LSP | `src/lsp/` | 语言服务器集成 | +| Permission | `src/permission/` | 权限控制 | +| Config | `src/config/` | 配置管理 | +| Storage | `src/storage/` | SQLite 持久化 | +| Server | `src/server/` | Hono HTTP 服务 | +| Git | `src/git/` | Git 操作 | +| Snapshot | `src/snapshot/` | 文件快照 | +| File | `src/file/` | 文件操作 | +| Auth | `src/auth/` | 认证管理 | +| Command | `src/command/` | 命令系统 | +| Plugin | `src/plugin/` | 插件加载 | + +依赖:`@opencode-ai/sdk`、`@opencode-ai/util`、`@opencode-ai/plugin`、`@opencode-ai/script` + +#### `@opencode-ai/ui`(UI 组件库) + +``` +packages/ui/ +``` + +基于 React + Tailwind CSS 的 UI 组件库。提供主题、布局、表单、对话框等通用组件,被所有前端应用共享。 + +依赖:`@opencode-ai/sdk`、`@opencode-ai/util` + +--- + +### 🖥️ 界面层 + +#### `@opencode-ai/app`(Web 前端应用) + +``` +packages/app/ +``` + +主要的 Web 前端应用,提供图形化的聊天界面、会话管理、设置等功能。 + +依赖:`@opencode-ai/sdk`、`@opencode-ai/ui`、`@opencode-ai/util` + +#### `@opencode-ai/web`(官方网站) + +``` +packages/web/ +``` + +基于 [Astro](https://astro.build/) 构建的官方网站和文档。直接依赖 `opencode` 核心包。 + +#### `@opencode-ai/enterprise`(企业版) + +``` +packages/enterprise/ +``` + +企业版管理界面,提供团队管理、使用统计等功能。 + +依赖:`@opencode-ai/ui`、`@opencode-ai/util` + +#### `@opencode-ai/storybook`(组件文档) + +``` +packages/storybook/ +``` + +UI 组件库的可视化展示与测试环境,帮助前端开发者查看和调试组件。 + +依赖:`@opencode-ai/ui` + +--- + +### 🚀 终端层 + +#### `@opencode-ai/desktop`(Tauri 桌面应用) + +``` +packages/desktop/ +``` + +基于 [Tauri](https://tauri.app/) 的轻量级桌面应用,利用系统 WebView 渲染,安装包体积小。 + +依赖:`@opencode-ai/app`、`@opencode-ai/ui` + +#### `@opencode-ai/desktop-electron`(Electron 桌面应用) + +``` +packages/desktop-electron/ +``` + +基于 [Electron](https://www.electronjs.org/) 的桌面应用,跨平台兼容性更好。 + +依赖:`@opencode-ai/app`、`@opencode-ai/ui` + +#### `@opencode-ai/slack`(Slack 集成) + +``` +packages/slack/ +``` + +Slack 机器人,让团队可以在 Slack 频道中直接使用 OpenCode。 + +依赖:`@opencode-ai/sdk` + +--- + +### 🎛️ 控制台子系统 + +控制台(Console)是 OpenCode 的后台管理平台,自身也是一个小型分层架构: + +```mermaid +graph BT + ConsoleMail["console-mail
邮件服务"] --> ConsoleCore + ConsoleRes["console-resource
基础设施资源"] --> ConsoleCore + ConsoleCore["console-core
核心逻辑 & 数据库"] + ConsoleCore --> ConsoleApp["console-app
管理界面"] + ConsoleCore --> ConsoleFn["console-function
云函数"] + UI2["@opencode-ai/ui"] --> ConsoleApp + + style ConsoleCore fill:#37474F,color:#fff + style ConsoleApp fill:#546E7A,color:#fff +``` + +--- + +## 依赖被引用排行 + +哪个包被依赖最多?这反映了它在架构中的**基础程度**: + +| 排名 | 包名 | 被依赖次数 | 角色 | +|------|------|-----------|------| +| 1 | `@opencode-ai/sdk` | 6 | 类型契约,生态通用语言 | +| 2 | `@opencode-ai/ui` | 5 | UI 组件库,所有前端共享 | +| 3 | `@opencode-ai/util` | 4 | 工具函数,内部共享 | +| 4 | `@opencode-ai/console-resource` | 3 | 基础设施资源定义 | +| 5 | `@opencode-ai/app` | 2 | Web 前端,桌面应用复用 | +| 5 | `@opencode-ai/console-core` | 2 | 控制台核心逻辑 | +| 5 | `@opencode-ai/console-mail` | 2 | 邮件服务 | +| 5 | `@opencode-ai/plugin` | 1 | 插件接口 | + +--- + +## 图解说明 + +让我们用一个更直观的视角来理解这 19 个包的关系——把它们想象成一座建筑: + +```mermaid +graph TD + subgraph Roof["🏠 屋顶:终端产品"] + Desktop + Electron + Slack + GH["GitHub Action"] + end + + subgraph Floor2["🏢 二楼:用户界面"] + App + Web + Enterprise + ConsoleApp["Console App"] + end + + subgraph Floor1["🏗️ 一楼:核心引擎"] + OpenCode["opencode 核心包"] + UI["UI 组件库"] + ConsoleCore["Console Core"] + end + + subgraph Base["🧱 地基:基础设施"] + SDK + Util + Plugin + Script + end + + Roof --> Floor2 + Floor2 --> Floor1 + Floor1 --> Base + + style Base fill:#795548,color:#fff + style Floor1 fill:#FF9800,color:#fff + style Floor2 fill:#2196F3,color:#fff + style Roof fill:#4CAF50,color:#fff +``` + +**读图规则**: +- **地基层**的包不依赖其他内部包,它们是"纯基础设施" +- **一楼**是核心逻辑,依赖地基但不关心谁在使用自己 +- **二楼**是面向用户的界面,组合核心逻辑和 UI 组件 +- **屋顶**是最终交付给用户的产品形态 + +--- + +## 关键设计决策 + +### 1. 为什么用 Monorepo? + +- **代码共享**:`sdk`、`util`、`ui` 等包被多个应用复用,Monorepo 确保版本一致性 +- **原子提交**:跨包的功能变更可以在一个 PR 中完成 +- **统一工具链**:Turborepo + Bun 一套构建工具管理所有包 + +### 2. 为什么 SDK 是独立包? + +`@opencode-ai/sdk` 独立出来而不是放在 `opencode` 核心包中,因为: +- 外部集成(Slack Bot、GitHub Action、VS Code 插件)只需要类型定义和 API 客户端,不需要整个核心引擎 +- SDK 可以独立发布到 npm,方便第三方开发者使用 + +### 3. 为什么有两个桌面应用框架? + +- **Tauri**(`packages/desktop/`):体积小、性能好,但依赖系统 WebView +- **Electron**(`packages/desktop-electron/`):体积大,但自带 Chromium,跨平台兼容性更稳定 + +两者共享同一个 `@opencode-ai/app` 前端代码,只是"壳"不同。 + +### 4. Console 子系统为什么独立分包? + +管理控制台(Console)是 SaaS 平台的后台,与核心编程助手功能正交: +- `console-resource`:使用 [SST](https://sst.dev/) 定义云基础设施 +- `console-core`:后台业务逻辑和数据库 +- `console-app`:管理界面前端 +- `console-function`:事件处理的无服务器函数 +- `console-mail`:邮件通知服务 + +--- + +## 与下一节的衔接 + +到这里,我们已经从三个维度理解了 OpenCode: + +1. **架构分层**(01-一图看懂 OpenCode):六层架构,从用户到基础设施 +2. **概念关系**(02-核心概念关系图谱):Session、Message、Part 等实体的关系 +3. **代码组织**(本节):19 个包的依赖拓扑和职责划分 + +接下来,我们将进入 [02-快速上手](../02-快速上手/) 章节,动手安装和运行 OpenCode——亲身体验这张全景图中的每一个部分。 From fc12a788e57d9c21b431be3dceea78d96c423889 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Mar 2026 09:12:38 +0000 Subject: [PATCH 2/7] docs: address review feedback for chapter 01 - Fix Part type field spacing in ER diagram - Fix ranking number for plugin package (was tied at 5, now correctly 8) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...\277\265\345\205\263\347\263\273\345\233\276\350\260\261.md" | 2 +- ...\270\252\345\214\205\347\232\204\345\205\263\347\263\273.md" | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git "a/all-in-one-book/01-\345\205\250\346\231\257\350\247\206\351\207\216/02-\346\240\270\345\277\203\346\246\202\345\277\265\345\205\263\347\263\273\345\233\276\350\260\261.md" "b/all-in-one-book/01-\345\205\250\346\231\257\350\247\206\351\207\216/02-\346\240\270\345\277\203\346\246\202\345\277\265\345\205\263\347\263\273\345\233\276\350\260\261.md" index 8bf970baf6e6..0babd58d10d0 100644 --- "a/all-in-one-book/01-\345\205\250\346\231\257\350\247\206\351\207\216/02-\346\240\270\345\277\203\346\246\202\345\277\265\345\205\263\347\263\273\345\233\276\350\260\261.md" +++ "b/all-in-one-book/01-\345\205\250\346\231\257\350\247\206\351\207\216/02-\346\240\270\345\277\203\346\246\202\345\277\265\345\205\263\347\263\273\345\233\276\350\260\261.md" @@ -61,7 +61,7 @@ erDiagram Part { string id PK string messageID FK - string type "text | reasoning | tool | file | snapshot | patch | subtask | step_start | step_finish | compaction | retry | agent" + string type "text|reasoning|tool|file|snapshot|patch|subtask|step_start|step_finish|compaction|retry|agent" } Agent { diff --git "a/all-in-one-book/01-\345\205\250\346\231\257\350\247\206\351\207\216/03-Monorepo\345\205\250\346\231\257-19\344\270\252\345\214\205\347\232\204\345\205\263\347\263\273.md" "b/all-in-one-book/01-\345\205\250\346\231\257\350\247\206\351\207\216/03-Monorepo\345\205\250\346\231\257-19\344\270\252\345\214\205\347\232\204\345\205\263\347\263\273.md" index 45dff290a88a..44f90b603dcc 100644 --- "a/all-in-one-book/01-\345\205\250\346\231\257\350\247\206\351\207\216/03-Monorepo\345\205\250\346\231\257-19\344\270\252\345\214\205\347\232\204\345\205\263\347\263\273.md" +++ "b/all-in-one-book/01-\345\205\250\346\231\257\350\247\206\351\207\216/03-Monorepo\345\205\250\346\231\257-19\344\270\252\345\214\205\347\232\204\345\205\263\347\263\273.md" @@ -343,7 +343,7 @@ graph BT | 5 | `@opencode-ai/app` | 2 | Web 前端,桌面应用复用 | | 5 | `@opencode-ai/console-core` | 2 | 控制台核心逻辑 | | 5 | `@opencode-ai/console-mail` | 2 | 邮件服务 | -| 5 | `@opencode-ai/plugin` | 1 | 插件接口 | +| 8 | `@opencode-ai/plugin` | 1 | 插件接口 | --- From 8bcb333a6391e84bb451ca0274f7d8e4c7bcca31 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Mar 2026 09:28:55 +0000 Subject: [PATCH 3/7] =?UTF-8?q?docs:=20add=20three=20journey-tracing=20cha?= =?UTF-8?q?pters=20for=2001-=E5=85=A8=E6=99=AF=E8=A7=86=E9=87=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add detailed Chinese technical book chapters that trace complete execution paths through the OpenCode system: - 04-一次对话的完整旅程.md: Full conversation flow with sequence diagram - 05-一次Tool调用的完整旅程.md: Complete tool call lifecycle - 06-一次文件编辑的完整旅程.md: File edit chain with patch/snapshot systems Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...14\346\225\264\346\227\205\347\250\213.md" | 335 ++++++++++++++ ...14\346\225\264\346\227\205\347\250\213.md" | 324 ++++++++++++++ ...14\346\225\264\346\227\205\347\250\213.md" | 407 ++++++++++++++++++ 3 files changed, 1066 insertions(+) create mode 100644 "all-in-one-book/01-\345\205\250\346\231\257\350\247\206\351\207\216/04-\344\270\200\346\254\241\345\257\271\350\257\235\347\232\204\345\256\214\346\225\264\346\227\205\347\250\213.md" create mode 100644 "all-in-one-book/01-\345\205\250\346\231\257\350\247\206\351\207\216/05-\344\270\200\346\254\241Tool\350\260\203\347\224\250\347\232\204\345\256\214\346\225\264\346\227\205\347\250\213.md" create mode 100644 "all-in-one-book/01-\345\205\250\346\231\257\350\247\206\351\207\216/06-\344\270\200\346\254\241\346\226\207\344\273\266\347\274\226\350\276\221\347\232\204\345\256\214\346\225\264\346\227\205\347\250\213.md" diff --git "a/all-in-one-book/01-\345\205\250\346\231\257\350\247\206\351\207\216/04-\344\270\200\346\254\241\345\257\271\350\257\235\347\232\204\345\256\214\346\225\264\346\227\205\347\250\213.md" "b/all-in-one-book/01-\345\205\250\346\231\257\350\247\206\351\207\216/04-\344\270\200\346\254\241\345\257\271\350\257\235\347\232\204\345\256\214\346\225\264\346\227\205\347\250\213.md" new file mode 100644 index 000000000000..bb1082b75582 --- /dev/null +++ "b/all-in-one-book/01-\345\205\250\346\231\257\350\247\206\351\207\216/04-\344\270\200\346\254\241\345\257\271\350\257\235\347\232\204\345\256\214\346\225\264\346\227\205\347\250\213.md" @@ -0,0 +1,335 @@ +# 一次对话的完整旅程 + +> 📌 一句话总结:从用户在终端输入一句话,到 AI 读取文件、调用工具、修改代码、返回结果——我们将跟踪每一个字节穿越系统的完整路径。 +> 🗺️ 本节在全景中的位置:前三节分别从宏观架构、概念关系、包组织三个维度描绘了 OpenCode 的全貌。本节开始**进入运行时**——用一个真实场景串联所有模块,让你看到代码是如何"活起来"的。 + +--- + +## 场景设定 + +小明打开终端,输入 `opencode`,然后说: + +> "帮我把 utils.ts 里的 forEach 改成 for...of" + +让我们追踪这句话从键盘到文件系统的完整旅程。 + +--- + +## 全景图:完整对话时序 + +```mermaid +sequenceDiagram + autonumber + participant User as 👤 小明 + participant TUI as 🖥️ TUI 终端
cli/cmd/tui/ + participant Server as 🌐 HTTP Server
server/server.ts + participant Route as 📡 Session Route
server/routes/session.ts + participant Prompt as 🔄 Chat Loop
session/prompt.ts + participant Proc as ⚙️ Processor
session/processor.ts + participant LLM as 🤖 LLM Stream
session/llm.ts + participant Provider as 🏭 Provider
provider/provider.ts + participant AI as ☁️ LLM API
(Claude/GPT/Gemini) + participant Tool as 🔧 Tool System
tool/ + participant FS as 💾 文件系统 + + User->>TUI: 输入 "帮我把 utils.ts 里的 forEach 改成 for...of" + Note over TUI: prompt/index.tsx
构建 PromptInput + + TUI->>Server: POST /session/:id/message
{parts: [{type:"text", text:"..."}]} + Note over Server: server.ts 中间件链
Auth → CORS → WorkspaceContext + + Server->>Route: 路由匹配 → session.prompt + Note over Route: routes/session.ts:783-822
validator 校验参数 + + Route->>Prompt: SessionPrompt.prompt({sessionID, parts, agent, model}) + Note over Prompt: prompt.ts:162
创建 UserMessage + + Prompt->>Prompt: loop({sessionID}) 进入主循环 + Note over Prompt: prompt.ts:278
while(true) 外层编排循环 + + Prompt->>Proc: processor.process(streamInput) + Note over Proc: processor.ts:46
while(true) 内层重试循环 + + Proc->>LLM: LLM.stream({user, agent, system, messages, tools}) + Note over LLM: llm.ts:48
构建 system prompt + 解析 tools + + LLM->>Provider: Provider.getLanguage(model) + Note over Provider: provider.ts
缓存 SDK → 创建 LanguageModelV2 + + Provider->>LLM: 返回 languageModel 实例 + + LLM->>AI: streamText({model, system, messages, tools}) + Note over AI: AI SDK → 流式响应 + + AI-->>Proc: stream.fullStream 事件流
text-delta / tool-call / finish + + Note over Proc: 💡 关键点:Agent 决定调用 read 工具 + + Proc->>Tool: execute read({filePath: "utils.ts"}) + Note over Tool: tool/read.ts
权限检查 → 读取文件 + + Tool->>FS: Filesystem.readText("utils.ts") + FS-->>Tool: 文件内容 + Tool-->>Proc: {output: "文件内容...", title: "utils.ts"} + + Note over Proc: tool-result 事件 → 更新 ToolPart + + Proc-->>LLM: 工具结果回传(下一轮流式) + + LLM->>AI: 再次调用 LLM(包含工具结果) + AI-->>Proc: tool-call: edit({filePath, oldString, newString}) + + Note over Proc: 💡 关键点:Agent 决定调用 edit 工具 + + Proc->>Tool: execute edit({filePath: "utils.ts", oldString: "forEach", newString: "for...of"}) + Note over Tool: tool/edit.ts
权限检查 → 匹配替换 → 写入文件 + + Tool->>FS: Filesystem.write("utils.ts", newContent) + FS-->>Tool: 写入成功 + Tool-->>Proc: {output: "Edit applied.", diff: "...", title: "utils.ts"} + + Proc-->>LLM: 工具结果回传 + + LLM->>AI: 最终调用 LLM + AI-->>Proc: text-delta: "已完成修改..."
finish: stop + + Note over Proc: finish-step → 计算 token/cost
创建 snapshot patch + + Proc-->>Prompt: return "continue"(无更多工具调用则结束) + + Prompt->>Prompt: 检查 finish reason → 非 tool-calls → break + + Prompt-->>Route: 返回最终 AssistantMessage + Route-->>TUI: stream.write(JSON.stringify(msg)) + TUI-->>User: 显示 "已完成修改..." + diff 预览 +``` + +--- + +## 数据流转图:每个阶段的数据格式 + +```mermaid +flowchart LR + subgraph Input["📝 用户输入"] + A["纯文本字符串
'帮我把 utils.ts 里的
forEach 改成 for...of'"] + end + + subgraph SDK["📦 SDK 封装"] + B["PromptInput
{sessionID, agent,
model, variant,
parts: [{type:'text',
text:'...'}]}"] + end + + subgraph HTTP["🌐 HTTP 请求"] + C["POST JSON Body
{parts, agent,
model:{providerID,
modelID}}"] + end + + subgraph UserMsg["💬 UserMessage"] + D["MessageV2.User
{id, sessionID,
role:'user',
system:[], tools:{}}"] + end + + subgraph ModelMsg["🔄 模型消息"] + E["ModelMessage[]
[{role:'system',
content:'...'},
{role:'user',
content:'...'}]"] + end + + subgraph Stream["⚡ 流式事件"] + F["StreamEvent
text-delta |
tool-call |
tool-result |
finish"] + end + + subgraph Parts["🧩 消息部件"] + G["MessageV2.Part
TextPart |
ToolPart |
ReasoningPart"] + end + + subgraph Result["✅ 最终结果"] + H["AssistantMessage
{parts, finish,
cost, tokens,
time}"] + end + + A --> B --> C --> D --> E --> F --> G --> H + + style Input fill:#e1f5fe + style SDK fill:#f3e5f5 + style HTTP fill:#fff3e0 + style UserMsg fill:#e8f5e9 + style ModelMsg fill:#fce4ec + style Stream fill:#fff9c4 + style Parts fill:#f1f8e9 + style Result fill:#e0f2f1 +``` + +### 💡 关键点:数据格式在每一步都在变化 + +| 阶段 | 数据格式 | 对应文件 | +|------|---------|---------| +| 用户输入 | 纯文本字符串 | `cli/cmd/tui/component/prompt/index.tsx` | +| SDK 封装 | `PromptInput`(含 parts 数组、model 信息) | `server/routes/session.ts` | +| 创建用户消息 | `MessageV2.User`(含 system、tools 覆盖) | `session/prompt.ts` | +| 转换为模型消息 | `ModelMessage[]`(system + user + assistant 历史) | `session/prompt.ts` → `MessageV2.toModelMessages()` | +| Provider 归一化 | 经 `ProviderTransform.message()` 处理的消息 | `provider/transform.ts` | +| 流式事件 | `StreamEvent`(text-delta、tool-call 等) | `session/processor.ts` | +| 持久化部件 | `MessageV2.Part`(TextPart / ToolPart / ReasoningPart) | `session/index.ts` | +| 最终响应 | `AssistantMessage`(含 cost、tokens、finish) | `session/prompt.ts` | + +--- + +## Agent 决策循环:ReAct 模式状态机 + +OpenCode 的 Agent 采用经典的 ReAct(Reasoning + Acting)模式。整个过程由**两层循环**驱动: + +```mermaid +stateDiagram-v2 + [*] --> Init: loop() 启动 + + state "外层循环 (prompt.ts)" as Outer { + Init --> Resolve: 解析 model/agent/tools + Resolve --> BuildSystem: 构建 system prompt + BuildSystem --> Process: processor.process() + + state "内层循环 (processor.ts)" as Inner { + state "🧠 Think" as Think + state "🔧 Act" as Act + state "👁️ Observe" as Observe + state "✍️ Respond" as Respond + state "🔁 Retry" as Retry + + Think: LLM 推理
分析用户需求
决定下一步行动 + Act: 调用工具
read/edit/bash/grep... + Observe: 接收工具结果
更新 ToolPart 状态 + Respond: 生成文本回复
text-delta 事件流 + + [*] --> Think: LLM.stream() + Think --> Act: tool-call 事件 + Think --> Respond: text-delta 事件(无工具调用) + Act --> Observe: tool-result / tool-error + Observe --> Think: 结果回传 LLM + Respond --> [*]: finish 事件 + Think --> Retry: 可重试错误 + Retry --> Think: 延迟后重试 + } + + Process --> CheckResult: 返回结果 + } + + state CheckResult <> + CheckResult --> Outer: "continue"
还有工具要调用 + CheckResult --> Compact: "compact"
上下文溢出 + CheckResult --> Done: "stop"
完成或被阻止 + + Compact --> Outer: 压缩后继续 + Done --> [*]: 对话结束 +``` + +### 💡 关键点:双层循环的设计智慧 + +**外层循环**(`prompt.ts:278` — `while(true)`)负责**编排**: +- 每轮重新解析可用工具(因为权限可能变化) +- 重新构建 system prompt(因为环境可能变化) +- 处理上下文压缩(`SessionCompaction`) +- 当 Agent 完成推理(finish reason 非 `tool-calls`)时退出 + +**内层循环**(`processor.ts:46` — `while(true)`)负责**执行**: +- 调用 `LLM.stream()` 获取流式事件 +- 处理每个事件(文本、工具调用、推理) +- 处理重试逻辑(`SessionRetry.retryable(error)`) +- 检测死循环(Doom Loop Detection)——连续 3 次相同工具+相同参数 + +--- + +## 图解说明:关键步骤详解 + +### 第 1 步:TUI 捕获输入 + +``` +文件:cli/cmd/tui/component/prompt/index.tsx +``` + +TUI 使用 Ink(React for CLI)渲染终端界面。当小明按下 Enter,`prompt/index.tsx` 将输入文本封装为 `PromptInput`,通过 SDK client 发送 HTTP 请求到本地 Server: + +```typescript +sdk.client.session.prompt({ + sessionID, + agent: local.agent.current().name, // "build" + model: selectedModel, // {providerID, modelID} + parts: [{ id, type: "text", text: inputText }], +}) +``` + +### 第 2 步:Server 中间件链 + +``` +文件:server/server.ts +``` + +HTTP 请求经过中间件链处理: +1. **错误处理** — 捕获 `NamedError` 映射到 HTTP 状态码 +2. **Basic Auth** — 如果设置了 `OPENCODE_SERVER_PASSWORD` +3. **请求日志** — 记录所有请求(跳过 `/log` 端点避免递归) +4. **CORS** — 允许 `localhost:*`、`tauri://`、`*.opencode.ai` +5. **WorkspaceContext** — 从 header/query 提取 `workspaceID` +6. **Instance Bootstrap** — 初始化项目上下文 + +### 第 3 步:Session Route 分发 + +``` +文件:server/routes/session.ts:783-822 +``` + +路由 `POST /:sessionID/message` 使用 Zod 校验参数后,调用 `SessionPrompt.prompt()`。该函数创建 `MessageV2.User`,然后启动主循环。 + +### 第 4-5 步:模型解析与 Provider 初始化 + +``` +文件:provider/provider.ts → provider/transform.ts +``` + +`Provider.getLanguage(model)` 的解析过程: +1. 检查缓存(key = `providerID/modelID`) +2. 获取 Provider SDK(如 `@ai-sdk/anthropic` → `createAnthropic()`) +3. 通过 Custom Loader 或默认方式加载模型实例 +4. `ProviderTransform.message()` 处理 Provider 特异性(Anthropic 空内容过滤、Mistral 工具 ID 标准化等) + +### 第 6-9 步:ReAct 循环 + +这是整个系统的核心。Agent 通常会: +1. **Think**:分析用户需求,决定先 `read` 文件 +2. **Act**:调用 `read` 工具获取 `utils.ts` 内容 +3. **Observe**:接收文件内容,理解需要修改的位置 +4. **Think**:决定使用 `edit` 工具进行精确替换 +5. **Act**:调用 `edit({filePath, oldString, newString})` +6. **Observe**:收到 "Edit applied successfully" + LSP 诊断 +7. **Respond**:生成总结文本回复用户 + +--- + +## 关键设计决策 + +### 1. 为什么用 HTTP Server 而不是直接调用? + +``` +TUI ←HTTP→ Server ←→ 核心逻辑 +``` + +💡 **解耦前端与后端**。这使得 Web UI、Desktop App、IDE 插件都能复用同一套核心逻辑。Server 是唯一的入口点,所有客户端通过相同的 API 交互。 + +### 2. 为什么有两层循环? + +💡 **外层管编排,内层管执行**。外层循环在每轮重新解析工具和提示词,使系统能适应动态变化(如用户中途修改权限)。内层循环专注于一次 LLM 调用的流式处理和重试逻辑。 + +### 3. 为什么 Agent 不一次性完成所有修改? + +💡 **ReAct 模式的渐进式推理**。Agent 先读取文件理解上下文,再决定如何修改。每一步都有完整的观察 → 推理 → 行动闭环,这比"一次生成所有修改"更可靠。 + +### 4. 流式事件的价值 + +💡 **实时反馈**。`text-delta` 让用户在 Agent 思考时就能看到部分输出,`tool-call` 状态更新让用户知道 Agent 正在执行什么工具。这一切通过 `Bus.publish()` 和 `SyncEvent` 实现。 + +### 5. 死循环检测(Doom Loop Detection) + +``` +文件:session/processor.ts — tool-call 事件处理 +``` + +💡 如果最近 3 个工具调用使用了**相同工具 + 相同参数**,Processor 会触发 `Permission.ask("doom_loop")`,让用户决定是否继续。这防止了 Agent 陷入无限重试。 + +--- + +## 与下一节的衔接 + +我们已经看到对话旅程中 Agent 决定调用工具的那一刻(时序图中的 `tool-call` 事件)。但工具调用本身还有一个完整的生命周期——从权限检查、到工具发现、到参数校验、到执行、到结果格式化。下一节 **"一次 Tool 调用的完整旅程"** 将深入这个过程。 diff --git "a/all-in-one-book/01-\345\205\250\346\231\257\350\247\206\351\207\216/05-\344\270\200\346\254\241Tool\350\260\203\347\224\250\347\232\204\345\256\214\346\225\264\346\227\205\347\250\213.md" "b/all-in-one-book/01-\345\205\250\346\231\257\350\247\206\351\207\216/05-\344\270\200\346\254\241Tool\350\260\203\347\224\250\347\232\204\345\256\214\346\225\264\346\227\205\347\250\213.md" new file mode 100644 index 000000000000..447037dfb97c --- /dev/null +++ "b/all-in-one-book/01-\345\205\250\346\231\257\350\247\206\351\207\216/05-\344\270\200\346\254\241Tool\350\260\203\347\224\250\347\232\204\345\256\214\346\225\264\346\227\205\347\250\213.md" @@ -0,0 +1,324 @@ +# 一次 Tool 调用的完整旅程 + +> 📌 一句话总结:从 Agent 说"我要调用 bash 工具"到结果返回,中间经历了注册发现、权限评估、参数校验、执行隔离、输出截断六道关卡。 +> 🗺️ 本节在全景中的位置:上一节追踪了完整对话流程,在 Agent 决定"调用工具"的那一刻停下。本节接过接力棒,放大工具调用的完整生命周期。 + +--- + +## 全景图:工具调用时序 + +当 Processor 收到 LLM 返回的 `tool-call` 事件时,以下流程启动: + +```mermaid +sequenceDiagram + autonumber + participant LLM as 🤖 LLM 响应
session/processor.ts + participant Proc as ⚙️ Processor
tool-call 事件处理 + participant Registry as 📋 ToolRegistry
tool/registry.ts + participant Define as 🏗️ Tool.define
tool/tool.ts + participant Perm as 🔐 Permission
permission/index.ts + participant Eval as 📏 Evaluate
permission/evaluate.ts + participant User as 👤 用户
(TUI 弹窗) + participant Exec as 🔧 Tool Execute
tool/bash.ts 等 + participant FS as 💾 外部系统
文件/Shell/网络 + + LLM->>Proc: tool-call 事件
{toolName:"bash", args:{command:"cat utils.ts"}} + Note over Proc: processor.ts
创建 ToolPart
status: "pending" → "running" + + Proc->>Registry: 查找工具定义 + Note over Registry: registry.ts
内置工具 + 自定义工具 + MCP 工具 + + Registry->>Define: tool.init({agent}) + Note over Define: tool.ts:49-89
返回 {description, parameters, execute} + + Define->>Define: Zod 参数校验
parameters.safeParse(args) + Note over Define: 💡 关键点:校验失败会
调用 formatValidationError() + + Define->>Exec: execute(validatedArgs, ctx) + + Note over Exec: === 进入具体工具逻辑 === + + Exec->>Perm: ctx.ask({permission:"bash", patterns:[command]}) + Note over Perm: permission/index.ts:166-201
遍历所有 pattern + + Perm->>Eval: evaluate(permission, pattern, ...rulesets) + Note over Eval: evaluate.ts:9-15
findLast 匹配规则
Wildcard.match() + + alt action = "allow" + Eval-->>Perm: 直接通过 + Perm-->>Exec: resolve(无阻塞) + else action = "deny" + Eval-->>Perm: 抛出 DeniedError + Perm-->>Proc: PermissionDeniedError + Note over Proc: ToolPart status → "error" + else action = "ask" + Perm->>User: Bus.publish(Permission.Event.Asked) + Note over User: TUI 显示权限弹窗
用户看到工具名+参数 + + alt 用户批准 "once" + User->>Perm: reply("once") + Perm-->>Exec: resolve + else 用户批准 "always" + User->>Perm: reply("always") + Note over Perm: 💡 添加到 approved 规则集
后续同类调用自动通过 + Perm-->>Exec: resolve + else 用户拒绝 + User->>Perm: reply("reject") + Perm-->>Proc: RejectedError + Note over Proc: blocked = true + end + end + + Exec->>FS: 执行实际操作
spawn / readFile / write + FS-->>Exec: 执行结果 + + Exec-->>Define: {title, metadata, output} + + Define->>Define: Truncate.output(output)
截断超长输出 + Note over Define: 💡 关键点:超限写入文件
metadata.truncated = true + + Define-->>Proc: 完整工具结果 + + Proc->>Proc: ToolPart status → "completed"
更新 output/title/metadata + + Note over Proc: tool-result 事件
Session.updatePart() + + Proc-->>LLM: 结果回传(下一轮流式调用) +``` + +--- + +## 工具注册与发现 + +OpenCode 的工具来自三个来源,最终汇入统一注册表(Unified Registry): + +```mermaid +flowchart TB + subgraph Builtin["🏗️ 内置工具(Built-in)"] + direction TB + bash["bash
tool/bash.ts"] + read["read
tool/read.ts"] + edit["edit
tool/edit.ts"] + write["write
tool/write.ts"] + grep["grep
tool/grep.ts"] + glob["glob
tool/glob.ts"] + task["task
tool/task.ts"] + fetch["webfetch
tool/webfetch.ts"] + lsp["lsp
tool/lsp.ts"] + patch["apply_patch
tool/apply_patch.ts"] + todo["todowrite
tool/todowrite.ts"] + batch["batch
tool/batch.ts"] + question["question
(条件注册)"] + websearch["websearch / codesearch
(条件注册)"] + end + + subgraph Custom["📁 自定义工具(Custom)"] + direction TB + tooldir["{tool,tools}/*.{js,ts}
项目目录下的工具文件"] + plugin["Plugin.list()
插件提供的工具"] + end + + subgraph MCP["🌐 MCP 工具(Model Context Protocol)"] + direction TB + mcpserver["MCP Server 连接
mcp/index.ts"] + mcptools["远程工具列表
.opencode/mcp.json 配置"] + end + + subgraph Registry["📋 统一注册表
tool/registry.ts"] + direction TB + register["register(tool)
注册/更新"] + ids["ids()
列出所有工具 ID"] + tools["tools(model, agent)
根据模型+Agent 过滤"] + end + + subgraph Filter["🔍 运行时过滤"] + direction TB + model_filter["模型能力过滤
是否支持 toolcall"] + agent_filter["Agent 权限过滤
Permission.disabled()"] + user_filter["用户覆盖
user.tools[name] === false"] + plugin_filter["Plugin hook
tool.definition 扩展"] + end + + subgraph Final["✅ 最终工具集"] + available["Record<string, Tool>
传入 LLM.stream()"] + end + + Builtin --> Registry + Custom --> Registry + MCP --> Registry + + Registry --> Filter + Filter --> Final + + style Builtin fill:#e8f5e9 + style Custom fill:#fff3e0 + style MCP fill:#e3f2fd + style Registry fill:#f3e5f5 + style Filter fill:#fce4ec + style Final fill:#e0f2f1 +``` + +### 💡 关键点:条件注册 + +并非所有内置工具都会注册。`registry.ts` 中的条件逻辑: + +| 工具 | 条件 | 原因 | +|------|------|------| +| `question` | 仅当 `OPENCODE_CLIENT` 或 `OPENCODE_ENABLE_QUESTION_TOOL` | 需要 TUI 交互能力 | +| `websearch` / `codesearch` | 仅当 `ProviderID.opencode` 或 `OPENCODE_ENABLE_EXA` | 需要 Exa 搜索服务 | +| `apply_patch` vs `edit`/`write` | 取决于模型变体(GPT-4/OSS 选择) | 不同模型擅长不同的编辑格式 | +| `batch` | 需要 `experimental.batch` 配置 | 实验性功能 | + +### Tool.define() 模式 + +每个工具通过统一的 `Tool.define(id, init)` 注册(`tool/tool.ts:49-89`): + +``` +Tool.define("bash", async (initCtx) => ({ + description: "执行 shell 命令", + parameters: z.object({ command: z.string(), ... }), + execute: async (args, ctx) => ({ title, output, metadata }), +})) +``` + +`init` 函数在工具被请求时**惰性调用**,传入 `{agent}` 上下文。这意味着同一个工具面对不同 Agent 可能返回不同的 `description` 或 `parameters`。 + +--- + +## 工具权限模型:Auto / Ask / Deny 决策流 + +权限系统是工具调用的守门人。每次工具调用都会触发 `ctx.ask()`,启动以下决策流: + +```mermaid +flowchart TD + Start["ctx.ask({permission, patterns, always})"] --> ForEach + + ForEach["遍历每个 pattern"] --> Evaluate + + Evaluate["evaluate(permission, pattern, ...rulesets)
permission/evaluate.ts:9-15"] --> FindLast + + FindLast["findLast: 从后向前
匹配 permission + pattern
使用 Wildcard.match()"] --> Found + + Found{找到匹配规则?} + + Found -->|否| DefaultAsk["默认:action = 'ask'"] + Found -->|是| CheckAction + + CheckAction{规则的 action?} + + CheckAction -->|"allow"| Allow["✅ 直接通过
无阻塞"] + CheckAction -->|"deny"| Deny["❌ 抛出 DeniedError
用户不可见"] + CheckAction -->|"ask"| NeedsAsk["标记 needsAsk = true"] + DefaultAsk --> NeedsAsk + + Allow --> NextPattern{更多 pattern?} + NextPattern -->|是| ForEach + NextPattern -->|否| AllDone["所有 pattern 通过"] + NeedsAsk --> NextPattern2{更多 pattern?} + NextPattern2 -->|是| ForEach + NextPattern2 -->|否| ShowPrompt + + ShowPrompt["📢 Bus.publish(Permission.Event.Asked)
创建 PendingEntry + Deferred"] + ShowPrompt --> WaitUser["⏳ await Deferred.await()
阻塞直到用户回复"] + + WaitUser --> UserReply{用户回复} + + UserReply -->|"once"| Once["✅ 仅此次通过
不修改规则集"] + UserReply -->|"always"| Always["✅ 永久通过
添加 allow 规则
自动批准同类请求"] + UserReply -->|"reject"| Reject["❌ RejectedError
取消本会话所有
pending 请求"] + + Deny --> ToolError["ToolPart.status = 'error'"] + Reject --> Blocked["blocked = true
processor 返回 'stop'"] + + style Allow fill:#c8e6c9 + style Deny fill:#ffcdd2 + style Reject fill:#ffcdd2 + style Once fill:#c8e6c9 + style Always fill:#a5d6a7 + style NeedsAsk fill:#fff9c4 + style DefaultAsk fill:#fff9c4 +``` + +### 💡 关键点:规则叠加与优先级 + +权限规则来自多个层级,**最后匹配的规则胜出**(`findLast` 语义): + +``` +1. 全局默认规则(allow all, doom_loop ask, question deny) +2. 白名单目录(Truncate.GLOB + skill 目录) +3. 用户配置权限(config.permission) +4. Agent 配置权限(agent.permission) +5. Session 级权限(session.permission — 可覆盖一切) +``` + +例如 `explore` Agent 的权限配置: + +``` +{ "*": "deny", grep: "allow", glob: "allow", list: "allow", + bash: "allow", read: "allow" } +``` + +这意味着 `explore` Agent 只能使用只读工具,无法调用 `edit` 或 `write`。 + +--- + +## 图解说明:工具执行细节 + +### Bash 工具执行流(`tool/bash.ts`) + +Bash 工具的执行比想象中复杂——它包含**两阶段权限检查**: + +1. **外部目录检查**:使用 tree-sitter 解析 bash 命令 AST,提取 `cd`/`rm`/`cp`/`mv` 等文件系统操作的目标路径,判断是否超出项目目录 +2. **命令权限检查**:提取命令名,以完整命令文本(含重定向)作为 pattern 请求权限 + +执行本身通过 `spawn()` 创建子进程,**流式更新 metadata**——用户可以实时在 TUI 中看到命令输出。超时和 abort 信号都会触发进程终止。 + +### Edit 工具执行流(`tool/edit.ts`) + +Edit 工具使用**文件锁 + 时间戳断言**保护并发安全: + +``` +FileTime.withLock(filePath, async () => { + FileTime.assert(sessionID, filePath) // 确认文件未被外部修改 + // ... 执行替换 ... +}) +``` + +替换本身通过**9 种 Replacer 策略**依次尝试(下一节详述),确保即使 LLM 给出的 `oldString` 有微小差异也能匹配成功。 + +### MCP 工具集成(`mcp/index.ts`) + +MCP(Model Context Protocol)工具通过标准协议连接外部工具服务器。配置在 `.opencode/mcp.json` 中定义,OpenCode 作为 MCP client 连接到这些 server,将远程工具注册到统一注册表中。MCP 工具的权限检查与内置工具完全一致。 + +--- + +## 关键设计决策 + +### 1. 为什么工具定义是惰性初始化(Lazy Init)? + +💡 `Tool.define(id, init)` 中 `init` 是一个异步函数,只在工具被实际请求时才执行。这允许: +- 不同 Agent 获得不同的工具描述(`init({agent})`) +- 运行时根据环境决定工具能力 +- 避免启动时加载所有工具的开销 + +### 2. 为什么使用 Wildcard 匹配而非精确匹配? + +💡 `Wildcard.match()` 让权限规则可以使用 `*` 通配。例如 `bash` 权限的 `always` 模式 `"git *"` 可以一次批准所有 git 命令,避免用户为每个 `git status`、`git diff` 单独确认。 + +### 3. 为什么 "always" 回复会自动批准同会话的其他请求? + +💡 当用户回复 `"always"` 时,系统不仅通过当前请求,还会扫描该 Session 中所有 pending 请求,自动批准匹配的。这避免了"批准了 edit 权限但还要单独批准每个文件"的烦人体验。 + +### 4. 为什么输出需要截断? + +💡 `Tool.define()` 的包装层(`tool.ts:71-84`)会调用 `Truncate.output()` 截断超长输出。过长的工具输出会消耗 LLM 的上下文窗口(Context Window),导致重要信息被挤出。截断后的内容写入临时文件,Agent 可按需读取。 + +### 5. 死循环检测为什么设在 Processor 层? + +💡 Doom Loop 检测在 `processor.ts` 的 `tool-call` 事件处理中——检查最近 3 个 ToolPart 是否使用相同工具+相同参数。这比在工具层面检测更全面,因为它能跨工具类型发现重复模式。 + +--- + +## 与下一节的衔接 + +我们已经看到工具调用的完整生命周期——从发现、权限、执行到结果返回。但其中最复杂的工具是 `edit`——它如何将 LLM 给出的"旧文本 → 新文本"转变为实际的文件修改?下一节 **"一次文件编辑的完整旅程"** 将深入 9 种 Replacer 策略、Patch 系统和 Snapshot 机制。 diff --git "a/all-in-one-book/01-\345\205\250\346\231\257\350\247\206\351\207\216/06-\344\270\200\346\254\241\346\226\207\344\273\266\347\274\226\350\276\221\347\232\204\345\256\214\346\225\264\346\227\205\347\250\213.md" "b/all-in-one-book/01-\345\205\250\346\231\257\350\247\206\351\207\216/06-\344\270\200\346\254\241\346\226\207\344\273\266\347\274\226\350\276\221\347\232\204\345\256\214\346\225\264\346\227\205\347\250\213.md" new file mode 100644 index 000000000000..d6c474317df9 --- /dev/null +++ "b/all-in-one-book/01-\345\205\250\346\231\257\350\247\206\351\207\216/06-\344\270\200\346\254\241\346\226\207\344\273\266\347\274\226\350\276\221\347\232\204\345\256\214\346\225\264\346\227\205\347\250\213.md" @@ -0,0 +1,407 @@ +# 一次文件编辑的完整旅程 + +> 📌 一句话总结:从 Agent 说"我要改这个文件"到文件落盘,中间经历了路径解析、文件锁、时间戳断言、9 种模糊匹配策略、格式化、LSP 诊断、Snapshot 快照——每一步都是一道安全网。 +> 🗺️ 本节在全景中的位置:上一节描述了工具调用的通用流程,本节聚焦最复杂、最关键的一类工具——文件编辑。理解这条链路,就理解了 OpenCode 如何**安全、可靠地修改你的代码**。 + +--- + +## 场景回顾 + +还是小明的故事。Agent 已经读取了 `utils.ts` 的内容,现在决定执行编辑: + +```json +{ + "toolName": "edit", + "args": { + "filePath": "/project/src/utils.ts", + "oldString": "items.forEach((item) => {", + "newString": "for (const item of items) {" + } +} +``` + +让我们追踪这个 JSON 如何变成磁盘上的真实修改。 + +--- + +## 全景图:文件编辑完整链路 + +```mermaid +sequenceDiagram + autonumber + participant Agent as 🤖 Agent 决策
processor.ts + participant Define as 🏗️ Tool.define
tool/tool.ts + participant Edit as ✏️ EditTool
tool/edit.ts + participant FT as ⏱️ FileTime
file/time.ts + participant FS as 💾 Filesystem
util/filesystem.ts + participant Perm as 🔐 Permission
permission/index.ts + participant Format as 💅 Formatter
Format.file() + participant LSP as 🔍 LSP Server
lsp/ + participant Snap as 📸 Snapshot
snapshot/index.ts + participant Git as 🗂️ Snapshot Git
~/.opencode/data/snapshot/ + participant Bus as 📢 Event Bus + + Agent->>Define: execute({filePath, oldString, newString}) + Note over Define: tool.ts:49-89
Zod 参数校验 + + Define->>Edit: 转入 EditTool.execute() + + Edit->>Edit: 路径解析
相对路径 → 绝对路径 + Note over Edit: path.isAbsolute() ?
: path.join(Instance.directory, filePath) + + Edit->>Edit: assertExternalDirectory(ctx, filePath) + Note over Edit: 💡 关键点:检查路径是否
在项目目录之内 + + Edit->>FT: FileTime.withLock(filePath, fn) + Note over FT: 获取信号量锁
同一文件串行操作 + + FT->>FT: FileTime.assert(sessionID, filePath) + Note over FT: 💡 关键点:比较 mtime/ctime/size
确认文件未被外部修改 + + FT->>FS: Filesystem.stat(filePath) + FS-->>FT: {mtime, ctime, size} + + Edit->>FS: Filesystem.readText(filePath) + FS-->>Edit: contentOld(原始文件内容) + + Edit->>Edit: detectLineEnding(contentOld)
识别换行符: "\n" 或 "\r\n" + + Edit->>Edit: replace(contentOld, oldString, newString) + Note over Edit: 💡 核心:9 种 Replacer 策略
依次尝试直到匹配成功 + + Edit->>Edit: createTwoFilesPatch()
生成 Unified Diff + + Edit->>Perm: ctx.ask({permission:"edit", patterns:[相对路径]}) + Note over Perm: 权限评估 → allow/ask/deny
metadata 包含完整 diff + + Perm-->>Edit: 权限通过 + + Edit->>FS: Filesystem.write(filePath, contentNew) + Note over FS: 💡 ENOENT → mkdir -r → retry
原子写入 + + Edit->>Format: Format.file(filePath) + Note over Format: 应用项目格式化规则
(Prettier/Biome/etc.) + + Edit->>FT: FileTime.read(sessionID, filePath) + Note over FT: 记录新的 mtime/ctime/size
供下次编辑断言使用 + + Edit->>Bus: Bus.publish(File.Event.Edited) + Edit->>Bus: Bus.publish(FileWatcher.Event.Updated) + + Edit->>LSP: LSP.touchFile(filePath, true) + LSP->>LSP: 重新分析文件 + LSP-->>Edit: diagnostics(诊断信息) + + Edit->>Edit: 过滤 errors (severity === 1)
最多 20 条/文件 + + Note over Edit: 构建 FileDiff metadata + + Edit-->>Define: {title, output, metadata:{diff, filediff, diagnostics}} + + Define->>Define: Truncate.output(output) + Define-->>Agent: 完整工具结果 + + Note over Agent: finish-step 事件触发 + + Agent->>Snap: Snapshot.track() + Note over Snap: 💡 关键点:捕获修改后快照 + + Snap->>Git: git add --sparse . + Snap->>Git: git write-tree + Git-->>Snap: tree hash(快照哈希) + + Snap->>Snap: Snapshot.patch(beforeHash) + Note over Snap: 对比 before/after 树
生成变更列表 + + Agent->>Agent: SessionSummary.summarize() + Note over Agent: 更新 session.summary
{additions, deletions, files, diffs} +``` + +--- + +## Patch 系统:LLM 输出如何变成文件修改 + +OpenCode 支持两种编辑模式,由不同的工具实现。我们来看它们的完整对比: + +```mermaid +flowchart TB + subgraph LLM["🤖 LLM 输出"] + direction TB + edit_output["Edit 模式
{oldString, newString}
精确替换指令"] + patch_output["Apply Patch 模式
Unified Diff 格式
多文件批量修改"] + end + + subgraph EditPath["✏️ Edit Tool 路径
tool/edit.ts"] + direction TB + E1["读取原文件内容"] + E2["9 种 Replacer 策略匹配"] + E3["字符串替换生成新内容"] + E4["createTwoFilesPatch() 生成 diff"] + end + + subgraph PatchPath["🩹 Apply Patch 路径
tool/apply_patch.ts + patch/index.ts"] + direction TB + P1["Patch.parsePatch()
解析 Begin/End 块"] + P2["提取 Hunk 列表
add / update / delete"] + P3["deriveNewContentsFromChunks()
计算行替换"] + P4["applyReplacements()
倒序 splice 应用"] + end + + subgraph Common["🔄 共同后续流程"] + direction TB + C1["权限检查 ctx.ask()"] + C2["Filesystem.write() 写入"] + C3["Format.file() 格式化"] + C4["LSP.touchFile() 诊断"] + C5["Snapshot.track() 快照"] + end + + edit_output --> EditPath + patch_output --> PatchPath + EditPath --> Common + PatchPath --> Common + + style LLM fill:#e3f2fd + style EditPath fill:#e8f5e9 + style PatchPath fill:#fff3e0 + style Common fill:#f3e5f5 +``` + +### Edit Tool 的 9 种 Replacer 策略 + +当 LLM 给出的 `oldString` 与文件实际内容有微小差异时(这在实践中非常常见),`replace()` 函数(`edit.ts:630-667`)会依次尝试 9 种匹配策略: + +```mermaid +flowchart TD + Start["replace(content, oldString, newString)"] --> R1 + + R1["① SimpleReplacer
精确字符串匹配"] + R1 -->|匹配成功| Done["✅ 应用替换"] + R1 -->|失败| R2 + + R2["② LineTrimmedReplacer
逐行 trim 后匹配"] + R2 -->|匹配成功| Done + R2 -->|失败| R3 + + R3["③ BlockAnchorReplacer
首尾行锚定 + Levenshtein 距离"] + R3 -->|匹配成功| Done + R3 -->|失败| R4 + + R4["④ WhitespaceNormalizedReplacer
所有空白归一化为单空格"] + R4 -->|匹配成功| Done + R4 -->|失败| R5 + + R5["⑤ IndentationFlexibleReplacer
忽略缩进差异"] + R5 -->|匹配成功| Done + R5 -->|失败| R6 + + R6["⑥ EscapeNormalizedReplacer
处理转义序列差异
\\n \\t \\' \\\" \\`"] + R6 -->|匹配成功| Done + R6 -->|失败| R7 + + R7["⑦ MultiOccurrenceReplacer
所有精确匹配(replaceAll 模式)"] + R7 -->|匹配成功| Done + R7 -->|失败| R8 + + R8["⑧ TrimmedBoundaryReplacer
多行块边界 trim"] + R8 -->|匹配成功| Done + R8 -->|失败| R9 + + R9["⑨ ContextAwareReplacer
上下文行锚定 + 50% 相似度"] + R9 -->|匹配成功| Done + R9 -->|失败| Error["❌ 未找到匹配
返回错误信息"] + + style R1 fill:#c8e6c9 + style R3 fill:#fff9c4 + style R6 fill:#ffe0b2 + style R9 fill:#ffcdd2 + style Done fill:#a5d6a7 + style Error fill:#ef9a9a +``` + +### 💡 关键点:为什么需要这么多策略? + +LLM 生成的 `oldString` 常见问题: +- **缩进不一致**:LLM 可能用 2 空格,而文件用 4 空格 → `IndentationFlexibleReplacer` +- **尾部空白**:LLM 可能丢失行尾空格 → `LineTrimmedReplacer` +- **转义字符**:LLM 可能输出 `\n` 而文件中是实际换行 → `EscapeNormalizedReplacer` +- **上下文偏移**:文件被其他编辑修改后行号偏移 → `BlockAnchorReplacer`(使用 Levenshtein 距离容错) + +### Apply Patch 的 Hunk 格式 + +`apply_patch` 工具解析的 Patch 格式(`patch/index.ts`): + +``` +*** Begin Patch +*** Update File: src/utils.ts +@@ items.forEach((item) => { @@ +- items.forEach((item) => { ++ for (const item of items) { + console.log(item) +*** End Patch +``` + +解析后生成 `Hunk` 结构,通过 `deriveNewContentsFromChunks()` 应用: +1. `seekSequence()` — 4 轮匹配(精确 → rstrip → trim → Unicode 归一化) +2. `computeReplacements()` — 计算 `[startIndex, oldLength, newLines]` 数组 +3. `applyReplacements()` — **倒序 splice** 避免索引偏移 + +--- + +## Snapshot 系统:编辑的时光机 + +每次编辑前后,Snapshot 系统都会用**独立的 Git 仓库**记录文件系统状态: + +```mermaid +flowchart LR + subgraph Before["📸 编辑前"] + B1["Snapshot.track()
processor.ts start-step"] + B2["git add --sparse ."] + B3["git write-tree"] + B4["hash: abc123"] + end + + subgraph Edit["✏️ 编辑过程"] + E1["Filesystem.write()"] + E2["文件已修改"] + end + + subgraph After["📸 编辑后"] + A1["Snapshot.track()
processor.ts finish-step"] + A2["git add --sparse ."] + A3["git write-tree"] + A4["hash: def456"] + end + + subgraph Ops["🔧 可用操作"] + O1["Snapshot.diff(abc123)
获取 Unified Diff"] + O2["Snapshot.diffFull(abc123, def456)
获取 FileDiff[] 详情"] + O3["Snapshot.restore(abc123)
完全回滚到编辑前"] + O4["Snapshot.revert(patches)
按文件选择性回滚"] + end + + Before --> Edit --> After + After --> Ops + + style Before fill:#e3f2fd + style Edit fill:#fff3e0 + style After fill:#e8f5e9 + style Ops fill:#f3e5f5 +``` + +### Snapshot 存储位置 + +``` +~/.opencode/data/snapshot/{projectID}/{worktreeHash}/.git +├── objects/ ← 文件内容快照 +├── refs/ ← 无分支,纯 tree 对象 +├── HEAD +├── config ← autocrlf=false, fsmonitor=false +└── info/exclude ← 排除 .gitignore + >2MB 文件 +``` + +💡 **关键点**:Snapshot 使用**独立于项目的 Git 仓库**。它不会干扰项目的 `.git`,也不会出现在 `git status` 中。大于 2MB 的文件自动排除,后台每小时运行 `git gc --prune=7.days` 清理过期快照。 + +--- + +## 图解说明:并发安全三重保护 + +文件编辑面临的核心挑战:多个工具可能同时修改同一文件。OpenCode 通过三重机制保护: + +```mermaid +flowchart TD + subgraph Lock["🔒 第一重:文件信号量锁"] + L1["FileTime.withLock(filePath, fn)
file/time.ts"] + L2["每个文件路径一个 Semaphore
同一文件的编辑串行化"] + end + + subgraph Assert["⏱️ 第二重:时间戳断言"] + A1["FileTime.assert(sessionID, filePath)
file/time.ts"] + A2["比较当前 mtime/ctime/size
与上次 read 时记录的值"] + A3["不一致 → 抛出错误
'文件已被外部修改'"] + end + + subgraph Detect["🔍 第三重:行尾检测"] + D1["detectLineEnding(content)"] + D2["保持原文件的换行风格
\\n 或 \\r\\n"] + end + + Lock --> Assert --> Detect --> Safe["✅ 安全执行替换"] + + style Lock fill:#e3f2fd + style Assert fill:#fff3e0 + style Detect fill:#e8f5e9 + style Safe fill:#c8e6c9 +``` + +--- + +## 关键设计决策 + +### 1. 为什么 Edit 而非 Write? + +💡 `edit` 工具做**精确替换**(oldString → newString),`write` 工具做**全量覆盖**。精确替换的好处: +- diff 更小、更可读 +- 不会意外丢失文件其他部分的内容 +- 权限弹窗中展示的 diff 更有意义 +- LLM 不需要重复输出整个文件(节省 token) + +### 2. 为什么 9 种 Replacer 而非 1 种? + +💡 LLM 不是精确的文本复制机。实测中,LLM 输出的 `oldString` 经常有空白差异、缩进偏移、转义不一致。9 种策略从严格到宽松依次尝试,**优先使用最精确的匹配**——这避免了过度模糊导致的错误替换。 + +### 3. 为什么用独立 Git 做 Snapshot? + +💡 三个原因: +- **不污染项目**:Snapshot 的 Git 操作不影响项目的 `.git` +- **原子性**:`git write-tree` 是原子操作,不会出现半写状态 +- **高效 diff**:`git diff --cached` 比文件系统级 diff 快得多 + +### 4. 为什么编辑后要跑 Formatter? + +💡 `Format.file(filePath)` 在写入后自动运行项目配置的格式化器(Prettier、Biome 等)。这确保 AI 生成的代码**遵循项目的代码风格**,不会因为 LLM 的格式偏好引入不一致。 + +### 5. 为什么编辑后要收集 LSP 诊断? + +💡 `LSP.touchFile()` + `LSP.diagnostics()` 让 Agent 立即知道编辑是否引入了类型错误或语法错误。如果检测到错误,诊断信息会附在工具输出中——Agent 可以在下一轮 ReAct 循环中自动修复。 + +``` +output: "Edit applied successfully. + +LSP errors detected: + +src/utils.ts:42 - error TS2304: Cannot find name 'item'. +" +``` + +### 6. Apply Patch 的倒序 splice + +💡 `applyReplacements()` 将替换操作按行号**从大到小排序**后执行。这是一个经典技巧——倒序处理避免了前面的 splice 操作导致后面的行号偏移。 + +--- + +## Write 工具与 MultiEdit 工具 + +除了 `edit`,文件修改还有两个变体: + +**`write` 工具**(`tool/write.ts`)— 全量写入: +- 适合创建新文件或大规模重写 +- 同样经过权限检查 + 格式化 + LSP 诊断 +- diff 展示完整的 before/after 差异 + +**`multiedit` 工具**(`tool/multiedit.ts`)— 批量编辑: +- 接受一个 `edits` 数组,对同一文件执行多次替换 +- 内部循环调用 `EditTool.execute()` +- 避免多次文件读写的开销 + +**`apply_patch` 工具**(`tool/apply_patch.ts`)— 多文件补丁: +- 解析 `*** Begin Patch ... *** End Patch` 格式 +- 支持 Add / Update / Delete / Move 四种操作 +- 单次权限请求覆盖所有文件变更 +- 适合大规模重构场景 + +--- + +## 与下一节的衔接 + +至此,我们完成了"全景视野"章节的三次旅程——对话、工具调用、文件编辑。你已经看到了 OpenCode 从用户输入到文件落盘的完整数据流。接下来,**"快速上手"** 章节将带你从阅读者变为使用者,亲手体验这些流程。 From 5398338afa1eb9b89222f946e97d853c58bd469a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Mar 2026 09:45:31 +0000 Subject: [PATCH 4/7] =?UTF-8?q?Add=20all-in-one-book=20chapter=2001=20?= =?UTF-8?q?=E5=85=A8=E6=99=AF=E8=A7=86=E9=87=8E=20(11=20sections)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: propress <202759273+propress@users.noreply.github.com> Agent-Logs-Url: https://github.com/propress/opencode/sessions/3ba6ec6c-19d3-4c9a-8b93-d24de7a88c29 --- ...01\345\205\250\346\231\257\345\233\276.md" | 443 +++++++++++++++++ ...37\345\205\250\346\231\257\345\233\276.md" | 467 ++++++++++++++++++ ...71\345\205\250\346\231\257\345\233\276.md" | 364 ++++++++++++++ ...11\345\236\213\347\220\206\347\224\261.md" | 340 +++++++++++++ ...45\347\234\213\345\223\252\347\253\240.md" | 155 ++++++ 5 files changed, 1769 insertions(+) create mode 100644 "all-in-one-book/01-\345\205\250\346\231\257\350\247\206\351\207\216/07-\346\225\260\346\215\256\346\265\201\345\205\250\346\231\257\345\233\276.md" create mode 100644 "all-in-one-book/01-\345\205\250\346\231\257\350\247\206\351\207\216/08-\347\212\266\346\200\201\347\224\237\345\221\275\345\221\250\346\234\237\345\205\250\346\231\257\345\233\276.md" create mode 100644 "all-in-one-book/01-\345\205\250\346\231\257\350\247\206\351\207\216/09-\346\211\251\345\261\225\347\202\271\345\205\250\346\231\257\345\233\276.md" create mode 100644 "all-in-one-book/01-\345\205\250\346\231\257\350\247\206\351\207\216/10-\346\212\200\346\234\257\346\240\210\345\205\250\346\231\257\344\270\216\351\200\211\345\236\213\347\220\206\347\224\261.md" create mode 100644 "all-in-one-book/01-\345\205\250\346\231\257\350\247\206\351\207\216/11-\351\200\237\346\237\245\357\274\232\346\210\221\346\203\263\345\201\232X\345\272\224\350\257\245\347\234\213\345\223\252\347\253\240.md" diff --git "a/all-in-one-book/01-\345\205\250\346\231\257\350\247\206\351\207\216/07-\346\225\260\346\215\256\346\265\201\345\205\250\346\231\257\345\233\276.md" "b/all-in-one-book/01-\345\205\250\346\231\257\350\247\206\351\207\216/07-\346\225\260\346\215\256\346\265\201\345\205\250\346\231\257\345\233\276.md" new file mode 100644 index 000000000000..ce8d75bf6f47 --- /dev/null +++ "b/all-in-one-book/01-\345\205\250\346\231\257\350\247\206\351\207\216/07-\346\225\260\346\215\256\346\265\201\345\205\250\346\231\257\345\233\276.md" @@ -0,0 +1,443 @@ +# 数据流全景图 + +> 📌 一句话总结:从用户敲下回车到 LLM 回复落盘,数据经历 7 次格式变换——字符串 → 内部消息 → AI SDK 格式 → HTTP 请求 → 流式响应 → 结构化 Part → SQLite 行,每一步都有明确的类型守卫。 +> 🗺️ 本节在全景中的位置:前三节以"旅程"视角讲述了对话、工具调用、文件编辑的流程。本节切换到**数据(Data)**视角,用 DFD(数据流图)把系统中所有数据的来源、变换、去向画成一张全景图。 + +--- + +## 全景图:系统级数据流 + +```mermaid +flowchart LR + subgraph External["外部实体"] + User["👤 用户
终端/桌面/VSCode"] + LLM["🤖 LLM API
Anthropic · OpenAI · Google …"] + FS["📁 文件系统
项目源码 · .opencode/"] + Git["🔀 Git
版本控制"] + MCP["🔌 MCP Server
外部工具"] + end + + subgraph Processing["处理层"] + Server["🌐 Hono Server
server/index.ts"] + Prompt["📨 SessionPrompt
session/prompt.ts"] + Processor["⚙️ SessionProcessor
session/processor.ts"] + LLMModule["🧠 LLM.stream
provider/llm.ts"] + ToolExec["🔧 ToolRegistry
tool/registry.ts"] + Compaction["📦 Compaction
session/compaction.ts"] + end + + subgraph DataStores["数据存储"] + SQLite["🗃️ SQLite
SessionTable
MessageTable
PartTable"] + Config["⚙️ Config Files
opencode.json
.opencode/"] + Snapshot["📸 Snapshot
VCS 快照"] + end + + subgraph Bus["事件总线"] + EventBus["📡 Bus
BusEvent · SyncEvent"] + end + + User -->|"PromptInput (JSON)"| Server + Server -->|"验证后的 PromptInput"| Prompt + Prompt -->|"MessageV2.User + Parts"| SQLite + Prompt -->|"ModelMessage[]"| LLMModule + LLMModule -->|"streamText() 调用"| LLM + LLM -->|"StreamTextResult (SSE)"| Processor + Processor -->|"ToolPart{status:running}"| ToolExec + ToolExec -->|"ToolPart{status:completed}"| Processor + ToolExec -->|"读/写文件"| FS + ToolExec -->|"MCP 调用"| MCP + Processor -->|"Part 增量写入"| SQLite + Processor -->|"PartDelta 事件"| EventBus + EventBus -->|"SSE /event 推送"| Server + Server -->|"实时流"| User + Compaction -->|"压缩后消息"| SQLite + Config -->|"agent/tool/mcp 配置"| Prompt + Git -->|"diff · branch"| FS + Processor -->|"PatchPart · SnapshotPart"| Snapshot +``` + +--- + +## 消息格式变换链 + +这是数据流的核心——一条用户输入从 `string` 到 SQLite 行,经历了 **7 次格式变换**: + +```mermaid +flowchart TB + subgraph S1["① 用户输入"] + Raw["纯文本字符串
'帮我重构这个函数'"] + end + + subgraph S2["② PromptInput"] + PI["PromptInput {
sessionID,
parts: [{type:'text', text:'…'}],
model?, agent?
}"] + end + + subgraph S3["③ MessageV2.User"] + MU["MessageV2.User {
id: MessageID,
role: 'user',
agent: 'coder',
model: {providerID, modelID},
time: {created}
}"] + UP["Part[] — TextPart / FilePart / AgentPart"] + end + + subgraph S4["④ ModelMessage (AI SDK)"] + MM["CoreMessage[] {
role: 'user' | 'assistant',
content: ContentPart[]
}"] + end + + subgraph S5["⑤ HTTP 请求"] + HTTP["POST https://api.anthropic.com/v1/messages
{model, messages, tools, system, stream:true}"] + end + + subgraph S6["⑥ 流式响应"] + Stream["StreamTextResult
事件序列:
text-delta · tool-call · step-finish …"] + end + + subgraph S7["⑦ 持久化格式"] + DB["MessageTable.data (JSON)
PartTable.data (JSON)
每个 Part 独立一行"] + end + + S1 -->|"TUI/Server 封装"| S2 + S2 -->|"SessionPrompt.prompt()"| S3 + S3 -->|"MessageV2.toModelMessages()"| S4 + S4 -->|"Vercel AI SDK streamText()"| S5 + S5 -->|"LLM 返回 SSE 流"| S6 + S6 -->|"SessionProcessor.process()"| S7 +``` + +--- + +## 图解说明:每一步的细节 + +### ① → ② 用户输入 → PromptInput + +用户在 TUI 中输入文本后,客户端将其封装为 `PromptInput`: + +```typescript +// session/prompt.ts — PromptInput schema +const PromptInput = z.object({ + sessionID: SessionID.zod, + parts: z.array(z.discriminatedUnion("type", [ + TextPartInput, // { type: "text", text: string } + FilePartInput, // { type: "file", url, mime } + AgentPartInput, // { type: "agent", name } + SubtaskPartInput, // { type: "subtask", prompt, description, agent } + ])), + model: z.object({ providerID, modelID }).optional(), + agent: z.string().optional(), + format: MessageV2.Format.optional(), + noReply: z.boolean().optional(), +}) +``` + +注意 `parts` 数组——用户输入不仅仅是文字,还可以附带文件、指定 Agent、甚至发起子任务。 + +### ② → ③ PromptInput → MessageV2.User + +`SessionPrompt.prompt()` 把 `PromptInput` 转化为持久化的 `MessageV2.User`: + +```typescript +// 关键字段映射 +MessageV2.User = { + id: MessageID.ascending(), // 时间有序 ID + sessionID: input.sessionID, + role: "user", + agent: resolvedAgent.name, // 从 config 解析 + model: { providerID, modelID }, // 从 config / input 解析 + time: { created: Date.now() }, + format: input.format, // text 或 json_schema + tools: input.tools, // 工具白名单 +} +``` + +同时,每个 `part` 被写入 `PartTable`,与 `MessageTable` 通过 `message_id` 关联。 + +### ③ → ④ MessageV2 → ModelMessage(AI SDK 格式) + +这是最复杂的变换。`MessageV2.toModelMessages()` 负责把内部 Part 类型映射到 Vercel AI SDK 的 `CoreMessage` 格式: + +| 内部 Part 类型 | AI SDK ContentPart | 说明 | +|---|---|---| +| `TextPart` | `{ type: "text", text }` | 直接映射 | +| `FilePart` | `{ type: "file", url, mediaType }` | URL 或 base64 | +| `ReasoningPart` | `{ type: "reasoning", text }` | 思考过程 | +| `ToolPart(completed)` | `{ type: "tool-result", toolCallId, result }` | 工具结果 | +| `ToolPart(error)` | `{ type: "tool-result", isError: true }` | 工具错误 | +| `StepFinishPart` | *(不映射)* | 仅内部使用 | +| `PatchPart` | *(不映射)* | 仅内部使用 | + +> 💡 **关键设计**:并非所有 Part 都需要发给 LLM。`StepFinishPart`、`PatchPart`、`SnapshotPart` 等仅用于内部记录和 UI 展示。 + +### ④ → ⑤ ModelMessage → HTTP 请求 + +Vercel AI SDK 的 `streamText()` 接管剩余工作: + +```typescript +// provider/llm.ts — 简化后的调用 +const result = streamText({ + model: provider(modelID), // AI SDK provider 实例 + messages: modelMessages, // CoreMessage[] + tools: builtTools, // 工具定义 + system: systemPrompts.join("\n"), // 合并系统提示词 + abortSignal: abort, + maxSteps: agent.steps ?? 100, // 最大迭代步数 + toolChoice: agent.toolChoice, +}) +``` + +### ⑤ → ⑥ HTTP 响应 → StreamTextResult + +LLM 以 Server-Sent Events 返回流式数据。Vercel AI SDK 将其解析为标准化事件序列: + +``` +start → reasoning-start → reasoning-delta* → reasoning-end + → text-start → text-delta* → text-end + → tool-input-start → tool-call → tool-result + → step-finish → finish +``` + +### ⑥ → ⑦ StreamTextResult → 持久化 + +`SessionProcessor.process()` 消费这些事件,**实时**写入数据库: + +```mermaid +flowchart LR + subgraph Processor["SessionProcessor"] + direction TB + Listen["监听 stream 事件"] + Create["创建/更新 Part"] + Persist["写入 PartTable"] + Emit["发射 Bus 事件"] + end + + Stream["StreamTextResult"] --> Listen + Listen -->|"text-delta"| Create + Listen -->|"tool-call"| Create + Listen -->|"step-finish"| Create + Create --> Persist + Persist --> Emit + Emit -->|"PartDelta
PartUpdated"| Bus["EventBus"] +``` + +每种流事件对应的 Part 创建逻辑: + +| Stream 事件 | 创建的 Part 类型 | Part 状态 | +|---|---|---| +| `reasoning-start` | `ReasoningPart` | `text: ""` | +| `reasoning-delta` | *(更新)* | 追加 `text` | +| `text-start` | `TextPart` | `text: ""` | +| `text-delta` | *(更新)* | 追加 `text` | +| `tool-input-start` | `ToolPart` | `status: "pending"` | +| `tool-call` | *(更新)* | `status: "running"` | +| `tool-result` | *(更新)* | `status: "completed"` | +| `tool-error` | *(更新)* | `status: "error"` | +| `step-finish` | `StepFinishPart` | `cost`, `tokens` | + +--- + +## 数据存储层详解 + +### SQLite 表结构 + +```mermaid +erDiagram + ProjectTable ||--o{ SessionTable : "project_id" + SessionTable ||--o{ MessageTable : "session_id" + MessageTable ||--o{ PartTable : "message_id" + SessionTable ||--o{ TodoTable : "session_id" + ProjectTable ||--o| PermissionTable : "project_id" + + ProjectTable { + text id PK + text directory + integer time_created + integer time_updated + } + + SessionTable { + text id PK + text project_id FK + text workspace_id + text parent_id FK + text slug + text directory + text title + text version + text share_url + text summary_diffs "JSON" + text revert "JSON" + text permission "JSON" + integer time_created + integer time_updated + integer time_compacting + integer time_archived + } + + MessageTable { + text id PK + text session_id FK + integer time_created + integer time_updated + text data "JSON — MessageV2.Info" + } + + PartTable { + text id PK + text message_id FK + text session_id FK + integer time_created + integer time_updated + text data "JSON — MessageV2.Part" + } + + TodoTable { + text session_id FK + text content + text status + text priority + integer position + integer time_created + integer time_updated + } + + PermissionTable { + text project_id PK + integer time_created + integer time_updated + text data "JSON — Permission.Ruleset" + } +``` + +### 存储设计决策 + +**为什么 `data` 列存 JSON?** + +`MessageTable.data` 和 `PartTable.data` 都用 JSON 列存储完整对象。这是**事件溯源(Event Sourcing)**的简化实现: + +1. **写入快**——消息结构复杂且频繁变化,不值得把每个字段拆成列 +2. **读取灵活**——整条 JSON 反序列化后即可得到完整类型 +3. **迁移简单**——新增字段只需在应用层处理默认值,不需要 `ALTER TABLE` + +但核心索引字段(`session_id`、`time_created`、`id`)仍然是独立列,确保查询性能。 + +--- + +## 事件总线:实时数据分发 + +```mermaid +flowchart TB + subgraph Publishers["发布者"] + SP["SessionPrompt"] + PROC["SessionProcessor"] + SESS["Session CRUD"] + end + + subgraph Bus["Bus (Effect PubSub)"] + direction LR + Typed["typed Map
按事件类型路由"] + Wild["wildcard PubSub
所有事件"] + end + + subgraph Subscribers["订阅者"] + SSE["SSE /event 端点
→ TUI / 桌面客户端"] + Plugin["Plugin Hooks
→ 插件系统"] + Internal["内部服务
→ Status · Compaction"] + end + + SP -->|"MessageV2.Event.Updated"| Typed + SP -->|"MessageV2.Event.PartUpdated"| Typed + PROC -->|"MessageV2.Event.PartDelta"| Typed + PROC -->|"Session.Event.Error"| Typed + SESS -->|"Session.Event.Created"| Typed + SESS -->|"Session.Event.Updated"| Typed + + Typed --> Wild + Typed --> SSE + Typed --> Plugin + Wild --> Internal +``` + +**事件类型分两类**: + +| 类型 | 定义方式 | 用途 | 示例 | +|---|---|---|---| +| `SyncEvent` | `SyncEvent.define()` | 需要持久化/同步的状态变更 | `Session.Event.Created`、`MessageV2.Event.Updated` | +| `BusEvent` | `BusEvent.define()` | 瞬时通知,不持久化 | `MessageV2.Event.PartDelta`、`SessionStatus.Event.Status` | + +--- + +## 完整数据流时序 + +把上面所有部分串起来,一次完整的对话数据流如下: + +```mermaid +sequenceDiagram + autonumber + participant U as 👤 User + participant S as 🌐 Hono Server + participant P as 📨 SessionPrompt + participant DB as 🗃️ SQLite + participant L as 🧠 LLM.stream + participant API as 🤖 LLM API + participant Proc as ⚙️ Processor + participant T as 🔧 ToolExec + participant Bus as 📡 EventBus + + U->>S: POST /session/:id/message
{parts:[{type:"text",text:"…"}]} + S->>P: 验证 PromptInput + P->>DB: INSERT MessageTable (User) + P->>DB: INSERT PartTable (TextPart) + P->>Bus: MessageV2.Event.Updated + P->>P: MessageV2.toModelMessages() + P->>L: StreamInput {messages, tools, system} + L->>API: streamText() → HTTP POST + API-->>Proc: SSE: text-delta + Proc->>DB: INSERT PartTable (TextPart) + Proc->>Bus: PartDelta {delta} + Bus-->>S: SSE 推送 + S-->>U: 实时文本流 + API-->>Proc: SSE: tool-call + Proc->>DB: UPDATE PartTable (ToolPart → running) + Proc->>T: 执行工具 + T->>Proc: 工具结果 + Proc->>DB: UPDATE PartTable (ToolPart → completed) + Proc->>Bus: PartUpdated + API-->>Proc: SSE: step-finish + Proc->>DB: INSERT PartTable (StepFinishPart) + Proc->>DB: UPDATE MessageTable (tokens, cost) + Proc->>Bus: MessageV2.Event.Updated + Bus-->>S: SSE 推送 + S-->>U: 完成通知 +``` + +--- + +## 关键设计决策 + +### 1. 为什么 Part 独立存储? + +每个 `Part` 在 `PartTable` 中是独立的一行,而不是嵌入在 `MessageTable.data` 中。这让我们能: + +- **增量更新**:流式过程中只需 `UPDATE` 一个 Part 行,而不是重写整条 Message +- **独立删除**:compaction(上下文压缩)时可以只删除工具输出的 Part,保留其他 +- **细粒度事件**:`PartDelta` 事件只携带变化部分,减少 SSE 传输量 + +### 2. 为什么消息 ID 是时间有序的? + +`MessageID.ascending()` 生成时间有序 ID(类似 ULID),这意味着: + +- 按 `id` 排序 = 按时间排序,无需额外 `ORDER BY time_created` +- `message_session_time_created_id_idx` 索引可以高效支持分页查询 +- `SessionID` 使用 `descending()`——最新会话排在前面 + +### 3. 为什么用 Effect PubSub 而不是 Node.js EventEmitter? + +Effect PubSub 提供: + +- **背压(Backpressure)**:订阅者消费慢时不会丢失事件 +- **类型安全**:每个事件有 Zod schema 验证 +- **Effect 集成**:可以无缝嵌入 Effect 的错误处理和资源管理 + +--- + +## 与下一节的衔接 + +我们已经看到数据在系统中如何**流动**。但数据的"形态"在时间轴上是如何变化的?一个 Session 从创建到归档经历了哪些状态?一个 ToolPart 从 `pending` 到 `completed` 的状态机是什么样的? + +下一节 [08-状态生命周期全景图](./08-状态生命周期全景图.md) 将从**状态(State)**视角,为每种核心实体画出完整的状态机图。 diff --git "a/all-in-one-book/01-\345\205\250\346\231\257\350\247\206\351\207\216/08-\347\212\266\346\200\201\347\224\237\345\221\275\345\221\250\346\234\237\345\205\250\346\231\257\345\233\276.md" "b/all-in-one-book/01-\345\205\250\346\231\257\350\247\206\351\207\216/08-\347\212\266\346\200\201\347\224\237\345\221\275\345\221\250\346\234\237\345\205\250\346\231\257\345\233\276.md" new file mode 100644 index 000000000000..10060b3de907 --- /dev/null +++ "b/all-in-one-book/01-\345\205\250\346\231\257\350\247\206\351\207\216/08-\347\212\266\346\200\201\347\224\237\345\221\275\345\221\250\346\234\237\345\205\250\346\231\257\345\233\276.md" @@ -0,0 +1,467 @@ +# 状态生命周期全景图 + +> 📌 一句话总结:OpenCode 中有 4 条核心状态机——Session、SessionStatus、Message(含 Assistant 与 Part)、ToolPart,它们互相联动但各自独立,理解这些状态流转就能理解系统在任意时刻"正在做什么"。 +> 🗺️ 本节在全景中的位置:上一节从数据流视角画出了信息的变换链路。本节切换到**状态(State)**视角,用状态机图展示每种核心实体的完整生命周期,以及状态之间的触发关系。 + +--- + +## 全景图:四大状态机联动 + +```mermaid +flowchart TB + subgraph Overview["四大状态机总览"] + direction LR + A["🗂️ Session 生命周期
创建 → 活跃 → 归档"] + B["🚦 SessionStatus
idle ↔ busy ↔ retry"] + C["💬 Message 生命周期
创建 → 处理中 → 完成"] + D["🔧 ToolPart 状态
pending → running → completed"] + end + + A ---|"Session 活跃时
触发消息处理"| C + C ---|"消息处理驱动
Status 切换"| B + C ---|"消息包含
工具调用"| D + D ---|"工具完成/失败
影响消息状态"| C +``` + +--- + +## 一、Session 生命周期 + +```mermaid +stateDiagram-v2 + [*] --> Created: Session.create() + + Created --> Active: 首次消息 / touch() + + Active --> Active: setTitle() / setPermission()\nsetSummary() / touch() + Active --> Compacting: 上下文溢出\ntime.compacting = now + Active --> Forked: Session.fork() + Active --> Shared: Session.share() + Active --> Reverting: setRevert() + + Compacting --> Active: 压缩完成\ntime.compacting = undefined + + Shared --> Active: Session.unshare() + + Reverting --> Active: clearRevert() + + Active --> Archived: setArchived()\ntime.archived = now + Archived --> Active: setArchived(undefined) + + Active --> Deleted: Session.delete() + Archived --> Deleted: Session.delete() + Deleted --> [*] + + note right of Created + Session.Info { + id: SessionID (descending) + slug: string + projectID: ProjectID + title: string + time: { created, updated } + } + end note + + note right of Compacting + time.compacting 非空 + 表示正在压缩上下文 + 期间不接受新消息 + end note + + note right of Archived + time.archived 非空 + UI 中不再默认显示 + 但数据完整保留 + end note +``` + +### Session 状态说明 + +| 状态 | 判断依据 | 触发事件 | 可执行操作 | +|---|---|---|---| +| **Created** | `time.created` 存在,无消息 | `Session.Event.Created` | 发消息、修改标题、设权限 | +| **Active** | `time.updated > time.created` | `Session.Event.Updated` | 全部操作 | +| **Compacting** | `time.compacting` 非空 | 内部状态 | 等待压缩完成 | +| **Shared** | `share.url` 非空 | `Session.Event.Updated` | 取消分享 | +| **Reverting** | `revert` 非空 | `Session.Event.Updated` | 确认/取消回滚 | +| **Archived** | `time.archived` 非空 | `Session.Event.Updated` | 恢复、删除 | +| **Deleted** | 数据已移除 | `Session.Event.Deleted` | *(终态)* | + +### 关键字段 + +```typescript +// session/index.ts — Session.Info 中的时间字段 +time: { + created: number, // 创建时间 + updated: number, // 最后更新时间 + compacting?: number, // 压缩中时间戳(存在即为压缩中) + archived?: number, // 归档时间戳(存在即为已归档) +} +``` + +--- + +## 二、SessionStatus 状态机 + +`SessionStatus` 是独立于 `Session` 的**运行时状态**——它不存在数据库中,只在内存 `Map` 中维护。 + +```mermaid +stateDiagram-v2 + [*] --> idle: 默认状态 + + idle --> busy: stream 开始\nProcessor 收到 "start" 事件 + + busy --> idle: 正常完成\nProcessor 收到 "finish" + busy --> retry: 可重试错误\n(429 / 5xx / 网络错误) + busy --> idle: 不可重试错误\n(401 / 403 / 404) + busy --> idle: 用户取消\nAbortSignal triggered + + retry --> busy: 延迟后重试\nsleep(delay) → 重新 streamText() + retry --> idle: 超过最大重试\n或用户取消 + + note right of idle + SessionStatus.Info = + { type: "idle" } + end note + + note right of busy + SessionStatus.Info = + { type: "busy" } + end note + + note right of retry + SessionStatus.Info = { + type: "retry", + attempt: number, + message: string, + next: number // 下次重试时间戳 + } + end note +``` + +### 重试策略 + +```mermaid +flowchart LR + Error["捕获错误"] --> Check{"可重试?"} + Check -->|"429 / 5xx"| Retry["retry 状态"] + Check -->|"401 / 403 / 404"| Fail["→ idle + error"] + Check -->|"ContextOverflow"| Compact["→ compact"] + + Retry --> Delay["sleep(delay)"] + Delay --> Again["attempt++\n重新 streamText()"] + Again --> Check +``` + +重试延迟采用指数退避(Exponential Backoff),由 `SessionRetry.delay(attempt, error)` 计算。对于 429 错误,会尊重 LLM 返回的 `Retry-After` 头。 + +### 状态事件 + +```typescript +// session/status.ts — 事件定义 +Event.Status = BusEvent.define("session.status", z.object({ + sessionID: SessionID.zod, + status: Info, // { type: "idle" } | { type: "busy" } | { type: "retry", ... } +})) +``` + +--- + +## 三、Message 生命周期 + +### User Message 生命周期 + +User Message 是**不可变的**——创建后不再修改。 + +```mermaid +stateDiagram-v2 + [*] --> Created: SessionPrompt.prompt() + + Created --> Persisted: INSERT MessageTable\nINSERT PartTable[] + Persisted --> Published: Bus: MessageV2.Event.Updated + Published --> [*]: ✅ 不可变,生命周期结束 + + note right of Created + MessageV2.User { + role: "user", + id: MessageID.ascending(), + agent: string, + model: { providerID, modelID }, + time: { created } + } + end note +``` + +### Assistant Message 生命周期 + +Assistant Message 是**可变的**——在 LLM 流式返回过程中不断更新。 + +```mermaid +stateDiagram-v2 + [*] --> Created: Processor 创建空 Assistant + + Created --> Streaming: LLM 开始返回 + + state Streaming { + [*] --> Reasoning: reasoning-start + Reasoning --> TextGen: reasoning-end → text-start + [*] --> TextGen: text-start (无 reasoning) + TextGen --> ToolCalling: tool-input-start + ToolCalling --> TextGen: tool-result → text-start + ToolCalling --> StepDone: step-finish + TextGen --> StepDone: step-finish + StepDone --> [*]: finish + StepDone --> TextGen: 新的 step (多轮迭代) + } + + Streaming --> Completed: finish 事件\ntime.completed = now + Streaming --> Error: 不可重试错误\nerror = DiscriminatedError + Streaming --> Retrying: 可重试错误\nRetryPart 插入 + + Retrying --> Streaming: 延迟后重新开始 + + Completed --> [*]: ✅ 最终状态 + Error --> [*]: ❌ 错误终态 + + note right of Completed + Assistant { + time: { created, completed }, + cost: number, + tokens: { input, output, reasoning, + cache: { read, write } }, + finish: "stop" | "tool-calls" | ... + } + end note + + note right of Error + error 字段可能是: + · APIError (含 statusCode) + · AuthError (认证失败) + · AbortedError (用户取消) + · ContextOverflowError + · StructuredOutputError + end note +``` + +### Assistant Message 累积更新 + +在 `Streaming` 状态内,`cost` 和 `tokens` 在每个 `step-finish` 时累加: + +```typescript +// session/processor.ts — step-finish 处理 +case "step-finish": { + msg.cost += event.cost + msg.tokens.input += event.tokens.input + msg.tokens.output += event.tokens.output + msg.tokens.reasoning += event.tokens.reasoning + msg.tokens.cache.read += event.tokens.cache.read + msg.tokens.cache.write += event.tokens.cache.write +} +``` + +--- + +## 四、ToolPart 状态机 + +每个工具调用在 `PartTable` 中是一个 `ToolPart`,拥有独立的 4 态状态机。 + +```mermaid +stateDiagram-v2 + [*] --> pending: tool-input-start\n开始接收工具输入 + + pending --> running: tool-call\n输入完整,开始执行 + + running --> completed: tool-result\n执行成功 + running --> error: tool-error\n执行失败 + running --> error: Permission.RejectedError\n用户拒绝权限 + running --> error: Question.RejectedError\n用户拒绝确认 + + completed --> [*]: ✅ + error --> [*]: ❌ + + note right of pending + ToolStatePending { + status: "pending", + input: {}, + raw: "" // 原始 JSON 输入 + } + end note + + note right of running + ToolStateRunning { + status: "running", + input: Record<string, any>, + title?: string, + time: { start: number } + } + end note + + note right of completed + ToolStateCompleted { + status: "completed", + input, output: string, + title: string, + time: { start, end, compacted? }, + attachments?: FilePart[] + } + end note + + note right of error + ToolStateError { + status: "error", + input, error: string, + time: { start, end } + } + end note +``` + +### Doom Loop 检测 + +Processor 会检测**工具循环调用**——如果最近 3 次工具调用的名称和输入完全相同,则触发 `doom_loop` 权限请求: + +```mermaid +flowchart LR + TC1["tool-call #N-2
name: edit, input: {...}"] --> Check + TC2["tool-call #N-1
name: edit, input: {...}"] --> Check + TC3["tool-call #N
name: edit, input: {...}"] --> Check{"三次
完全相同?"} + Check -->|"是"| Ask["请求 doom_loop 权限"] + Ask -->|"允许"| Continue["继续执行"] + Ask -->|"拒绝"| Block["blocked = true\n→ stop"] + Check -->|"否"| Continue +``` + +--- + +## 五、Provider 连接状态 + +```mermaid +stateDiagram-v2 + [*] --> Unconfigured: 未设置 API Key + + Unconfigured --> Methods: ProviderAuth.methods() + Methods --> Authorizing: authorize(providerID, method) + + state Authorizing { + [*] --> OAuthFlow: type: "oauth" + [*] --> APIKeyFlow: type: "api" + OAuthFlow --> Callback: 用户完成 OAuth + APIKeyFlow --> [*]: key 直接验证 + Callback --> [*]: access_token 获取成功 + } + + Authorizing --> Ready: 认证成功 + Authorizing --> AuthError: 认证失败 + + Ready --> Streaming: streamText() 调用 + Streaming --> Ready: 成功完成 + Streaming --> RateLimited: 429 Too Many Requests + Streaming --> ServerError: 500-599 + Streaming --> AuthError: 401 / 403 + + RateLimited --> Streaming: Retry-After 后重试 + ServerError --> Streaming: 指数退避后重试 + + AuthError --> Unconfigured: 重新认证 + + note right of Ready + Provider 已认证 + 可以发起 API 调用 + end note + + note right of RateLimited + 尊重 Retry-After 头 + 由 SessionRetry 控制延迟 + end note +``` + +### 上下文溢出特殊处理 + +`ContextOverflowError` 不走 retry 路径,而是触发**消息压缩**: + +```mermaid +flowchart LR + Overflow["ContextOverflowError
检测 26+ 种错误模式"] --> Flag["needsCompaction = true"] + Flag --> Compact["SessionCompaction.create()"] + Compact --> Resume["压缩后重新发送
减少的消息列表"] +``` + +支持的溢出错误模式覆盖所有主要 Provider(Anthropic / OpenAI / Google / Bedrock / xAI / Groq 等),共识别 **26+ 种**不同的错误消息格式。 + +--- + +## 六、状态联动总览 + +所有状态机并非孤立运行。下面的时序图展示了一次包含工具调用的完整状态流转: + +```mermaid +sequenceDiagram + autonumber + participant Session as 🗂️ Session + participant Status as 🚦 Status + participant UserMsg as 💬 UserMsg + participant AssistMsg as 🤖 AssistMsg + participant Tool as 🔧 ToolPart + + Note over Session: Active + + UserMsg->>UserMsg: Created → Persisted + + Status->>Status: idle → busy + AssistMsg->>AssistMsg: Created (time.completed = undefined) + + AssistMsg->>AssistMsg: Streaming: TextPart 增长中 + + AssistMsg->>Tool: tool-input-start + Tool->>Tool: → pending + + AssistMsg->>Tool: tool-call + Tool->>Tool: → running + + Tool->>Tool: 执行完成 → completed + Tool->>AssistMsg: 结果返回 + + AssistMsg->>AssistMsg: step-finish (tokens 累加) + + AssistMsg->>AssistMsg: finish → Completed + Status->>Status: busy → idle + + Note over Session: touch() → time.updated +``` + +--- + +## 关键设计决策 + +### 1. 为什么 SessionStatus 不存数据库? + +`SessionStatus`(idle/busy/retry)是纯运行时状态: + +- **重启恢复**:进程重启后所有 Session 自然回到 `idle`,不需要数据库恢复 +- **性能**:每次 `text-delta` 都可能更新状态,走数据库太重 +- **内存高效**:用 `Map` 存储,`idle` 状态从 Map 中移除 + +### 2. 为什么 ToolPart 有 `pending` 状态? + +LLM 流式返回工具调用时,先发送 `tool-input-start`(开始接收 JSON 输入),然后逐步传输输入参数,最后发送 `tool-call`(输入完整)。`pending` 状态存在于这个"输入还在传输中"的窗口期,让 UI 可以显示"正在组装工具参数…"。 + +### 3. 为什么错误类型是区分联合(Discriminated Union)? + +```typescript +type Error = APIError | AuthError | AbortedError | ContextOverflowError | StructuredOutputError +``` + +不同错误类型需要不同的处理策略: +- `APIError(isRetryable: true)` → 重试 +- `ContextOverflowError` → 压缩上下文 +- `AuthError` → 提示用户重新认证 +- `AbortedError` → 静默处理 + +使用区分联合让 TypeScript 编译器强制我们处理每种情况。 + +--- + +## 与下一节的衔接 + +我们已经从**数据流**和**状态机**两个角度理解了 OpenCode 的运行机制。但一个更实际的问题是:如果我想扩展 OpenCode——加一个自定义 Agent、写一个工具、接入一个 MCP Server——从哪里下手? + +下一节 [09-扩展点全景图](./09-扩展点全景图.md) 将列出 OpenCode 所有可定制的扩展点,每个扩展点附带配置方式、文件位置和上手难度。 diff --git "a/all-in-one-book/01-\345\205\250\346\231\257\350\247\206\351\207\216/09-\346\211\251\345\261\225\347\202\271\345\205\250\346\231\257\345\233\276.md" "b/all-in-one-book/01-\345\205\250\346\231\257\350\247\206\351\207\216/09-\346\211\251\345\261\225\347\202\271\345\205\250\346\231\257\345\233\276.md" new file mode 100644 index 000000000000..1c35ce68b2fd --- /dev/null +++ "b/all-in-one-book/01-\345\205\250\346\231\257\350\247\206\351\207\216/09-\346\211\251\345\261\225\347\202\271\345\205\250\346\231\257\345\233\276.md" @@ -0,0 +1,364 @@ +# 扩展点全景图 + +> 📌 一句话总结:OpenCode 提供 9 大扩展点——从零代码的 Markdown Agent 到完整的 Plugin Hook 系统,覆盖"配置即扩展"到"代码级深度定制"的全部场景。 +> 🗺️ 本节在全景中的位置:前两节从数据流和状态机角度理解了系统内部。本节转向**外部视角**——作为使用者或贡献者,你能在哪些地方"插入"自己的逻辑? + +--- + +## 全景图:扩展点辐射图 + +```mermaid +flowchart TB + Core["🏠 OpenCode Core
packages/opencode/"] + + Agent["🤖 Custom Agent
.opencode/agent/*.md"] + Tool["🔧 Custom Tool
.opencode/tool/*.ts"] + Command["📝 Custom Command
.opencode/command/*.md"] + MCP["🔌 MCP Server
config: mcp"] + Plugin["🧩 Plugin
config: plugin"] + Provider["☁️ Provider
config: provider"] + Theme["🎨 Theme
.opencode/themes/*.json"] + VSCode["💻 VSCode Extension
sdks/vscode/"] + Skill["📚 Skill
.opencode/skill/SKILL.md"] + + Core --- Agent + Core --- Tool + Core --- Command + Core --- MCP + Core --- Plugin + Core --- Provider + Core --- Theme + Core --- VSCode + Core --- Skill + + style Agent fill:#d4edda + style Command fill:#d4edda + style Skill fill:#d4edda + style Theme fill:#d4edda + style Tool fill:#fff3cd + style MCP fill:#fff3cd + style Provider fill:#fff3cd + style Plugin fill:#f8d7da + style VSCode fill:#f8d7da +``` + +> 🟢 绿色 = 零代码(Markdown/JSON) · 🟡 黄色 = 少量代码/配置 · 🔴 红色 = 需要开发 + +--- + +## 各扩展点详解 + +### 1. 🤖 Custom Agent(自定义 Agent)🟢 + +| 属性 | 说明 | +|---|---| +| **配置方式** | Markdown 文件 + YAML frontmatter | +| **文件位置** | `.opencode/agent/*.md` 或 `.opencode/agents/*.md` | +| **源码入口** | `config/config.ts` → `loadAgent()` 函数 | +| **难度** | 🟢 零代码 | + +```markdown +--- +description: "代码审查专家" +mode: "primary" +model: "anthropic/claude-sonnet-4-20250514" +temperature: 0.3 +color: "#38A3EE" +permission: + edit: "deny" + bash: "deny" +steps: 50 +--- + +你是一位资深代码审查专家。请仔细审查用户提交的代码... +``` + +**frontmatter 字段**:`description`、`mode`(`"subagent"` | `"primary"` | `"all"`)、`model`、`temperature`、`top_p`、`color`、`permission`、`hidden`、`steps`、`options`。 + +文件名即为 Agent 名称:`docs.md` → Agent `docs`。 + +--- + +### 2. 📝 Custom Command(自定义命令)🟢 + +| 属性 | 说明 | +|---|---| +| **配置方式** | Markdown 文件 + YAML frontmatter | +| **文件位置** | `.opencode/command/*.md` 或 `.opencode/commands/*.md` | +| **源码入口** | `config/config.ts` → `loadCommand()` 函数 | +| **难度** | 🟢 零代码 | + +```markdown +--- +description: "查找 GitHub issue" +model: "anthropic/claude-haiku-4-5-20250501" +agent: "coder" +subtask: false +--- + +在 GitHub 上搜索以下关键词相关的 issue:$ARGUMENTS +``` + +**模板变量**:`$1`、`$2`…(位置参数)、`$ARGUMENTS`(全部参数)。 + +内置命令包括 `init` 和 `review`。 + +--- + +### 3. 📚 Skill(技能文件)🟢 + +| 属性 | 说明 | +|---|---| +| **配置方式** | Markdown 文件,文件名含 `SKILL` | +| **文件位置** | `.opencode/skill/SKILL.md`、`.opencode/skills/` | +| **源码入口** | `skill/index.ts` → `loadSkills()` | +| **难度** | 🟢 零代码 | + +```json +// opencode.json +{ + "skills": { + "paths": ["./skills", "~/shared-skills"], + "urls": ["https://example.com/.well-known/skills/"] + } +} +``` + +Skill 文件会被注入到 Agent 的系统提示词(System Prompt)中,扫描模式为 `**/SKILL.md`。支持本地路径和远程 URL。 + +--- + +### 4. 🎨 Theme(主题)🟢 + +| 属性 | 说明 | +|---|---| +| **配置方式** | JSON 文件 | +| **文件位置** | `.opencode/themes/*.json` | +| **源码入口** | `cli/cmd/tui/context/theme.tsx` | +| **难度** | 🟢 零代码 | + +```json +{ + "$schema": "https://opencode.ai/theme.json", + "defs": { + "blue": "#38A3EE", + "dark": "#1a1a2e" + }, + "theme": { + "primary": { "dark": "blue", "light": "blue" }, + "background": { "dark": "dark", "light": "#ffffff" }, + "text": { "dark": "#e0e0e0", "light": "#1a1a1a" }, + "border": { "dark": "#333333", "light": "#cccccc" }, + "diffAdded": { "dark": "#2ea043", "light": "#1a7f37" }, + "diffRemoved": { "dark": "#f85149", "light": "#cf222e" } + } +} +``` + +在 TUI 中通过 `t` 打开主题选择器。 + +--- + +### 5. 🔧 Custom Tool(自定义工具)🟡 + +| 属性 | 说明 | +|---|---| +| **配置方式** | TypeScript/JavaScript 文件,使用 `@opencode-ai/plugin` API | +| **文件位置** | `.opencode/tool/*.ts` 或 `.opencode/tools/*.ts` | +| **源码入口** | `tool/registry.ts` → 扫描 `{tool,tools}/*.{js,ts}` | +| **难度** | 🟡 需少量 TypeScript | + +```typescript +import { tool } from "@opencode-ai/plugin" + +export default tool({ + description: "搜索 GitHub PR", + args: { + query: tool.schema.string().describe("搜索关键词"), + limit: tool.schema.number().optional().describe("结果数量"), + }, + execute: async (args, ctx) => { + const result = await fetch(`https://api.github.com/search/issues?q=${args.query}+is:pr`) + return JSON.stringify(await result.json()) + }, +}) +``` + +支持默认导出(单工具)和命名导出(多工具)。 + +--- + +### 6. 🔌 MCP Server(模型上下文协议)🟡 + +| 属性 | 说明 | +|---|---| +| **配置方式** | `opencode.json` 中的 `mcp` 字段 | +| **文件位置** | 配置级,无需项目内文件 | +| **源码入口** | `mcp/index.ts` → `create()` 函数 | +| **难度** | 🟡 需配置 + 外部 MCP Server | + +```json +{ + "mcp": { + "my-local-server": { + "type": "local", + "command": ["node", "mcp-server.js"], + "environment": { "API_KEY": "..." }, + "enabled": true, + "timeout": 5000 + }, + "remote-api": { + "type": "remote", + "url": "https://api.example.com/mcp", + "headers": { "Authorization": "Bearer ..." }, + "oauth": { "clientId": "..." } + } + } +} +``` + +支持两种传输:**Local**(Stdio 子进程)和 **Remote**(HTTP/SSE,支持 OAuth)。MCP Server 暴露的工具自动注册到 Agent 可用工具列表中。 + +--- + +### 7. ☁️ Provider(AI 提供商)🟡 + +| 属性 | 说明 | +|---|---| +| **配置方式** | `opencode.json` 中的 `provider` 字段 | +| **文件位置** | 配置级 | +| **源码入口** | `provider/provider.ts` | +| **难度** | 🟡 需 API Key | + +```json +{ + "provider": { + "anthropic": { "apiKey": "sk-ant-..." }, + "openai": { "apiKey": "sk-...", "timeout": 300000 } + }, + "model": "anthropic/claude-sonnet-4-20250514", + "small_model": "anthropic/claude-haiku-4-5-20250501", + "enabled_providers": ["anthropic", "openai"], + "disabled_providers": ["openrouter"] +} +``` + +内置 Provider 覆盖 **18+** 个:Anthropic、OpenAI、Azure、Google、Vertex、Bedrock、Groq、Mistral、xAI、Cohere、Cerebras、DeepInfra、TogetherAI、Perplexity、Vercel、OpenRouter、GitLab、Gateway。通过 `models.dev` 注册表获取模型元数据。 + +--- + +### 8. 🧩 Plugin(插件系统)🔴 + +| 属性 | 说明 | +|---|---| +| **配置方式** | `opencode.json` 中的 `plugin` 数组 | +| **文件位置** | NPM 包 或 `file://` 本地路径 | +| **源码入口** | `plugin/index.ts` → Hook 系统 | +| **难度** | 🔴 需完整开发 | + +```json +{ + "plugin": [ + "opencode-github", + "oh-my-opencode@1.2.3", + "file:///path/to/local/plugin.ts" + ] +} +``` + +**可用 Hook 列表**: + +| Hook | 时机 | 用途 | +|---|---|---| +| `config` | 配置加载后 | 修改运行时配置 | +| `event` | 任何事件触发时 | 监听系统事件 | +| `tool` | 工具注册 | 注册自定义工具 | +| `auth` | 认证流程 | 自定义认证方式 | +| `chat.message` | 消息发送前 | 修改消息内容 | +| `chat.params` | API 参数构建时 | 修改 API 参数 | +| `chat.headers` | API 请求发送前 | 添加自定义 HTTP 头 | +| `permission.ask` | 权限请求时 | 自定义权限逻辑 | +| `tool.execute.before` | 工具执行前 | 拦截工具调用 | +| `tool.execute.after` | 工具执行后 | 后处理工具结果 | +| `tool.definition` | 工具定义时 | 修改工具描述 | +| `shell.env` | Shell 环境构建时 | 注入环境变量 | +| `experimental.chat.messages.transform` | 消息转换时 | 实验性消息变换 | +| `experimental.chat.system.transform` | 系统提示时 | 实验性提示词变换 | +| `experimental.text.complete` | 文本生成完成后 | 后处理文本输出 | + +内置插件包括:`CodexAuthPlugin`、`CopilotAuthPlugin`、`GitlabAuthPlugin`、`PoeAuthPlugin`。 + +--- + +### 9. 💻 VSCode Extension 🔴 + +| 属性 | 说明 | +|---|---| +| **配置方式** | VSCode 市场安装 | +| **文件位置** | `sdks/vscode/` | +| **源码入口** | `sdks/vscode/src/extension.ts` | +| **难度** | 🔴 需 VSCode 扩展开发 | + +提供 3 个命令: +- `opencode.openTerminal` — 在 VSCode 终端中打开 OpenCode(`Cmd+Escape`) +- `opencode.openNewTerminal` — 新标签页打开(`Cmd+Shift+Escape`) +- `opencode.addFilepathToTerminal` — 将当前文件路径发送到 OpenCode(`Cmd+Alt+K`) + +通过 HTTP 与 OpenCode Server 的 `/tui/append-prompt` 端点通信。 + +--- + +## 配置文件优先级 + +从高到低,后者被前者覆盖: + +```mermaid +flowchart TB + M["🏢 Managed Config
/etc/opencode/opencode.json
(企业管控)"] --> I + I["🔧 Inline Config
OPENCODE_CONFIG_CONTENT 环境变量"] --> C + C["☁️ Console Config
从已连接账户获取"] --> D + D["📁 .opencode 目录
.opencode/opencode.json"] --> P + P["📄 Project Config
opencode.json(项目根目录)"] --> E + E["🌍 ENV Config
OPENCODE_CONFIG 环境变量"] --> G + G["🏠 Global Config
~/.config/opencode/opencode.json"] +``` + +--- + +## 扩展点速查矩阵 + +| 扩展点 | 零代码? | 热重载? | 对 Agent 可见? | 需发布? | +|---|---|---|---|---| +| Custom Agent | ✅ | ✅ | — (本身即 Agent) | ❌ | +| Custom Command | ✅ | ✅ | ❌ | ❌ | +| Skill | ✅ | ✅ | ✅ (系统提示词) | ❌ | +| Theme | ✅ | ✅ | ❌ | ❌ | +| Custom Tool | ❌ (TS) | ✅ | ✅ (工具列表) | ❌ | +| MCP Server | ❌ (配置) | ✅ | ✅ (工具列表) | ❌ | +| Provider | ❌ (API Key) | ✅ | ❌ | ❌ | +| Plugin | ❌ (TS/NPM) | ❌ | ✅ (视 Hook) | 可选 | +| VSCode Ext | ❌ (TS) | ❌ | ❌ | ✅ | + +--- + +## 关键设计决策 + +### 1. 为什么 Agent 和 Command 用 Markdown? + +降低创建门槛——任何人都能用文本编辑器创建自定义 Agent,不需要理解 TypeScript 或构建系统。YAML frontmatter 提供结构化配置,Markdown body 就是 prompt。 + +### 2. 为什么 Plugin 用 Hook 而不是继承/接口? + +Hook 模式(`before/after`)比 OOP 继承更灵活:多个插件可以叠加同一个 Hook,且不会互相干扰。这也是 Webpack/Vite 等工具链验证过的模式。 + +### 3. 为什么同时支持 MCP 和 Custom Tool? + +MCP(Model Context Protocol)是跨应用的标准协议——一个 MCP Server 可以同时被 OpenCode、Claude Desktop 等多个客户端使用。Custom Tool 则是 OpenCode 专属的、更轻量的方案。两者互补。 + +--- + +## 与下一节的衔接 + +了解了"能扩展什么"之后,下一个问题是"底层用了什么技术"。为什么选 Bun 而不是 Node.js?为什么用 Effect-TS 而不是普通 async/await? + +下一节 [10-技术栈全景与选型理由](./10-技术栈全景与选型理由.md) 将从技术选型的角度,解释 OpenCode 每一层技术栈的选择理由。 diff --git "a/all-in-one-book/01-\345\205\250\346\231\257\350\247\206\351\207\216/10-\346\212\200\346\234\257\346\240\210\345\205\250\346\231\257\344\270\216\351\200\211\345\236\213\347\220\206\347\224\261.md" "b/all-in-one-book/01-\345\205\250\346\231\257\350\247\206\351\207\216/10-\346\212\200\346\234\257\346\240\210\345\205\250\346\231\257\344\270\216\351\200\211\345\236\213\347\220\206\347\224\261.md" new file mode 100644 index 000000000000..cf22ec0b41ca --- /dev/null +++ "b/all-in-one-book/01-\345\205\250\346\231\257\350\247\206\351\207\216/10-\346\212\200\346\234\257\346\240\210\345\205\250\346\231\257\344\270\216\351\200\211\345\236\213\347\220\206\347\224\261.md" @@ -0,0 +1,340 @@ +# 技术栈全景与选型理由 + +> 📌 一句话总结:OpenCode 的技术栈从上到下都围绕两个原则——**开发者体验优先**和**类型安全到底**,每一层选型都有明确的"为什么不用 X"。 +> 🗺️ 本节在全景中的位置:前一节列出了 OpenCode 的所有扩展点。本节退后一步,从**技术选型**角度解释整个系统为什么长成这个样子。 + +--- + +## 全景图:技术栈分层 + +```mermaid +flowchart TB + subgraph Infra["基础设施层"] + SST["☁️ SST v3.18
Infrastructure as Code"] + CF["⚡ Cloudflare
Workers · R2"] + Nix["❄️ Nix Flake
可复现构建"] + end + + subgraph Monorepo["工程管理层"] + Turbo["🔄 Turborepo v2.8
Monorepo 任务编排"] + Bun["🥟 Bun v1.3
运行时 + 包管理"] + TS["📘 TypeScript v5.8
类型系统"] + end + + subgraph Core["核心引擎层"] + Effect["⚡ Effect-TS v4.0-beta
函数式效果管理"] + AI["🤖 Vercel AI SDK v5.0
多 Provider 统一接口"] + MCP2["🔌 MCP SDK v1.27
模型上下文协议"] + Drizzle["🗃️ Drizzle ORM v1.0-beta
+ SQLite"] + Hono["🌐 Hono v4.10
HTTP Server"] + Zod["🛡️ Zod v4.1
运行时类型验证"] + end + + subgraph UI["用户界面层"] + Solid["💎 SolidJS v1.9
响应式 UI"] + OpenTUI["📺 OpenTUI v0.1.90
终端 UI 框架"] + Kobalte["🧱 Kobalte v0.13
无头 UI 组件"] + TW["🎨 Tailwind CSS v4.1
原子化样式"] + Vite2["⚡ Vite v7.1
前端构建"] + end + + subgraph Desktop["桌面应用层"] + Tauri["🦀 Tauri v2
Rust 桌面框架"] + Electron["⚛️ Electron v40
Node.js 桌面框架"] + end + + Infra --> Monorepo --> Core --> UI --> Desktop +``` + +--- + +## 逐层选型分析 + +### 🥟 Runtime: Bun v1.3.11 + +**为什么不用 Node.js?** + +| 维度 | Bun | Node.js | +|---|---|---| +| 启动速度 | ~50ms | ~200-500ms | +| 包管理 | 内置(极快) | 需要 npm/yarn/pnpm | +| TypeScript | 原生运行 `.ts` | 需要 `tsx` / `ts-node` | +| SQLite | 内置 `bun:sqlite` | 需要 `better-sqlite3` | +| 文件 API | `Bun.file()` / `Bun.write()` | `fs.readFile()` / `fs.writeFile()` | +| 测试 | 内置 `bun test` | 需要 Jest / Vitest | + +OpenCode 是一个 CLI 工具——**启动速度至关重要**。Bun 的内置 SQLite 驱动(`bun:sqlite`)消除了原生模块编译问题,`Bun.file()` API 更符合现代 TypeScript 风格。 + +```toml +# bunfig.toml +[install] +exact = true # 锁定精确版本 + +[test] +root = "./do-not-run-tests-from-root" # 防止从根目录运行测试 +``` + +--- + +### 📘 Language: TypeScript v5.8.2 + +**为什么不用 Go / Rust?** + +| 维度 | TypeScript | Go | Rust | +|---|---|---|---| +| AI SDK 生态 | ✅ 最丰富 | ⚠️ 有限 | ⚠️ 有限 | +| JSON 处理 | 原生 | 需要 struct tag | 需要 serde | +| Plugin 生态 | NPM (最大) | 需要 CGo 或 gRPC | 需要 FFI | +| 前后端统一 | ✅ SolidJS + 后端 | ❌ 前端另选 | ❌ 前端另选 | +| 类型安全 | ✅ (with Effect + Zod) | ✅ (编译期) | ✅✅ (编译期) | + +关键因素是 **AI SDK 生态**——Vercel AI SDK、LangChain、OpenAI SDK 等都以 TypeScript 为第一等公民。选择 TypeScript 意味着可以直接使用 18+ 个 AI Provider 的官方 SDK。 + +--- + +### ⚡ Effect Management: Effect-TS v4.0.0-beta.35 + +**为什么不用普通 async/await?** + +```typescript +// 普通 async/await — 错误类型丢失 +async function getSession(id: string): Promise { + // throw 什么?谁知道呢… +} + +// Effect-TS — 错误类型是签名的一部分 +function getSession(id: string): Effect { + // 调用方被强制处理两种错误 +} +``` + +Effect-TS 在 OpenCode 中主要用于: + +1. **依赖注入**(Service Layer)——`Bus.Service`、`Database.Service` 等 +2. **错误追踪**——`NamedError` 体系让错误类型可追踪 +3. **资源管理**——`Effect.acquireRelease` 确保数据库连接、子进程等资源正确释放 +4. **PubSub**——`Effect.PubSub` 提供带背压的事件总线 + +--- + +### 🤖 AI Integration: Vercel AI SDK v5.0.124 + +**为什么不用 LangChain?** + +| 维度 | Vercel AI SDK | LangChain | +|---|---|---| +| 抽象层级 | 薄——直接映射 API | 厚——Chain/Agent/Memory 抽象 | +| Provider 支持 | 官方维护 18+ Provider | 社区维护,质量不一 | +| 流式支持 | `streamText()` 一等公民 | 需要额外配置 | +| 体积 | 轻量 | 依赖链长 | +| TypeScript | 原生 TS | JS + 类型声明 | + +OpenCode 需要**底层控制权**——自己管理 Agent 循环、工具调用、重试逻辑、上下文压缩。LangChain 的高级抽象反而会碍手碍脚。Vercel AI SDK 提供刚好够用的 Provider 统一层,其余由 OpenCode 自己实现。 + +当前集成的 AI Provider SDK: + +``` +@ai-sdk/anthropic @ai-sdk/openai @ai-sdk/azure +@ai-sdk/google @ai-sdk/google-vertex @ai-sdk/amazon-bedrock +@ai-sdk/groq @ai-sdk/mistral @ai-sdk/xai +@ai-sdk/cohere @ai-sdk/cerebras @ai-sdk/deepinfra +@ai-sdk/togetherai @ai-sdk/perplexity @ai-sdk/vercel +@ai-sdk/gateway @openrouter/ai-sdk-provider +gitlab-ai-provider ai-gateway-provider +``` + +--- + +### 🔌 Protocol: MCP SDK v1.27.1 + +**为什么选 MCP?** + +MCP(Model Context Protocol)是 Anthropic 提出的开放协议,已被多个 AI 产品采用(Claude Desktop、Cursor 等)。选择 MCP 意味着: + +- 用户已有的 MCP Server 可直接复用 +- OpenCode 的工具也可以暴露给其他 MCP 客户端 +- 标准化的工具发现、权限协商机制 + +同时 OpenCode 还集成了 `@agentclientprotocol/sdk v0.14.1`(Agent Client Protocol),为未来的 Agent 间通信做准备。 + +--- + +### 💎 TUI Framework: SolidJS v1.9.10 + OpenTUI v0.1.90 + +**为什么不用 React + Ink?** + +| 维度 | SolidJS + OpenTUI | React + Ink | +|---|---|---| +| 响应式模型 | 细粒度 Signal | Virtual DOM + diff | +| 终端渲染 | 直接操作终端 | Yoga 布局引擎 | +| 性能 | O(1) 更新 | O(n) diff | +| 与桌面共享 | ✅ 同一 UI 组件库 | ⚠️ 需要适配 | + +SolidJS 的细粒度响应式非常适合终端 UI——当一个 token 到达时,只需更新对应的文本节点,而不是重新 diff 整个组件树。OpenTUI 是 OpenCode 团队自建的终端 UI 框架(`@opentui/core` + `@opentui/solid`),专为这个场景设计。 + +UI 组件库使用 **Kobalte v0.13.11**(SolidJS 的无头 UI 组件),配合 **Tailwind CSS v4.1** 处理样式。 + +--- + +### 🗃️ ORM: Drizzle v1.0.0-beta + SQLite + +**为什么不用 PostgreSQL?** + +``` +OpenCode 是本地 CLI 工具 → 不能要求用户安装 PostgreSQL + → SQLite 零配置、单文件、嵌入式 + → Bun 内置 bun:sqlite 驱动 +``` + +**为什么 Drizzle 不用 Prisma?** + +| 维度 | Drizzle | Prisma | +|---|---|---| +| 代码生成 | ❌ 不需要 | ✅ 需要 `prisma generate` | +| 查询风格 | SQL-like(贴近 SQL) | 自定义 API | +| Bundle 大小 | 轻量 | 需要 Prisma Engine | +| TypeScript | Schema as Code | Schema DSL → 生成 | + +Drizzle 的"Schema as Code"哲学与项目风格契合——表定义就是 TypeScript 代码,类型推导自动完成,不需要额外的代码生成步骤。 + +--- + +### 🔄 Monorepo: Turborepo v2.8.13 + +**为什么不用 Nx / Lerna?** + +| 维度 | Turborepo | Nx | Lerna | +|---|---|---|---| +| 配置复杂度 | 低(单 `turbo.json`) | 高 | 中 | +| 缓存 | ✅ 远程+本地 | ✅ 远程+本地 | ❌ | +| Bun 支持 | ✅ 原生 | ⚠️ 需配置 | ⚠️ 需配置 | +| 学习曲线 | 低 | 高 | 中 | + +Turborepo 配置极简,与 Bun workspace 天然集成。`turbo.json` 仅定义任务依赖关系: + +```json +{ + "$schema": "https://turbo.build/schema.json", + "globalEnv": ["CI", "OPENCODE_DISABLE_SHARE"], + "tasks": { + "typecheck": {}, + "build": {}, + "opencode#test": {}, + "@opencode-ai/app#test": {} + } +} +``` + +--- + +### 🦀 Desktop: Tauri v2 + Electron v40 + +**为什么两个都有?** + +```mermaid +flowchart LR + subgraph Tauri["🦀 Tauri v2 (packages/desktop/)"] + T1["✅ 体积小 (~10MB)"] + T2["✅ 内存占用低"] + T3["⚠️ 需要系统 WebView"] + end + + subgraph Electron["⚛️ Electron v40 (packages/desktop-electron/)"] + E1["✅ 跨平台一致性好"] + E2["✅ 自带 Chromium"] + E3["⚠️ 体积大 (~150MB)"] + end + + User["用户选择"] --> Tauri + User --> Electron +``` + +Tauri 是主力方案(体积小、性能好),Electron 作为兼容性后备——在某些 Linux 发行版上系统 WebView 可能有问题,Electron 自带 Chromium 规避了这个问题。两者共享同一套 SolidJS 前端代码。 + +--- + +### ☁️ Infrastructure: SST v3.18.10 + +**为什么不用 CDK / Terraform?** + +SST(Serverless Stack)是专为 TypeScript 全栈应用设计的 IaC 框架: + +```typescript +// sst.config.ts +export default $config({ + app(input) { + return { + name: "opencode", + home: "cloudflare", // 部署目标 + providers: { + stripe: true, + planetscale: "0.4.1", + }, + } + }, + async run() { + // TypeScript 编写基础设施 + await import("./infra/app.js") + await import("./infra/console.js") + if ($app.stage === "production") + await import("./infra/enterprise.js") + }, +}) +``` + +- **TypeScript 原生**:与项目技术栈一致,一个语言贯穿前后端和基础设施 +- **Cloudflare 优先**:OpenCode 的 Web 服务部署在 Cloudflare Workers +- **开发体验**:`sst dev` 提供实时 Lambda/Worker 调试 + +--- + +## 版本总览表 + +| 层级 | 技术 | 版本 | 用途 | +|---|---|---|---| +| **Runtime** | Bun | 1.3.11 | 运行时 + 包管理 + 测试 | +| **Language** | TypeScript | 5.8.2 | 类型系统 | +| **Effect** | Effect-TS | 4.0.0-beta.35 | 函数式效果管理 | +| **AI SDK** | Vercel AI SDK | 5.0.124 | 多 Provider 统一 | +| **Protocol** | MCP SDK | 1.27.1 | 工具协议 | +| **ORM** | Drizzle ORM | 1.0.0-beta.19 | 数据库访问 | +| **DB** | SQLite | (Bun 内置) | 嵌入式数据库 | +| **HTTP** | Hono | 4.10.7 | Web 框架 | +| **Validation** | Zod | 4.1.8 | 运行时校验 | +| **UI** | SolidJS | 1.9.10 | 响应式 UI | +| **TUI** | OpenTUI | 0.1.90 | 终端 UI | +| **Components** | Kobalte | 0.13.11 | 无头 UI | +| **CSS** | Tailwind CSS | 4.1.11 | 原子化样式 | +| **Build** | Vite | 7.1.4 | 前端构建 | +| **Desktop** | Tauri | v2 | 桌面(主力) | +| **Desktop** | Electron | 40.4.1 | 桌面(兼容) | +| **Monorepo** | Turborepo | 2.8.13 | 任务编排 | +| **IaC** | SST | 3.18.10 | 基础设施 | +| **Website** | Astro | 5.7.13 | 文档站点 | + +--- + +## 关键设计决策 + +### 1. 为什么大量使用 Beta 版本? + +Effect-TS v4.0-beta、Drizzle v1.0-beta——这些库虽然版本号带 beta,但在各自社区已经广泛使用。OpenCode 团队选择"跟进最新"而非"等待稳定",因为这些 beta 版本的 API 已经足够稳定,且新特性(如 Effect 的改进错误追踪)对项目有实际价值。 + +### 2. 全栈 TypeScript 的统一性 + +从 TUI 到 Web 到桌面到基础设施,一个语言覆盖所有层级。这意味着: +- 团队成员无需切换语言上下文 +- 类型定义可以跨包共享(`workspace:*`) +- 一套工具链(Bun + Turbo)管理全部代码 + +### 3. "薄封装"哲学 + +OpenCode 倾向于选择提供薄封装的库(Vercel AI SDK vs LangChain、Drizzle vs Prisma、Hono vs Express),保留底层控制权。这在 AI 领域尤其重要——Provider API 变化快,薄封装层更容易跟进。 + +--- + +## 与下一节的衔接 + +我们已经从扩展点和技术栈两个角度完成了全景扫描。但如果你只是想快速找到"我想做 X,应该看哪一章"的答案呢? + +下一节 [11-速查:我想做X应该看哪章](./11-速查:我想做X应该看哪章.md) 提供一张速查表,覆盖 20+ 种常见场景,直接指向对应章节。 diff --git "a/all-in-one-book/01-\345\205\250\346\231\257\350\247\206\351\207\216/11-\351\200\237\346\237\245\357\274\232\346\210\221\346\203\263\345\201\232X\345\272\224\350\257\245\347\234\213\345\223\252\347\253\240.md" "b/all-in-one-book/01-\345\205\250\346\231\257\350\247\206\351\207\216/11-\351\200\237\346\237\245\357\274\232\346\210\221\346\203\263\345\201\232X\345\272\224\350\257\245\347\234\213\345\223\252\347\253\240.md" new file mode 100644 index 000000000000..9bae58f4045e --- /dev/null +++ "b/all-in-one-book/01-\345\205\250\346\231\257\350\247\206\351\207\216/11-\351\200\237\346\237\245\357\274\232\346\210\221\346\203\263\345\201\232X\345\272\224\350\257\245\347\234\213\345\223\252\347\253\240.md" @@ -0,0 +1,155 @@ +# 速查:我想做X应该看哪章 + +> 📌 一句话总结:不想从头读?这张表帮你直接跳到需要的章节——覆盖用户、开发者、贡献者、企业管理员四种角色的 20+ 常见场景。 +> 🗺️ 本节在全景中的位置:本节是"01-全景视野"的收尾,把前面 10 节的内容浓缩为一张可检索的速查表,作为全书的导航入口。 + +--- + +## 全景图:读者角色与章节映射 + +```mermaid +flowchart LR + subgraph Roles["读者角色"] + U["👤 用户
日常使用"] + D["🔧 开发者
扩展定制"] + C["🤝 贡献者
贡献代码"] + E["🏢 管理员
企业部署"] + end + + subgraph Chapters["核心章节"] + C02["02-快速上手"] + C03["03-关键路径详解"] + C04["04-核心引擎"] + C05["05-用户界面"] + C06["06-扩展与集成"] + C07["07-基础设施与部署"] + C08["08-开发者指南"] + C09["09-周边知识与生态"] + C10["10-实战案例"] + end + + U --> C02 + U --> C10 + D --> C06 + D --> C04 + C --> C08 + C --> C03 + E --> C07 + E --> C06 +``` + +--- + +## 速查表 + +### 👤 用户场景 + +| 我想要… | 直接去读 | 先了解 | +|---|---|---| +| 安装并跑起来 | 02-快速上手 | — | +| 了解 OpenCode 能做什么 | 01-全景视野/01-一图看懂OpenCode | — | +| 理解一次对话背后发生了什么 | 01-全景视野/04-一次对话的完整旅程 | 01-全景视野/02-核心概念关系图谱 | +| 自定义一个 Agent | 06-扩展与集成(Agent 章节) | 01-全景视野/09-扩展点全景图 | +| 添加自定义命令 | 06-扩展与集成(Command 章节) | 01-全景视野/09-扩展点全景图 | +| 切换 AI 模型 / Provider | 02-快速上手(Provider 配置) | 01-全景视野/10-技术栈全景与选型理由 | +| 配置 MCP Server | 06-扩展与集成(MCP 章节) | 01-全景视野/09-扩展点全景图 | +| 自定义主题 | 06-扩展与集成(Theme 章节) | — | +| 在 VSCode 中使用 | 06-扩展与集成(VSCode 章节) | — | +| 了解权限系统 | 03-关键路径详解(Permission 章节) | 01-全景视野/05-一次Tool调用的完整旅程 | + +### 🔧 开发者场景 + +| 我想要… | 直接去读 | 先了解 | +|---|---|---| +| 写一个自定义 Tool | 06-扩展与集成(Custom Tool 章节) | 01-全景视野/05-一次Tool调用的完整旅程 | +| 开发一个 Plugin | 06-扩展与集成(Plugin 章节) | 01-全景视野/09-扩展点全景图 | +| 理解消息格式和数据流 | 01-全景视野/07-数据流全景图 | 01-全景视野/02-核心概念关系图谱 | +| 理解 Session 状态机 | 01-全景视野/08-状态生命周期全景图 | 01-全景视野/04-一次对话的完整旅程 | +| 对接新的 AI Provider | 04-核心引擎(Provider 章节) | 01-全景视野/10-技术栈全景与选型理由 | +| 理解 Effect-TS 用法 | 09-周边知识与生态(Effect-TS 章节) | 01-全景视野/10-技术栈全景与选型理由 | +| 理解数据库 Schema | 04-核心引擎(Storage 章节) | 01-全景视野/07-数据流全景图 | +| 理解事件总线机制 | 04-核心引擎(Bus 章节) | 01-全景视野/07-数据流全景图 | +| 使用 OpenCode SDK | 06-扩展与集成(SDK 章节) | 01-全景视野/03-Monorepo全景 | + +### 🤝 贡献者场景 + +| 我想要… | 直接去读 | 先了解 | +|---|---|---| +| 搭建本地开发环境 | 08-开发者指南(环境搭建) | 01-全景视野/03-Monorepo全景 | +| 理解 Monorepo 结构 | 01-全景视野/03-Monorepo全景-19个包的关系 | — | +| 运行测试 | 08-开发者指南(测试章节) | 01-全景视野/10-技术栈全景与选型理由 | +| 理解文件编辑的安全机制 | 01-全景视野/06-一次文件编辑的完整旅程 | 01-全景视野/05-一次Tool调用的完整旅程 | +| 理解上下文压缩(Compaction) | 04-核心引擎(Compaction 章节) | 01-全景视野/08-状态生命周期全景图 | +| 给 TUI 加新功能 | 05-用户界面(TUI 章节) | 01-全景视野/10-技术栈全景与选型理由(SolidJS + OpenTUI) | +| 理解 Server API 端点 | 04-核心引擎(Server 章节) | 01-全景视野/07-数据流全景图 | +| 构建桌面应用 | 05-用户界面(Desktop 章节) | 01-全景视野/10-技术栈全景与选型理由(Tauri vs Electron) | + +### 🏢 企业管理员场景 + +| 我想要… | 直接去读 | 先了解 | +|---|---|---| +| 企业级部署 | 07-基础设施与部署 | 01-全景视野/10-技术栈全景与选型理由(SST) | +| 配置全局权限策略 | 06-扩展与集成(Permission 章节) | 01-全景视野/09-扩展点全景图(配置优先级) | +| Managed Config 管控 | 07-基础设施与部署(企业配置) | 01-全景视野/09-扩展点全景图 | +| 理解安全模型 | 03-关键路径详解(安全章节) | 01-全景视野/05-一次Tool调用的完整旅程 | + +--- + +## 按知识域速查 + +| 知识域 | 入门 | 进阶 | 深入 | +|---|---|---|---| +| **架构总览** | 01-01 一图看懂 | 01-02 概念图谱 | 01-03 Monorepo 全景 | +| **对话流程** | 01-04 对话旅程 | 01-07 数据流全景图 | 04 核心引擎 | +| **工具系统** | 01-05 Tool 调用旅程 | 01-06 文件编辑旅程 | 04 核心引擎(Tool 章节) | +| **状态管理** | 01-08 状态生命周期 | 04 核心引擎(Session) | 04 核心引擎(Processor) | +| **扩展定制** | 01-09 扩展点全景 | 06 扩展与集成 | Plugin 开发实战 | +| **技术选型** | 01-10 技术栈全景 | 09 周边知识 | 各技术官方文档 | +| **UI 开发** | 05 用户界面 | SolidJS + OpenTUI | 05 深入章节 | +| **部署运维** | 07 基础设施 | SST + Cloudflare | 07 企业部署 | + +--- + +## 阅读路径推荐 + +### 🚀 "我赶时间"(30 分钟) + +``` +01-01 一图看懂 → 02 快速上手 → 01-09 扩展点全景(按需跳转) +``` + +### 📖 "我想全面了解"(2-3 小时) + +``` +01 全景视野全部 11 节 → 02 快速上手 → 感兴趣的章节 +``` + +### 🔬 "我要贡献代码"(1 天) + +``` +01 全景视野 → 08 开发者指南 → 03 关键路径详解 → 04 核心引擎 +``` + +### 🏗️ "我要基于 OpenCode 做产品"(2-3 天) + +``` +01 全景视野 → 04 核心引擎 → 06 扩展与集成 → 10 实战案例 +``` + +--- + +## 关键设计决策 + +本节作为全书导航,刻意**不引入新概念**,只做索引。设计原则: + +1. **按角色分组**——不同读者关心的问题完全不同 +2. **"直接去读"+"先了解"双列**——有些章节需要前置知识,明确告知 +3. **多维度索引**——按场景、按知识域、按阅读时间三种方式 + +--- + +## 与下一章的衔接 + +"01-全景视野"到此结束。我们已经从一张总图出发,逐步展开了概念关系、包结构、对话旅程、工具调用、文件编辑、数据流、状态机、扩展点、技术栈——最后用这张速查表把所有内容串联起来。 + +准备好了吗?下一章 [02-快速上手](../02-快速上手/) 将带你从零开始,5 分钟内跑起 OpenCode。 From 6e0bdb2d088904d69686ba9c109cf9547bf33f78 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Mar 2026 09:47:27 +0000 Subject: [PATCH 5/7] =?UTF-8?q?Add=20README.md=20and=2000-=E5=89=8D?= =?UTF-8?q?=E8=A8=80=20chapters=20(partial=20book)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: propress <202759273+propress@users.noreply.github.com> Agent-Logs-Url: https://github.com/propress/opencode/sessions/3ba6ec6c-19d3-4c9a-8b93-d24de7a88c29 --- ...05\350\257\273\346\214\207\345\215\227.md" | 68 ++++++++++ ...de\346\230\257\344\273\200\344\271\210.md" | 56 ++++++++ all-in-one-book/README.md | 127 ++++++++++++++++++ 3 files changed, 251 insertions(+) create mode 100644 "all-in-one-book/00-\345\211\215\350\250\200/01-\346\234\254\344\271\246\347\256\200\344\273\213\344\270\216\351\230\205\350\257\273\346\214\207\345\215\227.md" create mode 100644 "all-in-one-book/00-\345\211\215\350\250\200/02-OpenCode\346\230\257\344\273\200\344\271\210.md" create mode 100644 all-in-one-book/README.md diff --git "a/all-in-one-book/00-\345\211\215\350\250\200/01-\346\234\254\344\271\246\347\256\200\344\273\213\344\270\216\351\230\205\350\257\273\346\214\207\345\215\227.md" "b/all-in-one-book/00-\345\211\215\350\250\200/01-\346\234\254\344\271\246\347\256\200\344\273\213\344\270\216\351\230\205\350\257\273\346\214\207\345\215\227.md" new file mode 100644 index 000000000000..5170d27beeb3 --- /dev/null +++ "b/all-in-one-book/00-\345\211\215\350\250\200/01-\346\234\254\344\271\246\347\256\200\344\273\213\344\270\216\351\230\205\350\257\273\346\214\207\345\215\227.md" @@ -0,0 +1,68 @@ +# 本书简介与阅读指南 + +> 📌 一本从零到精通的 OpenCode 中文技术书籍,采用"地图→路线→细节"三级认知结构。 + +## 本书的目标读者 + +本书面向以下读者: + +| 角色 | 你会获得什么 | +|------|-------------| +| **AI 工具用户** | 学会高效使用 OpenCode 完成日常编码任务 | +| **全栈开发者** | 理解 Coding Agent 的架构设计,学习 TypeScript/Bun/Effect-TS 实践 | +| **开源贡献者** | 掌握源码结构,快速定位并修改代码 | +| **架构师** | 理解 Agent 系统的设计模式和技术选型 | +| **AI 产品经理** | 理解 AI Coding Agent 的能力边界和扩展方式 | + +## 本书的认知结构 + +我们采用 **"地图→路线→细节"** 三级认知结构: + +``` +第一级(全景地图): 花 30 分钟建立完整心智模型 + ↓ 知道"所有部件在哪、怎么连、怎么跑" +第二级(路线导航): 沿着数据流/执行流走通关键路径 + ↓ 知道"一个请求从输入到输出经历了什么" +第三级(细节深入): 对每个模块进行源码级解析 + ↓ 知道"每个零件内部怎么工作" +``` + +### 推荐阅读路线 + +**路线 A:快速了解(1 小时)** +1. 01-全景视野/01-一图看懂OpenCode +2. 01-全景视野/04-一次对话的完整旅程 +3. 02-快速上手/02-五分钟体验OpenCode + +**路线 B:深入架构(半天)** +1. 01-全景视野(全部 11 节) +2. 03-关键路径详解(全部 5 节) + +**路线 C:扩展开发(按需)** +1. 01-全景视野/09-扩展点全景图 +2. 06-扩展与集成(选择你需要的扩展类型) +3. 10-实战案例(动手练习) + +**路线 D:完整精读(一周)** +按目录顺序从头到尾读完。 + +## 约定说明 + +- 技术术语首次出现时使用 `中文(English)` 格式,例如:`会话(Session)` +- 源码引用标注文件路径,如 `// 文件: packages/opencode/src/agent/agent.ts` +- 所有架构图使用 Mermaid 语法 +- 💡 标记表示关键设计决策 +- 📍 标记表示"你在全景图中的位置" +- ⚠️ 标记表示需要特别注意的地方 + +## 前置知识 + +阅读本书的不同章节需要不同程度的前置知识: + +| 章节 | 需要了解 | +|------|---------| +| 00-前言、01-全景视野 | 基本编程概念 | +| 02-快速上手 | 命令行基础操作 | +| 03-关键路径、04-核心引擎 | TypeScript 基础、async/await | +| 05-用户界面 | 前端框架基本概念 | +| 09-周边知识 | 无(本章就是帮你补前置知识的) | diff --git "a/all-in-one-book/00-\345\211\215\350\250\200/02-OpenCode\346\230\257\344\273\200\344\271\210.md" "b/all-in-one-book/00-\345\211\215\350\250\200/02-OpenCode\346\230\257\344\273\200\344\271\210.md" new file mode 100644 index 000000000000..6f32050364c4 --- /dev/null +++ "b/all-in-one-book/00-\345\211\215\350\250\200/02-OpenCode\346\230\257\344\273\200\344\271\210.md" @@ -0,0 +1,56 @@ +# OpenCode 是什么 + +> 📌 OpenCode 是一个开源的 AI 编程代理(Coding Agent),让你在终端中与 AI 结对编程。 + +## 一句话定义 + +**OpenCode 是一个运行在终端里的 AI 编程助手**,它能理解你的代码库,帮你读代码、写代码、改代码、运行命令,就像一个坐在你旁边的资深程序员搭档。 + +## 核心特性 + +| 特性 | 说明 | +|------|------| +| 🤖 **多模型支持** | 支持 Claude、GPT、Gemini、Mistral 等 20+ 种 LLM | +| 🔧 **内置工具** | 文件读写、代码搜索、Shell 执行、Git 操作等 | +| 🔌 **MCP 协议** | 通过 Model Context Protocol 无限扩展工具能力 | +| 🖥️ **多端体验** | TUI 终端界面、Web 应用、桌面客户端 | +| 📂 **项目感知** | 自动理解项目结构、Git 状态、文件关系 | +| 🔒 **权限控制** | 精细的权限模型,控制 AI 可以做什么 | +| 🧩 **可扩展** | 自定义 Agent、Tool、Command、Plugin | +| 💾 **会话持久化** | 对话历史保存在本地 SQLite 数据库 | + +## 与同类工具对比 + +| 特性 | OpenCode | GitHub Copilot CLI | Cursor | Aider | +|------|----------|-------------------|--------|-------| +| 开源 | ✅ MIT | ❌ | ❌ | ✅ | +| 多 LLM 支持 | ✅ 20+ | ❌ 仅 GPT | ❌ 有限 | ✅ | +| MCP 协议 | ✅ | ❌ | ✅ | ❌ | +| TUI 界面 | ✅ | ✅ | ❌ | ✅ | +| Web 界面 | ✅ | ❌ | ✅ | ❌ | +| 桌面客户端 | ✅ | ❌ | ✅ | ❌ | +| 插件系统 | ✅ | ❌ | ❌ | ❌ | +| 自定义 Agent | ✅ | ❌ | ❌ | ❌ | + +## 项目背景 + +OpenCode 最初由 [anomalyco](https://github.com/anomalyco) 团队开发,本书基于 `propress/opencode` 仓库的 `dev` 分支编写。项目采用 MIT 许可证,欢迎社区贡献。 + +- **官网**: https://opencode.ai +- **GitHub**: https://github.com/anomalyco/opencode +- **技术栈**: TypeScript + Bun + Effect-TS + Vercel AI SDK + SolidJS + Tauri + Drizzle ORM + +## OpenCode 能做什么? + +``` +你:帮我把 src/utils.ts 里的所有 forEach 改成 for...of 循环 + +OpenCode: +1. 🔍 搜索 src/utils.ts 中的 forEach 用法 +2. 📖 读取文件内容,理解上下文 +3. ✏️ 逐个替换为 for...of,保持语义一致 +4. ✅ 展示修改 diff,等你确认 +5. 💾 保存文件,创建 Git 快照 +``` + +这就是 OpenCode 的核心工作方式:**理解意图 → 分析代码 → 选择工具 → 执行操作 → 确认结果**。 diff --git a/all-in-one-book/README.md b/all-in-one-book/README.md new file mode 100644 index 000000000000..dcb0455f6851 --- /dev/null +++ b/all-in-one-book/README.md @@ -0,0 +1,127 @@ +# 📖 OpenCode 全方位技术书籍 + +> **从零到精通:深入理解开源 Coding Agent 的一切** + +--- + +## 🎯 本书简介 + +这是一本关于 [OpenCode](https://opencode.ai) 的全面中文技术书籍。OpenCode 是一个开源的 AI Coding Agent,基于 TypeScript/Bun 构建,支持多种 LLM 提供商,通过 MCP 协议扩展工具能力,提供 TUI、Web、桌面多端体验。 + +本书基于 `propress/opencode`(fork 自 `anomalyco/opencode`)`dev` 分支的源码编写,带你从概念理解到源码精读,从快速上手到深度扩展。 + +--- + +## 🧠 阅读方法:地图 → 路线 → 细节 + +本书采用三级认知结构,建议按以下方式阅读: + +| 级别 | 目标 | 时间 | 章节 | +|------|------|------|------| +| 🗺️ **第一级:全景地图** | 建立完整心智模型 | 30 分钟 | 01-全景视野 | +| 🛤️ **第二级:路线导航** | 跑通每条关键路径 | 2-3 小时 | 02-快速上手、03-关键路径详解 | +| 🔬 **第三级:模块深入** | 逐个零件拆解 | 按需 | 04~10 章 | + +> 💡 **强烈建议**:无论你的目标是什么,都先读完 **01-全景视野**。它是整本书的"导航仪"。 + +--- + +## 📚 目录 + +### 🗺️ 第一级:全景地图 + +#### [00-前言](./00-前言/) +- [01-本书简介与阅读指南](./00-前言/01-本书简介与阅读指南.md) +- [02-OpenCode是什么](./00-前言/02-OpenCode是什么.md) +- [03-为什么学习OpenCode](./00-前言/03-为什么学习OpenCode.md) + +#### [01-全景视野](./01-全景视野/) ⭐ 全书最核心章节 +- [01-一图看懂OpenCode](./01-全景视野/01-一图看懂OpenCode.md) — 总架构图 + 每个组件一句话 +- [02-核心概念关系图谱](./01-全景视野/02-核心概念关系图谱.md) — 所有核心概念及其关系 +- [03-Monorepo全景-19个包的关系](./01-全景视野/03-Monorepo全景-19个包的关系.md) — 包间依赖拓扑图 +- [04-一次对话的完整旅程](./01-全景视野/04-一次对话的完整旅程.md) ⭐ — 全链路追踪 +- [05-一次Tool调用的完整旅程](./01-全景视野/05-一次Tool调用的完整旅程.md) ⭐ — Tool 执行全链路 +- [06-一次文件编辑的完整旅程](./01-全景视野/06-一次文件编辑的完整旅程.md) ⭐ — 从指令到文件落盘 +- [07-数据流全景图](./01-全景视野/07-数据流全景图.md) — 数据在各模块间的流动 +- [08-状态生命周期全景图](./01-全景视野/08-状态生命周期全景图.md) — 各实体的状态机 +- [09-扩展点全景图](./01-全景视野/09-扩展点全景图.md) — 所有可扩展的点 +- [10-技术栈全景与选型理由](./01-全景视野/10-技术栈全景与选型理由.md) — 技术选型分析 +- [11-速查:我想做X应该看哪章](./01-全景视野/11-速查:我想做X应该看哪章.md) — 快速导航 + +### 🛤️ 第二级:路线导航 + +#### [02-快速上手](./02-快速上手/) +- 01-安装与环境准备 +- 02-五分钟体验OpenCode +- 03-核心概念详解 +- 04-常见使用场景手册 + +#### [03-关键路径详解](./03-关键路径详解/) +- 01-启动流程详解 +- 02-对话循环详解 +- 03-Tool执行详解 +- 04-Session管理详解 +- 05-Provider调用详解 + +### 🔬 第三级:模块深入 + +#### [04-核心引擎-packages-opencode](./04-核心引擎-packages-opencode/) +- 01~21:Agent系统、Session管理、Provider、Tool系统、Skill、Command、MCP、LSP、Git、Shell、文件系统、存储、HTTP Server、权限、快照、事件总线、插件、配置、Effect-TS、ACP + +#### [05-用户界面](./05-用户界面/) +- TUI终端界面、Web应用、桌面客户端、Console管理平台、UI组件库 + +#### [06-扩展与集成](./06-扩展与集成/) +- 自定义Agent/Command/Tool、Plugin SDK、MCP服务器、Provider适配、主题、VSCode扩展、Slack集成 + +#### [07-基础设施与部署](./07-基础设施与部署/) +- SST基础设施、容器化、Nix打包、CI/CD、企业版部署、安装脚本 + +#### [08-开发者指南](./08-开发者指南/) +- 本地开发、调试、测试、代码风格、发布、贡献指南 + +#### [09-周边知识与生态](./09-周边知识与生态/) +- Bun、Effect-TS、AI SDK、MCP协议、LSP、SolidJS、Tauri、Drizzle、Turborepo、SST + +#### [10-实战案例](./10-实战案例/) +- 从零创建Agent、开发Tool、接入Provider、构建MCP服务器、开发插件、贡献代码 + +### 📎 附录 + +#### [11-附录](./11-附录/) +- 术语表、配置参考、API参考、CLI命令、内置Tool/Skill列表、Provider列表、FAQ、安全策略 + +--- + +## 📊 质量报告 + +- [QUALITY_REPORT.md](./QUALITY_REPORT.md) — 各章质量评分与改进计划 + +--- + +## 🔧 技术栈速览 + +| 层次 | 技术 | 版本 | +|------|------|------| +| 运行时 | Bun | 1.3.11 | +| 语言 | TypeScript | 5.8.2 | +| 副作用管理 | Effect-TS | 4.0.0-beta.35 | +| AI 集成 | Vercel AI SDK | 5.x | +| 协议 | MCP (Model Context Protocol) | 1.27.1 | +| Web 框架 | Hono | 4.10.7 | +| 前端 | SolidJS | 1.9.x | +| ORM | Drizzle | 1.0.0-beta | +| 桌面 | Tauri 2 + Electron 40 | — | +| 构建 | Turborepo | — | +| 基础设施 | SST | — | + +--- + +## 📝 关于本书 + +- **语言**: 中文(简体) +- **基于仓库**: `propress/opencode` (fork of `anomalyco/opencode`) +- **分支**: `dev` +- **许可证**: 与仓库一致 (MIT) + +> ⚠️ 本书内容基于特定时间点的源码编写,如源码有更新,部分内容可能需要对照最新代码阅读。 From d9d7c6a13b74e172381cf29e024171ca84b96f64 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Mar 2026 10:53:52 +0000 Subject: [PATCH 6/7] =?UTF-8?q?Add=20chapters=2000-=E5=89=8D=E8=A8=80,=200?= =?UTF-8?q?2-=E5=BF=AB=E9=80=9F=E4=B8=8A=E6=89=8B,=2003-=E5=85=B3=E9=94=AE?= =?UTF-8?q?=E8=B7=AF=E5=BE=84=E8=AF=A6=E8=A7=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: propress <202759273+propress@users.noreply.github.com> Agent-Logs-Url: https://github.com/propress/opencode/sessions/0fbec29b-9863-449f-a0a7-731daa5aa2ed --- ...01\347\250\213\350\257\246\350\247\243.md" | 605 ++++++++++++ ...52\347\216\257\350\257\246\350\247\243.md" | 824 +++++++++++++++++ ...47\350\241\214\350\257\246\350\247\243.md" | 853 +++++++++++++++++ ...41\347\220\206\350\257\246\350\247\243.md" | 862 ++++++++++++++++++ ...03\347\224\250\350\257\246\350\247\243.md" | 354 +++++++ 5 files changed, 3498 insertions(+) create mode 100644 "all-in-one-book/03-\345\205\263\351\224\256\350\267\257\345\276\204\350\257\246\350\247\243/01-\345\220\257\345\212\250\346\265\201\347\250\213\350\257\246\350\247\243.md" create mode 100644 "all-in-one-book/03-\345\205\263\351\224\256\350\267\257\345\276\204\350\257\246\350\247\243/02-\345\257\271\350\257\235\345\276\252\347\216\257\350\257\246\350\247\243.md" create mode 100644 "all-in-one-book/03-\345\205\263\351\224\256\350\267\257\345\276\204\350\257\246\350\247\243/03-Tool\346\211\247\350\241\214\350\257\246\350\247\243.md" create mode 100644 "all-in-one-book/03-\345\205\263\351\224\256\350\267\257\345\276\204\350\257\246\350\247\243/04-Session\347\256\241\347\220\206\350\257\246\350\247\243.md" create mode 100644 "all-in-one-book/03-\345\205\263\351\224\256\350\267\257\345\276\204\350\257\246\350\247\243/05-Provider\350\260\203\347\224\250\350\257\246\350\247\243.md" diff --git "a/all-in-one-book/03-\345\205\263\351\224\256\350\267\257\345\276\204\350\257\246\350\247\243/01-\345\220\257\345\212\250\346\265\201\347\250\213\350\257\246\350\247\243.md" "b/all-in-one-book/03-\345\205\263\351\224\256\350\267\257\345\276\204\350\257\246\350\247\243/01-\345\220\257\345\212\250\346\265\201\347\250\213\350\257\246\350\247\243.md" new file mode 100644 index 000000000000..e68623a6072e --- /dev/null +++ "b/all-in-one-book/03-\345\205\263\351\224\256\350\267\257\345\276\204\350\257\246\350\247\243/01-\345\220\257\345\212\250\346\265\201\347\250\213\350\257\246\350\247\243.md" @@ -0,0 +1,605 @@ +# 第一节 启动流程详解 + +📍 **你在这里** +> 在第01章全景视野中,我们用一张大图鸟瞰了整个 OpenCode。现在我们沿着 **CLI 入口 → 配置加载 → 数据库迁移 → 服务器启动 → TUI 渲染** 这条线路,深入探索每一步的实现细节。 + +--- + +## 学习目标 + +读完本节,你将能够: + +1. 理解从 `opencode` 命令到最终 TUI 界面出现之间发生的**每一步** +2. 掌握 Yargs 命令注册(Command Registration)和中间件(Middleware)机制 +3. 了解配置(Config)的**七层加载优先级** +4. 理解 SQLite 数据库迁移(Migration)与 WAL 模式的作用 +5. 理解 Worker 线程(Worker Thread)架构及 RPC 通信机制 + +--- + +## 一、CLI 入口:一切从这里开始 + +OpenCode 的所有命令都从一个文件出发: + +```typescript +// 文件: packages/opencode/src/index.ts + +import yargs from "yargs" +import { hideBin } from "yargs/helpers" + +let cli = yargs(hideBin(process.argv)) + .parserConfiguration({ "populate--": true }) + .scriptName("opencode") + .wrap(100) + .help("help", "show help") + .version("version", "show version number", Installation.VERSION) +``` + +`hideBin(process.argv)` 会去掉前两个参数(`node` 和脚本路径),只保留用户实际输入的部分。`parserConfiguration({ "populate--": true })` 让双破折号后面的参数原样传递——这在转发参数给子进程时非常有用。 + +### 1.1 全局错误守卫 + +在任何命令执行前,两个全局错误处理器就已经就位: + +```typescript +// 文件: packages/opencode/src/index.ts + +process.on("unhandledRejection", (e) => { + Log.Default.error("rejection", { + e: e instanceof Error ? e.message : e, + }) +}) + +process.on("uncaughtException", (e) => { + Log.Default.error("exception", { + e: e instanceof Error ? e.message : e, + }) +}) +``` + +这确保了即使在异步操作中出现意外错误,也能被记录而不是默默消失。 + +### 1.2 日志初始化中间件 + +Yargs 的 `middleware` 会在**所有命令执行前**运行: + +```typescript +// 文件: packages/opencode/src/index.ts + +.middleware(async (opts) => { + await Log.init({ + print: process.argv.includes("--print-logs"), + dev: Installation.isLocal(), + level: (() => { + if (opts.logLevel) return opts.logLevel as Log.Level + if (Installation.isLocal()) return "DEBUG" + return "INFO" + })(), + }) + + process.env.AGENT = "1" + process.env.OPENCODE = "1" + process.env.OPENCODE_PID = String(process.pid) + + Log.Default.info("opencode", { + version: Installation.VERSION, + args: process.argv.slice(2), + }) +``` + +注意三个环境变量的设置——`AGENT`、`OPENCODE` 和 `OPENCODE_PID`。其他工具和子进程可以通过这些变量检测自己是否运行在 OpenCode 环境中。 + +--- + +## 二、数据库迁移:一次性初始化 + +紧接着日志初始化,是一段**只执行一次**的数据库迁移逻辑: + +```typescript +// 文件: packages/opencode/src/index.ts + +const marker = path.join(Global.Path.data, "opencode.db") +if (!(await Filesystem.exists(marker))) { + process.stderr.write( + "Performing one time database migration, may take a few minutes..." + EOL, + ) + // ...带进度条的迁移过程... + await JsonMigration.run(Database.Client().$client, { + progress: (event) => { + const percent = Math.floor((event.current / event.total) * 100) + // 渲染进度条 ■■■■■■■■■■・・・・・ 45% + }, + }) + process.stderr.write("Database migration complete." + EOL) +} +``` + +这段代码检查 `~/.local/share/opencode/opencode.db` 是否存在。如果是首次运行或从旧版本 JSON 存储升级,会将所有 JSON 数据迁移到 SQLite 数据库,并在终端显示一个精美的进度条。 + +--- + +## 三、命令注册树 + +迁移完成后,所有子命令被注册到 Yargs: + +```typescript +// 文件: packages/opencode/src/index.ts + +.command(AcpCommand) +.command(McpCommand) +.command(TuiThreadCommand) // ← 默认命令 "$0" +.command(AttachCommand) +.command(RunCommand) +.command(GenerateCommand) +.command(ServeCommand) +.command(ModelsCommand) +// ... 更多命令 +``` + +其中 `TuiThreadCommand` 的命令名是 `$0`——这是 Yargs 的特殊语法,表示**默认命令**。当用户直接运行 `opencode` 而不带任何子命令时,就会执行这个 TUI 命令。 + +```typescript +// 文件: packages/opencode/src/cli/cmd/tui/thread.ts + +export const TuiThreadCommand = cmd({ + command: "$0 [project]", + describe: "start opencode tui", + builder: (yargs) => yargs + .positional("project", { type: "string", describe: "path to start opencode in" }) + .option("model", { type: "string", alias: ["m"], describe: "model to use" }) + .option("continue", { alias: ["c"], describe: "continue last session" }) + .option("session", { alias: ["s"], describe: "session id to continue" }) + .option("prompt", { type: "string", describe: "initial prompt" }) + .option("agent", { type: "string", describe: "agent to use" }), + handler: async (args) => { /* ... */ }, +}) +``` + +--- + +## 四、TUI 启动:Worker 线程架构 + +当默认命令被触发后,OpenCode 不会在主线程中运行服务器——它启动一个独立的 **Worker 线程**: + +```typescript +// 文件: packages/opencode/src/cli/cmd/tui/thread.ts + +handler: async (args) => { + // 1. Windows 平台特殊处理 + const unguard = win32InstallCtrlCGuard() + win32DisableProcessedInput() + + // 2. 解析工作目录 + const cwd = Filesystem.resolve(process.cwd()) + + // 3. 启动 Worker 线程 + const file = await target() + const worker = new Worker(file, { env: process.env }) + + // 4. 建立 RPC 通信 + const client = Rpc.client(worker) +``` + +### 4.1 为什么使用 Worker 线程? + +这种架构有三个关键优势: + +| 优势 | 说明 | +|------|------| +| **UI 不阻塞** | 主线程专注于 TUI 渲染,LLM 调用在 Worker 中异步执行 | +| **内存隔离** | Worker 有独立的 V8 堆,大模型响应不影响 UI 内存 | +| **优雅重载** | 可以向 Worker 发送 `reload` 指令而不重启整个进程 | + +### 4.2 两种传输模式 + +OpenCode 支持两种 Transport(传输模式): + +```typescript +// 文件: packages/opencode/src/cli/cmd/tui/thread.ts + +const external = process.argv.includes("--port") || network.port !== 0 + +const transport = external + ? { + url: (await client.call("server", network)).url, + fetch: undefined, + events: undefined, + } + : { + url: "http://opencode.internal", + fetch: createWorkerFetch(client), + events: createEventSource(client), + } +``` + +**内部传输(Internal)**:HTTP 请求直接通过 RPC 转发给 Worker 线程内的 Hono 服务器,无需占用真实网络端口。URL 使用虚拟地址 `http://opencode.internal`。 + +**外部传输(External)**:在指定端口启动真实的 HTTP 服务器。这在 `opencode serve` 命令或远程访问时使用。 + +### 4.3 Worker 内部初始化 + +Worker 线程启动后的工作: + +```typescript +// 文件: packages/opencode/src/cli/cmd/tui/worker.ts + +// 初始化日志 +await Log.init({ + print: process.argv.includes("--print-logs"), + dev: Installation.isLocal(), + level: Installation.isLocal() ? "DEBUG" : "INFO", +}) + +// 订阅全局事件并转发 +GlobalBus.on("event", (event) => { + Rpc.emit("global.event", event) +}) + +// 暴露 RPC 接口 +export const rpc = { + async fetch(input) { + const response = await Server.Default().fetch(new Request(input.url, { ... })) + return { status: response.status, headers: ..., body: await response.text() } + }, + async server(input) { + server = await Server.listen(input) + return { url: server.url.toString() } + }, + async reload() { + Config.global.reset() + await Instance.disposeAll() + }, + async shutdown() { + if (eventStream.abort) eventStream.abort.abort() + await Instance.disposeAll() + if (server) await server.stop(true) + }, +} + +Rpc.listen(rpc) +``` + +--- + +## 五、Bootstrap 模式:实例生命周期 + +每当一个命令需要访问项目上下文时,它会通过 `bootstrap()` 函数初始化: + +```typescript +// 文件: packages/opencode/src/cli/bootstrap.ts + +export async function bootstrap(directory: string, cb: () => Promise) { + return Instance.provide({ + directory, + init: InstanceBootstrap, + fn: async () => { + try { + const result = await cb() + return result + } finally { + await Instance.dispose() + } + }, + }) +} +``` + +`Instance.provide()` 做了这些事情: + +1. 解析目录路径,查找 Git worktree 根目录 +2. 加载项目配置(`opencode.json`) +3. 初始化数据库连接 +4. 缓存实例——同一目录不会重复初始化 +5. 回调执行完毕后,调用 `dispose()` 清理资源 + +--- + +## 六、配置加载:七层优先级 + +配置加载是启动过程中最复杂的部分。OpenCode 使用多层配置合并(Merge),优先级从低到高: + +```typescript +// 文件: packages/opencode/src/config/config.ts + +export const state = Instance.state(async () => { + let result = {} as Config + + // 1. 远程配置(Organization 默认) + for (const [key, value] of Object.entries(auth)) { + if (value.type === "wellknown") { + const response = await fetch(`${url}/.well-known/opencode`) + result = mergeConfigConcatArrays(result, remoteConfig) + } + } + + // 2. 全局用户配置 (~/.config/opencode/opencode.json) + result = mergeConfigConcatArrays(result, await global()) + + // 3. 自定义路径 (OPENCODE_CONFIG 环境变量) + if (Flag.OPENCODE_CONFIG) { + result = mergeConfigConcatArrays(result, await loadFile(Flag.OPENCODE_CONFIG)) + } + + // 4. 项目配置 (./opencode.json) + for (const file of await ConfigPaths.projectFiles(...)) { + result = mergeConfigConcatArrays(result, await loadFile(file)) + } + + // 5. .opencode 目录 (agents/, commands/, plugins/) + for (const dir of unique(directories)) { + if (dir.endsWith(".opencode")) { + result.plugin.push(...(await loadPlugin(dir))) + result.agent = mergeDeep(result.agent, await loadAgent(dir)) + } + } + + // 6. 内联配置 (OPENCODE_CONFIG_CONTENT 环境变量) + if (process.env.OPENCODE_CONFIG_CONTENT) { + result = mergeConfigConcatArrays(result, await load(...)) + } + + // 7. 企业托管配置(最高优先级) + if (existsSync(managedDir)) { + result = mergeConfigConcatArrays(result, await loadFile(...)) + } + + return { config: result, directories, deps } +}) +``` + +``` +┌─────────────────────────────────────┐ 优先级最高 +│ 7. 企业托管配置 (Managed Config) │ ↑ +├─────────────────────────────────────┤ +│ 6. 内联环境变量 │ +├─────────────────────────────────────┤ +│ 5. .opencode/ 目录 │ +├─────────────────────────────────────┤ +│ 4. 项目 opencode.json │ +├─────────────────────────────────────┤ +│ 3. OPENCODE_CONFIG 路径 │ +├─────────────────────────────────────┤ +│ 2. 全局 ~/.config/opencode/ │ +├─────────────────────────────────────┤ +│ 1. 远程 .well-known/opencode │ ↓ +└─────────────────────────────────────┘ 优先级最低 +``` + +--- + +## 七、数据库初始化 + +OpenCode 使用 **SQLite + Drizzle ORM** 作为存储引擎: + +```typescript +// 文件: packages/opencode/src/storage/db.ts + +export const Client = lazy(() => { + const db = init(Path) + + // 性能优化 Pragma + db.run("PRAGMA journal_mode = WAL") // 写前日志(并发读写) + db.run("PRAGMA synchronous = NORMAL") // 平衡性能与安全 + db.run("PRAGMA busy_timeout = 5000") // 等待锁 5 秒 + db.run("PRAGMA cache_size = -64000") // 64MB 缓存 + db.run("PRAGMA foreign_keys = ON") // 启用外键 + db.run("PRAGMA wal_checkpoint(PASSIVE)") // 被动检查点 + + // 执行数据库迁移 + migrate(db, entries) + return db +}) +``` + +> 💡 **为什么选择 WAL 模式?** WAL(Write-Ahead Logging)模式允许读操作和写操作同时进行,而不互相阻塞。这对于 TUI 前端实时刷新消息列表非常关键——前端可以在 LLM 写入新 token 的同时读取消息历史。 + +--- + +## 八、服务器启动 + +服务器基于 **Hono 框架** 构建,通过 `Bun.serve()` 启动: + +```typescript +// 文件: packages/opencode/src/server/server.ts + +export function listen(opts) { + const app = createApp(opts) + + const tryServe = (port: number) => { + try { + return Bun.serve({ ...args, port }) + } catch { + return undefined + } + } + + // 端口分配策略:指定端口 → 4096 → 系统分配 + const server = opts.port === 0 + ? (tryServe(4096) ?? tryServe(0)) + : tryServe(opts.port) +} +``` + +服务器的中间件栈(Middleware Stack): + +```typescript +// 文件: packages/opencode/src/server/server.ts + +const app = new Hono() + // 1. 错误处理 + .onError((err, c) => { + if (err instanceof NamedError) { + let status = 500 + if (err instanceof NotFoundError) status = 404 + return c.json(err.toObject(), { status }) + } + }) + // 2. 基础认证(可选) + .use((c, next) => { + const password = Flag.OPENCODE_SERVER_PASSWORD + if (!password) return next() + return basicAuth({ username, password })(c, next) + }) + // 3. 请求日志 + .use(async (c, next) => { + const timer = log.time("request", { method: c.req.method, path: c.req.path }) + await next() + timer.stop() + }) + // 4. CORS 策略 + .use(cors({ origin(input) { /* localhost 和 opencode.ai 域名 */ } })) + // 5. 实例上下文注入 + .use(async (c, next) => { + const directory = c.req.query("directory") || process.cwd() + return Instance.provide({ directory, init: InstanceBootstrap, fn: () => next() }) + }) + // 6. 路由注册 + .route("/session", SessionRoutes()) + .route("/config", ConfigRoutes()) + .route("/project", ProjectRoutes()) + // ... 更多路由 +``` + +--- + +## 九、完整启动时序 + +```mermaid +sequenceDiagram + participant User as 用户终端 + participant CLI as index.ts (Yargs) + participant Log as 日志系统 + participant DB as SQLite 数据库 + participant Thread as TUI 主线程 + participant Worker as Worker 线程 + participant Server as Hono 服务器 + participant TUI as React TUI + + User->>CLI: opencode [project] + CLI->>CLI: hideBin(process.argv) + CLI->>Log: Log.init({ level, dev }) + CLI->>CLI: 设置环境变量 AGENT, OPENCODE + + alt 首次运行 + CLI->>DB: JsonMigration.run() + DB-->>CLI: 迁移完成 ■■■■■■■ 100% + end + + CLI->>Thread: TuiThreadCommand.handler(args) + Thread->>Thread: 解析工作目录 cwd + Thread->>Worker: new Worker(file, { env }) + Worker->>Log: Log.init() + Worker->>Worker: 订阅 GlobalBus 事件 + Worker->>Worker: Rpc.listen(rpc) + + Thread->>Thread: Rpc.client(worker) + + alt 外部传输模式 + Thread->>Worker: client.call("server", { port, hostname }) + Worker->>Server: Server.listen({ port }) + Server-->>Worker: server.url + Worker-->>Thread: { url } + else 内部传输模式 + Thread->>Thread: createWorkerFetch(client) + Thread->>Thread: url = "http://opencode.internal" + end + + Thread->>TUI: tui({ url, config, directory }) + TUI->>Server: HTTP/SSE 请求 + Server->>Server: Instance.provide(directory) + Server->>Server: 加载配置(七层合并) + Server->>DB: Database.Client() + WAL 模式 + Server-->>TUI: 响应数据 + TUI-->>User: 渲染终端界面 +``` + +--- + +## 十、XDG 目录规范 + +OpenCode 严格遵循 **XDG Base Directory** 规范来组织文件: + +```typescript +// 文件: packages/opencode/src/global/index.ts + +export const Path = { + data: path.join(xdgData, "opencode"), // ~/.local/share/opencode + config: path.join(xdgConfig, "opencode"), // ~/.config/opencode + cache: path.join(xdgCache, "opencode"), // ~/.cache/opencode + state: path.join(xdgState, "opencode"), // ~/.local/state/opencode + log: path.join(data, "log"), // ~/.local/share/opencode/log + bin: path.join(cache, "bin"), // ~/.cache/opencode/bin +} +``` + +| 路径 | 用途 | 示例内容 | +|------|------|----------| +| `data/` | 持久数据 | `opencode.db`、会话文件 | +| `config/` | 用户配置 | `opencode.json` | +| `cache/` | 可清除缓存 | 下载的二进制工具 | +| `state/` | 运行时状态 | 锁文件 | +| `log/` | 日志文件 | `opencode.log` | + +--- + +## 动手练习 + +### 练习 1:追踪启动日志 + +启用详细日志来观察启动过程: + +```bash +opencode --print-logs --log-level DEBUG 2>startup.log +# 在另一个终端查看日志 +tail -f startup.log | grep -E "(creating instance|request|migration)" +``` + +### 练习 2:观察配置合并 + +在项目根目录创建 `opencode.json`,设置一个自定义选项,然后在 `~/.config/opencode/opencode.json` 设置不同的值,观察最终生效的是哪一个。 + +### 练习 3:使用外部传输模式 + +```bash +# 启动带有外部端口的 TUI +opencode --port 4096 + +# 在另一个终端用 curl 查看 API +curl http://localhost:4096/session?directory=$(pwd) | jq +``` + +--- + +## 常见问题 + +### Q: 为什么 OpenCode 启动时要检查 `opencode.db` 是否存在? + +这是一个**一次性迁移守卫**。早期版本使用 JSON 文件存储会话数据,升级后需要将这些数据迁移到 SQLite。通过检查数据库文件是否存在,可以判断是否需要执行迁移。 + +### Q: Worker 线程崩溃会怎样? + +Worker 线程中的 `unhandledRejection` 和 `uncaughtException` 都被捕获并记录日志。如果 Worker 意外终止,主线程的 TUI 也会跟着退出(通过 `finally` 块中的 `stop()`)。 + +### Q: 内部传输模式中,HTTP 请求真的会走网络吗? + +不会。`createWorkerFetch(client)` 创建了一个自定义的 `fetch` 函数,它通过 RPC 直接调用 Worker 线程内的 Hono 服务器的 `fetch` 方法。整个过程在进程内部完成,不涉及 TCP 连接。 + +### Q: 我能在同一台机器上运行多个 OpenCode 实例吗? + +可以。端口分配策略会自动处理冲突——如果 4096 被占用,系统会自动分配一个可用端口。每个实例有独立的 Worker 线程和数据库连接。 + +--- + +## 小结 + +本节我们完整追踪了 OpenCode 的启动流程: + +1. **CLI 解析**:Yargs 解析命令行参数,执行日志初始化中间件 +2. **数据库迁移**:首次运行时从 JSON 迁移到 SQLite +3. **命令路由**:默认执行 `TuiThreadCommand`(`$0`) +4. **Worker 架构**:主线程负责 TUI 渲染,Worker 线程运行服务器 +5. **传输模式**:内部 RPC 传输(默认)或外部 HTTP 传输 +6. **配置合并**:七层优先级从远程到企业托管 +7. **服务器中间件**:错误处理 → 认证 → 日志 → CORS → 实例注入 + +> ⏭️ 下一节,我们将深入对话循环——从用户发送消息到 LLM 返回响应的完整路径。 diff --git "a/all-in-one-book/03-\345\205\263\351\224\256\350\267\257\345\276\204\350\257\246\350\247\243/02-\345\257\271\350\257\235\345\276\252\347\216\257\350\257\246\350\247\243.md" "b/all-in-one-book/03-\345\205\263\351\224\256\350\267\257\345\276\204\350\257\246\350\247\243/02-\345\257\271\350\257\235\345\276\252\347\216\257\350\257\246\350\247\243.md" new file mode 100644 index 000000000000..a277412067a4 --- /dev/null +++ "b/all-in-one-book/03-\345\205\263\351\224\256\350\267\257\345\276\204\350\257\246\350\247\243/02-\345\257\271\350\257\235\345\276\252\347\216\257\350\257\246\350\247\243.md" @@ -0,0 +1,824 @@ +# 第二节 对话循环详解 + +📍 **你在这里** +> 在第01章全景视野中,我们用一张大图鸟瞰了整个 OpenCode。现在我们沿着 **消息发送 → Prompt 组装 → LLM 流式调用 → Tool 检测与执行 → 结果反馈 → 下一轮调用** 这条线路,深入探索每一步的实现细节。 + +--- + +## 学习目标 + +读完本节,你将能够: + +1. 理解 OpenCode 的 **ReAct 循环**(Reasoning + Acting)完整实现 +2. 掌握从用户消息到 LLM 响应的**三层架构**:Prompt → Processor → LLM +3. 了解流式事件(Stream Event)的种类和处理方式 +4. 理解 Doom Loop(死循环)检测机制 +5. 掌握结构化输出(Structured Output)模式的工作原理 + +--- + +## 一、概念解释:ReAct 模式 + +OpenCode 的对话循环基于 **ReAct** 模式——LLM 交替执行"推理"(Reasoning)和"行动"(Acting): + +``` +用户提问 → LLM 思考 → 调用工具 → 获得结果 → LLM 再思考 → 调用工具 → ... → 最终回答 +``` + +这不是简单的一问一答,而是一个**多步循环**,LLM 可以在一轮对话中多次调用工具,直到它认为任务完成。 + +--- + +## 二、三层架构总览 + +对话循环由三层组件协作完成: + +``` +┌──────────────────────────────────────────────┐ +│ SessionPrompt(会话提示层) │ +│ 创建用户消息 → 管理循环生命周期 → 处理退出条件 │ +├──────────────────────────────────────────────┤ +│ SessionProcessor(处理器层) │ +│ 驱动流式调用 → 处理事件 → 检测死循环 → 错误重试 │ +├──────────────────────────────────────────────┤ +│ LLM(调用层) │ +│ 组装 System Prompt → 转换消息格式 → 调用 AI SDK │ +└──────────────────────────────────────────────┘ +``` + +我们从最上层开始,逐层深入。 + +--- + +## 三、SessionPrompt:循环的起点 + +### 3.1 用户消息创建 + +当用户在 TUI 中按下回车发送消息时,`SessionPrompt.prompt()` 被调用: + +```typescript +// 文件: packages/opencode/src/session/prompt.ts + +export const prompt = fn(PromptInput, async (input) => { + // 创建用户消息 + const msg = await Session.updateMessage({ + id: MessageID.ascending(), + role: "user", + sessionID: input.sessionID, + agent: agent.name, + model: { providerID: model.providerID, modelID: model.id }, + format: input.format, + time: { created: Date.now() }, + }) + + // 添加消息部件(文本、文件、Agent 引用) + for (const part of input.parts) { + await Session.updatePart({ + id: PartID.ascending(), + messageID: msg.id, + sessionID: input.sessionID, + ...part, + }) + } + + // 启动对话循环 + if (!input.noReply) { + loop({ sessionID: input.sessionID }) + } +}) +``` + +> 💡 `MessageID.ascending()` 生成**单调递增**的 ID(基于 ULID),保证消息的时间顺序。 + +### 3.2 主循环 — loop() + +`loop()` 是整个对话循环的核心。它是一个 `while(true)` 循环,每一轮都做出"继续"或"停止"的决策: + +```typescript +// 文件: packages/opencode/src/session/prompt.ts + +export const loop = fn(LoopInput, async (input) => { + const abort = input.resume_existing + ? resume(input.sessionID) + : start(input.sessionID) + + while (true) { + // 获取所有消息(跳过已压缩的) + let msgs = await MessageV2.filterCompacted( + MessageV2.stream(input.sessionID), + ) + + // 找到最后的用户消息和助手消息 + let lastUser: MessageV2.User + let lastAssistant: MessageV2.Assistant + + // ========== 退出条件检查 ========== + if ( + lastAssistant.finish && + !["tool-calls", "unknown"].includes(lastAssistant.finish) && + lastUser.id < lastAssistant.id + ) { + break // LLM 正常结束,退出循环 + } + + // ========== 处理挂起任务 ========== + if (task?.type === "subtask") { + // 执行子任务(委派给其他 Agent) + continue + } + if (task?.type === "compaction") { + // 压缩会话历史 + continue + } + + // ========== 上下文溢出检查 ========== + if (await SessionCompaction.isOverflow({ ... })) { + await SessionCompaction.create({ ... }) + continue + } + + // ========== 构建工具集 ========== + const agent = await Agent.get(lastUser.agent) + const tools = await resolveTools({ ... }) + + // ========== 构建 System Prompt ========== + const system = [ + ...(await SystemPrompt.environment(model)), + ...(skills ? [skills] : []), + ...(await InstructionPrompt.system()), + ] + + // ========== 调用 Processor ========== + const processor = SessionProcessor.create({ + assistantMessage, + sessionID: input.sessionID, + model, + abort, + }) + + const result = await processor.process({ + user: lastUser, + agent, + abort, + messages: MessageV2.toModelMessages(msgs, model), + tools, + system, + }) + + // ========== 处理结果 ========== + if (result === "stop") break + if (result === "compact") { + await SessionCompaction.create({ ... }) + } + // result === "continue" → 下一轮循环 + } + + // 循环结束后:修剪旧工具输出 + SessionCompaction.prune({ sessionID: input.sessionID }) + return lastAssistantMessage +}) +``` + +### 3.3 循环流程图 + +```mermaid +flowchart TD + A[获取所有消息] --> B{LLM 已正常结束?} + B -->|是| Z[退出循环] + B -->|否| C{有挂起的子任务?} + C -->|是| D[执行子任务] --> A + C -->|否| E{有挂起的压缩?} + E -->|是| F[压缩会话] --> A + E -->|否| G{上下文溢出?} + G -->|是| H[创建压缩任务] --> A + G -->|否| I[构建工具集和 System Prompt] + I --> J[创建 Processor] + J --> K[调用 processor.process] + K --> L{返回结果} + L -->|stop| Z + L -->|compact| H + L -->|continue| A + Z --> M[修剪旧工具输出] + M --> N[返回最终助手消息] +``` + +--- + +## 四、SessionProcessor:流式事件处理引擎 + +### 4.1 创建处理器 + +```typescript +// 文件: packages/opencode/src/session/processor.ts + +export namespace SessionProcessor { + const DOOM_LOOP_THRESHOLD = 3 + + export function create(input: { + assistantMessage: MessageV2.Assistant + sessionID: SessionID + model: Provider.Model + abort: AbortSignal + }) { + const toolcalls: Record = {} + let snapshot: string | undefined + let blocked = false + let attempt = 0 + let needsCompaction = false + // ... + } +} +``` + +`toolcalls` 字典是整个处理器的核心数据结构——它追踪每一个正在执行的工具调用,从 `pending` 到 `running` 到 `completed`/`error`。 + +### 4.2 process() — 事件驱动的内循环 + +`process()` 方法驱动了实际的 LLM 调用和响应处理: + +```typescript +// 文件: packages/opencode/src/session/processor.ts + +async process(streamInput: LLM.StreamInput) { + const shouldBreak = + (await Config.get()).experimental?.continue_loop_on_deny !== true + + while (true) { + try { + const stream = await LLM.stream(streamInput) + + for await (const value of stream.fullStream) { + input.abort.throwIfAborted() + + switch (value.type) { + case "start": + await SessionStatus.set(input.sessionID, { type: "busy" }) + break + + case "text-start": + // 创建文本部件 + currentText = { + id: PartID.ascending(), + messageID: input.assistantMessage.id, + sessionID: input.assistantMessage.sessionID, + type: "text", + text: "", + time: { start: Date.now() }, + } + await Session.updatePart(currentText) + break + + case "text-delta": + // 流式追加文本 + currentText.text += value.text + await Session.updatePartDelta({ + sessionID: currentText.sessionID, + messageID: currentText.messageID, + partID: currentText.id, + field: "text", + delta: value.text, + }) + break + + case "tool-call": + // 工具被调用 + // ... + break + + case "tool-result": + // 工具执行完毕 + // ... + break + + case "finish-step": + // 一步完成,更新 token 计数 + // ... + break + } + } + } catch (e) { + // 错误处理与重试 + } + } +} +``` + +### 4.3 流式事件完整清单 + +LLM 返回的流式事件(Stream Event)包括: + +| 事件类型 | 触发时机 | 处理方式 | +|----------|----------|----------| +| `start` | 流开始 | 设置会话状态为 `busy` | +| `reasoning-start` | 开始思考(Claude/o1) | 创建 Reasoning Part | +| `reasoning-delta` | 思考增量 | 追加文本 + 发布 delta | +| `reasoning-end` | 思考结束 | 更新时间戳 | +| `text-start` | 开始输出文本 | 创建 Text Part | +| `text-delta` | 文本增量 | 追加文本 + 发布 delta | +| `text-end` | 文本结束 | 触发 Plugin hook | +| `tool-input-start` | 开始构建工具参数 | 创建 Tool Part (pending) | +| `tool-call` | 工具调用确认 | 更新为 running + 死循环检测 | +| `tool-result` | 工具返回结果 | 更新为 completed | +| `tool-error` | 工具执行出错 | 更新为 error | +| `start-step` | 新步骤开始 | 创建文件快照 | +| `finish-step` | 步骤结束 | 更新 token、创建 patch | +| `error` | 流式错误 | 抛出异常 | + +### 4.4 文本流式更新机制 + +文本更新使用了两层机制——**持久化**和**广播**: + +```typescript +// 文件: packages/opencode/src/session/processor.ts + +case "text-delta": + if (currentText) { + // 1. 内存中追加 + currentText.text += value.text + + // 2. 通过 Bus 广播增量(TUI 实时更新) + await Session.updatePartDelta({ + sessionID: currentText.sessionID, + messageID: currentText.messageID, + partID: currentText.id, + field: "text", + delta: value.text, // 仅发送增量,不是全文 + }) + } + break + +case "text-end": + if (currentText) { + currentText.text = currentText.text.trimEnd() + // 3. 文本完成后,触发插件 hook + const output = await Plugin.trigger( + "experimental.text.complete", + { sessionID, messageID, partID: currentText.id }, + { text: currentText.text }, + ) + currentText.text = output.text + // 4. 最终持久化到数据库 + await Session.updatePart(currentText) + } + break +``` + +> 💡 `updatePartDelta` 只发布增量文本——这让 TUI 可以高效地逐字符渲染,而不需要每次重新获取全文。 + +--- + +## 五、Doom Loop 检测 + +当 LLM 陷入死循环,反复用相同参数调用同一个工具时,OpenCode 能检测到并中断: + +```typescript +// 文件: packages/opencode/src/session/processor.ts + +case "tool-call": { + const match = toolcalls[value.toolCallId] + if (match) { + // 更新工具状态为 running + const part = await Session.updatePart({ + ...match, + tool: value.toolName, + state: { + status: "running", + input: value.input, + time: { start: Date.now() }, + }, + }) + + // ===== 死循环检测 ===== + const parts = await MessageV2.parts(input.assistantMessage.id) + const lastThree = parts.slice(-DOOM_LOOP_THRESHOLD) + + if ( + lastThree.length === DOOM_LOOP_THRESHOLD && + lastThree.every( + (p) => + p.type === "tool" && + p.tool === value.toolName && + p.state.status !== "pending" && + JSON.stringify(p.state.input) === JSON.stringify(value.input), + ) + ) { + // 连续 3 次相同工具+相同参数 → 请求用户许可 + await Permission.ask({ + permission: "doom_loop", + patterns: [value.toolName], + sessionID: input.assistantMessage.sessionID, + metadata: { tool: value.toolName, input: value.input }, + always: [value.toolName], + ruleset: agent.permission, + }) + } + } + break +} +``` + +检测算法非常简洁:取最近 3 个工具调用,如果工具名和输入参数完全相同,就触发权限询问。用户可以选择: + +- **允许一次**:继续执行,但下次还会问 +- **始终允许**:加入白名单 +- **拒绝**:中断循环 + +--- + +## 六、错误处理与重试 + +流式调用可能因为多种原因失败。处理器有一套完整的重试机制: + +```typescript +// 文件: packages/opencode/src/session/processor.ts + +catch (e: any) { + const error = MessageV2.fromError(e, { + providerID: input.model.providerID, + aborted: input.abort.aborted, + }) + + if (MessageV2.ContextOverflowError.isInstance(error)) { + // 上下文溢出 → 触发压缩 + needsCompaction = true + Bus.publish(Session.Event.Error, { sessionID: input.sessionID, error }) + } else { + const retry = SessionRetry.retryable(error) + if (retry !== undefined) { + // 可重试错误 → 指数退避 + attempt++ + const delay = SessionRetry.delay(attempt, error) + await SessionStatus.set(input.sessionID, { + type: "retry", + attempt, + message: retry, + next: Date.now() + delay, + }) + await SessionRetry.sleep(delay, input.abort).catch(() => {}) + continue // 重试 + } + // 不可重试错误 → 记录并停止 + input.assistantMessage.error = error + Bus.publish(Session.Event.Error, { ... }) + } +} +``` + +错误处理流程: + +```mermaid +flowchart TD + E[捕获错误] --> F{上下文溢出?} + F -->|是| G[标记需要压缩] --> H[发布错误事件] + F -->|否| I{可重试?} + I -->|是| J[计算退避延迟] + J --> K[设置状态 retry] + K --> L[等待延迟] + L --> M[continue 重试] + I -->|否| N[记录错误到消息] + N --> O[发布错误事件] + O --> P[设置状态 idle] +``` + +--- + +## 七、LLM 调用层 + +### 7.1 StreamInput 结构 + +```typescript +// 文件: packages/opencode/src/session/llm.ts + +export type StreamInput = { + user: MessageV2.User // 用户消息 + sessionID: string // 会话 ID + model: Provider.Model // 模型定义 + agent: Agent.Info // Agent 配置 + permission?: Permission.Ruleset // 权限规则 + system: string[] // System Prompt 数组 + abort: AbortSignal // 取消信号 + messages: ModelMessage[] // 对话历史 + tools: Record // 可用工具集 + toolChoice?: "auto" | "required" | "none" +} +``` + +### 7.2 System Prompt 组装 + +System Prompt 的构建遵循严格的优先级: + +```typescript +// 文件: packages/opencode/src/session/llm.ts + +export async function stream(input: StreamInput) { + // System Prompt 分两部分(用于缓存优化) + // 第一部分:稳定内容(命中缓存) + // 第二部分:动态内容(可能变化) + + const system = input.system + + // 插件可以修改 System Prompt + const transformed = await Plugin.trigger( + "experimental.chat.system.transform", + { sessionID: input.sessionID }, + { system }, + ) +``` + +### 7.3 消息历史转换 + +不同 Provider(提供商)对消息格式有不同要求: + +```typescript +// 文件: packages/opencode/src/session/llm.ts + +// OAuth 模型:system 放在 instructions 字段 +// 普通模型:system 作为消息历史的第一条 +if (isOAuthModel) { + messages = input.messages // system 在别处传入 +} else { + messages = [ + { role: "system", content: systemPrompt }, + ...input.messages, + ] +} +``` + +### 7.4 工具准备 + +```typescript +// 文件: packages/opencode/src/session/llm.ts + +// 过滤被禁用的工具 +const filtered = Object.fromEntries( + Object.entries(input.tools).filter(([id]) => { + // 检查权限规则 + return evaluate(id, "*", input.permission).action !== "deny" + }), +) + +// 为 LiteLLM 兼容性添加空操作工具 +if (needsNoop) { + filtered["_noop"] = tool({ /* 什么都不做的工具 */ }) +} +``` + +> 💡 `_noop` 工具是一个兼容性 hack——某些 LLM 代理(如 LiteLLM)在工具列表为空时会报错,所以加一个空操作工具。 + +--- + +## 八、完整的 ReAct 循环时序 + +```mermaid +sequenceDiagram + participant User as 用户 + participant Prompt as SessionPrompt + participant Proc as SessionProcessor + participant LLM as LLM 层 + participant AI as AI Provider + participant Tool as 工具系统 + participant DB as 数据库 + + User->>Prompt: 发送消息 + Prompt->>DB: 创建 User Message + Prompt->>DB: 创建 Message Parts + Prompt->>Prompt: 进入 loop() + + loop ReAct 循环 + Prompt->>Prompt: 获取消息历史 + Prompt->>Prompt: 检查退出条件 + Prompt->>Prompt: 构建工具集 + System Prompt + Prompt->>Proc: 创建 Processor + + Proc->>LLM: stream(StreamInput) + LLM->>AI: streamText() / AI SDK + + loop 流式事件 + AI-->>Proc: text-delta + Proc->>DB: updatePartDelta + Proc-->>User: 实时显示文本 + + AI-->>Proc: tool-call + Proc->>Proc: 死循环检测 + Proc->>Tool: 执行工具 + Tool-->>Proc: tool-result + Proc->>DB: updatePart (completed) + + AI-->>Proc: finish-step + Proc->>DB: 更新 token 计数 + Proc->>DB: 创建文件 patch + end + + Proc-->>Prompt: "continue" | "stop" | "compact" + + alt stop + Prompt->>Prompt: 退出循环 + else compact + Prompt->>Prompt: 创建压缩任务 + else continue + Prompt->>Prompt: 下一轮循环 + end + end + + Prompt->>DB: prune 旧工具输出 + Prompt-->>User: 返回最终响应 +``` + +--- + +## 九、工具调用的状态机 + +每个工具调用都经历一个明确的状态机转换: + +```mermaid +stateDiagram-v2 + [*] --> pending: tool-input-start + pending --> running: tool-call + running --> completed: tool-result + running --> error: tool-error + completed --> [*] + error --> [*] + + note right of pending + 创建 Tool Part + input 为空对象 + end note + + note right of running + 解析完整参数 + 开始执行 + 检测死循环 + end note + + note right of completed + 记录 output + 记录 metadata + 记录时间戳 + end note + + note right of error + 权限被拒 → blocked + 工具异常 → error message + end note +``` + +对应的数据结构: + +```typescript +// 文件: packages/opencode/src/session/message-v2.ts + +// Pending 状态 +{ status: "pending", input: {}, raw: "" } + +// Running 状态 +{ status: "running", input: { command: "ls -la" }, time: { start: 1234567890 } } + +// Completed 状态 +{ + status: "completed", + input: { command: "ls -la" }, + output: "total 42\ndrwxr-xr-x ...", + title: "Listed directory contents", + metadata: { exitCode: 0 }, + time: { start: 1234567890, end: 1234567900 }, +} + +// Error 状态 +{ + status: "error", + input: { command: "rm -rf /" }, + error: "Permission denied", + time: { start: 1234567890, end: 1234567895 }, +} +``` + +--- + +## 十、结构化输出模式 + +当用户需要 JSON 格式的输出时,OpenCode 会注入一个特殊的 `StructuredOutput` 工具: + +```typescript +// 文件: packages/opencode/src/session/prompt.ts + +if (lastUser.format?.type === "json_schema") { + tools["StructuredOutput"] = createStructuredOutputTool({ + schema: lastUser.format.schema, + onOutput: (output) => { + structuredOutput = output + }, + }) +} +``` + +当 LLM 调用 `StructuredOutput` 工具并传入有效 JSON 时,循环立即停止并返回结构化数据。这是一种巧妙的设计——不修改 LLM 的行为,而是通过工具机制"截获"输出。 + +--- + +## 十一、步骤快照与文件 Patch + +每一步(Step)开始和结束时,OpenCode 会创建文件系统快照: + +```typescript +// 文件: packages/opencode/src/session/processor.ts + +case "start-step": + snapshot = await Snapshot.track() + await Session.updatePart({ + id: PartID.ascending(), + messageID: input.assistantMessage.id, + sessionID: input.sessionID, + snapshot, + type: "step-start", + }) + break + +case "finish-step": + // ...更新 token 和 cost... + if (snapshot) { + const patch = await Snapshot.patch(snapshot) + if (patch.files.length) { + await Session.updatePart({ + id: PartID.ascending(), + messageID: input.assistantMessage.id, + sessionID: input.sessionID, + type: "patch", + hash: patch.hash, + files: patch.files, + }) + } + snapshot = undefined + } + // 在步骤结束后触发摘要生成 + SessionSummary.summarize({ + sessionID: input.sessionID, + messageID: input.assistantMessage.parentID, + }) + break +``` + +这使得 OpenCode 能够精确追踪每一步修改了哪些文件,并在需要时回滚到之前的状态。 + +--- + +## 动手练习 + +### 练习 1:观察 ReAct 循环 + +在 TUI 中向 OpenCode 发送一个需要多步工具调用的请求,例如: + +``` +请阅读 src/index.ts 文件,然后在其中添加一个注释 +``` + +观察日志中的 `tool-call` → `tool-result` 循环。 + +### 练习 2:触发死循环检测 + +尝试构造一个让 LLM 反复执行相同操作的提示,观察 Doom Loop 检测是否触发权限询问。 + +### 练习 3:追踪流式事件 + +启用 DEBUG 日志后,观察一次完整对话产生的事件序列: + +```bash +opencode --print-logs --log-level DEBUG 2>events.log +# 发送一条消息后查看 +grep "process" events.log +``` + +--- + +## 常见问题 + +### Q: `process()` 返回 "continue" 和 "stop" 的区别是什么? + +`"continue"` 表示 LLM 执行了工具调用后还需要继续处理(`finishReason` 是 `"tool-calls"`)。`"stop"` 表示 LLM 认为任务已完成,或者发生了不可恢复的错误。`"compact"` 表示上下文溢出,需要先压缩才能继续。 + +### Q: 为什么要用 `Bus.publish` 广播 delta 而不是直接写入数据库? + +性能考虑。LLM 每秒可能产生数十个 `text-delta` 事件,如果每个都写入 SQLite 会造成 I/O 瓶颈。通过 Bus 广播(内存中的发布/订阅),TUI 可以即时收到更新。最终的完整文本在 `text-end` 时才写入数据库。 + +### Q: 如果 LLM 提供商的 API 临时不可用怎么办? + +`SessionRetry` 模块会计算指数退避(Exponential Backoff)延迟。第一次重试等待几秒,随后每次翻倍。同时会在 TUI 中显示重试状态和下次重试时间,用户可以选择等待或取消。 + +### Q: 一轮对话循环最多能调用多少次工具? + +没有硬编码的限制。循环会持续到 LLM 自己决定停止,或者上下文窗口溢出触发压缩。不过,Doom Loop 检测会在连续 3 次相同调用时介入。 + +--- + +## 小结 + +本节我们完整追踪了对话循环的每一个环节: + +1. **用户消息创建**:`SessionPrompt.prompt()` 创建 User Message 和 Parts +2. **主循环管理**:`loop()` 持续驱动,检查退出条件、处理子任务和压缩 +3. **流式处理**:`SessionProcessor.process()` 消费 LLM 流式事件 +4. **事件类型**:从 `text-delta` 到 `tool-call`,11 种事件各司其职 +5. **安全机制**:Doom Loop 检测防止 LLM 陷入死循环 +6. **错误恢复**:上下文溢出触发压缩,API 错误触发指数退避重试 +7. **快照系统**:每步开始/结束时创建快照,精确追踪文件变更 + +> ⏭️ 下一节,我们将深入 Tool 执行系统——了解 Bash、Edit、Read 等工具的具体实现。 diff --git "a/all-in-one-book/03-\345\205\263\351\224\256\350\267\257\345\276\204\350\257\246\350\247\243/03-Tool\346\211\247\350\241\214\350\257\246\350\247\243.md" "b/all-in-one-book/03-\345\205\263\351\224\256\350\267\257\345\276\204\350\257\246\350\247\243/03-Tool\346\211\247\350\241\214\350\257\246\350\247\243.md" new file mode 100644 index 000000000000..b7d2e1c076a1 --- /dev/null +++ "b/all-in-one-book/03-\345\205\263\351\224\256\350\267\257\345\276\204\350\257\246\350\247\243/03-Tool\346\211\247\350\241\214\350\257\246\350\247\243.md" @@ -0,0 +1,853 @@ +# 第三节 Tool 执行详解 + +📍 **你在这里** +> 在第01章全景视野中,我们用一张大图鸟瞰了整个 OpenCode。现在我们沿着 **Tool 注册 → 初始化 → 选择 → 权限检查 → 执行 → 结果格式化** 这条线路,深入探索每一步的实现细节。 + +--- + +## 学习目标 + +读完本节,你将能够: + +1. 理解 Tool 的**定义、注册和初始化**三阶段生命周期 +2. 掌握权限系统(Permission System)的**规则评估算法** +3. 深入了解 **Bash Tool** 的 AST 分析和安全沙箱机制 +4. 理解 **Edit Tool** 的 9 种替换策略(Replacement Strategy) +5. 了解 **Read Tool** 的文件类型检测和分页机制 + +--- + +## 一、概念解释:Tool 是什么? + +在 OpenCode 中,Tool 是 LLM 与现实世界交互的**唯一通道**。LLM 本身不能读文件、不能执行命令——它只能请求调用一个 Tool,并等待结果。 + +``` +LLM: "我想读取 src/index.ts 的内容" + → 调用 ReadTool({ filePath: "src/index.ts" }) + → OpenCode 执行读取,检查权限 + → 返回文件内容给 LLM +``` + +每个 Tool 都有严格的 **Zod Schema** 定义输入参数,并经过**权限检查**才能执行。 + +--- + +## 二、Tool 定义:`Tool.define()` + +### 2.1 核心接口 + +```typescript +// 文件: packages/opencode/src/tool/tool.ts + +export namespace Tool { + export type Context = { + sessionID: SessionID + messageID: MessageID + agent: string + abort: AbortSignal + callID?: string + messages: MessageV2.WithParts[] + metadata(input: { title?: string; metadata?: M }): void + ask(input: Omit): Promise + } + + export interface Info { + id: string + init: (ctx?: InitContext) => Promise<{ + description: string + parameters: Parameters + execute( + args: z.infer, + ctx: Context, + ): Promise<{ + title: string + metadata: M + output: string + attachments?: Omit[] + }> + formatValidationError?(error: z.ZodError): string + }> + } +} +``` + +关键设计点: + +| 字段 | 作用 | +|------|------| +| `init()` | 惰性初始化——只在需要时加载描述和参数 Schema | +| `ctx.metadata()` | 流式更新元数据——工具运行中就能在 TUI 显示进度 | +| `ctx.ask()` | 权限请求——向用户申请执行许可 | +| `attachments` | 文件附件——如图片、PDF 的 base64 内容 | + +### 2.2 `define()` 的包装逻辑 + +`Tool.define()` 不只是创建工具——它还包装了**参数验证**和**输出截断**: + +```typescript +// 文件: packages/opencode/src/tool/tool.ts + +export function define( + id: string, + init: Info["init"] | Awaited["init"]>>, +): Info { + return { + id, + init: async (initCtx) => { + const toolInfo = init instanceof Function ? await init(initCtx) : init + const execute = toolInfo.execute + + toolInfo.execute = async (args, ctx) => { + // 1. 参数验证(Zod Schema) + try { + toolInfo.parameters.parse(args) + } catch (error) { + if (error instanceof z.ZodError && toolInfo.formatValidationError) { + throw new Error(toolInfo.formatValidationError(error), { cause: error }) + } + throw new Error( + `The ${id} tool was called with invalid arguments: ${error}.` + + `\nPlease rewrite the input so it satisfies the expected schema.`, + { cause: error }, + ) + } + + // 2. 执行工具 + const result = await execute(args, ctx) + + // 3. 输出截断(除非工具已自行处理) + if (result.metadata.truncated !== undefined) { + return result + } + const truncated = await Truncate.output(result.output, {}, initCtx?.agent) + return { + ...result, + output: truncated.content, + metadata: { + ...result.metadata, + truncated: truncated.truncated, + outputPath: truncated.truncated ? truncated.outputPath : undefined, + }, + } + } + return toolInfo + }, + } +} +``` + +> 💡 如果工具的返回值中 `metadata.truncated` 已经有值(如 Bash Tool 自行截断了输出),`define()` 就不会再次截断。这避免了双重截断的问题。 + +--- + +## 三、Tool 注册:Registry + +### 3.1 内置工具列表 + +```typescript +// 文件: packages/opencode/src/tool/registry.ts + +const BUILTIN_TOOLS = [ + InvalidTool, // 无效工具调用的 fallback + QuestionTool, // 向用户提问 + BashTool, // 执行 Shell 命令 + ReadTool, // 读取文件/目录 + GlobTool, // 文件模式匹配 + GrepTool, // 内容搜索 + EditTool, // 编辑文件 + WriteTool, // 创建新文件 + TaskTool, // 委派子任务 + WebFetchTool, // 获取网页内容 + TodoWriteTool, // 管理待办事项 + WebSearchTool, // 网络搜索 + CodeSearchTool, // 代码语义搜索 + SkillTool, // 自定义技能 + ApplyPatchTool, // 应用 Git Patch + LspTool, // LSP 诊断(实验性) + BatchTool, // 批量工具调用(实验性) + PlanExitTool, // Plan 模式退出(实验性) +] +``` + +### 3.2 基于模型的工具过滤 + +不同模型会看到不同的工具集: + +```typescript +// 文件: packages/opencode/src/tool/registry.ts + +// GPT 模型使用 ApplyPatchTool 代替 EditTool/WriteTool +if (isGptModel) { + tools = tools.filter(t => t.id !== "edit" && t.id !== "write") +} else { + tools = tools.filter(t => t.id !== "apply_patch") +} + +// WebSearch/CodeSearch 仅在特定条件下启用 +if (!isOpenCodeProvider && !Flag.OPENCODE_ENABLE_EXA) { + tools = tools.filter(t => !["websearch", "codesearch"].includes(t.id)) +} +``` + +### 3.3 插件工具加载 + +```typescript +// 文件: packages/opencode/src/tool/registry.ts + +function fromPlugin(id: string, def: ToolDefinition): Tool.Info { + return { + id, + init: async (initCtx) => ({ + parameters: z.object(def.args), + description: def.description, + execute: async (args, toolCtx) => { + const result = await def.execute(args, pluginCtx) + const out = await Truncate.output(result, {}, initCtx?.agent) + return { + title: "", + output: out.truncated ? out.content : result, + metadata: { + truncated: out.truncated, + outputPath: out.truncated ? out.outputPath : undefined, + }, + } + }, + }), + } +} +``` + +--- + +## 四、权限系统 + +### 4.1 权限评估算法 + +权限评估的核心在一个仅有 15 行的文件中: + +```typescript +// 文件: packages/opencode/src/permission/evaluate.ts + +import { Wildcard } from "@/util/wildcard" + +type Rule = { + permission: string + pattern: string + action: "allow" | "deny" | "ask" +} + +export function evaluate( + permission: string, + pattern: string, + ...rulesets: Rule[][], +): Rule { + const rules = rulesets.flat() + const match = rules.findLast( + (rule) => + Wildcard.match(permission, rule.permission) && + Wildcard.match(pattern, rule.pattern), + ) + return match ?? { action: "ask", permission, pattern: "*" } +} +``` + +算法非常简洁: + +1. 将所有规则集扁平化为一个数组 +2. **从后向前查找**第一个匹配的规则(最后添加的规则优先级最高) +3. 使用通配符匹配 `permission` 和 `pattern` +4. 没有匹配规则时,默认 `"ask"`(询问用户) + +### 4.2 权限请求流程 + +当工具需要执行敏感操作时: + +```typescript +// 简化的权限请求流程 + +// 1. 工具调用 ctx.ask() +await ctx.ask({ + permission: "edit", + patterns: ["src/index.ts"], + always: ["*"], + metadata: { filepath, diff }, +}) + +// 2. 评估规则 +for (const pattern of request.patterns) { + const rule = evaluate(request.permission, pattern, ruleset, approved) + if (rule.action === "deny") throw new DeniedError() + if (rule.action === "allow") continue + needsAsk = true +} + +// 3. 如果需要询问,创建 Deferred 并等待 +if (needsAsk) { + const deferred = Deferred.make() + pending.set(id, { info, deferred }) + Bus.publish(Event.Asked, info) // 通知 TUI 显示权限弹窗 + return await deferred // 阻塞直到用户响应 +} +``` + +### 4.3 权限级联效应 + +当用户对一个权限做出回应时,会触发级联效应: + +```mermaid +flowchart TD + A[用户回应权限请求] --> B{回应类型} + B -->|reject 拒绝| C[当前请求失败] + C --> D[同会话所有挂起请求也失败] + B -->|once 允许一次| E[当前请求通过] + B -->|always 始终允许| F[当前请求通过] + F --> G[添加到 approved 规则集] + G --> H[自动审批匹配的挂起请求] +``` + +这个设计非常巧妙——当用户选择"始终允许编辑 `*`"时,队列中等待的其他编辑请求也会自动通过,无需逐个确认。 + +--- + +## 五、Bash Tool 详解 + +Bash Tool 是最复杂的内置工具,它使用 **Tree-Sitter** 解析 Shell 语法来进行安全分析。 + +### 5.1 参数定义 + +```typescript +// 文件: packages/opencode/src/tool/bash.ts + +parameters: z.object({ + command: z.string().describe("The command to execute"), + timeout: z.number().optional().describe("Timeout in milliseconds"), + workdir: z.string().optional().describe("Working directory"), + description: z.string().describe("Description of what command does"), +}) +``` + +### 5.2 命令 AST 分析 + +Bash Tool 不会盲目执行命令——它先用 Tree-Sitter 解析命令的 AST(抽象语法树)来识别潜在的安全问题: + +```typescript +// 文件: packages/opencode/src/tool/bash.ts + +// 1. 解析命令语法 +const ast = parseBashCommand(params.command) + +// 2. 分析文件操作 +// 识别: cd, rm, cp, mv, mkdir, touch, chmod, chown, cat 等 +const ops = analyzeFileOperations(ast) + +// 3. 检测外部目录访问 +for (const op of ops) { + if (!Instance.containsPath(op.path)) { + await ctx.ask({ + permission: "external_directory", + patterns: [op.path + "/*"], + always: [op.path + "/*"], + metadata: {}, + }) + } +} + +// 4. 请求 Bash 执行权限 +await ctx.ask({ + permission: "bash", + patterns: [fullCommandText], + always: [BashArity.prefix(command).join(" ") + " *"], + metadata: {}, +}) +``` + +> 💡 `BashArity.prefix()` 提取命令的前缀部分用于"始终允许"规则。例如 `git commit -m "fix"` 的前缀是 `git commit`,这样用户选择"始终允许"时,所有 `git commit *` 命令都会被自动批准。 + +### 5.3 进程生成与管理 + +```typescript +// 文件: packages/opencode/src/tool/bash.ts + +const proc = spawn(params.command, { + shell: Shell.acceptable(), // sh (Unix) 或 cmd.exe (Windows) + cwd: params.workdir || Instance.directory, + env: { ...process.env, ...shellEnv }, + stdio: ["ignore", "pipe", "pipe"], + detached: process.platform !== "win32", +}) +``` + +### 5.4 超时与中止处理 + +```typescript +// 文件: packages/opencode/src/tool/bash.ts + +// 默认超时 2 分钟 +const timeout = params.timeout ?? DEFAULT_TIMEOUT_MS + +// 超时处理 +const timeoutHandler = setTimeout(() => { + Shell.killTree(proc.pid) // 杀死整个进程树 +}, timeout) + +// 中止信号处理 +ctx.abort.addEventListener("abort", () => { + Shell.killTree(proc.pid) +}) +``` + +### 5.5 输出流处理 + +```typescript +// 文件: packages/opencode/src/tool/bash.ts + +// stdout 和 stderr 交织收集 +let output = "" +const MAX_METADATA = 30 * 1024 // 30KB 元数据截断 + +proc.stdout.on("data", (data) => { + output += data.toString() + // 流式更新元数据(TUI 实时显示输出) + ctx.metadata({ + title: params.description, + metadata: { + output: output.slice(-MAX_METADATA), + exitCode: undefined, + }, + }) +}) + +proc.stderr.on("data", (data) => { + output += data.toString() + ctx.metadata({ ... }) +}) +``` + +### 5.6 完整执行流程 + +```mermaid +flowchart TD + A[接收 command 参数] --> B[Tree-Sitter 解析 AST] + B --> C[分析文件操作] + C --> D{访问外部目录?} + D -->|是| E[请求 external_directory 权限] + D -->|否| F[请求 bash 执行权限] + E --> F + F --> G{权限通过?} + G -->|拒绝| H[返回 PermissionError] + G -->|通过| I[spawn 子进程] + I --> J[设置超时计时器] + J --> K[流式收集 stdout/stderr] + K --> L[实时更新 metadata] + L --> M{进程结束?} + M -->|超时| N[Shell.killTree 杀死进程树] + M -->|中止| N + M -->|正常退出| O[清除超时计时器] + N --> P[附加超时/中止信息到输出] + O --> P + P --> Q[返回 output + exitCode] +``` + +--- + +## 六、Edit Tool 详解 + +Edit Tool 采用了一种极其健壮的**多策略替换引擎**来处理 LLM 输出中可能存在的格式差异。 + +### 6.1 参数定义 + +```typescript +// 文件: packages/opencode/src/tool/edit.ts + +parameters: z.object({ + filePath: z.string().describe("Absolute path to file"), + oldString: z.string().describe("Text to replace"), + newString: z.string().describe("Replacement text"), + replaceAll: z.boolean().optional().describe("Replace all occurrences"), +}) +``` + +### 6.2 九种替换策略 + +当 LLM 生成的 `oldString` 与文件中的实际内容存在细微差异时(缩进不同、空白字符差异等),Edit Tool 会依次尝试 9 种策略: + +| 顺序 | 策略名 | 匹配方式 | +|------|--------|----------| +| 1 | **SimpleReplacer** | 精确字符串匹配 | +| 2 | **LineTrimmedReplacer** | 每行去除首尾空白后匹配 | +| 3 | **BlockAnchorReplacer** | 用首行和末行作为锚点,Levenshtein 距离模糊匹配 | +| 4 | **WhitespaceNormalizedReplacer** | 将所有连续空白规范化为单个空格 | +| 5 | **IndentationFlexibleReplacer** | 忽略缩进差异 | +| 6 | **EscapeNormalizedReplacer** | 处理转义序列差异(`\n` vs 实际换行) | +| 7 | **MultiOccurrenceReplacer** | 查找所有匹配(用于 `replaceAll`) | +| 8 | **TrimmedBoundaryReplacer** | 首尾内容被截断时的匹配 | +| 9 | **ContextAwareReplacer** | 使用上下文锚点和相似度检查 | + +### 6.3 执行流程 + +```typescript +// 文件: packages/opencode/src/tool/edit.ts(简化) + +async execute(args, ctx) { + // 1. 权限检查——展示 diff 给用户 + const diff = createTwoFilesPatch(args.filePath, args.filePath, args.oldString, args.newString) + await ctx.ask({ + permission: "edit", + patterns: [path.relative(Instance.worktree, args.filePath)], + always: ["*"], + metadata: { filepath: args.filePath, diff }, + }) + + // 2. 读取文件内容 + const content = await Bun.file(args.filePath).text() + + // 3. 检测行尾风格 + const lineEnding = detectLineEnding(content) + + // 4. 依次尝试替换策略 + let result: string | undefined + for (const strategy of strategies) { + result = strategy.replace(content, args.oldString, args.newString) + if (result !== undefined) break + } + + if (result === undefined) { + throw new Error("oldString not found in file") + } + + // 5. 写入文件 + await Bun.write(args.filePath, result) + + // 6. 自动格式化 + await Format.run(args.filePath) + + // 7. LSP 诊断 + const diagnostics = await LSP.diagnostics(args.filePath) + if (diagnostics.length > 0) { + output += formatDiagnostics(diagnostics) + } + + return { title: "Edit applied", output, metadata: {} } +} +``` + +### 6.4 LSP 集成 + +编辑完成后,Edit Tool 会自动运行 LSP 诊断并报告错误: + +``` +Edit applied successfully. + +LSP errors detected in this file, please fix: + +Error at line 42: Property 'foo' does not exist on type 'Bar'. +Error at line 58: Expected 2 arguments, but got 1. + +``` + +这让 LLM 能够**立即发现并修复**编辑引入的类型错误或语法错误。 + +--- + +## 七、Read Tool 详解 + +### 7.1 参数定义 + +```typescript +// 文件: packages/opencode/src/tool/read.ts + +parameters: z.object({ + filePath: z.string().describe("Absolute path to file or directory"), + offset: z.coerce.number().optional().describe("Line number to start (1-indexed)"), + limit: z.coerce.number().optional().describe("Max lines to read (default 2000)"), +}) +``` + +### 7.2 文件类型检测 + +Read Tool 能智能处理不同类型的文件: + +```mermaid +flowchart TD + A[接收 filePath] --> B{是目录?} + B -->|是| C[列出目录内容 + 分页] + B -->|否| D{是图片/PDF?} + D -->|是| E[返回 base64 附件] + D -->|否| F{是二进制文件?} + F -->|是| G[返回错误: 二进制文件不可读] + F -->|否| H[逐行读取 + 分页] +``` + +### 7.3 文本文件读取 + +```typescript +// 文件: packages/opencode/src/tool/read.ts(简化) + +// 读取限制 +const DEFAULT_LIMIT = 2000 // 默认行数 +const MAX_LINE_LENGTH = 2000 // 最大行长 +const MAX_OUTPUT = 50 * 1024 // 50KB 最大输出 + +// 逐行读取 +const lines: string[] = [] +let lineNum = 0 + +for await (const line of readline.createInterface({ input: stream })) { + lineNum++ + if (lineNum < offset) continue + if (lines.length >= limit) break + + // 截断超长行 + const display = line.length > MAX_LINE_LENGTH + ? line.slice(0, MAX_LINE_LENGTH) + "... (truncated)" + : line + + lines.push(`${lineNum}: ${display}`) +} +``` + +### 7.4 输出格式 + +```xml +/home/user/project/src/index.ts +file + +1: import { createApp } from "./app" +2: import { Config } from "./config" +3: +4: const app = createApp() +5: app.listen(3000) +... +(Showing lines 1-2000 of 3456. Use offset=2001 to continue.) + +``` + +### 7.5 二进制检测 + +```typescript +// 文件: packages/opencode/src/tool/read.ts + +// 检查前 8KB 内容中非打印字符的比例 +// 超过 30% 判定为二进制文件 +const sample = await file.slice(0, 8192).arrayBuffer() +const bytes = new Uint8Array(sample) +let nonPrintable = 0 +for (const byte of bytes) { + if (byte < 32 && byte !== 9 && byte !== 10 && byte !== 13) { + nonPrintable++ + } +} +const isBinary = nonPrintable / bytes.length > 0.3 +``` + +--- + +## 八、权限检查的实际场景 + +### 8.1 不同工具的权限类型 + +| 工具 | 权限类型 | 模式示例 | +|------|---------|---------| +| Bash | `bash` | `git commit *`, `npm install *` | +| Bash | `external_directory` | `/etc/*`, `~/../other-project/*` | +| Edit | `edit` | `src/index.ts`, `*.config.js` | +| Read | `read` | `src/index.ts`, `*` | +| Write | `write` | `src/new-file.ts` | + +### 8.2 规则匹配示例 + +假设有以下规则: + +```json +[ + { "permission": "read", "pattern": "*", "action": "allow" }, + { "permission": "edit", "pattern": "src/*", "action": "allow" }, + { "permission": "edit", "pattern": "*.lock", "action": "deny" }, + { "permission": "bash", "pattern": "git *", "action": "allow" } +] +``` + +评估结果: + +| 请求 | 匹配规则 | 结果 | +|------|---------|------| +| `read`, `src/index.ts` | `read` + `*` → allow | ✅ 通过 | +| `edit`, `src/app.ts` | `edit` + `src/*` → allow | ✅ 通过 | +| `edit`, `package-lock.json` | `edit` + `*.lock` → deny | ❌ 拒绝 | +| `bash`, `git status` | `bash` + `git *` → allow | ✅ 通过 | +| `bash`, `rm -rf /` | 无匹配 → ask | ❓ 询问 | + +--- + +## 九、完整的 Tool 执行时序 + +```mermaid +sequenceDiagram + participant LLM as LLM + participant Proc as Processor + participant Tool as Tool.define 包装器 + participant Impl as 工具实现 (Bash/Edit/Read) + participant Perm as 权限系统 + participant User as 用户 + + LLM->>Proc: tool-call { toolName, input } + Proc->>Proc: 死循环检测 + Proc->>Tool: execute(args, ctx) + + Tool->>Tool: Zod Schema 验证参数 + alt 参数无效 + Tool-->>Proc: Error: invalid arguments + Proc->>LLM: tool-error + end + + Tool->>Impl: execute(validatedArgs, ctx) + Impl->>Perm: ctx.ask({ permission, patterns }) + + Perm->>Perm: evaluate(permission, pattern, rulesets) + alt action = "allow" + Perm-->>Impl: 通过 + else action = "deny" + Perm-->>Impl: DeniedError + Impl-->>Tool: throw + Tool-->>Proc: tool-error + else action = "ask" + Perm->>User: 显示权限弹窗 + User-->>Perm: once | always | reject + alt reject + Perm-->>Impl: RejectedError + Impl-->>Tool: throw + Tool-->>Proc: tool-error + else once/always + Perm-->>Impl: 通过 + end + end + + Impl->>Impl: 执行具体操作 + Impl->>Impl: ctx.metadata() 流式更新 + Impl-->>Tool: { title, output, metadata } + + Tool->>Tool: Truncate.output() 截断 + + Tool-->>Proc: tool-result + Proc->>Proc: 更新 ToolPart (completed) + Proc->>LLM: 结果反馈 +``` + +--- + +## 十、其他内置工具简介 + +### 10.1 GlobTool — 文件模式匹配 + +```typescript +// 参数: { pattern: "**/*.ts", path?: "/project/src" } +// 输出: 匹配的文件路径列表 +``` + +### 10.2 GrepTool — 内容搜索 + +```typescript +// 参数: { pattern: "TODO|FIXME", path?: "src/", include?: "*.ts" } +// 输出: 匹配行及上下文 +``` + +### 10.3 WriteTool — 创建新文件 + +```typescript +// 参数: { filePath: "/abs/path/new.ts", content: "..." } +// 需要 "write" 权限 +// 文件已存在时报错 +``` + +### 10.4 TaskTool — 子任务委派 + +```typescript +// 参数: { agent: "explore", prompt: "分析这段代码的复杂度" } +// 创建子会话,委派给指定 Agent +// 结果汇总后返回给主会话 +``` + +### 10.5 ApplyPatchTool — Git Patch + +```typescript +// 参数: { patch: "--- a/file\n+++ b/file\n@@ ..." } +// GPT 模型专用——替代 Edit/Write +// 支持标准 unified diff 格式 +``` + +--- + +## 动手练习 + +### 练习 1:观察权限规则 + +在 `opencode.json` 中配置权限规则,然后观察不同操作的权限行为: + +```jsonc +{ + "permission": [ + { "permission": "read", "pattern": "*", "action": "allow" }, + { "permission": "edit", "pattern": "*.test.ts", "action": "deny" } + ] +} +``` + +### 练习 2:追踪 Tool 执行 + +用 DEBUG 日志观察一次 Edit Tool 的执行过程: + +```bash +opencode --print-logs --log-level DEBUG 2>tool.log +# 发送编辑请求后查看 +grep -E "(tool-call|permission|edit)" tool.log +``` + +### 练习 3:创建自定义 Tool + +在 `.opencode/tools/` 目录下创建一个简单的自定义工具: + +```typescript +// .opencode/tools/hello.ts +export default { + description: "Say hello", + args: { name: z.string() }, + async execute({ name }) { + return `Hello, ${name}!` + }, +} +``` + +--- + +## 常见问题 + +### Q: 为什么 Edit Tool 需要 9 种替换策略? + +因为 LLM 生成的代码片段经常与实际文件有细微差异——缩进用了 tab 而不是空格、多了一个空行、转义字符不同等。9 种策略从精确到模糊依次尝试,极大地提高了编辑的成功率。 + +### Q: Bash Tool 能执行任意命令吗? + +技术上可以,但每次执行都需要经过权限系统。首次执行时用户必须确认,之后如果选择了"始终允许",匹配前缀的命令会自动通过。关键命令如 `rm` 通常需要逐次确认。 + +### Q: 工具的输出截断阈值是多少? + +默认约 **50KB**。超过此大小的输出会被截断,截断的完整内容会保存到一个临时文件中,LLM 可以通过 `outputPath` 元数据知道完整内容的位置。 + +### Q: 权限规则存储在哪里? + +权限规则来自三个来源:1) `opencode.json` 配置文件中的静态规则;2) Agent 定义中的默认规则;3) 用户在会话中选择"始终允许"后动态添加的规则(存储在数据库中)。 + +--- + +## 小结 + +本节我们深入探索了 Tool 系统的每一个环节: + +1. **Tool.define()**:自动包装参数验证和输出截断 +2. **Registry**:管理 17+ 内置工具,支持基于模型的过滤和插件扩展 +3. **权限系统**:15 行核心算法,支持通配符匹配、级联审批和持久化规则 +4. **Bash Tool**:Tree-Sitter AST 分析 → 安全检查 → 进程管理 → 超时保护 +5. **Edit Tool**:9 种替换策略 → 自动格式化 → LSP 诊断反馈 +6. **Read Tool**:文件类型检测 → 分页读取 → 二进制保护 + +> ⏭️ 下一节,我们将深入 Session 管理——了解会话的创建、消息持久化、上下文压缩和摘要生成。 diff --git "a/all-in-one-book/03-\345\205\263\351\224\256\350\267\257\345\276\204\350\257\246\350\247\243/04-Session\347\256\241\347\220\206\350\257\246\350\247\243.md" "b/all-in-one-book/03-\345\205\263\351\224\256\350\267\257\345\276\204\350\257\246\350\247\243/04-Session\347\256\241\347\220\206\350\257\246\350\247\243.md" new file mode 100644 index 000000000000..95dfc33dc774 --- /dev/null +++ "b/all-in-one-book/03-\345\205\263\351\224\256\350\267\257\345\276\204\350\257\246\350\247\243/04-Session\347\256\241\347\220\206\350\257\246\350\247\243.md" @@ -0,0 +1,862 @@ +# 第四节 Session 管理详解 + +📍 **你在这里** +> 在第01章全景视野中,我们用一张大图鸟瞰了整个 OpenCode。现在我们沿着 **Session 创建 → 消息写入 → 持久化 → 上下文压缩 → 摘要生成 → 会话恢复** 这条线路,深入探索每一步的实现细节。 + +--- + +## 学习目标 + +读完本节,你将能够: + +1. 理解 Session 和 Message 的**数据库 Schema** 设计 +2. 掌握消息持久化的**事件溯源**(Event Sourcing)模式 +3. 了解上下文压缩(Compaction)的**触发条件和执行策略** +4. 理解 Prune(修剪)机制如何在不丢失结构的前提下减少上下文 +5. 掌握摘要生成(Summary)和 Diff 快照的工作原理 + +--- + +## 一、概念解释:Session 的数据模型 + +一个 Session(会话)包含一系列交替出现的 User Message(用户消息)和 Assistant Message(助手消息)。每条消息又包含多个 Part(部件),如文本、工具调用、文件附件等。 + +``` +Session +├── User Message #1 +│ ├── TextPart: "请读取 index.ts" +│ └── FilePart: screenshot.png +├── Assistant Message #1 +│ ├── TextPart: "好的,让我读取..." +│ ├── ToolPart: read({ filePath: "index.ts" }) → completed +│ ├── TextPart: "文件内容如下..." +│ ├── StepStartPart: { snapshot: "abc123" } +│ └── StepFinishPart: { tokens: {...}, cost: 0.003 } +├── User Message #2 +│ └── TextPart: "请修改第10行" +└── Assistant Message #2 + ├── ToolPart: edit({ filePath: "index.ts", ... }) → completed + ├── PatchPart: { hash: "def456", files: ["index.ts"] } + └── TextPart: "已完成修改" +``` + +--- + +## 二、数据库 Schema + +### 2.1 Session 表 + +```typescript +// 文件: packages/opencode/src/session/index.ts + +export type Info = { + id: SessionID + slug: string // URL 友好的短标识 + projectID: ProjectID + workspaceID?: WorkspaceID + directory: string // 工作目录 + parentID?: SessionID // 父会话(fork 场景) + title: string + version: string + summary?: { + additions: number // 新增行数 + deletions: number // 删除行数 + files: number // 变更文件数 + diffs?: FileDiff[] // 详细 diff + } + share?: { url: string } // 分享链接 + revert?: { // 回滚信息 + messageID: MessageID + partID?: PartID + snapshot?: string + diff?: string + } + permission?: Permission.Ruleset // 会话级权限规则 + time: { + created: number + updated: number + compacting?: number // 正在压缩中 + archived?: number // 归档时间 + } +} +``` + +对应的数据库列: + +```sql +CREATE TABLE session ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL, + workspace_id TEXT, + directory TEXT NOT NULL, + parent_id TEXT, + title TEXT NOT NULL, + version TEXT NOT NULL, + slug TEXT NOT NULL, + share_url TEXT, + permission TEXT, -- JSON + summary_additions INTEGER, + summary_deletions INTEGER, + summary_files INTEGER, + summary_diffs TEXT, -- JSON + time_created INTEGER NOT NULL, + time_updated INTEGER NOT NULL, + time_compacting INTEGER, + time_archived INTEGER, + revert TEXT -- JSON +); +``` + +### 2.2 Message 表 + +```typescript +// 文件: packages/opencode/src/session/message-v2.ts + +// User Message +export type User = { + id: MessageID + sessionID: SessionID + role: "user" + time: { created: number } + format?: { + type: "text" | "json_schema" + schema?: Record + } + agent: string + model: { providerID: ProviderID; modelID: ModelID } + system?: string // 自定义 System Prompt + variant?: string +} + +// Assistant Message +export type Assistant = { + id: MessageID + sessionID: SessionID + role: "assistant" + parentID: MessageID // 关联的用户消息 + time: { created: number; completed?: number } + error?: ErrorObject + agent: string + modelID: ModelID + providerID: ProviderID + summary?: boolean // 压缩消息标记 + cost: number + tokens: { + total?: number + input: number + output: number + reasoning: number + cache: { read: number; write: number } + } + structured?: any // JSON Schema 输出 + finish?: string // 结束原因 +} +``` + +```sql +CREATE TABLE message ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL REFERENCES session(id), + data TEXT NOT NULL, -- JSON: 完整消息对象 + time_created INTEGER NOT NULL +); +``` + +### 2.3 Part 表 + +```sql +CREATE TABLE part ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL REFERENCES session(id), + message_id TEXT NOT NULL REFERENCES message(id), + data TEXT NOT NULL -- JSON: 完整部件对象 +); +``` + +### 2.4 Part 类型一览 + +```typescript +// 文件: packages/opencode/src/session/message-v2.ts + +// 所有 Part 类型 +type Part = + | TextPart // LLM 文本输出 + | ReasoningPart // 扩展思考(Claude, o1) + | ToolPart // 工具调用及结果 + | FilePart // 文件附件(图片、PDF) + | StepStartPart // 步骤开始快照 + | StepFinishPart // 步骤结束(token 计数) + | PatchPart // Git 风格 diff + | CompactionPart // 压缩标记 + | SubtaskPart // 子任务执行 + | AgentPart // Agent 引用 (@agent) + | SnapshotPart // 文件系统快照 +``` + +--- + +## 三、消息持久化:事件溯源模式 + +OpenCode 使用**事件溯源**(Event Sourcing)模式来持久化消息。每次更新都通过 `SyncEvent` 发布,然后由同步处理器写入数据库。 + +### 3.1 更新消息 + +```typescript +// 文件: packages/opencode/src/session/index.ts(简化) + +export async function updateMessage(msg: MessageV2.User | MessageV2.Assistant) { + // 通过事件溯源写入 + SyncEvent.run(MessageV2.Event.Updated, { + sessionID: msg.sessionID, + info: msg, + }) +} +``` + +### 3.2 更新 Part + +```typescript +// 文件: packages/opencode/src/session/index.ts(简化) + +export async function updatePart(part: MessageV2.Part) { + SyncEvent.run(MessageV2.Event.PartUpdated, { + sessionID: part.sessionID, + messageID: part.messageID, + info: part, + }) + return part +} +``` + +### 3.3 流式增量更新 + +对于流式文本输出,使用更轻量的增量更新: + +```typescript +// 文件: packages/opencode/src/session/index.ts(简化) + +export async function updatePartDelta(delta: { + sessionID: SessionID + messageID: MessageID + partID: PartID + field: string + delta: string +}) { + // 只通过 Bus 广播,不写入数据库 + Bus.publish(MessageV2.Event.PartDelta, delta) +} +``` + +> 💡 **关键区分**:`updatePart()` 触发数据库写入(持久化),`updatePartDelta()` 只触发 Bus 广播(内存通知)。这避免了每个 token 都写入数据库的 I/O 开销。 + +### 3.4 事件流架构 + +```mermaid +flowchart LR + A[Processor] -->|updateMessage| B[SyncEvent] + A -->|updatePart| B + A -->|updatePartDelta| C[Bus 广播] + B -->|写入| D[(SQLite)] + B -->|发布| E[SSE 推送] + C -->|发布| F[TUI 实时更新] + D -->|查询| G[Session.messages] +``` + +--- + +## 四、Session 生命周期 + +### 4.1 创建会话 + +```typescript +// 文件: packages/opencode/src/session/index.ts(简化) + +export async function create() { + return createNext({}) +} + +export async function createNext(input: { + parentID?: SessionID + title?: string + permission?: Permission.Ruleset +}) { + const id = SessionID.ascending() + const session: Info = { + id, + slug: generateSlug(), + projectID: Instance.current.project.id, + directory: Instance.directory, + title: input.title ?? "", + version: Installation.VERSION, + time: { + created: Date.now(), + updated: Date.now(), + }, + // ... + } + + // 写入数据库 + Database.transaction((tx) => { + tx.insert(SessionTable).values(toRow(session)).run() + }) + + // 发布创建事件 + Bus.publish(Session.Event.Created, { session }) + return session +} +``` + +### 4.2 Fork 会话 + +Fork(分叉)允许从历史某一点创建新的分支会话: + +```typescript +// 文件: packages/opencode/src/session/index.ts(简化) + +export async function fork(sessionID: SessionID, messageID?: MessageID) { + // 1. 创建新会话 + const forked = await createNext({ + parentID: sessionID, + title: original.title + " (fork)", + permission: original.permission, + }) + + // 2. 复制消息到分叉点 + const messages = await Session.messages({ sessionID }) + for (const msg of messages) { + if (messageID && msg.info.id > messageID) break + // 复制消息和部件到新会话 + await copyMessage(msg, forked.id) + } + + return forked +} +``` + +### 4.3 查询消息 + +```typescript +// 文件: packages/opencode/src/session/index.ts(简化) + +export async function messages(input: { sessionID: SessionID; limit?: number }) { + // 从数据库查询 + const rows = Database.Client() + .select() + .from(MessageTable) + .where(eq(MessageTable.session_id, input.sessionID)) + .orderBy(asc(MessageTable.time_created)) + .limit(input.limit ?? Infinity) + .all() + + // 关联查询 Parts + const parts = Database.Client() + .select() + .from(PartTable) + .where(eq(PartTable.session_id, input.sessionID)) + .all() + + // 组装 WithParts 结构 + return rows.map(row => ({ + info: JSON.parse(row.data), + parts: parts.filter(p => p.message_id === row.id).map(p => JSON.parse(p.data)), + })) +} +``` + +--- + +## 五、上下文压缩(Compaction) + +当对话历史超过模型的上下文窗口时,OpenCode 会自动触发压缩。 + +### 5.1 溢出检测 + +```typescript +// 文件: packages/opencode/src/session/compaction.ts + +const COMPACTION_BUFFER = 20_000 + +export async function isOverflow(input: { + tokens: MessageV2.Assistant["tokens"] + model: Provider.Model +}) { + const config = await Config.get() + if (config.compaction?.auto === false) return false + + const context = input.model.limit.context + if (context === 0) return false + + const count = + input.tokens.total || + input.tokens.input + + input.tokens.output + + input.tokens.cache.read + + input.tokens.cache.write + + const reserved = config.compaction?.reserved ?? + Math.min(COMPACTION_BUFFER, ProviderTransform.maxOutputTokens(input.model)) + const usable = input.model.limit.input + ? input.model.limit.input - reserved + : context - ProviderTransform.maxOutputTokens(input.model) + + return count >= usable +} +``` + +核心公式: + +``` +可用上下文 = min(input_limit, context_limit - max_output) - reserved_buffer +如果 实际使用 token ≥ 可用上下文 → 触发压缩 +``` + +### 5.2 Prune(修剪)机制 + +在触发完整压缩之前,OpenCode 先尝试"修剪"——清除旧工具调用的输出内容: + +```typescript +// 文件: packages/opencode/src/session/compaction.ts + +export const PRUNE_MINIMUM = 20_000 +export const PRUNE_PROTECT = 40_000 + +const PRUNE_PROTECTED_TOOLS = ["skill"] + +export async function prune(input: { sessionID: SessionID }) { + const config = await Config.get() + if (config.compaction?.prune === false) return + + const msgs = await Session.messages({ sessionID: input.sessionID }) + let total = 0 + let pruned = 0 + let turns = 0 + + // 从后向前遍历 + loop: for (let msgIndex = msgs.length - 1; msgIndex >= 0; msgIndex--) { + const msg = msgs[msgIndex] + if (msg.info.role === "user") turns++ + if (turns < 2) continue // 保护最近 2 轮 + if (msg.info.role === "assistant" && msg.info.summary) break loop + + for (let partIndex = msg.parts.length - 1; partIndex >= 0; partIndex--) { + const part = msg.parts[partIndex] + if (part.type !== "tool") continue + if (part.state.status !== "completed") continue + if (PRUNE_PROTECTED_TOOLS.includes(part.tool)) continue + + const size = Token.estimate(part.state.output ?? "") + total += size + + if (total <= PRUNE_PROTECT) continue // 保护前 40K tokens + // 超过保护阈值,开始修剪 + if (part.state.time.compacted) continue + + part.state.time.compacted = Date.now() + part.state.output = "[pruned]" + await Session.updatePart(part) + pruned += size + } + } +} +``` + +修剪策略的核心思想: + +```mermaid +flowchart TD + A[从后向前遍历消息] --> B{最近 2 轮?} + B -->|是| C[跳过,不修剪] + B -->|否| D{是完成的工具调用?} + D -->|否| A + D -->|是| E{累计 token < 40K?} + E -->|是| F[保留,计入保护配额] + E -->|否| G["替换输出为 [pruned]"] + G --> H[记录压缩时间戳] + F --> A + H --> A +``` + +**关键设计**: +- **保护最近 2 轮**:避免修剪用户刚看到的内容 +- **保护前 40K tokens**:保留一定量的上下文 +- **保护 `skill` 工具**:自定义技能的输出通常包含重要说明 +- **标记而非删除**:`time.compacted` 时间戳标记修剪,方便调试 + +### 5.3 压缩处理 + +当修剪不够时,执行完整压缩——让 LLM 生成对话摘要: + +```typescript +// 文件: packages/opencode/src/session/compaction.ts(简化) + +export async function process(input: { + parentID: MessageID + messages: MessageV2.WithParts[] + sessionID: SessionID + abort: AbortSignal + auto: boolean + overflow?: boolean +}) { + // 1. 创建压缩助手消息 + const msg = await Session.updateMessage({ + id: MessageID.ascending(), + role: "assistant", + sessionID: input.sessionID, + parentID: input.parentID, + agent: "compaction", + summary: true, // 标记为压缩消息 + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: Date.now() }, + }) + + // 2. 调用 LLM 生成摘要 + // 使用专门的压缩 Prompt,要求: + // - 保留关键决策和上下文 + // - 记录已执行的文件变更 + // - 压缩重复信息 + const processor = SessionProcessor.create({ + assistantMessage: msg, + sessionID: input.sessionID, + model: compactionModel, + abort: input.abort, + }) + + await processor.process({ + system: [COMPACTION_PROMPT], + messages: MessageV2.toModelMessages(input.messages, compactionModel), + tools: {}, // 压缩时不提供工具 + agent: compactionAgent, + // ... + }) + + // 3. 发布压缩完成事件 + Bus.publish(Event.Compacted, { sessionID: input.sessionID }) +} +``` + +### 5.4 压缩前后的消息结构 + +``` +压缩前: +├── User #1: "帮我重构 auth 模块" +├── Assistant #1: [read auth.ts] [edit auth.ts] [read test.ts] "重构完成" +├── User #2: "测试通过了吗?" +├── Assistant #2: [bash "npm test"] "3 个测试通过" +├── User #3: "再优化一下性能" +├── Assistant #3: [read auth.ts] [edit auth.ts] "已优化" + +压缩后: +├── Assistant (summary=true): "对话摘要: +│ 1. 用户请求重构 auth 模块 +│ 2. 已完成重构,修改了 auth.ts +│ 3. 测试通过(3/3) +│ 4. 进行了性能优化 +│ 5. 当前状态:auth.ts 已更新..." +├── CompactionPart: { auto: true } +├── User #3: "再优化一下性能" ← 最后的用户消息被保留 +└── Assistant #3: ... ← 继续对话 +``` + +--- + +## 六、摘要生成(Summary) + +### 6.1 会话级摘要 + +每次步骤完成后,都会异步生成摘要: + +```typescript +// 文件: packages/opencode/src/session/summary.ts(简化) + +export async function summarize(input: { + sessionID: SessionID + messageID: MessageID +}) { + const all = await Session.messages({ sessionID: input.sessionID }) + + // 计算整个会话的文件变更 + const diffs = await computeDiff({ messages: all }) + await Session.setSummary({ + additions: sum(diffs.map(d => d.additions)), + deletions: sum(diffs.map(d => d.deletions)), + files: diffs.length, + }) + + // 存储详细 diff 到文件 + await Storage.write(["session_diff", input.sessionID], diffs) + + // 广播 diff 事件 + Bus.publish(Session.Event.Diff, { sessionID: input.sessionID, diff: diffs }) +} +``` + +### 6.2 消息级摘要 + +```typescript +// 文件: packages/opencode/src/session/summary.ts(简化) + +// 每条用户消息也有独立的摘要 +const msgWithParts = messages.find(m => m.info.id === input.messageID) +const diffs = await computeDiff({ messages: [userMsg, ...assistantMsgs] }) +userMsg.summary = { diffs } +await Session.updateMessage(userMsg) +``` + +### 6.3 Diff 计算 + +```typescript +// 文件: packages/opencode/src/session/summary.ts(简化) + +export async function computeDiff(input: { + messages: MessageV2.WithParts[] +}) { + // 找最早的 "step-start" 快照作为起点 + let from: string | undefined + // 找最晚的 "step-finish" 快照作为终点 + let to: string | undefined + + for (const msg of input.messages) { + for (const part of msg.parts) { + if (part.type === "step-start" && part.snapshot) { + from = from ?? part.snapshot + } + if (part.type === "step-finish" && part.snapshot) { + to = part.snapshot + } + } + } + + if (from && to) { + return Snapshot.diffFull(from, to) + } + return [] +} +``` + +### 6.4 Diff 输出格式 + +```typescript +export type FileDiff = { + file: string // 文件路径 + additions: number // 新增行数 + deletions: number // 删除行数 + hunks: Hunk[] // diff 区块 +} +``` + +--- + +## 七、会话恢复 + +### 7.1 继续上次会话 + +```bash +# 继续上次的会话 +opencode --continue + +# 继续指定会话 +opencode --session + +# Fork 后继续 +opencode --session --fork +``` + +### 7.2 消息流过滤 + +恢复会话时,需要跳过已压缩的消息: + +```typescript +// 文件: packages/opencode/src/session/message-v2.ts(简化) + +export async function filterCompacted( + messages: AsyncIterable, +) { + const result: MessageV2.WithParts[] = [] + let lastCompactionIndex = -1 + + // 找到最后一个压缩标记 + for await (const msg of messages) { + result.push(msg) + const hasCompaction = msg.parts.some(p => p.type === "compaction") + if (hasCompaction) { + lastCompactionIndex = result.length - 1 + } + } + + // 跳过压缩标记之前的所有消息 + if (lastCompactionIndex >= 0) { + return result.slice(lastCompactionIndex) + } + return result +} +``` + +--- + +## 八、完整数据流 + +```mermaid +flowchart TD + subgraph 创建阶段 + A[用户发送消息] --> B[Session.create / 获取现有] + B --> C[创建 User Message] + C --> D[创建 Parts: text/file/agent] + end + + subgraph 处理阶段 + D --> E[loop 循环] + E --> F[Processor 处理流事件] + F --> G[创建 Assistant Message] + G --> H[流式写入 Parts] + H -->|text-delta| I[Bus 广播增量] + H -->|text-end/tool-result| J[SyncEvent 持久化] + H -->|step-finish| K[创建 Snapshot + Patch] + end + + subgraph 维护阶段 + K --> L{上下文溢出?} + L -->|否| M[SessionSummary.summarize] + L -->|是| N[Prune 修剪旧工具输出] + N --> O[Compaction 压缩摘要] + O --> P[创建压缩标记 Part] + M --> Q[更新 Session.summary] + Q --> R[存储 diff 到文件] + end + + subgraph 恢复阶段 + S[opencode --continue] --> T[加载 Session] + T --> U[filterCompacted 跳过已压缩] + U --> V[resume loop 继续循环] + end +``` + +--- + +## 九、Session 事件系统 + +Session 通过 Bus 发布多种事件,TUI 和 API 客户端可以订阅: + +| 事件 | 触发时机 | 包含数据 | +|------|---------|---------| +| `Session.Event.Created` | 会话创建 | `{ session }` | +| `Session.Event.Updated` | 会话元数据更新 | `{ session }` | +| `Session.Event.Deleted` | 会话删除 | `{ sessionID }` | +| `MessageV2.Event.Updated` | 消息创建/更新 | `{ sessionID, info }` | +| `MessageV2.Event.PartUpdated` | Part 创建/更新 | `{ sessionID, messageID, info }` | +| `MessageV2.Event.PartDelta` | 流式增量 | `{ sessionID, messageID, partID, delta }` | +| `Session.Event.Error` | 错误发生 | `{ sessionID, error }` | +| `Session.Event.Diff` | Diff 计算完成 | `{ sessionID, diff }` | +| `SessionCompaction.Event.Compacted` | 压缩完成 | `{ sessionID }` | + +--- + +## 十、数据库事务管理 + +OpenCode 使用上下文感知的事务管理: + +```typescript +// 文件: packages/opencode/src/storage/db.ts + +export function transaction( + callback: (tx: TxOrDb) => NotPromise, + options?: { behavior?: "deferred" | "immediate" | "exclusive" }, +): NotPromise { + try { + // 尝试使用当前上下文中的事务 + return callback(ctx.use().tx) + } catch (err) { + if (err instanceof Context.NotFound) { + // 没有现有事务 → 创建新事务 + const effects: (() => void | Promise)[] = [] + const result = Client().transaction( + (tx: TxOrDb) => { + return ctx.provide({ tx, effects }, () => callback(tx)) + }, + { behavior: options?.behavior }, + ) + // 事务成功后执行副作用 + for (const effect of effects) effect() + return result as NotPromise + } + throw err + } +} +``` + +**关键设计**: +- 如果已在事务上下文中,复用现有事务(嵌套友好) +- 如果不在事务中,创建新事务 +- 副作用(如 Bus 事件发布)在事务**提交后**才执行 + +--- + +## 动手练习 + +### 练习 1:查看 Session 数据库 + +```bash +# 查看数据库路径 +ls ~/.local/share/opencode/opencode*.db + +# 用 sqlite3 查看表结构 +sqlite3 ~/.local/share/opencode/opencode.db ".schema session" +sqlite3 ~/.local/share/opencode/opencode.db ".schema message" +sqlite3 ~/.local/share/opencode/opencode.db ".schema part" +``` + +### 练习 2:观察压缩过程 + +创建一个会使用大量 token 的对话(让 LLM 读取多个大文件),然后观察日志中的压缩事件: + +```bash +opencode --print-logs 2>session.log +# 发送需要大量上下文的请求 +grep -E "(compaction|prune|overflow)" session.log +``` + +### 练习 3:Fork 会话 + +```bash +# 列出会话 +opencode session list + +# Fork 一个会话 +opencode --session --fork +# 在 fork 的会话中做不同的修改 +``` + +--- + +## 常见问题 + +### Q: 为什么消息和 Part 分开存储? + +这种设计有两个优势:1) Part 可以独立更新而不需要重写整个消息(工具调用状态频繁变化);2) 查询时可以选择只加载消息元数据,需要时再加载 Part(延迟加载)。 + +### Q: 压缩会丢失历史信息吗? + +不会完全丢失。压缩后的摘要保留了关键决策和文件变更记录。原始消息仍然存在于数据库中,只是在 `filterCompacted()` 时被跳过。如果需要,可以通过数据库直接查看完整历史。 + +### Q: `updatePartDelta` 的增量数据如果丢失怎么办? + +没有关系。`updatePartDelta` 是纯广播机制(用于 TUI 实时更新),最终的完整内容会通过 `updatePart()` 持久化到数据库。TUI 重新连接时会从数据库读取完整状态。 + +### Q: Prune 和 Compaction 有什么区别? + +**Prune**(修剪)只清除旧工具调用的输出文本,不调用 LLM,速度快且成本为零。**Compaction**(压缩)调用 LLM 生成对话摘要,替换整个历史前缀,更彻底但需要消耗 token。Prune 是压缩的轻量级替代方案。 + +--- + +## 小结 + +本节我们深入探索了 Session 管理的完整生命周期: + +1. **数据模型**:Session → Message → Part 三层结构,JSON 序列化存储在 SQLite +2. **持久化模式**:事件溯源(SyncEvent)+ 增量广播(Bus)双通道 +3. **上下文压缩**:`isOverflow()` 检测 → `prune()` 修剪旧输出 → `process()` LLM 摘要 +4. **摘要生成**:Snapshot 快照 + Diff 计算,追踪每步文件变更 +5. **会话恢复**:`filterCompacted()` 跳过已压缩消息,`resume()` 恢复循环 +6. **事务管理**:上下文感知的嵌套事务 + 提交后副作用 + +> ⏭️ 下一节,我们将深入 Provider 调用系统——了解多提供商适配、认证、消息转换和流式处理。 diff --git "a/all-in-one-book/03-\345\205\263\351\224\256\350\267\257\345\276\204\350\257\246\350\247\243/05-Provider\350\260\203\347\224\250\350\257\246\350\247\243.md" "b/all-in-one-book/03-\345\205\263\351\224\256\350\267\257\345\276\204\350\257\246\350\247\243/05-Provider\350\260\203\347\224\250\350\257\246\350\247\243.md" new file mode 100644 index 000000000000..16560eb9d59b --- /dev/null +++ "b/all-in-one-book/03-\345\205\263\351\224\256\350\267\257\345\276\204\350\257\246\350\247\243/05-Provider\350\260\203\347\224\250\350\257\246\350\247\243.md" @@ -0,0 +1,354 @@ +# Provider 调用详解 + +📍 **你在这里** +> 在第01章全景视野中,我们看到 Provider 是连接 Agent 与 LLM 的"万能适配器"。现在我们深入探索 Provider 从配置到调用的完整链路。 + +> 📌 Provider 系统负责将 20+ 种 LLM 服务统一为一致的调用接口,处理认证、模型解析、参数转换和流式响应。 + +## 本章学习目标 + +- [ ] 理解 Provider 和 Model 的关系 +- [ ] 掌握模型解析(Model Resolution)的完整流程 +- [ ] 理解认证凭据的管理方式 +- [ ] 知道 LLM API 调用是如何构造和发送的 +- [ ] 理解流式响应的处理机制 + +## 概念解释 + +### Provider 是什么? + +提供商(Provider)是 OpenCode 与各种大语言模型(LLM)服务之间的适配层。每个 Provider 对应一个 LLM 服务商,如 Anthropic(Claude)、OpenAI(GPT)、Google(Gemini)等。 + +``` +// 文件: packages/opencode/src/provider/schema.ts +// ProviderID 和 ModelID 都是品牌类型(Branded Type) +export const ProviderID = Schema.String.pipe(Schema.brand("ProviderID")) +export const ModelID = Schema.String.pipe(Schema.brand("ModelID")) +``` + +### 支持的 Provider 列表 + +| Provider ID | 服务商 | AI SDK 包 | 代表模型 | +|-------------|--------|-----------|----------| +| `anthropic` | Anthropic | `@ai-sdk/anthropic` | Claude 4, Claude 3.5 Sonnet | +| `openai` | OpenAI | `@ai-sdk/openai` | GPT-4o, GPT-4.1, o3 | +| `google` | Google | `@ai-sdk/google` | Gemini 2.5 Pro/Flash | +| `amazon-bedrock` | AWS | `@ai-sdk/amazon-bedrock` | Claude via Bedrock | +| `azure` | Microsoft | `@ai-sdk/azure` | GPT via Azure | +| `openrouter` | OpenRouter | `@openrouter/ai-sdk-provider` | 多模型路由 | +| `mistral` | Mistral | `@ai-sdk/mistral` | Mistral Large | +| `groq` | Groq | `@ai-sdk/groq` | Llama 3, Mixtral | +| `deepinfra` | DeepInfra | `@ai-sdk/openai` (兼容) | 各开源模型 | +| `gitlab` | GitLab | `gitlab-ai-provider` | GitLab Duo | +| `opencode` | OpenCode | 内置 | Zen (托管模型) | +| `copilot` | GitHub Copilot | 自定义 | GPT-4o via Copilot | +| `cerebras` | Cerebras | `@ai-sdk/cerebras` | 快速推理 | +| `cohere` | Cohere | `@ai-sdk/cohere` | Command R | +| `together` | Together AI | `@ai-sdk/togetherai` | 开源模型 | +| `perplexity` | Perplexity | `@ai-sdk/openai` (兼容) | 搜索增强 | +| `vercel` | Vercel | `@ai-sdk/vercel` | v0 模型 | + +``` +// 文件: packages/opencode/src/provider/provider.ts +// BUNDLED_PROVIDERS 定义了所有内置的 Provider SDK 映射 +const BUNDLED_PROVIDERS: Record Promise<...>> = { + "@ai-sdk/anthropic": () => import("@ai-sdk/anthropic"), + "@ai-sdk/openai": () => import("@ai-sdk/openai"), + "@ai-sdk/google": () => import("@ai-sdk/google-vertex"), + // ... 15+ 个 Provider +} +``` + +## 模型解析流程 + +当用户选择一个模型(或使用默认模型)时,Provider 系统需要解析出完整的模型信息: + +```mermaid +flowchart TD + A[用户指定 modelID + providerID] --> B{配置中有自定义模型?} + B -->|是| C[使用配置中的模型定义] + B -->|否| D[从 models.dev 获取模型目录] + D --> E{models.dev 有此模型?} + E -->|是| F[使用远程模型定义] + E -->|否| G[使用默认参数创建模型] + C --> H[合并模型能力/限制/成本] + F --> H + G --> H + H --> I[Provider.Model 完整对象] + I --> J[获取 LanguageModel 实例] +``` + +### Model 数据结构 + +```typescript +// 文件: packages/opencode/src/provider/provider.ts +// Provider.Model 包含模型的所有元数据 +Model = { + id: ModelID // 如 "claude-sonnet-4-20250514" + providerID: ProviderID // 如 "anthropic" + family?: string // 如 "claude" + capabilities: { + temperature: boolean // 是否支持温度调节 + reasoning: boolean // 是否支持推理模式 + interleaved?: boolean // 是否支持交错内容 + attachment: boolean // 是否支持文件附件 + toolCall: boolean // 是否支持工具调用 + } + cost?: { + input: number // 每百万 token 输入成本 + output: number // 每百万 token 输出成本 + cache_read?: number // 缓存读取成本 + cache_write?: number // 缓存写入成本 + } + limit: { + context: number // 上下文窗口大小(token 数) + output: number // 最大输出 token 数 + } + variants?: Record> // 模型变体 + headers?: Record // 自定义请求头 +} +``` + +### models.dev 集成 + +OpenCode 使用 [models.dev](https://models.dev) 作为模型目录服务,获取最新的模型信息: + +```typescript +// 文件: packages/opencode/src/provider/models.ts +// 从 models.dev API 获取模型目录 +// 包含本地缓存,避免重复请求 +// 可通过 OPENCODE_DISABLE_MODELS_FETCH 禁用 +``` + +## 认证管理 + +### 认证类型 + +```typescript +// 文件: packages/opencode/src/auth/index.ts +// 三种认证方式 +type AuthInfo = + | { type: "oauth"; refresh: string; access: string; expires: number } + | { type: "api"; key: string } + | { type: "wellknown"; key: string; token: string } +``` + +### 认证解析流程 + +```mermaid +flowchart TD + A[需要 Provider 认证] --> B{检查环境变量} + B -->|有| C[使用环境变量 API Key] + B -->|无| D{检查配置文件} + D -->|有| E[使用配置中的凭据] + D -->|无| F{检查 auth.json} + F -->|有| G[使用存储的认证信息] + F -->|无| H{是否 OAuth Provider?} + H -->|是| I[触发 OAuth 流程] + H -->|否| J[报错: 缺少认证] + C --> K[构造请求头] + E --> K + G --> K + I --> K +``` + +### 凭据存储 + +认证信息存储在 `~/.opencode/auth.json`,权限设为 `0o600`(仅所有者可读写): + +```typescript +// 文件: packages/opencode/src/auth/index.ts +// 安全存储认证信息 +// set(key, info) - 保存凭据 +// get(providerID) - 获取凭据 +// remove(key) - 删除凭据 +``` + +## API 调用构造 + +### LanguageModel 获取 + +```typescript +// 文件: packages/opencode/src/provider/provider.ts +// getLanguage(model) 将 Provider.Model 转换为 AI SDK 的 LanguageModel +// 1. 查找对应的 BUNDLED_PROVIDER SDK +// 2. 获取认证凭据 +// 3. 创建 provider 实例(带 baseURL、headers) +// 4. 调用 provider(modelID) 返回 LanguageModel +``` + +### 参数转换(ProviderTransform) + +```typescript +// 文件: packages/opencode/src/provider/transform.ts +// ProviderTransform.options(model, agent) 将模型+代理配置转为 AI SDK 选项 +// - maxTokens: 根据 model.limit.output 设置 +// - temperature: 根据 agent 配置或模型默认值 +// - topP: 同上 +// - providerOptions: Provider 特定的参数 +// - anthropic: thinking.type, cacheControl +// - openai: reasoningEffort +// - google: thinkingConfig +``` + +### Provider 特定参数 + +不同 Provider 需要不同的参数格式: + +| Provider | 特殊参数 | 说明 | +|----------|---------|------| +| Anthropic | `thinking.type`, `cacheControl` | 推理模式和缓存控制 | +| OpenAI | `reasoningEffort` | 推理强度(low/medium/high) | +| Google | `thinkingConfig.thinkingBudget` | 思考预算 token 数 | +| Bedrock | `anthropic.thinking` | Bedrock 中的 Claude 推理 | +| OpenRouter | `x-openrouter-*` 请求头 | 路由控制 | + +## 流式响应处理 + +### 调用流程 + +```mermaid +sequenceDiagram + participant Agent + participant LLM as LLM Module + participant Provider + participant SDK as AI SDK + participant API as LLM API + + Agent->>LLM: stream(input) + LLM->>Provider: getLanguage(model) + Provider-->>LLM: LanguageModel + LLM->>SDK: streamText({model, messages, tools, ...}) + SDK->>API: HTTP POST (streaming) + + loop 流式响应 + API-->>SDK: SSE chunk + SDK-->>LLM: onChunk callback + LLM-->>Agent: text/toolCall/reasoning delta + end + + SDK-->>LLM: onFinish callback + LLM->>LLM: 记录 usage (token 计数) + LLM-->>Agent: 完整响应 +``` + +### 流式事件类型 + +```typescript +// 文件: packages/opencode/src/session/llm.ts +// streamText 返回的事件流包含以下类型: +// - text-delta: 文本增量 +// - tool-call: 工具调用请求 +// - tool-call-streaming-start: 工具调用流开始 +// - tool-call-delta: 工具调用参数增量 +// - tool-result: 工具执行结果 +// - reasoning: 推理过程文本 +// - finish: 完成信号(含 usage 统计) +``` + +### Token 计费 + +```typescript +// 文件: packages/opencode/src/session/llm.ts +// onFinish 回调中记录 token 使用量: +// usage.promptTokens - 输入 token 数 +// usage.completionTokens - 输出 token 数 +// usage.totalTokens - 总 token 数 +// 结合 model.cost 计算实际费用 +``` + +## 错误处理 + +Provider 调用可能遇到的错误和处理策略: + +| 错误类型 | 原因 | 处理方式 | +|----------|------|---------| +| 认证失败 (401) | API Key 无效或过期 | 提示用户重新配置 | +| 配额超限 (429) | 速率限制或余额不足 | 自动重试 + 退避 | +| 模型不可用 (404) | 模型 ID 错误或已下线 | 提示切换模型 | +| 超时 | 网络问题或响应过长 | 重试机制 | +| 内容过滤 | 提供商安全策略 | 返回错误信息 | + +```typescript +// 文件: packages/opencode/src/provider/error.ts +// ProviderError 封装了各种 Provider 相关错误 +// 包含友好的错误消息和原始错误信息 +``` + +## 插件扩展点 + +Provider 系统提供多个插件钩子(Hook): + +```typescript +// 文件: packages/opencode/src/plugin/index.ts +// Plugin hooks 相关到 Provider: +// - chat.params: 修改 LLM 调用参数(temperature, topP 等) +// - chat.headers: 添加自定义请求头 +// - auth: 自定义认证流程 +``` + +## 动手练习 + +### 练习 1:查看当前 Provider 配置 + +```bash +# 启动 OpenCode 后查看可用 Provider +opencode providers +``` + +### 练习 2:配置一个新的 Provider + +在项目的 `.opencode/opencode.jsonc` 中添加: + +```jsonc +{ + "provider": { + "openai": { + "options": {} + } + } +} +``` + +然后设置环境变量: +```bash +export OPENAI_API_KEY="sk-..." +``` + +### 练习 3:阅读 Provider 源码 + +```bash +# 查看所有支持的 Provider +cat packages/opencode/src/provider/provider.ts | grep "BUNDLED_PROVIDERS" -A 30 + +# 查看模型转换逻辑 +cat packages/opencode/src/provider/transform.ts | head -100 +``` + +## 常见问题 + +**Q: 如何添加自定义 Provider?** +A: 在配置中设置 provider,使用 OpenAI 兼容的 baseURL 指向自定义端点。许多 Provider 使用 `@ai-sdk/openai` 的兼容模式。 + +**Q: 为什么有些模型不支持工具调用?** +A: 模型的 `capabilities.toolCall` 标志决定了是否支持。某些小模型或特殊模型(如嵌入模型)不支持工具调用。 + +**Q: 如何切换模型?** +A: 在 TUI 中使用 `/model` 命令或快捷键选择模型。也可以在配置中设置默认模型。 + +## 本章小结 + +Provider 系统是 OpenCode 多模型支持的核心。通过统一的抽象层,它将 20+ 种 LLM 服务整合为一致的接口: + +1. **模型解析**:从配置、models.dev 或默认值获取模型信息 +2. **认证管理**:支持 API Key、OAuth、环境变量等多种方式 +3. **参数转换**:将通用参数转为 Provider 特定格式 +4. **流式处理**:基于 Vercel AI SDK 的统一流式接口 +5. **错误处理**:友好的错误提示和自动重试 + +## 延伸阅读 + +- [01-全景视野/04-一次对话的完整旅程](../01-全景视野/04-一次对话的完整旅程.md) — Provider 在对话链路中的位置 +- [04-核心引擎/04-Provider-LLM提供商](../04-核心引擎-packages-opencode/04-Provider-LLM提供商.md) — Provider 模块源码深入分析 +- [09-周边知识/03-AI-SDK与LLM调用](../09-周边知识与生态/03-AI-SDK与LLM调用.md) — Vercel AI SDK 详解 +- [06-扩展与集成/06-Provider适配](../06-扩展与集成/06-Provider适配.md) — 如何适配新 Provider From 2d2aed757943d2da2c48de68b497ecb64be9d52a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Mar 2026 14:44:26 +0000 Subject: [PATCH 7/7] =?UTF-8?q?Add=2002-=E5=BF=AB=E9=80=9F=E4=B8=8A?= =?UTF-8?q?=E6=89=8B=20(4=20sections)=20and=2004-=E6=A0=B8=E5=BF=83?= =?UTF-8?q?=E5=BC=95=E6=93=8E=20sections=2001-09?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: propress <202759273+propress@users.noreply.github.com> Agent-Logs-Url: https://github.com/propress/opencode/sessions/900d8dcb-f3bc-459e-805e-73b44390b1f7 --- .../08-Command\347\263\273\347\273\237.md" | 244 +++++++++++++ .../09-MCP\351\233\206\346\210\220.md" | 325 ++++++++++++++++++ 2 files changed, 569 insertions(+) create mode 100644 "all-in-one-book/04-\346\240\270\345\277\203\345\274\225\346\223\216-packages-opencode/08-Command\347\263\273\347\273\237.md" create mode 100644 "all-in-one-book/04-\346\240\270\345\277\203\345\274\225\346\223\216-packages-opencode/09-MCP\351\233\206\346\210\220.md" diff --git "a/all-in-one-book/04-\346\240\270\345\277\203\345\274\225\346\223\216-packages-opencode/08-Command\347\263\273\347\273\237.md" "b/all-in-one-book/04-\346\240\270\345\277\203\345\274\225\346\223\216-packages-opencode/08-Command\347\263\273\347\273\237.md" new file mode 100644 index 000000000000..2cae1552fe36 --- /dev/null +++ "b/all-in-one-book/04-\346\240\270\345\277\203\345\274\225\346\223\216-packages-opencode/08-Command\347\263\273\347\273\237.md" @@ -0,0 +1,244 @@ +# 08 - Command 系统 + +## 学习目标 + +- 理解 OpenCode 的斜杠命令(Slash Command)系统架构 +- 掌握命令的四种来源:内置命令、配置命令、MCP Prompts、Skills +- 了解命令注册、模板解析与执行流程 +- 学会自定义命令扩展系统 + +## 概念解释 + +在 OpenCode 中,**Command 系统**是用户与 AI 交互的快捷入口。当我们在输入框中键入 `/review` 或 `/init` 时,背后就是 Command 系统在工作。它将用户的简短指令扩展为完整的提示词(Prompt),再交给 AI 模型执行。 + +核心概念包括: + +- **命令(Command)**:一个带名称、描述和模板的指令单元 +- **模板(Template)**:命令对应的提示词模板,支持 `$1`、`$ARGUMENTS` 等占位符(Placeholder) +- **提示词(Hints)**:从模板中自动提取的占位符列表,用于 UI 提示 +- **命令来源(Source)**:命令可来自内置 `"command"`、MCP `"mcp"` 或技能 `"skill"` + +## 设计原理 + +Command 系统采用**多源聚合、懒加载、名称去重**的设计: + +```mermaid +graph TD + A[Command Service 初始化] --> B[内置命令 init/review] + A --> C[配置文件命令 config.command] + A --> D[MCP Prompts 远程命令] + A --> E[Skills 技能命令] + B --> F[命令注册表 Record] + C --> F + D --> F + E --> F + F --> G[get/list API] +``` + +**设计决策:** + +1. **InstanceState 缓存**:命令注册表只初始化一次,后续调用从缓存读取 +2. **优先级机制**:同名命令中,先注册者胜出(内置 > 配置 > MCP > Skill) +3. **异步模板**:MCP Prompts 的模板是 `Promise`,实现按需加载 +4. **Effect 架构**:整个服务基于 Effect.js 构建,支持依赖注入与资源管理 + +## 源码分析 + +### 文件结构 + +``` +packages/opencode/src/command/ +├── index.ts # 主模块(~186 行) +└── template/ + ├── initialize.txt # init 命令模板 + └── review.txt # review 命令模板(~102 行) +``` + +### 命令信息 Schema + +```typescript +// packages/opencode/src/command/index.ts (约第 33-51 行) +const Info = z.object({ + name: z.string(), + description: z.string().optional(), + agent: z.string().optional(), + model: z.string().optional(), + source: z.enum(["command", "mcp", "skill"]).optional(), + template: z.custom | string>(), + subtask: z.boolean().optional(), + hints: z.array(z.string()), // 自动提取的占位符 +}) +``` + +每个命令由 `Info` 描述。注意 `template` 字段支持同步字符串或异步 Promise——这是为了适配 MCP 远程获取的场景。 + +### Hints 提取函数 + +```typescript +// packages/opencode/src/command/index.ts (约第 53-61 行) +function hints(template: string) { + const numbered = [...new Set(template.match(/\$\d+/g) || [])].sort() + const args = template.includes("$ARGUMENTS") ? ["$ARGUMENTS"] : [] + return [...numbered, ...args] +} +``` + +这个函数从模板中提取 `$1`、`$2`、`$ARGUMENTS` 等占位符,供 UI 层展示参数提示。 + +### 内置命令注册 + +```typescript +// 约第 63-100 行 +const DEFAULT = { INIT: "init", REVIEW: "review" } as const + +// init 命令 —— 创建/更新 AGENTS.md +commands[DEFAULT.INIT] = { + name: DEFAULT.INIT, + description: "create/update AGENTS.md", + source: "command", + template: initTemplate.replace("${path}", Instance.directory), + hints: hints(initTemplate), +} + +// review 命令 —— 代码审查 +commands[DEFAULT.REVIEW] = { + name: DEFAULT.REVIEW, + description: "review changes", + source: "command", + template: reviewTemplate, + hints: hints(reviewTemplate), +} +``` + +### 配置文件命令 + +```typescript +// 约第 102-115 行 +for (const [name, cmd] of Object.entries(cfg.command ?? {})) { + commands[name] = { + name, + agent: cmd.agent, + model: cmd.model, + description: cmd.description, + template: cmd.template, + subtask: cmd.subtask, + hints: hints(cmd.template), + } +} +``` + +用户可以在配置文件中自定义命令,指定使用的 agent 和 model。 + +### MCP Prompts 命令 + +```typescript +// 约第 117-140 行 +const prompts = yield* Effect.promise(() => Mcp.prompts()) +for (const [key, prompt] of Object.entries(prompts)) { + if (commands[prompt.name]) continue // 名称去重 + commands[prompt.name] = { + name: prompt.name, + description: prompt.description, + source: "mcp", + // 异步模板 —— 按需从 MCP 服务器获取 + template: Mcp.getPrompt(key, args).then(r => r.messages.map(...).join("\n")), + hints: (prompt.arguments ?? []).map((_, i) => `$${i + 1}`), + } +} +``` + +### Skills 命令 + +```typescript +// 约第 142-153 行 +const skills = yield* Effect.promise(() => Skill.all()) +for (const skill of skills) { + if (commands[skill.name]) continue // 已注册则跳过 + commands[skill.name] = { + name: skill.name, + description: skill.description, + source: "skill", + template: skill.content, + hints: hints(skill.content), + } +} +``` + +## 执行流程 + +```mermaid +sequenceDiagram + participant User as 用户 + participant UI as TUI 界面 + participant Cmd as Command Service + participant Bus as Event Bus + participant AI as AI Provider + + User->>UI: 输入 /review + UI->>Cmd: Command.get("review") + Cmd-->>UI: 返回 Info{template, hints} + UI->>UI: 替换模板占位符 + UI->>Bus: 发布 Command.Event.Executed + Note over Bus: {name, sessionID, arguments, messageID} + Bus->>AI: 将展开后的模板作为用户消息发送 + AI-->>User: 返回代码审查结果 +``` + +**关键步骤:** + +1. 用户键入 `/review commit abc123` +2. UI 调用 `Command.get("review")` 获取命令信息 +3. 模板中的 `$ARGUMENTS` 被替换为 `commit abc123` +4. 通过 `Bus.publish(Command.Event.Executed, ...)` 触发执行 +5. AI 接收完整提示词并返回结果 + +## 动手练习 + +### 练习 1:查看所有可用命令 + +在 OpenCode 中输入 `/` 即可看到命令列表。尝试理解每个命令的来源(source)。 + +### 练习 2:自定义配置命令 + +在配置文件中添加: + +```json +{ + "command": { + "explain": { + "description": "解释代码", + "template": "请详细解释以下代码的功能和设计思路:$ARGUMENTS" + } + } +} +``` + +重启后输入 `/explain` 即可使用。 + +### 练习 3:阅读 review 模板 + +打开 `packages/opencode/src/command/template/review.txt`,理解它如何指导 AI 进行代码审查——包括确定审查范围(未提交、特定 commit、分支、PR)和审查关注点。 + +## 常见问题 + +**Q:自定义命令和 MCP 命令同名会怎样?** +A:配置命令优先。注册顺序是:内置 → 配置 → MCP → Skill,同名命令先到先得。 + +**Q:模板中 `$1` 和 `$ARGUMENTS` 有什么区别?** +A:`$1`、`$2` 是按位置匹配的参数,`$ARGUMENTS` 则捕获所有剩余参数。 + +**Q:为什么 MCP 命令的模板是 Promise?** +A:MCP Prompts 内容存储在远程服务器上,使用 Promise 实现懒加载,避免初始化时的网络开销。 + +**Q:命令注册表何时刷新?** +A:当前实现中,命令表在 `InstanceState` 初始化时加载一次。MCP 工具变更会触发 `ToolsChanged` 事件,但命令表需重启刷新。 + +## 小结 + +OpenCode 的 Command 系统通过**多源聚合**将内置命令、用户配置、MCP Prompts 和 Skills 统一为斜杠命令接口。核心设计要点: + +- **Zod Schema** 定义命令元数据,`hints` 自动提取模板占位符 +- **InstanceState** 实现一次初始化、全程缓存 +- **名称去重**保证优先级:内置 > 配置 > MCP > Skill +- **异步模板**支持远程 MCP Prompts 的按需加载 +- **Event Bus** 解耦命令执行与 UI 层 diff --git "a/all-in-one-book/04-\346\240\270\345\277\203\345\274\225\346\223\216-packages-opencode/09-MCP\351\233\206\346\210\220.md" "b/all-in-one-book/04-\346\240\270\345\277\203\345\274\225\346\223\216-packages-opencode/09-MCP\351\233\206\346\210\220.md" new file mode 100644 index 000000000000..be538e581a36 --- /dev/null +++ "b/all-in-one-book/04-\346\240\270\345\277\203\345\274\225\346\223\216-packages-opencode/09-MCP\351\233\206\346\210\220.md" @@ -0,0 +1,325 @@ +# 09 - MCP 集成 + +## 学习目标 + +- 理解 MCP(Model Context Protocol)在 OpenCode 中的集成架构 +- 掌握 MCP 客户端的三种传输方式:StreamableHTTP、SSE、Stdio +- 了解工具(Tool)、资源(Resource)、提示词(Prompt)的代理机制 +- 学会配置远程和本地 MCP 服务器 + +## 概念解释 + +**MCP(Model Context Protocol)** 是一种标准化协议,让 AI 模型能够调用外部工具和访问外部资源。在 OpenCode 中,MCP 集成模块扮演着**桥梁(Bridge)**的角色——它管理多个 MCP 服务器连接,将远程工具转换为 AI SDK 可用的工具格式。 + +核心概念: + +- **MCP 客户端(Client)**:与 MCP 服务器建立连接的客户端实例 +- **传输层(Transport)**:通信方式,支持 HTTP 流、SSE(Server-Sent Events)和 Stdio +- **工具代理(Tool Proxying)**:将 MCP 工具定义转换为 AI SDK 的 `Tool` 类型 +- **OAuth 认证(Authentication)**:远程服务器的身份验证流程 +- **状态机(Status)**:`connected` | `disabled` | `failed` | `needs_auth` | `needs_client_registration` + +## 设计原理 + +```mermaid +graph TD + A[Config 配置] --> B[MCP Service 初始化] + B --> C{服务器类型?} + C -->|远程 URL| D[StreamableHTTP Transport] + C -->|远程 URL 降级| E[SSE Transport] + C -->|本地命令| F[Stdio Transport] + D --> G[MCP Client] + E --> G + F --> G + G --> H[Tool 代理] + G --> I[Resource 代理] + G --> J[Prompt 代理] + H --> K[AI SDK Tools] + G --> L[状态追踪 Status] + G --> M[工具变更监听 Watch] +``` + +**设计决策:** + +1. **并行初始化**:所有 MCP 服务器以 `concurrency: "unbounded"` 并行连接 +2. **传输降级**:远程服务器先尝试 StreamableHTTP,失败则降级到 SSE +3. **命名空间隔离**:工具名格式为 `{clientName}_{toolName}`,避免冲突 +4. **优雅清理**:通过 Effect Finalizer 管理子进程生命周期,防止僵尸进程 + +## 源码分析 + +### 文件结构 + +``` +packages/opencode/src/mcp/ +├── index.ts # 核心实现(~936 行) +├── auth.ts # OAuth 认证状态管理(~182 行) +├── oauth-callback.ts # OAuth 回调服务器(~216 行) +└── oauth-provider.ts # OAuth Provider 实现(~186 行) +``` + +### 资源定义 Schema + +```typescript +// packages/opencode/src/mcp/index.ts (约第 39-48 行) +const Resource = z.object({ + name: z.string(), + uri: z.string(), + description: z.string().optional(), + mimeType: z.string().optional(), + client: z.string(), +}) +``` + +### 状态类型 + +```typescript +// 约第 74-117 行 +type Status = + | { status: "connected" } + | { status: "disabled" } + | { status: "failed"; error: string } + | { status: "needs_auth" } + | { status: "needs_client_registration" } +``` + +### MCP 工具转换 + +这是整个模块最核心的函数——将 MCP 工具定义转换为 AI SDK 格式: + +```typescript +// 约第 133-161 行 +function convertMcpTool(tool, client, timeout?) { + const schema = { + ...tool.inputSchema, + type: "object", + properties: tool.inputSchema.properties ?? {}, + } + return { + parameters: schema, + execute: async (args) => { + const result = await client.callTool( + { name: tool.name, arguments: args }, + CallToolResultSchema, + { resetTimeoutOnProgress: true } + ) + return result + }, + } +} +``` + +### 远程服务器连接 + +```typescript +// 约第 205-310 行 +async function create(key, mcp) { + // 远程服务器:尝试两种传输方式 + const transports = [ + new StreamableHTTPClientTransport(new URL(mcp.url), { + authProvider, + requestInit: mcp.headers ? { headers: mcp.headers } : undefined, + }), + new SSEClientTransport(new URL(mcp.url), { + authProvider, + requestInit: mcp.headers ? { headers: mcp.headers } : undefined, + }), + ] + + for (const transport of transports) { + try { + await withTimeout(client.connect(transport), timeout) + return { mcpClient: client, status: { status: "connected" } } + } catch (err) { + if (err instanceof UnauthorizedError) { + // OAuth 认证流程 + return { status: { status: "needs_auth" } } + } + lastError = err + } + } +} +``` + +### 本地 Stdio 服务器 + +```typescript +// 约第 314-355 行 +// 本地服务器:通过子进程通信 +const [cmd, ...args] = mcp.command +const transport = new StdioClientTransport({ + stderr: "pipe", + command: cmd, + args, + cwd: Instance.directory, + env: { + ...process.env, + ...(cmd === "opencode" ? { BUN_BE_BUN: "1" } : {}), + ...mcp.environment, + }, +}) + +// 捕获 stderr 用于调试 +transport.stderr?.on("data", (chunk) => { + log.info(`mcp stderr: ${chunk.toString()}`, { key }) +}) +``` + +### 工具代理与命名 + +```typescript +// 约第 616-650 行 +// 只代理已连接的客户端工具 +const connected = Object.entries(s.clients).filter( + ([name]) => s.status[name]?.status === "connected" +) + +for (const tool of listed) { + // 命名格式:clientName_toolName(特殊字符替换为下划线) + const sanitized = clientName.replace(/[^a-zA-Z0-9_-]/g, "_") + const toolName = tool.name.replace(/[^a-zA-Z0-9_-]/g, "_") + result[sanitized + "_" + toolName] = convertMcpTool(tool, client, timeout) +} +``` + +### 工具变更监听 + +```typescript +// 约第 461-475 行 +function watch(s, name, client, timeout?) { + client.setNotificationHandler( + ToolListChangedNotificationSchema, + async () => { + if (s.clients[name] !== client) return + const listed = await defs(name, client, timeout) + if (!listed) return + s.defs[name] = listed // 更新缓存 + await Bus.publish(ToolsChanged, { server: name }) + } + ) +} +``` + +### 进程清理(Finalizer) + +```typescript +// 约第 514-535 行 +yield* Effect.addFinalizer(() => + Effect.gen(function* () { + for (const client of Object.values(s.clients)) { + // 获取子进程 PID,杀死进程树 + const pid = (client.transport as any)?.pid + if (pid) { + const children = execSync(`pgrep -P ${pid}`).toString().trim().split("\n") + children.forEach(c => process.kill(Number(c), "SIGTERM")) + } + await client.close() + } + }) +) +``` + +## 执行流程 + +```mermaid +sequenceDiagram + participant Config as 配置文件 + participant MCP as MCP Service + participant Transport as Transport 层 + participant Server as MCP Server + participant AI as AI Provider + + Config->>MCP: 加载 mcp 配置 + MCP->>MCP: 并行初始化所有服务器 + MCP->>Transport: 创建传输(HTTP/SSE/Stdio) + Transport->>Server: 建立连接 + Server-->>MCP: 连接成功,获取工具列表 + MCP->>MCP: 缓存工具定义 (defs) + + Note over AI: AI 需要调用工具时 + AI->>MCP: tools() 获取所有可用工具 + MCP-->>AI: 返回 {clientName_toolName: Tool} + AI->>MCP: 调用 tool.execute(args) + MCP->>Server: client.callTool(name, args) + Server-->>AI: 返回执行结果 +``` + +### OAuth 认证流程 + +```mermaid +sequenceDiagram + participant MCP as MCP Service + participant Server as 远程服务器 + participant Auth as OAuth Provider + participant Browser as 浏览器 + + MCP->>Server: 尝试连接 + Server-->>MCP: 401 UnauthorizedError + MCP->>Auth: 启动 OAuth 流程 + Auth->>Browser: 打开授权 URL + Browser->>Auth: 回调 OAuth Code + Auth->>Server: 交换 Access Token + Auth->>MCP: 保存 token 到 mcp-auth.json + MCP->>Server: 重新连接(携带 token) +``` + +## 动手练习 + +### 练习 1:配置本地 MCP 服务器 + +```json +{ + "mcp": { + "my-tools": { + "command": ["npx", "-y", "my-mcp-server"], + "environment": { "API_KEY": "your-key" } + } + } +} +``` + +### 练习 2:配置远程 MCP 服务器 + +```json +{ + "mcp": { + "remote-server": { + "url": "https://mcp.example.com/api", + "headers": { "Authorization": "Bearer token" }, + "timeout": 60000 + } + } +} +``` + +### 练习 3:观察工具命名 + +启动 OpenCode 并查看日志,观察 MCP 工具如何被命名为 `serverName_toolName` 格式。 + +## 常见问题 + +**Q:StreamableHTTP 和 SSE 有什么区别?** +A:StreamableHTTP 是更新的双向流协议,SSE 是单向事件流的降级方案。系统会自动尝试前者,失败后回退到后者。 + +**Q:本地命令中 `BUN_BE_BUN: "1"` 是什么?** +A:当 MCP 命令是 `opencode` 自身时,这个环境变量确保 Bun 运行时正确初始化。 + +**Q:MCP 认证信息存储在哪里?** +A:存储在 `{dataDir}/mcp-auth.json`,文件权限为 `0o600`(仅当前用户可读写)。 + +**Q:工具列表变更后会自动更新吗?** +A:是的。系统通过 `ToolListChangedNotification` 监听服务器推送的工具变更事件,自动更新缓存。 + +**Q:默认超时是多少?** +A:默认 30 秒(`DEFAULT_TIMEOUT`),可通过 `mcp.timeout` 或全局 `experimental.mcp_timeout` 配置。 + +## 小结 + +MCP 集成模块是 OpenCode 连接外部工具生态的核心桥梁: + +- **三种传输层**:StreamableHTTP → SSE 降级(远程),Stdio(本地子进程) +- **并行初始化**:所有服务器 `concurrency: "unbounded"` 并发连接 +- **工具代理**:MCP 工具定义自动转换为 AI SDK Tool,命名空间隔离 +- **OAuth 支持**:完整的 OAuth 认证流程,token 安全存储 +- **生命周期管理**:Effect Finalizer 确保子进程正确清理,防止僵尸进程 +- **实时监听**:工具变更通知自动刷新缓存,通过 Bus 事件广播