@gehennawu/dsh-service 0.13.2 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.en.md CHANGED
@@ -8,10 +8,12 @@ A service-control and operations plugin for self-hosted DSH Web. Provides safe r
8
8
 
9
9
  ## Features
10
10
 
11
+ The Settings panel "Service Control" page has six top-level tabs: **Overview, Notifications, Health, Model stats, Backups, Restart**; the Restart tab can also enable a `Restart` quick entry at the bottom of the settings left navigation (off by default).
12
+
11
13
  ### Version and updates
12
14
 
13
15
  - Displays current DSH and plugin versions with links to GitHub Releases
14
- - Automatically checks npm registry for stable and preview releases, shows update status
16
+ - Automatically checks npm registry for stable and preview releases; when a new version exists, the right-side status text (chevron + `New version`) is clickable to expand/collapse, showing the current/latest versions and both dist-tag versions inline, each with npmjs and npmmirror links (npmjs.com is blocked on some networks; npmmirror serves as the mirror entry)
15
17
  - One-click plugin upgrade with automatic restart after completion
16
18
 
17
19
  ### Safe restart
@@ -19,6 +21,7 @@ A service-control and operations plugin for self-hosted DSH Web. Provides safe r
19
21
  - Detects active agents, background jobs, and terminals before restart; lists them and requires explicit confirmation
20
22
  - `/restart` command also available in conversations; automatically refuses when active work is detected
21
23
  - Automatically probes for the new process after restart and reloads the page; manual reload available after 60 seconds
24
+ - Optional `Restart` entry at the bottom of the settings left navigation, enabled by a switch in the Restart tab (off by default), sharing the exact same confirmation flow as the Restart tab
22
25
 
23
26
  ### Health diagnostics
24
27
 
@@ -30,7 +33,8 @@ A service-control and operations plugin for self-hosted DSH Web. Provides safe r
30
33
 
31
34
  - 7-day stacked bar chart of input/output/cache tokens with blue/orange/teal legend
32
35
  - Filter by project; hover for exact values
33
- - Model breakdown sorted by steps: `x times · Cache hit x% · Input xM token · Output xM token`
36
+ - Model breakdown as horizontal stacked bars (same legend colors), sorted by total tokens, largest first; each row keeps `x times · Cache hit x% · Input xM token · Output xM token`
37
+ - Steps whose provider reports no token usage are excluded from the statistics
34
38
  - Last-24-hour model/tool error statistics, collapsed by default
35
39
 
36
40
  ### Backup management
@@ -43,7 +47,8 @@ A service-control and operations plugin for self-hosted DSH Web. Provides safe r
43
47
 
44
48
  ### Task notifications
45
49
 
46
- - Browser notification when a session finishes its turn, or when your approval, plan review, or answer is needed
50
+ - Notification settings live in the top-level Notifications tab: browser notification when a session finishes its turn, or when your approval, plan review, or answer is needed
51
+ - Clicking a notification focuses the DSH page and closes the popup
47
52
  - Three toggle switches: master switch, task completion, and approvals & questions, each controlled independently
48
53
  - Bell icon in the conversation input bar toggles the master switch quickly
49
54
  - All toggles persist across page reloads
package/README.md CHANGED
@@ -8,10 +8,12 @@
8
8
 
9
9
  ## 功能
10
10
 
11
+ 设置页「服务控制」面板包含六个顶部标签:**概览、通知、健康诊断、模型统计、备份维护、重启**;重启标签还可在设置页左侧标签列底部开启「重启」快捷入口(默认关闭)。
12
+
11
13
  ### 版本与更新
12
14
 
13
15
  - 显示当前 DSH 和插件版本,版本号链接到 GitHub Releases
14
- - 自动检查 npm registry 的正式版和预览版,显示更新状态
16
+ - 自动检查 npm registry 的正式版和预览版;有新版本时右侧「小三角 + 有新版本」状态文本可点击展开/收起,行内展示当前/最新版本与正式版/预览版双 tag,版本号分别带 npmjs 与 npmmirror 链接(部分网络会被 npmjs.com 拦截,npmmirror 作为镜像入口)
15
17
  - 一键升级插件,升级后自动重启
16
18
 
17
19
  ### 安全重启
@@ -19,6 +21,7 @@
19
21
  - 重启前检测活跃 Agent、后台任务和终端,展示清单并要求显式确认
20
22
  - 对话中输入 `/restart` 也可触发,检测到运行中工作时自动拒绝
21
23
  - 重启后自动探测新进程并刷新页面,60 秒未恢复时提供手动刷新
24
+ - 可在「重启」标签开启「设置页左列显示入口」开关(默认关闭),开启后在设置页左侧标签列底部显示「重启」快捷入口,与「重启」标签共用同一套确认流程
22
25
 
23
26
  ### 健康诊断
24
27
 
@@ -30,7 +33,8 @@
30
33
 
31
34
  - 近 7 天输入/输出/缓存 token 堆叠柱图,蓝/橙/青图例
32
35
  - 按项目筛选,鼠标悬停显示精确数值
33
- - 模型明细按步骤数降序,格式为「x次 · 缓存命中 x% · 输入 xM token · 输出 xM token
36
+ - 模型明细为横向堆叠柱形图(沿用主图图例配色),按 token 总量从多到少排序;每行附「x次 · 缓存命中 x% · 输入 xM token · 输出 xM token」明细
37
+ - 提供方未上报 token 用量的模型步骤不纳入统计
34
38
  - 最近 24 小时模型/工具报错统计,默认折叠
35
39
 
36
40
  ### 备份管理
@@ -43,7 +47,8 @@
43
47
 
44
48
  ### 任务通知
45
49
 
46
- - 会话完成一轮任务、或需要你授权/审阅计划/选择答案时发送浏览器通知
50
+ - 通知设置位于服务控制顶部的「通知」标签:会话完成一轮任务、或需要你授权/审阅计划/选择答案时发送浏览器通知
51
+ - 点击系统通知弹窗会聚焦到 DSH 页面并关闭该通知
47
52
  - 三档开关条:总开关、任务结束通知、授权与提问通知,各自独立控制
48
53
  - 对话栏内铃铛图标快速切换总开关
49
54
  - 各开关在页面刷新后保持
package/client.js CHANGED
@@ -10,6 +10,7 @@ window.__ModuleLoader__.load({
10
10
  const NS = 'dsh-service'
11
11
  const zh = {
12
12
  'nav.label': '服务控制',
13
+ 'nav.restart': '重启',
13
14
  'overlay.label': '服务重启状态',
14
15
  'recovery.waiting.title': '服务重启中…',
15
16
  'recovery.waiting.body': '正在等待新的 DSH Web 进程启动,已等待 {seconds} 秒。',
@@ -52,6 +53,7 @@ window.__ModuleLoader__.load({
52
53
  'health.detail.permissions.warning': '发现 {count} 个文件或目录权限异常',
53
54
  'health.detail.generic': '{status}',
54
55
  'tabs.overview': '概览',
56
+ 'tabs.notify': '通知',
55
57
  'tabs.health': '健康诊断',
56
58
  'tabs.usage': '模型统计',
57
59
  'overview.container': '容器信息',
@@ -106,6 +108,8 @@ window.__ModuleLoader__.load({
106
108
  'update.checking': '检查中…',
107
109
  'update.current': '已是最新版本',
108
110
  'update.available': '有新版本:{version}',
111
+ 'update.detailsButton': '查看详情',
112
+ 'update.detailsHide': '收起',
109
113
  'update.unavailable': '暂时无法检查最新版本',
110
114
  'update.unpublished': '尚未发布可检查版本',
111
115
  'health.recheck': '重新诊断',
@@ -113,7 +117,8 @@ window.__ModuleLoader__.load({
113
117
  'update.details.title': 'DSH 更新可用',
114
118
  'update.details.current': '当前版本:{version}',
115
119
  'update.details.latest': '最新版本:{version}',
116
- 'update.channels': '正式版 {latest} · 预览版 {next}',
120
+ 'update.channelStable': '正式版',
121
+ 'update.channelPreview': '预览版',
117
122
  'update.details.close': '关闭',
118
123
  'update.upgrade': '升级插件',
119
124
  'update.upgrading': '升级中…',
@@ -146,6 +151,8 @@ window.__ModuleLoader__.load({
146
151
  'restart.sent': '重启指令已发出。',
147
152
  'restart.sentHint': '页面连接即将断开,服务恢复后将自动刷新。',
148
153
  'restart.idleHint': '当前没有检测到运行中的工作。确认后将断开连接,等待服务自动重启。',
154
+ 'restart.navToggle': '设置页左列显示「重启」入口',
155
+ 'restart.navToggleHint': '默认关闭;开启后在设置页左侧标签列底部显示快捷重启入口',
149
156
  'activity.agent': 'Agent',
150
157
  'activity.job': '后台任务',
151
158
  'activity.terminal': '终端',
@@ -165,11 +172,14 @@ window.__ModuleLoader__.load({
165
172
  'usage.axis': 'token 纵轴',
166
173
  'usage.models.more': '展开其余 {count} 个模型',
167
174
  'usage.models.less': '收起模型列表',
175
+ 'usage.modelSortHint': '按 token 总量从多到少排列',
176
+ 'usage.modelBar': '{model}:共 {total} token',
168
177
  'usage.refresh': '刷新统计',
169
178
  'usage.refreshing': '刷新中…',
170
179
  'usage.empty': '尚未建立使用统计索引。点击刷新统计开始只读建立索引。',
171
180
  'usage.error': '无法读取模型使用统计',
172
181
  'usage.allProjects': '全部项目',
182
+ 'usage.total': 'token 总量',
173
183
  'usage.steps': '成功模型步骤',
174
184
  'usage.stepsValue': '{count} 次',
175
185
  'usage.modelLine': '{steps}次 · 缓存命中 {hitRate} · 输入 {input} token · 输出 {output} token',
@@ -179,7 +189,6 @@ window.__ModuleLoader__.load({
179
189
  'usage.hitRate': '缓存命中率',
180
190
  'usage.today': '今天',
181
191
  'usage.sevenDays': '近 7 天',
182
- 'usage.missing': '{count} 个步骤没有 token 数据',
183
192
  'usage.errors.title': '模型报错',
184
193
  'usage.errors.toggle': '模型报错({count} 类)',
185
194
  'usage.errors.recent': '最近 24 小时',
@@ -189,17 +198,14 @@ window.__ModuleLoader__.load({
189
198
  'usage.toolErrors.empty': '最近 24 小时没有记录到工具报错。',
190
199
  'usage.errors.count': '{count} 次',
191
200
  'notification.title': '通知',
192
- 'notification.description': '任务结束或需要你授权、选择答案时发送浏览器通知。需要授权浏览器通知权限;各开关在页面刷新后保持。',
201
+ 'notification.description': '任务结束或需要你授权、抉择时发送浏览器通知。需要授权浏览器通知权限。',
193
202
  'notification.enable': '开启通知',
194
203
  'notification.enabled': '通知已开启',
195
204
  'notification.disable': '关闭通知',
196
205
  'notification.denied': '通知权限被拒绝',
197
206
  'notification.master': '通知总开关',
198
- 'notification.masterHint': '关闭时下面两个开关暂停生效',
199
207
  'notification.done': '任务结束通知',
200
- 'notification.doneHint': '会话完成一轮任务时提醒',
201
208
  'notification.input': '授权与提问通知',
202
- 'notification.inputHint': '需要授权、审阅计划或选择答案时提醒',
203
209
  'notification.doneTitle': '任务完成',
204
210
  'notification.doneBody': '{title} 已完成本轮任务',
205
211
  'notification.inputTitle': '需要你的确认',
@@ -212,6 +218,7 @@ window.__ModuleLoader__.load({
212
218
  }
213
219
  const en = {
214
220
  'nav.label': 'Service Control',
221
+ 'nav.restart': 'Restart',
215
222
  'overlay.label': 'Service restart status',
216
223
  'recovery.waiting.title': 'Restarting service…',
217
224
  'recovery.waiting.body': 'Waiting for a new DSH Web process. Elapsed: {seconds} seconds.',
@@ -254,6 +261,7 @@ window.__ModuleLoader__.load({
254
261
  'health.detail.permissions.warning': 'Found {count} file or directory permission anomalies',
255
262
  'health.detail.generic': '{status}',
256
263
  'tabs.overview': 'Overview',
264
+ 'tabs.notify': 'Notifications',
257
265
  'tabs.health': 'Health',
258
266
  'tabs.usage': 'Models',
259
267
  'overview.container': 'Container info',
@@ -308,6 +316,8 @@ window.__ModuleLoader__.load({
308
316
  'update.checking': 'Checking…',
309
317
  'update.current': 'Up to date',
310
318
  'update.available': 'New version: {version}',
319
+ 'update.detailsButton': 'View details',
320
+ 'update.detailsHide': 'Collapse',
311
321
  'update.unavailable': 'Latest version is temporarily unavailable',
312
322
  'update.unpublished': 'No published version is available to check',
313
323
  'health.recheck': 'Run again',
@@ -315,7 +325,8 @@ window.__ModuleLoader__.load({
315
325
  'update.details.title': 'DSH update available',
316
326
  'update.details.current': 'Current version: {version}',
317
327
  'update.details.latest': 'Latest version: {version}',
318
- 'update.channels': 'Stable {latest} · Preview {next}',
328
+ 'update.channelStable': 'Stable',
329
+ 'update.channelPreview': 'Preview',
319
330
  'update.details.close': 'Close',
320
331
  'update.upgrade': 'Upgrade plugin',
321
332
  'update.upgrading': 'Upgrading…',
@@ -348,6 +359,8 @@ window.__ModuleLoader__.load({
348
359
  'restart.sent': 'Restart request sent.',
349
360
  'restart.sentHint': 'The connection will close shortly. The page will reload automatically after recovery.',
350
361
  'restart.idleHint': 'No active work was detected. Confirm to disconnect and wait for the service to restart.',
362
+ 'restart.navToggle': 'Show "Restart" entry in settings left nav',
363
+ 'restart.navToggleHint': 'Off by default; when enabled, a quick-restart entry appears at the bottom of the settings left navigation',
351
364
  'activity.agent': 'Agent',
352
365
  'activity.job': 'Background job',
353
366
  'activity.terminal': 'Terminal',
@@ -367,11 +380,14 @@ window.__ModuleLoader__.load({
367
380
  'usage.axis': 'token vertical axis',
368
381
  'usage.models.more': 'Show {count} more models',
369
382
  'usage.models.less': 'Collapse model list',
383
+ 'usage.modelSortHint': 'Sorted by total tokens, largest first',
384
+ 'usage.modelBar': '{model}: {total} tokens in total',
370
385
  'usage.refresh': 'Refresh usage',
371
386
  'usage.refreshing': 'Refreshing…',
372
387
  'usage.empty': 'No usage index yet. Select Refresh usage to build the read-only index.',
373
388
  'usage.error': 'Could not read model usage',
374
389
  'usage.allProjects': 'All projects',
390
+ 'usage.total': 'Total tokens',
375
391
  'usage.steps': 'Successful model steps',
376
392
  'usage.stepsValue': '{count} times',
377
393
  'usage.modelLine': '{steps} times · Cache hit {hitRate} · Input {input} token · Output {output} token',
@@ -381,7 +397,6 @@ window.__ModuleLoader__.load({
381
397
  'usage.hitRate': 'Cache hit rate',
382
398
  'usage.today': 'Today',
383
399
  'usage.sevenDays': 'Last 7 days',
384
- 'usage.missing': '{count} step(s) have no token data',
385
400
  'usage.errors.title': 'Model errors',
386
401
  'usage.errors.toggle': 'Model errors ({count} types)',
387
402
  'usage.errors.recent': 'Last 24 hours',
@@ -391,17 +406,14 @@ window.__ModuleLoader__.load({
391
406
  'usage.toolErrors.empty': 'No tool errors were recorded in the last 24 hours.',
392
407
  'usage.errors.count': '{count} occurrence(s)',
393
408
  'notification.title': 'Notifications',
394
- 'notification.description': 'Send a browser notification when a task finishes or when your approval or answer is needed. Requires browser notification permission; the toggles persist across page reloads.',
409
+ 'notification.description': 'Send a browser notification when a task finishes or when your approval or decision is needed. Requires browser notification permission.',
395
410
  'notification.enable': 'Enable notifications',
396
411
  'notification.enabled': 'Notifications enabled',
397
412
  'notification.disable': 'Disable notifications',
398
413
  'notification.denied': 'Notification permission denied',
399
414
  'notification.master': 'Master switch',
400
- 'notification.masterHint': 'Both kinds below stay paused while this is off',
401
415
  'notification.done': 'Task completion',
402
- 'notification.doneHint': 'Notify when a session finishes its turn',
403
416
  'notification.input': 'Approvals & questions',
404
- 'notification.inputHint': 'Notify when approval, plan review, or an answer is requested',
405
417
  'notification.doneTitle': 'Task complete',
406
418
  'notification.doneBody': '{title} has finished its turn',
407
419
  'notification.inputTitle': 'Your attention needed',
@@ -456,6 +468,36 @@ window.__ModuleLoader__.load({
456
468
  }, [])
457
469
  return { enabled, done, input, setEnabled: (v) => setNotifyEnabled(v), setDone: (v) => setNotifyDone(v), setInput: (v) => setNotifyInput(v) }
458
470
  }
471
+ // 设置页左列「重启」入口显示开关:默认关闭,localStorage 持久化;
472
+ // 开启时才注册 settings.section 条目(导航列单元格由外壳渲染,开关不生效就不能用 null 内容占位)。
473
+ let restartNavEnabled = false
474
+ try { restartNavEnabled = localStorage.getItem('dsh-service-restart-nav') === 'true' } catch (_) {}
475
+ let restartNavDispose = null
476
+ const syncRestartNavEntry = () => {
477
+ if (restartNavDispose) { restartNavDispose(); restartNavDispose = null }
478
+ if (!restartNavEnabled) return
479
+ restartNavDispose = ctx.slots.register(
480
+ { name: 'settings.section', id: 'dsh-service-restart', order: 499, label: () => t('nav.restart') },
481
+ () => React.createElement(RestartSection, null),
482
+ )
483
+ }
484
+ const restartNavListeners = new Set()
485
+ const setRestartNavEnabled = (value) => {
486
+ restartNavEnabled = value === true
487
+ try { localStorage.setItem('dsh-service-restart-nav', restartNavEnabled ? 'true' : 'false') } catch (_) {}
488
+ syncRestartNavEntry()
489
+ for (const listener of restartNavListeners) listener()
490
+ }
491
+ const useRestartNavEnabled = () => {
492
+ const [enabled, setEnabled] = useState(restartNavEnabled)
493
+ useEffect(() => {
494
+ const update = () => setEnabled(restartNavEnabled)
495
+ restartNavListeners.add(update)
496
+ setEnabled(restartNavEnabled)
497
+ return () => restartNavListeners.delete(update)
498
+ }, [])
499
+ return [enabled, setRestartNavEnabled]
500
+ }
459
501
  // 会话边沿通知:running→idle 记一次任务结束;pendingInteraction 出现记一次需要确认。
460
502
  // 数据源是客户端运行时的会话列表快照(订阅推送);首个快照只建立基线,重连后重建基线,二者都不响铃。
461
503
  const NOTIFY_KIND_KEYS = {
@@ -466,7 +508,15 @@ window.__ModuleLoader__.load({
466
508
  const notifyPermissionGranted = () => typeof Notification !== 'undefined' && Notification.permission === 'granted'
467
509
  const fireNotification = (title, body) => {
468
510
  if (!notifyPermissionGranted()) return
469
- try { new Notification(title, { body }) } catch (_) {}
511
+ try {
512
+ const notification = new Notification(title, { body })
513
+ // 点击系统通知弹窗:聚焦 DSH 页面并关闭该通知(chrome/ff 从通知点击回调
514
+ // 视为用户手势,window.focus() 可把标签页带到前台)
515
+ notification.onclick = () => {
516
+ try { window.focus() } catch (_) {}
517
+ try { notification.close() } catch (_) {}
518
+ }
519
+ } catch (_) {}
470
520
  }
471
521
  if (ctx.sessions && typeof ctx.sessions.list?.subscribe === 'function') {
472
522
  const observed = new Map()
@@ -540,6 +590,60 @@ window.__ModuleLoader__.load({
540
590
  return snapshot
541
591
  }
542
592
 
593
+ // 重启流程共享状态:设置面板「重启」标签与专属设置页共用同一份 stage/activity/busy/error,
594
+ // 触发路径(两段式确认、强制重启、恢复浮层)完全一致。
595
+ const restartFlowListeners = new Set()
596
+ let restartFlow = { stage: 0, activity: null, busy: false, error: null }
597
+ const setRestartFlow = (next) => {
598
+ restartFlow = next
599
+ for (const listener of restartFlowListeners) listener(next)
600
+ }
601
+ const useRestartFlow = () => {
602
+ const [snapshot, setSnapshot] = useState(restartFlow)
603
+ useEffect(() => {
604
+ restartFlowListeners.add(setSnapshot)
605
+ setSnapshot(restartFlow)
606
+ return () => restartFlowListeners.delete(setSnapshot)
607
+ }, [])
608
+ return snapshot
609
+ }
610
+ const checkRestart = async () => {
611
+ setRestartFlow({ ...restartFlow, busy: true, error: null })
612
+ try {
613
+ const res = await ctx.connection.rpc.call('/dsh-service', 'activity', {})
614
+ if (res && res.ok === false) {
615
+ console.error('dsh-service: activity check failed', res.error)
616
+ throw new Error(t('error.activity'))
617
+ }
618
+ const nextActivity = res && res.value ? res.value : { hasActive: false, items: [] }
619
+ setRestartFlow({ ...restartFlow, activity: nextActivity, stage: nextActivity.hasActive ? 3 : 1, busy: false, error: null })
620
+ } catch (err) {
621
+ setRestartFlow({ ...restartFlow, error: err && err.message ? String(err.message) : String(err), stage: 0, busy: false })
622
+ }
623
+ }
624
+ const restartWeb = async (force) => {
625
+ setRestartFlow({ ...restartFlow, busy: true, error: null })
626
+ try {
627
+ const res = await ctx.connection.rpc.call('/dsh-service', 'web', { force: force === true })
628
+ if (res && res.ok === false) {
629
+ if (res.error === 'active-work' && res.value) {
630
+ setRestartFlow({ ...restartFlow, activity: res.value, stage: 3, busy: false, error: null })
631
+ return
632
+ }
633
+ console.error('dsh-service: restart failed', res.error)
634
+ throw new Error(t('error.restart'))
635
+ }
636
+ const previousInstanceId = res && res.value ? res.value.instanceId : undefined
637
+ if (typeof previousInstanceId !== 'string' || previousInstanceId.length === 0) {
638
+ throw new Error(t('error.instance'))
639
+ }
640
+ setRestartFlow({ ...restartFlow, stage: 2, busy: false, error: null })
641
+ startRecovery(previousInstanceId).catch((err) => console.error('dsh-service: recovery failed', err))
642
+ } catch (err) {
643
+ setRestartFlow({ ...restartFlow, error: err && err.message ? String(err.message) : String(err), stage: 0, busy: false })
644
+ }
645
+ }
646
+
543
647
  const useRecoveryState = () => {
544
648
  const [snapshot, setSnapshot] = useState(recoveryState)
545
649
  useEffect(() => {
@@ -587,6 +691,7 @@ window.__ModuleLoader__.load({
587
691
  ctx.effect(() => () => {
588
692
  recoveryGeneration += 1
589
693
  recoveryListeners.clear()
694
+ restartFlowListeners.clear()
590
695
  updateListeners.clear()
591
696
  }, 'dsh-service recovery')
592
697
 
@@ -601,6 +706,27 @@ window.__ModuleLoader__.load({
601
706
  }, translate('update.badge'))
602
707
  }
603
708
 
709
+ // 正式/预览通道行:版本号后跟 npmjs(版本页)与 npmmirror(镜像版本页)两个文字链接。
710
+ // 版本串嵌进 URL 前过安全字符集校验,不过校验的标签降级为纯文本。供更新详情浮层使用。
711
+ const NPM_DSH_PACKAGE = '@deepseek-ai/dsh'
712
+ const packageVersionHref = (base, version) => {
713
+ if (typeof version !== 'string' || version.length === 0 || !/^[0-9A-Za-z.+_-]+$/.test(version)) return null
714
+ return `${base}${version}`
715
+ }
716
+ const siteLabelLink = (kind, label, href) => {
717
+ const testid = `version-dsh-channel-${kind}-${label}`
718
+ if (!href) return React.createElement('span', { 'data-testid': testid, style: { marginLeft: '10px' } }, label)
719
+ return React.createElement('a', { 'data-testid': testid, href, target: '_blank', rel: 'noreferrer', style: { color: 'var(--dsw-alias-brand-primary)', textDecoration: 'underline', marginLeft: '10px' } }, label)
720
+ }
721
+ const channelLine = (translate, kind, version) => React.createElement('div', { style: { whiteSpace: 'nowrap', lineHeight: 1.7 } },
722
+ kind === 'latest' ? translate('update.channelStable') : translate('update.channelPreview'),
723
+ ' ', React.createElement('span', { 'data-testid': `version-dsh-channel-${kind}`, style: { marginLeft: '4px' } }, version || '—'),
724
+ siteLabelLink(kind, 'npmjs', packageVersionHref(`https://www.npmjs.com/package/${NPM_DSH_PACKAGE}/v/`, version)),
725
+ siteLabelLink(kind, 'npmmirror', packageVersionHref(`https://www.npmmirror.com/package/${NPM_DSH_PACKAGE}/home?version=`, version)))
726
+ const channelLines = (translate, tags) => React.createElement('div', { style: { margin: '4px 0', fontSize: '12px', lineHeight: 1.7, color: 'var(--dsw-alias-label-secondary)', display: 'flex', flexDirection: 'column', gap: '6px' } },
727
+ channelLine(translate, 'latest', tags && tags.latest),
728
+ channelLine(translate, 'next', tags && tags.next))
729
+
604
730
  function ServiceOverlay() {
605
731
  const recovery = useRecoveryState()
606
732
  const updateState = useUpdateState()
@@ -617,7 +743,7 @@ window.__ModuleLoader__.load({
617
743
  React.createElement('div', { style: { fontSize: '18px', fontWeight: 700, marginBottom: '10px' } }, translate('update.details.title')),
618
744
  React.createElement('p', { style: { margin: '4px 0', fontSize: '13px' } }, translate('update.details.current', { version: update.current })),
619
745
  React.createElement('p', { style: { margin: '4px 0', fontSize: '13px' } }, translate('update.details.latest', { version: update.latest })),
620
- React.createElement('p', { style: { margin: '4px 0', fontSize: '12px', color: 'var(--dsw-alias-label-secondary)' } }, translate('update.channels', { latest: update.tags?.latest || '—', next: update.tags?.next || '—' })),
746
+ channelLines(translate, update.tags),
621
747
  React.createElement('button', { style: { marginTop: '16px', padding: '7px 16px', borderRadius: '6px', border: 0, background: 'var(--dsw-alias-brand-primary)', color: 'var(--dsw-alias-button-contrast-fill)', cursor: 'pointer' }, onClick: () => setUpdateDetailsOpen(false) }, translate('update.details.close'))))
622
748
  }
623
749
 
@@ -666,6 +792,89 @@ window.__ModuleLoader__.load({
666
792
  return React.createElement(ServiceOverlay, null)
667
793
  }
668
794
 
795
+ // 重启区块:设置面板「重启」标签与设置页左列底部的专属入口共用,状态取自共享重启流。
796
+ // showNavToggle 仅「重启」标签传入:控制设置页左列入口是否显示(默认关闭)。
797
+ function RestartSection({ showNavToggle }) {
798
+ const translate = useTranslation()
799
+ const flow = useRestartFlow()
800
+ const [navEnabled, setNavEnabled] = useRestartNavEnabled()
801
+ const btn = { minHeight: '32px', padding: '6px 14px', borderRadius: '7px', border: '1px solid transparent', cursor: 'pointer', fontSize: '13px', fontWeight: 550, transition: 'border-color 120ms ease, color 120ms ease, background 120ms ease', lineHeight: '20px' }
802
+ const danger = { ...btn, background: 'var(--dsw-alias-state-error-primary)', color: '#fff', borderColor: 'var(--dsw-alias-state-error-primary)' }
803
+ const ghost = { ...btn, background: 'transparent', color: 'var(--dsw-alias-label-primary)', borderColor: 'var(--dsw-alias-border-l2)' }
804
+ const row = { display: 'flex', alignItems: 'center', gap: '8px', marginTop: '12px', flexWrap: 'wrap' }
805
+ const hint = { color: 'var(--dsw-alias-label-secondary)', fontSize: '12px', marginTop: '8px', lineHeight: 1.5 }
806
+ const card = { padding: '4px 0 14px', marginBottom: '12px', color: 'var(--dsw-alias-label-primary)' }
807
+ const displaySurface = { background: 'var(--dsh-svc-surface-bg)', color: 'var(--dsw-alias-label-primary)', border: '1px solid var(--dsw-alias-border-l1)', borderRadius: '8px', padding: '10px' }
808
+ const sectionTitle = { fontSize: '14px', fontWeight: 700, margin: '0 0 8px', color: 'var(--dsw-alias-label-primary)' }
809
+
810
+ // 重启后提示
811
+ if (flow.stage === 2) {
812
+ return React.createElement('div', { 'data-testid': 'restart-card', style: card },
813
+ React.createElement('div', { style: sectionTitle }, translate('restart.title')),
814
+ React.createElement('p', { style: { margin: 0, fontSize: '13px' } }, translate('restart.sent')),
815
+ React.createElement('p', { style: hint }, translate('restart.sentHint')))
816
+ }
817
+
818
+ const activityLabels = {
819
+ agent: translate('activity.agent'),
820
+ job: translate('activity.job'),
821
+ terminal: translate('activity.terminal'),
822
+ }
823
+ const activityItems = flow.activity && Array.isArray(flow.activity.items) ? flow.activity.items : []
824
+ const activityWarning = flow.stage === 3
825
+ ? React.createElement('div', { style: { marginTop: '12px', padding: '10px 12px', borderRadius: '6px', background: 'rgba(211,51,51,0.1)', border: '1px solid rgba(211,51,51,0.35)' } },
826
+ React.createElement('p', { style: { margin: '0 0 8px', color: 'var(--dsw-alias-state-error-primary)', fontSize: '13px', fontWeight: 600 } },
827
+ translate('activity.warning', { count: activityItems.length })),
828
+ React.createElement('ul', { style: { margin: 0, paddingLeft: '20px', fontSize: '12px', lineHeight: 1.7 } },
829
+ activityItems.map((item) => React.createElement('li', { key: item.type + ':' + item.id },
830
+ translate('activity.item', {
831
+ type: activityLabels[item.type] || item.type,
832
+ label: item.label,
833
+ status: item.status,
834
+ }))))
835
+ )
836
+ : null
837
+
838
+ // 重启按钮区块
839
+ return React.createElement('div', { 'data-testid': 'restart-card', style: card },
840
+ React.createElement('div', { style: sectionTitle }, translate('restart.title')),
841
+ React.createElement('div', { 'data-testid': 'restart-region', style: displaySurface },
842
+ React.createElement('p', { style: { margin: 0, fontSize: '13px' } }, translate('restart.description')),
843
+ activityWarning,
844
+ React.createElement('div', { style: row },
845
+ flow.stage === 0
846
+ ? React.createElement('button', { style: danger, 'data-variant': 'danger', onClick: checkRestart, disabled: flow.busy }, translate(flow.busy ? 'update.checking' : 'restart.button'))
847
+ : flow.stage === 1
848
+ ? [
849
+ React.createElement('button', { key: 'confirm', style: danger, onClick: () => restartWeb(false), disabled: flow.busy }, translate(flow.busy ? 'restart.sending' : 'restart.confirm')),
850
+ React.createElement('button', { key: 'cancel', style: ghost, onClick: () => setRestartFlow({ ...restartFlow, activity: null, stage: 0, busy: false, error: null }), disabled: flow.busy }, translate('restart.cancel')),
851
+ ]
852
+ : flow.stage === 3
853
+ ? [
854
+ React.createElement('button', { key: 'force', style: danger, onClick: () => restartWeb(true), disabled: flow.busy }, translate(flow.busy ? 'restart.sending' : 'restart.force')),
855
+ React.createElement('button', { key: 'cancel', style: ghost, onClick: () => setRestartFlow({ ...restartFlow, activity: null, stage: 0, busy: false, error: null }), disabled: flow.busy }, translate('restart.cancel')),
856
+ ]
857
+ : null
858
+ ),
859
+ flow.stage === 1 ? React.createElement('p', { style: hint }, translate('restart.idleHint')) : null,
860
+ flow.error ? React.createElement('p', { style: Object.assign({}, hint, { color: 'var(--dsw-alias-state-error-primary)' }) }, String(flow.error)) : null,
861
+ showNavToggle
862
+ ? React.createElement('div', { style: { marginTop: '12px', paddingTop: '10px', borderTop: '1px solid var(--dsw-alias-border-l1)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '12px' } },
863
+ React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: '2px' } },
864
+ React.createElement('span', { style: { fontSize: '13px', color: 'var(--dsw-alias-label-primary)' } }, translate('restart.navToggle')),
865
+ React.createElement('span', { style: hint }, translate('restart.navToggleHint'))),
866
+ React.createElement('button', {
867
+ type: 'button',
868
+ role: 'switch',
869
+ 'data-testid': 'restart-nav-switch',
870
+ 'aria-checked': String(navEnabled),
871
+ onClick: () => setNavEnabled(!navEnabled),
872
+ style: { width: '34px', height: '20px', borderRadius: '10px', padding: 0, flexShrink: 0, position: 'relative', border: `1px solid ${navEnabled ? 'var(--dsw-alias-state-success-primary)' : 'var(--dsw-alias-border-l2)'}`, background: navEnabled ? 'var(--dsw-alias-state-success-primary)' : 'var(--dsw-alias-bg-layer-2)', cursor: 'pointer', lineHeight: 0 },
873
+ }, React.createElement('span', { style: { position: 'absolute', top: '1px', left: navEnabled ? '15px' : '1px', width: '16px', height: '16px', borderRadius: '50%', background: navEnabled ? '#fff' : 'var(--dsw-alias-label-tertiary)' } })))
874
+ : null)
875
+ )
876
+ }
877
+
669
878
  function ServicePanel() {
670
879
  const translate = useTranslation()
671
880
  const [health, setHealth] = useState(null)
@@ -703,11 +912,10 @@ window.__ModuleLoader__.load({
703
912
  const [toolErrorsOpen, setToolErrorsOpen] = useState(false)
704
913
  const [modelsOpen, setModelsOpen] = useState(false)
705
914
  const [activeTab, setActiveTab] = useState('overview')
706
- // 重启状态:0=初始,1=普通确认,2=已发出,3=检测到活动工作
707
- const [stage, setStage] = useState(0)
708
- const [activity, setActivity] = useState(null)
709
- const [busy, setBusy] = useState(false)
710
- const [error, setError] = useState(null)
915
+ // 版本详情行内展开(不用浮层:弹层会被设置模态盖住)
916
+ const [channelOpen, setChannelOpen] = useState(false)
917
+ // 重启流程状态来自共享流(与设置页左列底部的专属入口同源)
918
+ const restartFlowState = useRestartFlow()
711
919
  const usageRequestPayload = { timezoneOffsetMinutes: new Date().getTimezoneOffset() }
712
920
 
713
921
  // 进入面板时拉取当前版本和健康快照;健康数据每 5 秒刷新,卸载即停止。
@@ -994,54 +1202,6 @@ window.__ModuleLoader__.load({
994
1202
  }
995
1203
  }
996
1204
 
997
- const checkRestart = async () => {
998
- setBusy(true)
999
- setError(null)
1000
- try {
1001
- const res = await ctx.connection.rpc.call('/dsh-service', 'activity', {})
1002
- if (res && res.ok === false) {
1003
- console.error('dsh-service: activity check failed', res.error)
1004
- throw new Error(translate('error.activity'))
1005
- }
1006
- const nextActivity = res && res.value ? res.value : { hasActive: false, items: [] }
1007
- setActivity(nextActivity)
1008
- setStage(nextActivity.hasActive ? 3 : 1)
1009
- } catch (err) {
1010
- setError(err && err.message ? String(err.message) : String(err))
1011
- setStage(0)
1012
- } finally {
1013
- setBusy(false)
1014
- }
1015
- }
1016
-
1017
- const restart = async (force) => {
1018
- setBusy(true)
1019
- setError(null)
1020
- try {
1021
- const res = await ctx.connection.rpc.call('/dsh-service', 'web', { force: force === true })
1022
- if (res && res.ok === false) {
1023
- if (res.error === 'active-work' && res.value) {
1024
- setActivity(res.value)
1025
- setStage(3)
1026
- return
1027
- }
1028
- console.error('dsh-service: restart failed', res.error)
1029
- throw new Error(translate('error.restart'))
1030
- }
1031
- setStage(2)
1032
- const previousInstanceId = res && res.value ? res.value.instanceId : undefined
1033
- if (typeof previousInstanceId !== 'string' || previousInstanceId.length === 0) {
1034
- throw new Error(translate('error.instance'))
1035
- }
1036
- startRecovery(previousInstanceId).catch((err) => console.error('dsh-service: recovery failed', err))
1037
- } catch (err) {
1038
- setError(err && err.message ? String(err.message) : String(err))
1039
- setStage(0)
1040
- } finally {
1041
- setBusy(false)
1042
- }
1043
- }
1044
-
1045
1205
  // 样式
1046
1206
  const btn = { minHeight: '32px', padding: '6px 14px', borderRadius: '7px', border: '1px solid transparent', cursor: 'pointer', fontSize: '13px', fontWeight: 550, transition: 'border-color 120ms ease, color 120ms ease, background 120ms ease', lineHeight: '20px' }
1047
1207
  const primary = { ...btn, background: 'var(--dsw-alias-brand-primary)', color: 'var(--dsw-alias-button-contrast-fill)', borderColor: 'var(--dsw-alias-brand-primary)' }
@@ -1102,15 +1262,19 @@ window.__ModuleLoader__.load({
1102
1262
  ]
1103
1263
  const usageTotalsFor = (day) => {
1104
1264
  const source = usage && usage.days ? usage.days[day] : null
1105
- if (!source) return { steps: 0, missingUsage: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, cacheHitRate: 0 }
1265
+ if (!source) return { steps: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, cacheHitRate: 0 }
1106
1266
  if (usageProject === 'all') return source.totals
1107
1267
  const project = source.projects.find((item) => item.id === usageProject)
1108
- return project ? project.totals : { steps: 0, missingUsage: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, cacheHitRate: 0 }
1268
+ return project ? project.totals : { steps: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, cacheHitRate: 0 }
1109
1269
  }
1110
1270
  const modelCacheHitRate = (model) => {
1111
1271
  const denominator = Number(model.inputTokens || 0) + Number(model.cacheReadTokens || 0) + Number(model.cacheWriteTokens || 0)
1112
1272
  return denominator === 0 ? 0 : Number(model.cacheReadTokens || 0) / denominator
1113
1273
  }
1274
+ const modelTotalTokens = (model) => Number(model.inputTokens || 0) + Number(model.outputTokens || 0) + Number(model.cacheReadTokens || 0) + Number(model.cacheWriteTokens || 0)
1275
+ const modelSegmentValue = (model, metric) => metric === 'cacheTokens'
1276
+ ? Number(model.cacheReadTokens || 0) + Number(model.cacheWriteTokens || 0)
1277
+ : Number(model[metric] || 0)
1114
1278
  const usageValue = (totals, metricName) => metricName === 'cacheTokens'
1115
1279
  ? totals.cacheReadTokens + totals.cacheWriteTokens
1116
1280
  : totals[metricName] || 0
@@ -1133,13 +1297,12 @@ window.__ModuleLoader__.load({
1133
1297
  const sevenTotals = usageDays.reduce((total, day) => {
1134
1298
  const source = usageTotalsFor(day.key)
1135
1299
  total.steps += source.steps || 0
1136
- total.missingUsage += source.missingUsage || 0
1137
1300
  total.inputTokens += source.inputTokens || 0
1138
1301
  total.outputTokens += source.outputTokens || 0
1139
1302
  total.cacheReadTokens += source.cacheReadTokens || 0
1140
1303
  total.cacheWriteTokens += source.cacheWriteTokens || 0
1141
1304
  return total
1142
- }, { steps: 0, missingUsage: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 })
1305
+ }, { steps: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 })
1143
1306
  const sevenDenominator = sevenTotals.inputTokens + sevenTotals.cacheReadTokens + sevenTotals.cacheWriteTokens
1144
1307
  sevenTotals.cacheHitRate = sevenDenominator === 0 ? 0 : sevenTotals.cacheReadTokens / sevenDenominator
1145
1308
  const selectedProjects = usageProject === 'all'
@@ -1178,6 +1341,7 @@ window.__ModuleLoader__.load({
1178
1341
  return translate('health.detail.generic', { status: translate(`health.status.${check.status}`) })
1179
1342
  }
1180
1343
  const summaryItems = (totals) => [
1344
+ ['usage.total', formatTokenValue(usageValue(totals, 'inputTokens') + usageValue(totals, 'outputTokens') + usageValue(totals, 'cacheTokens'))],
1181
1345
  ...usageSegments.map(([metricName, label]) => [label, formatTokenValue(usageValue(totals, metricName))]),
1182
1346
  ['usage.steps', formatUsageValue(totals.steps, 'steps')],
1183
1347
  ['usage.hitRate', formatUsageValue(totals.cacheHitRate, 'cacheHitRate')],
@@ -1188,9 +1352,10 @@ window.__ModuleLoader__.load({
1188
1352
  React.createElement('span', { style: { color: 'var(--dsw-alias-label-secondary)' } }, translate(label)),
1189
1353
  React.createElement('span', { style: { fontWeight: 650 } }, value)))
1190
1354
  )
1191
- const sortedModels = [...modelTotals.values()].sort((a, b) => b.steps - a.steps || a.id.localeCompare(b.id))
1355
+ const sortedModels = [...modelTotals.values()].sort((a, b) => modelTotalTokens(b) - modelTotalTokens(a) || b.steps - a.steps || a.id.localeCompare(b.id))
1192
1356
  const visibleModels = modelsOpen ? sortedModels : sortedModels.slice(0, 3)
1193
1357
  const hiddenModelCount = Math.max(0, sortedModels.length - 3)
1358
+ const maxModelTokens = Math.max(1, ...visibleModels.map((model) => modelTotalTokens(model)))
1194
1359
  const selectedErrors = (list) => (list || [])
1195
1360
  .filter((error) => usageProject === 'all' || error.projectId === usageProject)
1196
1361
  .sort((a, b) => b.count - a.count || a.key.localeCompare(b.key))
@@ -1249,16 +1414,28 @@ window.__ModuleLoader__.load({
1249
1414
  React.createElement('div', { style: { display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: '8px', marginTop: '10px' } },
1250
1415
  summaryBlock('today', 'usage.today', todayTotals),
1251
1416
  summaryBlock('seven', 'usage.sevenDays', sevenTotals)),
1252
- sevenTotals.missingUsage > 0 ? React.createElement('p', { style: hint }, translate('usage.missing', { count: sevenTotals.missingUsage })) : null,
1253
1417
  React.createElement('div', { 'data-testid': 'usage-model-list', style: { marginTop: '10px', padding: '8px 10px', borderRadius: '8px', background: 'var(--dsw-alias-bg-layer-2)', border: '1px solid var(--dsw-alias-border-l1)' } },
1254
- visibleModels.map((model, index) => React.createElement('div', { key: model.id, style: { display: 'flex', justifyContent: 'space-between', gap: '12px', fontSize: '12px', padding: '8px 2px', borderTop: index === 0 ? 0 : '1px solid var(--dsw-alias-border-l1)' } },
1255
- React.createElement('span', null, model.id),
1256
- React.createElement('span', { style: { textAlign: 'right' } }, translate('usage.modelLine', {
1257
- steps: Number(model.steps || 0).toLocaleString(),
1258
- hitRate: formatUsageValue(modelCacheHitRate(model), 'cacheHitRate'),
1259
- input: formatTokenValue(model.inputTokens),
1260
- output: formatTokenValue(model.outputTokens),
1261
- })))),
1418
+ React.createElement('div', { style: { fontSize: '11px', color: 'var(--dsw-alias-label-secondary)', marginBottom: '4px' } }, translate('usage.modelSortHint')),
1419
+ visibleModels.map((model, index) => {
1420
+ const total = modelTotalTokens(model)
1421
+ const fillWidth = `${Math.round(total / maxModelTokens * 10000) / 100}%`
1422
+ return React.createElement('div', { key: model.id, 'data-testid': `usage-model-row-${model.id}`, style: { padding: '8px 2px', borderTop: index === 0 ? 0 : '1px solid var(--dsw-alias-border-l1)' } },
1423
+ React.createElement('div', { style: { display: 'flex', justifyContent: 'space-between', gap: '12px', alignItems: 'baseline', fontSize: '12px' } },
1424
+ React.createElement('span', { style: { overflowWrap: 'anywhere' } }, model.id),
1425
+ React.createElement('span', { style: { fontWeight: 650, whiteSpace: 'nowrap' } }, formatTokenValue(total))),
1426
+ React.createElement('div', { 'data-testid': `usage-model-bar-${model.id}`, 'data-value': total, 'aria-label': translate('usage.modelBar', { model: model.id, total: formatTokenValue(total) }), style: { height: '8px', borderRadius: '4px', marginTop: '6px', overflow: 'hidden', background: 'var(--dsw-alias-border-l1)' } },
1427
+ React.createElement('div', { style: { display: 'flex', height: '100%', width: fillWidth } },
1428
+ usageSegments.map(([metricName, , color]) => {
1429
+ const value = modelSegmentValue(model, metricName)
1430
+ return value > 0 ? React.createElement('div', { key: metricName, 'data-testid': `usage-model-segment-${model.id}-${metricName}`, style: { width: `${value / total * 100}%`, background: color } }) : null
1431
+ }))),
1432
+ React.createElement('div', { style: { color: 'var(--dsw-alias-label-secondary)', fontSize: '11px', marginTop: '4px', textAlign: 'right' } }, translate('usage.modelLine', {
1433
+ steps: Number(model.steps || 0).toLocaleString(),
1434
+ hitRate: formatUsageValue(modelCacheHitRate(model), 'cacheHitRate'),
1435
+ input: formatTokenValue(model.inputTokens),
1436
+ output: formatTokenValue(model.outputTokens),
1437
+ })))
1438
+ }),
1262
1439
  hiddenModelCount > 0 ? React.createElement('button', { style: Object.assign({}, toggle, { borderTop: '1px solid var(--dsw-alias-border-l1)', marginTop: '2px' }), onClick: () => setModelsOpen((value) => !value) }, `${modelsOpen ? '▾' : '▸'} ${translate(modelsOpen ? 'usage.models.less' : 'usage.models.more', { count: hiddenModelCount })}`) : null))
1263
1440
  : React.createElement('p', { style: hint }, usageError || translate('usage.empty')),
1264
1441
  React.createElement('div', { style: row }, React.createElement('button', { style: neutral, 'data-variant': 'neutral', onClick: refreshUsage, disabled: usageBusy }, translate(usageBusy ? 'usage.refreshing' : 'usage.refresh'))))
@@ -1394,21 +1571,36 @@ window.__ModuleLoader__.load({
1394
1571
  : null)))
1395
1572
  : null))
1396
1573
 
1397
- const versionRow = (id, label, fallbackVersion, state, action) => React.createElement('div', { key: id, style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '16px', padding: '10px 2px', borderTop: id === 'dsh' ? 0 : '1px solid var(--dsw-alias-border-l1)' } },
1398
- React.createElement('div', { style: { whiteSpace: 'nowrap', display: 'flex', alignItems: 'center', gap: '8px' } },
1399
- React.createElement('span', { style: { fontSize: '13px', fontWeight: 650 } }, `${label} `),
1400
- state?.url
1401
- ? React.createElement('a', { 'data-testid': `version-${id}-link`, href: state.url, target: '_blank', rel: 'noreferrer', style: { color: 'var(--dsw-alias-label-primary)', textDecoration: 'underline', fontSize: '12px', whiteSpace: 'nowrap', marginLeft: '16px' } }, state.current || fallbackVersion || translate('version.loading'))
1402
- : React.createElement('code', { style: { fontSize: '12px', color: 'var(--dsw-alias-label-primary)', marginLeft: '16px' } }, state?.current || fallbackVersion || translate('version.loading'))),
1403
- state?.tags && id === 'dsh' ? React.createElement('span', { style: { marginLeft: '8px', fontSize: '11px', color: 'var(--dsw-alias-label-secondary)', whiteSpace: 'nowrap' } }, translate('update.channels', { latest: state.tags.latest || '—', next: state.tags.next || '—' })) : null,
1574
+ // 正式/预览通道两行信息在版本卡内下拉展开(不弹浮层:弹层会被设置模态盖住)。
1575
+ // 有更新时状态文本本身可点击:小三角 + 「有新版本:…」整体切换展开/收起。
1576
+ const chevronIcon = (open) => React.createElement('svg', { xmlns: 'http://www.w3.org/2000/svg', width: 12, height: 12, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 3, strokeLinecap: 'round', strokeLinejoin: 'round', style: { display: 'block', transition: 'transform 150ms ease', transform: open ? 'rotate(90deg)' : 'none' } },
1577
+ React.createElement('path', { d: 'M9 6l6 6-6 6' }))
1578
+ const versionRow = (id, label, fallbackVersion, state, action, expandable, topBorder) => {
1579
+ const statusText = !state
1580
+ ? (updateError || translate('update.checking'))
1581
+ : state.status === 'unpublished' ? translate('update.unpublished')
1582
+ : state.status === 'unavailable' ? translate('update.unavailable')
1583
+ : state.upToDate ? translate('update.current')
1584
+ : translate('update.available', { version: state.latest })
1585
+ const statusColor = !state ? 'var(--dsw-alias-label-secondary)' : state.upToDate ? 'var(--dsw-alias-state-success-primary)' : 'var(--dsw-alias-state-warn-primary)'
1586
+ const clickable = expandable && state && state.status !== 'unpublished' && state.status !== 'unavailable' && !state.upToDate
1587
+ const rightSide = clickable
1588
+ ? React.createElement('button', { type: 'button', title: translate(channelOpen ? 'update.detailsHide' : 'update.detailsButton'), style: { background: 'transparent', border: 0, padding: 0, cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: '5px', fontSize: '12px', fontWeight: 600, color: statusColor }, onClick: () => setChannelOpen((value) => !value) },
1589
+ chevronIcon(channelOpen),
1590
+ React.createElement('span', null, statusText))
1591
+ : React.createElement('div', { style: { color: statusColor, fontWeight: 600 } }, statusText)
1592
+ return React.createElement('div', { key: id, style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '16px', padding: '10px 2px', borderTop: topBorder ? '1px solid var(--dsw-alias-border-l1)' : 0 } },
1593
+ React.createElement('div', { style: { whiteSpace: 'nowrap', display: 'flex', alignItems: 'center', gap: '8px' } },
1594
+ React.createElement('span', { style: { fontSize: '13px', fontWeight: 650 } }, `${label} `),
1595
+ state?.url
1596
+ ? React.createElement('a', { 'data-testid': `version-${id}-link`, href: state.url, target: '_blank', rel: 'noreferrer', style: { color: 'var(--dsw-alias-label-primary)', textDecoration: 'underline', fontSize: '12px', whiteSpace: 'nowrap', marginLeft: '16px' } }, state.current || fallbackVersion || translate('version.loading'))
1597
+ : React.createElement('code', { style: { fontSize: '12px', color: 'var(--dsw-alias-label-primary)', marginLeft: '16px' } }, state?.current || fallbackVersion || translate('version.loading'))),
1404
1598
  action || null,
1405
- React.createElement('div', { style: { textAlign: 'right', fontSize: '12px' } },
1406
- React.createElement('div', { style: { color: !state ? 'var(--dsw-alias-label-secondary)' : state.upToDate ? 'var(--dsw-alias-state-success-primary)' : 'var(--dsw-alias-state-warn-primary)', fontWeight: 600 } }, !state
1407
- ? (updateError || translate('update.checking'))
1408
- : state.status === 'unpublished' ? translate('update.unpublished')
1409
- : state.status === 'unavailable' ? translate('update.unavailable')
1410
- : state.upToDate ? translate('update.current') : translate('update.available', { version: state.latest }))))
1411
- // 版本信息区块
1599
+ React.createElement('div', { style: { textAlign: 'right', fontSize: '12px' } }, rightSide))
1600
+ }
1601
+ // 版本信息区块:DSH 行在有更新时状态文本(小三角 + 有新版本)整体可点击,行内下拉展开
1602
+ const dshUpdate = updateInfo?.dsh
1603
+ const dshExpandable = dshUpdate && dshUpdate.status !== 'unpublished' && dshUpdate.status !== 'unavailable' && !dshUpdate.upToDate
1412
1604
  const pluginUpdate = updateInfo?.plugin && !updateInfo.plugin.upToDate && updateInfo.plugin.status === 'available'
1413
1605
  const pluginAction = pluginUpdate
1414
1606
  ? React.createElement('button', { style: Object.assign({}, neutral, { minHeight: '24px', padding: '2px 8px', fontSize: '11px' }), disabled: upgradeBusy, onClick: upgradePlugin }, translate(upgradeBusy ? 'update.upgrading' : 'update.upgrade'))
@@ -1416,67 +1608,18 @@ window.__ModuleLoader__.load({
1416
1608
  const versionBlock = React.createElement('div', { key: 'version-card', 'data-testid': 'version-card', style: card },
1417
1609
  React.createElement('div', { key: 'title', style: sectionTitle }, translate('version.title')),
1418
1610
  React.createElement('div', { style: displaySurface },
1419
- versionRow('dsh', 'DSH', version, updateInfo?.dsh),
1420
- versionRow('plugin', 'dsh-service', pluginVersion, updateInfo?.plugin, pluginAction),
1611
+ versionRow('plugin', 'dsh-service', pluginVersion, updateInfo?.plugin, pluginAction, false, false),
1612
+ versionRow('dsh', 'DSH', version, dshUpdate, null, dshExpandable === true, true),
1613
+ channelOpen
1614
+ ? React.createElement('div', { 'data-testid': 'version-channel-details', style: { marginTop: '6px', paddingTop: '8px', borderTop: '1px solid var(--dsw-alias-border-l1)', fontSize: '12px', lineHeight: 1.7, color: 'var(--dsw-alias-label-secondary)', display: 'flex', flexDirection: 'column', gap: '6px' } },
1615
+ React.createElement('div', null, translate('update.details.current', { version: dshUpdate?.current || version || '—' })),
1616
+ React.createElement('div', null, translate('update.details.latest', { version: dshUpdate?.latest || '—' })),
1617
+ channelLines(translate, dshUpdate?.tags))
1618
+ : null,
1421
1619
  upgradeError ? React.createElement('p', { style: Object.assign({}, hint, { color: 'var(--dsw-alias-state-error-primary)', margin: '4px 0 0' }) }, upgradeError) : null))
1422
1620
 
1423
- // 重启后提示
1424
- if (stage === 2) {
1425
- return React.createElement('div', null,
1426
- healthBlock,
1427
- backupBlock,
1428
- versionBlock,
1429
- React.createElement('div', { key: 'restart-section' },
1430
- React.createElement('div', { style: sectionTitle }, translate('restart.title')),
1431
- React.createElement('p', { style: { margin: 0, fontSize: '13px' } }, translate('restart.sent')),
1432
- React.createElement('p', { style: hint }, translate('restart.sentHint')))
1433
- )
1434
- }
1435
-
1436
- const activityLabels = {
1437
- agent: translate('activity.agent'),
1438
- job: translate('activity.job'),
1439
- terminal: translate('activity.terminal'),
1440
- }
1441
- const activityItems = activity && Array.isArray(activity.items) ? activity.items : []
1442
- const activityWarning = stage === 3
1443
- ? React.createElement('div', { style: { marginTop: '12px', padding: '10px 12px', borderRadius: '6px', background: 'rgba(211,51,51,0.1)', border: '1px solid rgba(211,51,51,0.35)' } },
1444
- React.createElement('p', { style: { margin: '0 0 8px', color: 'var(--dsw-alias-state-error-primary)', fontSize: '13px', fontWeight: 600 } },
1445
- translate('activity.warning', { count: activityItems.length })),
1446
- React.createElement('ul', { style: { margin: 0, paddingLeft: '20px', fontSize: '12px', lineHeight: 1.7 } },
1447
- activityItems.map((item) => React.createElement('li', { key: item.type + ':' + item.id },
1448
- translate('activity.item', {
1449
- type: activityLabels[item.type] || item.type,
1450
- label: item.label,
1451
- status: item.status,
1452
- }))))
1453
- )
1454
- : null
1455
-
1456
- // 重启按钮区块
1457
- const restartBlock = React.createElement('div', { key: 'restart-section', 'data-testid': 'restart-card', style: card },
1458
- React.createElement('div', { style: sectionTitle }, translate('restart.title')),
1459
- React.createElement('div', { 'data-testid': 'restart-region', style: displaySurface },
1460
- React.createElement('p', { style: { margin: 0, fontSize: '13px' } }, translate('restart.description')),
1461
- activityWarning,
1462
- React.createElement('div', { style: row },
1463
- stage === 0
1464
- ? React.createElement('button', { style: danger, 'data-variant': 'danger', onClick: checkRestart, disabled: busy }, translate(busy ? 'update.checking' : 'restart.button'))
1465
- : stage === 1
1466
- ? [
1467
- React.createElement('button', { key: 'confirm', style: danger, onClick: () => restart(false), disabled: busy }, translate(busy ? 'restart.sending' : 'restart.confirm')),
1468
- React.createElement('button', { key: 'cancel', style: ghost, onClick: () => { setActivity(null); setStage(0) }, disabled: busy }, translate('restart.cancel')),
1469
- ]
1470
- : stage === 3
1471
- ? [
1472
- React.createElement('button', { key: 'force', style: danger, onClick: () => restart(true), disabled: busy }, translate(busy ? 'restart.sending' : 'restart.force')),
1473
- React.createElement('button', { key: 'cancel', style: ghost, onClick: () => { setActivity(null); setStage(0) }, disabled: busy }, translate('restart.cancel')),
1474
- ]
1475
- : null
1476
- ),
1477
- stage === 1 ? React.createElement('p', { style: hint }, translate('restart.idleHint')) : null,
1478
- error ? React.createElement('p', { style: Object.assign({}, hint, { color: 'var(--dsw-alias-state-error-primary)' }) }, String(error)) : null)
1479
- )
1621
+ // 重启区块复用共享组件(「重启」标签还承载设置页左列入口的显示开关;左侧入口默认关闭)
1622
+ const restartBlock = React.createElement(RestartSection, { showNavToggle: true })
1480
1623
 
1481
1624
  const { enabled: notifyOn, done: notifyDoneOn, input: notifyInputOn, setEnabled: setNotifyOn, setDone: setNotifyDoneOn, setInput: setNotifyInputOn } = useNotifyState()
1482
1625
  const notifSupported = typeof Notification !== 'undefined'
@@ -1494,10 +1637,10 @@ window.__ModuleLoader__.load({
1494
1637
  cursor: disabled ? 'default' : 'pointer', opacity: disabled ? 0.5 : 1, lineHeight: 0,
1495
1638
  },
1496
1639
  }, React.createElement('span', { style: { position: 'absolute', top: '1px', left: on ? '15px' : '1px', width: '16px', height: '16px', borderRadius: '50%', background: on ? '#fff' : 'var(--dsw-alias-label-tertiary)' } }))
1497
- const notifyRow = (label, labelHint, on, onChange, disabled) => React.createElement('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '12px', padding: '5px 0' } },
1640
+ const notifyRow = (testId, label, labelHint, on, onChange, disabled) => React.createElement('div', { 'data-testid': testId, style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '12px', padding: '5px 0' } },
1498
1641
  React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: '2px' } },
1499
1642
  React.createElement('span', { style: { fontSize: '13px', color: 'var(--dsw-alias-label-primary)' } }, label),
1500
- React.createElement('span', { style: hint }, labelHint)),
1643
+ labelHint ? React.createElement('span', { style: hint }, labelHint) : null),
1501
1644
  notifySwitch(on, onChange, disabled))
1502
1645
  const notificationBlock = !notifSupported ? null
1503
1646
  : React.createElement('div', { style: { marginTop: '18px' } },
@@ -1509,21 +1652,25 @@ window.__ModuleLoader__.load({
1509
1652
  React.createElement('button', { style: neutral, onClick: () => { Notification.requestPermission().then((p) => { if (p === 'granted') setNotifyOn(true) }) } }, translate('notification.enable')),
1510
1653
  React.createElement('span', { style: hint }, notifPermission === 'denied' ? translate('notification.denied') : ''))
1511
1654
  : React.createElement('div', { style: { marginTop: '8px', display: 'flex', flexDirection: 'column' } },
1512
- notifyRow(translate('notification.master'), translate('notification.masterHint'), notifyOn, setNotifyOn, false),
1513
- notifyRow(translate('notification.done'), translate('notification.doneHint'), notifyDoneOn, setNotifyDoneOn, !notifyOn),
1514
- notifyRow(translate('notification.input'), translate('notification.inputHint'), notifyInputOn, setNotifyInputOn, !notifyOn))))
1515
- const overviewBlock = React.createElement('div', null, versionBlock, notificationBlock, containerInfoBlock, overviewErrorsBlock)
1655
+ notifyRow('notify-row-master', translate('notification.master'), null, notifyOn, setNotifyOn, false),
1656
+ notifyRow('notify-row-done', translate('notification.done'), null, notifyDoneOn, setNotifyDoneOn, !notifyOn),
1657
+ notifyRow('notify-row-input', translate('notification.input'), null, notifyInputOn, setNotifyInputOn, !notifyOn))))
1658
+ const overviewBlock = React.createElement('div', null, versionBlock, containerInfoBlock, overviewErrorsBlock)
1659
+ // 任务通知独立成顶部标签(v0.14 起不再混在概览里)
1660
+ const notifyBlock = React.createElement('div', null, notificationBlock)
1516
1661
  const maintenanceBlock = React.createElement('div', { key: 'maintenance-card', 'data-testid': 'maintenance-card', style: card }, backupBlock)
1517
1662
  const diagnosticFailure = diagnostics?.checks?.some((check) => check.status === 'error' || (check.status === 'warning' && !(check.id === 'backup-storage' && String(check.detail || '').startsWith('0:')))) === true
1518
1663
  const tabWarnings = {
1519
1664
  overview: false,
1665
+ notify: false,
1520
1666
  health: Boolean(healthError || permissionError || diagnosticFailure || permissionAbnormal > 0),
1521
1667
  usage: Boolean(usageError),
1522
1668
  backup: Boolean(backupError),
1523
- restart: Boolean(error),
1669
+ restart: Boolean(restartFlowState.error),
1524
1670
  }
1525
1671
  const tabs = [
1526
1672
  ['overview', 'tabs.overview'],
1673
+ ['notify', 'tabs.notify'],
1527
1674
  ['health', 'tabs.health'],
1528
1675
  ['usage', 'tabs.usage'],
1529
1676
  ['backup', 'tabs.backup'],
@@ -1532,13 +1679,15 @@ window.__ModuleLoader__.load({
1532
1679
  const warningTabs = tabs.filter(([id]) => tabWarnings[id]).map(([, label]) => translate(label))
1533
1680
  const tabContent = activeTab === 'overview'
1534
1681
  ? overviewBlock
1535
- : activeTab === 'health'
1536
- ? healthBlock
1537
- : activeTab === 'usage'
1538
- ? usageBlock
1539
- : activeTab === 'backup'
1540
- ? maintenanceBlock
1541
- : restartBlock
1682
+ : activeTab === 'notify'
1683
+ ? notifyBlock
1684
+ : activeTab === 'health'
1685
+ ? healthBlock
1686
+ : activeTab === 'usage'
1687
+ ? usageBlock
1688
+ : activeTab === 'backup'
1689
+ ? maintenanceBlock
1690
+ : restartBlock
1542
1691
  return React.createElement('div', null,
1543
1692
  warningTabs.length > 0 ? React.createElement('div', { style: { marginBottom: '12px', padding: '11px 13px', borderRadius: '8px', background: 'rgba(198,128,0,0.16)', border: '1px solid rgba(198,128,0,0.48)', boxShadow: '0 2px 8px rgba(0,0,0,0.08)' } },
1544
1693
  React.createElement('div', { style: { fontSize: '13px', fontWeight: 700 } }, translate('tabs.alert.title')),
@@ -1581,10 +1730,21 @@ window.__ModuleLoader__.load({
1581
1730
  { name: 'sidebar.footer.action', id: 'dsh-service-update', order: 90, label: () => t('update.badge') },
1582
1731
  () => React.createElement(UpdateBadge, null),
1583
1732
  ))
1584
- ctx.slots.inject('settings.section', () => ctx.slots.register(
1585
- { name: 'settings.section', id: 'dsh-service', order: 99, label: () => t('nav.label') },
1586
- () => React.createElement(ServicePanel, null),
1587
- ))
1733
+ ctx.slots.inject('settings.section', () => {
1734
+ const disposePanel = ctx.slots.register(
1735
+ { name: 'settings.section', id: 'dsh-service', order: 99, label: () => t('nav.label') },
1736
+ () => React.createElement(ServicePanel, null),
1737
+ )
1738
+ // 左列「重启」入口由「重启」标签内的开关控制,默认不注册
1739
+ syncRestartNavEntry()
1740
+ return () => {
1741
+ disposePanel()
1742
+ if (restartNavDispose) {
1743
+ restartNavDispose()
1744
+ restartNavDispose = null
1745
+ }
1746
+ }
1747
+ })
1588
1748
  ctx.slots.inject('shell.overlay', () => ctx.slots.register(
1589
1749
  { name: 'shell.overlay', id: 'dsh-service-restart', order: 100, label: () => t('overlay.label') },
1590
1750
  () => React.createElement(RestartOverlay, null),
package/index.js CHANGED
@@ -21,7 +21,7 @@ const MAX_BACKUP_TRANSFER_BYTES = 128 * 1024 * 1024
21
21
  const instanceId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`
22
22
  const backupIdSecret = randomBytes(32)
23
23
  const BACKUP_NAME = /^dsh-backup-\d{8}-\d{6}\.tar\.gz$/
24
- const USAGE_INDEX_VERSION = 4
24
+ const USAGE_INDEX_VERSION = 5
25
25
  const USAGE_INDEX_FILE = 'dsh-service-usage-index.json'
26
26
 
27
27
  // 升级目标白名单:命令与包名全部来自宿主常量,浏览器不传任何输入。
@@ -376,12 +376,11 @@ function localDayForHour(hour, timezoneOffsetMinutes = 0) {
376
376
  }
377
377
 
378
378
  function emptyUsageTotals() {
379
- return { steps: 0, missingUsage: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 }
379
+ return { steps: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 }
380
380
  }
381
381
 
382
382
  function addUsageTotals(target, source) {
383
383
  target.steps += source.steps || 0
384
- target.missingUsage += source.missingUsage || 0
385
384
  target.inputTokens += source.inputTokens || 0
386
385
  target.outputTokens += source.outputTokens || 0
387
386
  target.cacheReadTokens += source.cacheReadTokens || 0
@@ -571,19 +570,18 @@ function foldUsageEvents(ctx, record, previous, events) {
571
570
  if (addToolError(session, event)) continue
572
571
  if (addUsageError(session, event, model)) continue
573
572
  if (event.type !== 'assistant/message') continue
573
+ // 提供方未上报 token 用量的步骤直接忽略:不计步骤、不建模型桶、不提示。
574
+ if (event.data?.usage === undefined) continue
574
575
  const hour = usageHour(event.time)
575
576
  const bucket = session.hours[hour] || (session.hours[hour] = { totals: emptyUsageTotals(), models: {} })
576
577
  const modelBucket = bucket.models[model.id] || (bucket.models[model.id] = { id: model.id, provider: model.provider, model: model.model, totals: emptyUsageTotals() })
577
- const usage = event.data?.usage
578
+ const usage = event.data.usage
578
579
  const delta = emptyUsageTotals()
579
580
  delta.steps = 1
580
- if (usage === undefined) delta.missingUsage = 1
581
- else {
582
- delta.inputTokens = Number(usage.inputTokens) || 0
583
- delta.outputTokens = Number(usage.outputTokens) || 0
584
- delta.cacheReadTokens = Number(usage.cacheReadTokens) || 0
585
- delta.cacheWriteTokens = Number(usage.cacheWriteTokens) || 0
586
- }
581
+ delta.inputTokens = Number(usage.inputTokens) || 0
582
+ delta.outputTokens = Number(usage.outputTokens) || 0
583
+ delta.cacheReadTokens = Number(usage.cacheReadTokens) || 0
584
+ delta.cacheWriteTokens = Number(usage.cacheWriteTokens) || 0
587
585
  addUsageTotals(bucket.totals, delta)
588
586
  addUsageTotals(modelBucket.totals, delta)
589
587
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gehennawu/dsh-service",
3
- "version": "0.13.2",
3
+ "version": "0.15.0",
4
4
  "description": "DSH Web 自托管运维面板:安全重启、健康监控、备份和 Linux 权限维护。",
5
5
  "type": "module",
6
6
  "scripts": {