安装
在 DeepSeek Harness 里通过 dsh-market 安装
dsh plugin --profile web add dshmarket
或使用命令行
dsh plugin --profile web add github:Yochor-Debug/dsh-float
装任何插件都等于在你的机器上跑第三方代码,权限和你本人一样大——能读你的文件、用你的凭据、访问网络。请先审阅源码,并尽量锁定 commit(github:owner/repo#sha)。
截图
README
DSH Web GUI 的 DeepSeek API 余额小浮窗 —— 仿搜狗输入法状态条的胶囊式悬浮窗。
- 打开 DSH(页面加载)时自动出现,关闭页面时随之消失:浮窗由宿主插件注入到每个
index.html,不依赖会话、不占用对话上下文。 - 折叠态只显示余额(绿/黄/红圆点表示充足/偏低/不足),余额后面还有一个
峰/谷徽章 表示当前时段是峰时还是谷时;并带一个很小的▼提示可展开。 - 点击展开明细:总额、赠金、充值、上次刷新时间。悬停不会弹任何东西——鼠标划过 胶囊不产生反应;再点一次收起,点页面其它地方也会收起。 (早期版本是"悬停即展开",实测会让人觉得浮窗在跟着鼠标闪,已改为点击触发。)
- 双击浮窗立即刷新(折叠态、展开态都支持),刷新时 ↻ 图标转一圈作为反馈;面板右上角的 ↻ 按钮同样可以手动刷新。
- 每 5 秒自动刷新一次;页面切到后台时不刷新,切回前台立刻补一次。
- 可拖动,位置自动吸附在视口内并记在
localStorage;深色/浅色跟随系统主题。 - 浮窗位于 closed shadow root 中,脱离 React 渲染树,不与界面样式互相污染。
展开状态会镜像到宿主元素的
data-open属性上,仅供测试/诊断读取(不改行为)。
结构
| 文件 | 作用 |
|---|---|
package.json |
插件清单:声明 dsh.bundle.patch,这是被 dsh plugin 认成 bundle 层的关键 |
cordis.patch.yml |
这个包自带的 profile 层:insert 一行 id: balance-float / name: dsh-plugin-balance-float |
lib/index.js |
宿主侧:注册 GET /x-balance-float 代理路由 + 注入浮窗脚本 |
lib/client.js |
浏览器侧:浮窗 DOM/CSS/拖拽/轮询逻辑(被注入为内联脚本) |
tools/verify-package.mjs |
插件包独立验证:清单、patch、路由、注入、点击式交互(不需要 DSH 在跑) |
tools/harness.mjs |
离线验证:桩上下文 + 真实上游调用 + 生成 widget.html |
tools/test-credentials.mjs |
密钥解析回归测试(两种 YAML 写法、凭证服务、环境变量优先级) |
tools/screenshot-server.mjs |
截图台:同源提供页面与余额路由,便于无头截图 |
tools/verify-interactions.mjs |
用 CDP 验证"点击才展开"、双击刷新、5 秒轮询 |
tools/shot-open.mjs |
点击一次后截图(确认展开态渲染) |
tools/gui-shot.mjs |
通过 CDP 对真实 GUI 截图 |
宿主在每次渲染 index 时重新读取 lib/client.js,所以改浮窗样式/参数只需刷新页面,
不用重装插件,也不用重启 dsh web。
为什么余额查询在宿主侧
DeepSeek 的 /user/balance 需要 Authorization: Bearer <key>,且不下发 CORS 头。
把密钥放进浏览器既暴露凭证又会被 CORS 拦住,因此由宿主读取本机凭证并代理,
浏览器只请求同源的 /x-balance-float。
密钥解析顺序(每次请求现取,不在插件里长期缓存):
DEEPSEEK_API_KEY环境变量;- DSH 凭证服务
ctx.credentials.resolve('DEEPSEEK_API_KEY')(环境 +.env+ 凭证存储); 访问该服务必须在try里——服务不可用时读属性本身就会抛,抛出去会让整条路由返回 400; - 兜底:直接解析
$DSH_HOME/.credentials.yaml。
⚠️ 第 3 步必须同时认两种 YAML 写法:refs: { DEEPSEEK_API_KEY: sk-x }(DSH 重启后会把
文件改写成这种流式写法)和块写法(refs: 换行后缩进的 DEEPSEEK_API_KEY: sk-x)。
最初只认块写法,重启后浮窗就显示「余额异常 · 未找到 DEEPSEEK_API_KEY」。
tools/test-credentials.mjs 是这条的回归测试,改动密钥解析后必须跑。
密钥不会出现在任何响应里。
安装(正式插件包)
dsh plugin --profile web add D:\DeepSeek_Workspace\dsh-plugin-balance-float
dsh plugin 是 pnpm 的转发器,会做三件事:
- 在 profile 目录里执行
pnpm add,本包以link:形式进入dependencies; - 因为本包
package.json里声明了dsh.bundle.patch,reconcilePlugins会自动 把dsh-plugin-balance-float加进 profile 的dsh.profile.bundles列表; - 于是它成为 profile 的一个层,层里
insert的balance-float就是宿主路由 + 注入。
卸载:
dsh plugin --profile web remove dsh-plugin-balance-float
为什么不用写 file:// 绝对路径
早期版本是在 profile 的 cordis.patch.yml 里手写一行 file:///D:/.../lib/index.js?v=3。
那样能用,但不是插件包该有的形态:换机器/换目录/DSH 升级都会失效,也没有依赖记录。
现在与 open-sea-skin、dsh-builtin-browser 等第三方插件一致——patch 里写裸包名,
由 profile 的 node_modules 解析。
改了代码怎么生效
| 改的是 | 怎么生效 |
|---|---|
lib/client.js(浮窗外观/参数) |
宿主每次渲染 index 都重读该文件 —— 刷新页面即可,前提是宿主读的那个文件就是你改的那个。从 GitHub/npm 安装时,宿主读的是 profile 里的安装副本,不是你的工作目录,所以改动要先 push 再重装;用 link: 安装才会直接读工作目录,那才是"改完刷新即见" |
lib/index.js(宿主逻辑) |
Node 按 URL 缓存 ESM,且运行中的进程不会重读 bundles 列表 —— 重启 dsh web(或重装插件后重启) |
package.json / cordis.patch.yml |
同上:bundles 列表只在 boot 时读一次,需要重启 |
⚠️ 实测结论:改 profile 的 package.json(bundles 列表)不会热生效。 运行中的 dsh
只在 boot 时组合一次 bundles 列表;patchReload: live 监视的是 cordis.patch.yml 这类
patch 文件,不包括 package.json。所以「装好插件但 GUI 没反应」时,重启进程即可。
验证
node tools/verify-package.mjs # 插件包独立验证(不需要 DSH 在跑)
node tools/verify-tariff.mjs http://127.0.0.1:3931/ # 峰/谷 徽章的每个边界(需要 rig)
node tools/test-credentials.mjs # 两种 YAML 写法 + 凭证服务分支(回归测试)
node tools/harness.mjs # 路由、缓存、HEAD/POST 语义、真实余额
node tools/screenshot-server.mjs 3931 # 然后浏览器打开 http://127.0.0.1:3931/?balance-float=open
node tools/verify-interactions.mjs http://127.0.0.1:3931/ # 需要 rig 在跑
verify-tariff.mjs 用假 Date 冻结页面时钟、强制重绘,再通过 CDP 的 pierced DOM 从
closed shadow root 里读出徽章。它的期望值是照着定价规则手写的(不是拿组件自己的
逻辑算的),所以窗口写错会失败,而不会"自己跟自己一致"。
峰/谷 徽章
DeepSeek 对谷时段按峰时价格的 50% 计费。据官方定价页:
Peak hours are 01:00 - 04:00 and 06:00 - 10:00 UTC, Monday through Friday, excluding Chinese public holidays. All other hours are off-peak, including weekends and Chinese public holidays in full.
即峰时段 = 周一至周五的 UTC 01:00–04:00 与 06:00–10:00(北京时间 09:00–12:00、 14:00–18:00);其余全部为谷,含周末与节假日全天。徽章在浏览器里按 UTC 计算,所以 不受本机时区影响,并且每 5 秒刷新时重新判断。
⚠️ 未计入中国法定节假日:那需要内置一份每年都会过期的日历。所以工作日的节假日会显示
峰,而实际按 谷 计费。徽章的悬停提示里写明了这一点。
验证(续)
verify-package.mjs 的实测输出(它模拟 loader 的方式 import 包入口并挂到桩上下文):
--- package manifest ---
PASS dsh.bundle.patch declared ./cordis.patch.yml
PASS patch file readable ...\cordis.patch.yml
PASS patch inserts our module bare package name in patch
PASS entry point exists in exports ./lib/index.js
PASS client script is shipped ["lib/index.js","lib/client.js","cordis.patch.yml","README.md"]
--- host half ---
PASS module exports apply entry=./lib/index.js name=balance-float inject=["webServer"]
PASS registered the balance route /x-balance-float
PASS injects the widget script script row: body
PASS injects the host style row .dsh-balance-float-host{...}
PASS widget uses click-only toggle no hover listeners in injected script
PASS route answers JSON {"ok":true,"available":true,...}
PASS route handles HEAD status=200
PASS route rejects other methods status=405 allow=GET, HEAD
VERIFY_PACKAGE: ALL_PASS
?balance-float=open 让浮窗以展开态启动(截图用);__RIG_MODE__=http-error
会把浮窗切到失败分支。渲染结果见 widget-expanded.png、widget-collapsed.png、
widget-error.png、widget-error-open.png、real-gui.png。
verify-interactions.mjs 的实测输出(真实 Edge + CDP,检测"点击才展开"这条规则):
baseline state (collapsed): closed
--- 1) hover only, no click ---
state after hover: closed -> PASS (hover does not open)
--- 2) single click ---
state after click: open -> PASS (panel opened)
--- 3) click again ---
state after 2nd click: closed -> PASS (panel closed)
--- 3b) click elsewhere closes it ---
state after outside click: closed -> PASS
--- 4) double click refreshes once ---
extra refresh requests: 1
--- 5) auto refresh rhythm over 12s ---
requests in 12s: 2 gaps(ms): 5010
检测方式说明:面板在 closed shadow root 里,页面脚本看不到它,所以浮窗把展开状态
镜像到宿主元素的 data-open 属性上供测试读取(只加属性,不改行为)。踩过的坑:最初想用
CDP 的 DOM.getDocument {pierce:true} 数影子树节点数,但那个数字在展开前后都是 40,
判据不灵敏、会误报"点击没生效"。
行为细节
- 路由只接受
GET/HEAD,其它方法返回405且带Allow头。 - 宿主对上游结果做缓存:成功 20 秒、失败 10 秒,所以浮窗 5 秒轮询不会等频打到
DeepSeek(最多每 20 秒一次真实请求)。轮询频率在
lib/client.js的REFRESH_MS, 缓存时长在lib/index.js的OK_TTL_MS/ERROR_TTL_MS。 - 上游 401/403 归为
bad-key,连接失败归为unreachable,非 JSON 归为bad-response, 凭证缺失归为no-api-key;四种情况浮窗都显示可读的中文原因并保留双击重试。 - 双击判定由浏览器原生
dblclick完成,拖动过的指针序列不会触发(preventDefault在拖动时发生)。 - 页面里没有浮窗时脚本会直接返回,重复注入不会产生两个浮窗。
卸载
dsh plugin --profile web remove dsh-plugin-balance-float
pnpm 会移除依赖,reconcilePlugins 会顺带把它从 profile 的 bundles 列表里摘掉。
删完重启 dsh web(bundles 列表只在 boot 时读)。
封装过程记录(踩过的坑)
| 现象 | 根因 | 处理 |
|---|---|---|
dsh plugin add 报 'pnpm' is not recognized |
%APPDATA%\npm 里只有 pnpm.ps1,没有 pnpm.cmd;dsh 用 spawnSync(shell:true) 起的是 cmd,cmd 只认 .cmd/.exe |
补了一个 pnpm.cmd shim(指向 node_modules\pnpm\pnpm.exe) |
| 装好后 GUI 仍是 404 | 运行中的进程只在 boot 时读一次 bundles 列表,package.json 不在 live 监视范围内 |
重启 dsh web;重启后路由 200、余额正常 |
patch 里写相对路径 ./… 不靠谱 |
loader 的解析基准不确定(profile 目录?patch 所在目录?) | 照抄第三方插件约定:写裸包名 |
改宿主代码后旧的 ?v= 讲究 |
那是 file:// 直挂时代的产物(Node 按 URL 缓存 ESM) | 插件包方式下不再需要;重启进程即可 |
评论
评论存放在 GitHub Discussions。用 GitHub 账号登录后可发表评论或点表情。