架构
整个仓库是一张严格的依赖 DAG——上四层平台无关,下三层是可替换的适配器,所以加一个新 surface 等于只写适配器,不动业务逻辑。
加一个平台,应该只是写适配器,而不是重写逻辑
Zapvol 用一套 React + TypeScript 代码同时驱动 web 服务、Electron 桌面应用、Chrome 扩展三种形态。整套架构的存在,就是为了让这件事无需按平台复制业务逻辑。它建立在三个不变量上:
- 契约驱动的平台抽象——UI 组件面向 TypeScript 接口(端口)编程,从不直接依赖传输机制。同一套 React 代码同时服务 web 和 desktop;跨越平台边界,只需在应用入口替换端口背后的 适配器。
- 以工厂函数做依赖反转——遵循依赖反转原则(DIP),服务以仓储 接口 为构造参数,从不接受具体数据库驱动。同一个
createTaskService(repo)在 PostgreSQL 和 SQLite 上行为完全一致,存储后端由调用方在组装时决定。 - 无环依赖图——包之间构成严格的 DAG:
common → backend → server|desktop、common → app → web|desktop,以及common → bua(BUA 仅依赖common,详见下文依赖关系图)。违反此图的导入在代码审查时被拦截;没有运行时强制,架构信任此图,由约定维护。
这三个不变量共同确保:新增一个 SPA 形态的 surface(如 mobile)只需编写适配器——为新存储引擎实现仓储、为新 IPC 机制编写传输封装——业务逻辑、UI 组件与类型契约全部不动。运行时根本不同的 surface——例如 Chrome 扩展(BUA),其 Service Worker + content script 沙箱无法承载 @zapvol/app——则与共享代码库并列存在而非复用,仅共享 @zapvol/common 的类型契约与跨切协议 schema。
Monorepo 拓扑
zapvol/
├── packages/
│ ├── common/ @zapvol/common — 纯类型 + Zod 校验(零行为)
│ ├── backend/ @zapvol/backend — 服务、仓储接口、Agent 引擎、基础设施
│ ├── app/ @zapvol/app — React 组件、hooks、页面、i18n、客户端契约
│ └── http/ @zapvol/http — 共享 Hono HTTP 层(路由工厂 + h()),server 与 desktop 共用
└── apps/
├── web/ web — Web 前端(Vite + React,端口 8000)
├── server/ @zapvol/server — API 服务器(Hono + Node,端口 8001)
├── desktop/ @zapvol/desktop — Electron 桌面应用(端口 8002)
└── bua/ @zapvol/bua — Chrome MV3 扩展(WXT + React,Browser Use Agent)
packages/ 与 apps/ 的分割反映了一个根本区分:packages 是库(被导入,从不部署),而
apps 是可部署产物(每个产出一个运行进程或静态站点)。Turborepo 的构建管线尊重这一点:turbo build 按依赖图拓扑排序,在消费它们的 apps 之前先构建 packages。
依赖关系图
@zapvol/common 位于依赖树的根部。它只导出类型、Zod
schema 和纯函数工具——零 I/O、零状态、零副作用。此约束确保每个包和应用都能安全地依赖它,而不会引入不必要的传递依赖。
涉及 I/O、数据库或外部服务的共享逻辑放在 @zapvol/backend
中。这条边界是刻意设置的:@zapvol/app(发布到浏览器)绝不能导入 @zapvol/backend(依赖 Node.js
API)。违反此规则将导致服务端代码被打包进客户端。
apps/bua 是共享代码库模型的有意例外。它只能依赖 @zapvol/common,不能依赖 @zapvol/app 或
@zapvol/backend。Chrome MV3 运行时没有 Node API,workspace 打包超过 CSP 与体积预算,且 Service Worker + content
script 的生命周期与 SPA 进程模型有根本差异。因此扩展在自己物理隔离的代码库中镜像了同一套 5-layer 模式,仅共享
@zapvol/common 中的类型契约(特别是定义 Agent ↔ 扩展协议的 browser-bridge schemas)。
分层架构
各层在运行时经依赖注入组合,每一层只依赖下层的 接口,从不依赖具体实现——于是上四层(UI、状态、契约、传输)平台无关,下三层(路由、仓储、基础设施)是平台特定的适配器。
契约模式
契约层是将 UI 与传输解耦的架构缝合点。每个业务领域定义一个纯 TypeScript 接口——无装饰器、无基类、无框架耦合:
// packages/app/src/contracts/task.contract.ts
export interface TaskClient {
list(options?: TaskListQuery): Promise<TaskListPage>;
get(id: string): Promise<TaskDetail>;
create(data: CreateTaskInput): Promise<CreateTaskData>;
update(id: string, data: UpdateTaskInput): Promise<TaskDetail>;
remove(id: string): Promise<void>;
// …另有 getMessages、stream、abort、setMessageFeedback、readArtifact
}
契约通过单个 React Context 提供(持有所有领域客户端),并通过领域专用 hook 消费:
// packages/app/src/context/service-context.tsx
interface Clients {
auth: AuthClient;
task: TaskClient;
chat: ChatClient;
agent: AgentClient;
// ... 每个业务领域一项
}
const ClientContext = createContext<Clients | null>(null);
export function ClientProvider({ clients, children }: Props) {
return <ClientContext.Provider value={clients}>{children}</ClientContext.Provider>;
}
// 每个领域一个 hook — 组件永远不会看到完整的 Clients 集合
export const useTaskClient = () => useClients().task;
当组件调用 useTaskClient().list() 时,它不知道此调用最终是 HTTP fetch、Electron
IPC 消息还是测试环境中的直接函数调用。平台在应用根部注入具体实现。
平台接线
每个平台提供一个工厂,经各自的原生传输机制满足契约接口。
Web——HTTP 模块委托给共享的 request 函数,负责序列化、错误映射与认证头:
// packages/app/src/api/modules/task.ts
export function createTaskModule(request: RequestFn) {
return {
list: (options?) => request<TaskListPage>("/api/tasks", { params: options }),
get: (id) => request<TaskDetail>(`/api/tasks/${id}`),
create: (data) => request<CreateTaskData>("/api/tasks", { method: "POST", body: data }),
remove: (id) => request<void>(`/api/tasks/${id}`, { method: "DELETE" }),
// …update、getMessages、stream、abort……
};
}
Desktop——IPC 封装把每个方法映射到 Electron invoke 调用,采用通道名约定(domain:method):
// apps/desktop/src/renderer/ipc/task.ts
export function createTaskClient(): TaskClient {
return {
list: (options) => window.electron.invoke("task:list", options),
get: (id) => window.electron.invoke("task:get", id),
create: (data) => window.electron.invoke("task:create", data),
update: (id, data) => window.electron.invoke("task:update", id, data),
remove: (id) => window.electron.invoke("task:remove", id),
// …getMessages、stream、abort、setMessageFeedback、readArtifact
};
}
两者都靠 TypeScript 的结构化类型系统满足 TaskClient——无需显式 implements 关键字,也无运行时注册。方法签名一旦偏移,编译器在构建时就会报错。
类型安全的路由处理器(Server)
路由层是平台特定的——服务端用 Hono 路由,桌面端用 IPC handlers,两者都委托给 @zapvol/backend 的同一份共享服务。在服务端,路由使用声明式 h() 封装,将认证、Zod 校验和业务处理组合到单个 Hono 中间件中:
// apps/server/src/routes/tasks.ts
app.post(
"/",
h({ auth: true, body: createTaskSchema, status: 201 }, async ({ user, body }) => {
return taskService.create(user.id, body); // body 类型自动推导为 z.infer<typeof createTaskSchema>
}),
);
关键设计洞察在 h() 的类型签名——它用条件交叉类型从配置推导出处理器上下文:
type HandlerContext<TAuth, TBody, TQuery> = { params: Record<string, string> } & (TAuth extends AuthOption
? { user: AuthUser }
: {}) & // 仅当配置了 auth 时 user 存在
(TBody extends z.ZodTypeAny ? { body: z.infer<TBody> } : {}); // body 从 schema 推导类型
这消除了一整类 bug:在未认证的情况下访问 user,或消费未校验的 body。类型系统使不可能的状态不可表达。
平台抽象
本节对比承载 Agent runtime 的两个 surface——Web(Server)与 Desktop(Electron)。Browser Extension (BUA) 有意不列为单独一列——因为它不承载 Agent:Agent 运行在 Web 或 Desktop 上,BUA 只是 Agent 经 BrowserBridge 操控的远程客户端。BUA 的架构定位详见 BUA 概览。
对比矩阵
| 关注点 | Web(Server) | Desktop(Electron) |
|---|---|---|
| 数据库 | PostgreSQL via Drizzle ORM | SQLite via better-sqlite3 |
| 传输 | HTTP / SSE(默认)或 WebSocket | IPC(Electron 异步处理器) |
| 认证 | better-auth + JWT(HTTP-only cookies) | 硬编码 local-user,无 auth flow |
| 文件存储 | Cloudflare R2(S3 兼容 API) | 本地文件系统 |
| 密钥加密 | 明文(no-op KeyEncryption 端口) | Electron SafeStorage(OS 钥匙串) |
| 沙箱 | Node(工厂可配 Daytona / E2B,当前占位) | Node(仅本地文件系统) |
| 流恢复 | Redis 支持的可恢复 SSE(仅 SSE 路径) | 直接 IPC 事件通道 |
Desktop 组装根
桌面端主进程镜像了服务端的架构。仓储与服务在应用启动时一次性组装——即组装根(Composition Root):
// apps/desktop/src/main/handlers/index.ts
export function registerAllHandlers(db: DesktopDatabase, getWindow: () => BrowserWindow | null) {
const taskRepo = createTaskRepository(db); // SQLite 实现
const taskService = createTaskService(taskRepo); // 和服务端完全相同的工厂 — 来自 @zapvol/backend
registerTaskHandlers(taskService); // 薄 IPC 胶水 → 共享服务
// ... 对每个领域重复
}
createTaskService 从 @zapvol/backend 导入——与服务端用的是 同一个函数。唯一差异是传入的仓储:服务端 PostgreSQL,桌面端 SQLite。这正是依赖反转原则的实地运用。
基础设施接口
@zapvol/backend
定义了一组基础设施接口(ports),用于抽象平台特定的关注点。每个平台提供各自的实现,在组装时注入。其中 ISandbox 与
BrowserBridge 两个接口因为直接决定了 Agent 能做什么,单独走查;其余在本节末尾以表格列出。
ISandbox
ISandbox
接口抽象了 Agent 的执行环境。所有文件系统工具和 shell 工具都通过它派发操作,使工具实现完全不感知底层运行的是本地文件系统、Daytona 容器还是 E2B 云端沙箱(目前仅 Node adapter 落地,Daytona / E2B 的 config 类型已定义、adapter 待移植):
export interface ISandbox {
readonly type: SandboxType; // "node" | "daytona" | "e2b"
readonly workspace: string; // 根工作目录
readonly capabilities: SandboxCapabilities; // 工具过滤用的功能标志
ensureReady(): Promise<ISandbox>; // 生命周期:确保沙箱可用
// 文件操作 — 签名精确匹配 Agent 工具参数
ls(options: LsOptions): Promise<LsResult>;
readFile(options: ReadFileOptions): Promise<ReadFileResult>;
writeFile(options: WriteFileOptions): Promise<WriteFileResult>;
editFile(options: EditFileOptions): Promise<EditFileResult>;
glob(options: GlobOptions): Promise<GlobResult>;
grep(options: GrepOptions): Promise<GrepResult>;
// 命令执行,支持可选的流式回调
execute(options: {
command: string;
timeout?: number;
onStdout?: (line: string) => void;
onStderr?: (line: string) => void;
}): Promise<ExecutionResult>;
}
沙箱选择用可辨识联合配置——type 字段作辨识符,每个变体携带各自的平台配置:
type SandboxConfig = NodeSandboxConfig | DaytonaSandboxConfig | E2BSandboxConfig;
BrowserBridge
BrowserBridge 是 BUA 在后端侧的 port。Agent 的 browser 工具把每一个动作都通过 BrowserBridge.request()
派发;平台决定调用如何抵达用户的扩展:
- Server——Hono WebSocket endpoint 按用户池化扩展连接;某用户的 bridge 实例解析为该用户当前活跃的 socket。
- Desktop——Electron 主进程在 loopback 启动 WebSocket 服务(
127.0.0.1:48123),通过 per-machine pairing token 鉴权;bridge 解析为本机唯一连接。
scope 校验(domain blocklist、首次 action 自动建 session)在扩展侧执行,因此后端把
{ ok: false, error: "domain_blocked" } 视为权威结果——Agent 没有任何机会绕过用户 deny-list。详见
BUA → Session 模型。
其他基础设施端口
| 接口 | 职责 | Server 适配器 | Desktop 适配器 |
|---|---|---|---|
FileStorage | 持久化上传文件和压缩卸载数据 | Cloudflare R2(S3 API) | 本地文件系统 |
KeyEncryption | 静态加密敏感数据(API 密钥、MCP 凭证) | 明文(no-op 适配器) | Electron SafeStorage |
OAuthTokenRefresher | 刷新 MCP 服务器连接的过期 OAuth 令牌 | 真实 OAuth 提供商调用 | No-op(本地模式) |
JobQueue | 后台任务执行(标题生成、压缩等) | 持久化队列 | 进程内队列 |
TaskLock | 防止同一任务并发执行 | Redis 支持 | 内存 |
StreamBuffer | 缓冲 SSE 事件以支持可恢复流 | Redis 支持 | n/a(直接 IPC) |
关键设计决策
| 决策 | 放弃了什么 | 获得了什么 |
|---|---|---|
| 工厂函数替代 class | instanceof 检查、原型链继承、熟悉的 OOP 模式 | 消除回调和解构中的 this 绑定 bug;真正的闭包私有(非 TypeScript 仅编译期的 private);更好的 tree-shaking 因为未使用的方法不在共享原型上。详见工厂函数 |
仓储接口在 @zapvol/backend | 比直接 DB 查询多一层间接 | 同一份服务代码在 PostgreSQL 和 SQLite 上无修改运行。添加第三种存储后端(如边缘部署的 LibSQL)只需编写一个适配器——零服务变更 |
| 客户端契约通过 React Context | 需要在应用根部包裹 ClientProvider;比直接导入多一些仪式 | 组件真正平台无关。测试替换一个 Context provider 而非 mock N 个模块导入。添加新平台意味着编写新适配器,而非修改组件 |
Zod schema 在 @zapvol/common | Zod 成为每个包的运行时依赖 | 校验的单一事实来源。服务端用同一个 schema 校验请求体,客户端用它做表单验证——客户端与服务端的校验规则不会漂移 |
h() 路由封装 + 条件类型 | 高阶函数增加一层间接;对新贡献者可能不熟悉 | 路由变成声明式单行代码。认证、body 校验和错误处理是结构性保证而非可选项。类型系统阻止了未认证时访问 user 或未校验时消费 body |
结构化类型(不用 implements) | 无运行时契约强制 | TypeScript 的结构化类型系统在编译时捕获接口不匹配。class Foo implements Bar 不增加运行时安全性——它在编译时被擦除。结构化类型以更少的语法实现了相同的保证 |