tuoLuoSuan/dsh-peak

dsh-peak-badge

输入框工具行左端的小胶囊:点一下就知道此刻 DeepSeek 按高峰价还是空闲价计费。点开是当前状态与计费方式、下次切换时间与倒计时,以及此刻为什么是这个状态:周末、具名的法定节假日、调休上班日,或者只是不在高峰时段内。判定用北京时间并显式写死 UTC 偏移,不看本机时钟;内置 2026 年中国法定节假日表(33 天节假日、6 个调休上班日),表外的年份直接说不知道,不猜。

미분류deepseekdsh

dsh-peak-badge

点一下就知道现在 DeepSeek 是按高峰价还是空闲价计费。

组件是一个小胶囊(chip),挂在输入框工具行的最左边(conversation.input.left)。 胶囊左边一个小圆点,颜色跟着状态走:高峰是琥珀色,空闲是中性色。 点开是一张小卡片,写着当前状态、计费方式、下次切换的时间和倒计时,以及此刻为什么是这个状态。

两个状态、浅色和深色主题各来一张。这些图都是 demo/ 里渲染出来的,跟仓库里的代码同源, 不是另画的(见截图是怎么来的):

空闲(默认) · 高峰 · 深色主题 · ·

点开的卡片,三个变体:中文空闲、中文高峰、英文空闲。中文空闲那张是故意挑的最长的一种: 国庆节里离下个高峰还有 4 天,下次切换 这一行是整张卡片能印出的最宽的值。

中文 · 空闲 · 中文 · 高峰 · English · off-peak · ·

底部那一行 系统通知 有四种状态,每种都把话写在勾选框旁边。左边是权限被拒(还能再点一次), 右边是这台机器根本没有通知接口(唯一会变灰的那种):

权限被拒 · 没有通知接口 ·

还差几分钟要切换时,右下角会主动弹一条,不用你去点开卡片:

要进高峰了 · 要进空闲了 · 深色主题 · ·

切换前提醒

这是在给「你正在写一个长提示词」这种情况准备的。

费率是在请求发出去的那一刻定下来的,不是完成的时候。所以「还有两分钟就要翻倍了」 是一句现在还有用的话,而「刚刚翻倍了」已经晚了。卡片里的倒计时只有点开才看得见。

  • 提前量在卡片里选:关闭 / 1 / 2 / 5 / 10 / 15 / 30 分钟,默认 2 分钟。
  • 每个切换点只说一次。判定的键是边界本身(北京日期 + 天偏移 + 钟点),

不是「我什么时候注意到的」——时钟 30 秒跳一次,两分钟的提前量会被看到四次, 键不稳就会连播四次,而且数字一次比一次小。

  • 两个方向都说:要进高峰了(要发就趁现在)和要进空闲了(不急就等一下)。
  • 说过的边界不再重说,换会话、切页面、重新挂载都不会再说一遍。时钟绑在插件上而不是

胶囊上,因为胶囊只在会话页存在,而「你正在看别的地方、准备起一个长任务」恰恰是最需要 知道的时候。

  • 可选系统通知。勾上时浏览器会问一次权限,四种结局都会写在勾选框旁边:还没试过、

被拒了(还能再点一次,改完浏览器设置就能开)、或者这个环境根本没有这个 API(唯一会 变灰的情形)。勾选状态每次读取都会和浏览器对账——权限被撤掉之后不会再显示一个 勾着却发不出通知的框。

  • 提醒本身不改变任何计费行为,也不拦任何请求。

计费规则

来自 DeepSeek 官方定价页:

北京时间周一至周五(不含中国法定节假日)9:00 - 12:00、14:00 - 18:00 为高峰时段;
其余时段,包括周末及中国法定节假日全天均为空闲时段。
空闲时段价格为高峰时段价格的一半。

所以谷时是 5 折。周末整天、法定节假日整天,无论几点都是谷时。

判定完全在浏览器里算:读本机时钟,换算成北京时间,再查一张硬编码的节假日表。 不发任何网络请求,不依赖 DeepSeek 的接口。价格表什么时候变,那是官方定价页的事, 这个插件只回答「现在落在哪一档」。

装

需要 DSH(DeepSeek Harness)。开发和验证是在 @deepseek-ai/dsh@0.2.0-rc.2 上做的。

从仓库装

跟着 main 分支走,不经过 npm,推荐。

dsh plugin --profile desktop add github:tuoLuoSuan/dsh-peak

profile 名就是侧栏「工作区」对应的那个(桌面端默认 desktop)。

装 npm 上的版本

dsh plugin --profile desktop add dsh-peak@0.2.1

别装成 `dsh-peak-badge`。 那个裸名字是别人的包(mj5809,停在 0.1.1,不是本项目)—— 本项目是 dsh-peak,一字之差装到的是另一个插件。

这个包以前叫 @tuoluosuan/dsh-peak-badge;0.2.0 起改名成 dsh-peak,旧名字停在 0.1.x。 两者代码同源,改名只是为了让市场里那张卡片显示成一行。

npm 上那份跟这个仓库同源,但不会自动跟着 main 走,想拿到新版本得等下一次发布。

0.2.1 只改了一处元数据:包里的 repository 指向新仓库名 tuoLuoSuan/dsh-peak(0.2.0 里还是改名前的 dsh-peak-badge)。代码没变。这一项对外是有用的——插件目录判断「这个仓库在 npm 上叫什么」时,会核对 npm 包里的 repository 是否指回同一个仓库,字段对不上就只能退回 github: 的装法。

版本号是故意写上的。pnpm 11 内置一个 24 小时的发版冷却期(minimumReleaseAge),这段时间里 裸包名不解析到 `latest`,而是解析到冷却期之外最新的那一版——命令照样成功、照样打印一行 +, 只是装到的是上一版。0.1.2 刚发布时跑裸包名,装到的是 0.1.1。

这一份装得上吗

node docs/probe-npm-install.mjs

在一个一次性的 profile 里真的装一遍,装的就是 package.json 里钉住的那一版,然后回答这个仓库 自己回答不了的三个问题:宿主有没有把它当插件收下、装进去的是不是你要发的那一版、以及那个包名在 三个地方是不是同一个。

最后一个值得多说一句。宿主是从 cordis.patch.yml 里学到这个包叫什么名字的,而那份补丁里的名字 和 package.json 里的名字没有任何东西在比对:写错一个字母,安装照样成功、注册照样成功、 插件永远不加载。所以这里把两份名字读出来对一遍,顺带确认 dsh.client.inject 声明的依赖不是空的。

找不到 dsh 会以退出码 2 结束并列出它找过的路径,PEAK_BADGE_DSH_CLI 可以指定。

在对话里让 DSH 自己装

plugin_manager action: install_bundle target: github:tuoLuoSuan/dsh-peak

本地开发

克隆下来直接指目录:

plugin_manager action: install_bundle target: D:\path\to\dsh-peak-badge

target 接受包名(可带版本)、git 地址、tarball 或本地绝对路径。

装完刷新页面。替换已装版本需要重启才能加载新代码(热加载只覆盖新装的 bundle)。

装不上或者想卸载

plugin_manager action: list_bundles
plugin_manager action: remove_bundle target: dsh-peak

list_plugins 里应该能看到这个包且 fiberPhase: active。

自己验一遍(推荐)

面板上的数字是算出来的,所以这里有能跑的证明:

node verify-policy.mjs

在仓库根目录直接 node verify-policy.mjs,在归档区那种外面套一层目录的地方则是 node dsh-peak-badge/verify-policy.mjs,都一样。

它把 client.js 里 #region policy 到 #endregion 之间那段(纯函数,无 React、无 DOM、 无服务)抠出来执行,跑八十多条断言:节假日表 33 天逐个对名字、四种边界分钟(9:00 / 12:00 / 14:00 / 18:00)的开闭、周末与节假日全天、调休日、秒数对齐、跨年拒绝回答、时区无关性, 以及两个曾经真的算错过的场景。一个是工作日 12:30(必须说「今天 14:00」,曾经说「明天 09:00」), 另一个是周五 20:00(必须说「3 天后 09:00」,曾经说「明天 09:00」,而那是周六)。

它接着把 #region text 那段也抠出来(new Function 包一层,塞一个假的 locale 服务进去), 验两种语言的数字格式、日期偏移词、每一句理由整句、以及中英来回切换之后中文能不能回来。 最后一类是真实存在过的 bug:恢复中文的路径曾经是一份手抄的字面量表,抄漏了一个键。

最后一段是两段合起来验的:同一个 new Function 里先放 policy 再放 text, 然后真正按卡片的方式跑一遍 reasonSentence(peakState(...))。分开验时两边都是手喂的假数据, 所以 policy 把 { name: ... } 改名、或者模板里把 {name} 打错,都能全绿通过, 而卡片上会直接印出 {name}(fill 是故意把缺失的占位符留在原地的)。 这一段就是为了堵这个缝,并且额外盯住 dateText / weekday / schedule 三个 别的断言都没碰过的字段。

再往后是切换前提醒的两段。先是 alertPlan——纯函数,验什么时候该说话、什么时候该闭嘴、 以及「同一条边界」的键到底稳不稳(时钟 30 秒一跳、提前量 2 分钟,键不稳就会连说四次)。

然后是本文件里唯一一段带状态的验证:把 #region policy + #region text + #region alert

  • #region view 四段一起抠出来,配一个能手动走的时钟、几十行的假 DOM、一个把 React.memo

当作恒等函数的 React,真的把时钟跑起来,也真的把那张卡片的设置行画出来读。 验的是纯函数验不到的东西——一条边界只播一次、再跳四拍还是同一条通知、下一个边界会替换掉 上一条、昨天的键不会让今天的边界闭嘴、提前量设成 0 就彻底安静、document.body 还没建好时 不消耗那一次发言机会、自动消失走的是 timer.timeout 而不是被客户端的半场禁掉的裸 setTimeout、偏好存得下也读得回、{"leadMinutes":3} 这种 select 画不出来的值被拒绝、 系统通知要「用户勾了」且「浏览器给了权限」两个条件同时成立才发。

通知那一行现在是画出来读的,因为「点了没反应」是肉眼才发现的:四种结局各自的文案都在 (没试过 / 被拒 / 环境没有这个 API / 默认未授权),被拒之后仍可点、只有 API 缺失才变灰, 而按下之后走的是真的 onChange 加真的 promise 链,两种答案形态各走一遍 (requestPermission 现代版 resolve 权限字符串,老版给回调传布尔值),并断言一次按下 只结算一次。这一段抓到的第二个真 bug 就在这里:原来写的是 granted === true,于是现代引擎 返回的 'granted' 被判成没给权限,框自己弹回去、文案还说「还没试过」——正是用户报的那句 「点了没反应」。改成 answer === true || answer === 'granted' 之后才通。 这一段还曾经抓到过一个真的时序依赖:showToast 靠一个由 apply 赋值的模块级变量拿定时器,而赋值在 startAlertClock 后面——第一次播报(同步发生的)就没有自动消失。现在 startAlertClock 自己认领那个定时器,顺序不再能被写反。

时区这件事值得单说:DSH 跑在用户本机时区上,而答案永远是北京时间的。这条不靠推理, 上面有一组专门的断言,并且整个文件可以在别的时区下重跑(TZ 环境变量 Node 会读):

TZ=UTC                  node verify-policy.mjs
TZ=America/New_York     node verify-policy.mjs
TZ=Asia/Shanghai        node verify-policy.mjs
TZ=Pacific/Kiritimati   node verify-policy.mjs

四个时区都是 200 条全过。把 peakState 里的 getUTC* 换成 get*(一个很自然的 「简化」),这套断言立刻挂。验证过,不是推测。

退出码按本工作区的约定分三种:0 全过,1 有断言挂了(代码错了), 2 根本没跑起来(与代码无关)。加了 --dump 会把抠出来的那段代码印出来。 最后还压了一条底线:跑过的条数少于预期(现在是 200)也一样按 1 退出, 否则「某个小节整段没跑」会打印成「0 passed, 0 failed」然后安安静静地返回 0。

关于这套断言的可信度:写它的时候,我手算的期望值错了六次,机器每次都对。
所以别相信「我看了一眼觉得对」,改动之后跑一遍。

一个刻意的选择:调休日按周末算

调休上班日(2026 年是 1/4、2/14、2/28、5/9、9/20、10/10)虽然是「上班日」, 但它们落在周六或周日。官方规则逐字写的是「包括周末……均为空闲时段」, 所以这里按字面执行:调休日一律算谷时。

卡片会在这种日子明说这件事,而不是默默按某个解释算:

调休上班日,但官方规则按周几算,周末一律空闲(周六),全天按空闲时段计价。

如果你认为调休日该按上班日算,改一处即可:client.js 里让 peakState 的 isMakeup 判定同时要求「不是周末」,工作日分支就会接住它。只改文案不算改, 文案和判定必须一起动,否则卡片会理直气壮地说反话。

verify-policy.mjs 里有一条专门盯这个的走查:周五 20:00 必须跨过调休的周六 5/9 走到周一 09:00;把调休算成工作日,它会答「周六早上」,该断言立刻挂。