← Back to home@qwq-wny

dsh-eta-forecast

DSH Web 界面的「工作完成时间预测」挂件:按 todo 清单与步进节奏估算本轮还剩多久、几点完成,附进度条与样本置信度。

Stars
2
Language
JavaScript
Created
Oct 26, 2026
Updated
Oct 26, 2026
GitHub repo

Introduction

dsh-eta-forecast

给 DeepSeek Harness 的 Web / 桌面界面加一个「这一轮大概几点做完」的小挂件。

它不是按字数、按步数拍脑袋外推的。真正被当成进度分母的是 todo 清单—— 模型被要求「开工前先列清单、做完就勾」;步数只用来估「一条 todo 平均要几步」, 是节奏修正项,不是进度本身。

它显示什么

位置内容
左上胶囊多会话时的会话切换(只有一条会话时不显示)
大字预计完成时刻(本地时钟,如 14:32)
副行还剩多久 + 已经跑了多久
进度条清单完成度(in_progress 按半条计)
左下3/7 项,没有清单时退化为「第 N 步」
右下徽章置信度:样本不足 / 粗略 / 中等 / 较准
第三行正在做的那一条(正在做:…)
超时时大字换成已跑时长,副行「已超时 N 分」—— 不再演倒计时

超过预测之后(超时追踪)

预测总有失手的时候。之前跑过预测点之后,倒计时只会逼近 0 然后卡住 —— 「还有 1 秒」挂好几分钟,用户不知道这轮已经超时了。现在:

  • 跑过预测终点(+ 90 秒宽容期)还在跑 → 大字换成已跑时长,副行「已超时 N 分」, 假倒计时停掉。宽容期是必须的:预测带误差,早 30~60 秒收尾是常态, 没有它每轮最后都会闪一下「超时」。
  • 超时会学进下一轮。 每次完整跑完的轮次若超出当初的预测,记下「超出比例」; 之后这个会话的估计按最近超时记录的中位比例放大(封顶 1.6×)。 超时越频繁的会话,后续估计越保守 —— 挂件从自己的误差里学习。 被中断/出错的轮次不算(预测锚点不可信),准时完成的也不记(不污染)。

外观(缩放 / 壁纸)点标题行的 ⚙ 调,见下方「外观设置」。

版本号

设置面板标题旁有一枚版本徽章(如 1.0.0)。它由宿主在下发客户端脚本时 从 package.json 注入(__DSH_ETA_VERSION__ 占位符替换)—— 浏览器里没有 文件系统,客户端自己读不到版本。state.json 也带 version 字段, 排查问题时和面板显示对得上。发新版本只改 package.json 的 version, 其余自动跟随。

三个刻意的设计选择

1. 等你的时间不算工期。 一轮里 ask_user_question 与 approval/asked 期间整个回合是挂起的。如果把这段算进「已用时长」,一次授权等待就能把 ETA 推到几小时之后,而且再也收不回来。所以挂起时长从 elapsed 里扣掉,挂起时 挂件上会显示 ⏸ 等你操作,这段时间不计入工期。

2. 没样本就说没样本。 一轮刚开始、清单一条都没勾时,任何数字都是编的。 这时大字显示 —,副行写「还看不出节奏」,而不是给一个两分钟后就会打脸的 时间点。置信度徽章也同步降级——先承认不知道,比给一个错的数字有用。

3. 平滑而不是重算。 每条事件都重算会让 ETA 抖动成随机数。剩余时长用 EWMA(α = 0.32)慢慢挪过去,用户看到的是一个在收敛的估计,不是一个乱跳的 读数。

估算来源

四个互相独立的估计,谁有样本用谁,按样本强度加权:

来源公式何时生效
progress净工期 × 剩余条数 / 已完成条数清单里已有条目被勾掉
pace平均步时 × 剩余条数 × 每条目步数本轮已有 ≥2 个步长样本
unit跨轮单条平均耗时 × 剩余条数本轮没有可外推进度,但历史有单条样本
history整轮历史均值 − 已跑连清单都没有,且历史 ≥2 轮

子代理会话(origin: 'subagent' / delegationDepth > 0)一律不显示—— 你要等的是主对话,不是某一路子代理跑完。

安装

# 本地开发(从目录链接,改完重启 DSH 即生效)
dsh plugin --profile desktop add link:<本目录绝对路径>

装完重启 DSH。挂件默认出现在右下角。

显示位置

设置面板里有「显示位置」,四选一(与 dsh-pet 同一套语义,不用记两套词):

值含义
仅网页只显示 DSH 页面右下角的挂件
仅桌面只显示桌面上的浮窗(置顶、透明、不占任务栏)
两者两边都显示(默认)
都不全部隐藏

桌面浮窗由宿主拉起一个 Electron 小窗(runtime/electron-helper/)。三点说明:

  1. 复用现成的 Electron 运行时($DSH_HOME/electron)。dsh-pet 已经下过那份 300MB, 不重复下载。找不到时不会替你静默下载 —— 那是个用户没同意的重操作,改为明确报错, 可用 DSH_ETA_ELECTRON_PATH 指定路径,或先装 dsh-pet 让它把运行时下好。
  2. 默认整窗点击穿透,只有光标落在挂件上才可交互。否则一个置顶透明窗会把下层应用全挡住。
  3. 拖动的是窗口,不是 DOM 元素。网页端拖元素、桌面端拖窗口,这是唯一需要分叉的地方; 渲染、缩放、壁纸、右键菜单、设置面板完全共用同一份 assets/eta-client.js。

配置存在宿主内存里(不是 localStorage):换浏览器也应该一致。改配置走 POST /dsh-eta/config,只接受回环请求(判据是 socket 的 remoteAddress,不信任何请求头)—— 这是本插件唯一的写接口,局域网改不了。

操作

  • 拖动:整张卡片都能拖 —— 按住左键或右键拖都行(不限于标题行)。 位置会记住,窗口变小会自动夹回屏幕内。
  • 收起:标题行右边的 −。
  • 设置:三种方式等价 —— 标题行的 ⚙、双击标题文字、右键原地轻点。
  • 右键:按下后移动就是拖动卡片;按下后原地松开就开关设置面板 —— 面板已经开着时再右键会把它关掉(同一个手势管开也管关,面板上右键同理)。 拖动与开关靠「按下后有没有移动」区分(阈值 3px),不需要记两个手势。
  • 切换会话:卡片左上方那排胶囊。点一下就换看哪条会话(见下)。
  • 这些偏好存在 localStorage(键 dsh-eta-forecast:v1);显示位置是宿主级的,不在这里。

切换会话

同时开了多条会话时,卡片左上方会出现一排胶囊,一条会话一个:

  • 运行中的会话前面有绿点,扫一眼就知道哪条在跑;
  • 当前看的那条高亮;点别的胶囊立刻切过去(立刻重画,不等下一轮轮询—— 最多 2 秒的空窗会让人以为点错了);
  • 会话多了会横向滚动,选中的自动滚到可见区;
  • 没标题的会话用 id 尾巴当文案(会话 abcd),而不是全叫「未命名」。

只有一条会话时整行收起 —— 没得切的东西不该占地方。

两个刻意的取舍:

  1. 选择只存在内存里,不进 localStorage。 会话是临时的,宿主重启后 id 全换; 记住一个死 id 只会让挂件永远显示「会话不存在」。
  2. 钉住的会话消失了就自动回落到默认(宿主排序的第一条),不会卡在空状态上。

右键已经没有中间菜单了。 它原来会弹一个三项小菜单(收起 / 外观设置 / 回到右下角), 多一层点击只增加摩擦。现在右键直开设置,而「回到右下角」挪进了设置面板底部 —— 那个兜底动作还在,只是不再占一个菜单位。

「隐藏挂件」已移除。 原来点 × 会把整张卡片收起来,只能靠双击右下角 一小块热区找回 —— 既难发现,也容易把自己锁在外面(尤其缩到 25% 之后)。 现在想让它不碍事:收起(只剩标题行)或者拖到角落。

外观设置

点 ⚙(或双击标题 / 右键轻点)打开一个独立的小面板(它不跟着卡片缩放—— 25% 时卡片只有 59px 宽,面板塞进去就没法用了):

项范围说明
缩放25% – 100%整卡等比缩放,以右下角为锚点,缩小时不会飘到屏幕中间
壁纸本地图片 / 图片链接本地图会先缩到最长边 1024、转 JPEG 0.82 再存
模糊0 – 24px只作用于壁纸层
暗化0 – 85%壁纸压暗,保证浅色字也读得清
卡片不透明度15% – 100%卡片底色盖在壁纸上的程度,决定壁纸露出多少
显示位置仅网页 / 仅桌面 / 两者 / 都不桌面浮窗是否出现(见上一节)
记住我的设置开 / 关(默认开)记忆模式:显示位置与桌面浮窗位置落盘,重启原样恢复
隐藏菜单按钮开 / 关开启后标题行按钮全部隐藏,改由右键开设置
回到右下角—位置拖丢了时的兜底(原来在右键菜单里)
恢复默认—缩放、壁纸、位置、折叠、菜单按钮显隐一起还原(记忆开时连显示位置与浮窗位置的记忆也清掉;不动记忆开关本身)

五个设计上的取舍:

0. 记忆模式分两层,因为「设置」根本存在两个地方。 外观(缩放 / 壁纸 / 折叠)在浏览器 localStorage —— 网页的天然行为,重启本来就保留, 不需要做任何事。真正会丢的是宿主级的:显示位置只活在宿主内存里(改一次重启就没), 桌面浮窗每次都从右下角重新出现。记忆模式把这两样落盘到 <DSH 配置目录>/dsh-eta-forecast.json, 启动读回;浮窗位置由 helper 在窗口稳定 600ms 后回报、宿主记录。 开关本身也存进去 —— 不然「关掉记忆」这件事重启后记不住,又悄悄变回开了。

1. 缩放用 transform,不用宽高。 transform: scale() 只影响绘制,不触发重排, 拖动时不会掉帧;代价是布局宽度仍是 236px,所以所有"盒子有多宽"的判断都必须读 getBoundingClientRect() 而不是 offsetWidth。

2. 卡片底色必须画在壁纸上面**。** 一开始把 background 设在根节点上,结果壁纸 图层整个盖住了它——「卡片不透明度」变成一个死滑块,深色壁纸上的深色文字也糊成 一片。现在改成四层:壁纸图 → 暗化 → 卡片底色(wash)→ 内容。

3. 壁纸单独存一个键。 dsh-eta-forecast:wallpaper 而不是塞进设置的那个 JSON: base64 图片动辄几百 KB,每拖一下滑块就重写整坨大字符串,主线程会卡,也更容易 撞 localStorage 配额。写失败(配额爆了)会明确提示,而不是静默丢掉。

4. 缩到很小要留一条逃生的路。 25% 时卡片只有 59px 宽,齿轮按钮的命中区约 4.5px——点不到。用户会把自己锁在一个打不开设置的小卡片里,唯一出路是手改 localStorage。所以缩放低于 60% 时,在卡片右下角贴一块 44px 的透明热区 (#dsh-eta-hotspot,不参与缩放),双击打开设置;放大回去时它自动消失。

5. 右键身兼三职,靠「有没有移动」区分。 用户要求「所有地方都能右键拖动」, 但右键同时也要能开/关设置。如果让右键纯粹等于拖动,设置就没入口了;如果让右键 纯粹等于设置,就不能拖。判据是按下后位移是否超过 3px:

  • 右键按下 → 移动 ≥3px → 拖动卡片(松手不动面板)
  • 右键按下 → 原地松开 → 开关设置面板(开着就关掉)

面板挂在 body 上、不参与卡片缩放——否则 25% 时字号只剩 3px,等于把人锁死。 面板里的动作与按钮走同一份逻辑(toggleCollapsed / showPanel / resetPosition), 不复制第二套行为。

「开着就关掉」有一个必须处理的细节:右键开面板时,Chromium 在 pointerup 之后才派发 contextmenu,而那时面板已经出现在光标下 —— 面板自己的 「右键关闭」会接住同一次右键,把它立刻关掉,表现成「右键没反应」。 解法是记下开面板那一下的坐标,面板侧只忽略落点重合(2px 内)的那一个 contextmenu;别的右键都是真实的「关掉」意图。用坐标而不是时间窗,是因为 时间窗会把「开完马上又想关」的合法快速右键一起吞掉——真浏览器校验里翻过车。

默认值是渲染出来比对过的:0.3 时深色字压在深蓝壁纸上糊成一片,0.95 时壁纸 几乎看不见(等于没换)。0.65 不透明度 + 28% 暗化是折中值。

它不做什么

  • 只开两个只读路由 + 一个回环写路由。 /dsh-eta/eta.js、/dsh-eta/state.json 只读; /dsh-eta/config 是唯一的写接口,而且只接受来自 127.0.0.1 / ::1 的 POST (判据是 socket 的 remoteAddress,不信任何可伪造的请求头)。局域网改不了显示设置。
  • 不 import 宿主服务(webServer 除外,且用 ctx.inject 动态等待)。老宿主 少一个服务时,插件降级而不是整个不 apply。
  • 不在页面里重算。 公式只存在于 lib/forecast.js 一处。前后端各写一遍是这类 小挂件最常见的坏味道:两边一改就悄悄不一致。
  • 不替你下载 300MB。 桌面模式需要 Electron 运行时,找不到就报错并说清怎么装, 绝不静默下载。

结构

lib/forecast.js     纯估算核心(无 IO / 无 DOM,可 node --test)
lib/index.js        宿主半侧:订阅 session/event、注入页面、三个路由
lib/desktop.js      桌面窗口管理:找 Electron、拉起/关停 helper、崩溃退避重启
assets/eta-client.js 浏览器半侧:每 2 秒拉一次快照,只负责画 + 外观
runtime/electron-helper/  桌面浮窗(Electron 主进程 + 渲染页),复用同一份客户端脚本
                     其中的 geometry.js 是拖拽/尺寸/命中判定的纯函数,单测覆盖
test/forecast.test.js 20 个用例,全部在测「会算错的地方」
test/client.test.js  42 个用例,用最小 DOM 垫片把客户端脚本真跑起来
test/host.test.js    16 个用例,路由/注入/写接口栅栏
test/desktop.test.js  6 个用例,Electron 路径解析与「不静默下载」
test/geometry.test.js 18 个用例,桌面浮窗的拖动/尺寸/命中算式
test/visual-check.mjs 可选:真浏览器(Edge)跑 demo.html 并截图
test/desktop-smoke.mjs 可选:真起一个 Electron 浮窗,用 CDP 连进去点开面板
demo.html           外观预览页,不参与插件运行
cordis.patch.yml    bundle 挂载声明

跑测试与预览

node --test "test/*.test.js"

想在重启 DSH 之前先看外观,开 demo.html(用任意静态服务器,直接双击 file:// 也行)。 test/visual-check.mjs 需要额外装 playwright,不是必跑项,只用来验证缩放/壁纸在 真浏览器里确实画得出来:

npm i -D playwright        # 一次性
node test/visual-check.mjs # 截图落在 shots/

桌面浮窗另有一个真机冒烟测试 test/desktop-smoke.mjs:它自建一个假宿主提供 state.json / eta.js,真拉起 Electron,再用 CDP 连进渲染页去点齿轮开面板, 断言窗口开得够大、开面板后能重算尺寸且右下角不动、面板在可交互区内、 没有 CSP 拦截或加载失败。

它很有必要:单元测试只能钉住 geometry 的算式,钉不住「Electron 里到底发生什么」—— 而窗口是透明的,出错时用户只看到「什么都没有」。这个测试第一次跑就抓到了 「面板按放大前的视口定位」这个次序问题。

node test/desktop-smoke.mjs   # 需要 Electron 与一块真实显示器

已知边界

  • ETA 依赖 todo 清单。模型这一轮没写清单时,只能退化到步长/历史兜底, 置信度会明显下降——这不是 bug,是「没有可观测进度」的诚实表现。
  • 多会话并行时只显示「优先级最高」的那一条(运行中的优先,其次最近有事件的)。 完整快照在 /dsh-eta/state.json 里,最多 8 条。
  • 桌面端的注入走 webserver/index-inject 的内联 script 行,并吞掉加载失败: 路由不在就静默降级,宿主永远不会因为本插件起不来。
  • 壁纸存在 localStorage,所以只在这台浏览器、这个域下有效;换浏览器要重设。 图片链接跨域时只能当背景铺,不能读像素(也不该读)。
  • 缩放到 25% 时字号约 3px,只是「看一眼还剩多久」,不是用来读文字的。