@gehennawu/dsh-service 0.12.0 → 0.13.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
@@ -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
@@ -171,18 +171,27 @@ window.__ModuleLoader__.load({
171
171
  'usage.toolErrors.toggle': '工具报错({count} 类)',
172
172
  'usage.toolErrors.empty': '最近 24 小时没有记录到工具报错。',
173
173
  'usage.errors.count': '{count} 次',
174
- 'notification.title': '任务通知',
175
- 'notification.description': '当运行中的 Agent 完成一轮任务时,发送浏览器通知提醒。需要授权浏览器通知权限;开关和轮询间隔在页面刷新后保持。',
174
+ 'notification.title': '通知',
175
+ 'notification.description': '任务结束或需要你授权、选择答案时发送浏览器通知。需要授权浏览器通知权限;各开关在页面刷新后保持。',
176
176
  'notification.enable': '开启通知',
177
177
  'notification.enabled': '通知已开启',
178
178
  'notification.disable': '关闭通知',
179
179
  'notification.denied': '通知权限被拒绝',
180
- 'notification.agentDone': '任务完成',
181
- 'notification.agentDoneBody': 'Agent {id} 已完成本轮任务',
180
+ 'notification.master': '通知总开关',
181
+ 'notification.masterHint': '关闭时下面两个开关暂停生效',
182
+ 'notification.done': '任务结束通知',
183
+ 'notification.doneHint': '会话完成一轮任务时提醒',
184
+ 'notification.input': '授权与提问通知',
185
+ 'notification.inputHint': '需要授权、审阅计划或选择答案时提醒',
186
+ 'notification.doneTitle': '任务完成',
187
+ 'notification.doneBody': '{title} 已完成本轮任务',
188
+ 'notification.inputTitle': '需要你的确认',
189
+ 'notification.inputBody': '{title}({kind})',
190
+ 'notification.kind.approval': '等待授权',
191
+ 'notification.kind.plan-review': '等待审阅计划',
192
+ 'notification.kind.question': '等待选择答案',
182
193
  'notification.bellOn': '通知开启',
183
194
  'notification.bellOff': '通知关闭',
184
- 'notification.interval': '轮询间隔',
185
- 'notification.intervalUnit': '秒(5-300)',
186
195
  }
187
196
  const en = {
188
197
  'nav.label': 'Service Control',
@@ -347,21 +356,30 @@ window.__ModuleLoader__.load({
347
356
  'usage.toolErrors.toggle': 'Tool errors ({count} types)',
348
357
  'usage.toolErrors.empty': 'No tool errors were recorded in the last 24 hours.',
349
358
  '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.',
359
+ 'notification.title': 'Notifications',
360
+ '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
361
  'notification.enable': 'Enable notifications',
353
362
  'notification.enabled': 'Notifications enabled',
354
363
  'notification.disable': 'Disable notifications',
355
364
  'notification.denied': 'Notification permission denied',
356
- 'notification.agentDone': 'Task complete',
357
- 'notification.agentDoneBody': 'Agent {id} has finished its turn',
365
+ 'notification.master': 'Master switch',
366
+ 'notification.masterHint': 'Both kinds below stay paused while this is off',
367
+ 'notification.done': 'Task completion',
368
+ 'notification.doneHint': 'Notify when a session finishes its turn',
369
+ 'notification.input': 'Approvals & questions',
370
+ 'notification.inputHint': 'Notify when approval, plan review, or an answer is requested',
371
+ 'notification.doneTitle': 'Task complete',
372
+ 'notification.doneBody': '{title} has finished its turn',
373
+ 'notification.inputTitle': 'Your attention needed',
374
+ 'notification.inputBody': '{title} — {kind}',
375
+ 'notification.kind.approval': 'approval requested',
376
+ 'notification.kind.plan-review': 'plan review requested',
377
+ 'notification.kind.question': 'answer requested',
358
378
  'notification.bellOn': 'Notifications on',
359
379
  'notification.bellOff': 'Notifications off',
360
- 'notification.interval': 'Polling interval',
361
- 'notification.intervalUnit': 'seconds (5–300)',
362
380
  }
363
381
 
364
- const inject = ['slots', 'connection', 'timer', 'locale']
382
+ const inject = ['slots', 'connection', 'timer', 'locale', 'sessions']
365
383
 
366
384
  function apply(ctx) {
367
385
  const { useState, useEffect } = React
@@ -379,58 +397,78 @@ window.__ModuleLoader__.load({
379
397
  useEffect(() => ctx.locale.subscribe(() => setSnapshot(ctx.locale.getSnapshot())), [])
380
398
  return t
381
399
  }
382
- // 全局 agent 完成通知轮询
400
+ // 全局通知:任务结束 + 需要授权/选择答案,两个独立子开关受总开关管辖
383
401
  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 (_) {}
402
+ let notifyDone = true
403
+ let notifyInput = true
404
+ try { notifyEnabled = localStorage.getItem('dsh-service-notify') === 'true' } catch (_) {}
405
+ try { notifyDone = localStorage.getItem('dsh-service-notify-done') !== 'false' } catch (_) {}
406
+ try { notifyInput = localStorage.getItem('dsh-service-notify-input') !== 'false' } catch (_) {}
387
407
  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
- }
408
+ const persistNotify = (key, value) => { try { localStorage.setItem(key, value ? 'true' : 'false') } catch (_) {} }
409
+ const publishNotify = () => { for (const listener of notifyListeners) listener() }
410
+ const setNotifyEnabled = (value) => { notifyEnabled = value; persistNotify('dsh-service-notify', value); publishNotify() }
411
+ const setNotifyDone = (value) => { notifyDone = value; persistNotify('dsh-service-notify-done', value); publishNotify() }
412
+ const setNotifyInput = (value) => { notifyInput = value; persistNotify('dsh-service-notify-input', value); publishNotify() }
399
413
  const useNotifyState = () => {
400
414
  const [, setTick] = useState(0)
401
415
  const [enabled, setEnabled] = useState(notifyEnabled)
402
- const [interval, setInterval_] = useState(notifyInterval)
416
+ const [done, setDone] = useState(notifyDone)
417
+ const [input, setInput] = useState(notifyInput)
403
418
  React.useEffect(() => {
404
- const update = () => { setEnabled(notifyEnabled); setInterval_(notifyInterval); setTick((t) => t + 1) }
419
+ const update = () => { setEnabled(notifyEnabled); setDone(notifyDone); setInput(notifyInput); setTick((t) => t + 1) }
405
420
  notifyListeners.add(update)
406
421
  return () => notifyListeners.delete(update)
407
422
  }, [])
408
- return { enabled, interval, setEnabled: (v) => setNotifyEnabled(v), setInterval: (v) => setNotifyInterval(v) }
423
+ return { enabled, done, input, setEnabled: (v) => setNotifyEnabled(v), setDone: (v) => setNotifyDone(v), setInput: (v) => setNotifyInput(v) }
409
424
  }
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
- }
425
+ // 会话边沿通知:running→idle 记一次任务结束;pendingInteraction 出现记一次需要确认。
426
+ // 数据源是客户端运行时的会话列表快照(订阅推送);首个快照只建立基线,重连后重建基线,二者都不响铃。
427
+ const NOTIFY_KIND_KEYS = {
428
+ approval: 'notification.kind.approval',
429
+ 'plan-review': 'notification.kind.plan-review',
430
+ question: 'notification.kind.question',
431
+ }
432
+ const notifyPermissionGranted = () => typeof Notification !== 'undefined' && Notification.permission === 'granted'
433
+ const fireNotification = (title, body) => {
434
+ if (!notifyPermissionGranted()) return
435
+ try { new Notification(title, { body }) } catch (_) {}
436
+ }
437
+ if (ctx.sessions && typeof ctx.sessions.list?.subscribe === 'function') {
438
+ const observed = new Map()
439
+ let baselined = false
440
+ const observeSessions = () => {
441
+ const snapshot = ctx.sessions.list.getSnapshot()
442
+ if (!snapshot || !snapshot.byId) return
443
+ if (!baselined) {
444
+ baselined = true
445
+ for (const [id, summary] of Object.entries(snapshot.byId)) {
446
+ observed.set(id, { running: summary.running === true, pending: summary.pendingInteraction !== undefined })
447
+ }
448
+ return
449
+ }
450
+ for (const [id, summary] of Object.entries(snapshot.byId)) {
451
+ const next = { running: summary.running === true, pending: summary.pendingInteraction !== undefined }
452
+ const prev = observed.get(id)
453
+ if (prev !== undefined) {
454
+ if (prev.running && !next.running && notifyEnabled && notifyDone) {
455
+ fireNotification(t('notification.doneTitle'), t('notification.doneBody', { title: summary.displayTitle || id }))
456
+ }
457
+ if (!prev.pending && next.pending && notifyEnabled && notifyInput) {
458
+ const kindKey = NOTIFY_KIND_KEYS[summary.pendingInteraction]
459
+ const kind = kindKey ? t(kindKey) : String(summary.pendingInteraction)
460
+ fireNotification(t('notification.inputTitle'), t('notification.inputBody', { title: summary.displayTitle || id, kind }))
425
461
  }
426
- previousAgentIds.clear()
427
- for (const id of currentIds) previousAgentIds.add(id)
428
- initialized = true
429
462
  }
430
- } catch (_) {}
431
- ctx.timer.timeout(pollActivity, notifyInterval * 1000)
463
+ observed.set(id, next)
464
+ }
465
+ for (const id of [...observed.keys()]) {
466
+ if (!(id in snapshot.byId)) observed.delete(id)
467
+ }
432
468
  }
433
- ctx.timer.timeout(pollActivity, notifyInterval * 1000)
469
+ ctx.effect(() => ctx.sessions.list.subscribe(() => observeSessions()), 'dsh-service: session notification observation')
470
+ ctx.effect(() => ctx.on('connection/reset', () => { observed.clear(); baselined = false }), 'dsh-service: notification rebaseline on reconnect')
471
+ observeSessions()
434
472
  }
435
473
 
436
474
  const recoveryListeners = new Set()
@@ -1113,14 +1151,14 @@ window.__ModuleLoader__.load({
1113
1151
  : null,
1114
1152
  usage && usage.indexedSessions > 0
1115
1153
  ? 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)' } },
1154
+ 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)' } },
1155
+ 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
1156
  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' } },
1157
+ React.createElement('div', { 'data-testid': 'usage-plot', style: { position: 'relative', height: '164px' } },
1120
1158
  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
1159
  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
1160
  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' } },
1161
+ 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
1162
  usageSegments.map(([metricName, label, color]) => {
1125
1163
  const value = usageValue(chartTotals[index], metricName)
1126
1164
  const segmentHeight = chartValues[index] === 0 ? 0 : value / chartValues[index] * 100
@@ -1291,13 +1329,14 @@ window.__ModuleLoader__.load({
1291
1329
  : null)))
1292
1330
  : null))
1293
1331
 
1294
- const versionRow = (id, label, fallbackVersion, state) => 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)' } },
1295
- React.createElement('div', { style: { whiteSpace: 'nowrap' } },
1332
+ 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)' } },
1333
+ React.createElement('div', { style: { whiteSpace: 'nowrap', display: 'flex', alignItems: 'center', gap: '8px' } },
1296
1334
  React.createElement('span', { style: { fontSize: '13px', fontWeight: 650 } }, `${label} `),
1297
1335
  state?.url
1298
1336
  ? 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'))
1299
1337
  : React.createElement('code', { style: { fontSize: '12px', color: 'var(--dsw-alias-label-primary)', marginLeft: '16px' } }, state?.current || fallbackVersion || translate('version.loading'))),
1300
1338
  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,
1339
+ action || null,
1301
1340
  React.createElement('div', { style: { textAlign: 'right', fontSize: '12px' } },
1302
1341
  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
1303
1342
  ? (updateError || translate('update.checking'))
@@ -1306,16 +1345,15 @@ window.__ModuleLoader__.load({
1306
1345
  : state.upToDate ? translate('update.current') : translate('update.available', { version: state.latest }))))
1307
1346
  // 版本信息区块
1308
1347
  const pluginUpdate = updateInfo?.plugin && !updateInfo.plugin.upToDate && updateInfo.plugin.status === 'available'
1348
+ const pluginAction = pluginUpdate
1349
+ ? React.createElement('button', { style: Object.assign({}, neutral, { minHeight: '24px', padding: '2px 8px', fontSize: '11px' }), disabled: upgradeBusy, onClick: upgradePlugin }, translate(upgradeBusy ? 'update.upgrading' : 'update.upgrade'))
1350
+ : null
1309
1351
  const versionBlock = React.createElement('div', { key: 'version-card', 'data-testid': 'version-card', style: card },
1310
1352
  React.createElement('div', { key: 'title', style: sectionTitle }, translate('version.title')),
1311
1353
  React.createElement('div', { style: displaySurface },
1312
1354
  versionRow('dsh', 'DSH', version, updateInfo?.dsh),
1313
- versionRow('plugin', 'dsh-service', pluginVersion, updateInfo?.plugin),
1314
- pluginUpdate || upgradeError
1315
- ? React.createElement('div', { style: { marginTop: '8px', display: 'flex', alignItems: 'center', gap: '8px' } },
1316
- pluginUpdate ? React.createElement('button', { style: primary, disabled: upgradeBusy, onClick: upgradePlugin }, translate(upgradeBusy ? 'update.upgrading' : 'update.upgrade')) : null,
1317
- upgradeError ? React.createElement('span', { style: { fontSize: '12px', color: 'var(--dsw-alias-state-error-primary)' } }, upgradeError) : null)
1318
- : null))
1355
+ versionRow('plugin', 'dsh-service', pluginVersion, updateInfo?.plugin, pluginAction),
1356
+ upgradeError ? React.createElement('p', { style: Object.assign({}, hint, { color: 'var(--dsw-alias-state-error-primary)', margin: '4px 0 0' }) }, upgradeError) : null))
1319
1357
 
1320
1358
  // 重启后提示
1321
1359
  if (stage === 2) {
@@ -1375,9 +1413,27 @@ window.__ModuleLoader__.load({
1375
1413
  error ? React.createElement('p', { style: Object.assign({}, hint, { color: 'var(--dsw-alias-state-error-primary)' }) }, String(error)) : null)
1376
1414
  )
1377
1415
 
1378
- const { enabled: notifyOn, interval: notifyIntv, setEnabled: setNotifyOn, setInterval: setNotifyIntv } = useNotifyState()
1416
+ const { enabled: notifyOn, done: notifyDoneOn, input: notifyInputOn, setEnabled: setNotifyOn, setDone: setNotifyDoneOn, setInput: setNotifyInputOn } = useNotifyState()
1379
1417
  const notifSupported = typeof Notification !== 'undefined'
1380
1418
  const notifPermission = notifSupported ? Notification.permission : 'denied'
1419
+ const notifySwitch = (on, onChange, disabled) => React.createElement('button', {
1420
+ type: 'button',
1421
+ role: 'switch',
1422
+ 'aria-checked': String(on === true),
1423
+ 'aria-disabled': disabled ? 'true' : undefined,
1424
+ onClick: disabled ? undefined : () => onChange(!on),
1425
+ style: {
1426
+ width: '34px', height: '20px', borderRadius: '10px', padding: 0, flexShrink: 0, position: 'relative',
1427
+ border: `1px solid ${on ? 'var(--dsw-alias-state-success-primary)' : 'var(--dsw-alias-border-l2)'}`,
1428
+ background: on ? 'var(--dsw-alias-state-success-primary)' : 'var(--dsw-alias-bg-layer-2)',
1429
+ cursor: disabled ? 'default' : 'pointer', opacity: disabled ? 0.5 : 1, lineHeight: 0,
1430
+ },
1431
+ }, 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)' } }))
1432
+ const notifyRow = (label, labelHint, on, onChange, disabled) => React.createElement('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '12px', padding: '5px 0' } },
1433
+ React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: '2px' } },
1434
+ React.createElement('span', { style: { fontSize: '13px', color: 'var(--dsw-alias-label-primary)' } }, label),
1435
+ React.createElement('span', { style: hint }, labelHint)),
1436
+ notifySwitch(on, onChange, disabled))
1381
1437
  const notificationBlock = !notifSupported ? null
1382
1438
  : React.createElement('div', { style: { marginTop: '18px' } },
1383
1439
  React.createElement('div', { style: sectionTitle }, translate('notification.title')),
@@ -1387,12 +1443,10 @@ window.__ModuleLoader__.load({
1387
1443
  ? React.createElement('div', { style: { marginTop: '8px', display: 'flex', alignItems: 'center', gap: '8px' } },
1388
1444
  React.createElement('button', { style: neutral, onClick: () => { Notification.requestPermission().then((p) => { if (p === 'granted') setNotifyOn(true) }) } }, translate('notification.enable')),
1389
1445
  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')))))
1446
+ : React.createElement('div', { style: { marginTop: '8px', display: 'flex', flexDirection: 'column' } },
1447
+ notifyRow(translate('notification.master'), translate('notification.masterHint'), notifyOn, setNotifyOn, false),
1448
+ notifyRow(translate('notification.done'), translate('notification.doneHint'), notifyDoneOn, setNotifyDoneOn, !notifyOn),
1449
+ notifyRow(translate('notification.input'), translate('notification.inputHint'), notifyInputOn, setNotifyInputOn, !notifyOn))))
1396
1450
  const overviewBlock = React.createElement('div', null, versionBlock, notificationBlock, containerInfoBlock, overviewErrorsBlock)
1397
1451
  const maintenanceBlock = React.createElement('div', { key: 'maintenance-card', 'data-testid': 'maintenance-card', style: card }, backupBlock)
1398
1452
  const diagnosticFailure = diagnostics?.checks?.some((check) => check.status === 'error' || (check.status === 'warning' && !(check.id === 'backup-storage' && String(check.detail || '').startsWith('0:')))) === true
@@ -1429,13 +1483,21 @@ window.__ModuleLoader__.load({
1429
1483
  React.createElement('div', { 'data-testid': 'tab-panel', style: tabPanel }, tabContent))
1430
1484
  }
1431
1485
 
1486
+ const bellPaths = {
1487
+ 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'],
1488
+ 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'],
1489
+ }
1490
+ function BellIcon(on) {
1491
+ 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' } },
1492
+ (on ? bellPaths.on : bellPaths.off).map(function (d, i) { return React.createElement('path', { key: i, d: d }) }))
1493
+ }
1432
1494
  function InlineNotifyBell() {
1433
1495
  const { enabled, setEnabled } = useNotifyState()
1434
1496
  const translate = useTranslation()
1435
1497
  return React.createElement('button', {
1436
1498
  type: 'button',
1437
1499
  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 },
1500
+ style: { background: 'transparent', border: 0, cursor: 'pointer', padding: '2px 4px', color: 'inherit', opacity: enabled ? 1 : 0.45, lineHeight: 0 },
1439
1501
  onClick: () => {
1440
1502
  if (typeof Notification === 'undefined') return
1441
1503
  if (Notification.permission !== 'granted') {
@@ -1444,7 +1506,7 @@ window.__ModuleLoader__.load({
1444
1506
  }
1445
1507
  setEnabled(!enabled)
1446
1508
  },
1447
- }, enabled ? '🔔' : '🔕')
1509
+ }, BellIcon(enabled))
1448
1510
  }
1449
1511
  ctx.slots.inject('conversation.input.left', () => ctx.slots.register(
1450
1512
  { name: 'conversation.input.left', id: 'dsh-service-notify', order: 90, label: () => t('notification.bellOn') },
package/index.js CHANGED
@@ -872,14 +872,13 @@ async function collectDiagnostics(ctx, dshHome) {
872
872
  try { add('tar', 'ok', await subprocess.resolveExecutable('tar')) } catch (error) { add('tar', 'error', error?.message || error) }
873
873
  }
874
874
 
875
- try {
876
- const snapshot = await permissionSnapshot(ctx, dshHome, new Map())
877
- if (snapshot.supported !== true) add('permissions', 'info', 'unsupported')
878
- else {
875
+ if (process.platform === 'linux' && typeof process.getuid === 'function' && typeof process.getgid === 'function') {
876
+ try {
877
+ const snapshot = await permissionSnapshot(ctx, dshHome, new Map())
879
878
  const abnormal = snapshot.items.filter((item) => item.writable === false).length
880
879
  add('permissions', abnormal === 0 ? 'ok' : 'warning', abnormal)
881
- }
882
- } catch (error) { add('permissions', 'warning', error?.message || error) }
880
+ } catch (error) { add('permissions', 'warning', error?.message || error) }
881
+ }
883
882
 
884
883
  let status = 'ok'
885
884
  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.0",
3
+ "version": "0.13.0",
4
4
  "description": "DSH Web 自托管运维面板:安全重启、健康监控、备份和 Linux 权限维护。",
5
5
  "type": "module",
6
6
  "scripts": {