@gehennawu/dsh-service 0.13.1 → 0.14.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
@@ -11,7 +11,7 @@ A service-control and operations plugin for self-hosted DSH Web. Provides safe r
11
11
  ### Version and updates
12
12
 
13
13
  - Displays current DSH and plugin versions with links to GitHub Releases
14
- - Automatically checks npm registry for stable and preview releases, shows update status
14
+ - 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
15
  - One-click plugin upgrade with automatic restart after completion
16
16
 
17
17
  ### Safe restart
@@ -19,6 +19,7 @@ A service-control and operations plugin for self-hosted DSH Web. Provides safe r
19
19
  - Detects active agents, background jobs, and terminals before restart; lists them and requires explicit confirmation
20
20
  - `/restart` command also available in conversations; automatically refuses when active work is detected
21
21
  - Automatically probes for the new process after restart and reloads the page; manual reload available after 60 seconds
22
+ - 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
23
 
23
24
  ### Health diagnostics
24
25
 
@@ -44,6 +45,7 @@ A service-control and operations plugin for self-hosted DSH Web. Provides safe r
44
45
  ### Task notifications
45
46
 
46
47
  - Browser notification when a session finishes its turn, or when your approval, plan review, or answer is needed
48
+ - Clicking a notification focuses the DSH page and closes the popup
47
49
  - Three toggle switches: master switch, task completion, and approvals & questions, each controlled independently
48
50
  - Bell icon in the conversation input bar toggles the master switch quickly
49
51
  - All toggles persist across page reloads
package/README.md CHANGED
@@ -11,7 +11,7 @@
11
11
  ### 版本与更新
12
12
 
13
13
  - 显示当前 DSH 和插件版本,版本号链接到 GitHub Releases
14
- - 自动检查 npm registry 的正式版和预览版,显示更新状态
14
+ - 自动检查 npm registry 的正式版和预览版;有新版本时右侧「小三角 + 有新版本」状态文本可点击展开/收起,行内展示当前/最新版本与正式版/预览版双 tag,版本号分别带 npmjs 与 npmmirror 链接(部分网络会被 npmjs.com 拦截,npmmirror 作为镜像入口)
15
15
  - 一键升级插件,升级后自动重启
16
16
 
17
17
  ### 安全重启
@@ -19,6 +19,7 @@
19
19
  - 重启前检测活跃 Agent、后台任务和终端,展示清单并要求显式确认
20
20
  - 对话中输入 `/restart` 也可触发,检测到运行中工作时自动拒绝
21
21
  - 重启后自动探测新进程并刷新页面,60 秒未恢复时提供手动刷新
22
+ - 可在「重启」标签开启「设置页左列显示入口」开关(默认关闭),开启后在设置页左侧标签列底部显示「重启」快捷入口,与「重启」标签共用同一套确认流程
22
23
 
23
24
  ### 健康诊断
24
25
 
@@ -44,6 +45,7 @@
44
45
  ### 任务通知
45
46
 
46
47
  - 会话完成一轮任务、或需要你授权/审阅计划/选择答案时发送浏览器通知
48
+ - 点击系统通知弹窗会聚焦到 DSH 页面并关闭该通知
47
49
  - 三档开关条:总开关、任务结束通知、授权与提问通知,各自独立控制
48
50
  - 对话栏内铃铛图标快速切换总开关
49
51
  - 各开关在页面刷新后保持
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,13 +117,30 @@ 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': '升级中…',
120
125
  'update.upgradeError': '插件升级失败',
121
126
  'update.upgradeErrorDetail': '插件升级失败({detail})',
122
127
  'update.upgradeSuccess': '升级成功,服务重启中…',
128
+ 'update.guardActiveWork': '有活动中的会话或后台任务,请处理完毕后再升级',
129
+ 'update.guardLinkInstall': '插件通过 link: 安装(开发模式),不能从 registry 一键升级;请更新源码仓库后重启',
130
+ 'update.guardFileInstall': '插件通过 file: 安装,不能从 registry 一键升级',
131
+ 'update.guardNoNewer': 'registry 最新版不高于当前版本,已拒绝可能回退的升级',
132
+ 'update.guardNoProfile': '没有找到安装了本插件的 profile,无法定位升级目标',
133
+ 'update.guardAmbiguous': '多个 profile 都安装了本插件且无法确定当前加载的副本,已中止',
134
+ 'update.guardStale': '命令报告成功但安装版本没有变化(可能被 pnpm 安全等待期拦下),保持当前版本不重启',
135
+ 'update.guardUnreadable': '命令成功但无法确认安装后的版本,已中止重启',
136
+ 'update.failPnpmMissing': '找不到 pnpm,无法执行升级;请先安装 pnpm 再重试',
137
+ 'update.failNetwork': '拉取依赖时网络临时失败,请稍后重试',
138
+ 'update.failFetchTimeout': '下载超时(网络较慢或安装包较大),请稍后重试',
139
+ 'update.failReleaseAge': '新版本被 pnpm 安全等待期拦截,已自动放行重试仍失败',
140
+ 'update.failHoist': 'profile 的 node_modules 由不同版本的 pnpm 创建,已重建重试仍失败',
141
+ 'update.failAddingToRoot': 'pnpm 拒绝在 workspace 根目录安装(缺少 -w)',
142
+ 'update.failNotWorkspace': 'profile 不是 pnpm workspace 却传入了 -w',
143
+ 'update.failIgnoredBuilds': '依赖构建脚本被 pnpm 默认拦截,无法完成升级',
123
144
  'restart.title': '服务重启',
124
145
  'restart.description': '重启 dsh web 进程。运行中的工作会中断,持久化会话可恢复。也可在对话中输入 /restart。',
125
146
  'restart.button': '重启 dsh web',
@@ -130,6 +151,8 @@ window.__ModuleLoader__.load({
130
151
  'restart.sent': '重启指令已发出。',
131
152
  'restart.sentHint': '页面连接即将断开,服务恢复后将自动刷新。',
132
153
  'restart.idleHint': '当前没有检测到运行中的工作。确认后将断开连接,等待服务自动重启。',
154
+ 'restart.navToggle': '设置页左列显示「重启」入口',
155
+ 'restart.navToggleHint': '默认关闭;开启后在设置页左侧标签列底部显示快捷重启入口',
133
156
  'activity.agent': 'Agent',
134
157
  'activity.job': '后台任务',
135
158
  'activity.terminal': '终端',
@@ -196,6 +219,7 @@ window.__ModuleLoader__.load({
196
219
  }
197
220
  const en = {
198
221
  'nav.label': 'Service Control',
222
+ 'nav.restart': 'Restart',
199
223
  'overlay.label': 'Service restart status',
200
224
  'recovery.waiting.title': 'Restarting service…',
201
225
  'recovery.waiting.body': 'Waiting for a new DSH Web process. Elapsed: {seconds} seconds.',
@@ -238,6 +262,7 @@ window.__ModuleLoader__.load({
238
262
  'health.detail.permissions.warning': 'Found {count} file or directory permission anomalies',
239
263
  'health.detail.generic': '{status}',
240
264
  'tabs.overview': 'Overview',
265
+ 'tabs.notify': 'Notifications',
241
266
  'tabs.health': 'Health',
242
267
  'tabs.usage': 'Models',
243
268
  'overview.container': 'Container info',
@@ -292,6 +317,8 @@ window.__ModuleLoader__.load({
292
317
  'update.checking': 'Checking…',
293
318
  'update.current': 'Up to date',
294
319
  'update.available': 'New version: {version}',
320
+ 'update.detailsButton': 'View details',
321
+ 'update.detailsHide': 'Collapse',
295
322
  'update.unavailable': 'Latest version is temporarily unavailable',
296
323
  'update.unpublished': 'No published version is available to check',
297
324
  'health.recheck': 'Run again',
@@ -299,13 +326,30 @@ window.__ModuleLoader__.load({
299
326
  'update.details.title': 'DSH update available',
300
327
  'update.details.current': 'Current version: {version}',
301
328
  'update.details.latest': 'Latest version: {version}',
302
- 'update.channels': 'Stable {latest} · Preview {next}',
329
+ 'update.channelStable': 'Stable',
330
+ 'update.channelPreview': 'Preview',
303
331
  'update.details.close': 'Close',
304
332
  'update.upgrade': 'Upgrade plugin',
305
333
  'update.upgrading': 'Upgrading…',
306
334
  'update.upgradeError': 'Plugin upgrade failed',
307
335
  'update.upgradeErrorDetail': 'Plugin upgrade failed ({detail})',
308
336
  'update.upgradeSuccess': 'Upgrade successful, restarting…',
337
+ 'update.guardActiveWork': 'Active sessions or background tasks are running; resolve them before upgrading',
338
+ 'update.guardLinkInstall': 'Installed via link: (development mode) — cannot one-click upgrade from the registry; update the source checkout and restart',
339
+ 'update.guardFileInstall': 'Installed via file: — cannot one-click upgrade from the registry',
340
+ 'update.guardNoNewer': 'The registry latest is not higher than the current version; refused a possible downgrade',
341
+ 'update.guardNoProfile': 'No profile with this plugin installed was found to target the upgrade at',
342
+ 'update.guardAmbiguous': 'Several profiles install this plugin and the loaded copy could not be determined; aborted',
343
+ 'update.guardStale': 'The command reported success but the installed version did not change (pnpm safety wait likely blocked it); keeping the current version without restarting',
344
+ 'update.guardUnreadable': 'The command succeeded but the installed version could not be confirmed; the restart was cancelled',
345
+ 'update.failPnpmMissing': 'pnpm was not found, so the upgrade cannot run; install pnpm first and retry',
346
+ 'update.failNetwork': 'A transient network failure occurred while fetching dependencies; please retry shortly',
347
+ 'update.failFetchTimeout': 'Download timed out (slow network or large package); please retry later',
348
+ 'update.failReleaseAge': 'The new release is blocked by pnpm\'s fresh-release safety wait; one automatic bypass retry also failed',
349
+ 'update.failHoist': 'This profile\'s node_modules was created by a different pnpm major; the rebuild retry also failed',
350
+ 'update.failAddingToRoot': 'pnpm refused to add at the workspace root (missing -w)',
351
+ 'update.failNotWorkspace': '-w was passed but the profile is not a pnpm workspace',
352
+ 'update.failIgnoredBuilds': 'Dependency build scripts are blocked by pnpm by default, so the upgrade could not finish',
309
353
  'restart.title': 'Service restart',
310
354
  'restart.description': 'Restart the dsh web process. Active work will be interrupted; persisted sessions can be resumed. You can also type /restart in a conversation.',
311
355
  'restart.button': 'Restart dsh web',
@@ -316,6 +360,8 @@ window.__ModuleLoader__.load({
316
360
  'restart.sent': 'Restart request sent.',
317
361
  'restart.sentHint': 'The connection will close shortly. The page will reload automatically after recovery.',
318
362
  'restart.idleHint': 'No active work was detected. Confirm to disconnect and wait for the service to restart.',
363
+ 'restart.navToggle': 'Show "Restart" entry in settings left nav',
364
+ 'restart.navToggleHint': 'Off by default; when enabled, a quick-restart entry appears at the bottom of the settings left navigation',
319
365
  'activity.agent': 'Agent',
320
366
  'activity.job': 'Background job',
321
367
  'activity.terminal': 'Terminal',
@@ -424,6 +470,36 @@ window.__ModuleLoader__.load({
424
470
  }, [])
425
471
  return { enabled, done, input, setEnabled: (v) => setNotifyEnabled(v), setDone: (v) => setNotifyDone(v), setInput: (v) => setNotifyInput(v) }
426
472
  }
473
+ // 设置页左列「重启」入口显示开关:默认关闭,localStorage 持久化;
474
+ // 开启时才注册 settings.section 条目(导航列单元格由外壳渲染,开关不生效就不能用 null 内容占位)。
475
+ let restartNavEnabled = false
476
+ try { restartNavEnabled = localStorage.getItem('dsh-service-restart-nav') === 'true' } catch (_) {}
477
+ let restartNavDispose = null
478
+ const syncRestartNavEntry = () => {
479
+ if (restartNavDispose) { restartNavDispose(); restartNavDispose = null }
480
+ if (!restartNavEnabled) return
481
+ restartNavDispose = ctx.slots.register(
482
+ { name: 'settings.section', id: 'dsh-service-restart', order: 499, label: () => t('nav.restart') },
483
+ () => React.createElement(RestartSection, null),
484
+ )
485
+ }
486
+ const restartNavListeners = new Set()
487
+ const setRestartNavEnabled = (value) => {
488
+ restartNavEnabled = value === true
489
+ try { localStorage.setItem('dsh-service-restart-nav', restartNavEnabled ? 'true' : 'false') } catch (_) {}
490
+ syncRestartNavEntry()
491
+ for (const listener of restartNavListeners) listener()
492
+ }
493
+ const useRestartNavEnabled = () => {
494
+ const [enabled, setEnabled] = useState(restartNavEnabled)
495
+ useEffect(() => {
496
+ const update = () => setEnabled(restartNavEnabled)
497
+ restartNavListeners.add(update)
498
+ setEnabled(restartNavEnabled)
499
+ return () => restartNavListeners.delete(update)
500
+ }, [])
501
+ return [enabled, setRestartNavEnabled]
502
+ }
427
503
  // 会话边沿通知:running→idle 记一次任务结束;pendingInteraction 出现记一次需要确认。
428
504
  // 数据源是客户端运行时的会话列表快照(订阅推送);首个快照只建立基线,重连后重建基线,二者都不响铃。
429
505
  const NOTIFY_KIND_KEYS = {
@@ -434,7 +510,15 @@ window.__ModuleLoader__.load({
434
510
  const notifyPermissionGranted = () => typeof Notification !== 'undefined' && Notification.permission === 'granted'
435
511
  const fireNotification = (title, body) => {
436
512
  if (!notifyPermissionGranted()) return
437
- try { new Notification(title, { body }) } catch (_) {}
513
+ try {
514
+ const notification = new Notification(title, { body })
515
+ // 点击系统通知弹窗:聚焦 DSH 页面并关闭该通知(chrome/ff 从通知点击回调
516
+ // 视为用户手势,window.focus() 可把标签页带到前台)
517
+ notification.onclick = () => {
518
+ try { window.focus() } catch (_) {}
519
+ try { notification.close() } catch (_) {}
520
+ }
521
+ } catch (_) {}
438
522
  }
439
523
  if (ctx.sessions && typeof ctx.sessions.list?.subscribe === 'function') {
440
524
  const observed = new Map()
@@ -508,6 +592,60 @@ window.__ModuleLoader__.load({
508
592
  return snapshot
509
593
  }
510
594
 
595
+ // 重启流程共享状态:设置面板「重启」标签与专属设置页共用同一份 stage/activity/busy/error,
596
+ // 触发路径(两段式确认、强制重启、恢复浮层)完全一致。
597
+ const restartFlowListeners = new Set()
598
+ let restartFlow = { stage: 0, activity: null, busy: false, error: null }
599
+ const setRestartFlow = (next) => {
600
+ restartFlow = next
601
+ for (const listener of restartFlowListeners) listener(next)
602
+ }
603
+ const useRestartFlow = () => {
604
+ const [snapshot, setSnapshot] = useState(restartFlow)
605
+ useEffect(() => {
606
+ restartFlowListeners.add(setSnapshot)
607
+ setSnapshot(restartFlow)
608
+ return () => restartFlowListeners.delete(setSnapshot)
609
+ }, [])
610
+ return snapshot
611
+ }
612
+ const checkRestart = async () => {
613
+ setRestartFlow({ ...restartFlow, busy: true, error: null })
614
+ try {
615
+ const res = await ctx.connection.rpc.call('/dsh-service', 'activity', {})
616
+ if (res && res.ok === false) {
617
+ console.error('dsh-service: activity check failed', res.error)
618
+ throw new Error(t('error.activity'))
619
+ }
620
+ const nextActivity = res && res.value ? res.value : { hasActive: false, items: [] }
621
+ setRestartFlow({ ...restartFlow, activity: nextActivity, stage: nextActivity.hasActive ? 3 : 1, busy: false, error: null })
622
+ } catch (err) {
623
+ setRestartFlow({ ...restartFlow, error: err && err.message ? String(err.message) : String(err), stage: 0, busy: false })
624
+ }
625
+ }
626
+ const restartWeb = async (force) => {
627
+ setRestartFlow({ ...restartFlow, busy: true, error: null })
628
+ try {
629
+ const res = await ctx.connection.rpc.call('/dsh-service', 'web', { force: force === true })
630
+ if (res && res.ok === false) {
631
+ if (res.error === 'active-work' && res.value) {
632
+ setRestartFlow({ ...restartFlow, activity: res.value, stage: 3, busy: false, error: null })
633
+ return
634
+ }
635
+ console.error('dsh-service: restart failed', res.error)
636
+ throw new Error(t('error.restart'))
637
+ }
638
+ const previousInstanceId = res && res.value ? res.value.instanceId : undefined
639
+ if (typeof previousInstanceId !== 'string' || previousInstanceId.length === 0) {
640
+ throw new Error(t('error.instance'))
641
+ }
642
+ setRestartFlow({ ...restartFlow, stage: 2, busy: false, error: null })
643
+ startRecovery(previousInstanceId).catch((err) => console.error('dsh-service: recovery failed', err))
644
+ } catch (err) {
645
+ setRestartFlow({ ...restartFlow, error: err && err.message ? String(err.message) : String(err), stage: 0, busy: false })
646
+ }
647
+ }
648
+
511
649
  const useRecoveryState = () => {
512
650
  const [snapshot, setSnapshot] = useState(recoveryState)
513
651
  useEffect(() => {
@@ -555,6 +693,7 @@ window.__ModuleLoader__.load({
555
693
  ctx.effect(() => () => {
556
694
  recoveryGeneration += 1
557
695
  recoveryListeners.clear()
696
+ restartFlowListeners.clear()
558
697
  updateListeners.clear()
559
698
  }, 'dsh-service recovery')
560
699
 
@@ -569,6 +708,27 @@ window.__ModuleLoader__.load({
569
708
  }, translate('update.badge'))
570
709
  }
571
710
 
711
+ // 正式/预览通道行:版本号后跟 npmjs(版本页)与 npmmirror(镜像版本页)两个文字链接。
712
+ // 版本串嵌进 URL 前过安全字符集校验,不过校验的标签降级为纯文本。供更新详情浮层使用。
713
+ const NPM_DSH_PACKAGE = '@deepseek-ai/dsh'
714
+ const packageVersionHref = (base, version) => {
715
+ if (typeof version !== 'string' || version.length === 0 || !/^[0-9A-Za-z.+_-]+$/.test(version)) return null
716
+ return `${base}${version}`
717
+ }
718
+ const siteLabelLink = (kind, label, href) => {
719
+ const testid = `version-dsh-channel-${kind}-${label}`
720
+ if (!href) return React.createElement('span', { 'data-testid': testid, style: { marginLeft: '10px' } }, label)
721
+ return React.createElement('a', { 'data-testid': testid, href, target: '_blank', rel: 'noreferrer', style: { color: 'var(--dsw-alias-brand-primary)', textDecoration: 'underline', marginLeft: '10px' } }, label)
722
+ }
723
+ const channelLine = (translate, kind, version) => React.createElement('div', { style: { whiteSpace: 'nowrap', lineHeight: 1.7 } },
724
+ kind === 'latest' ? translate('update.channelStable') : translate('update.channelPreview'),
725
+ ' ', React.createElement('span', { 'data-testid': `version-dsh-channel-${kind}`, style: { marginLeft: '4px' } }, version || '—'),
726
+ siteLabelLink(kind, 'npmjs', packageVersionHref(`https://www.npmjs.com/package/${NPM_DSH_PACKAGE}/v/`, version)),
727
+ siteLabelLink(kind, 'npmmirror', packageVersionHref(`https://www.npmmirror.com/package/${NPM_DSH_PACKAGE}/home?version=`, version)))
728
+ 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' } },
729
+ channelLine(translate, 'latest', tags && tags.latest),
730
+ channelLine(translate, 'next', tags && tags.next))
731
+
572
732
  function ServiceOverlay() {
573
733
  const recovery = useRecoveryState()
574
734
  const updateState = useUpdateState()
@@ -585,7 +745,7 @@ window.__ModuleLoader__.load({
585
745
  React.createElement('div', { style: { fontSize: '18px', fontWeight: 700, marginBottom: '10px' } }, translate('update.details.title')),
586
746
  React.createElement('p', { style: { margin: '4px 0', fontSize: '13px' } }, translate('update.details.current', { version: update.current })),
587
747
  React.createElement('p', { style: { margin: '4px 0', fontSize: '13px' } }, translate('update.details.latest', { version: update.latest })),
588
- 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 || '—' })),
748
+ channelLines(translate, update.tags),
589
749
  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'))))
590
750
  }
591
751
 
@@ -634,6 +794,89 @@ window.__ModuleLoader__.load({
634
794
  return React.createElement(ServiceOverlay, null)
635
795
  }
636
796
 
797
+ // 重启区块:设置面板「重启」标签与设置页左列底部的专属入口共用,状态取自共享重启流。
798
+ // showNavToggle 仅「重启」标签传入:控制设置页左列入口是否显示(默认关闭)。
799
+ function RestartSection({ showNavToggle }) {
800
+ const translate = useTranslation()
801
+ const flow = useRestartFlow()
802
+ const [navEnabled, setNavEnabled] = useRestartNavEnabled()
803
+ 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' }
804
+ const danger = { ...btn, background: 'var(--dsw-alias-state-error-primary)', color: '#fff', borderColor: 'var(--dsw-alias-state-error-primary)' }
805
+ const ghost = { ...btn, background: 'transparent', color: 'var(--dsw-alias-label-primary)', borderColor: 'var(--dsw-alias-border-l2)' }
806
+ const row = { display: 'flex', alignItems: 'center', gap: '8px', marginTop: '12px', flexWrap: 'wrap' }
807
+ const hint = { color: 'var(--dsw-alias-label-secondary)', fontSize: '12px', marginTop: '8px', lineHeight: 1.5 }
808
+ const card = { padding: '4px 0 14px', marginBottom: '12px', color: 'var(--dsw-alias-label-primary)' }
809
+ 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' }
810
+ const sectionTitle = { fontSize: '14px', fontWeight: 700, margin: '0 0 8px', color: 'var(--dsw-alias-label-primary)' }
811
+
812
+ // 重启后提示
813
+ if (flow.stage === 2) {
814
+ return React.createElement('div', { 'data-testid': 'restart-card', style: card },
815
+ React.createElement('div', { style: sectionTitle }, translate('restart.title')),
816
+ React.createElement('p', { style: { margin: 0, fontSize: '13px' } }, translate('restart.sent')),
817
+ React.createElement('p', { style: hint }, translate('restart.sentHint')))
818
+ }
819
+
820
+ const activityLabels = {
821
+ agent: translate('activity.agent'),
822
+ job: translate('activity.job'),
823
+ terminal: translate('activity.terminal'),
824
+ }
825
+ const activityItems = flow.activity && Array.isArray(flow.activity.items) ? flow.activity.items : []
826
+ const activityWarning = flow.stage === 3
827
+ ? 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)' } },
828
+ React.createElement('p', { style: { margin: '0 0 8px', color: 'var(--dsw-alias-state-error-primary)', fontSize: '13px', fontWeight: 600 } },
829
+ translate('activity.warning', { count: activityItems.length })),
830
+ React.createElement('ul', { style: { margin: 0, paddingLeft: '20px', fontSize: '12px', lineHeight: 1.7 } },
831
+ activityItems.map((item) => React.createElement('li', { key: item.type + ':' + item.id },
832
+ translate('activity.item', {
833
+ type: activityLabels[item.type] || item.type,
834
+ label: item.label,
835
+ status: item.status,
836
+ }))))
837
+ )
838
+ : null
839
+
840
+ // 重启按钮区块
841
+ return React.createElement('div', { 'data-testid': 'restart-card', style: card },
842
+ React.createElement('div', { style: sectionTitle }, translate('restart.title')),
843
+ React.createElement('div', { 'data-testid': 'restart-region', style: displaySurface },
844
+ React.createElement('p', { style: { margin: 0, fontSize: '13px' } }, translate('restart.description')),
845
+ activityWarning,
846
+ React.createElement('div', { style: row },
847
+ flow.stage === 0
848
+ ? React.createElement('button', { style: danger, 'data-variant': 'danger', onClick: checkRestart, disabled: flow.busy }, translate(flow.busy ? 'update.checking' : 'restart.button'))
849
+ : flow.stage === 1
850
+ ? [
851
+ React.createElement('button', { key: 'confirm', style: danger, onClick: () => restartWeb(false), disabled: flow.busy }, translate(flow.busy ? 'restart.sending' : 'restart.confirm')),
852
+ React.createElement('button', { key: 'cancel', style: ghost, onClick: () => setRestartFlow({ ...restartFlow, activity: null, stage: 0, busy: false, error: null }), disabled: flow.busy }, translate('restart.cancel')),
853
+ ]
854
+ : flow.stage === 3
855
+ ? [
856
+ React.createElement('button', { key: 'force', style: danger, onClick: () => restartWeb(true), disabled: flow.busy }, translate(flow.busy ? 'restart.sending' : 'restart.force')),
857
+ React.createElement('button', { key: 'cancel', style: ghost, onClick: () => setRestartFlow({ ...restartFlow, activity: null, stage: 0, busy: false, error: null }), disabled: flow.busy }, translate('restart.cancel')),
858
+ ]
859
+ : null
860
+ ),
861
+ flow.stage === 1 ? React.createElement('p', { style: hint }, translate('restart.idleHint')) : null,
862
+ flow.error ? React.createElement('p', { style: Object.assign({}, hint, { color: 'var(--dsw-alias-state-error-primary)' }) }, String(flow.error)) : null,
863
+ showNavToggle
864
+ ? 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' } },
865
+ React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: '2px' } },
866
+ React.createElement('span', { style: { fontSize: '13px', color: 'var(--dsw-alias-label-primary)' } }, translate('restart.navToggle')),
867
+ React.createElement('span', { style: hint }, translate('restart.navToggleHint'))),
868
+ React.createElement('button', {
869
+ type: 'button',
870
+ role: 'switch',
871
+ 'data-testid': 'restart-nav-switch',
872
+ 'aria-checked': String(navEnabled),
873
+ onClick: () => setNavEnabled(!navEnabled),
874
+ 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 },
875
+ }, 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)' } })))
876
+ : null)
877
+ )
878
+ }
879
+
637
880
  function ServicePanel() {
638
881
  const translate = useTranslation()
639
882
  const [health, setHealth] = useState(null)
@@ -671,11 +914,10 @@ window.__ModuleLoader__.load({
671
914
  const [toolErrorsOpen, setToolErrorsOpen] = useState(false)
672
915
  const [modelsOpen, setModelsOpen] = useState(false)
673
916
  const [activeTab, setActiveTab] = useState('overview')
674
- // 重启状态:0=初始,1=普通确认,2=已发出,3=检测到活动工作
675
- const [stage, setStage] = useState(0)
676
- const [activity, setActivity] = useState(null)
677
- const [busy, setBusy] = useState(false)
678
- const [error, setError] = useState(null)
917
+ // 版本详情行内展开(不用浮层:弹层会被设置模态盖住)
918
+ const [channelOpen, setChannelOpen] = useState(false)
919
+ // 重启流程状态来自共享流(与设置页左列底部的专属入口同源)
920
+ const restartFlowState = useRestartFlow()
679
921
  const usageRequestPayload = { timezoneOffsetMinutes: new Date().getTimezoneOffset() }
680
922
 
681
923
  // 进入面板时拉取当前版本和健康快照;健康数据每 5 秒刷新,卸载即停止。
@@ -913,6 +1155,25 @@ window.__ModuleLoader__.load({
913
1155
  reader.readAsArrayBuffer(file)
914
1156
  }
915
1157
 
1158
+ // 宿主返回的稳定失败码 → 词典文案;未知错误详情(如 dsh-failed: …)透出原文。
1159
+ const UPGRADE_FAILURES = {
1160
+ 'active-work': 'update.guardActiveWork',
1161
+ 'link-install': 'update.guardLinkInstall',
1162
+ 'file-install': 'update.guardFileInstall',
1163
+ 'no-newer-version': 'update.guardNoNewer',
1164
+ 'no-profile-found': 'update.guardNoProfile',
1165
+ 'ambiguous-profile': 'update.guardAmbiguous',
1166
+ 'upgrade-stale': 'update.guardStale',
1167
+ 'installed-version-unreadable': 'update.guardUnreadable',
1168
+ 'pnpm-missing': 'update.failPnpmMissing',
1169
+ 'transient-network': 'update.failNetwork',
1170
+ 'fetch-timeout': 'update.failFetchTimeout',
1171
+ 'release-age-violation': 'update.failReleaseAge',
1172
+ 'hoist-pattern-diff': 'update.failHoist',
1173
+ 'adding-to-root': 'update.failAddingToRoot',
1174
+ 'not-a-workspace': 'update.failNotWorkspace',
1175
+ 'ignored-builds': 'update.failIgnoredBuilds',
1176
+ }
916
1177
  const upgradePlugin = async () => {
917
1178
  setUpgradeBusy(true)
918
1179
  setUpgradeError(null)
@@ -920,10 +1181,16 @@ window.__ModuleLoader__.load({
920
1181
  const versionRes = await ctx.connection.rpc.call('/dsh-service', 'version', {})
921
1182
  const previousInstanceId = versionRes && versionRes.ok ? versionRes.value.instanceId : undefined
922
1183
  const res = await ctx.connection.rpc.call('/dsh-service', 'upgrade', {})
923
- // 宿主错误详情(如 npm-failed: …、npm was not found on PATH)随通用文案透出,便于排查。
924
1184
  if (!res || res.ok === false) {
925
- const detail = res && typeof res.error === 'string' ? res.error.trim() : ''
926
- throw new Error(detail || 'upgrade failed')
1185
+ const code = res && typeof res.error === 'string' ? res.error.trim() : ''
1186
+ const mapped = typeof code === 'string' && code.length > 0 ? UPGRADE_FAILURES[code] : undefined
1187
+ // 已知失败码走双语词典;其余(如 npm-failed: …、dsh-failed: …)随通用文案透出宿主错误详情。
1188
+ if (mapped !== undefined) {
1189
+ setUpgradeError(translate(mapped))
1190
+ } else {
1191
+ setUpgradeError(translate('update.upgradeErrorDetail', { detail: code || 'upgrade failed' }))
1192
+ }
1193
+ return
927
1194
  }
928
1195
  if (typeof previousInstanceId === 'string' && previousInstanceId.length > 0) {
929
1196
  startRecovery(previousInstanceId).catch(() => {})
@@ -937,54 +1204,6 @@ window.__ModuleLoader__.load({
937
1204
  }
938
1205
  }
939
1206
 
940
- const checkRestart = async () => {
941
- setBusy(true)
942
- setError(null)
943
- try {
944
- const res = await ctx.connection.rpc.call('/dsh-service', 'activity', {})
945
- if (res && res.ok === false) {
946
- console.error('dsh-service: activity check failed', res.error)
947
- throw new Error(translate('error.activity'))
948
- }
949
- const nextActivity = res && res.value ? res.value : { hasActive: false, items: [] }
950
- setActivity(nextActivity)
951
- setStage(nextActivity.hasActive ? 3 : 1)
952
- } catch (err) {
953
- setError(err && err.message ? String(err.message) : String(err))
954
- setStage(0)
955
- } finally {
956
- setBusy(false)
957
- }
958
- }
959
-
960
- const restart = async (force) => {
961
- setBusy(true)
962
- setError(null)
963
- try {
964
- const res = await ctx.connection.rpc.call('/dsh-service', 'web', { force: force === true })
965
- if (res && res.ok === false) {
966
- if (res.error === 'active-work' && res.value) {
967
- setActivity(res.value)
968
- setStage(3)
969
- return
970
- }
971
- console.error('dsh-service: restart failed', res.error)
972
- throw new Error(translate('error.restart'))
973
- }
974
- setStage(2)
975
- const previousInstanceId = res && res.value ? res.value.instanceId : undefined
976
- if (typeof previousInstanceId !== 'string' || previousInstanceId.length === 0) {
977
- throw new Error(translate('error.instance'))
978
- }
979
- startRecovery(previousInstanceId).catch((err) => console.error('dsh-service: recovery failed', err))
980
- } catch (err) {
981
- setError(err && err.message ? String(err.message) : String(err))
982
- setStage(0)
983
- } finally {
984
- setBusy(false)
985
- }
986
- }
987
-
988
1207
  // 样式
989
1208
  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' }
990
1209
  const primary = { ...btn, background: 'var(--dsw-alias-brand-primary)', color: 'var(--dsw-alias-button-contrast-fill)', borderColor: 'var(--dsw-alias-brand-primary)' }
@@ -1337,21 +1556,36 @@ window.__ModuleLoader__.load({
1337
1556
  : null)))
1338
1557
  : null))
1339
1558
 
1340
- 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)' } },
1341
- React.createElement('div', { style: { whiteSpace: 'nowrap', display: 'flex', alignItems: 'center', gap: '8px' } },
1342
- React.createElement('span', { style: { fontSize: '13px', fontWeight: 650 } }, `${label} `),
1343
- state?.url
1344
- ? 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'))
1345
- : React.createElement('code', { style: { fontSize: '12px', color: 'var(--dsw-alias-label-primary)', marginLeft: '16px' } }, state?.current || fallbackVersion || translate('version.loading'))),
1346
- 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,
1559
+ // 正式/预览通道两行信息在版本卡内下拉展开(不弹浮层:弹层会被设置模态盖住)。
1560
+ // 有更新时状态文本本身可点击:小三角 + 「有新版本:…」整体切换展开/收起。
1561
+ 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' } },
1562
+ React.createElement('path', { d: 'M9 6l6 6-6 6' }))
1563
+ const versionRow = (id, label, fallbackVersion, state, action, expandable, topBorder) => {
1564
+ const statusText = !state
1565
+ ? (updateError || translate('update.checking'))
1566
+ : state.status === 'unpublished' ? translate('update.unpublished')
1567
+ : state.status === 'unavailable' ? translate('update.unavailable')
1568
+ : state.upToDate ? translate('update.current')
1569
+ : translate('update.available', { version: state.latest })
1570
+ const statusColor = !state ? 'var(--dsw-alias-label-secondary)' : state.upToDate ? 'var(--dsw-alias-state-success-primary)' : 'var(--dsw-alias-state-warn-primary)'
1571
+ const clickable = expandable && state && state.status !== 'unpublished' && state.status !== 'unavailable' && !state.upToDate
1572
+ const rightSide = clickable
1573
+ ? 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) },
1574
+ chevronIcon(channelOpen),
1575
+ React.createElement('span', null, statusText))
1576
+ : React.createElement('div', { style: { color: statusColor, fontWeight: 600 } }, statusText)
1577
+ 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 } },
1578
+ React.createElement('div', { style: { whiteSpace: 'nowrap', display: 'flex', alignItems: 'center', gap: '8px' } },
1579
+ React.createElement('span', { style: { fontSize: '13px', fontWeight: 650 } }, `${label} `),
1580
+ state?.url
1581
+ ? 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'))
1582
+ : React.createElement('code', { style: { fontSize: '12px', color: 'var(--dsw-alias-label-primary)', marginLeft: '16px' } }, state?.current || fallbackVersion || translate('version.loading'))),
1347
1583
  action || null,
1348
- React.createElement('div', { style: { textAlign: 'right', fontSize: '12px' } },
1349
- 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
1350
- ? (updateError || translate('update.checking'))
1351
- : state.status === 'unpublished' ? translate('update.unpublished')
1352
- : state.status === 'unavailable' ? translate('update.unavailable')
1353
- : state.upToDate ? translate('update.current') : translate('update.available', { version: state.latest }))))
1354
- // 版本信息区块
1584
+ React.createElement('div', { style: { textAlign: 'right', fontSize: '12px' } }, rightSide))
1585
+ }
1586
+ // 版本信息区块:DSH 行在有更新时状态文本(小三角 + 有新版本)整体可点击,行内下拉展开
1587
+ const dshUpdate = updateInfo?.dsh
1588
+ const dshExpandable = dshUpdate && dshUpdate.status !== 'unpublished' && dshUpdate.status !== 'unavailable' && !dshUpdate.upToDate
1355
1589
  const pluginUpdate = updateInfo?.plugin && !updateInfo.plugin.upToDate && updateInfo.plugin.status === 'available'
1356
1590
  const pluginAction = pluginUpdate
1357
1591
  ? React.createElement('button', { style: Object.assign({}, neutral, { minHeight: '24px', padding: '2px 8px', fontSize: '11px' }), disabled: upgradeBusy, onClick: upgradePlugin }, translate(upgradeBusy ? 'update.upgrading' : 'update.upgrade'))
@@ -1359,67 +1593,18 @@ window.__ModuleLoader__.load({
1359
1593
  const versionBlock = React.createElement('div', { key: 'version-card', 'data-testid': 'version-card', style: card },
1360
1594
  React.createElement('div', { key: 'title', style: sectionTitle }, translate('version.title')),
1361
1595
  React.createElement('div', { style: displaySurface },
1362
- versionRow('dsh', 'DSH', version, updateInfo?.dsh),
1363
- versionRow('plugin', 'dsh-service', pluginVersion, updateInfo?.plugin, pluginAction),
1596
+ versionRow('plugin', 'dsh-service', pluginVersion, updateInfo?.plugin, pluginAction, false, false),
1597
+ versionRow('dsh', 'DSH', version, dshUpdate, null, dshExpandable === true, true),
1598
+ channelOpen
1599
+ ? 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' } },
1600
+ React.createElement('div', null, translate('update.details.current', { version: dshUpdate?.current || version || '—' })),
1601
+ React.createElement('div', null, translate('update.details.latest', { version: dshUpdate?.latest || '—' })),
1602
+ channelLines(translate, dshUpdate?.tags))
1603
+ : null,
1364
1604
  upgradeError ? React.createElement('p', { style: Object.assign({}, hint, { color: 'var(--dsw-alias-state-error-primary)', margin: '4px 0 0' }) }, upgradeError) : null))
1365
1605
 
1366
- // 重启后提示
1367
- if (stage === 2) {
1368
- return React.createElement('div', null,
1369
- healthBlock,
1370
- backupBlock,
1371
- versionBlock,
1372
- React.createElement('div', { key: 'restart-section' },
1373
- React.createElement('div', { style: sectionTitle }, translate('restart.title')),
1374
- React.createElement('p', { style: { margin: 0, fontSize: '13px' } }, translate('restart.sent')),
1375
- React.createElement('p', { style: hint }, translate('restart.sentHint')))
1376
- )
1377
- }
1378
-
1379
- const activityLabels = {
1380
- agent: translate('activity.agent'),
1381
- job: translate('activity.job'),
1382
- terminal: translate('activity.terminal'),
1383
- }
1384
- const activityItems = activity && Array.isArray(activity.items) ? activity.items : []
1385
- const activityWarning = stage === 3
1386
- ? 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)' } },
1387
- React.createElement('p', { style: { margin: '0 0 8px', color: 'var(--dsw-alias-state-error-primary)', fontSize: '13px', fontWeight: 600 } },
1388
- translate('activity.warning', { count: activityItems.length })),
1389
- React.createElement('ul', { style: { margin: 0, paddingLeft: '20px', fontSize: '12px', lineHeight: 1.7 } },
1390
- activityItems.map((item) => React.createElement('li', { key: item.type + ':' + item.id },
1391
- translate('activity.item', {
1392
- type: activityLabels[item.type] || item.type,
1393
- label: item.label,
1394
- status: item.status,
1395
- }))))
1396
- )
1397
- : null
1398
-
1399
- // 重启按钮区块
1400
- const restartBlock = React.createElement('div', { key: 'restart-section', 'data-testid': 'restart-card', style: card },
1401
- React.createElement('div', { style: sectionTitle }, translate('restart.title')),
1402
- React.createElement('div', { 'data-testid': 'restart-region', style: displaySurface },
1403
- React.createElement('p', { style: { margin: 0, fontSize: '13px' } }, translate('restart.description')),
1404
- activityWarning,
1405
- React.createElement('div', { style: row },
1406
- stage === 0
1407
- ? React.createElement('button', { style: danger, 'data-variant': 'danger', onClick: checkRestart, disabled: busy }, translate(busy ? 'update.checking' : 'restart.button'))
1408
- : stage === 1
1409
- ? [
1410
- React.createElement('button', { key: 'confirm', style: danger, onClick: () => restart(false), disabled: busy }, translate(busy ? 'restart.sending' : 'restart.confirm')),
1411
- React.createElement('button', { key: 'cancel', style: ghost, onClick: () => { setActivity(null); setStage(0) }, disabled: busy }, translate('restart.cancel')),
1412
- ]
1413
- : stage === 3
1414
- ? [
1415
- React.createElement('button', { key: 'force', style: danger, onClick: () => restart(true), disabled: busy }, translate(busy ? 'restart.sending' : 'restart.force')),
1416
- React.createElement('button', { key: 'cancel', style: ghost, onClick: () => { setActivity(null); setStage(0) }, disabled: busy }, translate('restart.cancel')),
1417
- ]
1418
- : null
1419
- ),
1420
- stage === 1 ? React.createElement('p', { style: hint }, translate('restart.idleHint')) : null,
1421
- error ? React.createElement('p', { style: Object.assign({}, hint, { color: 'var(--dsw-alias-state-error-primary)' }) }, String(error)) : null)
1422
- )
1606
+ // 重启区块复用共享组件(「重启」标签还承载设置页左列入口的显示开关;左侧入口默认关闭)
1607
+ const restartBlock = React.createElement(RestartSection, { showNavToggle: true })
1423
1608
 
1424
1609
  const { enabled: notifyOn, done: notifyDoneOn, input: notifyInputOn, setEnabled: setNotifyOn, setDone: setNotifyDoneOn, setInput: setNotifyInputOn } = useNotifyState()
1425
1610
  const notifSupported = typeof Notification !== 'undefined'
@@ -1455,18 +1640,22 @@ window.__ModuleLoader__.load({
1455
1640
  notifyRow(translate('notification.master'), translate('notification.masterHint'), notifyOn, setNotifyOn, false),
1456
1641
  notifyRow(translate('notification.done'), translate('notification.doneHint'), notifyDoneOn, setNotifyDoneOn, !notifyOn),
1457
1642
  notifyRow(translate('notification.input'), translate('notification.inputHint'), notifyInputOn, setNotifyInputOn, !notifyOn))))
1458
- const overviewBlock = React.createElement('div', null, versionBlock, notificationBlock, containerInfoBlock, overviewErrorsBlock)
1643
+ const overviewBlock = React.createElement('div', null, versionBlock, containerInfoBlock, overviewErrorsBlock)
1644
+ // 任务通知独立成顶部标签(v0.14 起不再混在概览里)
1645
+ const notifyBlock = React.createElement('div', null, notificationBlock)
1459
1646
  const maintenanceBlock = React.createElement('div', { key: 'maintenance-card', 'data-testid': 'maintenance-card', style: card }, backupBlock)
1460
1647
  const diagnosticFailure = diagnostics?.checks?.some((check) => check.status === 'error' || (check.status === 'warning' && !(check.id === 'backup-storage' && String(check.detail || '').startsWith('0:')))) === true
1461
1648
  const tabWarnings = {
1462
1649
  overview: false,
1650
+ notify: false,
1463
1651
  health: Boolean(healthError || permissionError || diagnosticFailure || permissionAbnormal > 0),
1464
1652
  usage: Boolean(usageError),
1465
1653
  backup: Boolean(backupError),
1466
- restart: Boolean(error),
1654
+ restart: Boolean(restartFlowState.error),
1467
1655
  }
1468
1656
  const tabs = [
1469
1657
  ['overview', 'tabs.overview'],
1658
+ ['notify', 'tabs.notify'],
1470
1659
  ['health', 'tabs.health'],
1471
1660
  ['usage', 'tabs.usage'],
1472
1661
  ['backup', 'tabs.backup'],
@@ -1475,13 +1664,15 @@ window.__ModuleLoader__.load({
1475
1664
  const warningTabs = tabs.filter(([id]) => tabWarnings[id]).map(([, label]) => translate(label))
1476
1665
  const tabContent = activeTab === 'overview'
1477
1666
  ? overviewBlock
1478
- : activeTab === 'health'
1479
- ? healthBlock
1480
- : activeTab === 'usage'
1481
- ? usageBlock
1482
- : activeTab === 'backup'
1483
- ? maintenanceBlock
1484
- : restartBlock
1667
+ : activeTab === 'notify'
1668
+ ? notifyBlock
1669
+ : activeTab === 'health'
1670
+ ? healthBlock
1671
+ : activeTab === 'usage'
1672
+ ? usageBlock
1673
+ : activeTab === 'backup'
1674
+ ? maintenanceBlock
1675
+ : restartBlock
1485
1676
  return React.createElement('div', null,
1486
1677
  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)' } },
1487
1678
  React.createElement('div', { style: { fontSize: '13px', fontWeight: 700 } }, translate('tabs.alert.title')),
@@ -1524,10 +1715,21 @@ window.__ModuleLoader__.load({
1524
1715
  { name: 'sidebar.footer.action', id: 'dsh-service-update', order: 90, label: () => t('update.badge') },
1525
1716
  () => React.createElement(UpdateBadge, null),
1526
1717
  ))
1527
- ctx.slots.inject('settings.section', () => ctx.slots.register(
1528
- { name: 'settings.section', id: 'dsh-service', order: 99, label: () => t('nav.label') },
1529
- () => React.createElement(ServicePanel, null),
1530
- ))
1718
+ ctx.slots.inject('settings.section', () => {
1719
+ const disposePanel = ctx.slots.register(
1720
+ { name: 'settings.section', id: 'dsh-service', order: 99, label: () => t('nav.label') },
1721
+ () => React.createElement(ServicePanel, null),
1722
+ )
1723
+ // 左列「重启」入口由「重启」标签内的开关控制,默认不注册
1724
+ syncRestartNavEntry()
1725
+ return () => {
1726
+ disposePanel()
1727
+ if (restartNavDispose) {
1728
+ restartNavDispose()
1729
+ restartNavDispose = null
1730
+ }
1731
+ }
1732
+ })
1531
1733
  ctx.slots.inject('shell.overlay', () => ctx.slots.register(
1532
1734
  { name: 'shell.overlay', id: 'dsh-service-restart', order: 100, label: () => t('overlay.label') },
1533
1735
  () => React.createElement(RestartOverlay, null),
package/index.js CHANGED
@@ -3,10 +3,11 @@
3
3
  import { createHmac, randomBytes, randomUUID } from 'node:crypto'
4
4
  import { Buffer } from 'node:buffer'
5
5
  import { constants as fsConstants } from 'node:fs'
6
- import { access, cp, lstat, mkdir, readFile, readdir, rename, rm, stat, unlink, writeFile } from 'node:fs/promises'
6
+ import { access, cp, lstat, mkdir, readFile, readdir, realpath, rename, rm, stat, unlink, writeFile } from 'node:fs/promises'
7
7
  import { homedir } from 'node:os'
8
- import { basename, join, relative, resolve, sep } from 'node:path'
8
+ import { basename, dirname, join, relative, resolve, sep } from 'node:path'
9
9
  import { createRequire } from 'node:module'
10
+ import { fileURLToPath } from 'node:url'
10
11
  import https from 'node:https'
11
12
 
12
13
  const require = createRequire(import.meta.url)
@@ -23,6 +24,14 @@ const BACKUP_NAME = /^dsh-backup-\d{8}-\d{6}\.tar\.gz$/
23
24
  const USAGE_INDEX_VERSION = 4
24
25
  const USAGE_INDEX_FILE = 'dsh-service-usage-index.json'
25
26
 
27
+ // 升级目标白名单:命令与包名全部来自宿主常量,浏览器不传任何输入。
28
+ // TARGET_RE 与 dsh-market 同源:只放行「包名@版本」这一种形状的字符集。
29
+ const TARGET_RE = /^[A-Za-z0-9@:./_#+~^=-]+$/
30
+ const RELEASE_AGE_OVERRIDE = '--config.minimumReleaseAge=0'
31
+ const FETCH_TIMEOUT_OVERRIDE = '--config.fetchTimeout=600000'
32
+ // 当前正在运行的插件源码目录:定位「本插件由哪个 profile 挂载」时与磁盘副本做 realpath 匹配。
33
+ const loadedPluginDir = dirname(fileURLToPath(import.meta.url))
34
+
26
35
  // 读取当前 dsh 版本。DSH 包由宿主安装,不作为插件依赖打包进来。
27
36
  let dshVersion = 'unknown'
28
37
  let pluginVersion = 'unknown'
@@ -476,6 +485,7 @@ function toolFailureCode(tool, code, message) {
476
485
  if (/ABORT|CANCEL/i.test(value) || /aborted|cancelled|canceled/i.test(message)) return undefined
477
486
  if (value && value !== 'UNKNOWN' && value !== 'Error') return value
478
487
  if (/requires reading\b/i.test(message)) return 'FS_NOT_OBSERVED'
488
+ if (/cannot read[^\n]*as an image/i.test(message)) return 'IMAGE_NOT_SUPPORTED'
479
489
  if (/old_string was not found/i.test(message)) return 'OLD_STRING_NOT_FOUND'
480
490
  if (/no such file or directory|path[^\n]*not found/i.test(message)) return 'PATH_NOT_FOUND'
481
491
  if (/file access denied|permission denied|EACCES/i.test(message)) return 'PERMISSION_DENIED'
@@ -487,6 +497,7 @@ function toolFailureCode(tool, code, message) {
487
497
 
488
498
  function toolFailureMessage(tool, code) {
489
499
  if (code === 'FS_NOT_OBSERVED') return `${tool} requires reading <path> first — read the file, then retry`
500
+ if (code === 'IMAGE_NOT_SUPPORTED') return `${tool} failed: the current model does not support image input; switch to an image-capable model`
490
501
  if (code === 'OLD_STRING_NOT_FOUND') return `${tool}: old_string was not found in <path>`
491
502
  if (code === 'PATH_NOT_FOUND') return `${tool} search failed: <path> not found`
492
503
  if (code === 'PERMISSION_DENIED') return `${tool} failed: permission denied for <path>`
@@ -663,11 +674,19 @@ function modeString(mode) {
663
674
  }
664
675
 
665
676
  async function runFixedCommand(ctx, argv) {
677
+ const result = await runCommandResult(ctx, argv)
678
+ if (result.exitCode !== 0 || result.signal !== null) {
679
+ throw new Error(`${argv[0]}-failed: ${(result.stderr || '').trim() || result.signal || result.exitCode}`)
680
+ }
681
+ }
682
+
683
+ // 非抛出的命令执行:返回退出码/信号/stdout/stderr,供升级流程做失败分类与一次性恢复。
684
+ async function runCommandResult(ctx, argv) {
666
685
  const subprocess = ctx.get('subprocess')
667
686
  if (subprocess === undefined) throw new Error('subprocess-unavailable')
668
687
  const executable = await subprocess.resolveExecutable(argv[0])
669
688
  let spawnArgv = [executable, ...argv.slice(1)]
670
- // Windows 上白名单命令(如 npm)经 PATHEXT 解析为 .cmd/.bat 脚本;subprocess 服务的
689
+ // Windows 上白名单命令(如 npm/dsh)经 PATHEXT 解析为 .cmd/.bat 脚本;subprocess 服务的
671
690
  // spawn 不带 shell,Node 对 .cmd/.bat 一律抛 EINVAL,无法直接执行。固定包一层
672
691
  // cmd.exe /d /s /c + 已解析的绝对路径,全部参数仍是宿主白名单常量,无输入拼接。
673
692
  if (process.platform === 'win32' && /\.(cmd|bat)$/i.test(executable)) {
@@ -680,22 +699,160 @@ async function runFixedCommand(ctx, argv) {
680
699
  cwd: process.platform === 'win32' ? (process.env.SystemRoot || 'C:\\Windows') : '/',
681
700
  stdio: {
682
701
  stdin: 'ignore',
683
- stdout: { maxBytes: 16 * 1024 },
684
- stderr: { maxBytes: 64 * 1024 },
702
+ stdout: { maxBytes: 64 * 1024 },
703
+ stderr: { maxBytes: 512 * 1024 },
685
704
  },
686
705
  graceMs: 5000,
687
706
  })
688
707
  const outcome = await handle.done
689
708
  const stderr = handle.collected.stderr?.readFrom(0).text || ''
690
- if (outcome.exitCode !== 0 || outcome.signal !== null) {
691
- throw new Error(`${argv[0]}-failed: ${stderr.trim() || outcome.signal || outcome.exitCode}`)
709
+ const stdout = handle.collected.stdout?.readFrom(0).text || ''
710
+ return { exitCode: outcome.exitCode ?? 1, signal: outcome.signal, stdout, stderr }
711
+ }
712
+
713
+ // 定位「安装了本插件的 profile」。只读扫描 DSH_HOME/profiles/*/package.json,浏览器不传
714
+ // 任何路径/名字;多候选时优先匹配当前正在运行的插件源码目录(realpath),避免同秒竞态。
715
+ async function resolveUpgradeProfile(dshHome) {
716
+ const profilesDir = join(dshHome, 'profiles')
717
+ let entries
718
+ try {
719
+ entries = await readdir(profilesDir, { withFileTypes: true })
720
+ } catch (_) {
721
+ throw new Error('no-profile-found')
722
+ }
723
+ const candidates = []
724
+ for (const entry of entries) {
725
+ if (!entry.isDirectory() || entry.name === 'node_modules') continue
726
+ const dir = join(profilesDir, entry.name)
727
+ let manifest
728
+ try {
729
+ manifest = JSON.parse(await readFile(join(dir, 'package.json'), 'utf8'))
730
+ } catch (_) {
731
+ continue
732
+ }
733
+ const spec = manifest?.dependencies?.[PLUGIN_PACKAGE]
734
+ if (typeof spec !== 'string') continue
735
+ candidates.push({ name: entry.name, dir, spec })
736
+ }
737
+ if (candidates.length === 0) throw new Error('no-profile-found')
738
+ let loadedReal = null
739
+ try { loadedReal = await realpath(loadedPluginDir) } catch (_) {}
740
+ const matches = []
741
+ if (loadedReal !== null) {
742
+ for (const candidate of candidates) {
743
+ let installedReal = null
744
+ try { installedReal = await realpath(join(candidate.dir, 'node_modules', PLUGIN_PACKAGE)) } catch (_) {}
745
+ if (installedReal === loadedReal) matches.push(candidate)
746
+ }
747
+ }
748
+ const selected = matches.length === 1 ? matches[0] : (candidates.length === 1 ? candidates[0] : null)
749
+ if (selected === null) throw new Error('ambiguous-profile')
750
+ let workspace = false
751
+ try { workspace = (await stat(join(selected.dir, 'pnpm-workspace.yaml'))).isFile() } catch (_) {}
752
+ return { name: selected.name, dir: selected.dir, spec: selected.spec, workspace }
753
+ }
754
+
755
+ async function readInstalledPluginVersion(profile) {
756
+ try {
757
+ const manifest = JSON.parse(await readFile(join(profile.dir, 'node_modules', PLUGIN_PACKAGE, 'package.json'), 'utf8'))
758
+ const version = typeof manifest?.version === 'string' ? manifest.version : ''
759
+ return version === '' ? null : version
760
+ } catch (_) {
761
+ return null
692
762
  }
693
763
  }
694
764
 
695
- async function upgradePlugin(ctx) {
696
- await runFixedCommand(ctx, ['npm', 'install', '-g', `${PLUGIN_PACKAGE}@latest`])
765
+ // pnpm 失败分类:dsh plugin 转发 pnpm 时只报「pnpm failed in profile directory」,不报原因;
766
+ // 必须按输出特征识别真实失败(踩坑见 KNOWLEDGE.md「pnpm 失败模式识别与自动恢复」)。
767
+ function classifyUpgradeFailure(output) {
768
+ const text = String(output || '')
769
+ if (text.includes('ERR_PNPM_PUBLIC_HOIST_PATTERN_DIFF')) {
770
+ return { code: 'hoist-pattern-diff', recoverable: true, message: 'hoist-pattern-diff' }
771
+ }
772
+ if (text.includes('ERR_PNPM_ADDING_TO_ROOT')) {
773
+ return { code: 'adding-to-root', recoverable: false, message: 'adding-to-root' }
774
+ }
775
+ if (/--workspace-root may only be used inside a workspace/i.test(text)) {
776
+ return { code: 'not-a-workspace', recoverable: false, message: 'not-a-workspace' }
777
+ }
778
+ if (text.includes('ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION') || text.includes('ERR_PNPM_NO_MATURE_MATCHING_VERSION')) {
779
+ return { code: 'release-age-violation', recoverable: true, message: 'release-age-violation' }
780
+ }
781
+ if (text.includes('ERR_PNPM_IGNORED_BUILDS')) {
782
+ return { code: 'ignored-builds', recoverable: false, message: 'ignored-builds' }
783
+ }
784
+ if (/ERR_PNPM_FETCH_5\d\d|ERR_PNPM_META_FETCH_FAIL|FetchError|ECONNRESET|ETIMEDOUT|EAI_AGAIN|ENETUNREACH|socket hang up|network timeout/i.test(text)) {
785
+ return { code: 'transient-network', recoverable: true, message: 'transient-network' }
786
+ }
787
+ if (/operation was aborted due to timeout|TimeoutError|error \(23\)/i.test(text)) {
788
+ return { code: 'fetch-timeout', recoverable: true, message: 'fetch-timeout' }
789
+ }
790
+ if (text.includes('pnpm not found on PATH')) {
791
+ return { code: 'pnpm-missing', recoverable: false, message: 'pnpm-missing' }
792
+ }
793
+ return null
794
+ }
795
+
796
+ // 一键升级:走「dsh plugin --profile <p> add <pkg>@<版本>」,落在 DSH 真正读取的 profile
797
+ // node_modules,替代旧的无升级语义的 npm install -g。命令、包名、版本全部宿主白名单常量。
798
+ // 安全教义守卫:活动工作拒用、link/file 拒绝、latest 不高于当前版本拒绝(防降级)一律先于网络。
799
+ async function upgradePlugin(ctx, dshHome) {
800
+ const activity = collectActiveWork(ctx)
801
+ if (activity.hasActive) throw new Error('active-work')
802
+
803
+ const profile = await resolveUpgradeProfile(dshHome)
804
+ if (profile.spec.startsWith('link:')) throw new Error('link-install')
805
+ if (profile.spec.startsWith('file:')) throw new Error('file-install')
806
+
807
+ const published = await fetchPublishedVersions(PLUGIN_PACKAGE)
808
+ const targetVersion = published.latest
809
+ if (parseSemver(targetVersion) !== null && parseSemver(pluginVersion) !== null && compareSemver(targetVersion, pluginVersion) <= 0) {
810
+ throw new Error('no-newer-version')
811
+ }
812
+ const target = `${PLUGIN_PACKAGE}@${targetVersion}`
813
+ if (!TARGET_RE.test(PLUGIN_PACKAGE) || !TARGET_RE.test(target) || !/^[A-Za-z0-9][A-Za-z0-9._+\-]*$/.test(targetVersion)) {
814
+ throw new Error('invalid-upgrade-target')
815
+ }
816
+
817
+ const addArgs = profile.workspace ? ['add', '-w'] : ['add']
818
+ const dshArgs = ['dsh', 'plugin', '--profile', profile.name]
819
+ const run = (extra) => runCommandResult(ctx, [...dshArgs, ...extra])
820
+ const ok = (result) => result.exitCode === 0 && result.signal === null
821
+
822
+ // pnpm 中断(signal 非 null)表示进程被终止,不做自动恢复。
823
+ let result = await run([...addArgs, target])
824
+ if (!ok(result) && result.signal === null) {
825
+ const failure = classifyUpgradeFailure(`${result.stderr}\n${result.stdout}`)
826
+ if (failure !== null) {
827
+ if (failure.code === 'hoist-pattern-diff') {
828
+ const rebuild = await run(['install', '--no-frozen-lockfile'])
829
+ if (ok(rebuild)) result = await run([...addArgs, target])
830
+ } else if (failure.code === 'release-age-violation' || failure.code === 'fetch-timeout') {
831
+ const override = failure.code === 'release-age-violation' ? RELEASE_AGE_OVERRIDE : FETCH_TIMEOUT_OVERRIDE
832
+ if (!addArgs.includes(override)) result = await run([addArgs[0], override, ...addArgs.slice(1), target])
833
+ } else if (failure.code === 'transient-network') {
834
+ result = await run([...addArgs, target])
835
+ } else {
836
+ throw new Error(failure.code)
837
+ }
838
+ }
839
+ }
840
+
841
+ if (!ok(result)) {
842
+ const failure = classifyUpgradeFailure(`${result.stderr}\n${result.stdout}`)
843
+ throw new Error(failure !== null ? failure.code : `dsh-failed: ${(result.stderr || '').trim().slice(-400) || result.signal || result.exitCode}`)
844
+ }
845
+
846
+ // pnpm 干净退出 ≠ 真升级:minimumReleaseAge 会静默保住旧版。重读磁盘安装版本确认变化,只在其进了才重启。
847
+ const installed = await readInstalledPluginVersion(profile)
848
+ if (installed === null) throw new Error('installed-version-unreadable')
849
+ const advanced = parseSemver(installed) !== null && parseSemver(pluginVersion) !== null
850
+ ? compareSemver(installed, pluginVersion) > 0
851
+ : installed !== pluginVersion
852
+ if (!advanced) throw new Error('upgrade-stale')
853
+
697
854
  scheduleRestart(ctx)
698
- return { ok: true }
855
+ return { result: 'upgraded', profile: profile.name, previous: pluginVersion, installed }
699
856
  }
700
857
 
701
858
  async function permissionSnapshot(ctx, dshHome, plans) {
@@ -1088,7 +1245,7 @@ function apply(ctx) {
1088
1245
 
1089
1246
  if (endpoint === 'upgrade') {
1090
1247
  try {
1091
- return { ok: true, value: await upgradePlugin(ctx) }
1248
+ return { ok: true, value: await upgradePlugin(ctx, dshHome) }
1092
1249
  } catch (error) {
1093
1250
  return { ok: false, error: error?.message || String(error) }
1094
1251
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gehennawu/dsh-service",
3
- "version": "0.13.1",
3
+ "version": "0.14.0",
4
4
  "description": "DSH Web 自托管运维面板:安全重启、健康监控、备份和 Linux 权限维护。",
5
5
  "type": "module",
6
6
  "scripts": {