☰
共享UI与双Runtime:DeepSeek Harness Desktop的跨端架构实践
2026/11/4 20:37:57 网站建设 项目流程

去年年底我把 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(): Promise<BootstrapPayload>; listSessions(): Promise<SessionSummary[]>; createSession(input: CreateSessionInput): Promise<Session>; streamCompletion(input: StreamRequest): AsyncIterable<StreamEvent>; resolveAsset(path: string): Promise<string | 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/bootstrap,streamCompletion 变成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 防护和会话管理,风险远大于收益。

完整流程一般是这样的:

  1. 桌面 Runtime 启动,检查钥匙串里有没有刷新令牌,没有则进入未授权状态。
  2. Runtime 请求认证服务生成一次性授权 URL,并通过壳层打印或弹窗展示。
  3. 用户在默认浏览器里打开该 URL,完成账号登录。
  4. 浏览器侧回调认证服务,换取授权码,再通过带外通道(如本地回调端口)交还给桌面 Runtime。
  5. Runtime 用授权码换刷新令牌,写入系统钥匙串;access token 留在内存。
  6. 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 Service),access token 仍然只保存在内存里。每次冷启动时,桌面端先读钥匙串里的刷新令牌去换新 access token。

我自己的一个很深体会是:判断一套双端代码是否成熟,就看它有没有把"凭证存储位置"和"凭证使用位置"分开。很多半吊子实现会把 token 直接塞进 localStorage 并声称"桌面端也跑在 WebView 里所以没问题"。但 WebView 里跑的前端同样有 XSS 攻击面,一旦某个渲染层漏洞被利用,localStorage 里的长期令牌等于白送。钥匙串也许不是最方便的方案,但它是桌面平台上有且仅有的"可信存储"。

3.4 启动竞态:防止登录页闪烁

双端在启动时都有一个容易出 bug 的点:bootstrap 是异步的,而 UI 渲染是同步的。如果 UI 在 bootstrap 完成之前就尝试渲染登录页,用户会看到"闪一下登录页又变成主界面"的诡异效果。

源码里的解法很朴素:整个应用在 bootstrap Promise resolve 之前渲染一个全屏加载态,bootstrap 返回值里带一个明确的authStatus字段(unauthorized/authenticated/expired),UI 根据这个字段决定进入登录流还是主界面。这个"等待一次,判断一次"的模式,比"渲染中反复探测登录态"要干净得多,也更容易在两端保持一致行为。

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 需要的能力,然后把每个交叉点都明确归属。做到这一步,剩下的就多半是体力活了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询