架构

整个仓库是一张严格的依赖 DAG——上四层平台无关,下三层是可替换的适配器,所以加一个新 surface 等于只写适配器,不动业务逻辑。

加一个平台,应该只是写适配器,而不是重写逻辑

Zapvol 用一套 React + TypeScript 代码同时驱动 web 服务、Electron 桌面应用、Chrome 扩展三种形态。整套架构的存在,就是为了让这件事无需按平台复制业务逻辑。它建立在三个不变量上:

  1. 契约驱动的平台抽象——UI 组件面向 TypeScript 接口(端口)编程,从不直接依赖传输机制。同一套 React 代码同时服务 web 和 desktop;跨越平台边界,只需在应用入口替换端口背后的 适配器
  2. 以工厂函数做依赖反转——遵循依赖反转原则(DIP),服务以仓储 接口 为构造参数,从不接受具体数据库驱动。同一个 createTaskService(repo) 在 PostgreSQL 和 SQLite 上行为完全一致,存储后端由调用方在组装时决定。
  3. 无环依赖图——包之间构成严格的 DAG:common → backend → server|desktopcommon → 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。

依赖关系图

依赖关系图 (Dependency Graph) ↓ = 依赖于 @zapvol/common 类型 (Types) + Zod 校验 (Schemas) @zapvol/backend 服务 (Services)、智能体 (Agent) 基础设施 (Infra) @zapvol/app 界面 (UI)、Hooks、契约 (Contracts) server Hono + PG desktop Electron + SQLite web Vite + React 边界规则 (Boundary Rules) server 禁止导入 @zapvol/app web 禁止导入 @zapvol/backend marketing 完全独立

@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)。

分层架构

分层架构 共享层(靛蓝色)与 平台特定层(青色) 共享(@zapvol 包) 平台特定(apps) 界面层 (UI Layer) @zapvol/app — React 组件、页面、布局 (components/ pages/) 状态层 (State Layer) @zapvol/app — React Query(服务端状态)+ Zustand(客户端状态)(hooks/ stores/) 契约层 (Contract Layer) @zapvol/app — XxxService 接口,平台无关 (contracts/) 传输层 (Transport Layer) HTTP 模块 (web) / IPC 封装 (desktop) (api/modules/ ipc/) 路由层 (Route Layer) Hono 路由 (server) / IPC 处理器 (desktop) (routes/ handlers/) 服务层 (Service Layer) @zapvol/backend — 业务逻辑,100% 跨平台共享 (services/) 仓储层 (Repository Layer) PostgreSQL (server) / SQLite (desktop) — 相同接口,不同后端 (repositories/) 基础设施层 (Infrastructure Layer) @zapvol/backend — FileStorage、KeyEncryption、Sandbox、OAuth 接口 (infra/) 顶部组件在各平台完全一致,仅传输层和存储层有所不同。

各层在运行时经依赖注入组合,每一层只依赖下层的 接口,从不依赖具体实现——于是上四层(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。类型系统使不可能的状态不可表达。

平台抽象

平台抽象 (Platform Abstraction) 相同的界面,相同的业务逻辑 — 不同的传输和存储 Web 浏览器 (Browser)(端口 8000) React SPA — @zapvol/app 组件 + 页面 api/modules/ — 通过 createApiClient() 发起 HTTP 请求 BrowserRouter — SPA 路由 HTTP / SSE 服务端 (Server)(端口 8001) Hono 路由 — HTTP + SSE 端点 @zapvol/backend — 服务 + 智能体引擎 PostgreSQL — Drizzle ORM 桌面端 (Desktop / Electron) 渲染进程 (Renderer Process) React SPA — @zapvol/app 组件 + 页面 ipc/modules/ — electron.invoke() 封装 HashRouter — file:// 协议路由 IPC 主进程 (Main Process) IPC 处理器 — 异步消息处理 @zapvol/backend — 服务 + 智能体引擎 SQLite — better-sqlite3 高亮行使用来自 @zapvol/app 和 @zapvol/backend 的相同代码 — 仅传输层和存储层不同

本节对比承载 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 ORMSQLite via better-sqlite3
传输HTTP / SSE(默认)或 WebSocketIPC(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),用于抽象平台特定的关注点。每个平台提供各自的实现,在组装时注入。其中 ISandboxBrowserBridge 两个接口因为直接决定了 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)

关键设计决策

决策放弃了什么获得了什么
工厂函数替代 classinstanceof 检查、原型链继承、熟悉的 OOP 模式消除回调和解构中的 this 绑定 bug;真正的闭包私有(非 TypeScript 仅编译期的 private);更好的 tree-shaking 因为未使用的方法不在共享原型上。详见工厂函数
仓储接口在 @zapvol/backend比直接 DB 查询多一层间接同一份服务代码在 PostgreSQL 和 SQLite 上无修改运行。添加第三种存储后端(如边缘部署的 LibSQL)只需编写一个适配器——零服务变更
客户端契约通过 React Context需要在应用根部包裹 ClientProvider;比直接导入多一些仪式组件真正平台无关。测试替换一个 Context provider 而非 mock N 个模块导入。添加新平台意味着编写新适配器,而非修改组件
Zod schema 在 @zapvol/commonZod 成为每个包的运行时依赖校验的单一事实来源。服务端用同一个 schema 校验请求体,客户端用它做表单验证——客户端与服务端的校验规则不会漂移
h() 路由封装 + 条件类型高阶函数增加一层间接;对新贡献者可能不熟悉路由变成声明式单行代码。认证、body 校验和错误处理是结构性保证而非可选项。类型系统阻止了未认证时访问 user 或未校验时消费 body
结构化类型(不用 implements无运行时契约强制TypeScript 的结构化类型系统在编译时捕获接口不匹配。class Foo implements Bar 不增加运行时安全性——它在编译时被擦除。结构化类型以更少的语法实现了相同的保证
这页有帮助吗?