@gehennawu/dsh-service 0.20.0 → 0.21.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 +3 -1
- package/README.md +3 -1
- package/client.js +252 -63
- package/index.js +73 -12
- package/package.json +4 -1
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
|
|
@@ -43,7 +45,7 @@ The Settings panel "Service Control" page has seven top-level tabs: **Overview,
|
|
|
43
45
|
### Quota lookup
|
|
44
46
|
|
|
45
47
|
- A dedicated "Quota lookup" tab shows adapted providers as separate cards, each window rendered as a percentage with its own bar and a reset countdown on its own line, plus a refresh icon beside the updated time that force-refreshes that provider on click (bypassing the poll interval); unadapted providers take no space — a "Manual adapt" row at the bottom enables one by picking its type, and each card's footer can switch type, fall back to auto-detect, or disable lookup at any time
|
|
46
|
-
- A quota ring inside the conversation composer follows the provider selected by the current session and shows the tightest budget window as a percentage (green below 80%, amber at or above); clicking opens a panel headed by the provider name, with a bar and used percentage for each window and reset times on their own lines
|
|
48
|
+
- A quota ring inside the conversation composer follows the provider selected by the current session and shows the tightest budget window as a percentage (green below 80%, amber at or above); clicking opens a panel headed by the provider name, with a bar and used percentage for each window and reset times on their own lines; on narrow viewports (phones) the panel switches to a viewport-centered floating layer — fully visible, scrolling internally when too tall — and rotates or resizing wider flips it back to the anchored placement above the ring
|
|
47
49
|
- A "Show a remote quota entry in the settings left navigation" switch lives in the Quota lookup tab (off by default), mirroring the Restart entry
|
|
48
50
|
- Built-in adaptations: **OpenCode Go** (`{baseURL}/usage`), **Zhipu GLM Coding Plan / zai-coding-cn** (official monitor `quota/limit` endpoint with three windows — 5-hour rolling tokens, weekly tokens, monthly MCP quota; an idle 5-hour window hides its reset time, matching the official console), **OpenRouter** (credits used %), **Kimi/Moonshot** and **SiliconFlow** (CNY balance text); dialects that natively report remaining percentage flip the panel word to "Remaining" and invert the warn threshold; transient network errors retry automatically and the Zhipu dual-domain candidate chain switches automatically; the provider-to-kind mapping lives in `DSH_HOME/dsh-service-quota.json`, and known services are auto-detected from their baseURL (e.g. opencode.ai, bigmodel.cn) with no manual picking; no upstream request is ever made for unadapted or disabled providers; disabling can be done via "Disable" in a card's footer or by writing `"<provider>": null` in the config file (both are equivalent). Zhipu reset cards have no API-key-queryable endpoint yet — you can add any number of them via each provider card's "Add reset card" button on the tab (name plus expiry time, down to the minute), and remove each one independently — the ring panel shows them too (stored in the same config file); expired cards are flagged automatically
|
|
49
51
|
- Anti-rate-limit pacing is enforced by the host: successful results are cached for 60 seconds (shared across tabs), failures back off exponentially (30 s doubling, capped at 15 minutes), upstream timeout is 15 s; the panel can set auto query to manual only / 1 / 2 / 5 / 10 minutes (manual only by default), paused automatically while the page is hidden
|
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
|
|
@@ -43,7 +45,7 @@
|
|
|
43
45
|
### 额度查询
|
|
44
46
|
|
|
45
47
|
- 独立的「额度查询」标签:以卡片分区展示已适配的供应商,每个窗口显示百分比、独立进度条,重置时间单独一行;卡片头部更新时间旁有刷新图标,点击即强制重拉该供应商(不受轮询间隔限制);未适配的不占位置,统一收进底部「手动适配」行选择类型启用,卡片脚部可随时切换适配类型、回退自动识别或停用查询
|
|
46
|
-
- 对话输入框内一枚额度圆环,跟随当前会话所选模型的供应商,显示最紧预算窗口的已用百分比(<80% 绿色、≥80%
|
|
48
|
+
- 对话输入框内一枚额度圆环,跟随当前会话所选模型的供应商,显示最紧预算窗口的已用百分比(<80% 绿色、≥80% 黄色);点击弹出面板——头部标明供应商,各窗口带独立进度条与已用百分比、重置时间单独一行;手机等窄屏上面板自动切换为视口居中的浮层(完整可见、超高时内部滚动),旋转或拖宽窗口即时切回圆环上方锚定
|
|
47
49
|
- 可在「额度查询」标签开启「设置页左列显示入口」开关(默认关闭),开启后在设置页左侧标签列底部显示「额度查询」快捷入口(与「重启」入口同模式)
|
|
48
50
|
- 内置适配:**OpenCode Go**(`{baseURL}/usage`)、**智谱 GLM Coding Plan / zai-coding-cn**(官方监控端点 `quota/limit`,含 5 小时滚动 Token、每周 Token、MCP 月度配额三个窗口,5 小时窗口空闲时与官网一致地不显示重置时间)、**OpenRouter**(credits 已用%)、**Kimi/Moonshot** 与 **硅基流动**(人民币余额);原生报「剩余百分比」的方言会自动把面板头部切换为「剩余」并把预警阈值反向;上游瞬时网络错误自动重试,智谱双域候选链自动切换;供应商与适配类型的对应关系保存在 `DSH_HOME/dsh-service-quota.json`,已知服务商按 baseURL 自动识别适配(如 opencode.ai、bigmodel.cn),无需手选;未适配或已停用的供应商不会被发起任何上游请求,停用可在卡片脚部选「停用查询」或在配置文件写 `"<provider>": null`(两者等价)。智谱的重置卡暂无 API Key 可查的接口,可在「额度查询」标签内点各供应商卡片的「添加重置卡」填写名称与到期时间(可精确到分钟),可连续添加多条、每条独立移除;圆环面板同步显示(数据存入同一配置文件),过期自动标注
|
|
49
51
|
- 防风控节律由宿主统一控制:成功结果缓存 60 秒(多标签共享)、失败指数退避(30 秒起 ×2、封顶 15 分钟)、上游超时 15 秒;面板可把自动查询调成仅手动 / 1 / 2 / 5 / 10 分钟(默认仅手动),页面不可见时自动暂停
|
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
|
|
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
|
-
|
|
855
|
-
|
|
856
|
-
|
|
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
|
|
@@ -1517,6 +1570,33 @@ window.__ModuleLoader__.load({
|
|
|
1517
1570
|
resetNode)
|
|
1518
1571
|
}
|
|
1519
1572
|
|
|
1573
|
+
// v0.21.1 圆环弹窗窄视口居中:宿主事实(别再翻外壳源码)——composer 工具行容器带
|
|
1574
|
+
// container-type:inline-size(布局包含:fixed 后代的 containing block 是它而非视口),
|
|
1575
|
+
// 外层会话滚动体又是 overflow:hidden auto,挂在 conversation.input.right 的弹层在手机上
|
|
1576
|
+
// 必然被裁一半,CSS 覆盖救不了。唯一可靠解法是 portal 到 document.body 后 fixed 视口居中。
|
|
1577
|
+
// react-dom 在平台 seed 表内(官方附件插件同源用 createPortal),老外壳可能缺席:
|
|
1578
|
+
// 惰性尝试,拿不到就回落原锚定弹层(与 modelDirectories 同款可选依赖哲学)。
|
|
1579
|
+
let quotaCreatePortal = null
|
|
1580
|
+
try {
|
|
1581
|
+
const reactDom = require('react-dom')
|
|
1582
|
+
if (reactDom !== null && reactDom !== undefined && typeof reactDom.createPortal === 'function') {
|
|
1583
|
+
quotaCreatePortal = reactDom.createPortal
|
|
1584
|
+
}
|
|
1585
|
+
} catch (_) {
|
|
1586
|
+
quotaCreatePortal = null
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
/** 窄视口判定:matchMedia 优先(可订阅变化),缺席时 innerWidth 兜底;
|
|
1590
|
+
* 无 window(SSR/测试)一律宽视口,维持圆环上方锚定弹层。断点 480px 与外壳窄容器一致。 */
|
|
1591
|
+
function quotaNarrowViewport() {
|
|
1592
|
+
if (typeof window === 'undefined' || window === null) return false
|
|
1593
|
+
if (typeof window.matchMedia === 'function') {
|
|
1594
|
+
const query = window.matchMedia('(max-width: 480px)')
|
|
1595
|
+
return typeof query.matches === 'boolean' ? query.matches : false
|
|
1596
|
+
}
|
|
1597
|
+
return typeof window.innerWidth === 'number' && window.innerWidth <= 480
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1520
1600
|
function QuotaRing(props) {
|
|
1521
1601
|
const translate = useTranslation()
|
|
1522
1602
|
const [quota, setQuota] = useState(quotaStore.getSnapshot())
|
|
@@ -1540,6 +1620,22 @@ window.__ModuleLoader__.load({
|
|
|
1540
1620
|
}, [store, directoryState, props && props.loadDirectory])
|
|
1541
1621
|
const [open, setOpen] = useState(false)
|
|
1542
1622
|
const rootRef = useRef(null)
|
|
1623
|
+
const panelRef = useRef(null)
|
|
1624
|
+
// 窄视口(手机竖屏):弹层 portal 到 body 视口居中;宽视口保持圆环上方锚定。
|
|
1625
|
+
// matchMedia change 订阅让旋转/拖宽窗口时弹层几何实时迁移,open 态不丢。
|
|
1626
|
+
const [narrow, setNarrow] = useState(quotaNarrowViewport)
|
|
1627
|
+
useEffect(() => {
|
|
1628
|
+
if (typeof window === 'undefined' || window === null || typeof window.matchMedia !== 'function') return undefined
|
|
1629
|
+
const query = window.matchMedia('(max-width: 480px)')
|
|
1630
|
+
const sync = () => setNarrow(query.matches)
|
|
1631
|
+
sync()
|
|
1632
|
+
if (typeof query.addEventListener === 'function') query.addEventListener('change', sync)
|
|
1633
|
+
else if (typeof query.addListener === 'function') query.addListener(sync)
|
|
1634
|
+
return () => {
|
|
1635
|
+
if (typeof query.removeEventListener === 'function') query.removeEventListener('change', sync)
|
|
1636
|
+
else if (typeof query.removeListener === 'function') query.removeListener(sync)
|
|
1637
|
+
}
|
|
1638
|
+
}, [])
|
|
1543
1639
|
const provider = directoryState?.current?.provider ?? null
|
|
1544
1640
|
const row = provider !== null ? (quota.providers || []).find((entry) => entry.provider === provider && entry.adapted === true) : null
|
|
1545
1641
|
const windows = Array.isArray(row?.windows) ? row.windows : []
|
|
@@ -1564,6 +1660,8 @@ window.__ModuleLoader__.load({
|
|
|
1564
1660
|
if (!open || typeof document === 'undefined') return undefined
|
|
1565
1661
|
const onPointerDown = (event) => {
|
|
1566
1662
|
if (rootRef.current && event.target instanceof Node && rootRef.current.contains(event.target)) return
|
|
1663
|
+
// portal 模式下面板挂在 body 下、不在 rootRef 子树内:点面板本体不算外点。
|
|
1664
|
+
if (panelRef.current && event.target instanceof Node && panelRef.current.contains(event.target)) return
|
|
1567
1665
|
setOpen(false)
|
|
1568
1666
|
}
|
|
1569
1667
|
const onKeyDown = (event) => {
|
|
@@ -1595,8 +1693,11 @@ window.__ModuleLoader__.load({
|
|
|
1595
1693
|
? React.createElement('div', { style: { marginTop: '8px', fontSize: '11px', color: 'var(--dsw-alias-label-tertiary)' } },
|
|
1596
1694
|
row.refreshing === true ? translate('quota.refreshing') : translate('quota.updated', { time: formatClockTime(row.fetchedAt) }))
|
|
1597
1695
|
: null
|
|
1598
|
-
|
|
1599
|
-
|
|
1696
|
+
// 面板先构造、再决定挂载方式:窄视口 portal 到 document.body 后 fixed 视口居中
|
|
1697
|
+
// (根因见 quotaCreatePortal 处注释);宽视口或 react-dom 缺席时锚定圆环上方(原行为)。
|
|
1698
|
+
const centered = open && narrow && quotaCreatePortal !== null
|
|
1699
|
+
&& typeof document !== 'undefined' && document.body !== null && document.body !== undefined
|
|
1700
|
+
const triggerNode = React.createElement('button', {
|
|
1600
1701
|
type: 'button',
|
|
1601
1702
|
'data-testid': 'quota-ring-trigger',
|
|
1602
1703
|
'aria-label': ariaText,
|
|
@@ -1617,13 +1718,16 @@ window.__ModuleLoader__.load({
|
|
|
1617
1718
|
cx: '7', cy: '7', r: radius, fill: 'none', stroke: color, strokeWidth: '2', strokeLinecap: 'round',
|
|
1618
1719
|
strokeDasharray: `${(circumference * percent) / 100} ${circumference}`,
|
|
1619
1720
|
transform: 'rotate(-90 7 7)',
|
|
1620
|
-
})))
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1721
|
+
})))
|
|
1722
|
+
const panelNode = open ? React.createElement('div', {
|
|
1723
|
+
ref: panelRef,
|
|
1724
|
+
role: 'dialog',
|
|
1725
|
+
'aria-label': translate('quota.panel.title'),
|
|
1726
|
+
'data-testid': 'quota-ring-panel',
|
|
1727
|
+
style: centered
|
|
1728
|
+
? { position: 'fixed', left: '50%', top: '50%', transform: 'translate(-50%, -50%)', zIndex: 1000, boxSizing: 'border-box', width: 'min(280px, calc(100vw - 32px))', maxHeight: 'calc(100dvh - 96px)', overflowY: 'auto', padding: '12px', borderRadius: '12px', background: 'var(--dsw-specific-menu)', border: '1px solid var(--dsw-alias-border-inverted)', boxShadow: 'var(--dsw-shadow-lv3)' }
|
|
1729
|
+
: { position: 'absolute', bottom: 'calc(100% + 8px)', right: 0, zIndex: 100, boxSizing: 'border-box', width: '240px', padding: '12px', borderRadius: '12px', background: 'var(--dsw-specific-menu)', border: '1px solid var(--dsw-alias-border-inverted)', boxShadow: 'var(--dsw-shadow-lv3)' },
|
|
1730
|
+
},
|
|
1627
1731
|
React.createElement('div', { style: { display: 'flex', alignItems: 'baseline', gap: '6px' } },
|
|
1628
1732
|
React.createElement('span', { style: { fontSize: '12px', color: 'var(--dsw-alias-label-tertiary)' } }, usedWord),
|
|
1629
1733
|
React.createElement('span', { style: { marginLeft: 'auto', fontSize: '11px', color: 'var(--dsw-alias-label-secondary)' } }, provider)),
|
|
@@ -1643,7 +1747,62 @@ window.__ModuleLoader__.load({
|
|
|
1643
1747
|
}))]
|
|
1644
1748
|
: []),
|
|
1645
1749
|
errorNode,
|
|
1646
|
-
updatedNode) : null
|
|
1750
|
+
updatedNode) : null
|
|
1751
|
+
return React.createElement('span', { ref: rootRef, style: { position: 'relative', display: 'inline-flex' } },
|
|
1752
|
+
triggerNode,
|
|
1753
|
+
centered ? quotaCreatePortal(panelNode, document.body) : panelNode)
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
function FeatureSettingsCard() {
|
|
1757
|
+
const translate = useTranslation()
|
|
1758
|
+
const { snapshot, value } = useFeatures()
|
|
1759
|
+
const [open, setOpen] = React.useState(false)
|
|
1760
|
+
const [saving, setSaving] = React.useState('')
|
|
1761
|
+
const writable = snapshot.status === 'ready' && snapshot.writable === true
|
|
1762
|
+
const row = (key) => React.createElement('div', {
|
|
1763
|
+
key,
|
|
1764
|
+
style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '14px', padding: '7px 0' },
|
|
1765
|
+
},
|
|
1766
|
+
React.createElement('span', { style: { fontSize: '13px', color: 'var(--dsw-alias-label-primary)' } }, translate('features.' + key)),
|
|
1767
|
+
React.createElement('button', {
|
|
1768
|
+
type: 'button',
|
|
1769
|
+
role: 'switch',
|
|
1770
|
+
'data-testid': 'feature-switch-' + key,
|
|
1771
|
+
'aria-checked': String(value[key] !== false),
|
|
1772
|
+
disabled: !writable || saving !== '',
|
|
1773
|
+
onClick: async () => {
|
|
1774
|
+
setSaving(key)
|
|
1775
|
+
try { await featureScope.set(key, value[key] === false) } catch (_) {}
|
|
1776
|
+
setSaving('')
|
|
1777
|
+
},
|
|
1778
|
+
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 },
|
|
1779
|
+
}, 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)' } })))
|
|
1780
|
+
return React.createElement('li', {
|
|
1781
|
+
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)' },
|
|
1782
|
+
},
|
|
1783
|
+
React.createElement('button', {
|
|
1784
|
+
type: 'button',
|
|
1785
|
+
'data-testid': 'feature-card-toggle',
|
|
1786
|
+
'aria-expanded': String(open),
|
|
1787
|
+
onClick: () => setOpen(!open),
|
|
1788
|
+
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' },
|
|
1789
|
+
},
|
|
1790
|
+
React.createElement('span', { style: { display: 'flex', minWidth: 0, flex: 1, flexDirection: 'column', gap: '4px' } },
|
|
1791
|
+
React.createElement('span', { style: { fontSize: '15px', fontWeight: 600, lineHeight: 1.4 } }, translate('features.cardTitle')),
|
|
1792
|
+
React.createElement('span', { style: { fontSize: '13px', lineHeight: 1.5, color: 'var(--dsw-alias-label-tertiary)' } }, translate('features.cardHint'))),
|
|
1793
|
+
React.createElement('svg', {
|
|
1794
|
+
viewBox: '0 0 14 14',
|
|
1795
|
+
width: 14,
|
|
1796
|
+
height: 14,
|
|
1797
|
+
'aria-hidden': 'true',
|
|
1798
|
+
style: { flex: 'none', color: 'var(--dsw-alias-label-tertiary)', transform: open ? 'rotate(180deg)' : 'none', transition: 'transform .16s' },
|
|
1799
|
+
}, React.createElement('path', { d: 'M3 5.25 7 9l4-3.75', fill: 'none', stroke: 'currentColor', strokeWidth: 1.4, strokeLinecap: 'round', strokeLinejoin: 'round' }))),
|
|
1800
|
+
open ? React.createElement('div', { style: { margin: '0 16px', padding: '12px 0 8px', borderTop: '1px solid var(--dsw-alias-border-l2)' } },
|
|
1801
|
+
React.createElement('div', { style: { fontSize: '12px', fontWeight: 700 } }, translate('features.optional')),
|
|
1802
|
+
['modelUsage', 'quotaLookup', 'backupMaintenance', 'taskNotifications'].map(row),
|
|
1803
|
+
React.createElement('div', { style: { fontSize: '12px', fontWeight: 700, marginTop: '8px', paddingTop: '10px', borderTop: '1px solid var(--dsw-alias-border-l1)' } }, translate('features.external')),
|
|
1804
|
+
row('healthz'),
|
|
1805
|
+
!writable ? React.createElement('p', { style: { margin: '6px 0 0', fontSize: '11px', color: 'var(--dsw-alias-label-tertiary)' } }, translate('features.readOnly')) : null) : null)
|
|
1647
1806
|
}
|
|
1648
1807
|
|
|
1649
1808
|
function QuotaSection() {
|
|
@@ -1907,6 +2066,7 @@ window.__ModuleLoader__.load({
|
|
|
1907
2066
|
|
|
1908
2067
|
function ServicePanel() {
|
|
1909
2068
|
const translate = useTranslation()
|
|
2069
|
+
const { value: features } = useFeatures()
|
|
1910
2070
|
const [health, setHealth] = useState(null)
|
|
1911
2071
|
const [healthError, setHealthError] = useState(null)
|
|
1912
2072
|
const [diagnostics, setDiagnostics] = useState(null)
|
|
@@ -1984,6 +2144,7 @@ window.__ModuleLoader__.load({
|
|
|
1984
2144
|
return () => { active = false }
|
|
1985
2145
|
}, [])
|
|
1986
2146
|
useEffect(() => {
|
|
2147
|
+
if (!featureEnabled('modelUsage')) return () => {}
|
|
1987
2148
|
let active = true
|
|
1988
2149
|
ctx.connection.rpc.call('/dsh-service', 'usage', usageRequestPayload).then(async (res) => {
|
|
1989
2150
|
if (!active) return
|
|
@@ -1998,8 +2159,9 @@ window.__ModuleLoader__.load({
|
|
|
1998
2159
|
if (active) setUsageError(translate('usage.error'))
|
|
1999
2160
|
})
|
|
2000
2161
|
return () => { active = false }
|
|
2001
|
-
}, [])
|
|
2162
|
+
}, [features.modelUsage])
|
|
2002
2163
|
useEffect(() => {
|
|
2164
|
+
if (!featureEnabled('backupMaintenance')) return () => {}
|
|
2003
2165
|
let active = true
|
|
2004
2166
|
ctx.connection.rpc.call('/dsh-service', 'backup-list', {}).then((res) => {
|
|
2005
2167
|
if (!active) return
|
|
@@ -2009,7 +2171,7 @@ window.__ModuleLoader__.load({
|
|
|
2009
2171
|
if (active) setBackupError(translate('backup.error'))
|
|
2010
2172
|
})
|
|
2011
2173
|
return () => { active = false }
|
|
2012
|
-
}, [])
|
|
2174
|
+
}, [features.backupMaintenance])
|
|
2013
2175
|
useEffect(() => {
|
|
2014
2176
|
let active = true
|
|
2015
2177
|
let cancelNext = () => {}
|
|
@@ -2760,25 +2922,26 @@ window.__ModuleLoader__.load({
|
|
|
2760
2922
|
}
|
|
2761
2923
|
const tabs = [
|
|
2762
2924
|
['overview', 'tabs.overview'],
|
|
2763
|
-
['notify', 'tabs.notify'],
|
|
2925
|
+
...(features.taskNotifications !== false ? [['notify', 'tabs.notify']] : []),
|
|
2764
2926
|
['health', 'tabs.health'],
|
|
2765
|
-
['usage', 'tabs.usage'],
|
|
2766
|
-
['quota', 'tabs.quota'],
|
|
2767
|
-
['backup', 'tabs.backup'],
|
|
2927
|
+
...(features.modelUsage !== false ? [['usage', 'tabs.usage']] : []),
|
|
2928
|
+
...(features.quotaLookup !== false ? [['quota', 'tabs.quota']] : []),
|
|
2929
|
+
...(features.backupMaintenance !== false ? [['backup', 'tabs.backup']] : []),
|
|
2768
2930
|
['restart', 'tabs.restart'],
|
|
2769
2931
|
]
|
|
2770
2932
|
const warningTabs = tabs.filter(([id]) => tabWarnings[id]).map(([, label]) => translate(label))
|
|
2771
|
-
const
|
|
2933
|
+
const visibleActiveTab = tabs.some(([id]) => id === activeTab) ? activeTab : 'overview'
|
|
2934
|
+
const tabContent = visibleActiveTab === 'overview'
|
|
2772
2935
|
? overviewBlock
|
|
2773
|
-
:
|
|
2936
|
+
: visibleActiveTab === 'notify'
|
|
2774
2937
|
? notifyBlock
|
|
2775
|
-
:
|
|
2938
|
+
: visibleActiveTab === 'health'
|
|
2776
2939
|
? healthBlock
|
|
2777
|
-
:
|
|
2940
|
+
: visibleActiveTab === 'usage'
|
|
2778
2941
|
? usageBlock
|
|
2779
|
-
:
|
|
2942
|
+
: visibleActiveTab === 'quota'
|
|
2780
2943
|
? React.createElement(RemoteQuotaCard, null)
|
|
2781
|
-
:
|
|
2944
|
+
: visibleActiveTab === 'backup'
|
|
2782
2945
|
? maintenanceBlock
|
|
2783
2946
|
: restartBlock
|
|
2784
2947
|
return React.createElement('div', null,
|
|
@@ -2786,7 +2949,7 @@ window.__ModuleLoader__.load({
|
|
|
2786
2949
|
React.createElement('div', { style: { fontSize: '13px', fontWeight: 700 } }, translate('tabs.alert.title')),
|
|
2787
2950
|
React.createElement('div', { style: Object.assign({}, hint, { marginTop: '3px' }) }, translate('tabs.alert.body', { tabs: warningTabs.join('、') }))) : null,
|
|
2788
2951
|
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,
|
|
2952
|
+
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
2953
|
React.createElement('div', { 'data-testid': 'tab-panel', style: tabPanel }, tabContent))
|
|
2791
2954
|
}
|
|
2792
2955
|
|
|
@@ -2815,14 +2978,28 @@ window.__ModuleLoader__.load({
|
|
|
2815
2978
|
},
|
|
2816
2979
|
}, BellIcon(enabled))
|
|
2817
2980
|
}
|
|
2818
|
-
ctx.slots.inject('conversation.input.left', () =>
|
|
2819
|
-
|
|
2820
|
-
() =>
|
|
2821
|
-
|
|
2981
|
+
ctx.slots.inject('conversation.input.left', () => {
|
|
2982
|
+
let dispose = null
|
|
2983
|
+
const sync = () => {
|
|
2984
|
+
if (dispose !== null) { dispose(); dispose = null }
|
|
2985
|
+
if (!featureEnabled('taskNotifications')) return
|
|
2986
|
+
dispose = ctx.slots.register(
|
|
2987
|
+
{ name: 'conversation.input.left', id: 'dsh-service-notify', order: 90, label: () => t('notification.bellOn') },
|
|
2988
|
+
() => React.createElement(InlineNotifyBell, null),
|
|
2989
|
+
)
|
|
2990
|
+
}
|
|
2991
|
+
sync()
|
|
2992
|
+
const unsubscribe = featureScope.subscribe(sync)
|
|
2993
|
+
return () => { unsubscribe(); if (dispose !== null) dispose() }
|
|
2994
|
+
})
|
|
2822
2995
|
ctx.slots.inject('sidebar.footer.action', () => ctx.slots.register(
|
|
2823
2996
|
{ name: 'sidebar.footer.action', id: 'dsh-service-update', order: 90, label: () => t('update.badge') },
|
|
2824
2997
|
() => React.createElement(UpdateBadge, null),
|
|
2825
2998
|
))
|
|
2999
|
+
ctx.slots.inject('settings.plugin.item', () => ctx.slots.register(
|
|
3000
|
+
{ name: 'settings.plugin.item', id: 'dsh-service', key: 'dsh-service', order: 40 },
|
|
3001
|
+
() => React.createElement(FeatureSettingsCard, null),
|
|
3002
|
+
))
|
|
2826
3003
|
ctx.slots.inject('settings.section', () => {
|
|
2827
3004
|
const disposePanel = ctx.slots.register(
|
|
2828
3005
|
{ name: 'settings.section', id: 'dsh-service', order: 99, label: () => t('nav.label') },
|
|
@@ -2831,7 +3008,9 @@ window.__ModuleLoader__.load({
|
|
|
2831
3008
|
// 左列「重启」「额度查询」入口由各自标签内的开关控制,默认不注册
|
|
2832
3009
|
syncRestartNavEntry()
|
|
2833
3010
|
syncQuotaNavEntry()
|
|
3011
|
+
const unsubscribeFeatures = featureScope.subscribe(syncQuotaNavEntry)
|
|
2834
3012
|
return () => {
|
|
3013
|
+
unsubscribeFeatures()
|
|
2835
3014
|
disposePanel()
|
|
2836
3015
|
if (restartNavDispose) {
|
|
2837
3016
|
restartNavDispose()
|
|
@@ -2852,31 +3031,41 @@ window.__ModuleLoader__.load({
|
|
|
2852
3031
|
// (老版本 DSH 没有)。槽位条目无条件注册,服务在条目渲染时(inject(sessionId))
|
|
2853
3032
|
// 经 ctx.get 惰性解析——此时会话已渲染、model-selection 必然已挂载,不受注入时序影响;
|
|
2854
3033
|
// 拿不到服务时 props 为空,QuotaRing 渲染 null 且不启动轮询,其他功能零影响。
|
|
2855
|
-
ctx.slots.inject('conversation.input.right', () =>
|
|
2856
|
-
|
|
2857
|
-
|
|
2858
|
-
|
|
2859
|
-
|
|
2860
|
-
|
|
2861
|
-
|
|
2862
|
-
|
|
2863
|
-
|
|
2864
|
-
|
|
2865
|
-
|
|
2866
|
-
|
|
2867
|
-
|
|
2868
|
-
|
|
2869
|
-
|
|
2870
|
-
|
|
2871
|
-
|
|
2872
|
-
|
|
2873
|
-
|
|
2874
|
-
|
|
2875
|
-
|
|
2876
|
-
|
|
2877
|
-
|
|
2878
|
-
|
|
2879
|
-
|
|
3034
|
+
ctx.slots.inject('conversation.input.right', () => {
|
|
3035
|
+
let dispose = null
|
|
3036
|
+
const sync = () => {
|
|
3037
|
+
if (dispose !== null) { dispose(); dispose = null }
|
|
3038
|
+
if (!featureEnabled('quotaLookup')) return
|
|
3039
|
+
dispose = ctx.slots.register({
|
|
3040
|
+
name: 'conversation.input.right',
|
|
3041
|
+
id: 'dsh-service-quota-ring',
|
|
3042
|
+
order: 95,
|
|
3043
|
+
label: () => t('quota.ring.label'),
|
|
3044
|
+
inject: (sessionId) => {
|
|
3045
|
+
if (sessionId === undefined || sessionId === null) return {}
|
|
3046
|
+
try {
|
|
3047
|
+
const models = getModelDirectories()
|
|
3048
|
+
if (models === undefined || typeof models.directoryFor !== 'function') return {}
|
|
3049
|
+
const directory = models.directoryFor(sessionId)
|
|
3050
|
+
return {
|
|
3051
|
+
directoryStore: directory.store,
|
|
3052
|
+
loadDirectory: () => {
|
|
3053
|
+
try {
|
|
3054
|
+
const pending = directory.load()
|
|
3055
|
+
if (pending && typeof pending.catch === 'function') pending.catch(() => {})
|
|
3056
|
+
} catch (_) {}
|
|
3057
|
+
},
|
|
3058
|
+
}
|
|
3059
|
+
} catch (_) {
|
|
3060
|
+
return {}
|
|
3061
|
+
}
|
|
3062
|
+
},
|
|
3063
|
+
}, (props) => React.createElement(QuotaRing, props))
|
|
3064
|
+
}
|
|
3065
|
+
sync()
|
|
3066
|
+
const unsubscribe = featureScope.subscribe(sync)
|
|
3067
|
+
return () => { unsubscribe(); if (dispose !== null) dispose() }
|
|
3068
|
+
})
|
|
2880
3069
|
}
|
|
2881
3070
|
|
|
2882
3071
|
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
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
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
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
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.
|
|
3
|
+
"version": "0.21.1",
|
|
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"
|