|
| 1 | +# 第 2 章:分层与依赖 —— 从 Schema 原子到多端产品 |
| 2 | + |
| 3 | +## 1. Workspace 不是按“业务页面”切包 |
| 4 | + |
| 5 | +OpenCode 的 package 划分更像一条依赖漏斗:越靠下越稳定、越靠上越接近产品宿主。 |
| 6 | + |
| 7 | +```mermaid |
| 8 | +flowchart BT |
| 9 | + S["schema: 可编码领域形状"] |
| 10 | + P["protocol: API / error / transport contract"] |
| 11 | + L["llm: canonical message / provider event"] |
| 12 | + C["core: Effect services + DB + Session V2"] |
| 13 | + SV["server: middleware / HTTP / location"] |
| 14 | + OC["opencode: CLI + product orchestration"] |
| 15 | + CL["client: generated promise API"] |
| 16 | + SDK["sdk-next: client + core + server composition"] |
| 17 | + UI["tui / app / web / desktop"] |
| 18 | + S --> P |
| 19 | + S --> L |
| 20 | + P --> SV |
| 21 | + L --> C |
| 22 | + S --> C |
| 23 | + C --> SV |
| 24 | + C --> OC |
| 25 | + SV --> OC |
| 26 | + P --> CL |
| 27 | + CL --> UI |
| 28 | + C --> SDK |
| 29 | + SV --> SDK |
| 30 | + SDK --> UI |
| 31 | +``` |
| 32 | + |
| 33 | +仓库 `AGENTS.md` 给出的原则可以压缩成一句话:**Schema → Core / Protocol → Server;Client 只能依赖 Schema / Protocol,不能反向依赖 Core / Server;sdk-next 才组合 Client、Core、Server。** |
| 34 | + |
| 35 | +## 2. 每一层的“原子”和“分子” |
| 36 | + |
| 37 | +### 2.1 Schema:可以存、传、回放的形状 |
| 38 | + |
| 39 | +`packages/schema/src` 定义 Session、Message、Part、Prompt、Event、Permission、Project、Workspace 等数据。它通过 Effect Schema 建立编码与解码关系,ID 也有前缀和格式约束。 |
| 40 | + |
| 41 | +Schema 的价值不只是类型检查: |
| 42 | + |
| 43 | +- HTTP body 和 query 可以由同一份定义解码; |
| 44 | +- SQLite projector 可以把事件转成同样的结构; |
| 45 | +- SDK codegen 可以从 API 定义生成客户端; |
| 46 | +- Event Manifest 可以把事件变成可订阅的联合类型。 |
| 47 | + |
| 48 | +### 2.2 Protocol:把领域形状变成 API 契约 |
| 49 | + |
| 50 | +`packages/protocol/src` 放跨 server/client 的错误和 API 描述。它不应该知道具体的 `SessionRunner`,但可以描述 `SessionNotFoundError` 这样的稳定错误边界。 |
| 51 | + |
| 52 | +### 2.3 LLM:给模型世界一个中间表示 |
| 53 | + |
| 54 | +`packages/llm/src/schema/messages.ts` 把消息、文本、reasoning、tool-call、tool-result、ToolDefinition 和 LLMRequest 标准化。provider 适配器只需要把 canonical request 翻译成自己的 wire format,再把流翻译回 LLMEvent。 |
| 55 | + |
| 56 | +### 2.4 Core:可复用的领域运行时 |
| 57 | + |
| 58 | +Core 是真正的“骨骼”: |
| 59 | + |
| 60 | +- `packages/core/src/session`:Session V2、History、Input、Runner、Compaction; |
| 61 | +- `packages/core/src/system-context`:上下文 Source、Registry、Baseline; |
| 62 | +- `packages/core/src/tool`:跨宿主可用的工具与输出治理; |
| 63 | +- `packages/core/src/database`:Effect + Drizzle + SQLite; |
| 64 | +- `packages/core/src/project`、`location`、`workspace`:作用域与持久化; |
| 65 | +- `packages/core/src/provider`、`catalog`:模型和 provider 的领域表示。 |
| 66 | + |
| 67 | +### 2.5 Server / OpenCode:产品化装配 |
| 68 | + |
| 69 | +`packages/server` 里是通用 HTTP / Location 中间件;`packages/opencode` 里则是具体的 CLI、旧版服务、配置发现、插件加载、MCP、LSP、路由 handlers 和应用 runtime。这样核心服务可被不同宿主使用,但产品入口仍然集中管理。 |
| 70 | + |
| 71 | +## 3. 为什么 TUI 不直接 import 后端 |
| 72 | + |
| 73 | +`specs/tui-package.md` 把 TUI 抽取的目标写得很清楚:TUI 通过 `@opencode-ai/sdk` 获取 Session、Message、File、Provider、Agent、Permission 等数据,缺少的能力要先加到 server API 和 generated SDK,而不是直接 import `packages/opencode` 内部实现。 |
| 74 | + |
| 75 | +这是一个很实用的架构测试:如果一个 UI 功能必须直接读取后端 service,说明 API 边界还没有表达完整的产品能力。 |
| 76 | + |
| 77 | +## 4. Effect Layer 是依赖注入图 |
| 78 | + |
| 79 | +`AppLayer` 不是“把很多服务放进数组”。每一个 `LayerNode` 表示一个服务的构造函数和依赖,`Layer.provideMerge` 让共享的 Node / observability / runtime 被合并。效果是: |
| 80 | + |
| 81 | +```text |
| 82 | +数据库、FS、配置、认证 |
| 83 | + → Project / Provider / Agent |
| 84 | + → Session / Context / LLM / Tools |
| 85 | + → HTTP handlers / CLI / TUI host |
| 86 | +``` |
| 87 | + |
| 88 | +当测试要替换 Database、LLM 或 Provider 时,不需要改业务函数签名,只需要提供另一层实现。这也是 `Effect.Service` 比全局单例更适合 OpenCode 的原因。 |
| 89 | + |
| 90 | +## 5. V1 / V2 为什么会在依赖图中同时出现 |
| 91 | + |
| 92 | +当前仓库不是一次性重写,而是在迁移: |
| 93 | + |
| 94 | +- V1 的 `packages/opencode/src/session/*` 仍承担大量产品功能和兼容入口; |
| 95 | +- V2 的 `packages/core/src/session/*` 把 durable inbox、Location-scoped runner、Context Epoch 和 Event V2 作为新的规范化方向; |
| 96 | +- `packages/opencode/src/event-v2-bridge.ts`、projector 和 API 层负责把两边逐步接起来。 |
| 97 | + |
| 98 | +这解释了为什么阅读时会遇到 `SessionV1`、`SessionV2`、`MessageV2` 和两套 event schema。正确做法不是把重复代码马上合并,而是先确认每个类型属于哪个状态模型。 |
| 99 | + |
| 100 | +## 6. 三个可迁移的设计方法 |
| 101 | + |
| 102 | +### 方法 1:依赖漏斗 |
| 103 | + |
| 104 | +让底层只依赖稳定数据和小接口,让上层决定宿主、显示和网络。越靠近 UI,依赖越多;越靠近 Schema,依赖越少。 |
| 105 | + |
| 106 | +### 方法 2:语义边界优先于文件边界 |
| 107 | + |
| 108 | +`SessionRunner`、`SystemContextRegistry`、`ToolRegistry` 都不是单个文件,而是由 schema、store、service、event 和测试共同组成的边界。看源码要按语义组,而不是只看文件名。 |
| 109 | + |
| 110 | +### 方法 3:用 API 反推领域模型 |
| 111 | + |
| 112 | +从 `SessionApi` 的路由反向看:如果 API 要支持 prompt、abort、compact、revert、permission、find file、share,那么后端必然需要对应的 Session、Input、Compaction、Revert、Permission、File 和 Share 服务。 |
| 113 | + |
| 114 | +## 本章小结 |
| 115 | + |
| 116 | +OpenCode 的分层不是“为了好看”,而是为了解决三个变化维度:模型供应商会变、客户端宿主会变、Session 执行语义会迁移。Schema / Protocol 稳定跨边界,Core 承载领域状态,Server 提供运行时,UI 只消费契约。 |
| 117 | + |
| 118 | +### 源码锚点 |
| 119 | + |
| 120 | +- [`AGENTS.md`](https://github.com/anomalyco/opencode/blob/dev/AGENTS.md) |
| 121 | +- [`specs/tui-package.md`](https://github.com/anomalyco/opencode/blob/dev/specs/tui-package.md) |
| 122 | +- [`packages/opencode/src/effect/app-runtime.ts`](https://github.com/anomalyco/opencode/blob/dev/packages/opencode/src/effect/app-runtime.ts) |
| 123 | +- [`packages/schema/src/session-event.ts`](https://github.com/anomalyco/opencode/blob/dev/packages/schema/src/session-event.ts) |
0 commit comments