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/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
- const { snapshot, error } = useSnapshot(ctx, props?.sessionId);
160
- const [, force] = React.useState(0);
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
- return h(
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
  }