@philomelcc/dsh-workbench 0.1.7 → 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 +365 -27
  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) {
@@ -470,6 +654,11 @@ window.__ModuleLoader__.load({
470
654
  '--dsw-specific-input-major': '#f2ece0',
471
655
  '--dsw-specific-tip': '#f7f2e7',
472
656
  '--dsw-specific-menu': '#fcf9f3',
657
+ // Scrollbars read their own alias pair and default to neutral grey.
658
+ '--dsw-alias-scrollbar-bg-l1': '#d8cfba',
659
+ '--dsw-alias-scrollbar-bg-l2': '#d8cfba',
660
+ '--dsw-alias-scrollbar-hover-l1': '#c6bba2',
661
+ '--dsw-alias-scrollbar-hover-l2': '#c6bba2',
473
662
  // The neutral greys are blue-tinted. Buttons ("load earlier"), inputs,
474
663
  // chips and cards read their pale surfaces from this ramp, so warm it up
475
664
  // too; deep steps stay untouched because they are text/borders.
@@ -555,12 +744,15 @@ window.__ModuleLoader__.load({
555
744
  return;
556
745
  }
557
746
  const rect = settings.getBoundingClientRect();
558
- if (rect.width > 0) box.style.right = (rect.width + 16 + 12) + 'px';
559
- // Same box as the account block, so `align-items: center` lands on
560
- // the account's optical center instead of a guessed offset.
561
- if (rect.height > 0) {
747
+ if (rect.width > 0) {
748
+ box.style.right = (rect.width + 16 + 12) + 'px';
562
749
  box.style.top = rect.top + 'px';
563
750
  box.style.height = rect.height + 'px';
751
+ } else {
752
+ // Narrow screens hide the account block; pin the swatches right.
753
+ box.style.right = '12px';
754
+ box.style.top = '2px';
755
+ box.style.height = '40px';
564
756
  }
565
757
  };
566
758
  place();
@@ -657,8 +849,74 @@ window.__ModuleLoader__.load({
657
849
  return ThemeSwitcher;
658
850
  }
659
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
+
660
917
  function createTopBar(ctx, balance) {
661
918
  const ThemeSwitcher = createThemeSwitcher(ctx);
919
+ const PanelMirror = createPanelMirror();
662
920
 
663
921
  function BalanceChip() {
664
922
  const snap = React.useSyncExternalStore(balance.subscribe, balance.getSnapshot);
@@ -723,6 +981,7 @@ window.__ModuleLoader__.load({
723
981
  function TopBar(props) {
724
982
  const rootRef = React.useRef(null);
725
983
  const tabsRef = React.useRef(null);
984
+ const enabled = useFeatures();
726
985
  const reg = useRegistry();
727
986
  const usePanelInfo = props.usePanelInfo;
728
987
  const panelInfo = typeof usePanelInfo === 'function' ? usePanelInfo((s) => s) : null;
@@ -777,12 +1036,32 @@ window.__ModuleLoader__.load({
777
1036
  if (wsRoot) {
778
1037
  Array.from(wsRoot.children).forEach((g, i) => { if (i > 0) g.style.display = 'none'; });
779
1038
  }
1039
+ // Narrow screens keep the official sidebar toggle: the workbench has no
1040
+ // session list of its own, so hiding it strands phone users.
1041
+ const narrowScreen = typeof window !== 'undefined' && window.innerWidth < 768;
780
1042
  document.querySelectorAll('button[aria-label]').forEach((b) => {
781
1043
  const label = b.getAttribute('aria-label') || '';
782
- if (label.includes('侧栏') || label.includes('侧边栏') || label.includes('折叠') || label.includes('collapse') || label.includes('toggle')) {
1044
+ if (!narrowScreen && (label.includes('侧栏') || label.includes('侧边栏') || label.includes('折叠') || label.includes('collapse') || label.includes('toggle'))) {
783
1045
  b.style.display = 'none';
784
1046
  }
785
1047
  });
1048
+ if (narrowScreen) {
1049
+ // The official toggle lives inside the translated sidebar container,
1050
+ // so a fixed copy would ride along with it. Own button in <body>,
1051
+ // clicks delegated to the official one.
1052
+ const original = Array.from(document.querySelectorAll('button[aria-label]'))
1053
+ .find((b) => b.getAttribute('aria-label') === '打开侧边栏');
1054
+ if (original) {
1055
+ const toggle = document.createElement('button');
1056
+ toggle.type = 'button';
1057
+ toggle.className = 'wb-mobile-sidebar-toggle';
1058
+ toggle.setAttribute('aria-label', '显示会话列表');
1059
+ toggle.textContent = '☰';
1060
+ toggle.addEventListener('click', () => original.click());
1061
+ document.body.appendChild(toggle);
1062
+ cleanup.push(() => toggle.remove());
1063
+ }
1064
+ }
786
1065
  const settings = document.querySelector('[class*="settingsArea"]');
787
1066
  if (settings) {
788
1067
  settings.style.position = 'fixed';
@@ -858,13 +1137,26 @@ window.__ModuleLoader__.load({
858
1137
 
859
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)' } },
860
1139
  h('div', { style: { height: 56, display: 'flex', alignItems: 'center', gap: 10, padding: '0 14px' } },
861
- h('div', { style: { width: 214, flex: '0 0 auto' } }),
862
- h('div', { style: { display: 'flex', gap: 2, flex: 1, overflow: 'hidden' } },
863
- iconBtn('workbench', '工作台', IconGrid),
864
- iconBtn('plugins', '插件', IconPlugins),
865
- iconBtn('schedules', '自动化任务', IconClock),
1140
+ h('div', { className: 'wb-topbar-brand', style: { width: 214, flex: '0 0 auto' } }),
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' }, '工作台'),
866
1157
  ),
867
- h(ThemeSwitcher),
1158
+ h(PanelMirror),
1159
+ enabled.theme ? h(ThemeSwitcher) : null,
868
1160
  ),
869
1161
  h('div', { style: { height: 45, display: 'flex', alignItems: 'center', gap: 8, padding: '0 14px', borderTop: '1px solid var(--dsw-alias-border-l1)' } },
870
1162
  h('div', { ref: tabsRef, style: { position: 'relative', display: 'flex', alignItems: 'center', gap: 4, flex: 1, overflowX: 'auto', overflowY: 'hidden' } },
@@ -887,12 +1179,12 @@ window.__ModuleLoader__.load({
887
1179
  tabs.map(viewTab),
888
1180
  ),
889
1181
  h('div', { style: { display: 'flex', alignItems: 'center', gap: 8, flex: '0 0 auto' } },
890
- h(BalanceChip, null),
891
- h('button', {
1182
+ enabled.balance ? h(BalanceChip, null) : null,
1183
+ enabled.restart ? h('button', {
892
1184
  type: 'button', title: '重启 DSH', className: 'wb-restart',
893
1185
  onClick: () => { restartDsh(ctx); },
894
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' },
895
- }, h(IconRestart, { size: 15 })),
1187
+ }, h(IconRestart, { size: 15 })) : null,
896
1188
  ),
897
1189
  ),
898
1190
  );
@@ -973,9 +1265,16 @@ window.__ModuleLoader__.load({
973
1265
  return key.indexOf("v4-pro") >= 0 ? PRICING["deepseek-v4-pro"] : PRICING["deepseek-flash"];
974
1266
  }
975
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
+
976
1275
  function computeCost(usage, ts) {
977
1276
  var model = modelOf(usage);
978
- var peak = isPeak(ts);
1277
+ var peak = isPeak(timeMs(ts));
979
1278
  var table = peak ? pricesFor(model).peak : pricesFor(model).offPeak;
980
1279
  var hit = num(usage && usage.cacheReadTokens);
981
1280
  var miss = num(usage && usage.uncachedInputTokens) + num(usage && usage.cacheWriteTokens);
@@ -1035,7 +1334,7 @@ window.__ModuleLoader__.load({
1035
1334
  ? entry.data.get("turn-tail")
1036
1335
  : undefined;
1037
1336
  if (tail && tail.tokenUsage && matchesTail(tail, seq, turnId)) {
1038
- return tail.tokenUsage;
1337
+ return tail;
1039
1338
  }
1040
1339
  }
1041
1340
  }
@@ -1044,7 +1343,7 @@ window.__ModuleLoader__.load({
1044
1343
  var found;
1045
1344
  snapshot.nodes.forEach(function (node) {
1046
1345
  if (!node || node.kind !== "turn-tail" || !node.data || !node.data.tokenUsage) return;
1047
- if (matchesTail(node.data, seq, turnId)) found = node.data.tokenUsage;
1346
+ if (matchesTail(node.data, seq, turnId)) found = node.data;
1048
1347
  });
1049
1348
  if (found) return found;
1050
1349
  }
@@ -1099,7 +1398,9 @@ window.__ModuleLoader__.load({
1099
1398
  });
1100
1399
  }
1101
1400
  if (!usage) return null;
1102
- 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);
1103
1404
  return h(
1104
1405
  "div",
1105
1406
  {
@@ -1184,6 +1485,9 @@ window.__ModuleLoader__.load({
1184
1485
  z-index: 1;
1185
1486
  filter: drop-shadow(0 0 2px rgba(170, 200, 255, 0.65));
1186
1487
  }
1488
+ body[data-wb-paper="1"] .dsh-glow-canvas {
1489
+ filter: drop-shadow(0 0 2px rgba(198, 156, 96, 0.6));
1490
+ }
1187
1491
  /* 隐藏运行中会话左侧的转圈:不依赖 JS 加的 class,React 重建行时也能兜底 */
1188
1492
  div[data-row-key^="session:"]:has(svg[data-state="ongoing"]) svg[data-state="ongoing"],
1189
1493
  .dsh-glow-on svg[data-state="ongoing"],
@@ -1378,12 +1682,18 @@ body[data-wb-paper="1"] [class*="_hoverPath"] {
1378
1682
  var breath = 0.5 + 0.5 * Math.sin((now / 2800) * Math.PI * 2);
1379
1683
  var dim = entry.lights === 1 ? 0.35 : 1;
1380
1684
  var edgeAlpha = (0.12 + breath * 0.33) * dim;
1685
+ // Paper turns the blue streak into a bronze one; blue reads as ink
1686
+ // smear on a warm sheet.
1687
+ var paperNow = false;
1688
+ try { paperNow = document.body.hasAttribute("data-wb-paper"); } catch (_error) { paperNow = false; }
1689
+ var glowBase = paperNow ? [138, 106, 69] : [99, 149, 255];
1690
+ var glowHot = paperNow ? [198, 156, 96] : [205, 180, 255];
1381
1691
 
1382
1692
  context.save();
1383
1693
  context.lineWidth = 1;
1384
- context.strokeStyle = "rgba(99, 149, 255, " + edgeAlpha.toFixed(3) + ")";
1694
+ context.strokeStyle = "rgba(" + glowBase[0] + ", " + glowBase[1] + ", " + glowBase[2] + ", " + edgeAlpha.toFixed(3) + ")";
1385
1695
  context.shadowBlur = (4 + breath * 10) * dim;
1386
- context.shadowColor = "rgba(99, 149, 255, " + ((0.2 + breath * 0.35) * dim).toFixed(3) + ")";
1696
+ context.shadowColor = "rgba(" + glowBase[0] + ", " + glowBase[1] + ", " + glowBase[2] + ", " + ((0.2 + breath * 0.35) * dim).toFixed(3) + ")";
1387
1697
  context.beginPath();
1388
1698
  if (typeof context.roundRect === "function") {
1389
1699
  context.roundRect(0.5, 0.5, width - 1, height - 1, r);
@@ -1400,9 +1710,9 @@ body[data-wb-paper="1"] [class*="_hoverPath"] {
1400
1710
  if (weight < 0.02) continue;
1401
1711
  var p0 = pointOnRect(width, height, phase);
1402
1712
  var p1 = pointOnRect(width, height, (i + 1) / count);
1403
- var rChannel = Math.round(99 + (205 - 99) * weight);
1404
- var gChannel = Math.round(149 + (180 - 149) * weight);
1405
- var bChannel = 255;
1713
+ var rChannel = Math.round(glowBase[0] + (glowHot[0] - glowBase[0]) * weight);
1714
+ var gChannel = Math.round(glowBase[1] + (glowHot[1] - glowBase[1]) * weight);
1715
+ var bChannel = Math.round(glowBase[2] + (glowHot[2] - glowBase[2]) * weight);
1406
1716
  // lights === 1 表示「只有后台任务」,整体压暗。
1407
1717
  var alpha = (0.05 + 0.95 * weight) * (entry.lights === 1 ? 0.35 : 1);
1408
1718
  context.strokeStyle =
@@ -1475,7 +1785,9 @@ body[data-wb-paper="1"] [class*="_hoverPath"] {
1475
1785
  + '\n.wb-session-row { display: flex; align-items: center; gap: 6px; width: 100%; padding: 5px 8px; border: none; border-radius: 6px; background: transparent; color: var(--dsw-alias-label-primary); cursor: pointer; text-align: left; font-size: 12px; }'
1476
1786
  + '\n.wb-session-row:hover { background: var(--dsw-alias-bg-layer-2); }'
1477
1787
  + '\n.wb-session-row .wb-session-more { opacity: 0; transition: opacity .12s; }'
1478
- + '\n.wb-balance-tip { position: absolute; top: calc(100% + 6px); right: 0; z-index: 500; padding: 8px 10px; border-radius: 8px; background: #23262c; border: 1px solid #2f333b; color: var(--dsw-alias-label-primary); font-size: 11px; line-height: 1.7; white-space: nowrap; max-width: 240px; box-sizing: border-box; box-shadow: 0 6px 20px rgba(0,0,0,.28); pointer-events: none; }'
1788
+ + '\n.wb-balance-tip { position: absolute; top: calc(100% + 6px); right: 0; z-index: 500; padding: 8px 10px; border-radius: 8px; background: var(--dsw-alias-bg-overlay, #23262c); border: 1px solid var(--dsw-alias-border-l2, #2f333b); color: var(--dsw-alias-label-primary); font-size: 11px; line-height: 1.7; white-space: nowrap; max-width: 240px; box-sizing: border-box; box-shadow: 0 6px 20px rgba(0,0,0,.28); pointer-events: none; }'
1789
+ + '\n.wb-mobile-sidebar-toggle { position: fixed; left: 10px; top: 10px; z-index: 5000; width: 30px; height: 30px; border-radius: 8px; border: 1px solid var(--dsw-alias-border-l2); background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-primary); font-size: 15px; line-height: 1; display: inline-flex; align-items: center; justify-content: center; cursor: pointer; padding: 0; box-shadow: 0 1px 4px rgba(0,0,0,.12); }'
1790
+ + '\n@media (max-width: 767px) { .wb-topbar-brand, [class*="settingsArea"] { display: none !important; } .wb-topbar-icons { margin-left: 42px; } [class*="sidebarCol"] [class*="brand"] { display: none !important; } [class*="sidebarCol"] { padding-top: 44px !important; } [aria-label="收起侧边栏"] { display: none !important; } }'
1479
1791
  + '\n.wb-balance:hover { border-color: #4ade80 !important; background: rgba(74,222,128,.12) !important; }'
1480
1792
  + '\n.wb-nav-item { position: relative; }'
1481
1793
  + '\n.wb-nav-label { max-width: 0; opacity: 0; overflow: hidden; white-space: nowrap; font-size: 12px; transition: max-width .18s ease, opacity .18s ease; }'
@@ -1509,6 +1821,17 @@ body[data-wb-paper="1"] [class*="_hoverPath"] {
1509
1821
  tab: false,
1510
1822
  }), 'workbench: overview view');
1511
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
+
1512
1835
  const balance = createBalanceStore(ctx);
1513
1836
  try {
1514
1837
  ctx.effect(() => { balance.start(); return () => balance.stop(); }, 'workbench: balance polling');
@@ -1532,15 +1855,30 @@ body[data-wb-paper="1"] [class*="_hoverPath"] {
1532
1855
  }
1533
1856
 
1534
1857
  const TurnCost = createTurnCost();
1858
+ function TurnCostGated(props) {
1859
+ const enabled = useFeatures();
1860
+ if (!enabled.cost) return null;
1861
+ return h(TurnCost, props);
1862
+ }
1535
1863
  ctx.slots.inject('conversation.chat.turnTail', () => ctx.slots.register({
1536
1864
  name: 'conversation.chat.turnTail',
1537
1865
  id: 'workbench-turn-cost',
1538
1866
  order: 600,
1539
- }, TurnCost));
1867
+ }, TurnCostGated));
1540
1868
 
1541
1869
  // 注意:ctx.effect 接收的是「返回 dispose 的函数」。
1542
1870
  // 直接传 installGlow(ctx) 的返回值会让 effect 立刻执行清理,光效刚装上就被拆掉。
1543
- 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');
1544
1882
 
1545
1883
  } catch (err) {
1546
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.7",
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
+ }