dsh-offpeak-guard 0.2.0 → 0.3.1
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 +18 -0
- package/README.en.md +41 -8
- package/README.md +44 -8
- package/client.js +457 -6
- package/cordis.patch.yml +4 -0
- package/docs/settings-panel.png +0 -0
- package/index.js +72 -0
- package/lib/guard.js +414 -28
- package/lib/store.js +40 -3
- package/lib/tools.js +59 -13
- package/package.json +1 -1
package/client.js
CHANGED
|
@@ -131,6 +131,8 @@ window.__ModuleLoader__.load({
|
|
|
131
131
|
if (mounted.current && parsed !== null && typeof parsed === 'object') {
|
|
132
132
|
setSnapshot(parsed);
|
|
133
133
|
setError(null);
|
|
134
|
+
sharedStatus.snapshot = parsed;
|
|
135
|
+
for (const listener of sharedStatus.listeners) listener();
|
|
134
136
|
}
|
|
135
137
|
} catch (caught) {
|
|
136
138
|
if (mounted.current) setError(String(caught?.message ?? caught));
|
|
@@ -149,15 +151,99 @@ window.__ModuleLoader__.load({
|
|
|
149
151
|
};
|
|
150
152
|
}, [load]);
|
|
151
153
|
|
|
152
|
-
return { snapshot, error };
|
|
154
|
+
return { snapshot, error, refresh: load };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const sharedStatus = { snapshot: null, listeners: new Set(), timer: null, loading: false, ctx: null };
|
|
158
|
+
|
|
159
|
+
async function readSharedStatus(ctx) {
|
|
160
|
+
const remote = ctx.remote;
|
|
161
|
+
if (!remote || typeof remote.session?.list !== 'function' || typeof remote.workspaceFiles?.read !== 'function') return null;
|
|
162
|
+
const listed = await remote.session.list({});
|
|
163
|
+
const items = listed?.value?.items ?? listed?.items ?? [];
|
|
164
|
+
if (!Array.isArray(items) || items.length === 0) return null;
|
|
165
|
+
const sorted = [...items].sort(
|
|
166
|
+
(left, right) => Number(right.updatedAt ?? right.lastActivityAt ?? 0) - Number(left.updatedAt ?? left.lastActivityAt ?? 0),
|
|
167
|
+
);
|
|
168
|
+
const home = remote.$host?.home;
|
|
169
|
+
if (typeof home !== 'string' || home.length === 0) return null;
|
|
170
|
+
const candidates = sorted
|
|
171
|
+
.filter((item) => item?.parentSession === undefined && item?.parentSessionId === undefined)
|
|
172
|
+
.map((item) => item?.sessionId ?? item?.id)
|
|
173
|
+
.filter((id) => typeof id === 'string' && id.length > 0)
|
|
174
|
+
.slice(0, 5);
|
|
175
|
+
for (const sessionId of candidates) {
|
|
176
|
+
try {
|
|
177
|
+
const result = await remote.workspaceFiles.read(sessionId, `${home}/.dsh/offpeak-guard/status.json`, {});
|
|
178
|
+
const parsed = JSON.parse(snapshotTextOf(result));
|
|
179
|
+
if (parsed !== null && typeof parsed === 'object') return parsed;
|
|
180
|
+
} catch {
|
|
181
|
+
/* 换下一个最近会话再试 */
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function loadSharedStatus(ctx) {
|
|
188
|
+
if (sharedStatus.loading) return;
|
|
189
|
+
sharedStatus.loading = true;
|
|
190
|
+
try {
|
|
191
|
+
const snapshot = await readSharedStatus(ctx);
|
|
192
|
+
if (snapshot !== null) {
|
|
193
|
+
sharedStatus.snapshot = snapshot;
|
|
194
|
+
for (const listener of sharedStatus.listeners) listener();
|
|
195
|
+
}
|
|
196
|
+
} catch {
|
|
197
|
+
/* 状态文件尚未生成时保持旧值 */
|
|
198
|
+
} finally {
|
|
199
|
+
sharedStatus.loading = false;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function ensureSharedStatus(ctx) {
|
|
204
|
+
sharedStatus.ctx = ctx;
|
|
205
|
+
if (sharedStatus.timer !== null) return;
|
|
206
|
+
void loadSharedStatus(ctx);
|
|
207
|
+
sharedStatus.timer = setInterval(() => void loadSharedStatus(ctx), 30000);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function useSharedStatus(ctx) {
|
|
211
|
+
const [, force] = React.useState(0);
|
|
212
|
+
React.useEffect(() => {
|
|
213
|
+
const listener = () => force((value) => value + 1);
|
|
214
|
+
sharedStatus.listeners.add(listener);
|
|
215
|
+
ensureSharedStatus(ctx);
|
|
216
|
+
return () => {
|
|
217
|
+
sharedStatus.listeners.delete(listener);
|
|
218
|
+
if (sharedStatus.listeners.size === 0 && sharedStatus.timer !== null) {
|
|
219
|
+
clearInterval(sharedStatus.timer);
|
|
220
|
+
sharedStatus.timer = null;
|
|
221
|
+
}
|
|
222
|
+
};
|
|
223
|
+
}, []);
|
|
224
|
+
return sharedStatus.snapshot;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async function postAction(body) {
|
|
228
|
+
const response = await fetch('/plugins/dsh-offpeak-guard/action', {
|
|
229
|
+
method: 'POST',
|
|
230
|
+
credentials: 'same-origin',
|
|
231
|
+
headers: { 'content-type': 'application/json' },
|
|
232
|
+
body: JSON.stringify(body),
|
|
233
|
+
});
|
|
234
|
+
const json = await response.json();
|
|
235
|
+
if (!response.ok || json.ok === false) throw new Error(json.error ?? `HTTP ${response.status}`);
|
|
236
|
+
if (sharedStatus.ctx !== null) await loadSharedStatus(sharedStatus.ctx);
|
|
237
|
+
return json;
|
|
153
238
|
}
|
|
154
239
|
|
|
155
240
|
function makeStrip(pluginCtx) {
|
|
156
241
|
return function Strip(props) {
|
|
157
242
|
const ctx = pluginCtx;
|
|
158
243
|
const t = useLocale(ctx);
|
|
159
|
-
|
|
160
|
-
|
|
244
|
+
const { snapshot, error, refresh } = useSnapshot(ctx, props?.sessionId);
|
|
245
|
+
const [busy, setBusy] = React.useState(false);
|
|
246
|
+
const [, force] = React.useState(0);
|
|
161
247
|
|
|
162
248
|
React.useEffect(() => {
|
|
163
249
|
const timer = setInterval(() => force((value) => value + 1), 1000);
|
|
@@ -218,6 +304,38 @@ window.__ModuleLoader__.load({
|
|
|
218
304
|
if (modelId !== '') children.push(h('span', { key: 'model', style: { opacity: 0.75 } }, `· ${modelId}`));
|
|
219
305
|
if (paused > 0) children.push(h('span', { key: 'paused', style: { color: '#e0a33e' } }, `· ${t('paused')} ${paused}`));
|
|
220
306
|
if (plans > 0) children.push(h('span', { key: 'plans' }, `· ${t('plans')} ${plans}`));
|
|
307
|
+
const currentSessionId = typeof props?.sessionId === 'string' ? props.sessionId : '';
|
|
308
|
+
const currentExempt =
|
|
309
|
+
currentSessionId !== '' &&
|
|
310
|
+
(snapshot.exemptSessions ?? []).some((item) => item.sessionId === currentSessionId);
|
|
311
|
+
if (currentSessionId !== '') {
|
|
312
|
+
children.push(
|
|
313
|
+
h(
|
|
314
|
+
'button',
|
|
315
|
+
{
|
|
316
|
+
key: 'exempt',
|
|
317
|
+
type: 'button',
|
|
318
|
+
className: 'opg-exempt',
|
|
319
|
+
'data-exempt': currentExempt ? 'true' : undefined,
|
|
320
|
+
disabled: busy,
|
|
321
|
+
title: currentExempt ? '取消本会话的峰谷豁免' : '让本会话豁免峰谷守卫(不暂停/不恢复)',
|
|
322
|
+
onClick: async (event) => {
|
|
323
|
+
event.stopPropagation();
|
|
324
|
+
setBusy(true);
|
|
325
|
+
try {
|
|
326
|
+
await postAction({ action: 'exempt', sessionId: currentSessionId, exempt: !currentExempt });
|
|
327
|
+
await refresh();
|
|
328
|
+
} catch {
|
|
329
|
+
/* 下一次点击重试 */
|
|
330
|
+
} finally {
|
|
331
|
+
setBusy(false);
|
|
332
|
+
}
|
|
333
|
+
},
|
|
334
|
+
},
|
|
335
|
+
[h(ShieldIcon, { key: 'icon', active: currentExempt }), currentExempt ? '取消豁免' : '豁免本会话'],
|
|
336
|
+
),
|
|
337
|
+
);
|
|
338
|
+
}
|
|
221
339
|
|
|
222
340
|
return h(
|
|
223
341
|
'div',
|
|
@@ -266,7 +384,7 @@ window.__ModuleLoader__.load({
|
|
|
266
384
|
}
|
|
267
385
|
|
|
268
386
|
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;}
|
|
387
|
+
.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;cursor:pointer;}
|
|
270
388
|
.opg-clock[data-compact="true"]{width:32px;min-height:32px;justify-content:center;padding:6px;gap:0;}
|
|
271
389
|
.opg-clock-dot{flex:none;width:7px;height:7px;border-radius:50%;background:currentColor;}
|
|
272
390
|
.opg-clock-state{flex:none;}
|
|
@@ -278,6 +396,34 @@ window.__ModuleLoader__.load({
|
|
|
278
396
|
@keyframes opg-blink-red{0%,100%{color:#e0554e;opacity:1;}50%{color:#ff8f8a;opacity:.4;}}
|
|
279
397
|
@keyframes opg-blink-green{0%,100%{color:#3f9d7a;opacity:1;}50%{color:#69d3aa;opacity:.4;}}
|
|
280
398
|
@media (prefers-reduced-motion: reduce){.opg-clock[data-blink]{animation:none;}}
|
|
399
|
+
.opg-panel{border:none;border-radius:14px;padding:0;width:min(600px,92vw);max-height:84vh;background:var(--dsw-alias-bg-base,#fff);color:var(--dsw-alias-label-primary,#252936);box-shadow:0 24px 80px #0006;font:13px/1.6 var(--dsw-font-family,system-ui,sans-serif);}
|
|
400
|
+
.opg-panel::backdrop{background:#0006;}
|
|
401
|
+
.opg-panel-shell{display:flex;flex-direction:column;max-height:84vh;}
|
|
402
|
+
.opg-panel-head{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:16px 20px;border-bottom:1px solid var(--dsw-alias-border-l1,#dde0e6);}
|
|
403
|
+
.opg-panel-head strong{font-size:15px;}
|
|
404
|
+
.opg-panel-close{border:1px solid var(--dsw-alias-border-l1,#dde0e6);background:transparent;color:inherit;border-radius:8px;padding:4px 10px;cursor:pointer;font:inherit;}
|
|
405
|
+
.opg-panel-body{padding:16px 20px;overflow:auto;}
|
|
406
|
+
.opg-panel-meta{color:var(--dsw-alias-label-secondary,#6c7769);font-size:12px;margin-bottom:14px;}
|
|
407
|
+
.opg-section{margin-bottom:18px;}
|
|
408
|
+
.opg-section h4{margin:0 0 8px;font-size:12px;font-weight:600;color:var(--dsw-alias-label-secondary,#6c7769);}
|
|
409
|
+
.opg-row{display:flex;align-items:center;gap:10px;padding:6px 0;border-top:1px solid var(--dsw-alias-border-l1,#eef0f3);min-width:0;}
|
|
410
|
+
.opg-row:first-of-type{border-top:0;}
|
|
411
|
+
.opg-row-label{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
|
412
|
+
.opg-row-sub{color:var(--dsw-alias-label-secondary,#8a8d94);font-size:11px;margin-left:4px;}
|
|
413
|
+
.opg-group-name{font-size:11px;color:var(--dsw-alias-label-secondary,#8a8d94);margin:8px 0 2px;}
|
|
414
|
+
.opg-check{width:15px;height:15px;accent-color:var(--dsw-alias-state-business-primary,#4d6bfe);flex:none;}
|
|
415
|
+
.opg-seg{display:inline-flex;border:1px solid var(--dsw-alias-border-l1,#dde0e6);border-radius:8px;overflow:hidden;}
|
|
416
|
+
.opg-seg button{border:0;background:transparent;color:inherit;padding:5px 10px;font:inherit;cursor:pointer;}
|
|
417
|
+
.opg-seg button[data-active="true"]{background:var(--dsw-alias-state-business-primary,#4d6bfe);color:#fff;}
|
|
418
|
+
.opg-note{color:var(--dsw-alias-label-secondary,#8a8d94);font-size:11px;line-height:1.6;}
|
|
419
|
+
.opg-error{color:#e0554e;font-size:12px;margin-top:8px;}
|
|
420
|
+
.opg-row-action{display:inline-flex;align-items:center;justify-content:center;flex:none;width:26px;height:26px;border:0;border-radius:6px;background:transparent;color:var(--dsw-alias-label-secondary,#6c7769);cursor:pointer;padding:0;}
|
|
421
|
+
.opg-row-action:hover{background:var(--dsw-alias-interactive-bg-hover,#4d6bfe12);color:var(--dsw-alias-label-primary,#252936);}
|
|
422
|
+
.opg-row-action[data-exempt="true"]{color:#e0554e;}
|
|
423
|
+
.opg-trail{display:inline-flex;align-items:center;justify-content:center;flex:none;color:#e0554e;opacity:.85;}
|
|
424
|
+
.opg-exempt{display:inline-flex;align-items:center;gap:4px;border:1px solid var(--dsw-alias-border-l1,#dde0e6);border-radius:7px;background:transparent;color:var(--dsw-alias-label-secondary,#6c7769);font:11px/16px var(--dsw-font-family,system-ui,sans-serif);padding:1px 6px;cursor:pointer;white-space:nowrap;}
|
|
425
|
+
.opg-exempt[data-exempt="true"]{color:#e0554e;border-color:#e0554e66;}
|
|
426
|
+
.opg-exempt:hover{border-color:var(--dsw-alias-label-secondary,#8a8d94);}
|
|
281
427
|
`;
|
|
282
428
|
|
|
283
429
|
function ClockIcon() {
|
|
@@ -299,15 +445,275 @@ window.__ModuleLoader__.load({
|
|
|
299
445
|
);
|
|
300
446
|
}
|
|
301
447
|
|
|
448
|
+
function useCatalog(ctx, enabled) {
|
|
449
|
+
const [groups, setGroups] = React.useState([]);
|
|
450
|
+
React.useEffect(() => {
|
|
451
|
+
if (!enabled) return undefined;
|
|
452
|
+
let alive = true;
|
|
453
|
+
(async () => {
|
|
454
|
+
try {
|
|
455
|
+
const result = await ctx.remote.session.modelCatalog();
|
|
456
|
+
const list = result?.value?.groups ?? result?.groups ?? [];
|
|
457
|
+
if (alive && Array.isArray(list)) setGroups(list);
|
|
458
|
+
} catch {
|
|
459
|
+
/* 目录不可用时留空 */
|
|
460
|
+
}
|
|
461
|
+
})();
|
|
462
|
+
return () => {
|
|
463
|
+
alive = false;
|
|
464
|
+
};
|
|
465
|
+
}, [enabled]);
|
|
466
|
+
return groups;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function SettingsPanel({ ctx, snapshot, refresh, groups, onClose }) {
|
|
470
|
+
const [busy, setBusy] = React.useState('');
|
|
471
|
+
const [error, setError] = React.useState('');
|
|
472
|
+
const [query, setQuery] = React.useState('');
|
|
473
|
+
const post = async (body) => {
|
|
474
|
+
setBusy(`${body.action}:${body.sessionId ?? body.provider ?? body.mode ?? ''}`);
|
|
475
|
+
setError('');
|
|
476
|
+
try {
|
|
477
|
+
const response = await fetch('/plugins/dsh-offpeak-guard/action', {
|
|
478
|
+
method: 'POST',
|
|
479
|
+
credentials: 'same-origin',
|
|
480
|
+
headers: { 'content-type': 'application/json' },
|
|
481
|
+
body: JSON.stringify(body),
|
|
482
|
+
});
|
|
483
|
+
const json = await response.json();
|
|
484
|
+
if (!response.ok || json.ok === false) throw new Error(json.error ?? `HTTP ${response.status}`);
|
|
485
|
+
await refresh();
|
|
486
|
+
} catch (caught) {
|
|
487
|
+
setError(String(caught?.message ?? caught));
|
|
488
|
+
} finally {
|
|
489
|
+
setBusy('');
|
|
490
|
+
}
|
|
491
|
+
};
|
|
492
|
+
const modelManaged = (provider, model) => {
|
|
493
|
+
const key = `${provider}/${model}`;
|
|
494
|
+
if (Object.prototype.hasOwnProperty.call(snapshot.modelRules ?? {}, key)) return snapshot.modelRules[key] === true;
|
|
495
|
+
if ((snapshot.peakPricedModels ?? []).length > 0) {
|
|
496
|
+
return snapshot.peakPricedModels.includes(key) || snapshot.peakPricedModels.includes(model);
|
|
497
|
+
}
|
|
498
|
+
if ((snapshot.exemptModels ?? []).includes(model)) return false;
|
|
499
|
+
const needle = String(snapshot.modelPattern ?? 'deepseek').toLowerCase();
|
|
500
|
+
return model.toLowerCase().includes(needle) || provider.toLowerCase().includes(needle);
|
|
501
|
+
};
|
|
502
|
+
const sessions = Object.values(snapshot.sessions ?? {}).filter((session) => {
|
|
503
|
+
if (query.trim() === '') return true;
|
|
504
|
+
const needle = query.trim().toLowerCase();
|
|
505
|
+
return String(session.title ?? '').toLowerCase().includes(needle) || String(session.id).toLowerCase().includes(needle);
|
|
506
|
+
});
|
|
507
|
+
return h('div', { className: 'opg-panel-shell' }, [
|
|
508
|
+
h('div', { className: 'opg-panel-head', key: 'head' }, [
|
|
509
|
+
h('strong', { key: 'title' }, '峰谷守卫设置'),
|
|
510
|
+
h('button', { key: 'close', className: 'opg-panel-close', onClick: onClose }, '关闭'),
|
|
511
|
+
]),
|
|
512
|
+
h('div', { className: 'opg-panel-body', key: 'body' }, [
|
|
513
|
+
h(
|
|
514
|
+
'div',
|
|
515
|
+
{ className: 'opg-panel-meta', key: 'meta' },
|
|
516
|
+
`${snapshot.presetLabel ?? ''} · ${snapshot.phaseLabel ?? ''} · ${snapshot.holiday ? '今天法定节假日(全天谷价)' : '按工作日峰时'}`,
|
|
517
|
+
),
|
|
518
|
+
h('div', { className: 'opg-section', key: 'mode' }, [
|
|
519
|
+
h('h4', { key: 'h' }, '峰时策略'),
|
|
520
|
+
h('div', { className: 'opg-seg' }, [
|
|
521
|
+
h(
|
|
522
|
+
'button',
|
|
523
|
+
{
|
|
524
|
+
key: 'now',
|
|
525
|
+
'data-active': snapshot.pauseMode !== 'finish-turn' ? 'true' : 'false',
|
|
526
|
+
disabled: busy !== '',
|
|
527
|
+
onClick: () => post({ action: 'pauseMode', mode: 'pause-now' }),
|
|
528
|
+
},
|
|
529
|
+
'立即暂停',
|
|
530
|
+
),
|
|
531
|
+
h(
|
|
532
|
+
'button',
|
|
533
|
+
{
|
|
534
|
+
key: 'finish',
|
|
535
|
+
'data-active': snapshot.pauseMode === 'finish-turn' ? 'true' : 'false',
|
|
536
|
+
disabled: busy !== '',
|
|
537
|
+
onClick: () => post({ action: 'pauseMode', mode: 'finish-turn' }),
|
|
538
|
+
},
|
|
539
|
+
'完成当前轮后暂停',
|
|
540
|
+
),
|
|
541
|
+
]),
|
|
542
|
+
h('div', { className: 'opg-note', key: 'note' }, '有子代理在运行时,会先放行子代理;子代理结束后再暂停父会话。'),
|
|
543
|
+
]),
|
|
544
|
+
h('div', { className: 'opg-section', key: 'models' }, [
|
|
545
|
+
h('h4', { key: 'h' }, '模型管理(精确到 provider/model)'),
|
|
546
|
+
groups.length === 0
|
|
547
|
+
? h('div', { className: 'opg-note', key: 'empty' }, '模型目录暂不可用;仍可在配置里用 modelPattern / exemptModels 管理。')
|
|
548
|
+
: groups.map((group) =>
|
|
549
|
+
h('div', { key: `g:${group.id}` }, [
|
|
550
|
+
h('div', { className: 'opg-group-name', key: 'name' }, group.name ?? group.id),
|
|
551
|
+
...(Array.isArray(group.models) ? group.models : []).map((model) =>
|
|
552
|
+
h('label', { className: 'opg-row', key: `m:${group.id}/${model.id}` }, [
|
|
553
|
+
h('input', {
|
|
554
|
+
className: 'opg-check',
|
|
555
|
+
type: 'checkbox',
|
|
556
|
+
checked: modelManaged(String(group.id), String(model.id)),
|
|
557
|
+
disabled: busy !== '',
|
|
558
|
+
onChange: (event) =>
|
|
559
|
+
post({
|
|
560
|
+
action: 'model',
|
|
561
|
+
provider: String(group.id),
|
|
562
|
+
model: String(model.id),
|
|
563
|
+
peakPriced: event.target.checked,
|
|
564
|
+
}),
|
|
565
|
+
}),
|
|
566
|
+
h('span', { className: 'opg-row-label' }, [
|
|
567
|
+
model.name ?? model.id,
|
|
568
|
+
h('span', { className: 'opg-row-sub', key: 'id' }, model.id),
|
|
569
|
+
]),
|
|
570
|
+
]),
|
|
571
|
+
),
|
|
572
|
+
]),
|
|
573
|
+
),
|
|
574
|
+
]),
|
|
575
|
+
h('div', { className: 'opg-section', key: 'sessions' }, [
|
|
576
|
+
h('h4', { key: 'h' }, '会话豁免(不参与暂停/恢复)'),
|
|
577
|
+
h('input', {
|
|
578
|
+
key: 'search',
|
|
579
|
+
type: 'search',
|
|
580
|
+
value: query,
|
|
581
|
+
placeholder: '按标题或 session id 过滤…',
|
|
582
|
+
onChange: (event) => setQuery(event.target.value),
|
|
583
|
+
style: {
|
|
584
|
+
width: '100%',
|
|
585
|
+
boxSizing: 'border-box',
|
|
586
|
+
margin: '0 0 6px',
|
|
587
|
+
padding: '5px 8px',
|
|
588
|
+
border: '1px solid var(--dsw-alias-border-l1,#dde0e6)',
|
|
589
|
+
borderRadius: '7px',
|
|
590
|
+
background: 'transparent',
|
|
591
|
+
color: 'inherit',
|
|
592
|
+
font: 'inherit',
|
|
593
|
+
},
|
|
594
|
+
}),
|
|
595
|
+
sessions.length === 0
|
|
596
|
+
? h('div', { className: 'opg-note', key: 'empty' }, '没有匹配的运行中根会话。')
|
|
597
|
+
: sessions.map((session) =>
|
|
598
|
+
h('label', { className: 'opg-row', key: `s:${session.id}` }, [
|
|
599
|
+
h('input', {
|
|
600
|
+
className: 'opg-check',
|
|
601
|
+
type: 'checkbox',
|
|
602
|
+
checked: session.exempt === true,
|
|
603
|
+
disabled: busy !== '',
|
|
604
|
+
onChange: (event) =>
|
|
605
|
+
post({ action: 'exempt', sessionId: String(session.id), exempt: event.target.checked }),
|
|
606
|
+
}),
|
|
607
|
+
h('span', { className: 'opg-row-label' }, [
|
|
608
|
+
session.title ?? session.id,
|
|
609
|
+
h('span', { className: 'opg-row-sub', key: 'model' }, session.id),
|
|
610
|
+
]),
|
|
611
|
+
]),
|
|
612
|
+
),
|
|
613
|
+
]),
|
|
614
|
+
h('div', { className: 'opg-section', key: 'holidays' }, [
|
|
615
|
+
h('h4', { key: 'h' }, '节假日'),
|
|
616
|
+
h(
|
|
617
|
+
'div',
|
|
618
|
+
{ className: 'opg-note', key: 'note' },
|
|
619
|
+
snapshot.holidaySource === 'auto'
|
|
620
|
+
? `动态识别:已同步(${snapshot.holidaySyncedAt ? new Date(snapshot.holidaySyncedAt).toLocaleString() : '尚未同步'}),失败时回退内置 2026 列表。`
|
|
621
|
+
: '使用内置节假日(可在配置 holidays 追加)。',
|
|
622
|
+
),
|
|
623
|
+
]),
|
|
624
|
+
error === '' ? null : h('div', { className: 'opg-error', key: 'error' }, error),
|
|
625
|
+
]),
|
|
626
|
+
]);
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
function ShieldIcon({ active }) {
|
|
630
|
+
return h(
|
|
631
|
+
'svg',
|
|
632
|
+
{
|
|
633
|
+
width: 15,
|
|
634
|
+
height: 15,
|
|
635
|
+
viewBox: '0 0 24 24',
|
|
636
|
+
fill: active ? 'currentColor' : 'none',
|
|
637
|
+
stroke: 'currentColor',
|
|
638
|
+
strokeWidth: 1.8,
|
|
639
|
+
strokeLinecap: 'round',
|
|
640
|
+
strokeLinejoin: 'round',
|
|
641
|
+
'aria-hidden': true,
|
|
642
|
+
},
|
|
643
|
+
[h('path', { key: 'p', d: 'M12 3l7 3v5c0 4.4-2.9 8.1-7 10-4.1-1.9-7-5.6-7-10V6z' })],
|
|
644
|
+
);
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
function makeRowAction(pluginCtx) {
|
|
648
|
+
return function RowAction(props) {
|
|
649
|
+
const snapshot = useSharedStatus(pluginCtx);
|
|
650
|
+
const [busy, setBusy] = React.useState(false);
|
|
651
|
+
const sessionId = typeof props?.sessionId === 'string' ? props.sessionId : '';
|
|
652
|
+
if (sessionId === '') return null;
|
|
653
|
+
const exempt = (snapshot?.exemptSessions ?? []).some((item) => item.sessionId === sessionId);
|
|
654
|
+
return h(
|
|
655
|
+
'button',
|
|
656
|
+
{
|
|
657
|
+
type: 'button',
|
|
658
|
+
className: 'opg-row-action',
|
|
659
|
+
title: exempt ? '取消峰谷豁免' : '本会话豁免峰谷守卫',
|
|
660
|
+
'aria-label': exempt ? '取消峰谷豁免' : '本会话豁免峰谷守卫',
|
|
661
|
+
'aria-pressed': exempt,
|
|
662
|
+
'data-exempt': exempt ? 'true' : undefined,
|
|
663
|
+
disabled: busy,
|
|
664
|
+
onClick: async (event) => {
|
|
665
|
+
event.stopPropagation();
|
|
666
|
+
setBusy(true);
|
|
667
|
+
try {
|
|
668
|
+
await postAction({ action: 'exempt', sessionId, exempt: !exempt });
|
|
669
|
+
} catch {
|
|
670
|
+
/* 状态文件/路由暂不可用时静默,下一次点击重试 */
|
|
671
|
+
} finally {
|
|
672
|
+
setBusy(false);
|
|
673
|
+
}
|
|
674
|
+
},
|
|
675
|
+
},
|
|
676
|
+
h(ShieldIcon, { active: exempt }),
|
|
677
|
+
);
|
|
678
|
+
};
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
function makeTrailing(pluginCtx) {
|
|
682
|
+
return function Trailing(props) {
|
|
683
|
+
const snapshot = useSharedStatus(pluginCtx);
|
|
684
|
+
const sessionId = typeof props?.sessionId === 'string' ? props.sessionId : '';
|
|
685
|
+
if (snapshot === null || sessionId === '') return null;
|
|
686
|
+
const exempt = (snapshot.exemptSessions ?? []).some((item) => item.sessionId === sessionId);
|
|
687
|
+
if (!exempt) return null;
|
|
688
|
+
return h('span', { className: 'opg-trail', title: '峰谷豁免中', 'aria-hidden': true }, h(ShieldIcon, { active: true }));
|
|
689
|
+
};
|
|
690
|
+
}
|
|
691
|
+
|
|
302
692
|
function makeClock(pluginCtx) {
|
|
303
693
|
return function Clock(props) {
|
|
304
694
|
const t = useLocale(pluginCtx);
|
|
305
|
-
const { snapshot } = useSnapshot(pluginCtx, null);
|
|
695
|
+
const { snapshot, refresh } = useSnapshot(pluginCtx, null);
|
|
696
|
+
const [open, setOpen] = React.useState(false);
|
|
697
|
+
const dialogRef = React.useRef(null);
|
|
698
|
+
const groups = useCatalog(pluginCtx, open);
|
|
306
699
|
const [, force] = React.useState(0);
|
|
307
700
|
React.useEffect(() => {
|
|
308
701
|
const timer = setInterval(() => force((value) => value + 1), 1000);
|
|
309
702
|
return () => clearInterval(timer);
|
|
310
703
|
}, []);
|
|
704
|
+
React.useEffect(() => {
|
|
705
|
+
const dialog = dialogRef.current;
|
|
706
|
+
if (dialog === null) return;
|
|
707
|
+
if (open && !dialog.open) dialog.showModal();
|
|
708
|
+
if (!open && dialog.open) dialog.close();
|
|
709
|
+
}, [open]);
|
|
710
|
+
React.useEffect(() => {
|
|
711
|
+
const dialog = dialogRef.current;
|
|
712
|
+
if (dialog === null) return undefined;
|
|
713
|
+
const onClose = () => setOpen(false);
|
|
714
|
+
dialog.addEventListener('close', onClose);
|
|
715
|
+
return () => dialog.removeEventListener('close', onClose);
|
|
716
|
+
}, []);
|
|
311
717
|
if (snapshot === null) return null;
|
|
312
718
|
|
|
313
719
|
const phase = snapshot.phase === 'peak' ? 'peak' : 'offpeak';
|
|
@@ -335,6 +741,7 @@ window.__ModuleLoader__.load({
|
|
|
335
741
|
t('offpeakNote'),
|
|
336
742
|
`${t('model')}: ${modelId || '—'}`,
|
|
337
743
|
t('clockHint'),
|
|
744
|
+
'点击打开设置 / Click to open settings',
|
|
338
745
|
]
|
|
339
746
|
.filter(Boolean)
|
|
340
747
|
.join('\n');
|
|
@@ -347,7 +754,7 @@ window.__ModuleLoader__.load({
|
|
|
347
754
|
]
|
|
348
755
|
: [ClockIcon()];
|
|
349
756
|
|
|
350
|
-
|
|
757
|
+
const clock = h(
|
|
351
758
|
'div',
|
|
352
759
|
{
|
|
353
760
|
className: 'opg-clock',
|
|
@@ -357,9 +764,31 @@ window.__ModuleLoader__.load({
|
|
|
357
764
|
'data-tone': tone,
|
|
358
765
|
'data-blink': blink ?? undefined,
|
|
359
766
|
'data-compact': wide ? 'false' : 'true',
|
|
767
|
+
onClick: () => setOpen(true),
|
|
360
768
|
},
|
|
361
769
|
[h('style', { key: 'style' }, clockStyle)].concat(inner),
|
|
362
770
|
);
|
|
771
|
+
const dialog = h(
|
|
772
|
+
'dialog',
|
|
773
|
+
{
|
|
774
|
+
key: 'dialog',
|
|
775
|
+
ref: dialogRef,
|
|
776
|
+
className: 'opg-panel',
|
|
777
|
+
onClick: (event) => {
|
|
778
|
+
if (event.target === event.currentTarget) setOpen(false);
|
|
779
|
+
},
|
|
780
|
+
},
|
|
781
|
+
open
|
|
782
|
+
? h(SettingsPanel, {
|
|
783
|
+
ctx: pluginCtx,
|
|
784
|
+
snapshot,
|
|
785
|
+
refresh,
|
|
786
|
+
groups,
|
|
787
|
+
onClose: () => setOpen(false),
|
|
788
|
+
})
|
|
789
|
+
: null,
|
|
790
|
+
);
|
|
791
|
+
return h(React.Fragment, null, [clock, dialog]);
|
|
363
792
|
};
|
|
364
793
|
}
|
|
365
794
|
|
|
@@ -389,6 +818,28 @@ window.__ModuleLoader__.load({
|
|
|
389
818
|
Clock,
|
|
390
819
|
),
|
|
391
820
|
);
|
|
821
|
+
const RowAction = makeRowAction(ctx);
|
|
822
|
+
const Trail = makeTrailing(ctx);
|
|
823
|
+
ctx.slots.inject('sidebar.workspaces.session.row.action', () =>
|
|
824
|
+
ctx.slots.register(
|
|
825
|
+
{
|
|
826
|
+
name: 'sidebar.workspaces.session.row.action',
|
|
827
|
+
id: 'offpeak-guard-exempt',
|
|
828
|
+
order: 150,
|
|
829
|
+
},
|
|
830
|
+
RowAction,
|
|
831
|
+
),
|
|
832
|
+
);
|
|
833
|
+
ctx.slots.inject('sidebar.session.row.trailing', () =>
|
|
834
|
+
ctx.slots.register(
|
|
835
|
+
{
|
|
836
|
+
name: 'sidebar.session.row.trailing',
|
|
837
|
+
id: 'offpeak-guard-exempt-trail',
|
|
838
|
+
order: 20,
|
|
839
|
+
},
|
|
840
|
+
Trail,
|
|
841
|
+
),
|
|
842
|
+
);
|
|
392
843
|
},
|
|
393
844
|
};
|
|
394
845
|
},
|
package/cordis.patch.yml
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
config:
|
|
15
15
|
enabled: true
|
|
16
16
|
preset: 'auto'
|
|
17
|
+
pauseMode: 'pause-now'
|
|
17
18
|
timezone: 'Asia/Shanghai'
|
|
18
19
|
peakWeekdays: [1, 2, 3, 4, 5]
|
|
19
20
|
peakWindows:
|
|
@@ -23,7 +24,10 @@
|
|
|
23
24
|
end: '18:00'
|
|
24
25
|
discount: 0.5
|
|
25
26
|
holidays: []
|
|
27
|
+
holidaySource: 'auto'
|
|
28
|
+
holidayApi: 'https://timor.tech/api/holiday/year'
|
|
26
29
|
modelPattern: 'deepseek'
|
|
30
|
+
peakPricedModels: []
|
|
27
31
|
exemptModels: []
|
|
28
32
|
pauseRunningOnPeak: true
|
|
29
33
|
deferNewTurnsOnPeak: true
|
|
Binary file
|
package/index.js
CHANGED
|
@@ -24,6 +24,7 @@ const OFFICIAL_WINDOWS = [
|
|
|
24
24
|
export const Config = z.object({
|
|
25
25
|
enabled: z.boolean().default(true),
|
|
26
26
|
preset: z.string().default('auto'),
|
|
27
|
+
pauseMode: z.string().default('pause-now'),
|
|
27
28
|
timezone: z.string().default('Asia/Shanghai'),
|
|
28
29
|
peakWeekdays: z.array(z.natural().max(6)).default([1, 2, 3, 4, 5]),
|
|
29
30
|
peakWindows: z
|
|
@@ -31,7 +32,10 @@ export const Config = z.object({
|
|
|
31
32
|
.default(OFFICIAL_WINDOWS),
|
|
32
33
|
discount: z.number().min(0.1).max(1).default(0.5),
|
|
33
34
|
holidays: z.array(z.string()).default([]),
|
|
35
|
+
holidaySource: z.string().default('auto'),
|
|
36
|
+
holidayApi: z.string().default('https://timor.tech/api/holiday/year'),
|
|
34
37
|
modelPattern: z.string().default('deepseek'),
|
|
38
|
+
peakPricedModels: z.array(z.string()).default([]),
|
|
35
39
|
exemptModels: z.array(z.string()).default([]),
|
|
36
40
|
pauseRunningOnPeak: z.boolean().default(true),
|
|
37
41
|
deferNewTurnsOnPeak: z.boolean().default(true),
|
|
@@ -90,6 +94,7 @@ export function envConfig(env = process.env) {
|
|
|
90
94
|
if (value !== undefined) out.enabled = value;
|
|
91
95
|
}
|
|
92
96
|
if (env.DSH_OFFPEAK_PRESET) out.preset = String(env.DSH_OFFPEAK_PRESET).trim();
|
|
97
|
+
if (env.DSH_OFFPEAK_PAUSE_MODE) out.pauseMode = String(env.DSH_OFFPEAK_PAUSE_MODE).trim();
|
|
93
98
|
if (env.DSH_OFFPEAK_TIMEZONE) out.timezone = String(env.DSH_OFFPEAK_TIMEZONE).trim();
|
|
94
99
|
if (env.DSH_OFFPEAK_PEAK_WEEKDAYS !== undefined) {
|
|
95
100
|
const value = parseWeekdays(env.DSH_OFFPEAK_PEAK_WEEKDAYS);
|
|
@@ -107,7 +112,13 @@ export function envConfig(env = process.env) {
|
|
|
107
112
|
const value = parseList(env.DSH_OFFPEAK_HOLIDAYS);
|
|
108
113
|
if (value !== undefined) out.holidays = value;
|
|
109
114
|
}
|
|
115
|
+
if (env.DSH_OFFPEAK_HOLIDAY_SOURCE) out.holidaySource = String(env.DSH_OFFPEAK_HOLIDAY_SOURCE).trim();
|
|
116
|
+
if (env.DSH_OFFPEAK_HOLIDAY_API) out.holidayApi = String(env.DSH_OFFPEAK_HOLIDAY_API).trim();
|
|
110
117
|
if (env.DSH_OFFPEAK_MODEL_PATTERN) out.modelPattern = String(env.DSH_OFFPEAK_MODEL_PATTERN).trim();
|
|
118
|
+
if (env.DSH_OFFPEAK_PEAK_MODELS !== undefined) {
|
|
119
|
+
const value = parseList(env.DSH_OFFPEAK_PEAK_MODELS);
|
|
120
|
+
if (value !== undefined) out.peakPricedModels = value;
|
|
121
|
+
}
|
|
111
122
|
if (env.DSH_OFFPEAK_EXEMPT_MODELS !== undefined) {
|
|
112
123
|
const value = parseList(env.DSH_OFFPEAK_EXEMPT_MODELS);
|
|
113
124
|
if (value !== undefined) out.exemptModels = value;
|
|
@@ -138,6 +149,21 @@ export function envConfig(env = process.env) {
|
|
|
138
149
|
return out;
|
|
139
150
|
}
|
|
140
151
|
|
|
152
|
+
async function readJsonBody(req) {
|
|
153
|
+
let raw = '';
|
|
154
|
+
for await (const chunk of req) raw += chunk;
|
|
155
|
+
if (raw === '') return {};
|
|
156
|
+
return JSON.parse(raw);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function sendJson(res, status, payload) {
|
|
160
|
+
const body = JSON.stringify(payload);
|
|
161
|
+
res.statusCode = status;
|
|
162
|
+
res.setHeader('content-type', 'application/json; charset=utf-8');
|
|
163
|
+
res.setHeader('cache-control', 'no-store');
|
|
164
|
+
res.end(body);
|
|
165
|
+
}
|
|
166
|
+
|
|
141
167
|
export function apply(ctx, rawConfig = {}) {
|
|
142
168
|
const config = { ...(rawConfig ?? {}), ...envConfig() };
|
|
143
169
|
const guard = createGuard(ctx, config);
|
|
@@ -170,4 +196,50 @@ export function apply(ctx, rawConfig = {}) {
|
|
|
170
196
|
}
|
|
171
197
|
};
|
|
172
198
|
}, 'offpeak-guard: tools');
|
|
199
|
+
|
|
200
|
+
const webServer = ctx.get('webServer');
|
|
201
|
+
if (webServer !== undefined && typeof webServer.register === 'function') {
|
|
202
|
+
ctx.effect(
|
|
203
|
+
() =>
|
|
204
|
+
webServer.register({
|
|
205
|
+
kind: 'prefix',
|
|
206
|
+
path: '/plugins/dsh-offpeak-guard',
|
|
207
|
+
async handler(req, res) {
|
|
208
|
+
try {
|
|
209
|
+
const url = new URL(req.url || '/', 'http://x');
|
|
210
|
+
const rest = url.pathname.replace(/^\/plugins\/dsh-offpeak-guard/, '') || '/';
|
|
211
|
+
if (req.method !== 'POST' || rest !== '/action') {
|
|
212
|
+
sendJson(res, 404, { ok: false, error: 'not found' });
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
const body = await readJsonBody(req);
|
|
216
|
+
if (body.action === 'exempt' || body.action === 'unexempt') {
|
|
217
|
+
const exempt = body.action === 'exempt' ? body.exempt === true : false;
|
|
218
|
+
const result = await guard.setExempt(String(body.sessionId || ''), exempt);
|
|
219
|
+
sendJson(res, 200, { ok: true, result });
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
if (body.action === 'model') {
|
|
223
|
+
const result = await guard.setModelRule(
|
|
224
|
+
String(body.provider || ''),
|
|
225
|
+
String(body.model || ''),
|
|
226
|
+
body.peakPriced === null || body.peakPriced === undefined ? null : body.peakPriced === true,
|
|
227
|
+
);
|
|
228
|
+
sendJson(res, 200, { ok: true, result });
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
if (body.action === 'pauseMode') {
|
|
232
|
+
const result = await guard.setPauseMode(body.mode === 'finish-turn' ? 'finish-turn' : 'pause-now');
|
|
233
|
+
sendJson(res, 200, { ok: true, result });
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
sendJson(res, 400, { ok: false, error: 'unknown action' });
|
|
237
|
+
} catch (error) {
|
|
238
|
+
sendJson(res, 400, { ok: false, error: String(error?.message ?? error) });
|
|
239
|
+
}
|
|
240
|
+
},
|
|
241
|
+
}),
|
|
242
|
+
'offpeak-guard: settings route',
|
|
243
|
+
);
|
|
244
|
+
}
|
|
173
245
|
}
|