anywhere-labs/deepseek-harness-desktop 双模式界面设计:兼容性、原生材质与 Slot 组合

摘要

作为既做过前端设计系统、也做过 Electron 原生窗口的开发者,我认为桌面化最棘手的 UI 问题并不是"怎样加一块漂亮的 Mica 或毛玻璃",而是怎样在不破坏既有产品语义和插件扩展点的前提下,让 Web 界面真正适应桌面窗口。本文专门从界面架构角度拆解社区项目 anywhere-labs/deepseek-harness-desktop。DSH Desktop 没有把官方 DeepSeek Harness Web UI 复制成另一套 React 应用,而是设计了 compatibility 与 advanced 两种呈现模式:兼容模式的 Client face 校验环境后不产生任何布局、样式或 slot effect,完整保留所选 Profile 的官方 layout/sidebar/conversation 组合;高级模式才禁用官方 ui-layout row,由 Desktop 提供 layout service 与唯一的 root slot occupant,但 sidebar、conversation、details 和 overlay 仍由上游或第三方 contribution 填充。这个选择让原生材质与业务组件形成清晰分工:macOS 使用 hidden-inset 标题栏、traffic lights 和 sidebar vibrancy,Windows 使用隐藏标题栏 overlay 与 Mica;桌面 frame 负责三栏几何、caption row、拖动区、ResizeObserver、窄屏自动折叠和 details 空间让渡,而会话、设置、工作区浏览与插件 UI 继续属于原来的 DSH 组件。我尤其会关注视觉稿容易忽视的工程问题:拖动区与按钮命中如何共存、details 为什么在空间不足时先关闭、窄屏临时展开为什么不能覆盖用户宽屏偏好、主题 token 如何在 fiber dispose 后干净撤销,以及 Linux 为什么宁可明确拒绝高级模式也不伪装成相同效果。本文会结合源码中的宽度常量、列计算函数、React external store、theme presenter 和窗口参数,分析两种模式如何切换、为何必须重启、怎样避免文字和交互控件落进拖动区,以及第三方插件应该如何面对 slot 所有权变化。对任何想把成熟 Web 产品变成桌面应用的团队,这套"展示层可替换、业务 surface 不复制"的方法都比简单套壳更值得研究。

项目身份说明:本文专门介绍社区仓库 anywhere-labs/deepseek-harness-desktop 的桌面界面实现,它不是 DeepSeek 官方产品。

图1 DSH Desktop 高级呈现:原生 frame 包围并复用已有 DSH 内容 surface

一、双模式不是两套产品

compatibility 与 advanced 共享同一个 Host、同一个 loopback HTTP/WebSocket carrier、同一个 Profile 和同一套第三方 Client module。差异只发生在 presentation ownership:谁提供 root layout,以及 BrowserWindow 使用标准边框还是平台原生材质。

对比项 Compatibility Advanced
Host/Agent/Session 原样复用上游 原样复用上游
Web carrier 127.0.0.1 HTTP/WebSocket 完全相同
root layout 所选 Profile 的上游 row Desktop AdvancedFrame
sidebar/conversation 上游/第三方组合 仍是上游/第三方组合
原生窗口 标准系统 frame macOS vibrancy / Windows Mica
Client 样式 Desktop 不安装 安装 Desktop frame 样式
Linux 支持 明确拒绝,不静默降级
复制代码
flowchart TD
    A["同一 DSH Host + Web Carrier"] --> B{"dsh-desktop.mode"}
    B -- "compatibility" --> C["校验 mode/platform"]
    C --> D["Client face 返回<br/>不注册 layout/root/styles"]
    D --> E["Profile 自己拥有完整 UI"]
    B -- "advanced" --> F["禁用官方 ui-layout row"]
    F --> G["Desktop 提供 layout service"]
    G --> H["AdvancedFrame 占用 root slot"]
    H --> I["复用 sidebar/conversation/details"]

    classDef shared fill:#2563eb,color:#fff,stroke:#1d4ed8,stroke-width:2px;
    classDef choice fill:#f59e0b,color:#111827,stroke:#d97706,stroke-width:2px;
    classDef compat fill:#10b981,color:#fff,stroke:#047857,stroke-width:2px;
    classDef advanced fill:#8b5cf6,color:#fff,stroke:#6d28d9,stroke-width:2px;
    class A shared;
    class B choice;
    class C,D,E compat;
    class F,G,H,I advanced;

图2 双模式分支:共享业务运行时,只替换明确的展示所有权

这种设计还给排错提供了基准线:如果第三方插件在高级模式异常,可以切回兼容模式判断问题来自上游组合还是 Desktop frame,而不是面对两套完全不同的前端实现。

二、模式如何成为单一事实源

模式保存在 DSH home 的 settings.yaml,而不是 Profile manifest 或 Electron 私有状态中:

bash 复制代码
dsh-desktop:
  mode: compatibility # 或 advanced

Launcher 在组合 generation 前读取当前 settings provider 解析到的同一文件,Host 又通过标准 settings service 注册 dsh-desktop namespace。托盘修改和手工编辑作用于同一个来源,不存在两份模式值互相覆盖。

模式变化不会在存活 renderer 中热替换。当前 Cordis tree 先 dispose,Electron 只在零退出码 shutdown 后 relaunch,下一代才重新决定 Loader row、root slot 和 BrowserWindow 材质。因为这三件事跨越 Host、Client 与 native window,强行热换会制造"页面布局已变、窗口 chrome 未变、旧 service 仍被引用"的中间状态。

三、Host 用经过校验的 Marker 告知 Client

desktop-shell 生成 loopback URL 时添加 dsh-desktop-modedsh-desktop-platform 查询参数。Client 不盲信任任意字符串,而是只接受 compatibility/advanced 和 darwin/win32/linux 的有限集合;缺失或非法值直接报错,不尝试猜测。

bash 复制代码
export function parseDesktopClientEnvironment(search: string) {
  const params = new URLSearchParams(search)
  const mode = params.get('dsh-desktop-mode')
  const platform = params.get('dsh-desktop-platform')

  if (!MODES.has(mode as DesktopClientMode)) throw new Error('invalid mode')
  if (!PLATFORMS.has(platform as DesktopClientPlatform)) throw new Error('invalid platform')
  return { mode, platform }
}

这些 marker 只是当前窗口的展示事实,不是第三方获取原生能力的通道。renderer 仍在 sandbox 中,也不能通过修改 URL 把 Linux 变成支持 Mica 的 Windows。

四、高级模式只拥有 Root Frame

高级 Client 创建 DesktopLayoutState,通过 Cordis reflect 提供标准 layout service,然后注册一个 root slot occupant。root 只定义四个 child seat:sidebar、conversation、details 与 shell.overlay

bash 复制代码
ctx.slots.register({
  name: 'root',
  children: {
    sidebar: { kind: 'single', scope: 'root' },
    conversation: { kind: 'single', scope: 'session-maybe' },
    details: { kind: 'single', scope: 'session' },
    'shell.overlay': { kind: 'list', scope: 'root' },
  },
  inject: () => ({ layout: desktopLayout, platform: environment.platform }),
}, AdvancedFrame)
Seat 所有权 高级 Frame 的责任
sidebar 官方 sidebar 或兼容插件 提供透明 surface 与列宽
conversation 官方 conversation 提供中心可用区域,不改业务内容
details 会话级详情 contribution 控制显示宽度,slot 仍挂载
shell.overlay 多个 overlay contribution 提供不改变三栏几何的覆盖层
bash 复制代码
flowchart LR
    ROOT["Desktop AdvancedFrame<br/>root occupant"] --> SIDE["sidebar seat"]
    ROOT --> CONV["conversation seat"]
    ROOT --> DETAIL["details seat"]
    ROOT --> OVER["shell.overlay list"]
    UP1["官方 Sidebar"] --> SIDE
    UP2["官方 Conversation"] --> CONV
    THIRD["第三方 Contributions"] --> DETAIL
    THIRD --> OVER

    classDef root fill:#8b5cf6,color:#fff,stroke:#6d28d9,stroke-width:2px;
    classDef seat fill:#2563eb,color:#fff,stroke:#1d4ed8,stroke-width:2px;
    classDef upstream fill:#10b981,color:#fff,stroke:#047857,stroke-width:2px;
    classDef third fill:#f59e0b,color:#111827,stroke:#d97706,stroke-width:2px;
    class ROOT root;
    class SIDE,CONV,DETAIL,OVER seat;
    class UP1,UP2 upstream;
    class THIRD third;

图3 Slot 所有权:Desktop 决定几何,内容仍由上游与第三方填充

这比复制官方 sidebar 组件更稳健:上游行为、动画、设置入口、工作区浏览和第三方 footer action seat 可以继续演进,Desktop 只维护 frame contract。

五、三栏算法首先保护会话区

高级界面不是简单 grid-template-columns: 280px 1fr 360px。源码定义了明确约束:普通收起 sidebar 为 56px,macOS 因 traffic lights 与桌面 inset 使用 90px;展开首选 280px,范围 264~420px;details 首选 360px,范围 300~520px;中心 conversation 最低目标为 640px;viewport 小于 1024px 时自动进入窄屏模式。

bash 复制代码
export function computeDesktopColumns(viewport, sidebar, details, collapsed = 56) {
  const side = sidebar === 0 ? collapsed : clamp(sidebar, 264, 420)
  const right = details === 0 ? 0 : clamp(details, 300, 520)

  if (side + right + 640 <= viewport) {
    return { sidebar: side, center: viewport - side - right, details: right }
  }
  // 空间不足时先压缩/关闭 details,优先保住中心会话区。
  return { sidebar: side, center: Math.max(0, viewport - side), details: 0 }
}
bash 复制代码
flowchart TD
    A["读取 Frame 实际宽度"] --> B["约束 Sidebar 偏好"]
    B --> C["约束 Details 偏好"]
    C --> D{"三栏 + 640px 中心<br/>是否放得下?"}
    D -- "是" --> E["使用偏好宽度"]
    D -- "否" --> F{"压缩 Details 后<br/>是否放得下?"}
    F -- "是" --> G["中心保持 640px"]
    F -- "否" --> H["关闭 Details"]
    H --> I["剩余空间全部给中心"]

    classDef measure fill:#2563eb,color:#fff,stroke:#1d4ed8,stroke-width:2px;
    classDef decision fill:#f59e0b,color:#111827,stroke:#d97706,stroke-width:2px;
    classDef result fill:#10b981,color:#fff,stroke:#047857,stroke-width:2px;
    class A,B,C measure;
    class D,F decision;
    class E,G,H,I result;

图4 三栏降级顺序:详情面板先让位,核心会话区优先

这套算法用 ResizeObserver 读取 frame 实际宽度,而不是把字体大小或列宽与 viewport CSS 猜测绑定。拖动把 pointer capture 留在 resize handle 上,sidebar 向右增加、details 向左增加,并通过 layout state clamp,动态内容不会把固定工具面板撑出不可预测宽度。

当 frame 小于 1024px,DesktopLayoutState 设置 narrow=true 并重置临时展开状态。此时 sidebar 默认显示紧凑 rail,用户仍可通过标准 layout action 临时展开;退出窄屏后,原来的宽屏 sidebar 偏好仍然保留。这样,"响应式自动状态"和"用户长期偏好"没有被混成同一个布尔值。

会话切换也会影响 details。当上一会话与当前非空会话不同,frame 主动关闭 details,避免右侧仍展示上一个 session 的上下文。slot 本身保持挂载,关闭只通过列宽为零表达,组件生命周期与可见性控制不会被粗暴混同。

状态字段 含义 为什么单独保存
sidebar 宽屏首选宽度,0 表示 rail 保留用户偏好
details 详情首选宽度,0 表示关闭 与 session surface 独立
narrow 当前是否低于断点 由 ResizeObserver 决定
narrowExpanded 窄屏临时展开 不覆盖宽屏偏好

七、macOS 与 Windows 原生 Chrome 的差异

高级模式不是同一套无边框窗口强行跨平台。macOS 使用 titleBarStyle: hiddenInset、透明背景、vibrancy: sidebar,traffic lights 定位在 (16,16);Windows 使用 hidden titlebar、32px overlay、透明背景、Mica、阴影、圆角与可调整粗边框。

平台 原生能力 Desktop CSS 需要配合的空间
macOS traffic lights、sidebar vibrancy 顶部 20px 视觉间距、32px drag hit region、80px 安全宽度
Windows 原生 caption controls、Mica 32px caption row、右侧 138px 控件保留区
Linux 标准 compatibility window 不启用 advanced,不伪造材质

拖动区是交互设计中的高风险位置。透明 caption row 必须允许拖动,但按钮、链接、输入框、dialog 与自定义 pointer target 必须声明 app-region: no-drag。否则视觉上存在的按钮会吞掉点击,或者可点击区域反过来让窗口无法拖动。Desktop 把完整业务 surface 放在 caption row 下方,减少逐个组件补 offset 的需求。

八、Theme Presenter 连接 Web 与原生材质

高级模式订阅上游 theme service 的 resolved snapshot,把 color scheme、token、深色 marker 和 theme-color meta 投影到 document;切换时先移除上一次自己写入的 token,再写入新值。dispose 时只清理自身拥有的 DOM 状态,不误删其他插件属性。

bash 复制代码
apply(snapshot: ThemeSnapshot): void {
  document.documentElement.style.colorScheme = snapshot.active.colorScheme
  for (const name of this.appliedTokens) document.body.style.removeProperty(name)
  this.appliedTokens = []
  for (const [name, value] of Object.entries(snapshot.active.tokens)) {
    document.body.style.setProperty(name, value)
    this.appliedTokens.push(name)
  }
}

Host 侧同时把内置 light/dark/system preference 同步给 Electron nativeTheme,让 macOS vibrancy 或 Windows Mica 与 Web 内容保持一致。第三方自定义 theme id 不会凭空成为 Host preference,这条边界避免 Client-only 概念污染原生 API。

九、第三方 UI 插件如何保持兼容

普通插件应面向 DSH 的 service 与 slot contract,而不是查询 .dshDesktopFrame DOM、硬编码 sidebar 像素或假设官方 layout 永远占用 root。兼容模式可能完整保留 Profile 自己的布局;高级模式则由 Desktop root 提供 sidebar/conversation/details seat。插件应声明自己真正需要的 slot,并把尺寸适配留给容器。

放在顶部区域的 pointer target 需要确认 advanced 模式的 drag region,不可交互装饰可以保持 aria-hidden,真实按钮必须提供 accessible name、焦点状态和 no-drag。overlay contribution 不应通过 absolute positioning 覆盖系统 caption controls;details contribution 还应正确处理宽度降为零与 session 切换。

十、界面验证清单

  1. Compatibility Client 不注册 layout、root、styles 或 presentation effect。

  2. Advanced 只替换 root/layout,官方 sidebar 与 conversation 仍能加载。

  3. 800px、1024px、1280px 与超宽窗口下中心区域不被 details 无限制挤压。

  4. sidebar/details 拖动受 min/max 约束,pointer capture 结束后布局不跳动。

  5. 会话切换关闭旧 details,overlay 不改变三栏尺寸。

  6. macOS traffic lights、drag strip、sidebar rail 和 conversation caption 不重叠。

  7. Windows caption controls 保持原生可用,Mica 不影响文字对比度。

  8. 所有顶栏按钮、输入和链接保持 no-drag、键盘焦点与可访问名称。

  9. light/dark/system 改变时 Web token、meta theme-color 与原生材质同步。

  10. Linux advanced 值明确报错,不渲染与设置不一致的伪高级模式。

十一、一次布局交互经过哪些状态

以用户拖动 details 分隔线为例,pointer down 记录起始坐标与当前宽度,并调用 pointer capture;后续 move 即使指针短暂离开细窄 handle,事件仍由同一元素接收。details 在右侧,因此向左拖动意味着宽度增加,计算后交给 layout.setDetails(),状态对象执行 300~520px clamp、替换不可变 snapshot,并逐个通知订阅者。AdvancedFrame 通过 useSyncExternalStore 读取新 snapshot,再结合 ResizeObserver 得到的实际 viewport 运行 computeDesktopColumns()。如果中心不足 640px,用户要求的 details 宽度不会被盲目照搬,而是被压缩或关闭。

bash 复制代码
sequenceDiagram
    actor User as 用户
    participant Handle as ResizeHandle
    participant State as DesktopLayoutState
    participant React as AdvancedFrame
    participant Grid as CSS Grid

    User->>Handle: pointerdown
    Handle->>Handle: 记录 origin/base + capture
    User->>Handle: pointermove
    Handle->>State: setDetails(base - delta)
    State->>State: clamp + immutable snapshot
    State-->>React: notify subscribers
    React->>React: computeDesktopColumns(viewport,...)
    React->>Grid: 更新 gridTemplateColumns

图5 从指针到三栏网格:偏好先进入状态,再由空间约束决定实际结果

这条链路避免把布局逻辑散落在事件处理器和 CSS 中。交互层只表达"用户想要多宽",状态层保证范围,几何函数保证中心区域,React 层负责将确定结果投影到 DOM。函数可以脱离浏览器做单元测试,pointer 行为则由聚焦组件测试覆盖。

Sidebar toggle 也遵循相同原则。宽屏下它在 0 与 280px 偏好之间切换;窄屏下只改变 narrowExpanded,不重写持久偏好。窗口重新变宽时,用户原先的 sidebar 状态仍然存在。这种状态分层能避免响应式页面常见的"缩一下窗口,之前设置永久丢失"。

十二、五种常见界面反模式

第一种是在兼容模式偷偷注入样式。即使只改一条 body 背景,也会让"官方默认体验"失去可验证性。Compatibility 应在环境校验后真正不产生 presentation effect。

第二种是复制上游 Sidebar。复制能快速控制样式,却会复制设置入口、工作区浏览、会话列表、动画与第三方 seat 的维护责任。高级模式应拥有容器,不拥有业务组件。

第三种是通过 DOM selector 接管插件区域。上游 class 名不是稳定 contract,查询并搬运节点还会破坏 React/Cordis 生命周期。正确接口是 service 与 slot,不是 MutationObserver。

第四种是把整块顶部区域设为 drag。Electron 的 drag region 会改变命中行为,按钮看得见却点不到。应缩小透明拖动条,所有交互目标显式 no-drag,并在 macOS traffic lights 与 Windows caption controls 周围保留安全区。

第五种是只在设计稿宽度测试。三栏、长标题、系统缩放、窄窗口、侧栏展开、details 打开和第三方 overlay 会组合出大量状态。稳定尺寸必须通过 min/max、grid track 和降级顺序保证,而不是依赖截图像素刚好合适。

反模式 短期诱惑 长期代价
Compatibility 注入小修补 快速统一品牌 无法判断上游兼容性
复制官方组件 视觉控制最直接 行为与扩展点持续分叉
DOM selector/搬运节点 无需理解 slot 生命周期和升级脆弱
大面积 drag region 窗口容易拖动 控件失去点击和选择
只测固定画布 演示图漂亮 实际窗口内容重叠

十三、多视口与多平台 QA 方法

界面验证不能只看一张桌面截图。我会至少选择 800×600、1024×768、1280×840、1440×900 和超宽五组窗口,分别组合 sidebar 收起/展开、details 关闭/打开、空白会话/活跃会话、浅色/深色、中文/英文长文本。每个状态都检查列宽、滚动、caption controls、拖动、焦点环和 overlay 层级。

macOS 需要单独观察 traffic lights 下方是否留出空间、90px rail 是否让官方 56px 内容居中、vibrancy 上文字对比是否足够;Windows 要检查系统缩放、标题栏按钮、Mica 支持版本、resize border 和移除菜单后的键盘行为。Linux 则重点确认 advanced 设置被明确拒绝、compatibility 仍能完整使用,而不是测试一套不存在的玻璃效果。

自动化层可以把 computeDesktopColumns()、layout state 与 environment parser 做纯测试;浏览器层验证 slot、pointer、键盘和响应式;Electron 层再做真实 BrowserWindow screenshot 与 hit-test。截图差异适合发现几何漂移,但不能替代点击、键盘、屏幕阅读器名称和原生拖动测试。只有把"看起来正确"和"操作起来正确"拆开验证,桌面外壳才不会在换平台或换缩放比例后暴露盲点。

参考资料

总结

从界面架构角度阅读 anywhere-labs/deepseek-harness-desktop,我最欣赏的是它没有把"高级模式"理解为重新实现官方 Web UI,而是把变化压缩在 presentation ownership 上。兼容模式真的保持沉默,让所选 Profile 对 layout、sidebar、conversation 和第三方 contribution 拥有完整控制;高级模式也只提供 layout service 与 root frame,用标准 seat 接住不变的业务 surface。这样一来,macOS vibrancy、Windows Mica、caption row、traffic lights、resize handle 和三栏算法都能由 Desktop 负责,而 Agent 会话、设置、工作区和插件组件继续跟随上游演进。源码中的几何策略同样体现了产品优先级:侧栏与详情都有稳定范围,空间不足时 details 先让位,中心 conversation 尽量保持 640px;窄屏自动状态与用户偏好分开保存,会话变化会关闭过期详情,布局通过 external store 与 ResizeObserver 保持可预测。交互状态从 pointer capture、layout snapshot 到列计算层层收敛,避免事件处理器直接写出不受约束的 CSS;多视口 QA 又把截图、点击、键盘、焦点、拖动和原生控件拆成不同证据。主题也不是复制一套颜色,而是把上游 resolved token 投影到 document,再把有限的 light/dark/system preference 同步到 nativeTheme。对我而言,这是一种成熟的桌面 UI 方法:先确定哪些区域真正需要原生化,再用 slot 和 service 隔离所有权,不通过 DOM 劫持、组件复制或平台假象获得短期效果。未来把其他 Web 产品迁移到 Electron 时,我会优先设计兼容基线、显式高级组合、稳定几何约束和可验证的拖动/焦点边界,并把"宽屏偏好""窄屏临时状态""业务内容"和"原生 chrome"分开建模;只有这些基础成立后,毛玻璃与 Mica 才是体验增强,而不是掩盖结构耦合的装饰。

相关推荐
AI砖家2 小时前
DeepSeek Harness 插件开发指南:从零开发到验证安装
deepseek·deepseekharness
探长782 小时前
DeepSeek 涨价,V4 已经用不起了:Harness + 私有化部署的丝滑体验
deepseek·harness·私有化部署,前缀缓存
云边有个稻草人3 小时前
anywhere-labs/deepseek-harness-desktop 发布工程解析:从源码到可运行安装包
deepseek
徐且行3 小时前
DeepSeek Harness初体验:入门、安装与自定义插件
深度学习·自然语言处理·llm·agents·deepseek·harness
AI风向标3 小时前
DeepSeek Harness + cc-connect + 飞书:从“只有最终结果”到“实时流式输出”
deepseek
Justin3go4 小时前
什么是 DeepSeek-Harness?完整介绍
ai编程·deepseek
AI英德西牛仔4 小时前
千问导出 pdf 颜色不一样怎么办,选用 AI 导出鸭优化格式转换,多维度剖析千问内容 PDF 变色各类成因
人工智能·ai·chatgpt·pdf·deepseek·ai导出鸭
水水不水啊4 小时前
DeepSeek自带的一些插件的功能介绍
笔记·ai·deepseek·harness·dsh
wujian831115 小时前
怎么用文心生成word文档?从格式错乱到智能导出,AI导出鸭让创作再无后顾之忧
人工智能·ai·word·豆包·deepseek·ai导出鸭