@philomelcc/dsh-workbench 0.1.8 → 0.1.9

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.
Files changed (5) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +9 -0
  3. package/client.js +314 -15
  4. package/index.js +42 -6
  5. package/package.json +19 -5
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 philomelcc
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.md CHANGED
@@ -2,6 +2,15 @@
2
2
 
3
3
  外壳本体:顶部第二行的标签切换、每个模块一整页,以及模块注册用的那套小 API。
4
4
 
5
+ ## 0.1.9 的变化
6
+
7
+ - 新增**功能开关**和**工作台设置页**(系统栏最左侧的 mark 图标):主题切换、余额显示、重启按钮、进行中光效、单次消耗估计,都能单独关掉。
8
+ - 顶部第一行系统栏改为**镜像原生侧栏导航项**,任何注册 `sidebar.panellist` 的插件都会自动出现。
9
+ - 模块支持**无外壳运行**:没有工作台时自己注册到原生侧栏。
10
+ - 设置入口挪到左上角,工作台图标重绘为「带标签栏的面板」;`apiVersion` 仍是 `1`。
11
+
12
+ 完整变更见 [CHANGELOG](https://github.com/philomelcc/dsh-workbench/blob/main/CHANGELOG.md)。
13
+
5
14
  它单独装没什么用——至少配一个模块:
6
15
 
7
16
  ```powershell
package/client.js CHANGED
@@ -21,6 +21,61 @@ window.__ModuleLoader__.load({
21
21
  labelSecondary: 'var(--dsw-alias-label-secondary)',
22
22
  };
23
23
 
24
+ // ---------- feature switches ----------
25
+ // The shell is optional decoration on top of the navigation layer: someone
26
+ // who only wants the tab row can turn the rest off and let another plugin
27
+ // own balances, themes or cost estimates. Every switch defaults to on, so
28
+ // installing the shell keeps the previous behaviour.
29
+ const FEATURE_KEYS = ['theme', 'balance', 'restart', 'glow', 'cost'];
30
+ const FEATURE_DEFAULTS = { theme: true, balance: true, restart: true, glow: true, cost: true };
31
+ const FEATURE_LABELS = {
32
+ theme: { title: '主题切换', hint: '顶部栏的深色 / 浅色 / 纸色三个色块' },
33
+ balance: { title: '余额显示', hint: '顶部栏的账户余额' },
34
+ restart: { title: '重启按钮', hint: '顶部栏的一键重启(需要外部管理器)' },
35
+ glow: { title: '进行中光效', hint: '会话运行中时列表行的流动光效' },
36
+ cost: { title: '单次消耗估计', hint: '每轮对话结束时显示的 token 与费用估算' },
37
+ };
38
+
39
+ function createFeaturesStore() {
40
+ const KEY = 'dsh-workbench:features';
41
+ const listeners = new Set();
42
+ let state = read();
43
+
44
+ function read() {
45
+ const next = { ...FEATURE_DEFAULTS };
46
+ try {
47
+ const raw = localStorage.getItem(KEY);
48
+ if (raw) {
49
+ const parsed = JSON.parse(raw);
50
+ for (const key of FEATURE_KEYS) {
51
+ if (typeof parsed[key] === 'boolean') next[key] = parsed[key];
52
+ }
53
+ }
54
+ } catch (_error) { /* storage disabled */ }
55
+ return next;
56
+ }
57
+
58
+ return {
59
+ getSnapshot() { return state; },
60
+ set(key, value) {
61
+ if (!FEATURE_KEYS.includes(key) || state[key] === value) return;
62
+ state = { ...state, [key]: value };
63
+ try { localStorage.setItem(KEY, JSON.stringify(state)); } catch (_error) { /* storage disabled */ }
64
+ for (const fn of listeners) fn();
65
+ },
66
+ subscribe(fn) {
67
+ listeners.add(fn);
68
+ return () => { listeners.delete(fn); };
69
+ },
70
+ };
71
+ }
72
+
73
+ const features = createFeaturesStore();
74
+
75
+ function useFeatures() {
76
+ return React.useSyncExternalStore(features.subscribe, features.getSnapshot);
77
+ }
78
+
24
79
  // ---------- shared UI primitives ----------
25
80
  function StatusDot(props) {
26
81
  const ok = props.ok !== false;
@@ -259,6 +314,33 @@ window.__ModuleLoader__.load({
259
314
  );
260
315
  }
261
316
 
317
+ // ---------- settings icon & page ----------
318
+ const SETTINGS_VIEW_ID = 'workbench-settings';
319
+
320
+ // A panel with its own tab strip: the workbench's mark, distinct from the
321
+ // native "More" grid (four squares) so the two do not read alike.
322
+ function IconWorkbenchTabs(props) {
323
+ const size = props.size || 20;
324
+ return h('svg', { viewBox: '0 0 24 24', width: size, height: size, 'aria-hidden': true, fill: 'none' },
325
+ h('rect', { x: 3.5, y: 4.5, width: 17, height: 15, rx: 2.5, stroke: 'currentColor', strokeWidth: 1.5 }),
326
+ h('path', { d: 'M3.5 9.2h17', stroke: 'currentColor', strokeWidth: 1.4 }),
327
+ h('circle', { cx: 6.4, cy: 6.85, r: 0.85, fill: 'currentColor' }),
328
+ h('circle', { cx: 9.4, cy: 6.85, r: 0.85, fill: 'currentColor' }),
329
+ h('path', { d: 'M6.2 13.2h5.6M6.2 16h8.4', stroke: 'currentColor', strokeWidth: 1.4, strokeLinecap: 'round' }),
330
+ );
331
+ }
332
+
333
+ function IconSettings(props) {
334
+ const size = props.size || 20;
335
+ return h('svg', { viewBox: '0 0 24 24', width: size, height: size, 'aria-hidden': true, fill: 'none' },
336
+ h('circle', { cx: 12, cy: 12, r: 3.2, stroke: 'currentColor', strokeWidth: 1.5 }),
337
+ h('path', {
338
+ d: 'M19.4 13.5a7.7 7.7 0 0 0 0-3l1.7-1.2-1.9-3.3-2 .8a7.6 7.6 0 0 0-2.6-1.5L14.3 3h-3.8l-.3 2.3a7.6 7.6 0 0 0-2.6 1.5l-2-.8-1.9 3.3 1.7 1.2a7.7 7.7 0 0 0 0 3l-1.7 1.2 1.9 3.3 2-.8a7.6 7.6 0 0 0 2.6 1.5l.3 2.3h3.8l.3-2.3a7.6 7.6 0 0 0 2.6-1.5l2 .8 1.9-3.3z',
339
+ stroke: 'currentColor', strokeWidth: 1.4, strokeLinejoin: 'round',
340
+ }),
341
+ );
342
+ }
343
+
262
344
  // ---------- overview page ----------
263
345
  function WorkbenchOverview() {
264
346
  const reg = useRegistry();
@@ -311,8 +393,110 @@ window.__ModuleLoader__.load({
311
393
  // ────────────────────────── top bar (stage 1) ──────────────────────────
312
394
  const TOPBAR_HEIGHT = 102;
313
395
 
396
+ // ---------- settings page ----------
397
+ // Reachable from the gear in the system bar. Two jobs: turn the shell's own
398
+ // extras on or off, and show which modules registered a page.
399
+ function WorkbenchSettings() {
400
+ const enabled = useFeatures();
401
+ const reg = useRegistry();
402
+ const views = Array.from(reg.views.values())
403
+ .filter((item) => item.id !== SETTINGS_VIEW_ID)
404
+ .sort((a, b) => a.order - b.order);
405
+
406
+ function toggleRow(key) {
407
+ const meta = FEATURE_LABELS[key];
408
+ const on = enabled[key];
409
+ return h('div', {
410
+ key,
411
+ style: {
412
+ display: 'flex', alignItems: 'center', gap: 14,
413
+ padding: '12px 14px', borderBottom: '1px solid ' + tokens.border1,
414
+ },
415
+ },
416
+ h('div', { style: { flex: '1 1 auto', minWidth: 0 } },
417
+ h('div', { style: { fontWeight: 550 } }, meta.title),
418
+ h('div', { style: { fontSize: 11, color: tokens.labelSecondary } }, meta.hint),
419
+ ),
420
+ h('button', {
421
+ type: 'button',
422
+ role: 'switch',
423
+ 'aria-checked': on ? 'true' : 'false',
424
+ 'aria-label': meta.title,
425
+ onClick: () => features.set(key, !on),
426
+ style: {
427
+ position: 'relative', width: 40, height: 22, borderRadius: 999,
428
+ border: '1px solid ' + (on ? 'var(--dsw-alias-brand-primary)' : tokens.border2),
429
+ background: on ? 'var(--dsw-alias-brand-primary)' : tokens.bgLayer2,
430
+ cursor: 'pointer', flex: '0 0 auto', padding: 0,
431
+ transition: 'background .15s, border-color .15s',
432
+ },
433
+ },
434
+ h('span', {
435
+ 'aria-hidden': true,
436
+ style: {
437
+ position: 'absolute', top: 2, left: on ? 20 : 2,
438
+ width: 16, height: 16, borderRadius: '50%',
439
+ background: on ? '#fff' : 'var(--dsw-alias-label-secondary)',
440
+ transition: 'left .15s',
441
+ },
442
+ }),
443
+ ),
444
+ );
445
+ }
446
+
447
+ function viewRow(item) {
448
+ return h('div', {
449
+ key: item.id,
450
+ style: {
451
+ display: 'flex', alignItems: 'center', gap: 10,
452
+ padding: '10px 14px', borderBottom: '1px solid ' + tokens.border1,
453
+ },
454
+ },
455
+ h('div', { style: { width: 22, height: 22, color: tokens.labelSecondary, flex: '0 0 auto', display: 'flex', alignItems: 'center', justifyContent: 'center' } },
456
+ item.icon ? h(item.icon, { size: 18, active: false }) : null),
457
+ h('div', { style: { flex: '1 1 auto', minWidth: 0 } },
458
+ h('div', { style: { fontWeight: 550, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, item.title),
459
+ h('div', { style: { fontSize: 11, color: tokens.labelSecondary } },
460
+ (item.tab === false ? '无标签页 · ' : '标签页 · ') + item.id + ' · order ' + item.order),
461
+ ),
462
+ );
463
+ }
464
+
465
+ return h('div', {
466
+ style: {
467
+ padding: '24px 28px', maxWidth: 920, margin: '0 auto', height: '100%',
468
+ boxSizing: 'border-box', overflowY: 'auto',
469
+ color: tokens.label, background: tokens.bgBase,
470
+ },
471
+ },
472
+ h('h2', { style: { margin: '0 0 4px', fontSize: 20, fontWeight: 650 } }, '工作台设置'),
473
+ h('div', { style: { fontSize: 13, color: tokens.labelSecondary, marginBottom: 20 } },
474
+ '只想要导航分层的话,上面这些都可以关掉,交给别的插件。'),
475
+ h('div', null,
476
+ h(SectionHeader, { title: '功能', subtitle: '外壳自带、可以单独关闭的额外功能' }),
477
+ h(Card, null, FEATURE_KEYS.map(toggleRow)),
478
+ ),
479
+ h('div', { style: { marginTop: 20 } },
480
+ h(SectionHeader, { title: '已注册的模块', subtitle: '它们出现在顶部第二行的模块栏' }),
481
+ h(Card, null,
482
+ views.length === 0
483
+ ? h(EmptyState, { text: '还没有模块注册页面。' })
484
+ : views.map(viewRow),
485
+ ),
486
+ ),
487
+ );
488
+ }
489
+
314
490
  async function restartDsh() {
315
- try { await fetch('/wb/workbench/restart', { method: 'POST' }); } catch (_e) {}
491
+ try {
492
+ const res = await fetch('/wb/workbench/restart', { method: 'POST' });
493
+ const json = await res.json().catch(() => null);
494
+ if (json && json.ok === false) {
495
+ window.alert(json.hint || '这台机器上没有可用的重启管理器,请手动重启 DSH Web。');
496
+ }
497
+ } catch (_error) {
498
+ /* offline: nothing to do */
499
+ }
316
500
  }
317
501
 
318
502
  function createBalanceStore(ctx) {
@@ -665,8 +849,74 @@ window.__ModuleLoader__.load({
665
849
  return ThemeSwitcher;
666
850
  }
667
851
 
852
+ // ---------- native sidebar panel mirror ----------
853
+ // The workbench keeps no navigation list of its own: whatever the sidebar
854
+ // lists (Plugins, Automations, More — plus any plugin that registers a
855
+ // panel later) is mirrored into the top bar, click delegated to that row.
856
+ function createPanelMirror() {
857
+ function PanelMirror() {
858
+ const [items, setItems] = React.useState(function () { return []; });
859
+
860
+ React.useEffect(function () {
861
+ function sync() {
862
+ const list = document.querySelector('nav[class*="panelList"]');
863
+ if (!list) return;
864
+ const next = [];
865
+ Array.prototype.forEach.call(list.children, function (node) {
866
+ const btn = node.tagName === 'BUTTON'
867
+ ? node
868
+ : (node.querySelector ? node.querySelector('button') : null);
869
+ if (!btn) return;
870
+ const label = btn.getAttribute('aria-label') || String(node.textContent || '').trim();
871
+ const svg = btn.querySelector('svg');
872
+ next.push({ label: label || '入口', html: svg ? svg.outerHTML : '', btn: btn });
873
+ });
874
+ setItems(function (prev) {
875
+ const same = prev.length === next.length && prev.every(function (item, index) {
876
+ return item.label === next[index].label && item.html === next[index].html;
877
+ });
878
+ return same ? prev : next;
879
+ });
880
+ }
881
+ sync();
882
+ const observer = new MutationObserver(sync);
883
+ observer.observe(document.body, { childList: true, subtree: true });
884
+ return function () { observer.disconnect(); };
885
+ }, []);
886
+
887
+ return h('div', { className: 'wb-topbar-icons', style: { display: 'flex', gap: 2, flex: 1, overflow: 'hidden' } },
888
+ items.map(function (item, index) {
889
+ return h('button', {
890
+ key: item.label + ':' + index,
891
+ type: 'button',
892
+ className: 'wb-nav-item',
893
+ 'aria-label': item.label,
894
+ onClick: function () {
895
+ try { item.btn.click(); } catch (_error) { /* row went away */ }
896
+ },
897
+ style: {
898
+ display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
899
+ gap: 4, height: 34, padding: '0 9px', borderRadius: 8, border: 'none',
900
+ background: 'transparent', color: 'var(--dsw-alias-label-secondary)',
901
+ cursor: 'pointer', flex: '0 0 auto', transition: 'background .15s, color .15s',
902
+ },
903
+ },
904
+ h('span', {
905
+ 'aria-hidden': true,
906
+ style: { display: 'inline-flex', width: 18, height: 18, flex: '0 0 auto' },
907
+ dangerouslySetInnerHTML: { __html: item.html },
908
+ }),
909
+ h('span', { className: 'wb-nav-label' }, item.label),
910
+ );
911
+ }),
912
+ );
913
+ }
914
+ return PanelMirror;
915
+ }
916
+
668
917
  function createTopBar(ctx, balance) {
669
918
  const ThemeSwitcher = createThemeSwitcher(ctx);
919
+ const PanelMirror = createPanelMirror();
670
920
 
671
921
  function BalanceChip() {
672
922
  const snap = React.useSyncExternalStore(balance.subscribe, balance.getSnapshot);
@@ -731,6 +981,7 @@ window.__ModuleLoader__.load({
731
981
  function TopBar(props) {
732
982
  const rootRef = React.useRef(null);
733
983
  const tabsRef = React.useRef(null);
984
+ const enabled = useFeatures();
734
985
  const reg = useRegistry();
735
986
  const usePanelInfo = props.usePanelInfo;
736
987
  const panelInfo = typeof usePanelInfo === 'function' ? usePanelInfo((s) => s) : null;
@@ -887,12 +1138,25 @@ window.__ModuleLoader__.load({
887
1138
  return h('div', { ref: rootRef, style: { position: 'fixed', top: 0, left: 0, right: 0, height: TOPBAR_HEIGHT, zIndex: 100, pointerEvents: 'auto', display: 'flex', flexDirection: 'column', background: 'var(--dsw-alias-bg-base)', borderBottom: '1px solid var(--dsw-alias-border-l1)' } },
888
1139
  h('div', { style: { height: 56, display: 'flex', alignItems: 'center', gap: 10, padding: '0 14px' } },
889
1140
  h('div', { className: 'wb-topbar-brand', style: { width: 214, flex: '0 0 auto' } }),
890
- h('div', { className: 'wb-topbar-icons', style: { display: 'flex', gap: 2, flex: 1, overflow: 'hidden' } },
891
- iconBtn('workbench', '工作台', IconGrid),
892
- iconBtn('plugins', '插件', IconPlugins),
893
- iconBtn('schedules', '自动化任务', IconClock),
1141
+ // System-level entry, first in the top-left corner: the workbench's
1142
+ // own mark opens its settings (feature switches + module list).
1143
+ h('button', {
1144
+ type: 'button',
1145
+ className: 'wb-nav-item wb-settings-entry',
1146
+ 'aria-label': '工作台设置',
1147
+ onClick: () => selectPanel(SETTINGS_VIEW_ID),
1148
+ style: {
1149
+ display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
1150
+ gap: 4, height: 34, padding: '0 9px', borderRadius: 8, border: 'none',
1151
+ background: 'transparent', color: 'var(--dsw-alias-label-secondary)',
1152
+ cursor: 'pointer', flex: '0 0 auto', transition: 'background .15s, color .15s',
1153
+ },
1154
+ },
1155
+ h(IconWorkbenchTabs, { size: 18 }),
1156
+ h('span', { className: 'wb-nav-label' }, '工作台'),
894
1157
  ),
895
- h(ThemeSwitcher),
1158
+ h(PanelMirror),
1159
+ enabled.theme ? h(ThemeSwitcher) : null,
896
1160
  ),
897
1161
  h('div', { style: { height: 45, display: 'flex', alignItems: 'center', gap: 8, padding: '0 14px', borderTop: '1px solid var(--dsw-alias-border-l1)' } },
898
1162
  h('div', { ref: tabsRef, style: { position: 'relative', display: 'flex', alignItems: 'center', gap: 4, flex: 1, overflowX: 'auto', overflowY: 'hidden' } },
@@ -915,12 +1179,12 @@ window.__ModuleLoader__.load({
915
1179
  tabs.map(viewTab),
916
1180
  ),
917
1181
  h('div', { style: { display: 'flex', alignItems: 'center', gap: 8, flex: '0 0 auto' } },
918
- h(BalanceChip, null),
919
- h('button', {
1182
+ enabled.balance ? h(BalanceChip, null) : null,
1183
+ enabled.restart ? h('button', {
920
1184
  type: 'button', title: '重启 DSH', className: 'wb-restart',
921
1185
  onClick: () => { restartDsh(ctx); },
922
1186
  style: { width: 28, height: 28, display: 'inline-flex', alignItems: 'center', justifyContent: 'center', borderRadius: 8, border: '1px solid var(--dsw-alias-border-l2)', background: 'transparent', color: 'var(--dsw-alias-label-secondary)', cursor: 'pointer', flex: '0 0 auto', transition: 'background .12s, color .12s, border-color .12s' },
923
- }, h(IconRestart, { size: 15 })),
1187
+ }, h(IconRestart, { size: 15 })) : null,
924
1188
  ),
925
1189
  ),
926
1190
  );
@@ -1001,9 +1265,16 @@ window.__ModuleLoader__.load({
1001
1265
  return key.indexOf("v4-pro") >= 0 ? PRICING["deepseek-v4-pro"] : PRICING["deepseek-flash"];
1002
1266
  }
1003
1267
 
1268
+ function timeMs(value) {
1269
+ var n = typeof value === "number" ? value : Number(value);
1270
+ if (!isFinite(n) || n <= 0) return undefined;
1271
+ // Turn times can arrive in seconds; anything below 1e12 ms is seconds.
1272
+ return n < 1e12 ? n * 1000 : n;
1273
+ }
1274
+
1004
1275
  function computeCost(usage, ts) {
1005
1276
  var model = modelOf(usage);
1006
- var peak = isPeak(ts);
1277
+ var peak = isPeak(timeMs(ts));
1007
1278
  var table = peak ? pricesFor(model).peak : pricesFor(model).offPeak;
1008
1279
  var hit = num(usage && usage.cacheReadTokens);
1009
1280
  var miss = num(usage && usage.uncachedInputTokens) + num(usage && usage.cacheWriteTokens);
@@ -1063,7 +1334,7 @@ window.__ModuleLoader__.load({
1063
1334
  ? entry.data.get("turn-tail")
1064
1335
  : undefined;
1065
1336
  if (tail && tail.tokenUsage && matchesTail(tail, seq, turnId)) {
1066
- return tail.tokenUsage;
1337
+ return tail;
1067
1338
  }
1068
1339
  }
1069
1340
  }
@@ -1072,7 +1343,7 @@ window.__ModuleLoader__.load({
1072
1343
  var found;
1073
1344
  snapshot.nodes.forEach(function (node) {
1074
1345
  if (!node || node.kind !== "turn-tail" || !node.data || !node.data.tokenUsage) return;
1075
- if (matchesTail(node.data, seq, turnId)) found = node.data.tokenUsage;
1346
+ if (matchesTail(node.data, seq, turnId)) found = node.data;
1076
1347
  });
1077
1348
  if (found) return found;
1078
1349
  }
@@ -1127,7 +1398,9 @@ window.__ModuleLoader__.load({
1127
1398
  });
1128
1399
  }
1129
1400
  if (!usage) return null;
1130
- var entry = computeCost(usage, Date.now());
1401
+ // Price by the turn's own timestamp, not by when this row happens to
1402
+ // render: otherwise every historical turn reprices at the current hour.
1403
+ var entry = computeCost(usage.tokenUsage, usage.time);
1131
1404
  return h(
1132
1405
  "div",
1133
1406
  {
@@ -1548,6 +1821,17 @@ body[data-wb-paper="1"] [class*="_hoverPath"] {
1548
1821
  tab: false,
1549
1822
  }), 'workbench: overview view');
1550
1823
 
1824
+ // Settings page: no tab — it is reached from the gear in the system bar.
1825
+ ctx.effect(() => api.registerView({
1826
+ id: SETTINGS_VIEW_ID,
1827
+ title: '工作台设置',
1828
+ subtitle: '功能开关与已注册的模块',
1829
+ icon: IconSettings,
1830
+ component: WorkbenchSettings,
1831
+ order: -20,
1832
+ tab: false,
1833
+ }), 'workbench: settings view');
1834
+
1551
1835
  const balance = createBalanceStore(ctx);
1552
1836
  try {
1553
1837
  ctx.effect(() => { balance.start(); return () => balance.stop(); }, 'workbench: balance polling');
@@ -1571,15 +1855,30 @@ body[data-wb-paper="1"] [class*="_hoverPath"] {
1571
1855
  }
1572
1856
 
1573
1857
  const TurnCost = createTurnCost();
1858
+ function TurnCostGated(props) {
1859
+ const enabled = useFeatures();
1860
+ if (!enabled.cost) return null;
1861
+ return h(TurnCost, props);
1862
+ }
1574
1863
  ctx.slots.inject('conversation.chat.turnTail', () => ctx.slots.register({
1575
1864
  name: 'conversation.chat.turnTail',
1576
1865
  id: 'workbench-turn-cost',
1577
1866
  order: 600,
1578
- }, TurnCost));
1867
+ }, TurnCostGated));
1579
1868
 
1580
1869
  // 注意:ctx.effect 接收的是「返回 dispose 的函数」。
1581
1870
  // 直接传 installGlow(ctx) 的返回值会让 effect 立刻执行清理,光效刚装上就被拆掉。
1582
- ctx.effect(() => installGlow(ctx), 'workbench: glow');
1871
+ // The switch can turn it off at runtime, so mount/unmount follows the store.
1872
+ ctx.effect(() => {
1873
+ let dispose = null;
1874
+ const sync = () => {
1875
+ if (dispose) { dispose(); dispose = null; }
1876
+ if (features.getSnapshot().glow) dispose = installGlow(ctx);
1877
+ };
1878
+ sync();
1879
+ const unsubscribe = features.subscribe(sync);
1880
+ return () => { unsubscribe(); if (dispose) dispose(); };
1881
+ }, 'workbench: glow');
1583
1882
 
1584
1883
  } catch (err) {
1585
1884
  console.error('[workbench] apply failed:', err);
package/index.js CHANGED
@@ -2,12 +2,39 @@
2
2
  * Host half of the workbench shell.
3
3
  *
4
4
  * The workbench's value lives in the Client half (module API + overview page).
5
- * The only Host duty is the one-click restart endpoint, which stops the running
6
- * web host in a detached process and lets the scheduled task relaunch it, so the
7
- * current page simply reconnects.
5
+ * The only Host duty is the one-click restart endpoint: it stops the running web
6
+ * host and lets an external supervisor start it again, so the current page
7
+ * simply reconnects. A restart is only offered when such a supervisor is
8
+ * actually present — exiting without one would strand the user with a dead
9
+ * server.
8
10
  */
9
11
  import { execSync } from 'node:child_process';
10
12
 
13
+ /** Supervisor that can start the host again, resolved per platform. */
14
+ function resolveSupervisor() {
15
+ const command = process.env.DSH_RESTART_CMD || '';
16
+ if (command) return { kind: 'command', command };
17
+ if (process.platform !== 'win32') return null;
18
+ // Windows: a scheduled task. DSH_RESTART_TASK overrides the name, so the
19
+ // default stays useful for the author's machine without hardcoding it for
20
+ // everyone else.
21
+ const task = process.env.DSH_RESTART_TASK || 'philomel-dsh-web';
22
+ try {
23
+ execSync(`schtasks /query /tn ${JSON.stringify(task)}`, { windowsHide: true, stdio: 'ignore' });
24
+ return { kind: 'task', task };
25
+ } catch (_error) {
26
+ return null;
27
+ }
28
+ }
29
+
30
+ function startSupervisor(supervisor) {
31
+ if (supervisor.kind === 'command') {
32
+ execSync(supervisor.command, { windowsHide: true, stdio: 'ignore' });
33
+ return;
34
+ }
35
+ execSync(`schtasks /run /tn ${JSON.stringify(supervisor.task)}`, { windowsHide: true, stdio: 'ignore' });
36
+ }
37
+
11
38
  export const inject = ['webServer'];
12
39
 
13
40
  export function apply(ctx) {
@@ -28,12 +55,21 @@ export function apply(ctx) {
28
55
  res.end('method not allowed\n');
29
56
  return;
30
57
  }
31
- // 先看 3080 是否在跑,再决定要不要让 ensure 脚本拉起。
32
- // 响应先到达页面,然后本进程退出,由每 5 分钟一次的 ensure 任务重新拉起。
58
+ // Refuse without a supervisor: exiting here would leave no server behind.
59
+ const supervisor = resolveSupervisor();
60
+ if (!supervisor) {
61
+ res.writeHead(409, { 'Content-Type': 'application/json; charset=utf-8' });
62
+ res.end(JSON.stringify({
63
+ ok: false,
64
+ reason: 'no-supervisor',
65
+ hint: '这台机器上没有可用的重启管理器,请手动重启 DSH Web。',
66
+ }));
67
+ return;
68
+ }
33
69
  res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
34
70
  res.end(JSON.stringify({ ok: true, restarting: true, etaSeconds: 300 }));
35
71
  setTimeout(() => {
36
- try { execSync('schtasks /run /tn philomel-dsh-web', { windowsHide: true, stdio: 'ignore' }); } catch (_e) {}
72
+ try { startSupervisor(supervisor); } catch (_e) { /* best effort */ }
37
73
  process.exit(0);
38
74
  }, 300);
39
75
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@philomelcc/dsh-workbench",
3
- "version": "0.1.8",
3
+ "version": "0.1.9",
4
4
  "type": "module",
5
5
  "description": "Workbench shell for DeepSeek Harness: a view/page module API, shared UI primitives and a top-bar switcher.",
6
6
  "license": "MIT",
@@ -14,20 +14,34 @@
14
14
  "bugs": {
15
15
  "url": "https://github.com/philomelcc/dsh-workbench/issues"
16
16
  },
17
- "keywords": ["dsh", "deepseek-harness", "workbench", "plugin", "web-ui"],
17
+ "keywords": [
18
+ "dsh",
19
+ "deepseek-harness",
20
+ "workbench",
21
+ "plugin",
22
+ "web-ui"
23
+ ],
18
24
  "exports": {
19
25
  ".": "./index.js",
20
26
  "./client": "./client.js",
21
27
  "./locale/*.json": "./locale/*.json",
22
28
  "./icon": "./icon.svg"
23
29
  },
24
- "files": ["index.js", "client.js", "cordis.patch.yml", "locale/*.json", "icon.svg"],
30
+ "files": [
31
+ "index.js",
32
+ "client.js",
33
+ "cordis.patch.yml",
34
+ "locale/*.json",
35
+ "icon.svg"
36
+ ],
25
37
  "publishConfig": {
26
38
  "access": "public",
27
39
  "registry": "https://registry.npmjs.org/"
28
40
  },
29
41
  "dsh": {
30
- "bundle": { "patch": "./cordis.patch.yml" },
42
+ "bundle": {
43
+ "patch": "./cordis.patch.yml"
44
+ },
31
45
  "client": {
32
46
  "platform": "web",
33
47
  "inject": [
@@ -38,4 +52,4 @@
38
52
  ]
39
53
  }
40
54
  }
41
- }
55
+ }