dsh-offpeak-guard 0.0.0-stage → 0.1.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 +11 -0
- package/LICENSE +21 -0
- package/README.en.md +70 -0
- package/README.md +69 -2
- package/client.js +395 -0
- package/cordis.patch.yml +26 -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 +47 -0
- package/lib/guard.js +641 -0
- package/lib/peak.js +287 -0
- package/lib/store.js +121 -0
- package/lib/tools.js +203 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +69 -4
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0 — 2026-11-05
|
|
4
|
+
|
|
5
|
+
- 首个公开版本。
|
|
6
|
+
- 时间识别:工作日 `09:00–12:00`、`14:00–18:00` 峰时,其余时间谷价 5 折(可配置窗口、星期、时区与折扣)。
|
|
7
|
+
- 模型识别:按会话真实请求头判断是否峰谷计价模型,支持逐模型豁免。
|
|
8
|
+
- 峰时自动暂停运行中的任务、停住峰时新任务;谷价开始自动恢复被中断的任务(保留待处理队列)。
|
|
9
|
+
- 错峰工作规划:`offpeak_plan` 把任务排进下一个谷价窗口,谷价时段自动执行。
|
|
10
|
+
- 界面:输入框下方峰谷状态条;左栏底部紧凑计时(临近峰价闪红、临近谷价闪绿),悬浮说明保持稳定不闪。
|
|
11
|
+
- 手动控制:`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,70 @@
|
|
|
1
|
+
# Off-peak guard (dsh-offpeak-guard)
|
|
2
|
+
|
|
3
|
+
Schedule work around **DeepSeek peak/off-peak pricing** inside DeepSeek Harness:
|
|
4
|
+
|
|
5
|
+
- **Time awareness**: weekdays `09:00–12:00` and `14:00–18:00` are peak price; every other hour (lunch, night, weekends) is off-peak at 50% (time zone `Asia/Shanghai` by default).
|
|
6
|
+
- **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.
|
|
7
|
+
- **Work planning**:
|
|
8
|
+
- peak starts → running tasks are paused (the pending inbox is kept, no message is lost);
|
|
9
|
+
- a task started during peak → stopped immediately, no peak-priced tokens spent;
|
|
10
|
+
- off-peak starts → interrupted tasks are handed back to their session automatically;
|
|
11
|
+
- the `offpeak_plan` tool queues "run this at noon" work for the next off-peak window.
|
|
12
|
+
- **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.
|
|
13
|
+
|
|
14
|
+

|
|
15
|
+
|
|
16
|
+
Sidebar clock, colored by the current window:
|
|
17
|
+
|
|
18
|
+

|
|
19
|
+
|
|
20
|
+
Red blink within the alert window before peak (sample):
|
|
21
|
+
|
|
22
|
+

|
|
23
|
+
|
|
24
|
+
## Tools
|
|
25
|
+
|
|
26
|
+
| Tool | Purpose |
|
|
27
|
+
| --- | --- |
|
|
28
|
+
| `offpeak_status` | Current window, next switch, whether the current model is peak-priced, paused/planned lists |
|
|
29
|
+
| `offpeak_plan` | `add` / `list` / `remove` / `clear` off-peak plans |
|
|
30
|
+
| `offpeak_guard` | `status` / `pause_now` / `resume_now` / `run_plans_now` manual control |
|
|
31
|
+
|
|
32
|
+
## Configuration
|
|
33
|
+
|
|
34
|
+
The `config` of the `offpeak-guard` row in `cordis.patch.yml`:
|
|
35
|
+
|
|
36
|
+
| Key | Default | Meaning |
|
|
37
|
+
| --- | --- | --- |
|
|
38
|
+
| `enabled` | `true` | Master switch |
|
|
39
|
+
| `timezone` | `Asia/Shanghai` | Pricing time zone |
|
|
40
|
+
| `peakWeekdays` | `[1,2,3,4,5]` | Weekdays that have peak windows (0 = Sunday) |
|
|
41
|
+
| `peakWindows` | 09:00–12:00, 14:00–18:00 | Peak windows; edit these when DeepSeek changes prices |
|
|
42
|
+
| `discount` | `0.5` | Off-peak discount, used for the "50%" label |
|
|
43
|
+
| `modelPattern` | `deepseek` | Model/provider name containing it counts as peak-priced |
|
|
44
|
+
| `exemptModels` | `[]` | Model ids exempted one by one |
|
|
45
|
+
| `pauseRunningOnPeak` | `true` | Pause running tasks when peak starts |
|
|
46
|
+
| `deferNewTurnsOnPeak` | `true` | Stop tasks started during peak immediately |
|
|
47
|
+
| `resumeOnOffPeak` | `true` | Resume paused tasks when off-peak starts |
|
|
48
|
+
| `pollSeconds` | `15` | Check interval (5–300) |
|
|
49
|
+
| `resumeDelaySeconds` | `3` | Wait a few seconds after off-peak starts before resuming |
|
|
50
|
+
| `bypassMinutes` | `10` | Grace period after a manual resume |
|
|
51
|
+
| `alertMinutes` | `10` | Blink the sidebar clock within this many minutes of a switch (0 = never) |
|
|
52
|
+
|
|
53
|
+
## Data
|
|
54
|
+
|
|
55
|
+
State and snapshots live in `$DSH_HOME/offpeak-guard/`:
|
|
56
|
+
|
|
57
|
+
- `state.json`: paused tasks, off-peak plans, last phase;
|
|
58
|
+
- `status.json`: read-only snapshot for the UI (mirrored to `~/.dsh/offpeak-guard/status.json`).
|
|
59
|
+
|
|
60
|
+
Nothing is written into user workspaces.
|
|
61
|
+
|
|
62
|
+
## How resuming works
|
|
63
|
+
|
|
64
|
+
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.
|
|
65
|
+
|
|
66
|
+
## Boundaries
|
|
67
|
+
|
|
68
|
+
- Only root sessions with a working directory are handled; subagent sessions are skipped so parents do not hang.
|
|
69
|
+
- 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.
|
|
70
|
+
- 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,70 @@
|
|
|
1
|
-
#
|
|
1
|
+
# 错峰调度 · 峰谷守卫(dsh-offpeak-guard)
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
在 DeepSeek Harness 里按 **DeepSeek 峰谷计价** 安排工作:
|
|
4
|
+
|
|
5
|
+
- **时间识别**:工作日 `09:00–12:00`、`14:00–18:00` 为峰值原价;其余时间(含午间、夜间、周末)为谷价,按 5 折计费(默认 `Asia/Shanghai`)。
|
|
6
|
+
- **模型识别**:只有使用峰谷计价模型(默认模型名含 `deepseek`,可在 `exemptModels` 里逐项豁免)的会话才会被暂停/恢复;用其它模型的会话不受影响。
|
|
7
|
+
- **工作规划**:
|
|
8
|
+
- 峰时开始 → 暂停正在运行的任务(保留待处理队列,不丢消息);
|
|
9
|
+
- 峰时新起的任务 → 立即停住,不花峰时 token;
|
|
10
|
+
- 谷价开始 → 自动把被打断的任务交还会话继续执行;
|
|
11
|
+
- 另提供 `offpeak_plan` 工具,把「12 点再跑」这类事排进下一个谷价窗口。
|
|
12
|
+
- **界面**:输入框下方一条状态条,显示当前峰/谷、距下次切换的倒计时、当前模型,以及暂停/规划数量;左侧栏底部另有一枚常驻计时,临近切换时闪色提醒(峰价快开始闪红、谷价快开始闪绿)。
|
|
13
|
+
|
|
14
|
+

|
|
15
|
+
|
|
16
|
+
左栏计时(平时按峰/谷着色):
|
|
17
|
+
|
|
18
|
+

|
|
19
|
+
|
|
20
|
+
距峰价开始 10 分钟内闪红(示例):
|
|
21
|
+
|
|
22
|
+

|
|
23
|
+
|
|
24
|
+
## 工具
|
|
25
|
+
|
|
26
|
+
| 工具 | 用途 |
|
|
27
|
+
| --- | --- |
|
|
28
|
+
| `offpeak_status` | 当前时段、下次切换、当前模型是否适用、暂停与规划清单 |
|
|
29
|
+
| `offpeak_plan` | `add` / `list` / `remove` / `clear` 错峰规划 |
|
|
30
|
+
| `offpeak_guard` | `status` / `pause_now` / `resume_now` / `run_plans_now` 手动控制 |
|
|
31
|
+
|
|
32
|
+
## 配置
|
|
33
|
+
|
|
34
|
+
`cordis.patch.yml` 里那一行 `offpeak-guard` 的 `config`:
|
|
35
|
+
|
|
36
|
+
| 键 | 默认 | 说明 |
|
|
37
|
+
| --- | --- | --- |
|
|
38
|
+
| `enabled` | `true` | 总开关 |
|
|
39
|
+
| `timezone` | `Asia/Shanghai` | 计价时区 |
|
|
40
|
+
| `peakWeekdays` | `[1,2,3,4,5]` | 峰时适用的星期(0=周日) |
|
|
41
|
+
| `peakWindows` | 09:00–12:00、14:00–18:00 | 峰时窗口;官方调价时改这里 |
|
|
42
|
+
| `discount` | `0.5` | 谷价折扣,仅用于显示「5 折」 |
|
|
43
|
+
| `modelPattern` | `deepseek` | 模型名/供应商名包含它即视为峰谷计价模型 |
|
|
44
|
+
| `exemptModels` | `[]` | 逐项豁免的模型 id |
|
|
45
|
+
| `pauseRunningOnPeak` | `true` | 峰时开始时暂停正在运行的任务 |
|
|
46
|
+
| `deferNewTurnsOnPeak` | `true` | 峰时新起的任务立即停住 |
|
|
47
|
+
| `resumeOnOffPeak` | `true` | 谷价开始时自动恢复 |
|
|
48
|
+
| `pollSeconds` | `15` | 检查间隔(5–300) |
|
|
49
|
+
| `resumeDelaySeconds` | `3` | 谷价开始后等几秒再恢复 |
|
|
50
|
+
| `bypassMinutes` | `10` | 手动恢复后的豁免期,避免立刻又被暂停 |
|
|
51
|
+
| `alertMinutes` | `10` | 左栏计时在距切换多少分钟内开始闪烁(0 = 不闪) |
|
|
52
|
+
|
|
53
|
+
## 数据
|
|
54
|
+
|
|
55
|
+
状态与快照都在 `$DSH_HOME/offpeak-guard/`:
|
|
56
|
+
|
|
57
|
+
- `state.json`:暂停中的任务、错峰规划、上次时段;
|
|
58
|
+
- `status.json`:给输入框状态条读的只读快照(同时镜像到 `~/.dsh/offpeak-guard/status.json`)。
|
|
59
|
+
|
|
60
|
+
不写用户工作区。
|
|
61
|
+
|
|
62
|
+
## 恢复是怎么做的
|
|
63
|
+
|
|
64
|
+
暂停用 `sessionController.cancel`,它只结束当前一轮、保留待处理队列;恢复用 `sessionController.prompt` 向原会话投递一条「继续上次中断处」的指令,冷会话会被自动唤醒。恢复文本会注明中断时间与被暂停的模型,方便对话里追溯。
|
|
65
|
+
|
|
66
|
+
## 边界
|
|
67
|
+
|
|
68
|
+
- 只处理有工作目录、非子代理的根会话,避免打断子代理让父会话卡住。
|
|
69
|
+
- 插件自身重启时不会主动打断正在跑的任务(避免刚安装就停掉当前对话);之后进入峰时或新起任务仍按规则处理。
|
|
70
|
+
- 手动「立即恢复」会给这些会话 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
|
+
});
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# dsh-offpeak-guard:DeepSeek 峰谷计价守卫。
|
|
2
|
+
# 规则来自 DeepSeek 夜间折扣:工作日 09:00–12:00、14:00–18:00 为峰值原价,
|
|
3
|
+
# 其余时间(含午间、夜间与周末)为谷价,按 5 折计费。
|
|
4
|
+
# 官方调价时只改这里的 peakWindows / discount 即可。
|
|
5
|
+
- insert:
|
|
6
|
+
- id: offpeak-guard
|
|
7
|
+
name: 'dsh-offpeak-guard'
|
|
8
|
+
config:
|
|
9
|
+
enabled: true
|
|
10
|
+
timezone: 'Asia/Shanghai'
|
|
11
|
+
peakWeekdays: [1, 2, 3, 4, 5]
|
|
12
|
+
peakWindows:
|
|
13
|
+
- start: '09:00'
|
|
14
|
+
end: '12:00'
|
|
15
|
+
- start: '14:00'
|
|
16
|
+
end: '18:00'
|
|
17
|
+
discount: 0.5
|
|
18
|
+
modelPattern: 'deepseek'
|
|
19
|
+
exemptModels: []
|
|
20
|
+
pauseRunningOnPeak: true
|
|
21
|
+
deferNewTurnsOnPeak: true
|
|
22
|
+
resumeOnOffPeak: true
|
|
23
|
+
pollSeconds: 15
|
|
24
|
+
resumeDelaySeconds: 3
|
|
25
|
+
bypassMinutes: 10
|
|
26
|
+
alertMinutes: 10
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/icon.svg
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="Off-peak guard">
|
|
2
|
+
<defs>
|
|
3
|
+
<linearGradient id="offpeak-arc" x1="8" y1="8" x2="56" y2="56" gradientUnits="userSpaceOnUse">
|
|
4
|
+
<stop offset="0" stop-color="#e0a33e"/>
|
|
5
|
+
<stop offset="1" stop-color="#3f9d7a"/>
|
|
6
|
+
</linearGradient>
|
|
7
|
+
</defs>
|
|
8
|
+
<rect x="2" y="2" width="60" height="60" rx="14" fill="#1f2329"/>
|
|
9
|
+
<circle cx="32" cy="33" r="16.5" fill="none" stroke="url(#offpeak-arc)" stroke-width="3"/>
|
|
10
|
+
<path d="M32 33V23" fill="none" stroke="#f4f5f7" stroke-width="3" stroke-linecap="round"/>
|
|
11
|
+
<path d="M32 33l7.5 4.6" fill="none" stroke="#f4f5f7" stroke-width="3" stroke-linecap="round"/>
|
|
12
|
+
<circle cx="32" cy="33" r="2.6" fill="url(#offpeak-arc)"/>
|
|
13
|
+
<path d="M46.5 14.5l1.6 3.4 3.4 1.6-3.4 1.6-1.6 3.4-1.6-3.4-3.4-1.6 3.4-1.6z" fill="#e0a33e"/>
|
|
14
|
+
<path d="M17 49.5a5.4 5.4 0 0 1 0-10.8 6.6 6.6 0 0 0 0 10.8z" fill="#3f9d7a"/>
|
|
15
|
+
</svg>
|
package/index.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-offpeak-guard 入口(Host 半)。
|
|
3
|
+
*
|
|
4
|
+
* 三件事:
|
|
5
|
+
* 1. 时间识别 —— 按 DeepSeek 夜间折扣规则判断现在处于峰值原价还是谷价;
|
|
6
|
+
* 2. 模型识别 —— 只有使用峰谷计价模型的会话才会被暂停/恢复;
|
|
7
|
+
* 3. 工作规划 —— 峰时暂停运行中的任务、延后新任务,谷价开始时自动恢复;
|
|
8
|
+
* 另提供 offpeak_plan 把稍后要做的事排进谷价时段。
|
|
9
|
+
*/
|
|
10
|
+
import { createGuard } from './lib/guard.js';
|
|
11
|
+
import { createOffpeakTools } from './lib/tools.js';
|
|
12
|
+
|
|
13
|
+
export const name = 'offpeak-guard';
|
|
14
|
+
export const inject = ['tools'];
|
|
15
|
+
|
|
16
|
+
export function apply(ctx, config = {}) {
|
|
17
|
+
const guard = createGuard(ctx, config ?? {});
|
|
18
|
+
|
|
19
|
+
ctx.effect(() => guard.start(), 'offpeak-guard: tick loop');
|
|
20
|
+
|
|
21
|
+
ctx.effect(() => {
|
|
22
|
+
const disposers = [];
|
|
23
|
+
try {
|
|
24
|
+
for (const tool of createOffpeakTools(guard)) {
|
|
25
|
+
disposers.push(ctx.tools.register(tool));
|
|
26
|
+
}
|
|
27
|
+
} catch (error) {
|
|
28
|
+
for (const dispose of disposers.splice(0).reverse()) {
|
|
29
|
+
try {
|
|
30
|
+
dispose();
|
|
31
|
+
} catch {
|
|
32
|
+
/* ignore */
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
throw error;
|
|
36
|
+
}
|
|
37
|
+
return () => {
|
|
38
|
+
for (const dispose of disposers.splice(0).reverse()) {
|
|
39
|
+
try {
|
|
40
|
+
dispose();
|
|
41
|
+
} catch {
|
|
42
|
+
/* ignore */
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
}, 'offpeak-guard: tools');
|
|
47
|
+
}
|