dsh-eta-forecast
qwq-wny/dsh-eta-forecast
给 DSH 网页端与桌面端加一个 ETA 挂件:按 todo 清单与步进节奏估算本轮何时完成,显示剩余时长、进度条与置信度徽章。
安装
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%)。 这是照着「别一直喊超时、可它明明还在干活」的抱怨选的方向,见「第三轮改造」。