- Home
- Plugins
- UI Enhancements
- dsh-eta-forecast
dsh-eta-forecast
qwq-wny/dsh-eta-forecast
Adds an ETA widget to the DSH web and desktop UI that estimates when the current turn will finish from the todo list and step pace, and shows the remaining time, a progress bar and a confidence badge.
Install
dsh plugin --profile web add github:qwq-wny/dsh-eta-forecastREADME
dsh-eta-forecast
给 DeepSeek Harness 的 Web / 桌面界面加一个「这一轮大概几点做完」的小挂件。
它不是按字数、按步数拍脑袋外推的。真正被当成进度分母的是 todo 清单—— 模型被要求「开工前先列清单、做完就勾」;步数只用来估「一条 todo 平均要几步」, 是节奏修正项,不是进度本身。
它显示什么
| 位置 | 内容 |
|---|---|
| 左上胶囊 | 多会话时的会话切换(只有一条会话时不显示) |
| 大字 | 预计完成时刻(本地时钟,如 14:32) |
| 副行 | 还剩多久 + 已经跑了多久 |
| 进度条 | 清单完成度(in_progress 按半条计) |
| 左下 | 3/7 项,没有清单时退化为「第 N 步」 |
| 右下徽章 | 置信度:样本不足 / 粗略 / 中等 / 较准 |
| 第三行 | 正在做的那一条(正在做:…) |
| 超时时 | 大字换成已跑时长,副行「已超时 N 分」—— 不再演倒计时 |
超过预测之后(超时追踪)
预测总有失手的时候。之前跑过预测点之后,倒计时只会逼近 0 然后卡住 —— 「还有 1 秒」挂好几分钟,用户不知道这轮已经超时了。现在:
- 跑过预测终点(+ 90 秒宽容期)还在跑 → 大字换成已跑时长,副行「已超时 N 分」。
- 但只有实时估计也认同「就快完了」时才喊。 如果越过截止点之后实时估计算出的 总时长仍然是截止点的两倍以上,说明这轮只是比 6 样本时的预测大、活儿还在正常推进 —— 这时不喊超时,让倒计时继续站岗。判据与实测见下方「第三轮改造」。
- 超时后仍然给重估时间:「重估还要 M 分(HH:MM)」—— 按现有样本重新算的。 这一条是实测补上的:真实一轮跑了 97 分钟,旧版从第 11 分钟起就只喊「已超时」, 连续 88 分钟没给任何时间信息。现在同一轮全程都有可读的重估。
- 超时锚点用「成熟预测」(样本 ≥6 时给出的第一次预测),不用最早那次。 首预测只有 2~3 个样本,最乐观也最不准;拿它当锚点会在第 11 分钟就误报超时, 而同一时刻细化后的估计还说「还有 838 秒」。改用成熟锚点后,同一轮的超时 首次出现从 11 分钟推迟到 32 分钟。
- 超时会学进下一轮。 每次完整跑完的轮次若超出预测,记下「超出比例」; 之后这个会话的估计按最近超时记录的中位比例放大(封顶 1.6×)。 被中断/出错的轮次不算(锚点不可信),准时完成的也不记(不污染)。
估算准确性
第一轮改造(真机上那轮 97.2 分钟、22 步、没有 todo 清单的回放)
| 指标 | 改前 | 改后 |
|---|---|---|
| 超时首次出现 | 第 11.3 分钟 | 第 31.9 分钟 |
| 超时后「无任何时间信息」的时刻 | 11 / 15 | 0 / 15 |
| 末期误差(第 1 轮) | +23% | +15% |
| 早期误差(第 2 轮 1.7 分钟处) | −86% | −60% |
| 平均绝对误差(第 2 轮) | 62% | 42% |
-
整轮时长先验不再排在最后。 没有 todo 清单时,「这类一轮通常跑多久」 比「还剩几步 × 中位步时」稳得多 —— 步时受模型思考时长影响,实测一轮内中位步时 从 22 秒漂到 277 秒。以前它只在其它估计全空时兜底,等于把最好的先验排在最后。 现在它与步数证据一起加权(≥2 轮给 0.45,只有 1 轮给 0.25)。 单样本只用于修正已有估计,绝不由它单独给出数字 —— 那不是估计,是把上一轮硬套过来。
-
平滑改为不对称:估短时跟得快(α=0.55),估长时跟得慢(α=0.22)。 用户对「说还要 20 分结果 5 分就好了」的容忍度,远高于「说还要 3 分又等了 20 分」。
-
精度可度量。 每次完整跑完的轮次记一条「预测 vs 实际」,快照里给出:
accuracy.mape:最近几轮的中位绝对相对误差(0.18 = 典型偏 18%)accuracy.bias:中位带符号误差(正 = 习惯估长,负 = 习惯估短)
卡片上表现为副行末尾的「近几轮偏差约 18%」,置信度徽章的 tooltip 里还带偏向。 样本不足 2 轮时一律不显示 —— 不编数字。
第二轮改造:早期上界(把「刚跑 40 秒就报还有 6 分钟」压回去)
第一轮改造之后,把本机全部真实会话日志都回放了一遍,才发现坏名声几乎全来自 一个地方:轮次早期的高估。一小时以上的轮次其实早就偏低了,问题只在开头。
| 分桶(第一轮改造后) | MAE / 偏置 |
|---|---|
| 1–2 分钟 | 112% / +111% |
| 2–5 分钟 | 53% / +31% |
| 5–15 分钟 | 57% / +20% |
| 15–30 分钟 | 44% / −26% |
| 30–60 分钟 | 50% / −18% |
| 60 分钟以上 | 103% / +12% |
长轮早就偏低了,所以修法不是「统统砍掉」,而是
「已经跑了 T 秒,这一轮最多还能再跑
k(T)·T + 120 秒」,其中k(T) = 2.42 + 1.98·log10(1 + T/120)—— 随 T 对数放松 (40 秒时 k≈2.7、10 分钟时 k≈4.0、一小时时 k≈5.4、两小时时 k≈6.0, 折算成「这一轮最多总长」分别是 3.8 / 42 / 324 / 717 分钟,长轮基本不再受约束)。
关键在于这条上界只作用于显示层。如果把它夹在平滑之前,超时锚点也会跟着变小,
实测会发现超时率明显升高 —— 更多轮次退化成「已超时、不给任何时间」,
那正是第一轮改造刚刚修好的、让挂件最没用的形态。放在 forecastSession 返回之前时,
超时判定与精度记录全都看未夹的估计,只有用户看到的那个数字被约束。
因此超时率一位小数都没变(同一批观测点上逐点完全一致)。
回放结果(同一批日志、同一批观测点,逐点对比):
| 分组 | MAE | 偏置 | 灾难率 |
|---|---|---|---|
| 全部 | 61% → 52% | +12% → +8% | 5% → 0% |
| 2 分钟以上 | 56% → 47% | +2% → −1% | 5% → 0% |
| 5–30 分钟 | 51% → 45% | −1% → −3% | 6% → 0% |
| 30 分钟以上 | 64% → 50% | −10% → −17% | 4% → 0% |
| 60 分钟以上 | 103% → 76% | +12% → −8% | 3% → 0% |
「灾难率」= 非超时态下「显示还剩 ≤30 秒、但实际还剩 2 分钟以上」的观测点占比。 超时态不计入,因为那时卡片显示的是「已超时 M 分」,倒计时数字根本不在屏幕上 —— 把它算进去会得到一个漂亮但没意义的数字。这个指标比 MAE 更贴近体感: 它就是「这破倒计时到底准不准」和「嗯,差不多就是这么回事」之间的区别。
顺带一个反直觉的结论:夹太紧反而更差。上界前面那个 ×1.15 不是随手写的, 把这条缓冲扫了一遍(同一批 2118 个观测点):
| 缓冲倍数 | MAE | 偏置 | 灾难率 |
|---|---|---|---|
| 1.00(直接把上界当结果) | 49% | −3% | 5% |
| 1.15(采用) | 52% | +8% | 0% |
| 1.30 | 56% | +18% | 0% |
| 1.60 | 67% | +36% | 0% |
1.00 的 MAE 确实更低,但它把误差整体压到偏低一侧,并且灾难率又回到 5% —— 也就是每二十个观测点就有一个「说还剩不到半分钟、实际还有好几分钟」。 这正是第一轮改造花力气消掉的那个形态,所以宁可多 3 个百分点 MAE 换回 0%。 再往右则纯亏:1.30 起 MAE 单调变差,灾难率也不会更低。
口径、分桶、灾难率的定义全部写死在回放脚本里,任何人都能在自己机器上复现:
node test/replay-all.mjs # 跑本机 ~/.dsh/sessions 的全部日志
node test/replay-all.mjs --before HEAD # 与任意 git 版本逐点对比
node test/replay-all.mjs --variant path/to/x.mjs # 并排第三组(调参时必须并排看)
--variant 存在的意义:调超时/夹取这类参数时,改文件→跑→改回来这套流程里
任何一次忘记还原都会污染结论(本轮就被坑过一次,得拿 git show HEAD: 当基线重跑)。
给它一个实验副本路径,脚本会把它复制到临时目录再 import,不动工作树。
test/replay-accuracy.mjs 保留着,但它只回放单独一轮,适合讲故事、
不适合支撑「准确率提高了几个百分点」这种结论 —— 换一组参数就能翻盘。
第三轮改造:超时抑制(把「明明还在干活却一直喊已超时」压回去)
前两轮把全部 MAE 从 100% 压到五成上下,但用户的实际抱怨换了个方向:「经常超时」。 把所有真实日志按「超时态占多少墙钟时间」重新量一遍,问题很具体 —— 超时态占了 60% 的墙钟,最长的一轮连续喊了 195 分钟「已超时」。
根因是超时锚点(maturePredictedMs)的语义:它在样本刚够 6 个时冻结,
之后永不更新 —— 不冻结就永远判不出超时,所以这个冻结本身是对的。错的是
拿它当无条件判据:一轮跑到第 195 分钟,早就不该再拿「第 6 个样本时的预测」说事。
修法不是改锚点,而是加一条克制:
越过承诺截止点之后,如果实时估计的总时长仍然远超截止点(
> 截止点 × 2), 说明活儿还在正常推进、只是比 6 样本时的预测大 —— 这时不喊超时, 让倒计时继续站岗;只有实时估计也认同「就快做完了」时才切到超时态。
试过的错误路线先记一笔:把锚点做成棘轮(估计总时长超过锚点 1.5 倍就把锚点 抬到新估计)能把超时率压到 14%,但真超时也几乎不再触发 —— 棘轮每次把锚点 追到当前总时长,等于永远差一点、永远判不出超时。过度校正,放弃。
实测(同一批 28 会话 / 196 轮 / 2301 个观测点):
| 指标 | 改前 | 改后 |
|---|---|---|
| 超时态占墙钟 | 60% | 8% |
| 最长连续超时 | 195 分钟 | 22 分钟 |
| 有超时段的轮次 | 45% | 31% |
| 真超时覆盖(实际还剩 >5 分时被标超时) | 63% | 8% |
| 「离谱」占比(超时里卡片自己还说剩余 >2×真实且 >5 分) | 13% | 4% |
| MAE / 偏置 / ±2 倍 / 灾难率 | 51% / +5% / 92% / 0% | 一位都没动 |
这个判据扫过一遍候选值(同一批观测点):
| 抑制倍数 | 超时率 | 最长连续 | 真超时覆盖 | 离谱占比 |
|---|---|---|---|---|
| 1.5 | 2% | 10.6 分钟 | 2% | 6% |
| 2.0(采用) | 8% | 22.1 分钟 | 8% | 4% |
| 3.0 | 18% | 45.7 分钟 | 19% | 4% |
| 不抑制(1e9) | 60% | 195.2 分钟 | 63% | 13% |
1.5 会把真超时也一起压没了(覆盖率只剩 2%,等于这个功能名存实亡);3.0 又回到 「动不动就喊超时」。2.0 是唯一同时把「最长连续」压到二十分钟级、又保住 8% 覆盖的档位。
代价写在明面上:抑制是有意识的少报。真正跑飞的轮次,卡片可能到很晚才承认 超时,期间一直显示一个偏大的倒计时。这个取舍是照着用户的真实抱怨选的 —— 「说还有 20 分结果 5 分钟就好了」的容忍度,远高于「一直在喊超时、可它明明还在干活」。
外观(缩放 / 壁纸)点标题行的 ⚙ 调,见下方「外观设置」。
版本号
设置面板标题旁有一枚版本徽章(如 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。挂件默认出现在右下角。
从 npm 装(推荐:预构建,装得最快)
dsh plugin --profile desktop add dsh-eta-forecast
已经在装了、要换新版本时必须带上版本号——pnpm 不会因为一个裸包名就重新解析已有的依赖:
dsh plugin --profile desktop add dsh-eta-forecast@<版本号> # 例如 dsh-eta-forecast@1.6.3
⚠️ 别用 @latest:pnpm 解析它用的是自己那份 registry 元数据缓存,刚发布的新版本常常还没进缓存,
结果是「已满足、什么都不做」——而且不报错,看起来像装成功了(实测踩过)。精确版本号没有这个问题。
不想用 npm(直接从 GitHub 源码装)
dsh plugin --profile desktop add github:qwq-wny/dsh-eta-forecast
本地开发(从目录链接,改完重启 DSH 即生效)
dsh plugin --profile desktop add link:<本目录绝对路径>
dsh plugin 只是把参数转发给 profile 目录里的 pnpm,所以 remove / why / update 同样能用。
--profile <名字> 换成你实际用的 profile(桌面版默认 desktop,网页版通常是 web)。
显示位置
设置面板里有「显示位置」,四选一(与 dsh-pet 同一套语义,不用记两套词):
| 值 | 含义 |
|---|---|
| 仅网页 | 只显示 DSH 页面右下角的挂件 |
| 仅桌面 | 只显示桌面上的浮窗(置顶、透明、不占任务栏) |
| 两者 | 两边都显示(默认) |
| 都不 | 全部隐藏 |
桌面浮窗由宿主拉起一个 Electron 小窗(runtime/electron-helper/)。三点说明:
- 复用现成的 Electron 运行时(
$DSH_HOME/electron)。dsh-pet 已经下过那份 300MB, 不重复下载。找不到时不会替你静默下载 —— 那是个用户没同意的重操作,改为明确报错, 可用DSH_ETA_ELECTRON_PATH指定路径,或先装 dsh-pet 让它把运行时下好。 - 默认整窗点击穿透,只有光标落在挂件上才可交互。否则一个置顶透明窗会把下层应用全挡住。
- 拖动的是窗口,不是 DOM 元素。网页端拖元素、桌面端拖窗口,这是唯一需要分叉的地方;
渲染、缩放、壁纸、右键菜单、设置面板完全共用同一份
assets/eta-client.js。
配置存在宿主内存里(不是 localStorage):换浏览器也应该一致。改配置走
POST /dsh-eta/config,只接受回环请求(判据是 socket 的 remoteAddress,不信任何请求头)——
这是本插件唯一的写接口,局域网改不了。
操作
- 拖动:整张卡片都能拖 —— 按住左键或右键拖都行(不限于标题行)。 位置会记住,窗口变小会自动夹回屏幕内。
- 收起:标题行右边的
−。折叠后只留一条进度条,不留文字 —— 折叠的意义就是「一眼看进度」,留一行「预计完成」占着地方却什么信息都没给。 卡片高度从 147px 压到 43px,进度条仍在走,会话胶囊、时钟、置信度徽章都收起。 没有进度可显示时(刚开局、还没有样本)折叠态退回标题文字 —— 否则就剩两个孤零零的按钮,认不出这是什么。 - 设置:三种方式等价 —— 标题行的
⚙、双击标题文字、右键原地轻点。 - 右键:按下后移动就是拖动卡片;按下后原地松开就开关设置面板 —— 面板已经开着时再右键会把它关掉(同一个手势管开也管关,面板上右键同理)。 拖动与开关靠「按下后有没有移动」区分(阈值 3px),不需要记两个手势。
- 缩放:
⚙里拖滑块。低于 70% 自动变脸 —— 说明文字整体让位, 只留「大号剩余时间 + 进度条」,卡片从 163px 掉到 42px 高。把缩放拖回 100% 一定恢复完整布局(缩扁是推导,不会改写你手动的折叠状态)。 - 切换会话:卡片左上方那排胶囊。点一下就换看哪条会话(见下)。
- 锁定:
⚙里打开「锁定挂件」。锁定后整张卡片不接收鼠标(像音乐软件的 桌面歌词锁定)—— 点它、拖它、右键它全都不响应,鼠标直接穿过去落到下层的窗口。 唯一还能点的,是卡片右上角那颗 🔒:点一下即解锁。 - 这些偏好存在
localStorage(键dsh-eta-forecast:v1);显示位置是宿主级的,不在这里。
切换会话
同时开了多条会话时,卡片左上方会出现一排胶囊,一条会话一个:
- 运行中的会话前面有绿点,扫一眼就知道哪条在跑;
- 当前看的那条高亮;点别的胶囊立刻切过去(立刻重画,不等下一轮轮询—— 最多 2 秒的空窗会让人以为点错了);
- 会话多了会横向滚动,选中的自动滚到可见区;
- 没标题的会话用 id 尾巴当文案(
会话 abcd),而不是全叫「未命名」。
只有一条会话时整行收起 —— 没得切的东西不该占地方。
两个刻意的取舍:
- 选择只存在内存里,不进 localStorage。 会话是临时的,宿主重启后 id 全换; 记住一个死 id 只会让挂件永远显示「会话不存在」。
- 钉住的会话消失了就自动回落到默认(宿主排序的第一条),不会卡在空状态上。
右键已经没有中间菜单了。 它原来会弹一个三项小菜单(收起 / 外观设置 / 回到右下角), 多一层点击只增加摩擦。现在右键直开设置,而「回到右下角」挪进了设置面板底部 —— 那个兜底动作还在,只是不再占一个菜单位。
「隐藏挂件」已移除。 原来点
×会把整张卡片收起来,只能靠双击右下角 一小块热区找回 —— 既难发现,也容易把自己锁在外面(尤其缩到 25% 之后)。 现在想让它不碍事:收起(只剩标题行)或者拖到角落。
外观设置
点 ⚙(或双击标题 / 右键轻点)打开一个独立的小面板(它不跟着卡片缩放——
25% 时卡片只有 59px 宽,面板塞进去就没法用了):
| 项 | 范围 | 说明 |
|---|---|---|
| 缩放 | 25% – 100% | 整卡等比缩放,以右下角为锚点,缩小时不会飘到屏幕中间。低于 70% 自动切成「时间 + 进度条」,见下方取舍 6 |
| 壁纸 | 本地图片 / 图片链接 | 本地图会先缩到最长边 1024、转 JPEG 0.82 再存 |
| 模糊 | 0 – 24px | 只作用于壁纸层 |
| 暗化 | 0 – 85% | 壁纸压暗,保证浅色字也读得清 |
| 卡片不透明度 | 15% – 100% | 卡片底色盖在壁纸上的程度,决定壁纸露出多少 |
| 显示位置 | 仅网页 / 仅桌面 / 两者 / 都不 | 桌面浮窗是否出现(见上一节) |
| 记住我的设置 | 开 / 关(默认开) | 记忆模式:显示位置与桌面浮窗位置落盘,重启原样恢复 |
| 隐藏菜单按钮 | 开 / 关 | 开启后标题行按钮全部隐藏,改由右键开设置 |
| 锁定挂件 | 开 / 关 | 整卡不接收鼠标,只剩右上角那颗 🔒 可点(见下方取舍 7) |
| 回到右下角 | — | 位置拖丢了时的兜底(原来在右键菜单里) |
| 恢复默认 | — | 缩放、壁纸、位置、折叠、菜单按钮显隐、锁定一起还原(记忆开时连显示位置与浮窗位置的记忆也清掉;不动记忆开关本身) |
九个设计上的取舍:
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 配额。写失败(配额爆了)会明确提示,而不是静默丢掉。
默认值是渲染出来比对过的:0.3 时深色字压在深蓝壁纸上糊成一片,0.95 时壁纸 几乎看不见(等于没换)。0.65 不透明度 + 28% 暗化是折中值。
4. 缩到很小要留一条逃生的路。 25% 时卡片只有 59px 宽,齿轮按钮的命中区约
4.5px——点不到。用户会把自己锁在一个打不开设置的小卡片里,唯一出路是手改
localStorage。所以缩放低于 60% 时,在卡片角外侧贴一块 44px 的透明热区
(#dsh-eta-hotspot,不参与缩放),双击打开设置;放大回去时它自动消失。
贴哪个角取决于折叠状态:展开态贴右下角;折叠态改贴左下角。
折叠卡片只有 42px 高(小缩放时视觉上仅 19px),贴右下角的话那块 22×22 的热区
会正好压住 + 和 ⚙(实测 45% 时热区 x116138、按钮 x114132)——
点不动 + 就再也展不开了,逃生门反而成了牢门。左下角只有进度条,盖住无所谓。
折叠态同时把按钮不透明度从 .55 提到 .8:8px 的按钮在那个透明度下基本看不见。
5. 右键身兼三职,靠「有没有移动」区分。 用户要求「所有地方都能右键拖动」, 但右键同时也要能开/关设置。如果让右键纯粹等于拖动,设置就没入口了;如果让右键 纯粹等于设置,就不能拖。判据是按下后位移是否超过 3px:
- 右键按下 → 移动 ≥3px → 拖动卡片(松手不动面板)
- 右键按下 → 原地松开 → 开关设置面板(开着就关掉)
面板挂在 body 上、不参与卡片缩放——否则 25% 时字号只剩 3px,等于把人锁死。
面板里的动作与按钮走同一份逻辑(toggleCollapsed / showPanel / resetPosition),
不复制第二套行为。
「开着就关掉」有一个必须处理的细节:右键开面板时,Chromium 在 pointerup 之后才派发 contextmenu,而那时面板已经出现在光标下 —— 面板自己的 「右键关闭」会接住同一次右键,把它立刻关掉,表现成「右键没反应」。 解法是记下开面板那一下的坐标,面板侧只忽略落点重合(2px 内)的那一个 contextmenu;别的右键都是真实的「关掉」意图。用坐标而不是时间窗,是因为 时间窗会把「开完马上又想关」的合法快速右键一起吞掉——真浏览器校验里翻过车。
6. 缩小到 70% 以下时,说明文字整体让位给进度条。 12px 的字缩到 70% 只剩 8px、缩到 45% 只剩 5px —— 读不出来,却还把卡片撑到 114px 高,缩放就失去了意义。既然读不了就别占地方:低于 70% 时自动只留 大号剩余时间 + 进度条,卡片高度从 163px 掉到 42px(65%)/29px(45%)。
这一档是由缩放值推出来的,不是新开关:用户拖滑块时想的是「我要它小」,
不是「我要它小、并且文字还在但读不出来」。多一个开关就是多一次需要他理解的设计
决策。缩扁只挂 class,不改写 collapsed 那个用户意图,所以把缩放拖回 100%
一定能展回完整布局。
「左右完成 · 还有 5 分 56 秒」这一句在缩扁态也一并藏掉:它在 165px 宽的卡里会折成 两行,7px 的字糊成一团比没有更难看;大号「14:59」已经说明了什么时候完事。
7. 锁定必须留一颗「点得到」的小锁,而且它不能画在卡片里。 锁定的语义是「鼠标碰不到它」,但一旦真的碰不到,解锁的入口也就跟着没了 —— 用户只能去手改 localStorage,这比不做这个功能更糟。所以:
- 网页端整卡
pointer-events:none;锁定时标题行按钮也一并隐藏(它们已经点不动了, 留着只会让人以为还能用)。 - 唯一例外是那颗 22px 的
#dsh-eta-lock,它挂在body上、不在卡片里 —— 挂在卡片内部的话会跟着pointer-events:none一起失效,等于把逃生门砌进墙里。 挂 body 还顺便躲开了transform: scale(),缩到 25% 时它依然是 22px、依然点得中。 - 桌面端的穿透由渲染壳的命中判定管(
geometry.hitTestPoint多收一个chip矩形)。 锁定态下卡片与面板都当作「不命中」,只把小锁算进去 —— 否则窗口在卡片那一块仍会 抢走鼠标,下层的应用就点不到,「碰不到」只做了一半。 - 锁定时不贴那块 44px 的逃生热区(见取舍 4):热区是「可点的」,留着等于给锁定 开了个后门。小锁本身就是逃生门,多一个只会互相打架。
- 锁定状态是客户端的事,存在 localStorage(和缩放、壁纸同级),不劳烦宿主 ——
它不影响估算,也没有跨浏览器的必要。渲染壳也不自己存一份状态,直接读 DOM 上的
data-locked:两边各存一份,迟早会出现「卡片说锁了、窗口还认它可点」。
8. 早期上界只准改「给人看的那一个数」,不准碰锚点。
同一个上界夹在平滑之前会更漂亮:全部 MAE 能从 57% 再降到 50%。但代价是
maturePredictedMs(超时判定的锚点)也跟着变小,实测超时率从 39% 涨到 54% ——
更多轮次退化成「已超时」、不再给任何时间。那正是上一轮改造刚刚修好的、
让这个挂件最没用的形态。所以在 forecastSession 里,超时判定、精度记录、
锚点写入全部用未夹的估计,只有最后 return 出去的那个 remainMs 被约束。
宁可精度少赚 7 个百分点,也不换回那个形态。
9. 超时锚点冻结,但「喊超时」这件事必须另设闸门。 锚点冻结是对的(不冻结就永远追着估计跑、永远判不出超时),可它单独用起来很糟: 一轮跑到第 195 分钟,还在拿第 6 个样本时的预测说「你已经超时了」。 真正的信息不是「超出了那个旧承诺」,而是「旧承诺本身已经不作数了」—— 所以越过截止点后先问一句:实时估计算出的总时长,是不是仍然远超截止点? 是,就说明活儿还在正常推进,让倒计时继续站岗;不是,才承认超时。 把这两件事拆开,超时态占的墙钟从 60% 掉到 8%,MAE 一个百分点都没动。
它不做什么
- 只开两个只读路由 + 一个回环写路由。
/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 43 个用例,全部在测「会算错的地方」
test/client.test.js 69 个用例,用最小 DOM 垫片把客户端脚本真跑起来
test/host.test.js 20 个用例,路由/注入/写接口栅栏
test/desktop.test.js 6 个用例,Electron 路径解析与「不静默下载」
test/geometry.test.js 20 个用例,桌面浮窗的拖动/尺寸/命中算式
test/replay-all.mjs 回放本机全部真实日志,逐点对比两个实现(准确性结论的唯一依据)
test/replay-accuracy.mjs 只回放单独一轮,适合讲故事,撑不起百分点结论(可单独跑)
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,只是「看一眼还剩多久」,不是用来读文字的。
- 锁定只挡鼠标。键盘(Tab 焦点)在网页端仍可能摸到卡片 —— 真要挡住,得让 卡片在锁定时不可聚焦;这一版没做,因为「桌面歌词锁定」本身就是鼠标语义。
- 锁定状态记在
localStorage,换浏览器要重新锁。 - 早期上界是显示层的约束,不是「真实预测变准了」。它做的事是「把显然不可能的 数字挡在屏幕外」,代价是长轮偶尔会被压得偏低一点(30–60 分钟档偏置 −22%)。 它救不了一个本来就信息不足的估计 —— 那种情况下卡片会老老实实显示「还看不出节奏」。
- 超时抑制是有意识的少报。确实跑飞的轮次,卡片可能到很晚才承认超时, 期间一直显示一个偏大的倒计时(实测真超时覆盖从 63% 降到 8%)。 这是照着「别一直喊超时、可它明明还在干活」的抱怨选的方向,见「第三轮改造」。
Related plugins
dsh-web (dsh-task-board)
zhu1090093659/dsh-web
dsh-web (dsh-web-all)
zhu1090093659/dsh-web
dsh-web-ui (dsh-task-board)
zhu1090093659/dsh-web-ui
dsh-web-ui (dsh-web-ui-all)
zhu1090093659/dsh-web-ui