
1. 从标题拆解这套桌面端架构的真实意图1.1 为什么共享 UI是这套方案最值钱的部分第一次看到 DeepSeek Harness Desktop 这个项目名很多人会下意识把它归类成又一个把网页套进桌面壳的工具。但真正把源码翻一遍之后会发现它想解决的核心问题根本不是套壳而是同一套界面代码如何同时服务于浏览器环境和本地运行时环境并且两边互不拖累。这件事听起来抽象落到实际场景里就很具体了。假设你做了一个本地推理工具界面部分有对话列表、参数面板、模型选择器、日志输出区。如果只做 Web 版那用户得自己起服务、开浏览器、手动填地址如果只做桌面版那团队就没法快速在浏览器里做 UI 调试也没法让用户通过网页快速体验。传统做法是维护两套前端一套给 Web一套给 Electron结果就是改一个按钮要改两遍样式漂移、逻辑分叉、bug 双份。DeepSeek Harness Desktop 的思路是反过来的UI 层只写一份Runtime 层做抽象Web 和本地各提供一个 Runtime 实现。UI 不关心自己跑在浏览器里还是 Electron 里它只跟一个约定好的接口打交道。这个接口背后Web 环境走 HTTP/WebSocket本地环境走进程间通信。这就是标题里解耦两个字的真正含义——不是简单的目录分离而是运行时能力的抽象与注入。我个人的判断是这套架构对三类人价值最大。第一类是正在做本地 AI 工具、想同时提供网页体验和桌面体验的独立开发者第二类是团队里负责前端、但经常被桌面端又要重新适配折磨的工程师第三类是想学习 Electron 工程化实践、但看腻了Hello World 教程的中级开发者。如果你属于这三类中的任何一类这套源码值得逐层拆开看。1.2 热词背后暴露的真实痛点把热搜词摊开看会发现它们其实指向了同一批问题。electron技术栈、electron教程、electron菜单说明大量人卡在 Electron 的基础工程化上failed to load plugins web boot、could not find the webview2 runtime、microsoft edge webview2 runtime说明运行时依赖和插件加载是高频翻车点ui界面卡顿、ui层、ui框架说明界面性能和分层是持续关注点而runtime、engine protocol runtime llama-server、no lm runtime found for model format gguf则直接指向本地推理运行时这一层。这些词拼在一起恰好就是 DeepSeek Harness Desktop 要处理的全部分层UI 层、桥接层、Runtime 层、依赖环境层。所以这篇解析不会只讲目录长什么样而是按这四层往下拆把每一层为什么这么设计、实际写的时候要注意什么、容易在哪里翻车都讲清楚。2. 整体架构设计与分层思路2.1 三层结构UI、Bridge、Runtime这套项目最核心的设计可以概括成三层。最上面是UI 层它是纯前端代码用常规的组件化方式组织负责渲染、交互、状态展示。它有一个硬性约束不允许直接调用任何 Electron API也不允许直接假设自己跑在浏览器里。所有它需要的外部能力都通过一个统一的接口对象获取。中间是Bridge 层也就是桥接层。它的职责是把 UI 层的调用翻译成具体环境能理解的形式。在 Web 环境下Bridge 把调用转成 HTTP 请求或 WebSocket 消息在桌面环境下Bridge 把调用转成 IPC 消息发给主进程。Bridge 层是唯一知道我现在在哪的地方。最下面是Runtime 层它才是真正干活的部分。模型加载、推理调度、文件读写、进程管理这些重活都在这里。Runtime 层对外暴露的能力是稳定的但内部实现可以完全不同——Web 版可能连的是一个远程服务桌面版连的是本地子进程。这样分层的好处非常直接。UI 层可以独立开发和测试用 mock 的 Bridge 就能跑起来Runtime 层可以单独替换比如从本地进程换成远程服务UI 一行都不用改Bridge 层是唯一需要针对环境写两份实现的地方而它的代码量通常远小于 UI。注意分层的边界一定要守住。我见过太多项目一开始分得很干净后来为了赶进度直接在 UI 组件里import { ipcRenderer }结果整个架构就塌了。一旦 UI 层出现环境判断代码共享就名存实亡。2.2 为什么选 Electron 而不是其他桌面方案热词里electron技术栈出现频率很高说明这是大家最关心的选型问题。这套项目选 Electron我认为有三个现实理由。第一是复用成本最低。UI 层本来就是 Web 技术栈Electron 直接就是 Chromium 加 Node前端代码几乎零改造就能跑。如果换成 Tauri前端虽然也能复用但 Rust 侧要重新写一套 Runtime 桥接团队如果没有 Rust 积累成本反而更高。如果换成纯原生方案那 UI 就得整个重写跟共享 UI的目标直接冲突。第二是生态成熟。Electron 的主进程、渲染进程、预加载脚本这套模型虽然学习曲线有点陡但文档、社区、踩坑记录都极其丰富。热词里electron应用移植鸿蒙教程、sandboxieplus electron、windows 95 electron这些看起来八竿子打不着的词恰恰说明 Electron 的生态覆盖面有多广遇到问题基本都能搜到答案。第三是本地能力访问方便。桌面端要调本地推理进程、要读写模型文件、要管理子进程生命周期这些在 Electron 里通过 Node 侧能力都能直接做。Web 版做不到这些所以必须有一个本地 Runtime来补位而 Electron 正好是这个补位的最佳载体。当然Electron 的代价也很明显包体积大、内存占用高、启动慢。热词里ui界面卡顿和steamwebhelper 没有响应这类问题本质上都是 Chromium 渲染进程的资源竞争。这套项目在架构上做的应对是把重计算全部推到 Runtime 层UI 层只做轻量渲染。这样即使推理在跑界面也不会被拖死。2.3 共享 UI 的边界在哪里共享 UI不等于所有代码都共享。这套项目里真正共享的是组件、样式、状态管理、业务逻辑不共享的是环境相关的适配代码。具体来说共享的部分包括对话组件、参数面板、模型列表、日志视图、主题系统、状态 store。这些代码里不应该出现任何window.electron、process.versions、navigator.userAgent之类的环境探测。不共享的部分包括Bridge 的具体实现、Runtime 的启动方式、文件系统访问、进程管理。这些代码按环境分成两份放在不同的入口里通过构建配置决定打包哪一份。这里有个很实用的判断标准如果一个模块需要知道我现在跑在哪它就不该被共享。反过来如果一个模块只关心我要拿到模型列表那它就应该被共享至于模型列表是从 HTTP 拿的还是从 IPC 拿的它不关心。2.4 目录结构的设计逻辑基于上面的分层目录结构通常会这样组织src/ ui/ # 共享 UI 层纯前端 components/ stores/ styles/ bridge/ # 桥接层接口定义 types.ts # 统一的能力接口 bridge-web/ # Web 环境实现 index.ts bridge-desktop/ # 桌面环境实现 index.ts runtime/ # Runtime 层 web/ desktop/ entry-web/ # Web 入口 entry-desktop/ # 桌面入口 main.ts # Electron 主进程 preload.ts # 预加载脚本这个结构的关键在于bridge/types.ts。它定义了 UI 层能调用的全部能力是整个架构的契约。UI 层只依赖这个文件不依赖任何具体实现。构建时Web 入口注入bridge-web桌面入口注入bridge-desktop。实操心得接口定义文件一定要写得足够细把参数类型、返回值类型、错误类型都定清楚。我踩过的坑是接口一开始写得太粗比如invoke(method: string, args: any)结果两边实现的行为不一致UI 层拿到的东西时对时错排查起来非常痛苦。宁可多写几十行类型定义也不要图省事。3. 核心细节解析与实操要点3.1 Bridge 接口怎么设计才不返工Bridge 接口是整个架构的地基设计得好后面顺风顺水设计得差改一处牵动全身。我的经验是接口设计要遵循三个原则。第一按能力分组不按环境分组。不要设计成webRequest、desktopIpc这种名字而应该设计成listModels、loadModel、chat、readLog这种业务能力名。UI 层看到的是业务能力不是传输方式。第二所有调用都是异步的。即使某个能力在本地是同步的接口也要定义成 Promise。因为 Web 环境下 HTTP 必然是异步的如果接口有时同步有时异步UI 层就没法统一处理。统一异步是唯一稳妥的选择。第三错误要结构化。不要直接抛字符串而是定义一套错误码和错误对象。比如模型不存在、运行时未启动、参数非法这些都应该有明确的错误类型UI 层才能针对性地提示用户。一个典型的接口定义大概长这样export interface RuntimeBridge { listModels(): PromiseModelInfo[]; loadModel(id: string, options: LoadOptions): Promisevoid; chat(req: ChatRequest): PromiseChatResponse; streamChat(req: ChatRequest, onChunk: (c: Chunk) void): Promisevoid; getRuntimeStatus(): PromiseRuntimeStatus; dispose(): Promisevoid; }注意streamChat这个设计。流式输出是本地推理的刚需但 Web 和桌面的流式机制完全不同Web 走 SSE 或 WebSocket桌面走 IPC 事件。接口层用回调函数把差异抹平UI 层只管处理 chunk不关心 chunk 从哪来。3.2 Web 侧 Runtime 的实现要点Web 侧的 Bridge 实现相对简单本质就是把接口调用翻译成网络请求。但有几个细节容易翻车。连接管理。Web 版通常连的是一个本地或远程的推理服务。这个服务可能没启动可能中途挂了可能响应很慢。所以 Bridge 实现里必须有一套连接状态管理未连接、连接中、已连接、断开。UI 层通过getRuntimeStatus拿到状态据此决定是显示启动服务按钮还是直接进对话界面。流式传输的选择。热词里spring boot 集成web socket yml 配置、web端实时视频都指向实时传输这个话题。对于推理流式输出SSE 和 WebSocket 各有优劣。SSE 实现简单、自动重连、单向推送够用WebSocket 双向、延迟低、但需要自己处理心跳和重连。这套项目如果只做单向流式输出SSE 是更省事的选择。超时与取消。推理请求可能跑很久用户可能中途想取消。Bridge 接口里要支持 AbortSignal让 UI 层能主动取消请求。这个能力在 Web 侧用fetch的 signal 实现在桌面侧用 IPC 消息通知主进程终止。async streamChat(req: ChatRequest, onChunk: (c: Chunk) void, signal?: AbortSignal) { const resp await fetch(${baseUrl}/chat/stream, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify(req), signal, }); const reader resp.body!.getReader(); const decoder new TextDecoder(); while (true) { const { done, value } await reader.read(); if (done) break; const text decoder.decode(value); for (const line of text.split(\n)) { if (line.startsWith(data: )) { onChunk(JSON.parse(line.slice(6))); } } } }3.3 桌面侧 Runtime 的实现要点桌面侧才是这套架构真正复杂的地方。它要处理进程管理、IPC 通信、生命周期、崩溃恢复。主进程与渲染进程的分工。渲染进程跑 UI主进程管 Runtime。渲染进程通过 preload 暴露的接口发 IPC 消息主进程收到后调用本地推理进程。这里有个关键点preload 脚本要用contextBridge暴露接口不能直接开nodeIntegration。热词里web安全、web服务器安全反复出现说明安全边界是大家关心的。开nodeIntegration等于把 Node 能力直接交给页面一旦页面加载了不可信内容后果很严重。// preload.ts import { contextBridge, ipcRenderer } from electron; contextBridge.exposeInMainWorld(runtimeBridge, { listModels: () ipcRenderer.invoke(runtime:listModels), loadModel: (id: string, opts: any) ipcRenderer.invoke(runtime:loadModel, id, opts), chat: (req: any) ipcRenderer.invoke(runtime:chat, req), streamChat: (req: any, onChunk: (c: any) void) { const channel runtime:stream:${Date.now()}; ipcRenderer.on(channel, (_e, chunk) onChunk(chunk)); ipcRenderer.invoke(runtime:streamChat, req, channel); return () ipcRenderer.removeAllListeners(channel); }, });本地推理进程的启动方式。热词里engine protocol runtime llama-server、no lm runtime found for model format gguf说明本地推理引擎的启动和模型格式识别是高频问题。常见做法是把推理引擎作为子进程启动主进程负责 spawn、监听 stdout/stderr、处理退出。启动参数里要指定模型路径、端口、上下文长度、线程数等。进程崩溃恢复。本地推理进程可能因为内存不足、模型文件损坏、参数错误而崩溃。主进程必须监听exit和error事件把崩溃信息通过 IPC 推给 UI并支持重启。这里有个细节重启要有退避策略不能崩溃了立刻无限重启否则会陷入死循环。3.4 环境依赖与运行时检测热词里could not find the webview2 runtime、microsoft edge webview2 runtime、vivado安装时显示 a fatel error has been detected by the java runtime envi这些本质上都是运行时依赖缺失这一类问题。桌面应用尤其容易在这里翻车因为用户机器上的环境千差万别。这套项目在架构上应该做的检测包括推理引擎二进制是否存在、模型文件是否可读、端口是否被占用、必要的系统库是否齐全。检测结果通过getRuntimeStatus返回给 UIUI 据此给出明确的引导而不是让用户面对一个空白界面猜哪里出了问题。实操心得错误提示一定要具体到哪个文件、哪个路径、哪个端口。我见过太多应用只报一句运行时启动失败用户完全无从下手。把spawn的error事件里的code、path、syscall都透出来能省掉大量客服成本。4. 实操过程与核心环节实现4.1 从零搭建共享 UI 的构建配置共享 UI 能成立构建配置是关键。核心思路是UI 层代码只构建一次两个入口分别引用它。用 Vite 的话可以配置多入口// vite.config.ts import { defineConfig } from vite; import react from vitejs/plugin-react; export default defineConfig(({ mode }) ({ plugins: [react()], define: { __RUNTIME_ENV__: JSON.stringify(mode desktop ? desktop : web), }, build: { rollupOptions: { input: mode desktop ? { desktop: src/entry-desktop/index.html } : { web: src/entry-web/index.html }, }, }, resolve: { alias: { ui: /src/ui, bridge: mode desktop ? /src/bridge-desktop : /src/bridge-web, }, }, }));这里bridge这个别名是关键。UI 层里统一import { bridge } from bridge构建时根据 mode 自动指向不同实现。UI 层代码完全不需要改。__RUNTIME_ENV__这个全局常量用于极少数确实需要区分环境的场景比如显示不同的标题栏。但要克制使用能不用就不用因为它会破坏共享性。4.2 桌面端主进程的完整启动流程主进程的启动流程大致分几步每一步都有坑。第一步创建窗口。窗口创建时要设置webPreferences重点是preload路径、contextIsolation: true、nodeIntegration: false、sandbox视情况开启。热词里sandboxieplus electron说明沙箱是大家关注的点Electron 自身的 sandbox 和第三方沙箱工具是两回事别混淆。const win new BrowserWindow({ width: 1280, height: 800, webPreferences: { preload: path.join(__dirname, preload.js), contextIsolation: true, nodeIntegration: false, sandbox: false, }, });第二步启动 Runtime 子进程。在主进程 ready 之后spawn 推理引擎。要记录子进程的 pid监听 stdout/stderr 并转发到日志监听 exit 并处理重启。function startRuntime(modelPath: string) { const proc spawn(enginePath, [ --model, modelPath, --port, String(PORT), --ctx, 4096, --threads, String(os.cpus().length), ]); proc.stdout.on(data, d win.webContents.send(runtime:log, d.toString())); proc.stderr.on(data, d win.webContents.send(runtime:log, d.toString())); proc.on(exit, code { win.webContents.send(runtime:exit, code); scheduleRestart(); }); return proc; }第三步注册 IPC handler。每个 Bridge 接口对应一个 handlerhandler 里调用 Runtime 能力并返回结果。注意 handler 要处理异常不能让异常直接冒泡导致渲染进程收到一个无法解析的错误。第四步处理窗口关闭。窗口关闭时要优雅地终止 Runtime 子进程否则会留下僵尸进程。用before-quit事件做清理先发终止信号等一段时间再强杀。4.3 流式输出的端到端打通流式输出是这套架构里最能体现解耦价值的地方。UI 层调用streamChatWeb 侧走 SSE桌面侧走 IPC 事件但 UI 层的处理代码完全一样。桌面侧的完整链路是这样的渲染进程调用streamChatpreload 生成一个唯一 channel通过 IPC 通知主进程开始流式请求主进程把请求转发给 Runtime 子进程子进程逐块输出主进程收到后通过 channel 推回渲染进程渲染进程的onChunk被触发。这里有个容易忽略的点channel 要能正确清理。如果用户连续发起多次流式请求或者中途取消旧的 channel 监听器必须移除否则会内存泄漏甚至出现旧请求的 chunk 混进新请求的情况。所以streamChat要返回一个取消函数UI 层在组件卸载或请求结束时调用它。// 主进程侧 ipcMain.handle(runtime:streamChat, async (e, req, channel) { const proc getRuntimeProcess(); proc.stdin.write(JSON.stringify({ type: chat, ...req }) \n); const onData (data: Buffer) { for (const line of data.toString().split(\n)) { if (!line.trim()) continue; e.sender.send(channel, JSON.parse(line)); } }; proc.stdout.on(data, onData); return () proc.stdout.off(data, onData); });4.4 参数计算与选择过程本地推理有几个关键参数选错了要么跑不动要么效果差。这里把常见的计算逻辑说清楚。上下文长度ctx。它决定了模型能记住多少 token。设得越大显存/内存占用越高。粗略估算每 1K token 的 KV cache 占用跟模型层数、隐藏维度、精度有关。以 7B 模型、FP16 为例每 1K token 大约占几百 MB。所以 4K 上下文和 32K 上下文内存差距可能是好几倍。桌面端要根据用户机器配置给一个合理默认值比如 4096并允许用户手动调。线程数threads。一般设成物理核心数不要设成逻辑核心数。因为推理是计算密集型超线程带来的收益有限反而可能因为调度开销导致性能下降。用os.cpus().length拿到的是逻辑核心数实际用的时候可以取一半或物理核心数。批大小batch。影响吞吐和延迟。批越大吞吐越高但首 token 延迟也越高。交互式场景下批大小设小一点让首 token 尽快出来用户体验更好。端口选择。本地服务要选一个不容易冲突的端口。可以先用net模块探测端口是否被占用被占用就换一个。热词里ensp配置防火墙web登录、h3c s7006x怎么开通web这类网络配置问题提醒我们端口冲突和防火墙拦截是真实存在的坑桌面应用要能检测并给出提示。参数推荐值影响调整建议ctx4096内存占用、记忆长度内存充足可调到 8192threads物理核心数推理速度不要超过物理核心数batch512吞吐与首 token 延迟交互场景可降到 256gpu_layers视显存是否用 GPU 加速显存够就全放 GPU5. 常见问题与排查技巧实录5.1 插件加载失败类问题热词里failed to load plugins web boot: 2 entries did not activate、harness failed to load plugins web boot: 1 entry did not activate这类报错指向的是插件系统在启动时没能激活某些条目。这类问题的排查思路是固定的。先看插件清单里每个条目的依赖是否满足。插件激活失败最常见的原因是它依赖的某个能力在当前环境不存在。比如一个插件依赖本地文件系统但当前跑在 Web 环境那它必然激活失败。这时候要么在 Web 环境禁用这个插件要么给它提供一个降级实现。再看激活顺序。有些插件之间有依赖关系A 插件依赖 B 插件先激活。如果激活顺序不对A 就会失败。解决办法是在插件清单里声明依赖激活时做拓扑排序。最后看错误日志。插件激活失败通常会抛出异常但很多框架会把异常吞掉只报一句未激活。所以要在插件加载器里加详细日志把每个插件激活时的异常都打出来。注意插件系统是共享 UI 架构里最容易破坏解耦的地方。如果插件能直接访问环境相关 API那它就没法跨环境复用。插件的能力也应该通过 Bridge 接口获取而不是直接调底层。5.2 运行时找不到模型或引擎no lm runtime found for model format gguf这个报错很典型意思是当前运行时不认识 gguf 格式的模型。可能的原因有几个引擎版本太老不支持这个格式模型文件损坏或下载不完整模型路径配置错误引擎根本没启动起来。排查顺序建议是先确认引擎进程是否在跑用getRuntimeStatus看状态再确认模型文件是否存在且大小正常再确认引擎版本是否支持该格式最后看引擎日志里有没有更具体的错误。unable to locate the codex cli binary or required runtime components这类报错则是二进制找不到。桌面应用打包后二进制文件的路径会变开发时用的相对路径在生产环境可能失效。解决办法是用process.resourcesPath或app.getAppPath()来定位打包后的资源路径而不是硬编码相对路径。5.3 界面卡顿与渲染性能ui界面卡顿是桌面端的高频抱怨。在共享 UI 架构下卡顿通常有几个来源。一是流式输出更新太频繁。如果每个 chunk 都触发一次 React 重渲染token 一多界面就卡。解决办法是做批量更新比如用requestAnimationFrame把多个 chunk 合并到一帧里更新或者用节流。二是日志区无限增长。推理日志可能刷得很快如果全部渲染到 DOM 里节点数会爆炸。解决办法是限制日志条数或者用虚拟列表。三是重计算跑在了渲染进程。如果 UI 层里做了 tokenize、格式化大文本这类重活会阻塞渲染。这些活应该推到 Runtime 层或 Web Worker 里。// 用 rAF 批量更新流式内容 let buffer ; let scheduled false; function onChunk(chunk: Chunk) { buffer chunk.text; if (!scheduled) { scheduled true; requestAnimationFrame(() { setContent(prev prev buffer); buffer ; scheduled false; }); } }5.4 常见问题速查表现象可能原因排查方向解决方式插件未激活依赖缺失或顺序错误看插件清单和激活日志补依赖或调整顺序找不到模型格式引擎版本或文件问题查引擎版本和文件完整性升级引擎或重下模型找不到二进制打包路径错误查 resourcesPath用 app 路径 API 定位界面卡顿更新过频或节点过多看渲染次数和 DOM 节点批量更新或虚拟列表运行时启动失败端口占用或依赖缺失查端口和系统库换端口或补依赖流式内容错乱channel 未清理查监听器数量请求结束移除监听5.5 独家避坑技巧第一个技巧在开发环境给 Bridge 加一层日志代理。所有经过 Bridge 的调用都打上时间戳、方法名、参数、返回值、耗时。这样排查问题时能一眼看出是 UI 层没调、Bridge 层没转、还是 Runtime 层没响应。这个代理只在开发环境启用生产环境关掉。第二个技巧给 Runtime 子进程加心跳。主进程定期给子进程发一个轻量请求如果连续几次没响应就判定为假死并重启。本地推理进程有时候不会崩溃但会卡住不响应光靠 exit 事件检测不到。第三个技巧模型加载做进度反馈。大模型加载可能要几十秒如果界面一直转圈用户会以为卡死了。让 Runtime 层把加载进度通过 Bridge 推给 UI哪怕只是正在加载第 3/12 层这种粗粒度信息体验也会好很多。第四个技巧错误信息里带上环境快照。当 Runtime 启动失败时把操作系统、架构、引擎版本、模型路径、端口、可用内存这些信息一起收集起来展示给用户或写进日志。用户反馈问题时这些信息能极大加速定位。6. 这套架构还能怎么扩展6.1 从双端到多端现在这套架构支持 Web 和桌面两端。如果以后要加第三端比如移动端或者某个嵌入式界面只要新端能实现 Bridge 接口UI 层就能直接复用。这就是接口抽象的价值——新增一个环境只需要新增一份 Bridge 实现UI 层零改动。热词里安卓手机调试打开ui绘制、充电桩显示ui开发、如何将figma里面的ui导入到unity中这些其实都指向UI 要在不同载体上复用这个需求。共享 UI 的思路可以推广到这些场景只要把载体能力抽象成接口。6.2 Runtime 的可插拔设计如果 Runtime 层也做成可插拔的那就能支持多种推理引擎。比如本地引擎 A、本地引擎 B、远程服务 C用户可以根据自己的情况选择。UI 层通过getRuntimeStatus拿到当前可用的 Runtime 列表让用户切换。这要求 Bridge 接口设计得足够通用不能绑定某个具体引擎的特性。引擎特有的能力可以通过扩展接口暴露但核心接口要保持稳定。6.3 配置与状态的持久化桌面端需要持久化配置模型路径、参数偏好、窗口大小、主题选择。这些配置的读写也应该走 Bridge 接口因为 Web 端和桌面端的存储机制不同。Web 端用 localStorage 或 IndexedDB桌面端用文件或 electron-store。UI 层只管调getConfig和setConfig不关心存哪。这套架构我实际拆下来最大的感受是解耦的收益是复利式的。前期多花时间设计接口、守住分层边界后期每加一个功能、每适配一个环境省下的时间都是成倍的。反过来如果一开始图快把环境判断散落在 UI 各处那后面每改一处都要提心吊胆。如果你正在做类似的项目我的建议是先把 Bridge 接口定死再动手写 UI这个顺序不能反。