共享UI与双Runtime:DeepSeek Harness Desktop的跨端架构实践

发布时间:2026/11/4 20:38:17
共享UI与双Runtime:DeepSeek Harness Desktop的跨端架构实践 去年年底我把 DeepSeek Harness Desktop 的源码从头到尾捋了一遍起因是社区里有人贴了一行报错dsh web authentication required; reopen the url printed by dsh web.很多人卡在这条消息上以为网页授权逻辑坏了。我把相关代码翻出来才发现这条提示恰恰是整套架构里最值得讲清楚的地方——它背后是一套共享 UI 双 Runtime的设计也就是标题说的一套前端代码如何在 Web 浏览器和本地桌面运行时之间切换而不分裂成两套工程。这篇文章不是逐行注释也不是官方文档复读而是我读源码过程中的一份架构复盘。我会重点拆解几个问题同一个前端应用怎么知道自己应该走 HTTP 还是走本地 IPC登录令牌在纯浏览器环境和桌面环境里分别存放在哪里流式输出为什么在 Web 端走 WebSocket、在桌面端却可以不经过网络桌面壳层到底承担了多少不该 UI 管的脏活。如果你正打算做类似一套 UI 多端 Runtime的项目或者只是好奇这类双模应用的代码组织方式这篇文章应该能帮你少踩不少坑。1. 需求倒推为什么不能简单做两个前端1.1 DeepSeek Harness 的定位命令行工具的第二层皮先交代一下项目背景。DeepSeek Harness 本身是做模型推理、评测和联调用的工具链核心能力在命令行里就已经很完整——跑测试用例、对比模型输出、管理会话、控制本地推理进程。Desktop 版本要解决的是让不熟悉终端的人也能用上这套能力同时让熟悉终端的人获得更好的可视化反馈。问题在于如果老老实实做网页版和桌面版两个前端就等于要维护两套会话管理、两套流式渲染、两套配置页面任何模型侧的改动都要同步两遍。任何一个经历过双前端维护的人都知道这种分裂到最后一定会变成网页版改了三处桌面版忘了同步的惨案。1.2 拆分的关键不是 UI是 Runtime读源码时我第一反应是去 UI 层找if (isDesktop)这种分支结果发现代码里根本没有铺满这样的判断。整个前端被清晰地分成了三层视图层只管渲染和用户交互应用层管状态和业务流程Runtime 层则是一个极薄的接口。视图层永远不需要知道自己在哪个环境里跑它只认 Runtime 层给出的能力清单。这个设计最聪明的点在于解耦的其实不是Web 和桌面而是界面逻辑和宿主能力。浏览器和桌面程序本质上是两个宿主各自能提供的资源完全不同——浏览器可以远程连服务器但碰不到本地密钥和模型进程桌面程序刚好反过来。UI 想同时吃两边的好处就不能自己判断环境而是要让 Runtime 层把环境差异全部吞掉。1.3 共享 UI 要付出的代价当然共享 UI 不是免费的。代价主要有三块兼容性、条件编译、性能边界。比如浏览器能用 WebSocket桌面端为了减少回环网络开销可能直接走本地事件总线两边的数据通道形态不同UI 层就必须只认统一的事件结构。又比如浏览器里拿不到进程句柄桌面端就必须把进程状态翻译成 UI 认识的会话状态机。这些约束我会在后面的章节逐个展开。2. Runtime 抽象层唯一被允许说我在哪的模块2.1 接口长什么样我在源码里找到的核心抽象是一个叫RuntimeClient的接口不同版本可能叫BackendAdapter或DshRuntime本质一样它只暴露一个最小的方法集合export interface RuntimeClient { bootstrap(): PromiseBootstrapPayload; listSessions(): PromiseSessionSummary[]; createSession(input: CreateSessionInput): PromiseSession; streamCompletion(input: StreamRequest): AsyncIterableStreamEvent; resolveAsset(path: string): Promisestring | null; on(config: { channel: string; handler: (msg: unknown) void }): () void; }注意这里没有sendRequest(url, method, body)这种通用方法。这是很关键的一个设计选择接口一旦做成通用 HTTP 风格就很难约束桌面端那些非 HTTP能力。面向能力而非面向传输方式建模双端实现才有约束力。不管是网页版还是桌面版都必须实现streamCompletion并返回一个异步可迭代的流事件序列至于底层是 fetch 流、WebSocket 还是本地管道UI 完全不关心。2.2 HttpRuntime浏览器里的基础实现Web 模式下的实现是最直观的所有方法都翻译成 HTTP 调用。bootstrap 变成GET /api/bootstrapstreamCompletion 变成POST /api/completions/stream返回的流按行解析成事件。这里有个值得提的细节即使在 Web 环境里它也没有直接把 WebSocket 对象写死在业务代码中而是把建立连接、解析事件、处理断线重连全部封装在一个传输对象里。这样做的理由是测试友好——单元测试里可以注入一个假的传输层而不必真的起一个服务。2.3 IpcRuntime桌面端的特权通道桌面端实现走的是 Tauri 的 invoke/event 机制如果项目用的是 Electron对应的就是 ipcRenderer/ipcMain。同样是streamCompletion桌面实现可能直接对接本地进程的管道把模型进程吐出来的增量结果转发成和 Web 端完全相同的StreamEvent。看源码时你会发现一个有趣的现象HttpRuntime和IpcRuntime的代码量完全不对等——HTTP 实现洋洋洒洒几百行处理各种网络错误IPC 实现却短得可疑。这不是因为桌面端简单而是因为桌面端把复杂度搬到了壳层进程里Rust 或 Node 一侧UI 进程里的 IPC 客户端只需要发请求、收事件。这种复杂度和能力向内收敛的做法比在 UI 进程里堆逻辑要健康得多。我见过不少项目把进程管理逻辑直接写进 React 组件等到要支持 Web 端时就傻眼了只能大规模重构。2.4 为什么接口里要留一个 resolveAsset这个方法是后来我才看懂价值的。Web 端页面上的资源都在同源服务器上浏览器自己就能解析路径但桌面端打包后资源可能在tauri://localhost或asset://自定义协议下而且有些模型附件存放在用户的 userData 目录里路径必须由 Runtime 层翻译。UI 拿到一个相对路径想展示图片或文本时永远先问 Runtime 要一个可访问地址。这个解耦看似不起眼实际上避免了在 UI 里到处写if (isTauri) return convertFileSrc(path)这种脏代码。等价地它也把哪些资源允许被访问的策略收口到了一处方便做安全控制。2.5 双实现的分发工厂函数与依赖注入两个实现怎么选源码里是一个简单的工厂函数而不是一个全局环境变量。大致逻辑是程序入口从宿主环境拿到一个 capability 对象其中包括isDesktop、hasWebSocket、storageMode等信息然后据此构造对应的 Runtime 实例。function createRuntime(capability: Capability): RuntimeClient { if (capability.isDesktop) { return new IpcRuntime(capability); } return new HttpRuntime(capability); }注意这个 capability 是打包期就决定好的不会在运行期突然变化所以用不着复杂的动态探测。反过来如果在运行期反复判断我能不能用 chrome.storage、我是不是在 Electron 里那种代码最后一定会在某些奇怪的环境里翻车。把宿主差异收敛到工厂函数里是为数不多既简单又正确的做法。3. 身份认证的双端分居从 reopen the url 说起3.1 那条报错到底是什么意思现在可以回到开头那条让很多人困惑的消息dsh web authentication required; reopen the url printed by dsh web.这段文字出现在两种场景。第一种是纯命令行场景你运行了dsh web它会启动一个本地服务并打印一个带一次性令牌的 URL你需要用浏览器打开这个 URL 完成授权。第二种发生在桌面壳层里桌面应用启动时发现没有本地凭证就输出这条消息同时把同样的授权 URL 自动拉起浏览器或展示在界面上。这个设计本质是 OAuth 里经典的 Device Authorization Flow设备授权流不适合直接输入用户名密码的终端或桌面环境通过一次性 URL 把授权动作转移到浏览器上完成。为什么桌面端不直接在应用内嵌一个登录框因为桌面端要复用同一个远程认证服务而认证服务的安全假定都是围绕浏览器会话设计的Cookie、SameSite、CSP。硬做一个原生登录框等于要重新实现一遍 CSRF 防护和会话管理风险远大于收益。完整流程一般是这样的桌面 Runtime 启动检查钥匙串里有没有刷新令牌没有则进入未授权状态。Runtime 请求认证服务生成一次性授权 URL并通过壳层打印或弹窗展示。用户在默认浏览器里打开该 URL完成账号登录。浏览器侧回调认证服务换取授权码再通过带外通道如本地回调端口交还给桌面 Runtime。Runtime 用授权码换刷新令牌写入系统钥匙串access token 留在内存。UI 下一次 bootstrap 时拿到authStatus: authenticated进入主界面。3.2 Web 模式令牌放在内存与会话存储Web 端走的是标准的登录后拿 access token流程它把短期令牌放在会话存储里刷新令牌则由 HttpRuntime 通过 HttpOnly Cookie 持有。源码里明显有分级处理UI 所有请求都带 access token但 access token 失效时 UI 不会自作主张去刷新——它只是收到 401然后由 Runtime 的onAuthExpired回调统一处理。注意这套设计里最容易抄错的一点是每个请求自己决定要不要刷新。一旦刷新逻辑分散到各个模块就会出现刷新令牌并发抢跑、过期时间判断不一致之类的怪问题。集中在一个地方处理即使慢一点也是可控的。3.3 桌面模式令牌进钥匙串桌面端不存在浏览器 Cookie 的概念所以处理方式变成了完成授权后桌面 Runtime 把刷新令牌写入系统钥匙串macOS Keychain / Windows Credential Manager / Linux Secret Serviceaccess token 仍然只保存在内存里。每次冷启动时桌面端先读钥匙串里的刷新令牌去换新 access token。我自己的一个很深体会是判断一套双端代码是否成熟就看它有没有把凭证存储位置和凭证使用位置分开。很多半吊子实现会把 token 直接塞进 localStorage 并声称桌面端也跑在 WebView 里所以没问题。但 WebView 里跑的前端同样有 XSS 攻击面一旦某个渲染层漏洞被利用localStorage 里的长期令牌等于白送。钥匙串也许不是最方便的方案但它是桌面平台上有且仅有的可信存储。3.4 启动竞态防止登录页闪烁双端在启动时都有一个容易出 bug 的点bootstrap 是异步的而 UI 渲染是同步的。如果 UI 在 bootstrap 完成之前就尝试渲染登录页用户会看到闪一下登录页又变成主界面的诡异效果。源码里的解法很朴素整个应用在 bootstrap Promise resolve 之前渲染一个全屏加载态bootstrap 返回值里带一个明确的authStatus字段unauthorized/authenticated/expiredUI 根据这个字段决定进入登录流还是主界面。这个等待一次判断一次的模式比渲染中反复探测登录态要干净得多也更容易在两端保持一致行为。4. 数据通道WebSocket、轮询与本地事件总线不是竞争关系4.1 为什么 Web 端必须流式而桌面端可以不走网络聊完认证来到这套架构的第二个主战场数据通道。模型输出的流式体验是这类应用的命根子但流式在 Web 和桌面端的实现路径完全不同。Web 端要穿过网络边界最常见的选择是 WebSocket其次才是 SSE 或 fetch stream。源码里其实做了一层回退优先 WebSocket连不上时退回text/event-stream的 SSE。这是我在实际部署中很认同的做法——有些反向代理对 WebSocket 升级支持不完善SSE 作为降级方案能救回不少用户。桌面端呢如果本地 Runtime 就在同一台机器上再走一次 WebSocket 就有点绕了建立连接、握手、心跳全是纯开销。桌面实现直接订阅壳层进程的事件总线模型进程每产出一段增量壳层用本地 channel 推给 UI 进程。这个通道的延迟可以低到忽略不计而且基本不会出现网络断开导致流中断的假象。4.2 统一事件形状唯一不能让步的契约双端数据通道形态不同能达成共享 UI 的前提就是事件形状必须完全一致。我在源码里看到这样一层定义StreamEvent是一个可辨识联合discriminated union固定有start、delta、tool_call、error、done几种类型。WebSocket 收到的 JSON 要映射成StreamEvent本地事件总线收到的消息也要映射成同一个StreamEvent。UI 层的会话状态机只认这一种形状任何不符合该形状的数据都会被当作非法丢弃并记录日志。这是整个共享 UI 架构里最不容妥协的部分。只要事件形状能保持一致下面传输层怎么换都行一旦形状分了叉UI 层逻辑就会开始写if (isDesktop)抽象就崩塌了。我见过一些项目在双端联调两周后才反应过来Web 端的事件字段是content桌面端是text结果 UI 渲染组件写了两个分支从此就再也合不回去了。4.3 回压与取消容易被忽略的通道语义读代码时我还注意到一个细节RuntimeClient的streamCompletion返回的是AsyncIterable这意味着 UI 可以用break或提前 return 来结束消费。这个看似自然的语义翻译到传输层时是有讲究的——WebSocket 实现里UI 停止消费会触发 socket 收到关闭帧通知服务端立即停止生成桌面端实现里则对应向本地进程发送取消指令。如果不做这个映射用户点了停止但模型还在背后悄悄生成就会成为常态资源白白烧掉。我在自己的项目里有一模一样的教训第一版只做了前端停止渲染没做后端停止生成结果日志里全是半截输出。后来才补上取消通道电量和显存占用立刻降了一个档次。这个细节很容易在架构图里被忽略但它对真实体验的影响非常大。4.4 传输方式对比速查维度Web 模式桌面模式主要传输WebSocket降级 SSE / fetch stream本地事件总线Tauri event / IPC channel认证方式短期令牌 HttpOnly Cookie 刷新钥匙串刷新令牌 内存 access token断线风险网络波动需要重连策略基本无网络断线只需处理进程退出流式延迟取决于网络通常几十到几百毫秒可感知本地进程管道延迟可忽略取消语义发送关闭帧发送本地取消指令典型失败代理超时、连接重置进程崩溃、端口冲突5. 桌面壳层一个被低估的托管进程5.1 壳层不该只做一个浏览器壳如果只把 Tauri/Electron 当成装前端的浏览器这个项目至少损失一半价值。真正读源码时会发现桌面壳层承担了四类脏活启动并托管本地 Runtime 进程、分配和通知端口、管理用户数据目录、执行自动更新。这四件事随便哪一件让 UI 层来干都会把共享 UI毁掉。先看进程托管。DeepSeek Harness 的本地 Runtime 本质上是一个独立的服务进程命令行dsh就是它的入口桌面壳层负责在应用启动时把它拉起来退出时保证把它带走。这里有个实操中非常容易踩的坑服务进程和 UI 进程的生命周期必须绑定否则用户退出应用后孤儿进程会一直占着端口和内存。源码里用的是一个父子进程组加退出钩子的组合方案应用收到退出信号时先给 Runtime 发优雅关闭指令等几秒不强退就强制结束。5.2 端口分配谁来决定 localhost 的端口本地 Runtime 启动后监听一个端口但这个端口不固定。开发环境可能是某个固定测试端口之后又是另一个随机端口。UI 去哪里知道这个端口答案是通过 bootstrap壳层先启动 Runtime拿到它打印的端口信息——这里又回到了dsh web那条命令dsh web会把完整 URL 打到 stdout桌面壳层读 stdout 就能拿到带令牌的地址然后把apiBaseUrl作为参数注入给 UI 层。注意注入时机必须发生在 bootstrap 之前否则 UI 首屏请求会打到空地址。这让我想起很多 Web 项目的环境变量误区——在桌面双模应用里apiBaseUrl不该是构建期环境变量而应该是运行期由宿主提供。因为桌面用户装完应用第一次启动时你根本不知道机器上哪个端口是空闲的。把这件事做成构建期常量等于在发布第二天就得准备热修。5.3 用户数据目录与版本隔离桌面端的数据不会和远端服务器共享数据目录它有一套本地持久化会话索引放在应用数据目录模型附件放在独立资源目录配置则是用户显式修改优先内置默认值兜底。最值得注意的是版本隔离每次自动更新后旧的缓存目录会被保留并标记新版本启动时如果发现数据格式不兼容会先做一次迁移而不是直接复用。这套逻辑虽然不复杂但能看出项目方对桌面端是真实安装软件这件事的重视——浏览器里改坏了刷新一下就好桌面端改坏了用户就只能卸载重装。所以凡是涉及磁盘目录结构变更都必须在发布前设计好迁移路径。5.4 自动更新的双份纠结远端版本与本地包最后是更新策略。Web 端根本不需要更新每次部署新版本用户刷新页面就生效桌面端则要拉远端版本清单比对本地版本号决定是否提示更新。源码里处理这两套逻辑的方式是两个独立模块UI 层只负责展示可更新状态具体的下载和安装全部在壳层完成。为什么必须分开因为共享 UI 一旦把更新逻辑耦合进来Web 端就会遇到我明明是最新代码为什么还需要自我更新的尴尬。把当前版本从哪来、怎么更新留给宿主层UI 才能保持纯净。6. 读这份源码时最容易被绕进去的五个认知陷阱这一节我用自己的排查经历来写列几个很容易误导阅读者的点也是很多人读这类双模代码时摔过的坑。6.1 陷阱一以为 Web 版是完整版桌面版是套壳恰恰相反。从能力面看桌面版的 Runtime 是超集它能访问本地文件、本地模型进程、系统钥匙串、系统通知。Web 版才是那个受限版本。如果你读源码时以 Web 为基准去理解数据流桌面端那些多出来的分支会显得像临时补丁反过来以桌面为基准Web 端的所有代码就都能解释为在受限宿主下的降级实现。6.2 陷阱二看到 localStorage 里有 token 就认为不安全要分清楚长期令牌和短期令牌。Web 端往 localStorage 里放的只有 access token有效期短刷新能力在 HttpOnly Cookie 里真正更值钱的刷新凭证不在脚本可见范围内。有些人一看到浏览器里存了 token就断言泄露其实没有理解分级存储的意义。桌面端的钥匙串同理它存的不是 access token 本身而是能交换新 token 的刷新凭证。这两层身份一旦被混淆安全分析就会全偏。6.3 陷阱三把 Vite 的 dev server 想象成生产静态资源开发环境里前端跑在 Vite dev server 上API 和页面天然同源一切顺畅。但桌面生产包里静态资源是从tauri://localhost这类自定义协议加载的而 API 是http://127.0.0.1:随机端口它们是跨源关系。所以代码里几乎所有请求都必须显式走 Runtime 的代理方法而不是直接fetch(/api/...)。阅读时如果把开发环境很好使当成生产环境也这样就会漏掉一大批和 baseUrl、CORS 相关的代码。6.4 陷阱四状态管理双端一致但初始化逻辑完全不同UI 层状态管理session、message、config确实是共享的但每个 store 的初始化路径是双份的Web 端从远端 API 拉历史会话桌面端从本地索引读历史会话并且要处理本地会话 id 和远端会话 id 冲突的问题。如果你只盯着 reducer 而忽略它的 init 函数会以为两端在占用同一份数据实际上它们在各自的世界里跑。6.5 陷阱五WebSocket 一定比 fetch 流快在 Web 场景WebSocket 的主要价值是双向通信和较低握手开销不代表它一定比 fetch 流快反过来桌面场景里本地事件总线的延迟比任何 Web 传输都快一两个数量级。性能比较必须绑定宿主。这类结论如果脱离 Runtime 语境拿到另一侧去用基本都会失效。最后说一点个人体会。我花在 DeepSeek Harness Desktop 源码上的时间大部分其实不是用在深奥算法上而是用在了梳理谁提供能力、谁消费能力这两组关系上。共享 UI 最难的从来不是把页面写成一份而是让所有环境差异都收敛到一个可以替换的边界后面。dsh web那条报错消息看似是小事却是整条链路的缩影一次授权、一个端口、一条 URL把 Web 和桌面从传输到存取都串了起来。如果你正在做类似的双模项目我建议先画一张能力矩阵——左边是所有 Runtime 实现上边是 UI 需要的能力然后把每个交叉点都明确归属。做到这一步剩下的就多半是体力活了。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询