← 返回列表
需源码安装
DeepSeek Harness 桌面壳dsh-ui
暂不能直接安装(需源码编译或环境不满足):仓库缺少 package.json,无法用 dsh 插件安装命令安装。 · 最近上游提交 2026/9/24 · 已提供中文文档
DeepSeek Harness (@deepseek-ai/dsh) 的 Windows 桌面外壳——将 Web UI 封装为单个便携式 .exe,带有系统托盘、插件管理器和一键 npm 更新。
综合分
31.4
GitHub 分
31.4
用户评分
—
★ Stars
2
周下载量
—
安装插件(需先安装 dsh CLI 引擎:npm install -g @deepseek-ai/dsh)
dsh plugin --profile web add maxwell-nc/DeepseekHarnessDesktopLite仓库缺少 package.json,无法用 dsh 插件安装命令安装,改用 GitHub 源安装
信任档位:已验证本站已于 3 天前真实安装成功(L4 · 真实安装)
- 是什么
- dsh 原生插件 · market
- 装得上吗
- 本站已真实安装成功(L4 · 真实安装,非静态推断)
- 安全吗
- 本站尚未对该插件做风险分级(暂未覆盖,不等同于无风险)
- 还在维护吗
- 活跃:最近一次提交在 1 天前
档位由下列信号合成:本站实装验证(真实安装,当前最高到 L4)· 验证所用 dsh 版本 · 静态安装检查 · 风险分级 · 仓库维护状态。下方各区块是它的证据明细。 验证判据与等级说明 →
🟢实装验证通过· 2026/9/23
由本站实装验证器在真实 dsh 环境安装成功,非静态推断。
数据截至 2026/9/25(元数据每日更新 · 实装验证按队列轮转,单条结论的验证时间见上方)
安装兼容性检查需源码安装
以下结论由程序自动检查 npm 包、engines 声明与入口文件得出,未做人工实机验证——能装不等于用着没问题。
✗npm 包DeepseekHarnessDesktopLite(未发布到 npm,仅可源码安装)
✓Node 引擎未声明 engines.node
✓dsh CLI 依赖未声明 dsh 版本约束
✗入口文件缺少入口声明
仓库缺少 package.json,无法用 dsh 插件安装命令安装
验证方式:npm registry 存在性 + package.json 静态校验 · 最后验证 2026/9/20 18:51:29
用户评分
还没有人投票,来当第一个
订阅周报,不错过优质插件更新
每周一封 · 高评分插件 + 新用户活动
README
由 DeepSeek 最新模型翻译生成DeepSeek Harness 桌面壳(dsh-ui)
把 DeepSeek Harness(@deepseek-ai/dsh)的 Web UI
包成一个 Windows 桌面程序。
特性
- WebView2 内核:用系统自带的 Edge WebView2 渲染界面,不依赖外部浏览器
- 自带 Node 运行时:产物内置 Node 26(exe 同目录 node/),用户机器不用另装 Node;
该目录缺失时才回退到系统 Node
- 亮色界面:启动页与插件管理器都是亮色主题(底色 #f4f6fb,DeepSeek 蓝强调色 #4d6bfe)
- 无命令行窗口:打包为 windowed onedir 目录(exe + _internal/),服务进程用
CREATE_NO_WINDOW 静默拉起
- 系统托盘:关闭窗口只是收进托盘,服务继续在后台跑;托盘菜单可重新打开界面,
可「重启服务」(只重启本地 dsh 服务,应用不动)或「重启应用」(先拉起新实例
再退出旧实例,服务随之重启);版本行只报 dsh ,不带运行状态
- 保持唤醒:启动即阻止系统睡眠(SetThreadExecutionState),后台任务不会被睡眠打断;
不阻止息屏、不影响锁屏,退出时自动恢复系统默认睡眠策略
- 插件管理器:托盘 →「插件管理器」,绿灯启用 / 红灯关闭,下面一个「重启服务并生效」
- 版本管理器:托盘 →「版本管理器」,预览远端最近 10 个版本(含 alpha 等预发布),
每个版本下载到独立槽位(runtime\slots\\,约 220 MB/个,互不覆盖),
下载过的版本秒级来回切换:切换前必弹窗口内确认,确认后自动重启应用。
槽位数超过上限(默认 5)只在页面里提示(非弹窗),并给「去清理」跳转按钮。
托盘没有「检查更新」和「选源」入口 —— 检查/下载/切换/删除和换 npm 源
全在版本管理器里(换源用窗口顶部的下拉框:npmmirror / 华为云 / 腾讯云 / 跟随系统)
- 配置目录与备份:每个版本一份 dsh home(homes\\ —— 登录态、设置、
会话、profile 全在里面),第一次给某版本建目录时从切换前那份拷一个副本过来,
已存在就一个字节不动;切换前先停服务再自动备份旧配置(backups\auto\),
版本管理器里另有手动「备份配置」和恢复 / 删除清单 —— auto、manual 两个池子
各留最近 10 份。删槽位不删配置,列表每行的「配置目录」按钮可打开手工清理
- 插件自动加载:启动时按启用状态把 exe 同目录 plugins/ 里的插件装进 dsh
(homes\\profiles\web\,见下面「配置目录与备份」),
关掉的就撤下来,不用手工改 dsh 的配置
- 默认走国内镜像(registry.npmmirror.com):首次安装时在启动页三选一,
之后下载/检查更新都沿用它(配置在 config.json 的 registry;
日常换源走版本管理器窗口顶部的下拉框,托盘不提供入口)
目录结构
src/ 代码(含构建、打包、自测)
├── dsh_shell.py 主程序(服务管理 / WebView2 / 托盘 / 版本槽位 / 配置目录与备份 / 插件同步 / 三个界面)
├── app_icon.py 运行时绘制图标,无外部资源依赖
├── build.py 一键打包入口
├── assets/app.ico 打包用的图标(缺了 build.py 会按 app_icon.py 重新生成)
├── packaging/.spec PyInstaller 打包配置
├── runtime/dsh_fastboot.mjs 启动加速补丁(node --import 注入,不改 dsh 文件)
└── tools/ 自测脚本
├── probe_service.py 服务层:安装 / 启动 / 健康检查 / 停止
├── probe_auth.py 鉴权链路:401 → 303 + Set-Cookie → 200
├── probe_gui.py GUI + 托盘:发 WM_CLOSE,确认收进托盘且进程存活
├── probe_update.py 多槽位版本链路:远端列表(含 alpha)/ 下载 / 占用
├── probe_version_manager.py 版本管理器窗口 + js_api 桥 + 每版本配置目录 + 备份/恢复
├── probe_plugins.py 插件同步:扫描 → 镜像进 dsh → 重写托管补丁块
├── probe_dsh_compat.py dsh 升级后的插件兼容体检(插件依赖的内部接口还在不在)
└── probe_plugin_manager.py 插件管理器窗口 + js_api 桥 + 红绿灯渲染
build/ PyInstaller 工作目录 + Python 字节码缓存(build/pycache)
dist/ 整个目录就是程序目录,一起分发
├── DeepSeekHarness.exe 启动器
├── _internal/ Python 运行时 + 依赖 + runtime/dsh_fastboot.mjs
├── plugins/ 插件源码,进版本库
├── plugins-third-party/ 第三方插件(本机私有,不进版本库,照常加载)
└── node/ 自带的 Node 26(win-x64 zip 摊平),不进版本库
.pyc 不走源码目录:dsh_shell.py / build.py / tools/.py 都设了 sys.pycache_prefix,
字节码统一落在 build/pycache/ 下,源码树里不会再冒 __pycache__。
构建
准备隔离环境
python -m venv .venv
.venv/Scripts/pip install pywebview pystray pillow pyinstaller
打包
.venv/Scripts/python src/build.py
产物(onedir,不是一个单文件):
dist/ 整个目录就是程序目录
├── DeepSeekHarness.exe 启动器,不能单独拷出来用
├── _internal/ Python 运行时 + 依赖 + runtime/dsh_fastboot.mjs
├── plugins/ 插件源码(运行时直接读这份)
├── plugins-third-party/ 第三方插件(本机私有,不进版本库,照常加载)
└── node/ 自带的 Node 26
分发要把整个 dist/ 压成 zip。
插件按源码放在 dist/plugins/(进版本库),不打包进 exe。本机私有的
第三方插件放 dist/plugins-third-party/(被 .gitignore 整体忽略,不进版本库),
外壳启动时两个目录一起扫描、一起参与加载,插件管理器里第三方插件带「第三方」徽标。
自带 Node 要手工准备(构建脚本不下载):从 nodejs.org 下 win-x64 的 zip,把解压出来的
node-v26.x.x-win-x64/ 里面的内容(node.exe、node_modules/、npm.cmd 等)直接铺到
dist/node/ 下 —— 要摊平,别留一层版本号目录。src/build.py 只检查 dist/node/node.exe
在不在并给警告,不会替你下载。dist/node/ 被 .gitignore 的 dist/ 规则忽略,不进版本库。
运行前提
- Windows 10/11,已安装 WebView2 运行时(Win11 与近期 Win10 一般自带)
- 不用单独装 Node:产物自带 Node 26(dist/node/)。只有该目录缺失时才回退到系统
Node —— PATH 里找 node,再找常见安装位置(此时才需要 Node.js 18+)
首次启动会自动在数据目录里 npm install @deepseek-ai/dsh(依赖约 520 个包、
2.3 万个文件,首次需要几分钟,之后走缓存会快很多),装进第一个版本槽位
runtime\slots\\;老版本的单目录布局(runtime\node_modules\)会在下次
启动时原地 rename 迁移成槽位(同卷瞬间完成,不重下、不丢版本)。
数据目录
两个地方,别搞混:
① %LOCALAPPDATA%\DeepSeekHarness\ —— 本壳程序自己的工作目录,可丢、可重建。
换台电脑不用带;只有想省掉重装和重登才值得拷。
| 路径 | 说明 | 丢了会怎样 |
| --- | --- | --- |
| runtime/slots// | dsh 的版本槽位,一个版本一个完整 npm 工程根(各约 220 MB,可并存多个,下载/切换都在这里) | 需要的版本重新下载(走 npm 缓存会快很多) |
| runtime/ | 槽位的父目录;老布局残留会在此被自动迁移进 slots/ | 同上 |
| homes// | 每版本一份 dsh home(登录态、设置、会话、profile):首建时从切换前那份拷副本 | 该版本回到空配置(登录 / 会话丢),可从备份恢复 |
| backups/{auto,manual}/ | 配置备份 zip:切换前自动备 + 版本管理器手动备,两池各留最近 10 份 | 丢掉历史备份(不影响当前配置) |
| workspace/ | dsh 启动时的工作目录 | 里面放过东西的话就没了 |
| webview/ | WebView2 用户数据(Cookie、localStorage) | 界面偏好重置;登录态由下面的 .dsh 决定 |
| config.json | 配置:registry(npm 源,留空=跟随系统)、activeSlot(当前活动版本)、maxSlots(槽位上限,默认 5)、versionsCache(远端版本列表缓存)、dshHome(可选:写死一个不分版本的配置目录) | 回到默认(国内镜像),活动版本按本地槽位自动探测 |
| plugins.json | 插件启用状态(插件管理器写,红灯/绿灯就是它) | 所有插件回到默认「开启」 |
| data/usage.json | usage 插件的 token 账本 | 用量统计清零(从零开始记) |
| shell.log / service.log / stdio.log | 日志 | 无影响 |
② %LOCALAPPDATA%\DeepSeekHarness\homes\\ —— dsh 自己的数据目录,按版本一份:
.credentials.yaml(账号凭据)、sessions/(会话历史)、storages/、
settings.yaml,加上 profiles/web/(profile、插件安装副本、cordis.patch.yml)。
换机器想保住登录和聊天记录,要带的是当前活动版本这一份;老的 %USERPROFILE%\.dsh\
现在只作为首次迁移的种子源留在原地(下次启动会把它拷成第一份版本 home)。
想固定一个不分版本的目录,可以在 config.json 里写 dshHome(外壳会把它
连同 DSH_HOME 一起注入服务子进程,两边永远读同一份)。
插件
插件包放在 exe 同目录的 plugins/(onedir 下就是 dist/plugins/,
壳运行时直接读这份,构建脚本不搬运),一个插件一个子目录。
第三方插件放在旁边的 plugins-third-party/(同样在 exe 同目录,开发态是
dist/plugins-third-party/):目录整体被 .gitignore 忽略、不提交 git,但外壳
照常扫描、照常参与加载,插件管理器里会标「第三方」徽标。两个目录的包结构完全一样:
dist/
├── plugins/ # 源码,同时也是运行时目录
│ ├── gitbash/ # 内置插件:Windows 上改用 Git Bash
│ │ ├── manifest.json # id / name / description / order / entry / patch
│ │ ├── cordis.patch.yml # 要合进 dsh profile 的补丁片段
│ │ ├── gitbash-shell.mjs # 插件本体(host 半边)
│ │ └── README.md
│ ├── usage/ # 内置插件:token 用量统计
│ │ ├── manifest.json
│ │ ├── cordis.patch.yml # 只插 host 半边
│ │ ├── package.json # 声明 dsh.client → 浏览器半边被自动发现
│ │ ├── usage.mjs # host 半边:采集 + 读取接口
│ │ ├── lib/client.js # 浏览器半边:侧边栏入口 + 堆叠柱状图
│ │ └── README.md
│ ├── lan_access/ # 内置插件:局域网扫码访问
│ │ ├── manifest.json
│ │ ├── cordis.patch.yml # 只插 host 半边(刻意不改 webserver 的 host)
│ │ ├── package.json
│ │ ├── lan_access.mjs # host 半边:访问码/开关落盘 + 起停代理 + 读取/开关/重置接口
│ │ ├── lib/proxy.mjs # 带鉴权的反向代理(HTTP + WebSocket)
│ │ ├── lib/qr.mjs # 纯 JS 二维码编码器 → SVG
│ │ ├── lib/client.js # 浏览器半边:侧边栏入口(独占一行)+ 二维码/开关面板
│ │ └── README.md
│ ├── retry/ # 内置插件:消息编辑 / 重试
│ │ ├── manifest.json
│ │ ├── cordis.patch.yml # 只插 host 半边
│ │ ├── package.json # 声明 dsh.client → 浏览器半边被自动发现
│ │ ├── retry.mjs # host 半边:算「切哪儿、原文是什么」的读取接口
│ │ ├── lib/origin.mjs # 纯函数:会话日志 → 切点 + 原文
│ │ ├── lib/client.js # 浏览器半边:消息上的编辑/重试按钮 + 分支重发
│ │ └── README.md
│ └── … # 其余内置插件
└── plugins-third-party/ # 第三方插件:本机私有,不进版本库,照常加载
├── dsh-cf/ # 例:CodeFree-O 反代(本机私有)
└── review/ # 例:待处理改动审查(本机私有)
装进 dsh 的规则(dsh-ui 每次启动、以及管理器点「重启」时执行):
1. 扫 plugins/ 和 plugins-third-party/ 下所有带 manifest.json 的包
(第三方目录不存在就跳过);没记录过的插件默认开启;
2. 开启的包 → 整目录镜像到 homes\\profiles\web\plugins\\
(源目录没变就跳过复制,靠 .dsh-ui-plugin.json 指纹判断;包根目录下的 data/
是插件的运行态,既不复制也不进指纹 —— 插件自己的数据放 /data/);
3. 关闭的包 → 删掉安装副本(只删本程序装的,认指纹文件;目录链接一律不碰);
4. 把已启用插件的补丁片段按 order 拼成一个托管块,重写
homes\\profiles\web\cordis.patch.yml ——
两个 dsh-ui 插件管理块 标记之外的内容原样保留,没有插件时回到 []。
插件目录里 cordis.patch.yml 的 ./plugins// 是相对 profile 目录的
路径,dsh 的 loader 会把它转成 file:// —— 所以插件本身可以放在任何地方。
浮层样式:--dsw-specific-menu 必须配 backdrop-filter
插件的浮层(面板 / 弹窗 / toast)一律 position:fixed 挂在页面里,背景用 dsh 的
菜单色变量。这个变量在 dsh 0.1.7 变成了半透明色:
| | --dsw-specific-menu | 配套 |
|---|---|---|
| ≤ 0.1.5-rc.x | var(--dsw-alias-bg-layer-3)(不透明) | 无 |
| ≥ 0.1.7-alpha | 浅色 #f8f9fa94 / 深色 #30313680(半透明) | --dsw-menu-backdrop-filter: blur(40px) saturate(150%) |
上游自己的面板都把那层模糊加在背景上(有的是 ::before 层),只留 background 的话
浮层就变成「透明白」—— 能看见底下滚动的对话内容(用户实测报过「dsh-cf 面板背景变
透明白」)。所以规则是:凡是 background:var(--dsw-specific-menu,…) 的规则,
紧跟着写 backdrop-filter:var(--dsw-menu-backdrop-filter,none)。
Old versions don't have --dsw-menu-backdrop-filter, so it falls back to none; back then the menu color was already opaque anyway, so the rendering is exactly the same as before the upgrade — one declaration satisfies both versions. probe_dsh_compat.py will individually check this pairing (the group of checks unrelated to the dsh version).
⚠️ By the way, an easily overlooked side effect: backdrop-filter makes the element a containing block for position:fixed descendants (just like transform/filter). If there's anything with fixed positioning inside the overlay (such as the tooltip of the usage bar chart), you need to createPortal it to document.body — otherwise the coordinates will be calculated relative to the overlay's padding origin, and it will also be clipped by overflow:auto. Upstream's own overlay uses isolation:isolate + ::before to carry the background, for the same reason (isolation only creates a stacking context, not a containing block).
Rules for the browser half accessing services: optional services can only use ctx.get()
cordis's ctx is a Proxy: property access ctx.foo is only safe for services that are "written into inject and already ready"; in other cases that getter will directly throw cannot get property "foo" without inject (whether the service hasn't been provided yet, or lives in another isolate, both count as "not there"). And if apply throws an exception = the entire plugin entry becomes failed, and the UI will report dsh-loop-guard: failed (under the web boot: 1 entry did not activate line). We've actually hit this: after the session-switch entry was moved to uiWorkspace, the plugin read it once via property access before it was mounted.
So: required services go into inject (property access is fine), optional/possibly-late services always use ctx.get(id) (returns undefined if unavailable, doesn't throw). In probe_dsh_compat.py there's a group of version-independent checks specifically for this: statically scan whether ctx.X goes beyond the inject list, then use a strict ctx (throws when accessing an unknown service, simulating cordis's real behavior) to actually run each plugin half's apply.
Built-in: gitbash
On Windows, replace the shell executor with Git Bash, and change the shell tool the model sees from pwsh to bash. Pure plugin: doesn't write agent presets, doesn't touch $DSH_HOME/.agent-presets; renaming and prompt rewriting are both done by intercepting tool registration at runtime. For the principle, cost, and pitfalls encountered, see dist/plugins/gitbash/README.md.
Built-in: usage
Counts token usage. In the UI, there's an extra entry at the bottom left, above the settings button; clicking it opens a stacked bar chart: fixed to the last 15 days, one bar per day (today on the far right, the horizontal axis tick only says "day"), each model gets a color stacked from bottom to top, and hovering over a color block shows "model + color + that day's share + usage". Days with no usage leave a placeholder short dash. Usage units automatically carry over: ten-thousand tokens, and at 100 million it switches to hundred-million.
- Ledger: %LOCALAPPDATA%\DeepSeekHarness\data\usage.json — the shell's own data root (the same place as config.json / plugins.json), not placed in the plugin directory: the plugin package is a mirror copied wholesale as an entire directory, so putting it inside would get it deleted along with everything else, and it would naturally create an extra copy. Accumulated by "day × model", persisted via "write temp file + rename" + 800ms debounce.
- Scope: input + cacheRead + cacheWrite + output, four buckets that don't overlap (consistent with upstream dsh-token-meter's usageTokens()).
- Only counts calls that newly occur after installation, doesn't backfill history — so it's empty right after installation, and there's data after one round of conversation. When the same (turn, step) is settled repeatedly, it's treated as "overwrite" rather than "accumulate", so retries/streaming convergence won't inflate the numbers.
- Refresh rereads the ledger file: every read (including the 5-second polling) first syncs to disk, so entries written elsewhere can be seen.
- Dual-sided plugin: the host half (usage.mjs) collects and registers the authenticated GET /api/usage.data; the browser half (lib/client.js) is automatically discovered by dsh-client-modules via dsh.client in package.json, no need to write it into the patch.
- For details and design trade-offs, see dist/plugins/usage/README.md.
Built-in: lan_access
Lets a phone open the web version by scanning a QR code on the same LAN. In the UI, there's also an extra entry at the bottom left, above the settings button (on its own line, not squeezed onto the same line as usage); clicking it opens a QR code + a link; scanning it with a phone lets you use it, with functionality identical to on the computer.
- Off by default, remembered once turned on: after the plugin is installed the entry is there, but port 3081 doesn't listen — a LAN channel is equivalent to spreading this machine's operating permissions onto the network, so it can't be on by default. Only after clicking "Enable LAN access" in the panel does the proxy start; the switch state is stored together with the access code, and it's automatically restored on the next startup; clicking "Disable" releases the port immediately.
- Fixed link: http://:3081/?lan=. The access code is 16 random bytes, generated only once, and stored in %LOCALAPPDATA%\DeepSeekHarness\data\lan_access.json, so as long as the LAN IP doesn't change, the link and QR code don't change (if the IP changes the link changes with it; this is a property of the LAN address itself). The port is hardcoded to 3081: if the port changes the link changes, so when it's occupied we'd rather report an error than switch (the panel will show the reason and offer "Retry").
- Doesn't touch dsh's binding: dsh still only listens on 127.0.0.1:3080. Upstream explicitly blocked --host 0.0.0.0 (in dsh-web-app's startup it says it "would expose RCE to the network"), so the plugin instead starts its own authenticated reverse proxy on the LAN side, and the exposure surface is entirely controlled by the plugin — turning off the switch releases the port, and turning off the plugin leaves nothing behind at all.
- Two layers of authentication: the LAN side uses a fixed access code (?lan= → long-lived cookie); the loopback side uses dsh's own session cookie, which the proxy completes the login redirect for using the process launch token and then holds on its behalf, the browser never touches dsh's token/cookie throughout. By default only LAN/loopback origins are allowed; the panel offers a one-click reset access code, which immediately invalidates old links.
- WebSocket 也走代理:对话流不是 SSE,而是 /api/remote.mux 上的 WebSocket
(dsh-api-gateway 注册的 Upgrade 路由,握手时同样过 dsh 的 Host/Origin 围栏 + cookie 鉴权)。
代理用 net.connect 原样转发握手,少了这一段页面能开但一发消息就废。
- 二维码在宿主侧生成(lib/qr.mjs,纯 JS 编码器,无依赖),前端只负责显示 ——
浏览器半边不用为了显示一张图多背几百行编码器。
- 首次连不上多半是 Windows 防火墙拦了入站,要放行本程序自带的 node.exe(私有网络);
更多坑与设计取舍见 dist/plugins/lan_access/README.md。
内置:retry
每条用户消息的复制按钮旁边多两个按钮:编辑(弹出编辑框,预填这轮消息的原文)
和重试(原文重发)。两者的效果都是从这一轮之前新建一个分支,把消息作为新的一轮
发出去* —— 原会话原样保留,新分支出现在同一个工作区分组里(标题自增),界面自动切过去。
- 为什么是新建分支:dsh 的会话日志是只追加的。日志之上那层 surface 虽然能追加
replace 把旧轮次从模型可见的历史里抹掉,但界面不认(可见记录只由 append
事件拼出来),真那么做就是「你看得见旧回答、模型看不见」。所以照搬内置「分支」按钮
的做法:在目标轮次之前切一刀,在新分支里重新发一轮。代价是每次重试多一个会话。
- 切点:宿主 session.fork 的语义是「第一个 seq ≥ atSeq 的 turn/end」,
所以 atSeq 要指到前一轮的 turn/end,切出来的分支才正好停在这一轮之前。
第一轮没有前一轮可指 → 改成新建一个同目录会话;这一轮还在生成 → 先
session.cancel()(和停止按钮同一个调用)再切。
- 原文从日志取、不从界面读:界面上的气泡是渲染过的(@文件 变成 chip、空白折叠),
拿它的文本去重发会走形。带附件的消息照样能重试/编辑,但附件不会跟着走
(重发通道只收浏览器现传的上传回执),弹窗和提示条里都会说明。
- 分组靠宿主:侧边栏按工作区分组,归属关系只记在工作区那边(会话 header 里没有)。
第一轮那条路要新建会话,宿主 session.create 只收 workspaceId 或 cwd 之一 ——
给 cwd 会得到不属于任何工作区的会话,掉进「未分组」。所以建分支 + 发消息都在
宿主做(sessionController.fork/create/prompt,也就是浏览器那条 RPC 的同一段代码)。
- 切完要清一次子会话收件箱:fork 的切点让这一轮那条用户消息的 inbox 插入 splice
落在种子里、配对的移除 splice 留在外面,子会话会把它当待发复活(先跑一遍旧消息,
再把重发的那条接上)。agent.cancel(cause, {keepInbox:false}) 清掉。
- 分支标题挑没被占用的序号:只按「源标题 +1」的话,连着从同一个会话切两次会得到
两个同名的 xxx (1);改成拿现有标题列表算 max+1。
- 按钮是 DOM 注入的:用户消息那一行没有插槽可挂(user 键位是整块替换,
extraActions 只给助手行)。认 [data-chat-flow-kind="user"][data-chat-turn] 那行、
插在动作行里复制按钮后面;MutationObserver 让它被 React 重建后自己长回来。
中途插话(steering)和还没有轮次号的本地回显不给按钮。
- host 半边提供两条接口:GET .../origin 只读地算「切哪儿、原文是什么」,
POST .../commit 真正建分支 + 发消息;浏览器半边只负责刷列表和把界面切过去。
改完这个插件要重启服务(patchReload: live 对插件文件不生效,重启最稳)。
- 详细取舍、DOM 契约和自测见 dist/plugins/retry/README.md。
任务栏鲸鱼动画(桌面壳内置)
有会话在活动(agent 正在响应)时,任务栏按钮的文字变成鲸鱼游动 + 波浪起伏的动画:
鲸鱼 🐋 匀速左右游动,三层波浪(≈ 内层、~ 中层、- 外层)从鲸鱼两侧一层层
泛起又收回;空闲时回到静止的「DeepSeek Harness」。
- 「在跑」的判据 = 前端会话列表的活动指示器:session/list 的 summaries 里
任一会话 running 为 true(agent 正在响应),或 $events 事件流收到
api-session/status 事件。和侧边栏会话名字旁那个旋转动画同源。
- 每帧固定 12 字符(「DeepSeek Harness」左右各删 2 个字符,按字符数计、
非字体测量),任务栏按钮不会随动画变宽变窄。
- 桌面壳 Python 侧实现(src/dsh_shell.py 的 TaskbarJobWatcher):WebSocket
直连本地服务订阅 $events 事件流,与 WebView2 页面无关 —— 窗口最小化、隐藏到
托盘、甚至页面卡住时都照常工作。
- 低占用:约 5.5 帧/秒(0.18s/帧),50ms 接收超时 + 50ms 等待,不空转;
标题只在变化时写入。
两个容易踩的坑
1. 访问必须带 token。 dsh web 启动时会打印
http://127.0.0.1:3080/?token=xxx,直接访问裸地址会返回 401。
本程序启动服务时抓取该地址(--no-open 同时阻止 dsh 自己弹浏览器),
带 token 访问会拿到 303 + Set-Cookie,Cookie 落在 webview/ 里持久化。
2. token 每次启动都变,所以服务必须由本程序自己拉起,不能复用外部已在跑的实例。
3. evaluate_js / load_url / hide 都会同步 Invoke 到 UI 线程。 程序刚起来的
那几秒,主线程正卡在 webview.start() 里初始化 WebView2,此时从后台线程发任何
窗口操作都会被堵住(实测约 3 秒)。所以启动页状态走 _post_ui() 投递给独立线程
异步发,service.start() 排在它们前面 —— 否则 dsh 那段冷启动会被平白推迟 3 秒。
同理,退出时「隐藏窗口 + 停托盘」是前台做的(毫秒级),杀服务和销毁窗口这些
耗时动作交给 _shutdown() 后台做。
启动构成 / 启动加速
一次冷启动(真机 onedir exe,DSH_UI_TIMING=1 实测):进程启动 → 界面开始加载 ≈ 3.4 秒。
同一套产物下把 dist/node/ 撤掉、回退到系统 Node 22.23.2,同一个打点会变成 ≈ 4.4 秒 ——
自带 Node 26 在这里省了约 1.0 秒(−22%)。各测 3 次:3.351 / 3.414 / 3.519s(自带)对
4.408 / 4.462 / 4.286s(系统 22)。
| 阶段 | 耗时 | 说明 |
| --- | --- | --- |
| exe 自举 + 壳自身(单实例锁 / 图标 / 建窗口 / 托盘 / 起服务) | ≈ 0.44s | PyInstaller 起运行时占大头,service.start() 本身只要 0.016s |
| dsh 冷启动 | ≈ 3.0s | 大头,见下 |
打包形态是 onedir,不是 onefile。 单文件 exe 每次启动都要把自己解压到
%TEMP%\_MEIxxxxx,实测固定多花 0.65s(端口 listen 3.35s vs onedir 2.75s),
而且被强杀 / 崩溃时解压目录不会清理 —— 实测一次排查就攒了 18 个残留共 646MB。
onedir 没有这一步,代价是产物从 1 个文件变成一个目录(159MB,其中自带 Node 占 107MB,
分发要压 zip)。
dsh 那 3.0 秒里:
- 约 1.5s 是 Node 加载 200 多个包。Node 版本对这个数字影响很大 —— 见下。
- 约 0.6s 是拼接前端 client bundle(延迟到首次被读时才算的那一次)。
不加速的话这一步要重复 10 次、合计 2.5 秒以上 —— 见下。
- 其余是插件加载、起 HTTP 服务、打印 token。
Node 版本:自带 26,不是系统 22
补丁之后,dsh 冷启动的瓶颈就落到 Node 本身。直连 dsh 测「spawn → 打印带 token 的 URL」
(各 5 次,都开补丁):
| Node | 耗时 |
| --- | --- |
| 22.23.2 | 3.66s |
| 24.21.0 LTS | 3.29s |
| 26.9.0(产物自带) | 2.50s |
所以产物自带 Node 26(dist/node/,win-x64 zip 摊平),find_node() 自带优先、系统兜底。
自带的这份不调 module.enableCompileCache,Node 26 也不默认开 —— 提速来自 V8 / 模块加载
本身,不需要任何配置。
启动加速补丁(src/runtime/dsh_fastboot.mjs)
dsh-client-modules 会在每注册一个插件时把全部前端 client bundle 重新拼接一遍
(含逐行生成的 identity sourcemap)。实测一次启动它被调用 10 次,
而启动阶段这些产物没有任何消费者——前端还没连上来,第一次读取发生在浏览器请求
首页(webserver/index-inject)或 .js 产物(bundleResource)的时候。
补丁把这四个字段(composed / responses / batchResponses /
previousBatchResponses)改成访问器:启动期间 compose() 只记账,首次被读时才算一次,
之后立刻交回 dsh 原逻辑。实测 10 次 → 1 次,直连 dsh 的「打印带 token 的 URL」
从 5.04s 降到 2.50s(Node 26;Node 22 上是 4.44s → 3.66s)。
- 不改 dsh 任何文件,由 node --import 注入,路径写在 DshService.start() 里。
- 只做延迟、不改结果:首次读取时按完整表格算,结果与不加速时一致;
若有人在启动中途读图,只是让加速失效,不会给出错误结果;整个补丁包在 try/catch 里,
任何异常都只意味着「没加速」。
- 开关:DSH_UI_FASTBOOT=0 关闭;DSH_UI_TIMING=1 时补丁会把
「跳过 N 次 / 实际算 1 次花多久」写进 service.log。
- 顺带说一个负面结果:补丁里曾经还有一段 module.enableCompileCache()(字节码缓存),
实测它建的 dsh-compile-cache 目录始终是 0 个文件、开关前后耗时也没有差异
(4.60s vs 4.51s),已从 dsh_fastboot.mjs 里删掉。别再往回加。
- 验证结论:补丁前后各抓一次首页 window.__DSH_BOOT__ 里的产物做逐字节比对 ——
dsh 每次启动会混入随机 nonce,产物字节本来就不可能完全一致,所以比对前先归一化;
归一化后实测 55 份产物里 54 份字节完全相同,只有那个 11MB 的批量包拼接顺序会变
(orderByModuleGraph 允许同优先级按扫描顺序打破平局,与补丁无关)。
自测
bash
/Scripts/python.exe src/tools/probe_plugins.py --list # 只看扫描到哪些插件
/Scripts/python.exe src/tools/probe_plugins.py # 真同步一次到 homes/
想不碰真实环境,指向临时目录:
/Scripts/python.exe src/tools/probe_plugins.py \
--plugins-dir --dsh-home
/Scripts/python.exe src/tools/probe_lan_access.py # 局域网访问插件
/Scripts/python.exe src/tools/probe_retry.py # 消息编辑 / 重试插件
/Scripts/python.exe src/tools/probe_dsh_compat.py # 升级 dsh 后跑这个
/Scripts/python.exe src/tools/probe_dsh_compat.py --slot 0.1.7-alpha.2
probe_gui.py 可以带一个参数:不传跑源码,传 exe 路径就测打包产物。
probe_lan_access.py 只负责找 node、把插件目录传给
src/tools/probe_lan_access_checks.mjs(检查本体是 JS)。它不碰真实环境:
假上游是现起的 node:http,状态文件指向临时目录,代理只绑 127.0.0.1 + 临时端口。
覆盖二维码编码器的已知向量与「生成矩阵 → 反解回原文」、代理的鉴权/头改写/Upgrade 透传/
收尾不卡住、宿主半边的默认关闭与开关持久化(含「重新 import 模拟重启」和状态文件损坏),
以及浏览器半边在几种数据状态下的渲染冒烟与「入口独占一行」。
probe_retry.py 同理,检查本体在 src/tools/probe_retry_checks.mjs,全程假日志 / 假 ctx /
假 DOM / 假 fetch(不起服务、不发请求)。覆盖切点计算的全部边界(第一轮 / 没跑完 / 注入
上下文 / 只有附件 / 轮次不存在)、宿主接口的参数与错误分支(含观测租约释放、退回活动会话),
以及浏览器半边的按钮注入幂等、行重建后自己长回来、重试 / 编辑 / 第一轮走新建 / 正在跑先
cancel / 四类失败路径。
probe_dsh_compat.py 是升级 dsh 之后的体检:dsh 改内部接口时插件往往不报错,
只是静默降级(执行器退回上游默认 argv、某个改写装不上、前端锚点找不到),界面还能开。
所以按「插件 → 它依赖的 dsh 接口」列成一张表(probe_dsh_compat_checks.mjs),
逐个已下载槽位核对:类原型上的方法(含「老版叫 run/start、新版叫 execute」这种改名,
只要有一组齐全就算过)、ctx 服务名、以及那些字面契约(事件名、DOM 属性、
插槽名、工具名)。最后 gitbash 那条会真跑一条 bash -c,检查 spawn 的 argv[0]
还是不是 Git Bash 绝对路径 —— 接口改名导致的「改了但没生效」只有真跑才看得出来。
它只读:不启动服务、不写 %LOCALAPPDATA%,只读槽位里的包文件 + 跑一条本地 bash。
耗时诊断
觉得「打开慢 / 退出慢」时,带 DSH_UI_TIMING=1 启动(源码或 exe 都行),
shell.log 里会多出形如 [t+ 3.412s] [timing] 的打点,直接看时间花在哪一段:
bat
set DSH_UI_TIMING=1
dist\DeepSeekHarness.exe
对照的耗时构成见上一节。改动启动 / 退出路径后,务必守着上面那条
「evaluate_js 会同步 Invoke 到 UI 线程」的约束 —— 那是当初 3 秒延迟的根源。
已验证的边界
dsh web 只监听 loopback,不允许对外提供服务:--host 0.0.0.0 被 CLI 直接拒绝,
--host 过不了配置校验。要让其他设备访问只能套反向代理/隧道,
且真实 authority 必须进 --trusted-host,否则 /api 会被 browser-trust fence 挡成 403。
内置的 lan_access 插件就是照这条路走的:反向代理在局域网侧另开一个口子,
转发时把 Host / Origin / Referer 改写成回环 authority、Cookie 换成它自己用
launch token 换来的 dsh 会话 cookie —— 围栏和鉴权都由代理替浏览器过掉,
浏览器从头到尾只跟代理打交道。而且这个口子默认是关的(要在面板里手动开),
开关状态落盘,下次启动照旧。原理、代价与踩过的坑见
dist/plugins/lan_access/README.md。
sidebar.footer.action 是 kind:"list" 槽位,注册项被摊在 dsh 的 footerActions 里,
而那是 display:flex 且不换行的行容器 —— 所以多个插件默认会各占一半挤在同一行。
想让入口独占一行,只能在挂载时把那个容器的 flex-wrap 改成 wrap(lan_access 就是这么做的)。
坑在于不能只看 parentElement:renderSlot() 会先套一层 display:contents 的锚点,
盒子不参与布局,改它等于没改 —— 要往上找第一个 computed display 是 flex 的祖先。
每条消息那一行没有插槽:conversation.chat.node 是按 kind 的 keyed 槽(user 键位是
整块替换,注册了就顶掉 dsh 自己的用户气泡渲染),MessageIconActions 的 extraActions
只往助手行传。想往用户消息上加动作只能 DOM 注入,靠 dsh 自己标在行上的三个属性认位置:
| 属性 | 值 | 用途 |
| --- | --- | --- |
| data-chat-flow-kind | user / steering / assistant / … | 用户自己发的起始消息是 user;中途插话是 steering(没有「轮」的概念,别给重试按钮) |
| data-chat-turn | 轮次号(整数) | 就是 turn/start 事件里的 turn,宿主拿它去会话日志里找切点 |
| data-chat-anchor-key / data-chat-flow-key | 节点 key | 形如 input-message:(不是 seq),别拿它当事件序号用 |
动作行容器是行里第一个 class 形如 _actions 的 div(CSS Modules 的
xzv4MW_actions,哈希随版本变、后缀不会),「复制」是它里面第一个 button。
按钮插在 React 管的 DOM 里会被重建冲掉,得挂 MutationObserver 自己长回来
(retry 就是这么做的)。