@gehennawu/dsh-service 0.20.0 → 0.21.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
@@ -10,6 +10,8 @@ A service-control and operations plugin for self-hosted DSH Web. Provides safe r
10
10
 
11
11
  The Settings panel "Service Control" page has seven top-level tabs: **Overview, Notifications, Health, Model stats, Quota lookup, Backups, Restart**; the Restart and Quota lookup tabs can each enable a quick entry at the bottom of the settings left navigation (off by default).
12
12
 
13
+ The plugin also appears under **Plugins → Plugin configuration**, with five host-level switches enabled by default: **Model statistics, Quota lookup, Backup maintenance, Task notifications, and the `/healthz` liveness endpoint**. Disabling one hides its UI, stops the associated polling/subscriptions, and makes the Host reject that capability; Overview, Health diagnostics, and Restart remain available. All five switches are live settings: disabling or re-enabling them requires neither a page reload nor a DSH Web restart. Statistics refreshes, quota requests, or backup operations already in flight are allowed to finish; quota re-enablement preserves existing cache, TTL, and backoff state, so its UI and calls return immediately but a new upstream request is not guaranteed at once.
14
+
13
15
  ### Version and updates
14
16
 
15
17
  - Displays current DSH and plugin versions with links to GitHub Releases
package/README.md CHANGED
@@ -10,6 +10,8 @@
10
10
 
11
11
  设置页「服务控制」面板包含七个顶部标签:**概览、通知、健康诊断、模型统计、额度查询、备份维护、重启**;重启与额度查询标签还可在设置页左侧标签列底部开启快捷入口(默认关闭)。
12
12
 
13
+ 插件同时出现在「插件 → 插件配置」,提供五个默认开启的宿主级开关:**模型统计、额度查询、备份维护、任务通知、`/healthz` 探活端点**。关闭后不仅隐藏对应界面,也会停止相关轮询/订阅并由宿主拒绝对应能力;概览、健康诊断与重启固定保留。设置写入 DSH settings,五项均为热生效:关闭或重新开启都无需刷新页面或重启 DSH Web。已在途的统计刷新、额度请求或备份操作允许完成;额度重新开启时保留既有缓存、TTL 与退避状态,因此可立即恢复界面和调用,但不保证立刻重新请求上游。
14
+
13
15
  ### 版本与更新
14
16
 
15
17
  - 显示当前 DSH 和插件版本,版本号链接到 GitHub Releases
package/client.js CHANGED
@@ -11,6 +11,16 @@ window.__ModuleLoader__.load({
11
11
  const zh = {
12
12
  'nav.label': '服务控制',
13
13
  'nav.restart': '重启',
14
+ 'features.cardTitle': '服务控制(dsh-service)',
15
+ 'features.cardHint': '控制可选功能和外部能力。开关立即生效,无需重启;详细状态与操作位于左侧「服务控制」。',
16
+ 'features.optional': '可选功能',
17
+ 'features.external': '外部能力',
18
+ 'features.modelUsage': '模型统计',
19
+ 'features.quotaLookup': '额度查询',
20
+ 'features.backupMaintenance': '备份维护',
21
+ 'features.taskNotifications': '任务通知',
22
+ 'features.healthz': '/healthz 探活端点',
23
+ 'features.readOnly': '当前设置不可写。',
14
24
  'overlay.label': '服务重启状态',
15
25
  'recovery.waiting.title': '服务重启中…',
16
26
  'recovery.waiting.body': '正在等待新的 DSH Web 进程启动,已等待 {seconds} 秒。',
@@ -309,6 +319,16 @@ window.__ModuleLoader__.load({
309
319
  const en = {
310
320
  'nav.label': 'Service Control',
311
321
  'nav.restart': 'Restart',
322
+ 'features.cardTitle': 'Service control (dsh-service)',
323
+ 'features.cardHint': 'Control optional features and external capabilities. Changes take effect immediately without a restart; detailed status and actions remain in Service Control.',
324
+ 'features.optional': 'Optional features',
325
+ 'features.external': 'External capabilities',
326
+ 'features.modelUsage': 'Model statistics',
327
+ 'features.quotaLookup': 'Quota lookup',
328
+ 'features.backupMaintenance': 'Backup maintenance',
329
+ 'features.taskNotifications': 'Task notifications',
330
+ 'features.healthz': '/healthz liveness endpoint',
331
+ 'features.readOnly': 'These settings are read-only.',
312
332
  'overlay.label': 'Service restart status',
313
333
  'recovery.waiting.title': 'Restarting service…',
314
334
  'recovery.waiting.body': 'Waiting for a new DSH Web process. Elapsed: {seconds} seconds.',
@@ -664,7 +684,7 @@ window.__ModuleLoader__.load({
664
684
  }
665
685
  }
666
686
 
667
- const inject = ['slots', 'connection', 'timer', 'locale', 'sessions']
687
+ const inject = ['slots', 'connection', 'timer', 'locale', 'sessions', 'settingsScope']
668
688
 
669
689
  function apply(ctx) {
670
690
  const { useState, useEffect, useRef } = React
@@ -687,6 +707,16 @@ window.__ModuleLoader__.load({
687
707
  ctx.effect(() => () => { if (svcStyle) svcStyle.remove() }, 'dsh-service theme styles')
688
708
  ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'dsh-service dictionaries')
689
709
  const t = ctx.locale.bind(NS)
710
+ const DEFAULT_FEATURES = { modelUsage: true, quotaLookup: true, backupMaintenance: true, taskNotifications: true, healthz: true }
711
+ const featureScope = ctx.settingsScope.bind({ namespace: NS })
712
+ const featureSnapshot = () => featureScope.getSnapshot()
713
+ const featureValue = () => Object.assign({}, DEFAULT_FEATURES, featureSnapshot().value || {})
714
+ const featureEnabled = (key) => featureValue()[key] !== false
715
+ const useFeatures = () => {
716
+ const [snapshot, setSnapshot] = React.useState(featureSnapshot())
717
+ React.useEffect(() => featureScope.subscribe(() => setSnapshot(featureSnapshot())), [])
718
+ return { snapshot, value: Object.assign({}, DEFAULT_FEATURES, snapshot.value || {}) }
719
+ }
690
720
  // 设置页左列三行打标记,配合上方样式换成各自图标;label 走 locale 绑定值。
691
721
  ctx.effect(
692
722
  () => markSettingsNavRows([
@@ -762,7 +792,7 @@ window.__ModuleLoader__.load({
762
792
  let quotaNavDispose = null
763
793
  const syncQuotaNavEntry = () => {
764
794
  if (quotaNavDispose) { quotaNavDispose(); quotaNavDispose = null }
765
- if (!quotaNavEnabled) return
795
+ if (!quotaNavEnabled || !featureEnabled('quotaLookup')) return
766
796
  quotaNavDispose = ctx.slots.register(
767
797
  { name: 'settings.section', id: 'dsh-service-quota', order: 498, label: () => t('tabs.quota') },
768
798
  () => React.createElement(QuotaSection, null),
@@ -811,11 +841,14 @@ window.__ModuleLoader__.load({
811
841
  } catch (_) {}
812
842
  return undefined
813
843
  }
814
- // 会话活跃态(sessions.list 快照派生,订阅推送更新):后台额度轮询只刷新 running 会话使用的供应商。
844
+ // 会话活跃态(sessions.list 快照派生,订阅推送更新):任务通知和后台额度轮询共享这一事实源。
845
+ // 两项都关闭时彻底摘除订阅;任一重新开启时重新建立当前快照基线,不补发关闭期间的旧边沿。
815
846
  const sessionActivity = { anyRunning: false, runningSessionIds: new Set() }
816
847
  if (ctx.sessions && typeof ctx.sessions.list?.subscribe === 'function') {
817
848
  const observed = new Map()
818
849
  let baselined = false
850
+ let sessionsDispose = null
851
+ let resetDispose = null
819
852
  const observeSessions = () => {
820
853
  const snapshot = ctx.sessions.list.getSnapshot()
821
854
  if (!snapshot || !snapshot.byId) return
@@ -832,10 +865,10 @@ window.__ModuleLoader__.load({
832
865
  const next = { running: summary.running === true, pending: summary.pendingInteraction !== undefined }
833
866
  const prev = observed.get(id)
834
867
  if (prev !== undefined) {
835
- if (prev.running && !next.running && notifyEnabled && notifyDone) {
868
+ if (prev.running && !next.running && featureEnabled('taskNotifications') && notifyEnabled && notifyDone) {
836
869
  fireNotification(t('notification.doneTitle'), t('notification.doneBody', { title: summary.displayTitle || id }))
837
870
  }
838
- if (!prev.pending && next.pending && notifyEnabled && notifyInput) {
871
+ if (!prev.pending && next.pending && featureEnabled('taskNotifications') && notifyEnabled && notifyInput) {
839
872
  const kindKey = NOTIFY_KIND_KEYS[summary.pendingInteraction]
840
873
  const kind = kindKey ? t(kindKey) : String(summary.pendingInteraction)
841
874
  fireNotification(t('notification.inputTitle'), t('notification.inputBody', { title: summary.displayTitle || id, kind }))
@@ -848,12 +881,30 @@ window.__ModuleLoader__.load({
848
881
  }
849
882
  // agent 启动时轮询链可能已因「隐藏页跳过周期」而死(runQuotaCycle 跳过即不再排下一轮):
850
883
  // 有活跃会话就重新拉起排程(幂等:已有挂起定时器/refs=0/仅手动时 no-op)。
851
- // 放在 baselined 早退之后——首次同步调用发生在工厂初始化期(quotaLoop 尚未定义),订阅事件只会在初始化完成后到。
852
884
  if (sessionActivity.anyRunning) scheduleQuotaCycle()
853
885
  }
854
- ctx.effect(() => ctx.sessions.list.subscribe(() => observeSessions()), 'dsh-service: session notification observation')
855
- ctx.effect(() => ctx.on('connection/reset', () => { observed.clear(); baselined = false }), 'dsh-service: notification rebaseline on reconnect')
856
- observeSessions()
886
+ const stopSessionObservation = () => {
887
+ if (sessionsDispose !== null) { sessionsDispose(); sessionsDispose = null }
888
+ if (resetDispose !== null) { resetDispose(); resetDispose = null }
889
+ observed.clear()
890
+ baselined = false
891
+ sessionActivity.anyRunning = false
892
+ sessionActivity.runningSessionIds = new Set()
893
+ }
894
+ const syncSessionObservation = () => {
895
+ const needed = featureEnabled('taskNotifications') || featureEnabled('quotaLookup')
896
+ if (!needed) { stopSessionObservation(); return }
897
+ if (sessionsDispose !== null) return
898
+ sessionsDispose = ctx.sessions.list.subscribe(() => observeSessions())
899
+ resetDispose = ctx.on('connection/reset', () => { observed.clear(); baselined = false })
900
+ observeSessions()
901
+ }
902
+ syncSessionObservation()
903
+ const unsubscribeFeatures = featureScope.subscribe(syncSessionObservation)
904
+ ctx.effect(() => () => {
905
+ unsubscribeFeatures()
906
+ stopSessionObservation()
907
+ }, 'dsh-service: shared session observation')
857
908
  }
858
909
 
859
910
  const recoveryListeners = new Set()
@@ -1342,7 +1393,7 @@ window.__ModuleLoader__.load({
1342
1393
  const quotaLoop = { refs: 0, allRefs: 0, nextDispose: null, running: false, onVisible: undefined }
1343
1394
  const isTabHidden = () => typeof document !== 'undefined' && document.visibilityState === 'hidden'
1344
1395
  function scheduleQuotaCycle() {
1345
- if (quotaLoop.refs === 0 || quotaLoop.nextDispose !== null || readQuotaPollMinutes() <= 0) return
1396
+ if (!featureEnabled('quotaLookup') || quotaLoop.refs === 0 || quotaLoop.nextDispose !== null || readQuotaPollMinutes() <= 0) return
1346
1397
  const minutes = readQuotaPollMinutes()
1347
1398
  quotaLoop.nextDispose = ctx.timer.timeout(() => {
1348
1399
  quotaLoop.nextDispose = null
@@ -1350,7 +1401,7 @@ window.__ModuleLoader__.load({
1350
1401
  }, minutes * 60000)
1351
1402
  }
1352
1403
  function runQuotaCycle() {
1353
- if (quotaLoop.refs === 0 || quotaLoop.running) return
1404
+ if (!featureEnabled('quotaLookup') || quotaLoop.refs === 0 || quotaLoop.running) return
1354
1405
  // 额度页打开时全量;其余自动/后台轮询只刷新 running 会话供应商。
1355
1406
  const payload = quotaLoop.allRefs > 0 ? { scope: 'all' } : { providers: runningQuotaProviders() }
1356
1407
  if (payload.scope !== 'all' && payload.providers.length === 0) return
@@ -1361,6 +1412,7 @@ window.__ModuleLoader__.load({
1361
1412
  })
1362
1413
  }
1363
1414
  function acquireQuotaLoop(options = {}) {
1415
+ if (!featureEnabled('quotaLookup')) return
1364
1416
  quotaLoop.refs += 1
1365
1417
  if (options.all === true) quotaLoop.allRefs += 1
1366
1418
  // 额度页显式全量;圆环等其他表面由当前交互/后台活跃集合决定目标。
@@ -1377,6 +1429,7 @@ window.__ModuleLoader__.load({
1377
1429
  }
1378
1430
  }
1379
1431
  function releaseQuotaLoop(options = {}) {
1432
+ if (!featureEnabled('quotaLookup') && quotaLoop.refs === 0) return
1380
1433
  quotaLoop.refs = Math.max(0, quotaLoop.refs - 1)
1381
1434
  if (options.all === true) quotaLoop.allRefs = Math.max(0, quotaLoop.allRefs - 1)
1382
1435
  if (quotaLoop.refs > 0) return
@@ -1646,6 +1699,58 @@ window.__ModuleLoader__.load({
1646
1699
  updatedNode) : null)
1647
1700
  }
1648
1701
 
1702
+ function FeatureSettingsCard() {
1703
+ const translate = useTranslation()
1704
+ const { snapshot, value } = useFeatures()
1705
+ const [open, setOpen] = React.useState(false)
1706
+ const [saving, setSaving] = React.useState('')
1707
+ const writable = snapshot.status === 'ready' && snapshot.writable === true
1708
+ const row = (key) => React.createElement('div', {
1709
+ key,
1710
+ style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '14px', padding: '7px 0' },
1711
+ },
1712
+ React.createElement('span', { style: { fontSize: '13px', color: 'var(--dsw-alias-label-primary)' } }, translate('features.' + key)),
1713
+ React.createElement('button', {
1714
+ type: 'button',
1715
+ role: 'switch',
1716
+ 'data-testid': 'feature-switch-' + key,
1717
+ 'aria-checked': String(value[key] !== false),
1718
+ disabled: !writable || saving !== '',
1719
+ onClick: async () => {
1720
+ setSaving(key)
1721
+ try { await featureScope.set(key, value[key] === false) } catch (_) {}
1722
+ setSaving('')
1723
+ },
1724
+ style: { width: '34px', height: '20px', borderRadius: '10px', padding: 0, flexShrink: 0, position: 'relative', border: '1px solid ' + (value[key] !== false ? 'var(--dsw-alias-state-success-primary)' : 'var(--dsw-alias-border-l2)'), background: value[key] !== false ? 'var(--dsw-alias-state-success-primary)' : 'var(--dsw-alias-bg-layer-2)', cursor: writable && saving === '' ? 'pointer' : 'default', opacity: writable ? 1 : 0.5, lineHeight: 0 },
1725
+ }, React.createElement('span', { style: { position: 'absolute', top: '1px', left: value[key] !== false ? '15px' : '1px', width: '16px', height: '16px', borderRadius: '50%', background: value[key] !== false ? '#fff' : 'var(--dsw-alias-label-tertiary)' } })))
1726
+ return React.createElement('li', {
1727
+ style: { listStyle: 'none', border: '1px solid ' + (open ? 'var(--dsw-alias-label-dimmed)' : 'var(--dsw-alias-border-l2)'), borderRadius: '12px', color: 'var(--dsw-alias-label-primary)', background: open ? 'var(--dsw-alias-bg-layer-2)' : 'var(--dsw-alias-bg-layer-3)' },
1728
+ },
1729
+ React.createElement('button', {
1730
+ type: 'button',
1731
+ 'data-testid': 'feature-card-toggle',
1732
+ 'aria-expanded': String(open),
1733
+ onClick: () => setOpen(!open),
1734
+ style: { appearance: 'none', width: '100%', display: 'flex', alignItems: 'center', gap: '12px', padding: '14px 16px', border: 0, borderRadius: '12px', background: 'transparent', color: 'inherit', font: 'inherit', textAlign: 'left', cursor: 'pointer' },
1735
+ },
1736
+ React.createElement('span', { style: { display: 'flex', minWidth: 0, flex: 1, flexDirection: 'column', gap: '4px' } },
1737
+ React.createElement('span', { style: { fontSize: '15px', fontWeight: 600, lineHeight: 1.4 } }, translate('features.cardTitle')),
1738
+ React.createElement('span', { style: { fontSize: '13px', lineHeight: 1.5, color: 'var(--dsw-alias-label-tertiary)' } }, translate('features.cardHint'))),
1739
+ React.createElement('svg', {
1740
+ viewBox: '0 0 14 14',
1741
+ width: 14,
1742
+ height: 14,
1743
+ 'aria-hidden': 'true',
1744
+ style: { flex: 'none', color: 'var(--dsw-alias-label-tertiary)', transform: open ? 'rotate(180deg)' : 'none', transition: 'transform .16s' },
1745
+ }, React.createElement('path', { d: 'M3 5.25 7 9l4-3.75', fill: 'none', stroke: 'currentColor', strokeWidth: 1.4, strokeLinecap: 'round', strokeLinejoin: 'round' }))),
1746
+ open ? React.createElement('div', { style: { margin: '0 16px', padding: '12px 0 8px', borderTop: '1px solid var(--dsw-alias-border-l2)' } },
1747
+ React.createElement('div', { style: { fontSize: '12px', fontWeight: 700 } }, translate('features.optional')),
1748
+ ['modelUsage', 'quotaLookup', 'backupMaintenance', 'taskNotifications'].map(row),
1749
+ React.createElement('div', { style: { fontSize: '12px', fontWeight: 700, marginTop: '8px', paddingTop: '10px', borderTop: '1px solid var(--dsw-alias-border-l1)' } }, translate('features.external')),
1750
+ row('healthz'),
1751
+ !writable ? React.createElement('p', { style: { margin: '6px 0 0', fontSize: '11px', color: 'var(--dsw-alias-label-tertiary)' } }, translate('features.readOnly')) : null) : null)
1752
+ }
1753
+
1649
1754
  function QuotaSection() {
1650
1755
  return React.createElement(RemoteQuotaCard, null)
1651
1756
  }
@@ -1907,6 +2012,7 @@ window.__ModuleLoader__.load({
1907
2012
 
1908
2013
  function ServicePanel() {
1909
2014
  const translate = useTranslation()
2015
+ const { value: features } = useFeatures()
1910
2016
  const [health, setHealth] = useState(null)
1911
2017
  const [healthError, setHealthError] = useState(null)
1912
2018
  const [diagnostics, setDiagnostics] = useState(null)
@@ -1984,6 +2090,7 @@ window.__ModuleLoader__.load({
1984
2090
  return () => { active = false }
1985
2091
  }, [])
1986
2092
  useEffect(() => {
2093
+ if (!featureEnabled('modelUsage')) return () => {}
1987
2094
  let active = true
1988
2095
  ctx.connection.rpc.call('/dsh-service', 'usage', usageRequestPayload).then(async (res) => {
1989
2096
  if (!active) return
@@ -1998,8 +2105,9 @@ window.__ModuleLoader__.load({
1998
2105
  if (active) setUsageError(translate('usage.error'))
1999
2106
  })
2000
2107
  return () => { active = false }
2001
- }, [])
2108
+ }, [features.modelUsage])
2002
2109
  useEffect(() => {
2110
+ if (!featureEnabled('backupMaintenance')) return () => {}
2003
2111
  let active = true
2004
2112
  ctx.connection.rpc.call('/dsh-service', 'backup-list', {}).then((res) => {
2005
2113
  if (!active) return
@@ -2009,7 +2117,7 @@ window.__ModuleLoader__.load({
2009
2117
  if (active) setBackupError(translate('backup.error'))
2010
2118
  })
2011
2119
  return () => { active = false }
2012
- }, [])
2120
+ }, [features.backupMaintenance])
2013
2121
  useEffect(() => {
2014
2122
  let active = true
2015
2123
  let cancelNext = () => {}
@@ -2760,25 +2868,26 @@ window.__ModuleLoader__.load({
2760
2868
  }
2761
2869
  const tabs = [
2762
2870
  ['overview', 'tabs.overview'],
2763
- ['notify', 'tabs.notify'],
2871
+ ...(features.taskNotifications !== false ? [['notify', 'tabs.notify']] : []),
2764
2872
  ['health', 'tabs.health'],
2765
- ['usage', 'tabs.usage'],
2766
- ['quota', 'tabs.quota'],
2767
- ['backup', 'tabs.backup'],
2873
+ ...(features.modelUsage !== false ? [['usage', 'tabs.usage']] : []),
2874
+ ...(features.quotaLookup !== false ? [['quota', 'tabs.quota']] : []),
2875
+ ...(features.backupMaintenance !== false ? [['backup', 'tabs.backup']] : []),
2768
2876
  ['restart', 'tabs.restart'],
2769
2877
  ]
2770
2878
  const warningTabs = tabs.filter(([id]) => tabWarnings[id]).map(([, label]) => translate(label))
2771
- const tabContent = activeTab === 'overview'
2879
+ const visibleActiveTab = tabs.some(([id]) => id === activeTab) ? activeTab : 'overview'
2880
+ const tabContent = visibleActiveTab === 'overview'
2772
2881
  ? overviewBlock
2773
- : activeTab === 'notify'
2882
+ : visibleActiveTab === 'notify'
2774
2883
  ? notifyBlock
2775
- : activeTab === 'health'
2884
+ : visibleActiveTab === 'health'
2776
2885
  ? healthBlock
2777
- : activeTab === 'usage'
2886
+ : visibleActiveTab === 'usage'
2778
2887
  ? usageBlock
2779
- : activeTab === 'quota'
2888
+ : visibleActiveTab === 'quota'
2780
2889
  ? React.createElement(RemoteQuotaCard, null)
2781
- : activeTab === 'backup'
2890
+ : visibleActiveTab === 'backup'
2782
2891
  ? maintenanceBlock
2783
2892
  : restartBlock
2784
2893
  return React.createElement('div', null,
@@ -2786,7 +2895,7 @@ window.__ModuleLoader__.load({
2786
2895
  React.createElement('div', { style: { fontSize: '13px', fontWeight: 700 } }, translate('tabs.alert.title')),
2787
2896
  React.createElement('div', { style: Object.assign({}, hint, { marginTop: '3px' }) }, translate('tabs.alert.body', { tabs: warningTabs.join('、') }))) : null,
2788
2897
  React.createElement('div', { 'data-testid': 'tab-list', style: { display: 'flex', gap: '10px', flexWrap: 'wrap', borderBottom: '1px solid var(--dsw-alias-border-l1)' } },
2789
- tabs.map(([id, label]) => React.createElement('button', { key: id, style: Object.assign({}, inlineTab, activeTab === id ? inlineTabActive : { color: tabWarnings[id] ? 'var(--dsw-alias-state-warn-primary)' : 'var(--dsw-alias-label-secondary)', borderBottom: '2px solid transparent' }), onClick: () => { setActiveTab(id); if (id === 'health') runDiagnostics(false) } }, `${tabWarnings[id] ? '⚠ ' : ''}${translate(label)}`))),
2898
+ tabs.map(([id, label]) => React.createElement('button', { key: id, style: Object.assign({}, inlineTab, visibleActiveTab === id ? inlineTabActive : { color: tabWarnings[id] ? 'var(--dsw-alias-state-warn-primary)' : 'var(--dsw-alias-label-secondary)', borderBottom: '2px solid transparent' }), onClick: () => { setActiveTab(id); if (id === 'health') runDiagnostics(false) } }, `${tabWarnings[id] ? '⚠ ' : ''}${translate(label)}`))),
2790
2899
  React.createElement('div', { 'data-testid': 'tab-panel', style: tabPanel }, tabContent))
2791
2900
  }
2792
2901
 
@@ -2815,14 +2924,28 @@ window.__ModuleLoader__.load({
2815
2924
  },
2816
2925
  }, BellIcon(enabled))
2817
2926
  }
2818
- ctx.slots.inject('conversation.input.left', () => ctx.slots.register(
2819
- { name: 'conversation.input.left', id: 'dsh-service-notify', order: 90, label: () => t('notification.bellOn') },
2820
- () => React.createElement(InlineNotifyBell, null),
2821
- ))
2927
+ ctx.slots.inject('conversation.input.left', () => {
2928
+ let dispose = null
2929
+ const sync = () => {
2930
+ if (dispose !== null) { dispose(); dispose = null }
2931
+ if (!featureEnabled('taskNotifications')) return
2932
+ dispose = ctx.slots.register(
2933
+ { name: 'conversation.input.left', id: 'dsh-service-notify', order: 90, label: () => t('notification.bellOn') },
2934
+ () => React.createElement(InlineNotifyBell, null),
2935
+ )
2936
+ }
2937
+ sync()
2938
+ const unsubscribe = featureScope.subscribe(sync)
2939
+ return () => { unsubscribe(); if (dispose !== null) dispose() }
2940
+ })
2822
2941
  ctx.slots.inject('sidebar.footer.action', () => ctx.slots.register(
2823
2942
  { name: 'sidebar.footer.action', id: 'dsh-service-update', order: 90, label: () => t('update.badge') },
2824
2943
  () => React.createElement(UpdateBadge, null),
2825
2944
  ))
2945
+ ctx.slots.inject('settings.plugin.item', () => ctx.slots.register(
2946
+ { name: 'settings.plugin.item', id: 'dsh-service', key: 'dsh-service', order: 40 },
2947
+ () => React.createElement(FeatureSettingsCard, null),
2948
+ ))
2826
2949
  ctx.slots.inject('settings.section', () => {
2827
2950
  const disposePanel = ctx.slots.register(
2828
2951
  { name: 'settings.section', id: 'dsh-service', order: 99, label: () => t('nav.label') },
@@ -2831,7 +2954,9 @@ window.__ModuleLoader__.load({
2831
2954
  // 左列「重启」「额度查询」入口由各自标签内的开关控制,默认不注册
2832
2955
  syncRestartNavEntry()
2833
2956
  syncQuotaNavEntry()
2957
+ const unsubscribeFeatures = featureScope.subscribe(syncQuotaNavEntry)
2834
2958
  return () => {
2959
+ unsubscribeFeatures()
2835
2960
  disposePanel()
2836
2961
  if (restartNavDispose) {
2837
2962
  restartNavDispose()
@@ -2852,31 +2977,41 @@ window.__ModuleLoader__.load({
2852
2977
  // (老版本 DSH 没有)。槽位条目无条件注册,服务在条目渲染时(inject(sessionId))
2853
2978
  // 经 ctx.get 惰性解析——此时会话已渲染、model-selection 必然已挂载,不受注入时序影响;
2854
2979
  // 拿不到服务时 props 为空,QuotaRing 渲染 null 且不启动轮询,其他功能零影响。
2855
- ctx.slots.inject('conversation.input.right', () => ctx.slots.register({
2856
- name: 'conversation.input.right',
2857
- id: 'dsh-service-quota-ring',
2858
- order: 95,
2859
- label: () => t('quota.ring.label'),
2860
- inject: (sessionId) => {
2861
- if (sessionId === undefined || sessionId === null) return {}
2862
- try {
2863
- const models = getModelDirectories()
2864
- if (models === undefined || typeof models.directoryFor !== 'function') return {}
2865
- const directory = models.directoryFor(sessionId)
2866
- return {
2867
- directoryStore: directory.store,
2868
- loadDirectory: () => {
2869
- try {
2870
- const pending = directory.load()
2871
- if (pending && typeof pending.catch === 'function') pending.catch(() => {})
2872
- } catch (_) {}
2873
- },
2874
- }
2875
- } catch (_) {
2876
- return {}
2877
- }
2878
- },
2879
- }, (props) => React.createElement(QuotaRing, props)))
2980
+ ctx.slots.inject('conversation.input.right', () => {
2981
+ let dispose = null
2982
+ const sync = () => {
2983
+ if (dispose !== null) { dispose(); dispose = null }
2984
+ if (!featureEnabled('quotaLookup')) return
2985
+ dispose = ctx.slots.register({
2986
+ name: 'conversation.input.right',
2987
+ id: 'dsh-service-quota-ring',
2988
+ order: 95,
2989
+ label: () => t('quota.ring.label'),
2990
+ inject: (sessionId) => {
2991
+ if (sessionId === undefined || sessionId === null) return {}
2992
+ try {
2993
+ const models = getModelDirectories()
2994
+ if (models === undefined || typeof models.directoryFor !== 'function') return {}
2995
+ const directory = models.directoryFor(sessionId)
2996
+ return {
2997
+ directoryStore: directory.store,
2998
+ loadDirectory: () => {
2999
+ try {
3000
+ const pending = directory.load()
3001
+ if (pending && typeof pending.catch === 'function') pending.catch(() => {})
3002
+ } catch (_) {}
3003
+ },
3004
+ }
3005
+ } catch (_) {
3006
+ return {}
3007
+ }
3008
+ },
3009
+ }, (props) => React.createElement(QuotaRing, props))
3010
+ }
3011
+ sync()
3012
+ const unsubscribe = featureScope.subscribe(sync)
3013
+ return () => { unsubscribe(); if (dispose !== null) dispose() }
3014
+ })
2880
3015
  }
2881
3016
 
2882
3017
  exports.inject = inject
package/index.js CHANGED
@@ -9,12 +9,28 @@ import { basename, dirname, join, relative, resolve, sep } from 'node:path'
9
9
  import { createRequire } from 'node:module'
10
10
  import { fileURLToPath } from 'node:url'
11
11
  import https from 'node:https'
12
+ import z from '@deepseek-ai/schemastery'
12
13
 
13
14
  const require = createRequire(import.meta.url)
14
15
  const name = 'dsh-service'
15
16
  const inject = ['connection']
16
17
  const DSH_PACKAGE = '@deepseek-ai/dsh'
17
18
  const PLUGIN_PACKAGE = '@gehennawu/dsh-service'
19
+ const SETTINGS_NAMESPACE = 'dsh-service'
20
+ const DEFAULT_FEATURE_SETTINGS = Object.freeze({
21
+ modelUsage: true,
22
+ quotaLookup: true,
23
+ backupMaintenance: true,
24
+ taskNotifications: true,
25
+ healthz: true,
26
+ })
27
+ const FeatureSettingsSchema = z.object({
28
+ modelUsage: z.boolean().default(true),
29
+ quotaLookup: z.boolean().default(true),
30
+ backupMaintenance: z.boolean().default(true),
31
+ taskNotifications: z.boolean().default(true),
32
+ healthz: z.boolean().default(true),
33
+ })
18
34
  const NPM_REGISTRY = 'https://registry.npmjs.org/'
19
35
  const MAX_NPM_RESPONSE_BYTES = 256 * 1024
20
36
  const MAX_BACKUP_TRANSFER_BYTES = 128 * 1024 * 1024
@@ -1800,6 +1816,25 @@ function scheduleRestart(ctx) {
1800
1816
 
1801
1817
  function apply(ctx) {
1802
1818
  const dshHome = resolveDshHome()
1819
+ let featureSettings = DEFAULT_FEATURE_SETTINGS
1820
+ const featureSettingsListeners = new Set()
1821
+ const publishFeatureSettings = () => {
1822
+ for (const listener of featureSettingsListeners) listener(featureSettings)
1823
+ }
1824
+ ctx.inject(['settings'], (settingsCtx) => {
1825
+ try {
1826
+ const scope = settingsCtx.settings.register(SETTINGS_NAMESPACE, FeatureSettingsSchema, { base: DEFAULT_FEATURE_SETTINGS })
1827
+ featureSettings = scope.get()
1828
+ publishFeatureSettings()
1829
+ if (typeof scope.watch === 'function') settingsCtx.effect(() => scope.watch((value) => {
1830
+ featureSettings = value ?? scope.get()
1831
+ publishFeatureSettings()
1832
+ }), 'dsh-service feature settings watch')
1833
+ } catch (error) {
1834
+ ctx.logger?.warn?.(`dsh-service: feature settings unavailable: ${error?.message || error}`)
1835
+ }
1836
+ })
1837
+ const featureEnabled = (key) => featureSettings?.[key] !== false
1803
1838
  // 进程运行环境在生命周期内不变:挂载时探测一次,version RPC 与升级分支共用。
1804
1839
  const runtimeEnv = detectRuntimeEnv()
1805
1840
  const permissionPlans = new Map()
@@ -1965,19 +2000,33 @@ function apply(ctx) {
1965
2000
  }
1966
2001
  const webServer = ctx.get('webServer')
1967
2002
  if (webServer !== undefined) {
1968
- ctx.effect(() => webServer.register({
1969
- kind: 'exact',
1970
- path: '/healthz',
1971
- handler: (req, res) => {
1972
- if (req.method !== 'GET' && req.method !== 'HEAD') {
1973
- res.writeHead(405)
2003
+ let healthzDispose = null
2004
+ const syncHealthzRoute = () => {
2005
+ if (healthzDispose !== null) {
2006
+ healthzDispose()
2007
+ healthzDispose = null
2008
+ }
2009
+ if (!featureEnabled('healthz')) return
2010
+ healthzDispose = webServer.register({
2011
+ kind: 'exact',
2012
+ path: '/healthz',
2013
+ handler: (req, res) => {
2014
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
2015
+ res.writeHead(405)
2016
+ res.end()
2017
+ return
2018
+ }
2019
+ res.writeHead(200)
1974
2020
  res.end()
1975
- return
1976
- }
1977
- res.writeHead(200)
1978
- res.end()
1979
- },
1980
- }), 'dsh-service healthz route')
2021
+ },
2022
+ })
2023
+ }
2024
+ syncHealthzRoute()
2025
+ featureSettingsListeners.add(syncHealthzRoute)
2026
+ ctx.effect(() => () => {
2027
+ featureSettingsListeners.delete(syncHealthzRoute)
2028
+ if (healthzDispose !== null) healthzDispose()
2029
+ }, 'dsh-service healthz route')
1981
2030
  ctx.effect(() => webServer.register({
1982
2031
  kind: 'exact',
1983
2032
  path: '/dsh-backup-download',
@@ -2071,6 +2120,7 @@ function apply(ctx) {
2071
2120
  }
2072
2121
 
2073
2122
  if (endpoint === 'usage') {
2123
+ if (!featureEnabled('modelUsage')) return { ok: false, error: 'feature-disabled' }
2074
2124
  try {
2075
2125
  return { ok: true, value: publicUsage(await usageIndexPromise, payload?.timezoneOffsetMinutes) }
2076
2126
  } catch (error) {
@@ -2079,6 +2129,7 @@ function apply(ctx) {
2079
2129
  }
2080
2130
 
2081
2131
  if (endpoint === 'usage-refresh') {
2132
+ if (!featureEnabled('modelUsage')) return { ok: false, error: 'feature-disabled' }
2082
2133
  try {
2083
2134
  if (usageRefreshPromise === undefined) {
2084
2135
  usageRefreshPromise = usageIndexPromise.then((index) => refreshUsageIndex(ctx, dshHome, index)).finally(() => { usageRefreshPromise = undefined })
@@ -2118,6 +2169,7 @@ function apply(ctx) {
2118
2169
  }
2119
2170
 
2120
2171
  if (endpoint === 'backup-list') {
2172
+ if (!featureEnabled('backupMaintenance')) return { ok: false, error: 'feature-disabled' }
2121
2173
  try {
2122
2174
  return { ok: true, value: await listBackups(dshHome) }
2123
2175
  } catch (error) {
@@ -2126,6 +2178,7 @@ function apply(ctx) {
2126
2178
  }
2127
2179
 
2128
2180
  if (endpoint === 'backup-create') {
2181
+ if (!featureEnabled('backupMaintenance')) return { ok: false, error: 'feature-disabled' }
2129
2182
  try {
2130
2183
  return { ok: true, value: await createBackup(ctx, dshHome) }
2131
2184
  } catch (error) {
@@ -2134,6 +2187,7 @@ function apply(ctx) {
2134
2187
  }
2135
2188
 
2136
2189
  if (endpoint === 'backup-export') {
2190
+ if (!featureEnabled('backupMaintenance')) return { ok: false, error: 'feature-disabled' }
2137
2191
  try {
2138
2192
  const value = await exportBackup(dshHome, downloadTokens, payload?.id)
2139
2193
  if (value === undefined) return { ok: false, error: 'unknown-backup' }
@@ -2144,6 +2198,7 @@ function apply(ctx) {
2144
2198
  }
2145
2199
 
2146
2200
  if (endpoint === 'backup-delete') {
2201
+ if (!featureEnabled('backupMaintenance')) return { ok: false, error: 'feature-disabled' }
2147
2202
  try {
2148
2203
  const value = await deleteBackup(dshHome, payload?.id)
2149
2204
  if (value === undefined) return { ok: false, error: 'unknown-backup' }
@@ -2154,6 +2209,7 @@ function apply(ctx) {
2154
2209
  }
2155
2210
 
2156
2211
  if (endpoint === 'backup-restore') {
2212
+ if (!featureEnabled('backupMaintenance')) return { ok: false, error: 'feature-disabled' }
2157
2213
  try {
2158
2214
  const value = await restoreBackup(ctx, dshHome, payload?.id)
2159
2215
  if (value === undefined) return { ok: false, error: 'unknown-backup' }
@@ -2165,6 +2221,7 @@ function apply(ctx) {
2165
2221
  }
2166
2222
 
2167
2223
  if (endpoint === 'backup-import') {
2224
+ if (!featureEnabled('backupMaintenance')) return { ok: false, error: 'feature-disabled' }
2168
2225
  try {
2169
2226
  const value = await importBackup(dshHome, payload?.name, payload?.data)
2170
2227
  if (value === undefined) return { ok: false, error: 'invalid-backup' }
@@ -2175,6 +2232,7 @@ function apply(ctx) {
2175
2232
  }
2176
2233
 
2177
2234
  if (endpoint === 'quota') {
2235
+ if (!featureEnabled('quotaLookup')) return { ok: false, error: 'feature-disabled' }
2178
2236
  try {
2179
2237
  const providers = readLlmProviders(ctx.get('settings'))
2180
2238
  quotaThrottle.prune(new Set(providers.map((profile) => profile.name)))
@@ -2226,6 +2284,7 @@ function apply(ctx) {
2226
2284
  }
2227
2285
 
2228
2286
  if (endpoint === 'quota-refresh') {
2287
+ if (!featureEnabled('quotaLookup')) return { ok: false, error: 'feature-disabled' }
2229
2288
  try {
2230
2289
  // 手动刷新入口:provider 过白名单且 kind 已适配;清掉节流闸后立即 kick。
2231
2290
  // 单飞仍生效(在途时本次点击为 no-op);上游结果经后续 quota 快照带出,不在此等待。
@@ -2248,6 +2307,7 @@ function apply(ctx) {
2248
2307
  }
2249
2308
 
2250
2309
  if (endpoint === 'quota-config') {
2310
+ if (!featureEnabled('quotaLookup')) return { ok: false, error: 'feature-disabled' }
2251
2311
  try {
2252
2312
  const providerName = typeof payload?.provider === 'string' ? payload.provider : ''
2253
2313
  // 三种写法,语义对齐配置文件解析(显式 kind > 显式 null 停用 > 自动推断):
@@ -2273,6 +2333,7 @@ function apply(ctx) {
2273
2333
  }
2274
2334
 
2275
2335
  if (endpoint === 'quota-reset-card') {
2336
+ if (!featureEnabled('quotaLookup')) return { ok: false, error: 'feature-disabled' }
2276
2337
  try {
2277
2338
  // 手录重置卡(v0.19 过渡方案;v0.20 免次数、每 provider 可多条)的面板写入口:
2278
2339
  // provider 过宿主清单白名单;{remove:true,id} 删除宿主下发 id 对应的那一条,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gehennawu/dsh-service",
3
- "version": "0.20.0",
3
+ "version": "0.21.0",
4
4
  "description": "DSH Web 自托管运维面板:安全重启、健康监控、备份和 Linux 权限维护。",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -40,6 +40,9 @@
40
40
  "engines": {
41
41
  "node": ">=22"
42
42
  },
43
+ "dependencies": {
44
+ "@deepseek-ai/schemastery": "^3.18.1"
45
+ },
43
46
  "dsh": {
44
47
  "bundle": {
45
48
  "patch": "./cordis.patch.yml"