好久没认真拆过一个“桌面端到底是什么”的项目了。前阵子在研究 Agent 工具链的交互形态,翻到 DeepSeek Harness Desktop 的源码,第一反应是“哦,又是一个给 CLI 包了个 WebView 壳的工具”,但顺着源码往下看才发现完全不是这么回事——它压根不是用 Web 去套本地进程,而是反过来,把一套 UI 同时摆在了 Web 和本地 Runtime 面前,UI 层对“自己跑在哪个 Runtime 上”这件事完全无感。
这篇文章我把源码里最关键的一条架构主线拆出来讲:一套共享 UI,到底是怎么通过抽象层和传输层解耦 Web 与本地 Runtime 的。适合正在做 Agent 工具链、桌面端产品,或者维护“Web 一套界面、桌面一套界面”双份代码快撑不住的人看。我会从为什么这么设计开始,一路讲到目录结构、双端链路、生命周期管理,最后是几个我在实操里踩过、也帮别人排查过的坑。尽量不贴大段源码,用接口关系和调用链路代替,你能看得懂,也方便自己动手改。
1. 从“套壳”到“共享 UI”:这个项目要解决的架构痛点
1.1 CLI 工具链的体验断层,是桌面端存在的真正理由
先说背景。DeepSeek Harness 本身是围绕 Agent 运行、评测和追踪的一套工具链,核心使用方式是命令行。CLI 对开发者很友好,但一旦涉及持续观察、多任务并行、给非技术人员演示,以及需要快速查看历史轨迹和评测报告,体验就断层了——终端里滚动日志刷得飞快,你想回看某一步发生了什么都得翻半天。
大多数工具链解决这个问题的思路是“加一个 Web 仪表盘”,比如起一个本地 HTTP 服务,浏览器打开看状态。这没问题,但没有桌面形态的缺点是:服务生命周期需要手动管理、端口容易被占、关闭浏览器再恢复要找地址、没有系统托盘和全局唤起的感觉。DeepSeek Harness Desktop 要补的,就是这一层“常驻、直接、像原生应用”的体验。
但这里出现了一个关键抉择:桌面端如果单独维护一套 UI,成本等同重写。Agent 任务的状态机、日志流、参数表单、评测图表,这些东西的展示逻辑和交互逻辑在 Web 端已经写好了,桌面端重写一遍,未来两边逻辑再漂移,维护就是灾难。所以源码的架构里,UI 只存在一份,桌面和 Web 都是同一套界面的不同宿主。
1.2 共享 UI 的三条常见路线,为什么另外两条都不够好
我把市面上常见的方案拉出来对比一下,你就明白这个项目选第四条路的原因。
| 路线 | 做法 | 优点 | 致命缺点 |
|---|---|---|---|
| 纯浏览器访问 | 本地起 HTTP 服务,浏览器打开用 | 零客户端成本 | 无桌面体验,生命周期散乱 |
| WebView 内嵌网页 | 桌面壳直接加载 Web 地址 | UI 天然复用 | 只解决了“加载”,没解决后端 Runtime 差异 |
| 双份 UI | Web 一套、桌面一套 | 各自可控 | 维护成本翻倍,逻辑必然漂移 |
| 共享 UI + 抽象 Runtime 接缝 | UI 不感知传输方式,桌面和 Web 各自注入 Runtime 适配层 | 代码一份,宿主随意,链路可替换 | 需要额外设计抽象层 |
DeepSeek Harness Desktop 走的是最后一条。它的核心不是“外壳”,而是“接缝”——在 UI 和 Runtime 之间切了一刀,这一刀的接口设计决定了整个项目是否成立。
1.3 解耦的本质:UI 依赖的是“语义请求”,不是“进程”
看源码时我一直在找 UI 层到底 import 了哪些东西。翻下来发现,UI 层几乎不直接接触任何与进程、WebView、HTTP Server 相关的代码。它只知道一件事:调用一个 client 对象的方法,拿到一个带状态的任务句柄。
也就是说,UI 关心的不是“这个请求是通过 IPC 发给本地进程,还是通过 HTTP 发给远端服务”,而是“我发起了任务、我要监听进度、我要拿结果”。这个语义层被稳定在一个RuntimeClient接口背后,Web 端有一个基于 HTTP 的实现,桌面端有一个基于本地链路(动态端口 + 本地 HTTP/IPC)的实现。两个实现可以共存、切换,甚至替换成远程服务,UI 的代码都不需要动。
这一刀切得干净,才是“共享 UI”能成立的根基。
2. Runtime 抽象层:UI 永远不直接碰进程
2.1 每次请求都过同一个接缝:RuntimeClient
我习惯把一个跨端架构的关键接口画出来再反向验证。这套体系里,UI 层所有对数据和执行的访问,都收敛到类似下面的契约上:
interface RuntimeClient { startTask(type: string, params: TaskParams): Promise<TaskHandle>; getTask(taskId: string): Promise<TaskSnapshot>; listTasks(filter?: TaskFilter): Promise<TaskSummary[]>; cancelTask(taskId: string): Promise<void>; streamEvents(taskId: string): AsyncIterable<TaskEvent>; getLogs(taskId: string, cursor?: number): Promise<LogChunk>; }这个接口(我为了说清楚逻辑做了简化和归一化处理)的每个方法,都是语义级别的,不掺杂任何传输细节。调用方拿到的不是“响应体”也不是“连接对象”,而是TaskHandle——一个代表任务实体、可继续查询、可取消、可订阅事件的句柄。
为什么这么设计?因为 Agent 任务不是一次性 HTTP 请求能表达的东西。它持续数十秒甚至几分钟,期间有状态变化、日志流、中间事件,如果把它建模成“函数调用”,UI 层就会被迫处理连接保持、消息轮询、断线重连……这些脏活会迅速渗透到每个页面里。而建模成“任务句柄”,UI 层的行为就统一了:拿到句柄 → 订阅事件 → 渲染快照 → 按需取消,无论在哪个链路都是这套动作。
2.2 任务模型而不是函数模型:Long-Running 任务的唯一合理解法
刚开始拆的时候,我一度觉得“startTask + getTask + streamEvents”的组合有点绕,为什么不能直接await client.runAgent(name, params)?
后来想明白了,短任务可以这么干,但 Harness 场景下的 Agent 任务没有“短”的。你要等模型推理、等工具调用、等评测跑批,整个过程是分钟级甚至小时级的。如果接口层用“函数模型”,传输层就必须处理同步等待、超时、客户端断开后的任务孤儿问题。而“任务模型”天然把“发请求”和“等结果”解耦了:
- 服务端收到
startTask后立刻返回任务 ID,后台异步跑,客户端断开不影响任务继续; - UI 层通过
getTask轮询快照,或通过streamEvents订阅增量事件,按需更新界面; cancelTask是一个显式的语义操作,不依赖连接是否存在。
这个设计的好处是,Web 链路和本地链路都能用同一套状态流转来描述任务——无非是 Web 端请求超时重试多一些、桌面端连接更稳定一些,但 UI 页面上那些“任务卡片”“进度条”“日志面板”的代码,完全不用知道传输层是谁。
2.3 传输无关带来的三个额外红利
把 UI 和 Runtime 通过抽象接口切开,除了代码复用,还有几个容易被忽略的好处:
第一是可测试性。UI 开发者不需要起真实的本地 Runtime,只需要一个 mock 的 RuntimeClient,就能完整跑通界面逻辑。我实测过,用内存态模拟任务推进,整个 UI 的状态流转测试跑得飞快。
第二是可观测性。因为所有调用都经过同一个 client 接口,在这层做日志埋点、请求追踪、链路耗时统计都非常方便。我排查“UI 卡顿是渲染问题还是数据问题”时,就是在这一层加的计时,一测就定位到了。
第三是可替换性。将来如果想把本地 Runtime 拆成远程集群服务,UI 代码一行不用改,只需要新增一个“远程 HTTP RuntimeClient”实现,把 base URL 指过去即可。
3. 同一份路由表如何驱动两条链路
3.1 协议无关的关键:路由注册表
抽象接口只是第一步,真正让 Web 与本地链路共享逻辑的,是隐藏在背后的路由注册表。我在源码里看到的设计是:所有后端能力(启动任务、查询任务、取消任务、拉日志、查配置)都注册到一个统一的 handler 表里,每一个能力有唯一的 method 名,绑定一个处理函数。这个表是“传输无关”的——它既不关心请求来自 HTTP,也不关心请求来自本地进程间通信,只负责按 method 名分发。
这样带来的直接效果是,Web 模式和桌面模式通向的是同一套业务逻辑。比如“启动一个 Agent 评测任务”这个方法,在 Web 模式暴露给浏览器调用的,和在桌面模式暴露给 UI 调用的,是同一个注册项。
这个注册表的设计跟很多 RPC 框架的思路很像,但它更轻。我拆的时候发现它没有序列化框架强绑定,直接用了 JSON 做载荷,method 名做路由键,非常简单粗暴但足够可靠。复杂的东西不在协议里,而在业务流程里,业务逻辑只挂在注册表后面,传输层再花哨也不影响它。
3.2 本地链路:动态端口 + 令牌的轻量守卫
桌面端的本地链路,表面上可以走进程间直接调用,但源码真正采取的方式,是在本地起一个环回地址上的 HTTP 服务,用动态端口 + 一次性令牌来守卫。也就是说,桌面 Runtime 本质是一个“只监听 127.0.0.1 随机端口”的迷你服务,桌面 UI 连的是这个本地服务。
这个设计的好处很实际:
- 端口动态分配,避免了“8080 被占”这类经典冲突;
- 令牌在启动时生成、UI 注入、关闭时失效,局域网里的其他机器探测不到也没权限;
- 桌面 UI 和 Web 模式共用同一套 HTTP 语义,调试时可以无缝切换后端地址。
这也解释了为什么我在开发环境里经常看到类似“desktop runtime listening on 127.0.0.1:xxxxx,token: xxxxxx”的日志。这个令牌的存在,保证了本地链路的请求不会被邻座的人随手调用,虽然 127.0.0.1 本身已经挡掉了大部分网络暴露风险,但令牌让安全边界更清晰。
3.3 Web 链路:常驻服务的会话管理与跨页恢复
Web 链路的形态是一种“常驻仪表盘模式”——通过dsh web拉起一个本地 HTTP 服务,浏览器打开后使用。这条链路跟桌面链路用同一套路由表,但会话管理走的是浏览器会话模型。
我在实际使用中碰到过一个典型场景:浏览器标签页关掉再打开,任务列表还在。这是因为任务状态都存在 Runtime 服务端,UI 只是“查询者”而不是“持有者”。而桌面端因为生命周期更长,这个体验更好——窗口关了再开,直接恢复到上次的任务视图。
两条链路在会话层差异化很明显,但落到 UI 组件的写法上,差异被抽象接口吸收掉了:UI 只负责根据快照渲染,不需要关心快照从哪来、是否跨页持久化。
3.4 双链路切换时的选路逻辑
在源码里,决定“走哪条链路”的逻辑并不在 UI 层,而在启动器层。桌面版本启动时做一次环境探测:
- 检查本地 Runtime 是否可启动、端口是否可用;
- 准备令牌并注入 UI 的启动上下文;
- UI 初始化时用注入的 baseURL 构造 RuntimeClient 实现。
Web 版本则是显式由dsh web起一个服务,UI 加载时读取当前页面地址作为 baseURL。两条路互不干扰,也不需要 UI 层在运行时动态判断“我是桌面还是 Web”——这个判断在启动期就定完了。
4. 工程目录与双端构建:一份代码长出两个壳
4.1 物理分区:shared、ui、desktop、web 四级划分
源码仓库的目录划分值得单独拿出来讲,因为它是这套架构能否长期维护的物理基础。我拆下来看到的分区逻辑非常清晰:
shared/:前后端共享的类型定义、常量、配置 schema,UI 和 Runtime 都依赖它;ui/:完整的前端应用,独立于任何宿主存在;desktop/:桌面壳,负责启动本地 Runtime、管理窗口生命周期、注入令牌;web/:Web 部署入口或服务入口,负责dsh web的启动与会话。
这个划分保证了一个底线:UI 目录里面不允许出现任何关于“我是桌面还是 Web”的代码,一切环境差异通过配置和启动参数注入。如果你在 UI 代码里看到了window.desktopAPI这类全局,那基本说明架构跑偏了——而这套源码里我没找到这种硬编码。
4.2 构建期如何注入环境差异
同一份 UI 代码产出两个壳,靠的是构建期的环境变量注入。关键差异只有几处:
| 配置项 | Web 模式 | 桌面模式 |
|---|---|---|
| API 基础地址 | 当前页面同源地址 | 动态端口本地地址 |
| 认证方式 | 浏览器会话 Cookie | 启动注入的一次性令牌 |
| 资源加载 | 静态资源 CDN/本地 | 打包内嵌本地资源 |
| 恢复逻辑 | 刷新后重连 | 窗口重启后重建 client |
其它像组件写法、状态管理、路由定义,全部共用一份。成本控制的关键,就是把“差异面”压缩到最小,不搞两张皮。
4.3 内嵌资源与静态导出的双轨产物
桌面端构建产物会把 UI 静态资源内嵌进安装包里,启动后由本地服务直接吐出页面和 JS,不需要额外起一个静态服务器,也不依赖外网。Web 端产物则走常规的静态资源导出,部署到任意静态站点或由dsh web托管。
这个双轨产物最大的好处是离线可用。我试过在完全无外网的环境里跑桌面版,页面加载、任务启动、日志流全部正常,因为所有依赖都被打包进本地了。这在跑评测任务、在隔离网络环境里调试 Agent 时非常救命。
5. 解耦之后浮现的新问题:认证、端口与生命周期管理
5.1 认证会话的安全边界:一次性令牌与浏览器的会话体系
把 UI 和 Runtime 解耦后,第一个绕不开的问题就是认证。桌面链路要考虑“如何证明你这个 UI 是这个 Runtime 本尊启动的”,Web 链路要考虑“如何让浏览器会话安全地持有任务操作权限”。
桌面端的解法是一次性令牌注入:Runtime 启动时生成令牌,UI 启动时通过启动参数拿到它,后续每个请求携带。令牌不在磁盘上落盘,进程退出即失效,即使别人知道端口号,也无法伪造合法请求。
Web 端的解法则落在传统的会话体系上——dsh web启动后,首次访问会进入一个认证流程,浏览器拿到会话凭证后,后续请求都带着会话走。我记得自己第一次跑dsh web时,终端里弹出一行提示:需要重新打开终端打印的 URL 完成认证,当时没仔细看,一直在浏览器里刷新,结果一直 401,后来把新 URL 重新打开就好了。这个“重新打开 URL”的机制,本质上就是认证流程和页面地址绑定的一次性跳转,跟很多本地开发工具的 onboarding 流程是一致的。
5.2 端口冲突与动态端口探测
既然是本地 HTTP 服务,端口冲突就跑不掉。源码的做法是启动时探测可用端口,而不是硬编码。这个策略的正确性体现在“大概率成功”上——固定端口的应用一旦被别的进程占用,要么报错退出,要么自动换端口但用户根本不知道现在用的是哪一个。动态端口配合日志输出,反而把“换端口”的行为透明化。
我平时调试时习惯在日志里直接看它选了什么端口,然后手动访问确认服务活着。如果你做类似架构,这一步一定不要省:端口号必须在启动日志里明确打印出来,不然 UI 连不上时用户根本不知道去哪找。
5.3 生命周期:窗口关了,Runtime 该不该跟着走
桌面端一个容易翻车的细节是生命周期管理。UI 窗口关闭,本地 Runtime 要不要一起退出?如果一起退出,后台任务谁继续跑?如果不退出,孤儿进程怎么回收?
从源码表现来看,桌面模式的 Runtime 生命周期跟 UI 窗口是绑定在一起的:窗口关闭触发 Runtime 的优雅退出,正在运行的任务会收到中断信号并记录状态,下次启动时可以恢复或显式标记为“中断”。这个取舍很务实——桌面工具保持“我关了就是关了”的一致体验,避免用户困惑“我明明没跑东西,怎么后台还有个进程”。
但这也带来一个副作用:如果你真的需要长时间跑批量任务,建议直接用dsh web模式挂一个常驻服务,而不是开桌面端。两种模式的定位在生命周期策略上分得很清楚:桌面端面向交互式操作,Web 端面向常驻服务。
6. 实操排错:我在这套双链路架构里踩过的坑
6.1 WebView2 Runtime 缺失,桌面端白屏的真凶
桌面端最经典的“怎么 UI 起不来”问题,十有八九是宿主环境缺少 WebView2 Runtime。实测表现是:进程起来了,本地端口监听了,令牌打印了,但窗口里只有一片空白或加载失败。解决方案就是手动补装 WebView2 Runtime,装完重开。
这个问题不是这套架构独有的,几乎所有基于 WebView 的桌面应用都会遇到,但它跟你是不是“共享 UI”没关系——UI 代码没错,是宿主渲染环境的锅。排查思路很简单:先看本地服务是不是活着,再开 DevTools 看加载报错,如果连静态资源都没加载出来,优先检查 WebView2 环境和资源路径。
6.2 “container runtime is not running”这类本地 Runtime 启动失败
有些环境下桌面 Runtime 会报“container runtime is not running”之类的错误,这不是 UI 的问题,而是底层依赖的服务没起来。排查链路我一般按顺序走:
- 先确认桌面 Runtime 进程是否真的启动了(看任务管理器或 ps);
- 确认日志里打印的端口号和 UI 实际请求的端口号是否一致;
- 直接 curl 一下本地地址,带上令牌试试核心接口通不通;
- 如果 curl 能通而 UI 报错,问题在 UI 的 client 配置;如果 curl 都不同,问题在 Runtime 本身。
这个排查顺序能快速把责任切到“UI 侧”还是“Runtime 侧”,不会在界面上瞎猜。
6.3 UI 能开但状态不刷新:多半是事件订阅断了
还有一个我帮人排查过多次的问题:页面能打开、任务也能发起,但状态不自动更新,要手动刷新才看到变化。这类问题十有八九是streamEvents或轮询机制断开了。
在 Web 链路下,这可能是浏览器对后台标签页的连接节流导致的;在桌面链路下,可能是本地服务的事件推送在某种异常下静默断开。处理思路是:在 UI 侧加一个兜底的心跳检测,事件流断开后自动重建订阅,并补一次快照拉取。
这属于“共享 UI 双链路”的特殊工程问题——链路不同,断开的隐蔽程度不同,但 UI 代码必须用同一种兜底策略来兼容两种怪异行为。把兜底逻辑写在 UI 层,而不是分别写在两套链路里,是这件事最优雅的解法。
6.4 给同样在折腾这套架构的人三个建议
最后给三点从实操里沉淀下来的体会:
第一,抽象接口不要一步到位。先让 UI 直接调真实 Runtime,等把页面写明白后,再抽 RuntimeClient,你会发现接口长什么样已经很清晰了。反过来一上来就抽接口,容易抽出一堆没人用的死方法。
第二,日志里一定把端口、令牌、会话地址都打出来。跨链路排查时,这些信息就是定位的第一线索,少一个就得多花十分钟靠猜。
第三,恢复逻辑要尽早设计。任务开始跑以后,UI 不管是刷新还是重开,都应该能重新拿到任务快照。这个能力不是“锦上添花”,是共享 UI 双链路下能不能被信任的基础。
我自己的体会是,这套“共享 UI + Runtime 接缝”的架构,最大的收益反而不是省代码,而是让交互层可以独立于底层运行机制快速发展。今天它跑在本地进程上,明天它可以接远程服务,后天可以换一套更快的运行时——只要接缝的接口不塌,界面就永远是那套界面。如果你也在犹豫要不要把 Web 和桌面拆成两份代码,我建议你冷静算一下:双份代码的维护成本,大概率比你想象的高得多。而一条干净的 RuntimeClient 接缝,才是那个值得你花时间打磨的地方。