@gehennawu/dsh-service 0.12.1 → 0.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.en.md CHANGED
@@ -43,10 +43,10 @@ A service-control and operations plugin for self-hosted DSH Web. Provides safe r
43
43
 
44
44
  ### Task notifications
45
45
 
46
- - Browser notification when an Agent completes a task
47
- - Bell icon toggle in the conversation input bar
48
- - Configurable polling interval (5–300 seconds)
49
- - Toggle and interval persist across page reloads
46
+ - Browser notification when a session finishes its turn, or when your approval, plan review, or answer is needed
47
+ - Three toggle switches: master switch, task completion, and approvals & questions, each controlled independently
48
+ - Bell icon in the conversation input bar toggles the master switch quickly
49
+ - All toggles persist across page reloads
50
50
 
51
51
  ### External liveness probe
52
52
 
package/README.md CHANGED
@@ -43,10 +43,10 @@
43
43
 
44
44
  ### 任务通知
45
45
 
46
- - Agent 完成任务时发送浏览器通知
47
- - 对话栏内铃铛图标切换开关
48
- - 可配置轮询间隔(5-300 秒)
49
- - 开关和间隔在页面刷新后保持
46
+ - 会话完成一轮任务、或需要你授权/审阅计划/选择答案时发送浏览器通知
47
+ - 三档开关条:总开关、任务结束通知、授权与提问通知,各自独立控制
48
+ - 对话栏内铃铛图标快速切换总开关
49
+ - 各开关在页面刷新后保持
50
50
 
51
51
  ### 外部探活
52
52
 
package/client.js CHANGED
@@ -118,6 +118,7 @@ window.__ModuleLoader__.load({
118
118
  'update.upgrade': '升级插件',
119
119
  'update.upgrading': '升级中…',
120
120
  'update.upgradeError': '插件升级失败',
121
+ 'update.upgradeErrorDetail': '插件升级失败({detail})',
121
122
  'update.upgradeSuccess': '升级成功,服务重启中…',
122
123
  'restart.title': '服务重启',
123
124
  'restart.description': '重启 dsh web 进程。运行中的工作会中断,持久化会话可恢复。也可在对话中输入 /restart。',
@@ -171,18 +172,27 @@ window.__ModuleLoader__.load({
171
172
  'usage.toolErrors.toggle': '工具报错({count} 类)',
172
173
  'usage.toolErrors.empty': '最近 24 小时没有记录到工具报错。',
173
174
  'usage.errors.count': '{count} 次',
174
- 'notification.title': '任务通知',
175
- 'notification.description': '当运行中的 Agent 完成一轮任务时,发送浏览器通知提醒。需要授权浏览器通知权限;开关和轮询间隔在页面刷新后保持。',
175
+ 'notification.title': '通知',
176
+ 'notification.description': '任务结束或需要你授权、选择答案时发送浏览器通知。需要授权浏览器通知权限;各开关在页面刷新后保持。',
176
177
  'notification.enable': '开启通知',
177
178
  'notification.enabled': '通知已开启',
178
179
  'notification.disable': '关闭通知',
179
180
  'notification.denied': '通知权限被拒绝',
180
- 'notification.agentDone': '任务完成',
181
- 'notification.agentDoneBody': 'Agent {id} 已完成本轮任务',
181
+ 'notification.master': '通知总开关',
182
+ 'notification.masterHint': '关闭时下面两个开关暂停生效',
183
+ 'notification.done': '任务结束通知',
184
+ 'notification.doneHint': '会话完成一轮任务时提醒',
185
+ 'notification.input': '授权与提问通知',
186
+ 'notification.inputHint': '需要授权、审阅计划或选择答案时提醒',
187
+ 'notification.doneTitle': '任务完成',
188
+ 'notification.doneBody': '{title} 已完成本轮任务',
189
+ 'notification.inputTitle': '需要你的确认',
190
+ 'notification.inputBody': '{title}({kind})',
191
+ 'notification.kind.approval': '等待授权',
192
+ 'notification.kind.plan-review': '等待审阅计划',
193
+ 'notification.kind.question': '等待选择答案',
182
194
  'notification.bellOn': '通知开启',
183
195
  'notification.bellOff': '通知关闭',
184
- 'notification.interval': '轮询间隔',
185
- 'notification.intervalUnit': '秒(5-300)',
186
196
  }
187
197
  const en = {
188
198
  'nav.label': 'Service Control',
@@ -294,6 +304,7 @@ window.__ModuleLoader__.load({
294
304
  'update.upgrade': 'Upgrade plugin',
295
305
  'update.upgrading': 'Upgrading…',
296
306
  'update.upgradeError': 'Plugin upgrade failed',
307
+ 'update.upgradeErrorDetail': 'Plugin upgrade failed ({detail})',
297
308
  'update.upgradeSuccess': 'Upgrade successful, restarting…',
298
309
  'restart.title': 'Service restart',
299
310
  '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.',
@@ -347,21 +358,30 @@ window.__ModuleLoader__.load({
347
358
  'usage.toolErrors.toggle': 'Tool errors ({count} types)',
348
359
  'usage.toolErrors.empty': 'No tool errors were recorded in the last 24 hours.',
349
360
  'usage.errors.count': '{count} occurrence(s)',
350
- 'notification.title': 'Task notifications',
351
- 'notification.description': 'Receive a browser notification when a running Agent finishes its turn. Requires browser notification permission; the toggle and polling interval persist across page reloads.',
361
+ 'notification.title': 'Notifications',
362
+ '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.',
352
363
  'notification.enable': 'Enable notifications',
353
364
  'notification.enabled': 'Notifications enabled',
354
365
  'notification.disable': 'Disable notifications',
355
366
  'notification.denied': 'Notification permission denied',
356
- 'notification.agentDone': 'Task complete',
357
- 'notification.agentDoneBody': 'Agent {id} has finished its turn',
367
+ 'notification.master': 'Master switch',
368
+ 'notification.masterHint': 'Both kinds below stay paused while this is off',
369
+ 'notification.done': 'Task completion',
370
+ 'notification.doneHint': 'Notify when a session finishes its turn',
371
+ 'notification.input': 'Approvals & questions',
372
+ 'notification.inputHint': 'Notify when approval, plan review, or an answer is requested',
373
+ 'notification.doneTitle': 'Task complete',
374
+ 'notification.doneBody': '{title} has finished its turn',
375
+ 'notification.inputTitle': 'Your attention needed',
376
+ 'notification.inputBody': '{title} — {kind}',
377
+ 'notification.kind.approval': 'approval requested',
378
+ 'notification.kind.plan-review': 'plan review requested',
379
+ 'notification.kind.question': 'answer requested',
358
380
  'notification.bellOn': 'Notifications on',
359
381
  'notification.bellOff': 'Notifications off',
360
- 'notification.interval': 'Polling interval',
361
- 'notification.intervalUnit': 'seconds (5–300)',
362
382
  }
363
383
 
364
- const inject = ['slots', 'connection', 'timer', 'locale']
384
+ const inject = ['slots', 'connection', 'timer', 'locale', 'sessions']
365
385
 
366
386
  function apply(ctx) {
367
387
  const { useState, useEffect } = React
@@ -379,58 +399,78 @@ window.__ModuleLoader__.load({
379
399
  useEffect(() => ctx.locale.subscribe(() => setSnapshot(ctx.locale.getSnapshot())), [])
380
400
  return t
381
401
  }
382
- // 全局 agent 完成通知轮询
402
+ // 全局通知:任务结束 + 需要授权/选择答案,两个独立子开关受总开关管辖
383
403
  let notifyEnabled = false
384
- let notifyInterval = 30
385
- try { notifyEnabled = localStorage.getItem('dsh-service-notify') !== 'false' } catch (_) {}
386
- try { const v = parseInt(localStorage.getItem('dsh-service-notify-interval'), 10); if (v >= 5 && v <= 300) notifyInterval = v } catch (_) {}
404
+ let notifyDone = true
405
+ let notifyInput = true
406
+ try { notifyEnabled = localStorage.getItem('dsh-service-notify') === 'true' } catch (_) {}
407
+ try { notifyDone = localStorage.getItem('dsh-service-notify-done') !== 'false' } catch (_) {}
408
+ try { notifyInput = localStorage.getItem('dsh-service-notify-input') !== 'false' } catch (_) {}
387
409
  const notifyListeners = new Set()
388
- const setNotifyEnabled = (value) => {
389
- notifyEnabled = value
390
- try { localStorage.setItem('dsh-service-notify', value ? 'true' : 'false') } catch (_) {}
391
- for (const listener of notifyListeners) listener()
392
- }
393
- const setNotifyInterval = (value) => {
394
- const v = Math.max(5, Math.min(300, Math.round(Number(value) || 30)))
395
- notifyInterval = v
396
- try { localStorage.setItem('dsh-service-notify-interval', String(v)) } catch (_) {}
397
- for (const listener of notifyListeners) listener()
398
- }
410
+ const persistNotify = (key, value) => { try { localStorage.setItem(key, value ? 'true' : 'false') } catch (_) {} }
411
+ const publishNotify = () => { for (const listener of notifyListeners) listener() }
412
+ const setNotifyEnabled = (value) => { notifyEnabled = value; persistNotify('dsh-service-notify', value); publishNotify() }
413
+ const setNotifyDone = (value) => { notifyDone = value; persistNotify('dsh-service-notify-done', value); publishNotify() }
414
+ const setNotifyInput = (value) => { notifyInput = value; persistNotify('dsh-service-notify-input', value); publishNotify() }
399
415
  const useNotifyState = () => {
400
416
  const [, setTick] = useState(0)
401
417
  const [enabled, setEnabled] = useState(notifyEnabled)
402
- const [interval, setInterval_] = useState(notifyInterval)
418
+ const [done, setDone] = useState(notifyDone)
419
+ const [input, setInput] = useState(notifyInput)
403
420
  React.useEffect(() => {
404
- const update = () => { setEnabled(notifyEnabled); setInterval_(notifyInterval); setTick((t) => t + 1) }
421
+ const update = () => { setEnabled(notifyEnabled); setDone(notifyDone); setInput(notifyInput); setTick((t) => t + 1) }
405
422
  notifyListeners.add(update)
406
423
  return () => notifyListeners.delete(update)
407
424
  }, [])
408
- return { enabled, interval, setEnabled: (v) => setNotifyEnabled(v), setInterval: (v) => setNotifyInterval(v) }
425
+ return { enabled, done, input, setEnabled: (v) => setNotifyEnabled(v), setDone: (v) => setNotifyDone(v), setInput: (v) => setNotifyInput(v) }
409
426
  }
410
- if (typeof Notification !== 'undefined' && Notification.permission === 'granted') {
411
- const previousAgentIds = new Set()
412
- let initialized = false
413
- const pollActivity = async () => {
414
- if (!notifyEnabled) { ctx.timer.timeout(pollActivity, notifyInterval * 1000); return }
415
- try {
416
- const res = await ctx.connection.rpc.call('/dsh-service', 'activity', {})
417
- if (res && res.ok) {
418
- const currentIds = new Set(res.value.items.filter((item) => item.type === 'agent').map((item) => item.id))
419
- if (initialized) {
420
- for (const id of previousAgentIds) {
421
- if (!currentIds.has(id)) {
422
- try { new Notification(t('notification.agentDone'), { body: t('notification.agentDoneBody', { id }) }) } catch (_) {}
423
- }
424
- }
427
+ // 会话边沿通知:running→idle 记一次任务结束;pendingInteraction 出现记一次需要确认。
428
+ // 数据源是客户端运行时的会话列表快照(订阅推送);首个快照只建立基线,重连后重建基线,二者都不响铃。
429
+ const NOTIFY_KIND_KEYS = {
430
+ approval: 'notification.kind.approval',
431
+ 'plan-review': 'notification.kind.plan-review',
432
+ question: 'notification.kind.question',
433
+ }
434
+ const notifyPermissionGranted = () => typeof Notification !== 'undefined' && Notification.permission === 'granted'
435
+ const fireNotification = (title, body) => {
436
+ if (!notifyPermissionGranted()) return
437
+ try { new Notification(title, { body }) } catch (_) {}
438
+ }
439
+ if (ctx.sessions && typeof ctx.sessions.list?.subscribe === 'function') {
440
+ const observed = new Map()
441
+ let baselined = false
442
+ const observeSessions = () => {
443
+ const snapshot = ctx.sessions.list.getSnapshot()
444
+ if (!snapshot || !snapshot.byId) return
445
+ if (!baselined) {
446
+ baselined = true
447
+ for (const [id, summary] of Object.entries(snapshot.byId)) {
448
+ observed.set(id, { running: summary.running === true, pending: summary.pendingInteraction !== undefined })
449
+ }
450
+ return
451
+ }
452
+ for (const [id, summary] of Object.entries(snapshot.byId)) {
453
+ const next = { running: summary.running === true, pending: summary.pendingInteraction !== undefined }
454
+ const prev = observed.get(id)
455
+ if (prev !== undefined) {
456
+ if (prev.running && !next.running && notifyEnabled && notifyDone) {
457
+ fireNotification(t('notification.doneTitle'), t('notification.doneBody', { title: summary.displayTitle || id }))
458
+ }
459
+ if (!prev.pending && next.pending && notifyEnabled && notifyInput) {
460
+ const kindKey = NOTIFY_KIND_KEYS[summary.pendingInteraction]
461
+ const kind = kindKey ? t(kindKey) : String(summary.pendingInteraction)
462
+ fireNotification(t('notification.inputTitle'), t('notification.inputBody', { title: summary.displayTitle || id, kind }))
425
463
  }
426
- previousAgentIds.clear()
427
- for (const id of currentIds) previousAgentIds.add(id)
428
- initialized = true
429
464
  }
430
- } catch (_) {}
431
- ctx.timer.timeout(pollActivity, notifyInterval * 1000)
465
+ observed.set(id, next)
466
+ }
467
+ for (const id of [...observed.keys()]) {
468
+ if (!(id in snapshot.byId)) observed.delete(id)
469
+ }
432
470
  }
433
- ctx.timer.timeout(pollActivity, notifyInterval * 1000)
471
+ ctx.effect(() => ctx.sessions.list.subscribe(() => observeSessions()), 'dsh-service: session notification observation')
472
+ ctx.effect(() => ctx.on('connection/reset', () => { observed.clear(); baselined = false }), 'dsh-service: notification rebaseline on reconnect')
473
+ observeSessions()
434
474
  }
435
475
 
436
476
  const recoveryListeners = new Set()
@@ -880,12 +920,18 @@ window.__ModuleLoader__.load({
880
920
  const versionRes = await ctx.connection.rpc.call('/dsh-service', 'version', {})
881
921
  const previousInstanceId = versionRes && versionRes.ok ? versionRes.value.instanceId : undefined
882
922
  const res = await ctx.connection.rpc.call('/dsh-service', 'upgrade', {})
883
- if (!res || res.ok === false) throw new Error('upgrade failed')
923
+ // 宿主错误详情(如 npm-failed: …、npm was not found on PATH)随通用文案透出,便于排查。
924
+ if (!res || res.ok === false) {
925
+ const detail = res && typeof res.error === 'string' ? res.error.trim() : ''
926
+ throw new Error(detail || 'upgrade failed')
927
+ }
884
928
  if (typeof previousInstanceId === 'string' && previousInstanceId.length > 0) {
885
929
  startRecovery(previousInstanceId).catch(() => {})
886
930
  }
887
- } catch (_) {
888
- setUpgradeError(translate('update.upgradeError'))
931
+ } catch (err) {
932
+ const detail = err instanceof Error && typeof err.message === 'string' && err.message !== 'upgrade failed' ? err.message.trim() : ''
933
+ console.error('dsh-service: upgrade failed', detail || err)
934
+ setUpgradeError(detail ? translate('update.upgradeErrorDetail', { detail }) : translate('update.upgradeError'))
889
935
  } finally {
890
936
  setUpgradeBusy(false)
891
937
  }
@@ -1113,14 +1159,14 @@ window.__ModuleLoader__.load({
1113
1159
  : null,
1114
1160
  usage && usage.indexedSessions > 0
1115
1161
  ? React.createElement('div', { 'data-testid': 'usage-statistics-region', style: Object.assign({}, displaySurface, { padding: '12px', borderRadius: '9px' }) },
1116
- React.createElement('div', { 'data-testid': 'usage-chart', style: { position: 'relative', display: 'grid', gridTemplateColumns: '42px minmax(0, 1fr)', height: '180px', padding: '12px 10px 8px', borderRadius: '8px', background: 'var(--dsw-alias-bg-layer-2)', color: 'var(--dsw-alias-label-primary)', border: '1px solid var(--dsw-alias-border-l1)', borderBottom: '1px solid var(--dsw-alias-border-l2)' } },
1117
- React.createElement('div', { 'data-testid': 'usage-y-axis', 'aria-label': translate('usage.axis'), style: { position: 'relative', height: '136px', fontSize: '10px', color: 'var(--dsw-alias-label-secondary)' } },
1162
+ React.createElement('div', { 'data-testid': 'usage-chart', style: { position: 'relative', display: 'grid', gridTemplateColumns: '42px minmax(0, 1fr)', height: '180px', padding: '12px 10px 4px', borderRadius: '8px', background: 'var(--dsw-alias-bg-layer-2)', color: 'var(--dsw-alias-label-primary)', border: '1px solid var(--dsw-alias-border-l1)', borderBottom: '1px solid var(--dsw-alias-border-l2)' } },
1163
+ React.createElement('div', { 'data-testid': 'usage-y-axis', 'aria-label': translate('usage.axis'), style: { position: 'relative', height: '144px', fontSize: '10px', color: 'var(--dsw-alias-label-secondary)' } },
1118
1164
  chartTicks.map((tick, index) => React.createElement('span', { key: index, style: { position: 'absolute', right: '7px', top: `${index * 25}%`, transform: index === 4 ? 'translateY(-100%)' : 'translateY(-50%)' } }, formatTokenValue(tick)))),
1119
- React.createElement('div', { 'data-testid': 'usage-plot', style: { position: 'relative', height: '156px' } },
1165
+ React.createElement('div', { 'data-testid': 'usage-plot', style: { position: 'relative', height: '164px' } },
1120
1166
  React.createElement('div', { style: { position: 'absolute', inset: '0 0 20px', display: 'flex', alignItems: 'end', gap: '8px', borderBottom: '1px solid var(--dsw-alias-border-l2)' } },
1121
1167
  chartTicks.map((_, index) => React.createElement('div', { key: index, 'data-testid': `usage-grid-${index}`, style: { position: 'absolute', left: 0, right: 0, top: `${index * 25}%`, borderTop: '1px solid var(--dsw-alias-border-l1)', pointerEvents: 'none' } })),
1122
1168
  usageDays.map((day, index) => React.createElement('div', { key: day.key, style: { position: 'relative', zIndex: 1, flex: 1, minWidth: 0, alignSelf: 'end' } },
1123
- React.createElement('div', { 'data-testid': `usage-bar-${day.key}`, style: { height: `${Math.max(2, chartValues[index] / chartMax * 136)}px`, display: 'flex', flexDirection: 'column-reverse', justifyContent: 'flex-start', borderRadius: '4px 4px 0 0', overflow: 'hidden' } },
1169
+ React.createElement('div', { 'data-testid': `usage-bar-${day.key}`, style: { height: `${Math.max(2, chartValues[index] / chartMax * 144)}px`, display: 'flex', flexDirection: 'column-reverse', justifyContent: 'flex-start', borderRadius: '4px 4px 0 0', overflow: 'hidden' } },
1124
1170
  usageSegments.map(([metricName, label, color]) => {
1125
1171
  const value = usageValue(chartTotals[index], metricName)
1126
1172
  const segmentHeight = chartValues[index] === 0 ? 0 : value / chartValues[index] * 100
@@ -1375,9 +1421,27 @@ window.__ModuleLoader__.load({
1375
1421
  error ? React.createElement('p', { style: Object.assign({}, hint, { color: 'var(--dsw-alias-state-error-primary)' }) }, String(error)) : null)
1376
1422
  )
1377
1423
 
1378
- const { enabled: notifyOn, interval: notifyIntv, setEnabled: setNotifyOn, setInterval: setNotifyIntv } = useNotifyState()
1424
+ const { enabled: notifyOn, done: notifyDoneOn, input: notifyInputOn, setEnabled: setNotifyOn, setDone: setNotifyDoneOn, setInput: setNotifyInputOn } = useNotifyState()
1379
1425
  const notifSupported = typeof Notification !== 'undefined'
1380
1426
  const notifPermission = notifSupported ? Notification.permission : 'denied'
1427
+ const notifySwitch = (on, onChange, disabled) => React.createElement('button', {
1428
+ type: 'button',
1429
+ role: 'switch',
1430
+ 'aria-checked': String(on === true),
1431
+ 'aria-disabled': disabled ? 'true' : undefined,
1432
+ onClick: disabled ? undefined : () => onChange(!on),
1433
+ style: {
1434
+ width: '34px', height: '20px', borderRadius: '10px', padding: 0, flexShrink: 0, position: 'relative',
1435
+ border: `1px solid ${on ? 'var(--dsw-alias-state-success-primary)' : 'var(--dsw-alias-border-l2)'}`,
1436
+ background: on ? 'var(--dsw-alias-state-success-primary)' : 'var(--dsw-alias-bg-layer-2)',
1437
+ cursor: disabled ? 'default' : 'pointer', opacity: disabled ? 0.5 : 1, lineHeight: 0,
1438
+ },
1439
+ }, 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)' } }))
1440
+ const notifyRow = (label, labelHint, on, onChange, disabled) => React.createElement('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '12px', padding: '5px 0' } },
1441
+ React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: '2px' } },
1442
+ React.createElement('span', { style: { fontSize: '13px', color: 'var(--dsw-alias-label-primary)' } }, label),
1443
+ React.createElement('span', { style: hint }, labelHint)),
1444
+ notifySwitch(on, onChange, disabled))
1381
1445
  const notificationBlock = !notifSupported ? null
1382
1446
  : React.createElement('div', { style: { marginTop: '18px' } },
1383
1447
  React.createElement('div', { style: sectionTitle }, translate('notification.title')),
@@ -1387,12 +1451,10 @@ window.__ModuleLoader__.load({
1387
1451
  ? React.createElement('div', { style: { marginTop: '8px', display: 'flex', alignItems: 'center', gap: '8px' } },
1388
1452
  React.createElement('button', { style: neutral, onClick: () => { Notification.requestPermission().then((p) => { if (p === 'granted') setNotifyOn(true) }) } }, translate('notification.enable')),
1389
1453
  React.createElement('span', { style: hint }, notifPermission === 'denied' ? translate('notification.denied') : ''))
1390
- : React.createElement('div', { style: { marginTop: '8px', display: 'flex', alignItems: 'center', gap: '12px', flexWrap: 'wrap' } },
1391
- React.createElement('span', { style: { fontSize: '12px', color: notifyOn ? 'var(--dsw-alias-state-success-primary)' : 'var(--dsw-alias-label-secondary)' } }, notifyOn ? `✓ ${translate('notification.enabled')}` : translate('notification.disable')),
1392
- React.createElement('button', { style: ghost, onClick: () => setNotifyOn(!notifyOn) }, translate(notifyOn ? 'notification.disable' : 'notification.enable')),
1393
- React.createElement('span', { style: { fontSize: '12px', color: 'var(--dsw-alias-label-secondary)', marginLeft: '4px' } }, translate('notification.interval')),
1394
- React.createElement('input', { type: 'number', min: 5, max: 300, step: 5, value: notifyIntv, onChange: (e) => setNotifyIntv(e.target.value), style: { width: '60px', padding: '4px 6px', borderRadius: '4px', border: '1px solid var(--dsw-alias-border-l2)', background: 'var(--dsw-alias-bg-layer-2)', color: 'var(--dsw-alias-label-primary)', fontSize: '12px', textAlign: 'center' } }),
1395
- React.createElement('span', { style: { fontSize: '12px', color: 'var(--dsw-alias-label-tertiary)' } }, translate('notification.intervalUnit')))))
1454
+ : React.createElement('div', { style: { marginTop: '8px', display: 'flex', flexDirection: 'column' } },
1455
+ notifyRow(translate('notification.master'), translate('notification.masterHint'), notifyOn, setNotifyOn, false),
1456
+ notifyRow(translate('notification.done'), translate('notification.doneHint'), notifyDoneOn, setNotifyDoneOn, !notifyOn),
1457
+ notifyRow(translate('notification.input'), translate('notification.inputHint'), notifyInputOn, setNotifyInputOn, !notifyOn))))
1396
1458
  const overviewBlock = React.createElement('div', null, versionBlock, notificationBlock, containerInfoBlock, overviewErrorsBlock)
1397
1459
  const maintenanceBlock = React.createElement('div', { key: 'maintenance-card', 'data-testid': 'maintenance-card', style: card }, backupBlock)
1398
1460
  const diagnosticFailure = diagnostics?.checks?.some((check) => check.status === 'error' || (check.status === 'warning' && !(check.id === 'backup-storage' && String(check.detail || '').startsWith('0:')))) === true
@@ -1429,13 +1491,21 @@ window.__ModuleLoader__.load({
1429
1491
  React.createElement('div', { 'data-testid': 'tab-panel', style: tabPanel }, tabContent))
1430
1492
  }
1431
1493
 
1494
+ const bellPaths = {
1495
+ on: ['M10.268 21a2 2 0 0 0 3.464 0', 'm15 8 2 2 4-4', 'M16.8607 4.4824A6 6 0 0 0 6 8C6 12.499 4.589 13.956 3.262 15.326', 'M3.262 15.326A1 1 0 0 0 4 17H20A1 1 0 0 0 20.74 15.327C20.209 14.779 19.665 14.218 19.203 13.454'],
1496
+ off: ['M10.268 21a2 2 0 0 0 3.464 0', 'M17 17H4a1 1 0 0 1-.74-1.673C4.59 13.956 6 12.499 6 8a6 6 0 0 1 .258-1.742', 'm2 2 20 20', 'M8.668 3.01A6 6 0 0 1 18 8c0 2.687.77 4.653 1.707 6.05'],
1497
+ }
1498
+ function BellIcon(on) {
1499
+ return React.createElement('svg', { xmlns: 'http://www.w3.org/2000/svg', width: 16, height: 16, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round', style: { display: 'block' } },
1500
+ (on ? bellPaths.on : bellPaths.off).map(function (d, i) { return React.createElement('path', { key: i, d: d }) }))
1501
+ }
1432
1502
  function InlineNotifyBell() {
1433
1503
  const { enabled, setEnabled } = useNotifyState()
1434
1504
  const translate = useTranslation()
1435
1505
  return React.createElement('button', {
1436
1506
  type: 'button',
1437
1507
  title: translate(enabled ? 'notification.bellOn' : 'notification.bellOff'),
1438
- style: { background: 'transparent', border: 0, cursor: 'pointer', fontSize: '15px', padding: '2px 4px', color: 'inherit', opacity: enabled ? 1 : 0.45 },
1508
+ style: { background: 'transparent', border: 0, cursor: 'pointer', padding: '2px 4px', color: 'inherit', opacity: enabled ? 1 : 0.45, lineHeight: 0 },
1439
1509
  onClick: () => {
1440
1510
  if (typeof Notification === 'undefined') return
1441
1511
  if (Notification.permission !== 'granted') {
@@ -1444,7 +1514,7 @@ window.__ModuleLoader__.load({
1444
1514
  }
1445
1515
  setEnabled(!enabled)
1446
1516
  },
1447
- }, enabled ? '🔔' : '🔕')
1517
+ }, BellIcon(enabled))
1448
1518
  }
1449
1519
  ctx.slots.inject('conversation.input.left', () => ctx.slots.register(
1450
1520
  { name: 'conversation.input.left', id: 'dsh-service-notify', order: 90, label: () => t('notification.bellOn') },
package/index.js CHANGED
@@ -666,9 +666,18 @@ async function runFixedCommand(ctx, argv) {
666
666
  const subprocess = ctx.get('subprocess')
667
667
  if (subprocess === undefined) throw new Error('subprocess-unavailable')
668
668
  const executable = await subprocess.resolveExecutable(argv[0])
669
+ let spawnArgv = [executable, ...argv.slice(1)]
670
+ // Windows 上白名单命令(如 npm)经 PATHEXT 解析为 .cmd/.bat 脚本;subprocess 服务的
671
+ // spawn 不带 shell,Node 对 .cmd/.bat 一律抛 EINVAL,无法直接执行。固定包一层
672
+ // cmd.exe /d /s /c + 已解析的绝对路径,全部参数仍是宿主白名单常量,无输入拼接。
673
+ if (process.platform === 'win32' && /\.(cmd|bat)$/i.test(executable)) {
674
+ const shell = await subprocess.resolveExecutable('cmd.exe')
675
+ spawnArgv = [shell, '/d', '/s', '/c', executable, ...argv.slice(1)]
676
+ }
669
677
  const handle = subprocess.spawn({
670
- argv: [executable, ...argv.slice(1)],
671
- cwd: '/',
678
+ argv: spawnArgv,
679
+ // '/' 不是合法的 Windows 目录路径;固定改用系统目录,POSIX 保持根目录。
680
+ cwd: process.platform === 'win32' ? (process.env.SystemRoot || 'C:\\Windows') : '/',
672
681
  stdio: {
673
682
  stdin: 'ignore',
674
683
  stdout: { maxBytes: 16 * 1024 },
@@ -872,14 +881,13 @@ async function collectDiagnostics(ctx, dshHome) {
872
881
  try { add('tar', 'ok', await subprocess.resolveExecutable('tar')) } catch (error) { add('tar', 'error', error?.message || error) }
873
882
  }
874
883
 
875
- try {
876
- const snapshot = await permissionSnapshot(ctx, dshHome, new Map())
877
- if (snapshot.supported !== true) add('permissions', 'info', 'unsupported')
878
- else {
884
+ if (process.platform === 'linux' && typeof process.getuid === 'function' && typeof process.getgid === 'function') {
885
+ try {
886
+ const snapshot = await permissionSnapshot(ctx, dshHome, new Map())
879
887
  const abnormal = snapshot.items.filter((item) => item.writable === false).length
880
888
  add('permissions', abnormal === 0 ? 'ok' : 'warning', abnormal)
881
- }
882
- } catch (error) { add('permissions', 'warning', error?.message || error) }
889
+ } catch (error) { add('permissions', 'warning', error?.message || error) }
890
+ }
883
891
 
884
892
  let status = 'ok'
885
893
  if (checks.some((check) => check.status === 'error')) status = 'error'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gehennawu/dsh-service",
3
- "version": "0.12.1",
3
+ "version": "0.13.1",
4
4
  "description": "DSH Web 自托管运维面板:安全重启、健康监控、备份和 Linux 权限维护。",
5
5
  "type": "module",
6
6
  "scripts": {