dsh-offpeak-guard 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -0
- package/LICENSE +21 -0
- package/README.en.md +109 -0
- package/README.md +108 -2
- package/client.js +395 -0
- package/cordis.patch.yml +34 -0
- package/docs/peak-strip.png +0 -0
- package/docs/sidebar-clock-blink.png +0 -0
- package/docs/sidebar-clock.png +0 -0
- package/icon.svg +15 -0
- package/index.js +173 -0
- package/lib/guard.js +663 -0
- package/lib/holidays.js +60 -0
- package/lib/peak.js +384 -0
- package/lib/store.js +121 -0
- package/lib/tools.js +221 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +72 -4
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.2.0 — 2026-11-05
|
|
4
|
+
|
|
5
|
+
- 默认规则改为 DeepSeek 官方峰谷:工作日 09:00–12:00、14:00–18:00 峰时,周末与**中国法定节假日全天**谷价;内置 2026 年节假日列表。
|
|
6
|
+
- 新增 `preset`:`auto` / `deepseek-official` / `workbuddy` / `custom`,按 provider 自动选择(WorkBuddy/CodeBuddy → workbuddy,其余 → 官方)。
|
|
7
|
+
- 新增 `Config` schema:DSH 设置页可直接修改时区、窗口、豁免模型、提醒阈值等。
|
|
8
|
+
- 新增 `DSH_OFFPEAK_*` 环境变量覆盖(优先级:环境变量 > 配置 > 默认),便于部署方统一注入。
|
|
9
|
+
- 状态工具与界面显示当前规则和节假日状态。
|
|
10
|
+
|
|
11
|
+
## 0.1.0 — 2026-11-05
|
|
12
|
+
|
|
13
|
+
- 首个公开版本。
|
|
14
|
+
- 时间识别:工作日 `09:00–12:00`、`14:00–18:00` 峰时,其余时间谷价 5 折(可配置窗口、星期、时区与折扣)。
|
|
15
|
+
- 模型识别:按会话真实请求头判断是否峰谷计价模型,支持逐模型豁免。
|
|
16
|
+
- 峰时自动暂停运行中的任务、停住峰时新任务;谷价开始自动恢复被中断的任务(保留待处理队列)。
|
|
17
|
+
- 错峰工作规划:`offpeak_plan` 把任务排进下一个谷价窗口,谷价时段自动执行。
|
|
18
|
+
- 界面:输入框下方峰谷状态条;左栏底部紧凑计时(临近峰价闪红、临近谷价闪绿),悬浮说明保持稳定不闪。
|
|
19
|
+
- 手动控制:`offpeak_guard` 的 `pause_now` / `resume_now` / `run_plans_now`。
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 HATO-24
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.en.md
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# Off-peak guard (dsh-offpeak-guard)
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/dsh-offpeak-guard)
|
|
4
|
+
|
|
5
|
+
Published on npm: install it from the DSH Plugins page, or run
|
|
6
|
+
`dsh plugin --profile <profile> add dsh-offpeak-guard`.
|
|
7
|
+
|
|
8
|
+
Schedule work around **DeepSeek peak/off-peak pricing** inside DeepSeek Harness:
|
|
9
|
+
|
|
10
|
+
- **Time awareness**: per DeepSeek's official pricing, Beijing-time weekdays (excluding Chinese statutory holidays) `09:00–12:00` and `14:00–18:00` are peak; every other hour (weekends, holidays, lunch, night) is off-peak at 50%. Built-in Chinese holidays cover 2026; add later years with `holidays`.
|
|
11
|
+
- **Model awareness**: only sessions using a peak-priced model (model name contains `deepseek` by default; exempt models one by one with `exemptModels`) are paused/resumed. Other models are untouched.
|
|
12
|
+
- **Work planning**:
|
|
13
|
+
- peak starts → running tasks are paused (the pending inbox is kept, no message is lost);
|
|
14
|
+
- a task started during peak → stopped immediately, no peak-priced tokens spent;
|
|
15
|
+
- off-peak starts → interrupted tasks are handed back to their session automatically;
|
|
16
|
+
- the `offpeak_plan` tool queues "run this at noon" work for the next off-peak window.
|
|
17
|
+
- **UI**: a status strip under the composer (state, countdown, model, paused/planned counts), plus a compact clock at the sidebar foot that blinks red before peak starts and green before off-peak starts.
|
|
18
|
+
|
|
19
|
+

|
|
20
|
+
|
|
21
|
+
Sidebar clock, colored by the current window:
|
|
22
|
+
|
|
23
|
+

|
|
24
|
+
|
|
25
|
+
Red blink within the alert window before peak (sample):
|
|
26
|
+
|
|
27
|
+

|
|
28
|
+
|
|
29
|
+
## Tools
|
|
30
|
+
|
|
31
|
+
| Tool | Purpose |
|
|
32
|
+
| --- | --- |
|
|
33
|
+
| `offpeak_status` | Current window, next switch, whether the current model is peak-priced, paused/planned lists |
|
|
34
|
+
| `offpeak_plan` | `add` / `list` / `remove` / `clear` off-peak plans |
|
|
35
|
+
| `offpeak_guard` | `status` / `pause_now` / `resume_now` / `run_plans_now` manual control |
|
|
36
|
+
|
|
37
|
+
## Configuration
|
|
38
|
+
|
|
39
|
+
The `offpeak-guard` row's `config` in `cordis.patch.yml`, or the plugin settings form in DSH (a Config schema is declared):
|
|
40
|
+
|
|
41
|
+
| Key | Default | Meaning |
|
|
42
|
+
| --- | --- | --- |
|
|
43
|
+
| `enabled` | `true` | Master switch |
|
|
44
|
+
| `preset` | `auto` | `auto` picks by provider: WorkBuddy/CodeBuddy → `workbuddy`, otherwise `deepseek-official`; can be pinned to `deepseek-official` / `workbuddy` / `custom` |
|
|
45
|
+
| `timezone` | `Asia/Shanghai` | Pricing time zone (DeepSeek bills in Beijing time) |
|
|
46
|
+
| `peakWeekdays` | `[1,2,3,4,5]` | Weekdays with peak windows (0 = Sunday); only used by `custom` |
|
|
47
|
+
| `peakWindows` | 09:00–12:00, 14:00–18:00 | Peak windows; only used by `custom` |
|
|
48
|
+
| `discount` | `0.5` | Off-peak discount, display only; only used by `custom` |
|
|
49
|
+
| `holidays` | `[]` | Extra statutory holidays (`YYYY-MM-DD`), appended to the built-in 2026 list for the official/WorkBuddy presets |
|
|
50
|
+
| `modelPattern` | `deepseek` | Model/provider name containing it counts as peak-priced |
|
|
51
|
+
| `exemptModels` | `[]` | Model ids exempted one by one |
|
|
52
|
+
| `pauseRunningOnPeak` | `true` | Pause running tasks when peak starts |
|
|
53
|
+
| `deferNewTurnsOnPeak` | `true` | Stop tasks started during peak immediately |
|
|
54
|
+
| `resumeOnOffPeak` | `true` | Resume paused tasks when off-peak starts |
|
|
55
|
+
| `pollSeconds` | `15` | Check interval (5–300) |
|
|
56
|
+
| `resumeDelaySeconds` | `3` | Wait a few seconds after off-peak starts before resuming |
|
|
57
|
+
| `bypassMinutes` | `10` | Grace period after a manual resume |
|
|
58
|
+
| `alertMinutes` | `10` | Blink the sidebar clock within this many minutes of a switch (0 = never) |
|
|
59
|
+
|
|
60
|
+
### Environment variables
|
|
61
|
+
|
|
62
|
+
Deployments can inject settings with environment variables. Priority: **env > config > default**. Invalid values are ignored.
|
|
63
|
+
|
|
64
|
+
| Variable | Example | Config field |
|
|
65
|
+
| --- | --- | --- |
|
|
66
|
+
| `DSH_OFFPEAK_ENABLED` | `0` / `1` | `enabled` |
|
|
67
|
+
| `DSH_OFFPEAK_PRESET` | `custom` | `preset` |
|
|
68
|
+
| `DSH_OFFPEAK_TIMEZONE` | `Asia/Shanghai` | `timezone` |
|
|
69
|
+
| `DSH_OFFPEAK_PEAK_WEEKDAYS` | `1,2,3,4,5` | `peakWeekdays` |
|
|
70
|
+
| `DSH_OFFPEAK_PEAK_WINDOWS` | `09:00-12:00,14:00-18:00` | `peakWindows` |
|
|
71
|
+
| `DSH_OFFPEAK_DISCOUNT` | `0.5` | `discount` |
|
|
72
|
+
| `DSH_OFFPEAK_HOLIDAYS` | `2026-10-01,2026-10-02` | `holidays` |
|
|
73
|
+
| `DSH_OFFPEAK_MODEL_PATTERN` | `deepseek` | `modelPattern` |
|
|
74
|
+
| `DSH_OFFPEAK_EXEMPT_MODELS` | `deepseek-v4-pro,deepseek-flash` | `exemptModels` |
|
|
75
|
+
| `DSH_OFFPEAK_PAUSE_RUNNING` | `0` / `1` | `pauseRunningOnPeak` |
|
|
76
|
+
| `DSH_OFFPEAK_DEFER_NEW` | `0` / `1` | `deferNewTurnsOnPeak` |
|
|
77
|
+
| `DSH_OFFPEAK_RESUME` | `0` / `1` | `resumeOnOffPeak` |
|
|
78
|
+
| `DSH_OFFPEAK_POLL_SECONDS` | `15` | `pollSeconds` |
|
|
79
|
+
| `DSH_OFFPEAK_RESUME_DELAY_SECONDS` | `3` | `resumeDelaySeconds` |
|
|
80
|
+
| `DSH_OFFPEAK_BYPASS_MINUTES` | `10` | `bypassMinutes` |
|
|
81
|
+
| `DSH_OFFPEAK_ALERT_MINUTES` | `10` | `alertMinutes` |
|
|
82
|
+
|
|
83
|
+
### Presets
|
|
84
|
+
|
|
85
|
+
- `deepseek-official` (default): official weekday peak windows plus built-in 2026 Chinese holidays (holidays are off-peak all day).
|
|
86
|
+
- `workbuddy`: same peak windows, labeled as WorkBuddy credit pricing; selected automatically for WorkBuddy/CodeBuddy providers.
|
|
87
|
+
- `custom`: use `peakWeekdays` / `peakWindows` / `discount` / `holidays` exactly, for other providers or self-hosted pricing.
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
## Data
|
|
91
|
+
|
|
92
|
+
State and snapshots live in `$DSH_HOME/offpeak-guard/`:
|
|
93
|
+
|
|
94
|
+
- `state.json`: paused tasks, off-peak plans, last phase;
|
|
95
|
+
- `status.json`: read-only snapshot for the UI (mirrored to `~/.dsh/offpeak-guard/status.json`).
|
|
96
|
+
|
|
97
|
+
Nothing is written into user workspaces.
|
|
98
|
+
|
|
99
|
+
## How resuming works
|
|
100
|
+
|
|
101
|
+
Pause uses `sessionController.cancel`, which ends only the current turn and keeps the pending inbox. Resume uses `sessionController.prompt` to deliver a "continue where you left off" instruction to the original session; cold sessions are woken automatically. The resume text mentions the interruption time and the paused model so it stays traceable in the conversation.
|
|
102
|
+
|
|
103
|
+
## Boundaries
|
|
104
|
+
|
|
105
|
+
- Built-in statutory holidays cover 2026 only; after the 2027 schedule is published, upgrade the plugin or append dates with `holidays` / `DSH_OFFPEAK_HOLIDAYS`, otherwise holidays are treated as ordinary weekdays.
|
|
106
|
+
- Named presets have fixed windows; use `preset: custom` for other providers or self-hosted pricing.
|
|
107
|
+
- Only root sessions with a working directory are handled; subagent sessions are skipped so parents do not hang.
|
|
108
|
+
- A plugin restart does not cancel tasks that are already running (so installing the plugin cannot kill the current conversation); the next peak or new task follows the rules again.
|
|
109
|
+
- Manual `resume_now` grants those sessions a 10-minute grace period so they are not paused again immediately.
|
package/README.md
CHANGED
|
@@ -1,3 +1,109 @@
|
|
|
1
|
-
#
|
|
1
|
+
# 错峰调度 · 峰谷守卫(dsh-offpeak-guard)
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/dsh-offpeak-guard)
|
|
4
|
+
|
|
5
|
+
已发布到 npm:在 DSH 的插件页搜索 `dsh-offpeak-guard` 安装,或运行
|
|
6
|
+
`dsh plugin --profile <profile> add dsh-offpeak-guard`。
|
|
7
|
+
|
|
8
|
+
在 DeepSeek Harness 里按 **DeepSeek 峰谷计价** 安排工作:
|
|
9
|
+
|
|
10
|
+
- **时间识别**:按 DeepSeek 官方规则,北京时间周一至周五(不含中国法定节假日)`09:00–12:00`、`14:00–18:00` 为峰值原价;其余时间(含周末、法定节假日全天)为谷价,按 5 折计费。内置 2026 年法定节假日;后续年份可用 `holidays` 追加。
|
|
11
|
+
- **模型识别**:只有使用峰谷计价模型(默认模型名含 `deepseek`,可在 `exemptModels` 里逐项豁免)的会话才会被暂停/恢复;用其它模型的会话不受影响。
|
|
12
|
+
- **工作规划**:
|
|
13
|
+
- 峰时开始 → 暂停正在运行的任务(保留待处理队列,不丢消息);
|
|
14
|
+
- 峰时新起的任务 → 立即停住,不花峰时 token;
|
|
15
|
+
- 谷价开始 → 自动把被打断的任务交还会话继续执行;
|
|
16
|
+
- 另提供 `offpeak_plan` 工具,把「12 点再跑」这类事排进下一个谷价窗口。
|
|
17
|
+
- **界面**:输入框下方一条状态条,显示当前峰/谷、距下次切换的倒计时、当前模型,以及暂停/规划数量;左侧栏底部另有一枚常驻计时,临近切换时闪色提醒(峰价快开始闪红、谷价快开始闪绿)。
|
|
18
|
+
|
|
19
|
+

|
|
20
|
+
|
|
21
|
+
左栏计时(平时按峰/谷着色):
|
|
22
|
+
|
|
23
|
+

|
|
24
|
+
|
|
25
|
+
距峰价开始 10 分钟内闪红(示例):
|
|
26
|
+
|
|
27
|
+

|
|
28
|
+
|
|
29
|
+
## 工具
|
|
30
|
+
|
|
31
|
+
| 工具 | 用途 |
|
|
32
|
+
| --- | --- |
|
|
33
|
+
| `offpeak_status` | 当前时段、下次切换、当前模型是否适用、暂停与规划清单 |
|
|
34
|
+
| `offpeak_plan` | `add` / `list` / `remove` / `clear` 错峰规划 |
|
|
35
|
+
| `offpeak_guard` | `status` / `pause_now` / `resume_now` / `run_plans_now` 手动控制 |
|
|
36
|
+
|
|
37
|
+
## 配置
|
|
38
|
+
|
|
39
|
+
`cordis.patch.yml` 里那一行 `offpeak-guard` 的 `config`,或在 DSH 设置页的插件配置里修改(已声明 Config schema):
|
|
40
|
+
|
|
41
|
+
| 键 | 默认 | 说明 |
|
|
42
|
+
| --- | --- | --- |
|
|
43
|
+
| `enabled` | `true` | 总开关 |
|
|
44
|
+
| `preset` | `auto` | `auto` 按 provider 自动选:WorkBuddy/CodeBuddy → `workbuddy`,其余 → `deepseek-official`;也可固定为 `deepseek-official` / `workbuddy` / `custom` |
|
|
45
|
+
| `timezone` | `Asia/Shanghai` | 计价时区(DeepSeek 计费按北京时间) |
|
|
46
|
+
| `peakWeekdays` | `[1,2,3,4,5]` | 峰时适用的星期(0=周日);仅 `custom` 预设使用 |
|
|
47
|
+
| `peakWindows` | 09:00–12:00、14:00–18:00 | 峰时窗口;仅 `custom` 预设使用,官方调价时改这里 |
|
|
48
|
+
| `discount` | `0.5` | 谷价折扣,仅用于显示「5 折」;仅 `custom` 预设使用 |
|
|
49
|
+
| `holidays` | `[]` | 追加的法定节假日(`YYYY-MM-DD`),官方/WorkBuddy 预设会在内置 2026 列表上追加 |
|
|
50
|
+
| `modelPattern` | `deepseek` | 模型名/供应商名包含它即视为峰谷计价模型 |
|
|
51
|
+
| `exemptModels` | `[]` | 逐项豁免的模型 id |
|
|
52
|
+
| `pauseRunningOnPeak` | `true` | 峰时开始时暂停正在运行的任务 |
|
|
53
|
+
| `deferNewTurnsOnPeak` | `true` | 峰时新起的任务立即停住 |
|
|
54
|
+
| `resumeOnOffPeak` | `true` | 谷价开始时自动恢复 |
|
|
55
|
+
| `pollSeconds` | `15` | 检查间隔(5–300) |
|
|
56
|
+
| `resumeDelaySeconds` | `3` | 谷价开始后等几秒再恢复 |
|
|
57
|
+
| `bypassMinutes` | `10` | 手动恢复后的豁免期,避免立刻又被暂停 |
|
|
58
|
+
| `alertMinutes` | `10` | 左栏计时在距切换多少分钟内开始闪烁(0 = 不闪) |
|
|
59
|
+
|
|
60
|
+
### 环境变量
|
|
61
|
+
|
|
62
|
+
部署方可以用环境变量统一注入,优先级:**环境变量 > 配置 > 默认**。无效值会被忽略。
|
|
63
|
+
|
|
64
|
+
| 环境变量 | 例子 | 对应配置 |
|
|
65
|
+
| --- | --- | --- |
|
|
66
|
+
| `DSH_OFFPEAK_ENABLED` | `0` / `1` | `enabled` |
|
|
67
|
+
| `DSH_OFFPEAK_PRESET` | `custom` | `preset` |
|
|
68
|
+
| `DSH_OFFPEAK_TIMEZONE` | `Asia/Shanghai` | `timezone` |
|
|
69
|
+
| `DSH_OFFPEAK_PEAK_WEEKDAYS` | `1,2,3,4,5` | `peakWeekdays` |
|
|
70
|
+
| `DSH_OFFPEAK_PEAK_WINDOWS` | `09:00-12:00,14:00-18:00` | `peakWindows` |
|
|
71
|
+
| `DSH_OFFPEAK_DISCOUNT` | `0.5` | `discount` |
|
|
72
|
+
| `DSH_OFFPEAK_HOLIDAYS` | `2026-10-01,2026-10-02` | `holidays` |
|
|
73
|
+
| `DSH_OFFPEAK_MODEL_PATTERN` | `deepseek` | `modelPattern` |
|
|
74
|
+
| `DSH_OFFPEAK_EXEMPT_MODELS` | `deepseek-v4-pro,deepseek-flash` | `exemptModels` |
|
|
75
|
+
| `DSH_OFFPEAK_PAUSE_RUNNING` | `0` / `1` | `pauseRunningOnPeak` |
|
|
76
|
+
| `DSH_OFFPEAK_DEFER_NEW` | `0` / `1` | `deferNewTurnsOnPeak` |
|
|
77
|
+
| `DSH_OFFPEAK_RESUME` | `0` / `1` | `resumeOnOffPeak` |
|
|
78
|
+
| `DSH_OFFPEAK_POLL_SECONDS` | `15` | `pollSeconds` |
|
|
79
|
+
| `DSH_OFFPEAK_RESUME_DELAY_SECONDS` | `3` | `resumeDelaySeconds` |
|
|
80
|
+
| `DSH_OFFPEAK_BYPASS_MINUTES` | `10` | `bypassMinutes` |
|
|
81
|
+
| `DSH_OFFPEAK_ALERT_MINUTES` | `10` | `alertMinutes` |
|
|
82
|
+
|
|
83
|
+
### 预设
|
|
84
|
+
|
|
85
|
+
- `deepseek-official`(默认):官方工作日峰时 + 内置 2026 法定节假日(节假日全天谷价)。
|
|
86
|
+
- `workbuddy`:WorkBuddy 积分规则,峰时窗口与官方相同;按 provider 自动选择时用于标注来源。
|
|
87
|
+
- `custom`:完全使用 `peakWeekdays` / `peakWindows` / `discount` / `holidays`,适合其他供应商或自建计价。
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
## 数据
|
|
91
|
+
|
|
92
|
+
状态与快照都在 `$DSH_HOME/offpeak-guard/`:
|
|
93
|
+
|
|
94
|
+
- `state.json`:暂停中的任务、错峰规划、上次时段;
|
|
95
|
+
- `status.json`:给输入框状态条读的只读快照(同时镜像到 `~/.dsh/offpeak-guard/status.json`)。
|
|
96
|
+
|
|
97
|
+
不写用户工作区。
|
|
98
|
+
|
|
99
|
+
## 恢复是怎么做的
|
|
100
|
+
|
|
101
|
+
暂停用 `sessionController.cancel`,它只结束当前一轮、保留待处理队列;恢复用 `sessionController.prompt` 向原会话投递一条「继续上次中断处」的指令,冷会话会被自动唤醒。恢复文本会注明中断时间与被暂停的模型,方便对话里追溯。
|
|
102
|
+
|
|
103
|
+
## 边界
|
|
104
|
+
|
|
105
|
+
- 内置法定节假日目前覆盖 2026 年;2027 年安排公布后可升级插件,或用 `holidays` / `DSH_OFFPEAK_HOLIDAYS` 追加,否则节假日会被当作普通工作日。
|
|
106
|
+
- 命名预设的窗口是固定的;需要其他供应商/自建计价时用 `preset: custom` 配 `peakWindows`。
|
|
107
|
+
- 只处理有工作目录、非子代理的根会话,避免打断子代理让父会话卡住。
|
|
108
|
+
- 插件自身重启时不会主动打断正在跑的任务(避免刚安装就停掉当前对话);之后进入峰时或新起任务仍按规则处理。
|
|
109
|
+
- 手动「立即恢复」会给这些会话 10 分钟豁免,避免峰时刚恢复又被停回去。
|
package/client.js
ADDED
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 峰谷守卫的 Web 半:输入框下方的状态条。
|
|
3
|
+
* 只读展示,数据来自 Host 周期写出的 status.json,倒计时在本地按秒刷新。
|
|
4
|
+
*/
|
|
5
|
+
window.__ModuleLoader__.load({
|
|
6
|
+
id: 'dsh-offpeak-guard',
|
|
7
|
+
factory(require) {
|
|
8
|
+
const React = require('react');
|
|
9
|
+
const h = React.createElement;
|
|
10
|
+
const NS = 'offpeak-guard';
|
|
11
|
+
|
|
12
|
+
const zh = {
|
|
13
|
+
peak: '峰时原价',
|
|
14
|
+
offpeak: '谷价',
|
|
15
|
+
toPeak: '距峰时',
|
|
16
|
+
toOffpeak: '距谷价',
|
|
17
|
+
paused: '已暂停',
|
|
18
|
+
plans: '规划',
|
|
19
|
+
title: 'DeepSeek 峰谷计价',
|
|
20
|
+
windows: '峰时:工作日 09:00–12:00、14:00–18:00',
|
|
21
|
+
offpeakNote: '谷价:其余时间(含周末与午间),按 5 折计费',
|
|
22
|
+
model: '当前模型',
|
|
23
|
+
guard: '守卫',
|
|
24
|
+
on: '开',
|
|
25
|
+
off: '关',
|
|
26
|
+
pausedNote: '被峰时暂停的任务会在谷价开始时自动恢复',
|
|
27
|
+
plansNote: '排定的错峰任务会在谷价时段自动执行',
|
|
28
|
+
unavailable: '峰谷状态暂不可用',
|
|
29
|
+
peakShort: '峰',
|
|
30
|
+
valleyShort: '谷',
|
|
31
|
+
toPeakShort: '距峰',
|
|
32
|
+
toOffpeakShort: '距谷',
|
|
33
|
+
clockHint: '临近切换时闪烁:红色 = 峰价快开始,绿色 = 谷价快开始',
|
|
34
|
+
clockTitle: '峰谷计时',
|
|
35
|
+
nextSwitch: '下次切换',
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const en = {
|
|
39
|
+
peak: 'Peak price',
|
|
40
|
+
offpeak: 'Off-peak',
|
|
41
|
+
toPeak: 'to peak',
|
|
42
|
+
toOffpeak: 'to off-peak',
|
|
43
|
+
paused: 'paused',
|
|
44
|
+
plans: 'planned',
|
|
45
|
+
title: 'DeepSeek peak/off-peak pricing',
|
|
46
|
+
windows: 'Peak: weekdays 09:00–12:00 and 14:00–18:00',
|
|
47
|
+
offpeakNote: 'Off-peak: all other hours (including weekends and lunch), billed at 50%',
|
|
48
|
+
model: 'Model',
|
|
49
|
+
guard: 'Guard',
|
|
50
|
+
on: 'on',
|
|
51
|
+
off: 'off',
|
|
52
|
+
pausedNote: 'Tasks paused at peak resume automatically when off-peak starts',
|
|
53
|
+
plansNote: 'Planned tasks run automatically during off-peak',
|
|
54
|
+
unavailable: 'Off-peak status unavailable',
|
|
55
|
+
peakShort: 'Peak',
|
|
56
|
+
valleyShort: 'Valley',
|
|
57
|
+
toPeakShort: 'to peak',
|
|
58
|
+
toOffpeakShort: 'to off-peak',
|
|
59
|
+
clockHint: 'Blink near the switch: red = peak starts soon, green = off-peak starts soon',
|
|
60
|
+
clockTitle: 'Peak/off-peak timer',
|
|
61
|
+
nextSwitch: 'next switch',
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
function fmtDuration(ms) {
|
|
65
|
+
const totalMinutes = Math.floor(Math.max(0, ms) / 60000);
|
|
66
|
+
if (totalMinutes < 1) return '不到1分';
|
|
67
|
+
const days = Math.floor(totalMinutes / 1440);
|
|
68
|
+
const hours = Math.floor((totalMinutes % 1440) / 60);
|
|
69
|
+
const minutes = totalMinutes % 60;
|
|
70
|
+
if (days > 0) return hours > 0 ? `${days}天${hours}小时` : `${days}天`;
|
|
71
|
+
if (hours > 0) return minutes > 0 ? `${hours}小时${minutes}分` : `${hours}小时`;
|
|
72
|
+
return `${minutes}分`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function useLocale(ctx) {
|
|
76
|
+
const [, force] = React.useState(0);
|
|
77
|
+
React.useEffect(() => {
|
|
78
|
+
if (!ctx.locale || typeof ctx.locale.subscribe !== 'function') return undefined;
|
|
79
|
+
const off = ctx.locale.subscribe(() => force((value) => value + 1));
|
|
80
|
+
return () => {
|
|
81
|
+
if (typeof off === 'function') {
|
|
82
|
+
try {
|
|
83
|
+
off();
|
|
84
|
+
} catch {
|
|
85
|
+
/* ignore */
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
}, []);
|
|
90
|
+
return ctx.locale ? ctx.locale.bind(NS) : (key) => key;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function snapshotTextOf(result) {
|
|
94
|
+
if (result?.ok === false) throw new Error(result?.error?.message ?? 'status read failed');
|
|
95
|
+
const page = result?.value ?? result;
|
|
96
|
+
const text = typeof page === 'string' ? page : page?.text;
|
|
97
|
+
if (typeof text !== 'string' || text.length === 0) throw new Error('status file is empty');
|
|
98
|
+
return text;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function useSnapshot(ctx, sessionId) {
|
|
102
|
+
const [snapshot, setSnapshot] = React.useState(null);
|
|
103
|
+
const [error, setError] = React.useState(null);
|
|
104
|
+
const busy = React.useRef(false);
|
|
105
|
+
const mounted = React.useRef(true);
|
|
106
|
+
|
|
107
|
+
const load = React.useCallback(async () => {
|
|
108
|
+
if (busy.current) return;
|
|
109
|
+
busy.current = true;
|
|
110
|
+
try {
|
|
111
|
+
const remote = ctx.remote;
|
|
112
|
+
if (!remote || typeof remote.workspaceFiles?.read !== 'function') {
|
|
113
|
+
throw new Error('remote.workspaceFiles 不可用');
|
|
114
|
+
}
|
|
115
|
+
let target = typeof sessionId === 'string' && sessionId.length > 0 ? sessionId : null;
|
|
116
|
+
if (target === null) {
|
|
117
|
+
if (typeof remote.session?.list !== 'function') throw new Error('remote.session 不可用');
|
|
118
|
+
const listed = await remote.session.list({});
|
|
119
|
+
const items = listed?.value?.items ?? listed?.items ?? [];
|
|
120
|
+
if (!Array.isArray(items) || items.length === 0) return;
|
|
121
|
+
const sorted = [...items].sort(
|
|
122
|
+
(left, right) => Number(right.updatedAt ?? right.lastActivityAt ?? 0) - Number(left.updatedAt ?? left.lastActivityAt ?? 0),
|
|
123
|
+
);
|
|
124
|
+
target = sorted[0]?.sessionId ?? sorted[0]?.id ?? null;
|
|
125
|
+
}
|
|
126
|
+
const home = remote.$host?.home;
|
|
127
|
+
if (typeof target !== 'string' || target.length === 0) throw new Error('没有可用的会话');
|
|
128
|
+
if (typeof home !== 'string' || home.length === 0) throw new Error('读不到 Harness 主目录');
|
|
129
|
+
const result = await remote.workspaceFiles.read(target, `${home}/.dsh/offpeak-guard/status.json`, {});
|
|
130
|
+
const parsed = JSON.parse(snapshotTextOf(result));
|
|
131
|
+
if (mounted.current && parsed !== null && typeof parsed === 'object') {
|
|
132
|
+
setSnapshot(parsed);
|
|
133
|
+
setError(null);
|
|
134
|
+
}
|
|
135
|
+
} catch (caught) {
|
|
136
|
+
if (mounted.current) setError(String(caught?.message ?? caught));
|
|
137
|
+
} finally {
|
|
138
|
+
busy.current = false;
|
|
139
|
+
}
|
|
140
|
+
}, [sessionId]);
|
|
141
|
+
|
|
142
|
+
React.useEffect(() => {
|
|
143
|
+
mounted.current = true;
|
|
144
|
+
void load();
|
|
145
|
+
const timer = setInterval(() => void load(), 30000);
|
|
146
|
+
return () => {
|
|
147
|
+
mounted.current = false;
|
|
148
|
+
clearInterval(timer);
|
|
149
|
+
};
|
|
150
|
+
}, [load]);
|
|
151
|
+
|
|
152
|
+
return { snapshot, error };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function makeStrip(pluginCtx) {
|
|
156
|
+
return function Strip(props) {
|
|
157
|
+
const ctx = pluginCtx;
|
|
158
|
+
const t = useLocale(ctx);
|
|
159
|
+
const { snapshot, error } = useSnapshot(ctx, props?.sessionId);
|
|
160
|
+
const [, force] = React.useState(0);
|
|
161
|
+
|
|
162
|
+
React.useEffect(() => {
|
|
163
|
+
const timer = setInterval(() => force((value) => value + 1), 1000);
|
|
164
|
+
return () => clearInterval(timer);
|
|
165
|
+
}, []);
|
|
166
|
+
|
|
167
|
+
if (snapshot === null) {
|
|
168
|
+
if (error === null) return h('div', { style: { textAlign: 'center', fontSize: 11, opacity: 0.4, padding: '2px 0 4px' } }, '…');
|
|
169
|
+
return h('div', { title: error, style: { textAlign: 'center', fontSize: 11, opacity: 0.5, padding: '2px 0 4px' } }, t('unavailable'));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const phase = snapshot.phase === 'peak' ? 'peak' : 'offpeak';
|
|
173
|
+
const nextAt = Number(snapshot.next?.at);
|
|
174
|
+
const remaining = Number.isFinite(nextAt) ? Math.max(0, nextAt - Date.now()) : 0;
|
|
175
|
+
const ownModel =
|
|
176
|
+
props?.sessionId && snapshot.sessions && typeof snapshot.sessions === 'object'
|
|
177
|
+
? snapshot.sessions[props.sessionId]
|
|
178
|
+
: null;
|
|
179
|
+
const activeModel = ownModel ?? snapshot.model ?? {};
|
|
180
|
+
const modelId =
|
|
181
|
+
typeof activeModel.label === 'string' && activeModel.label !== ''
|
|
182
|
+
? activeModel.label
|
|
183
|
+
: typeof activeModel.id === 'string'
|
|
184
|
+
? activeModel.id
|
|
185
|
+
: '';
|
|
186
|
+
const paused = Number(snapshot.counts?.interrupted ?? 0);
|
|
187
|
+
const plans = Number(snapshot.counts?.plans ?? 0);
|
|
188
|
+
const guardOn = snapshot.guard?.enabled === true;
|
|
189
|
+
const nextLabel = phase === 'peak' ? t('toOffpeak') : t('toPeak');
|
|
190
|
+
|
|
191
|
+
const tip = [
|
|
192
|
+
t('title'),
|
|
193
|
+
t('windows'),
|
|
194
|
+
t('offpeakNote'),
|
|
195
|
+
`${t('model')}: ${modelId || '—'}${activeModel.peakPriced ? ` (${phase === 'peak' ? t('peak') : t('offpeak')})` : ''}`,
|
|
196
|
+
`${t('guard')}: ${guardOn ? t('on') : t('off')}`,
|
|
197
|
+
paused > 0 ? `${t('paused')}: ${paused} — ${t('pausedNote')}` : '',
|
|
198
|
+
plans > 0 ? `${t('plans')}: ${plans} — ${t('plansNote')}` : '',
|
|
199
|
+
]
|
|
200
|
+
.filter(Boolean)
|
|
201
|
+
.join('\n');
|
|
202
|
+
|
|
203
|
+
const children = [
|
|
204
|
+
h('span', {
|
|
205
|
+
key: 'dot',
|
|
206
|
+
'aria-hidden': true,
|
|
207
|
+
style: {
|
|
208
|
+
width: 7,
|
|
209
|
+
height: 7,
|
|
210
|
+
borderRadius: '50%',
|
|
211
|
+
flex: '0 0 auto',
|
|
212
|
+
backgroundColor: phase === 'peak' ? '#e0a33e' : '#3f9d7a',
|
|
213
|
+
},
|
|
214
|
+
}),
|
|
215
|
+
h('span', { key: 'phase', style: { fontWeight: 600 } }, t(phase)),
|
|
216
|
+
h('span', { key: 'next' }, `${nextLabel} ${fmtDuration(remaining)}`),
|
|
217
|
+
];
|
|
218
|
+
if (modelId !== '') children.push(h('span', { key: 'model', style: { opacity: 0.75 } }, `· ${modelId}`));
|
|
219
|
+
if (paused > 0) children.push(h('span', { key: 'paused', style: { color: '#e0a33e' } }, `· ${t('paused')} ${paused}`));
|
|
220
|
+
if (plans > 0) children.push(h('span', { key: 'plans' }, `· ${t('plans')} ${plans}`));
|
|
221
|
+
|
|
222
|
+
return h(
|
|
223
|
+
'div',
|
|
224
|
+
{
|
|
225
|
+
title: tip,
|
|
226
|
+
style: {
|
|
227
|
+
display: 'flex',
|
|
228
|
+
alignItems: 'center',
|
|
229
|
+
justifyContent: 'center',
|
|
230
|
+
gap: 8,
|
|
231
|
+
padding: '2px 6px 6px',
|
|
232
|
+
fontSize: 11,
|
|
233
|
+
lineHeight: 1.4,
|
|
234
|
+
color: 'var(--dsw-alias-label-secondary, rgba(115,119,128,0.92))',
|
|
235
|
+
userSelect: 'none',
|
|
236
|
+
},
|
|
237
|
+
},
|
|
238
|
+
children,
|
|
239
|
+
);
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function fmtClock(ms) {
|
|
244
|
+
const total = Math.max(0, Math.floor(ms / 1000));
|
|
245
|
+
const days = Math.floor(total / 86400);
|
|
246
|
+
const hours = Math.floor((total % 86400) / 3600);
|
|
247
|
+
const minutes = Math.floor((total % 3600) / 60);
|
|
248
|
+
const seconds = total % 60;
|
|
249
|
+
const pad = (value) => String(value).padStart(2, '0');
|
|
250
|
+
if (days > 0) return `${days}天${hours}时`;
|
|
251
|
+
if (hours > 0) return `${hours}:${pad(minutes)}:${pad(seconds)}`;
|
|
252
|
+
return `${minutes}:${pad(seconds)}`;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** 平时只显示到分钟(紧凑,不挤占左栏其它按钮);临近切换时显示秒。 */
|
|
256
|
+
function fmtClockCompact(ms, withSeconds) {
|
|
257
|
+
const total = Math.max(0, Math.floor(ms / 1000));
|
|
258
|
+
const days = Math.floor(total / 86400);
|
|
259
|
+
const hours = Math.floor((total % 86400) / 3600);
|
|
260
|
+
const minutes = Math.floor((total % 3600) / 60);
|
|
261
|
+
const seconds = total % 60;
|
|
262
|
+
const pad = (value) => String(value).padStart(2, '0');
|
|
263
|
+
if (days > 0) return withSeconds ? `${days}天${hours}时` : `${days}天${hours}时`;
|
|
264
|
+
if (hours > 0) return withSeconds ? `${hours}:${pad(minutes)}:${pad(seconds)}` : `${hours}:${pad(minutes)}`;
|
|
265
|
+
return `${minutes}:${pad(seconds)}`;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const clockStyle = `
|
|
269
|
+
.opg-clock{box-sizing:border-box;display:inline-flex;flex:0 0 auto;align-items:center;gap:5px;width:auto;max-width:100%;min-height:32px;margin-left:auto;padding:4px 8px;border-radius:8px;color:var(--dsw-alias-label-primary,#252936);font:500 12px/18px var(--dsw-font-family,system-ui,sans-serif);white-space:nowrap;-webkit-app-region:no-drag;}
|
|
270
|
+
.opg-clock[data-compact="true"]{width:32px;min-height:32px;justify-content:center;padding:6px;gap:0;}
|
|
271
|
+
.opg-clock-dot{flex:none;width:7px;height:7px;border-radius:50%;background:currentColor;}
|
|
272
|
+
.opg-clock-state{flex:none;}
|
|
273
|
+
.opg-clock-time{flex:none;font-variant-numeric:tabular-nums;letter-spacing:.2px;}
|
|
274
|
+
.opg-clock[data-tone="peak"]{color:#e0554e;}
|
|
275
|
+
.opg-clock[data-tone="valley"]{color:#3f9d7a;}
|
|
276
|
+
.opg-clock[data-blink="red"]{animation:opg-blink-red .9s ease-in-out infinite;}
|
|
277
|
+
.opg-clock[data-blink="green"]{animation:opg-blink-green .9s ease-in-out infinite;}
|
|
278
|
+
@keyframes opg-blink-red{0%,100%{color:#e0554e;opacity:1;}50%{color:#ff8f8a;opacity:.4;}}
|
|
279
|
+
@keyframes opg-blink-green{0%,100%{color:#3f9d7a;opacity:1;}50%{color:#69d3aa;opacity:.4;}}
|
|
280
|
+
@media (prefers-reduced-motion: reduce){.opg-clock[data-blink]{animation:none;}}
|
|
281
|
+
`;
|
|
282
|
+
|
|
283
|
+
function ClockIcon() {
|
|
284
|
+
return h(
|
|
285
|
+
'svg',
|
|
286
|
+
{
|
|
287
|
+
width: 16,
|
|
288
|
+
height: 16,
|
|
289
|
+
viewBox: '0 0 24 24',
|
|
290
|
+
fill: 'none',
|
|
291
|
+
stroke: 'currentColor',
|
|
292
|
+
strokeWidth: 1.8,
|
|
293
|
+
strokeLinecap: 'round',
|
|
294
|
+
strokeLinejoin: 'round',
|
|
295
|
+
'aria-hidden': true,
|
|
296
|
+
style: { flex: 'none' },
|
|
297
|
+
},
|
|
298
|
+
[h('circle', { key: 'c', cx: 12, cy: 12, r: 8.5 }), h('path', { key: 'h', d: 'M12 7.5V12l3 1.8' })],
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function makeClock(pluginCtx) {
|
|
303
|
+
return function Clock(props) {
|
|
304
|
+
const t = useLocale(pluginCtx);
|
|
305
|
+
const { snapshot } = useSnapshot(pluginCtx, null);
|
|
306
|
+
const [, force] = React.useState(0);
|
|
307
|
+
React.useEffect(() => {
|
|
308
|
+
const timer = setInterval(() => force((value) => value + 1), 1000);
|
|
309
|
+
return () => clearInterval(timer);
|
|
310
|
+
}, []);
|
|
311
|
+
if (snapshot === null) return null;
|
|
312
|
+
|
|
313
|
+
const phase = snapshot.phase === 'peak' ? 'peak' : 'offpeak';
|
|
314
|
+
const nextAt = Number(snapshot.next?.at);
|
|
315
|
+
const remaining = Number.isFinite(nextAt) ? Math.max(0, nextAt - Date.now()) : 0;
|
|
316
|
+
const alertMs = Math.max(0, Number(snapshot.alertMinutes ?? 10)) * 60000;
|
|
317
|
+
const near = alertMs > 0 && remaining > 0 && remaining <= alertMs;
|
|
318
|
+
const blink = near ? (phase === 'offpeak' ? 'red' : 'green') : null;
|
|
319
|
+
const tone = phase === 'peak' ? 'peak' : 'valley';
|
|
320
|
+
const wide = props?.wide !== false;
|
|
321
|
+
const stateText = phase === 'peak' ? t('peakShort') : t('valleyShort');
|
|
322
|
+
const dirText = phase === 'peak' ? t('toOffpeakShort') : t('toPeakShort');
|
|
323
|
+
const modelId =
|
|
324
|
+
typeof snapshot.model?.label === 'string' && snapshot.model.label !== ''
|
|
325
|
+
? snapshot.model.label
|
|
326
|
+
: typeof snapshot.model?.id === 'string'
|
|
327
|
+
? snapshot.model.id
|
|
328
|
+
: '';
|
|
329
|
+
// 悬浮说明保持稳定:不能带每秒变化的倒计时,否则原生 tooltip 会被
|
|
330
|
+
// 逐秒重置而看起来一直在闪。倒计时只放在可见文本里。
|
|
331
|
+
const tip = [
|
|
332
|
+
t('clockTitle'),
|
|
333
|
+
`${stateText} · ${t('nextSwitch')} ${snapshot.next?.label ?? '--:--'}`,
|
|
334
|
+
t('windows'),
|
|
335
|
+
t('offpeakNote'),
|
|
336
|
+
`${t('model')}: ${modelId || '—'}`,
|
|
337
|
+
t('clockHint'),
|
|
338
|
+
]
|
|
339
|
+
.filter(Boolean)
|
|
340
|
+
.join('\n');
|
|
341
|
+
|
|
342
|
+
const inner = wide
|
|
343
|
+
? [
|
|
344
|
+
h('span', { key: 'dot', className: 'opg-clock-dot' }),
|
|
345
|
+
h('span', { key: 'state', className: 'opg-clock-state' }, stateText),
|
|
346
|
+
h('span', { key: 'time', className: 'opg-clock-time' }, fmtClockCompact(remaining, near)),
|
|
347
|
+
]
|
|
348
|
+
: [ClockIcon()];
|
|
349
|
+
|
|
350
|
+
return h(
|
|
351
|
+
'div',
|
|
352
|
+
{
|
|
353
|
+
className: 'opg-clock',
|
|
354
|
+
role: 'status',
|
|
355
|
+
title: tip,
|
|
356
|
+
'aria-label': `${stateText} ${dirText} ${fmtClock(remaining)}`,
|
|
357
|
+
'data-tone': tone,
|
|
358
|
+
'data-blink': blink ?? undefined,
|
|
359
|
+
'data-compact': wide ? 'false' : 'true',
|
|
360
|
+
},
|
|
361
|
+
[h('style', { key: 'style' }, clockStyle)].concat(inner),
|
|
362
|
+
);
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
return {
|
|
367
|
+
inject: ['slots', 'locale', 'remote', 'remote.session', 'remote.workspaceFiles'],
|
|
368
|
+
apply(ctx) {
|
|
369
|
+
const Strip = makeStrip(ctx);
|
|
370
|
+
const Clock = makeClock(ctx);
|
|
371
|
+
ctx.effect(() => ctx.locale.register(NS, { zh, en }));
|
|
372
|
+
ctx.slots.inject('conversation.composer.dock', () =>
|
|
373
|
+
ctx.slots.register(
|
|
374
|
+
{
|
|
375
|
+
name: 'conversation.composer.dock',
|
|
376
|
+
id: 'offpeak-guard',
|
|
377
|
+
order: 90,
|
|
378
|
+
},
|
|
379
|
+
Strip,
|
|
380
|
+
),
|
|
381
|
+
);
|
|
382
|
+
ctx.slots.inject('sidebar.footer.action', () =>
|
|
383
|
+
ctx.slots.register(
|
|
384
|
+
{
|
|
385
|
+
name: 'sidebar.footer.action',
|
|
386
|
+
id: 'offpeak-guard-clock',
|
|
387
|
+
order: 45,
|
|
388
|
+
},
|
|
389
|
+
Clock,
|
|
390
|
+
),
|
|
391
|
+
);
|
|
392
|
+
},
|
|
393
|
+
};
|
|
394
|
+
},
|
|
395
|
+
});
|