首页 / 资讯中心 / 文章详情

DeepSeek Harness Desktop 源码解析:Web 与 Electron 共享 UI 架构设计

DeepSeek Harness Desktop 源码解析:Web 与 Electron 共享 UI 架构设计 ★ FEATURED ARTICLE
1. 从一份源码说起为什么共享 UI 是个值得聊的话题第一次看到 DeepSeek Harness Desktop 这个项目结构的时候我脑子里冒出来的第一个念头是这套东西的 UI 层和 Runtime 层居然真的做到了一份代码同时喂给 Web 端和本地桌面端。这不是那种“写两套壳子共用几个工具函数”的伪共享而是从组件树到状态管理再到通信协议整条链路都做了抽象。做过 Electron 项目的人应该都有体会Web 和本地 Runtime 的边界一旦处理不好最后就是两套代码互相打架改一个按钮要在三个地方同步。这个项目解决的核心问题其实很具体同一套 UI 代码既要跑在浏览器里连远程服务又要跑在 Electron 里连本地进程还要保证交互体验一致、状态同步不丢、错误处理统一。听起来像是架构师画 PPT 时才会提的需求但 DeepSeek Harness Desktop 把它落地了。适合谁看如果你正在做 Electron 应用、正在考虑 Web 与桌面端共用前端、或者单纯想看看一个成熟项目怎么切分 UI 与 Runtime 的职责边界这份源码值得花时间啃。我花了大概两周时间把它的核心模块过了一遍下面把我理解到的设计思路、关键实现、踩过的坑和实操建议整理出来。不是官方文档的复述是我自己读代码和跑项目时的真实记录。2. 整体架构拆解UI 与 Runtime 的边界到底画在哪2.1 三层结构UI Shell、Adapter、Runtime HostDeepSeek Harness Desktop 的目录结构第一眼看上去并不复杂但仔细看会发现它把代码分成了三个清晰的层次。最上面是UI Shell也就是所有 React 组件、样式、路由、状态管理所在的地方。中间是Adapter 层这一层是整套架构的关键它定义了一组接口把“UI 需要什么”和“Runtime 提供什么”隔开。最下面是Runtime Host在 Web 环境下是 HTTP/WebSocket 客户端在 Electron 环境下是 IPC 通道加本地进程管理。这种分法的好处在于UI Shell 完全不知道自己跑在什么环境里。它只调用 Adapter 暴露的方法比如invokeTool()、subscribeState()、sendMessage()。至于这些方法背后是发 HTTP 请求还是走 IPCUI 层不关心。我第一次看到这个设计的时候觉得有点过度抽象但后来改了一个功能就明白了我要在 Web 端加一个重试逻辑只需要改 Adapter 的 Web 实现Electron 端完全不受影响。注意Adapter 层的接口设计要足够稳定否则每次加功能都要改接口共享 UI 就变成了共享负担。DeepSeek Harness Desktop 的做法是把接口按领域分组比如会话管理、工具调用、文件操作、状态订阅每组接口独立演进。2.2 为什么不用 Electron 的 remote 模块直接共享很多人第一反应是Electron 不是有 remote 模块吗直接让渲染进程调用主进程不就行了我早期也这么想过但实际用下来问题很多。remote 模块的调用是同步阻塞的UI 线程容易被卡住而且 remote 的 API 和 Web 端的 HTTP 调用完全不一样UI 层还是要写两套逻辑。更关键的是remote 模块的安全模型一直有争议Electron 官方也在逐步弱化它的使用。DeepSeek Harness Desktop 选择的是显式 IPC 协议抽象的路线。渲染进程通过contextBridge暴露一组有限的 API主进程收到请求后转发给本地 Runtime 进程Runtime 处理完再原路返回。整个过程是异步的UI 层拿到的 Promise 和 Web 端 fetch 返回的 Promise 在形态上一致。这样一来Adapter 的 Electron 实现和 Web 实现就可以用同一套错误处理、同一套超时逻辑、同一套重试策略。2.3 状态同步谁持有真相共享 UI 最头疼的问题之一是状态放哪。Web 端的状态在浏览器内存里Electron 端的状态可能分散在渲染进程、主进程和 Runtime 进程三处。DeepSeek Harness Desktop 的做法是Runtime 持有权威状态UI 只持有投影。所有状态变更都从 Runtime 发起通过 Adapter 推送到 UIUI 不直接修改状态而是发送意图intent给 Runtime等 Runtime 确认后再更新本地投影。这个模式在 Redux 圈子里叫“单向数据流”但放到跨进程场景下意义更大。因为进程间通信有延迟如果 UI 乐观更新了状态但 Runtime 拒绝了就会出现不一致。DeepSeek Harness Desktop 对某些高频操作做了乐观更新但都配了回滚逻辑而且回滚的粒度控制得很细不会因为一个工具调用失败就把整个会话状态重置。3. Adapter 层的核心实现接口设计与通信协议3.1 接口定义按领域切分而不是按传输方式切分我见过不少项目在 Adapter 层按传输方式切分比如httpAdapter、ipcAdapter每个适配器实现一堆方法。这种切法的问题是UI 层还是要判断当前环境然后选择调用哪个适配器。DeepSeek Harness Desktop 的做法是按领域切分接口比如SessionAdapter、ToolAdapter、FileAdapter每个接口定义一组语义化的方法然后由WebSessionAdapter和ElectronSessionAdapter分别实现。interface SessionAdapter { createSession(config: SessionConfig): PromiseSession; sendMessage(sessionId: string, message: Message): Promisevoid; subscribeState(sessionId: string, callback: (state: SessionState) void): Unsubscribe; closeSession(sessionId: string): Promisevoid; }UI 层只依赖SessionAdapter这个接口具体实现通过依赖注入在应用启动时决定。Web 入口注入WebSessionAdapterElectron 入口注入ElectronSessionAdapter。这样 UI 组件里没有任何if (isElectron)的判断代码干净很多。3.2 通信协议消息格式的统一与差异处理Web 端和 Electron 端的通信协议在消息格式上做了统一都用 JSON-RPC 风格的请求响应结构。但两者在传输层有本质差异Web 端走 HTTP 加 WebSocketElectron 端走 IPC 加本地 Socket。DeepSeek Harness Desktop 在 Adapter 层下面又加了一个Transport 层专门处理传输差异。Transport 层要解决的问题包括Web 端的 WebSocket 断线重连、Electron 端的 IPC 通道生命周期管理、两端的超时和取消机制。我读代码时发现一个细节Web 端的超时是用AbortController实现的Electron 端的超时是通过在主进程设置定时器然后发送取消消息实现的。虽然实现不同但暴露给上层的接口都是Promise加AbortSignalUI 层用起来完全一样。对比项Web 端实现Electron 端实现请求传输HTTP fetchIPC invoke流式传输WebSocketIPC event stream超时控制AbortController主进程定时器 取消消息断线重连WebSocket 自动重连IPC 通道重建错误传播HTTP 状态码 错误体序列化错误对象3.3 错误处理跨进程的错误传播跨进程通信最容易出问题的地方就是错误传播。Web 端的错误是 HTTP 状态码加 JSON 错误体Electron 端的错误是序列化的 Error 对象两者结构不一样。DeepSeek Harness Desktop 定义了一个统一的AdapterError类型包含code、message、details三个字段两端的 Transport 层负责把原生错误转换成这个统一类型。实操心得错误码的设计要足够细但不要细到每个接口一套。DeepSeek Harness Desktop 的错误码分了三层传输层错误网络断开、超时、协议层错误消息格式不对、版本不匹配、业务层错误工具调用失败、权限不足。UI 层根据错误码决定是重试、提示用户还是降级处理。4. 实操过程从零跑通共享 UI 的关键步骤4.1 环境准备与项目结构理解拿到源码后第一步是跑起来。DeepSeek Harness Desktop 的仓库结构大致是这样的packages/ui放共享 UI 代码packages/adapter放接口定义和两端实现packages/runtime放本地 Runtime 的封装apps/web和apps/electron分别是两个入口。依赖管理用的是 pnpm workspace我第一次跑的时候因为 Node 版本不对卡了半天后来切到 18.x 才顺利安装。# 安装依赖 pnpm install # 启动 Web 端开发环境 pnpm --filter dsh/web dev # 启动 Electron 端开发环境 pnpm --filter dsh/electron devWeb 端启动后是一个 Vite 开发服务器Electron 端启动后会先编译主进程代码再拉起窗口。两个入口都依赖packages/ui和packages/adapter所以改共享代码后两端都会热更新。我第一次改了一个按钮的样式两端同时生效那种感觉还是挺爽的。4.2 关键配置如何让 Vite 和 Electron 共用一套 UI 代码共享 UI 代码在构建配置上要处理几个问题。Web 端用 Vite 构建Electron 端渲染进程也可以用 Vite但主进程要用 esbuild 或 tsc 单独编译。DeepSeek Harness Desktop 的做法是把 UI 代码编译成 ES Module 包Web 端和 Electron 渲染进程都直接引用这个包。这样构建配置只需要在包级别维护一套两个入口各自处理自己的打包逻辑。Vite 配置里有一个关键点是resolve.alias要把dsh/ui指向源码目录而不是编译产物这样开发时热更新才能正常工作。Electron 端的 Vite 配置还要额外处理base路径因为 Electron 加载的是本地文件而不是 HTTP 服务相对路径和绝对路径的行为不一样。// vite.config.ts 关键配置 export default defineConfig({ resolve: { alias: { dsh/ui: path.resolve(__dirname, ../ui/src), dsh/adapter: path.resolve(__dirname, ../adapter/src), }, }, base: process.env.ELECTRON ? ./ : /, });4.3 运行时切换同一份 UI 如何感知当前环境虽然 UI 层不直接判断环境但应用启动时需要决定注入哪个 Adapter 实现。DeepSeek Harness Desktop 在入口文件里做这件事。Web 入口的main.tsx创建WebAdapterFactoryElectron 入口的renderer.tsx创建ElectronAdapterFactory然后通过 React Context 把 Adapter 实例传给整个组件树。// Web 入口 const adapter createWebAdapter({ baseUrl: /api }); ReactDOM.createRoot(root).render( AdapterProvider adapter{adapter} App / /AdapterProvider ); // Electron 入口 const adapter createElectronAdapter({ ipcChannel: dsh-runtime }); ReactDOM.createRoot(root).render( AdapterProvider adapter{adapter} App / /AdapterProvider );这种依赖注入的方式让 UI 组件完全无感知环境差异。我试过在 Web 端把 Adapter 换成 mock 实现UI 照样跑只是数据是假的。这对写测试特别友好不需要启动 Electron 就能测 UI 逻辑。4.4 本地 Runtime 的启动与连接Electron 端的本地 Runtime 是一个独立进程主进程负责启动它并建立 IPC 通道。DeepSeek Harness Desktop 的 Runtime 进程启动时会监听一个本地 Socket主进程通过这个 Socket 和 Runtime 通信渲染进程再通过 IPC 和主进程通信。整条链路是渲染进程 - IPC - 主进程 - Socket - Runtime 进程。启动 Runtime 的代码在主进程的app.whenReady()回调里要先检查 Runtime 二进制是否存在然后 spawn 子进程等待 Runtime 输出就绪信号后再创建窗口。我第一次跑的时候 Runtime 启动失败原因是二进制文件没有执行权限chmod 一下就好了。这个坑在官方文档里没写但实际部署时经常遇到。注意Runtime 进程的生命周期要和主进程绑定主进程退出时要确保 Runtime 也被杀掉否则会留下僵尸进程。DeepSeek Harness Desktop 用了tree-kill来确保子进程树被完整清理。5. 常见问题与排查技巧实录5.1 插件加载失败failed to load plugins web boot 的排查思路热词里提到的failed to load plugins web boot: 2 entries did not activate是这类项目常见的问题。我在跑 DeepSeek Harness Desktop 时也遇到过类似报错排查下来通常是三个原因插件依赖的 Runtime 版本不匹配、插件注册的激活事件没有被触发、插件代码本身有语法错误。排查步骤我整理了一个顺序先看 Runtime 日志里插件的加载顺序确认插件是否被扫描到然后检查插件的package.json里engines字段是否和当前 Runtime 版本兼容最后单独加载插件代码看有没有抛异常。DeepSeek Harness Desktop 的插件系统在启动时会输出每个插件的激活状态日志级别调到 debug 就能看到详细信息。报错信息可能原因排查方法entries did not activate激活事件未触发检查 activationEvents 配置plugin not found插件路径不对确认插件目录和扫描路径一致version mismatchRuntime 版本不兼容对比 engines 字段和实际版本timeout loading plugin插件初始化超时检查插件启动逻辑是否有阻塞5.2 UI 卡顿渲染进程与 Runtime 通信的性能优化UI 界面卡顿在 Electron 项目里很常见DeepSeek Harness Desktop 因为多了一层 IPC 通信更容易出现这个问题。我实测下来卡顿主要来自两个地方一是高频状态更新导致 React 重渲染过多二是 IPC 消息序列化开销太大。针对第一个问题DeepSeek Harness Desktop 用了状态分片加选择器订阅的方式每个组件只订阅自己需要的状态片段避免全局重渲染。针对第二个问题他们对 IPC 消息做了批量合并把短时间内的多个小消息合并成一个大消息发送减少 IPC 调用次数。我试过把批量合并的窗口从 16ms 调到 50msUI 流畅度明显提升但状态更新的实时性会稍微降低需要根据场景权衡。5.3 Runtime 连接失败no lm runtime found 类错误的处理热词里出现的no lm runtime found for model format gguf这类错误本质上是 Runtime 找不到对应格式的模型加载器。DeepSeek Harness Desktop 的 Runtime 支持多种模型格式每种格式需要对应的后端支持。如果启动时没有检测到某个格式的后端就会报这个错。处理方法是先确认 Runtime 的配置文件里是否启用了对应的后端然后检查后端二进制是否在预期路径下。有些后端需要额外的动态库依赖在 Linux 上经常因为缺少.so文件而加载失败。我建议在 Runtime 启动时加一个自检步骤把所有支持的后端列出来缺哪个一目了然。5.4 Electron 打包后的路径问题开发环境跑得好好的打包后 UI 加载不出来这是 Electron 项目的经典坑。DeepSeek Harness Desktop 在打包配置里处理了几个关键点files字段要包含 UI 编译产物和 Runtime 二进制extraResources要把 Runtime 放到resources目录下主进程里读取 Runtime 路径要用process.resourcesPath而不是相对路径。我踩过的另一个坑是 asar 打包。Runtime 二进制如果被打进 asar 里就没法执行了必须通过asarUnpack排除。DeepSeek Harness Desktop 的配置里把 Runtime 目录整个排除在 asar 之外这样打包后 Runtime 还是以独立文件形式存在可以正常 spawn。6. 从源码里学到的设计经验6.1 接口稳定比实现优雅更重要读 DeepSeek Harness Desktop 源码最大的感受是Adapter 层的接口设计非常克制。他们没有追求接口的“完备性”而是只定义 UI 真正需要的方法。有些方法在 Web 端实现起来很简单在 Electron 端却要绕一大圈但接口本身保持不变。这种克制让共享 UI 的维护成本大幅降低。我自己做项目时经常犯的错误是为了让接口“看起来合理”加了很多当前用不到的方法结果后来实现端要花大量精力去适配这些空方法。DeepSeek Harness Desktop 的做法是接口只加真正被 UI 调用的方法而且每个方法都有明确的调用场景。6.2 错误处理要跨进程一致跨进程场景下错误处理的一致性比功能实现更重要。因为错误路径往往比正常路径更复杂如果两端错误处理不一致UI 层就要写大量条件判断。DeepSeek Harness Desktop 在 Transport 层就把错误统一了上层拿到的永远是AdapterError不需要关心底层是 HTTP 错误还是 IPC 错误。实操心得定义错误类型时除了 code 和 message一定要加一个retryable字段。UI 层根据这个字段决定是否显示重试按钮比根据错误码硬编码要灵活得多。6.3 状态同步要区分权威状态和投影状态Runtime 持有权威状态UI 持有投影状态这个原则说起来简单做起来容易走偏。我见过不少项目一开始坚持这个原则后来为了性能做了乐观更新慢慢就把权威状态也搬到 UI 层了最后两端状态不一致的 bug 层出不穷。DeepSeek Harness Desktop 对乐观更新的使用非常谨慎只在少数高频且可回滚的操作上使用而且回滚逻辑写得非常细致。6.4 构建配置要尽早统一共享 UI 代码的构建配置如果两端各写一套后期维护会很痛苦。DeepSeek Harness Desktop 把 UI 代码编译成独立的包两端都引用这个包构建配置只在包级别维护。这样改构建配置时只需要改一处两端同时生效。我建议在项目初期就把这个结构定下来后期再改成本很高。7. 后续可以继续深挖的方向这套源码里还有几个我没完全吃透的地方比如 Runtime 进程的沙箱隔离机制、插件系统的热加载实现、以及 Web 端和 Electron 端在文件系统访问上的权限模型差异。这些部分涉及的安全边界和平台特性比较多我打算后面单独写一篇来分析。另外DeepSeek Harness Desktop 的 Adapter 层设计其实可以抽象成一个通用的跨端 UI 共享方案不局限于 Electron 和 Web。如果你在做类似 Tauri、Capacitor 或者小程序多端项目这套接口分层的思路应该也能借鉴。我最近在尝试把这套模式移植到一个 Tauri 项目上等跑通了再回来补一篇实操记录。最后分享一个小技巧读这类源码时不要从入口文件开始逐行读而是先找到 Adapter 接口定义然后看两端的实现差异最后再看 UI 层怎么调用。这样读下来整个架构的脉络会清晰很多也不会陷在细节里出不来。
阅读完成 · 觉得有帮助?
咨询建站