Skip to content

Latest commit

 

History

29 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 

Repository files navigation

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\<id>\ (源目录没变就跳过复制,靠 .dsh-ui-plugin.json 指纹判断;包根目录下的 data/ 是插件的运行态,既不复制也不进指纹 —— 插件自己的数据放 <数据目录>/data/);
  3. 关闭的包 → 删掉安装副本(只删本程序装的,认指纹文件;目录链接一律不碰);
  4. 把已启用插件的补丁片段按 order 拼成一个托管块,重写 homes\<版本>\profiles\web\cordis.patch.yml —— 两个 dsh-ui 插件管理块 标记之外的内容原样保留,没有插件时回到 []。

插件目录里 cordis.patch.yml 的 ./plugins/<id>/<entry> 是相对 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)。

老版本没有 --dsw-menu-backdrop-filter,回退 none,那时菜单色本来就不透明, 画面与升级前完全一致 —— 一条声明同时满足两个版本。 probe_dsh_compat.py 会逐个体检这条配对(与 dsh 版本无关的那组检查)。

⚠️ 顺带一条容易踩的副作用:backdrop-filter 会让该元素成为 position:fixed 后代的包含块(和 transform/filter 一样)。浮层里如果有 fixed 定位的东西 (usage 柱状图的气泡就是),要把它 createPortal 到 document.body —— 否则坐标 会按浮层的 padding 原点算、还会被 overflow:auto 裁掉。上游自己的浮层用 isolation:isolate + ::before 承载背景,也是同一个原因(isolation 只建层叠 上下文,不建包含块)。

浏览器半边取服务的规矩:可选服务只能 ctx.get()

cordis 的 ctx 是 Proxy:属性访问 ctx.foo 只对「写进 inject 且已就绪」的服务安全, 其它情况那个 getter 会直接抛 cannot get property "foo" without inject (服务还没被 provide、或者活在别的 isolate 里,都算「没有」)。而 apply 抛异常 = 这个插件条目整个变 failed,界面上就报 dsh-loop-guard: failed(web boot: 1 entry did not activate 那行下面)。实测踩过:换会话入口搬到 uiWorkspace 之后, 插件在它还没挂上时用属性访问读了一次。

所以:必需的服务写进 inject(拿属性访问没事),可选/可能晚到的服务一律 ctx.get(id)(拿不到给 undefined,不抛)。probe_dsh_compat.py 里有一组 与版本无关的检查专门管这条:静态扫 ctx.X 有没有越出 inject 名单,再用严格 ctx (访问未知服务就抛,模拟 cordis 真实行为)真跑一遍每个插件半边的 apply。

内置:gitbash

Windows 上把 shell 执行器换成 Git Bash,并把模型看到的 shell 工具从 pwsh 改成 bash。纯插件:不写 agent 预设、不动 $DSH_HOME/.agent-presets, 改名和提示词改写都在运行时拦截工具注册完成。原理、代价和踩过的坑见 dist/plugins/gitbash/README.md。

内置:usage

统计 token 用量。界面上在左下角、设置按钮上方多一个入口,点开是堆叠柱状图: 固定最近 15 天一天一根柱子(今天在最右、横轴刻度只写「日」),每个模型一个颜色自下而上叠, 鼠标移到色块上提示「模型 + 颜色 + 当日占比 + 用量」。没有用量的那天留一条占位短横。 用量单位自动进位:万 token,到 1 亿走亿。

  • 账本:%LOCALAPPDATA%\DeepSeekHarness\data\usage.json —— 外壳自己的数据根 (和 config.json / plugins.json 同处),不放插件目录:插件包是整目录重抄的镜像, 放里面会被连坐删掉、还天然多出一个副本。按「天 × 模型」累计,落盘走 「写临时文件 + rename」+ 800ms 去抖。
  • 口径:input + cacheRead + cacheWrite + output,四桶互不重叠 (与上游 dsh-token-meter 的 usageTokens() 一致)。
  • 只统计安装之后新发生的调用,不回填历史 —— 所以刚装上是空的,发一轮对话就有数据。 同一 (turn, step) 反复结算时按「覆盖」而非「累加」,重试/流式收敛不会虚高。
  • 刷新会回读账本文件:每次读(含 5 秒轮询)都先同步磁盘,别处写进去的账能看见。
  • 双面插件:host 半边(usage.mjs)采集并注册鉴权过的 GET /api/usage.data; 浏览器半边(lib/client.js)靠 package.json 里的 dsh.client 被 dsh-client-modules 自动发现,不用写进补丁。
  • 细节与设计取舍见 dist/plugins/usage/README.md。

内置:lan_access

让手机在同一个局域网里扫码打开网页版。界面上同样在左下角、设置按钮上方多一个入口 (独占一行,不和 usage 挤在同一行),点开是二维码 + 一条链接;手机上扫码就能用, 功能与电脑上一致。

  • 默认关闭、开了就记住:插件装上后入口就在,但 3081 端口不监听 —— 局域网通道等于 把本机的操作权限摊到网上,不能默认开着。面板里点「开启局域网访问」才起代理,开关状态和 访问码存在一起,下次启动自动恢复;点「关闭」端口立刻释放。
  • 链接固定:http://<内网IP>:3081/?lan=<访问码>。访问码 16 字节随机、只生成一次, 落在 %LOCALAPPDATA%\DeepSeekHarness\data\lan_access.json,所以只要内网 IP 不变, 链接和二维码就不变(IP 变了链接跟着变,这是内网地址本身的性质)。端口写死 3081: 端口一变链接就变,被占用时宁可报错也不换(面板会显示原因并给「重试」)。
  • 不动 dsh 的绑定:dsh 依旧只监听 127.0.0.1:3080。上游把 --host 0.0.0.0 明确堵死了 (dsh-web-app 的 startup 里写着「会把 RCE 暴露到网络」),插件改成自己在局域网侧起一个 带鉴权的反向代理,暴露面完全由插件控制 —— 关掉开关端口就释放,关掉插件更是一点不留。
  • 两层鉴权:局域网侧是固定访问码(?lan= → 长期 cookie);回环侧是 dsh 自己的会话 cookie, 由代理用进程 launch token 自己完成登录跳转换好后代持,浏览器全程不接触 dsh 的 token/cookie。 默认只放行内网/回环来源;面板上可一键重置访问码,旧链接立即失效。
  • 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 允许同优先级按扫描顺序打破平局,与补丁无关)。

自测

<venv>/Scripts/python.exe src/tools/probe_plugins.py --list      # 只看扫描到哪些插件
<venv>/Scripts/python.exe src/tools/probe_plugins.py             # 真同步一次到 homes/<版本>
# 想不碰真实环境,指向临时目录:
<venv>/Scripts/python.exe src/tools/probe_plugins.py \
    --plugins-dir <临时插件目录> --dsh-home <临时 dsh 目录>

<venv>/Scripts/python.exe src/tools/probe_lan_access.py          # 局域网访问插件
<venv>/Scripts/python.exe src/tools/probe_retry.py               # 消息编辑 / 重试插件

<venv>/Scripts/python.exe src/tools/probe_dsh_compat.py          # 升级 dsh 后跑这个
<venv>/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] <阶段名> 的打点,直接看时间花在哪一段:

set DSH_UI_TIMING=1
dist\DeepSeekHarness.exe

对照的耗时构成见上一节。改动启动 / 退出路径后,务必守着上面那条 「evaluate_js 会同步 Invoke 到 UI 线程」的约束 —— 那是当初 3 秒延迟的根源。

已验证的边界

dsh web 只监听 loopback,不允许对外提供服务:--host 0.0.0.0 被 CLI 直接拒绝, --host <具体IP> 过不了配置校验。要让其他设备访问只能套反向代理/隧道, 且真实 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:<消息 id>(不是 seq),别拿它当事件序号用

动作行容器是行里第一个 class 形如 <hash>_actions 的 div(CSS Modules 的 xzv4MW_actions,哈希随版本变、后缀不会),「复制」是它里面第一个 button。 按钮插在 React 管的 DOM 里会被重建冲掉,得挂 MutationObserver 自己长回来 (retry 就是这么做的)。

About

Windows desktop shell for DeepSeek Harness (@deepseek-ai/dsh) — wraps the web UI into a single portable .exe with a system tray, a plugin manager, and one-click npm updates.

Topics

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages