@gehennawu/dsh-service 2.0.7 → 2.0.9

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gehennawu/dsh-service",
3
- "version": "2.0.7",
3
+ "version": "2.0.9",
4
4
  "description": "DSH Web 运维面板:安全重启、健康监控、备份和 Linux 权限维护。",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -25,6 +25,13 @@
25
25
  "quota-routes.js",
26
26
  "subagent-routes.js",
27
27
  "backup-routes.js",
28
+ "model-workbench.js",
29
+ "model-workbench-routes.js",
30
+ "model-workbench-settings.js",
31
+ "model-config-routes.js",
32
+ "provider-availability.js",
33
+ "model-config-schema.js",
34
+ "model-smart-fill.js",
28
35
  "plugin-health.js",
29
36
  "client.js",
30
37
  "cordis.patch.yml",
@@ -52,7 +59,7 @@
52
59
  "homepage": "https://github.com/gehennawu/dsh-service#readme",
53
60
  "engines": {
54
61
  "node": ">=22",
55
- "dsh": ">=0.1.1-rc.2 <0.2.1-alpha.3"
62
+ "dsh": ">=0.1.1-rc.2 <0.2.1-alpha.4"
56
63
  },
57
64
  "dependencies": {
58
65
  "@deepseek-ai/schemastery": "^3.18.3"
@@ -64,7 +71,8 @@
64
71
  "client": {
65
72
  "inject": [
66
73
  "@deepseek-ai/dsh-client-connection",
67
- "@deepseek-ai/dsh-api-remotes"
74
+ "@deepseek-ai/dsh-api-remotes",
75
+ "@deepseek-ai/dsh-client-ui-primitives"
68
76
  ],
69
77
  "platform": "web"
70
78
  }
@@ -0,0 +1,84 @@
1
+ // Profile-owned policy: configuration and credentials never move or disappear.
2
+ export function createProviderAvailability(store) {
3
+ let disabled = new Set()
4
+ let disabledModels = new Set()
5
+ const modelKey = (provider, model) => JSON.stringify([provider, model])
6
+ let ready = false
7
+ const loading = store.load().then((state) => {
8
+ disabled = new Set(Object.entries(state.disabledProviders ?? {}).filter(([, value]) => value === true).map(([id]) => id))
9
+ disabledModels = new Set(Object.entries(state.disabledModels ?? {}).filter(([, value]) => value === true).map(([key]) => key))
10
+ ready = true
11
+ })
12
+ const enabled = (provider) => !disabled.has(provider)
13
+ const modelEnabled = (provider, model) => !disabledModels.has(modelKey(provider, model))
14
+ const assertEnabled = (provider, model) => {
15
+ if (!ready || !enabled(provider) || model !== undefined && !modelEnabled(provider, model)) {
16
+ const code = !ready ? 'provider-availability-loading' : !enabled(provider) ? 'provider-disabled' : 'model-disabled'
17
+ const error = new Error(code)
18
+ error.code = code.toUpperCase().replaceAll('-', '_')
19
+ throw error
20
+ }
21
+ }
22
+ let changes = Promise.resolve()
23
+ const set = (provider, value, expected) => {
24
+ const result = changes.then(async () => {
25
+ await loading
26
+ if (enabled(provider) !== expected) throw new Error('conflict')
27
+ await store.mutate((state) => {
28
+ state.disabledProviders ??= {}
29
+ if (value) delete state.disabledProviders[provider]
30
+ else Object.defineProperty(state.disabledProviders, provider, { value: true, enumerable: true, configurable: true, writable: true })
31
+ })
32
+ if (value) disabled.delete(provider); else disabled.add(provider)
33
+ return { route: provider, enabled: value }
34
+ })
35
+ changes = result.catch(() => {})
36
+ return result
37
+ }
38
+ const setModel = (provider, model, value, expected) => {
39
+ const result = changes.then(async () => {
40
+ await loading
41
+ if (modelEnabled(provider, model) !== expected) throw new Error('conflict')
42
+ const key = modelKey(provider, model)
43
+ await store.mutate((state) => {
44
+ state.disabledModels ??= {}
45
+ if (value) delete state.disabledModels[key]
46
+ else Object.defineProperty(state.disabledModels, key, { value: true, enumerable: true, configurable: true, writable: true })
47
+ })
48
+ if (value) disabledModels.delete(key); else disabledModels.add(key)
49
+ return { route: provider, modelId: model, enabled: value }
50
+ })
51
+ changes = result.catch(() => {})
52
+ return result
53
+ }
54
+ return { loading, enabled, modelEnabled, assertEnabled, set, setModel }
55
+ }
56
+
57
+ // Filter the shared catalog and guard exact routing, including inherited routes.
58
+ // Keep every original method and restore it when the owning Fiber is disposed.
59
+ export function installProviderAvailability(llm, policy, notify = () => {}) {
60
+ const originals = new Map()
61
+ const wrappers = new Map()
62
+ const wrap = (key, factory) => {
63
+ if (typeof llm[key] !== 'function') return
64
+ const original = llm[key]
65
+ const wrapper = factory(original.bind(llm))
66
+ originals.set(key, original); wrappers.set(key, wrapper); llm[key] = wrapper
67
+ }
68
+ wrap('listProviders', (original) => () => original().filter((row) => policy.enabled(row.id)))
69
+ wrap('listModels', (original) => async (provider, ...args) => { await policy.loading; return policy.enabled(provider) ? (await original(provider, ...args)).filter((row) => policy.modelEnabled(provider, row.id)) : [] })
70
+ wrap('resolveModelInfo', (original) => async (provider, ...args) => { await policy.loading; policy.assertEnabled(provider, args[0]); return original(provider, ...args) })
71
+ wrap('resolveCallConfig', (original) => async (config, ...args) => { await policy.loading; policy.assertEnabled(config.provider, config.model); return original(config, ...args) })
72
+ wrap('prepareCall', (original) => async (config, ...args) => {
73
+ await policy.loading; policy.assertEnabled(config.provider, config.model)
74
+ const prepared = await original(config, ...args)
75
+ return { ...prepared, stream: async function* (options) {
76
+ policy.assertEnabled(config.provider, config.model)
77
+ yield* prepared.stream(options)
78
+ } }
79
+ })
80
+ wrap('stream', (original) => async function* (options) { await policy.loading; policy.assertEnabled(options.provider, options.model); yield* original(options) })
81
+ let disposed = false
82
+ policy.loading.then(() => { if (!disposed) notify() }, () => {})
83
+ return () => { disposed = true; for (const [key, original] of originals) if (llm[key] === wrappers.get(key)) llm[key] = original; notify() }
84
+ }
package/quota-adapters.js CHANGED
@@ -1270,6 +1270,37 @@ async function fetchCliproxyUsage({ profile, config, credential, signal, request
1270
1270
  return windows
1271
1271
  }
1272
1272
 
1273
+ // Non-monetary points retain decimal strings instead of implying a currency or quota denominator.
1274
+ function normalizePoePoints(payload) {
1275
+ const raw = payload?.current_point_balance
1276
+ const text = typeof raw === 'string' ? raw : typeof raw === 'number' && Number.isFinite(raw) ? String(raw) : ''
1277
+ if (text.length > 64 || !/^-?\d+(?:\.\d+)?$/.test(text)) return { windows: [] }
1278
+ return { windows: [{ id: 'points', kindKey: 'points', text: `${text} points`, unit: 'points' }] }
1279
+ }
1280
+
1281
+ function normalizeVercelCredits(payload) {
1282
+ // The API declares USD decimal strings. Lifetime spend is not a quota denominator.
1283
+ const balance = payload?.balance
1284
+ if (typeof balance !== 'string' || balance.length > 64 || !/^\d+(?:\.\d+)?$/.test(balance)
1285
+ || !Number.isFinite(Number(balance)) || Number(balance) > Number.MAX_SAFE_INTEGER) return { windows: [] }
1286
+ return { windows: [{ id: 'balance', kindKey: 'balance', text: `$${balance}`, unit: 'usd' }] }
1287
+ }
1288
+
1289
+ function normalizeClinePassUsage(payload) {
1290
+ if (payload?.success !== true || !Array.isArray(payload?.data?.limits)) return { windows: [] }
1291
+ const windows = []
1292
+ for (const [type, id] of [['five_hour', 'five-hour'], ['weekly', 'weekly'], ['monthly', 'monthly']]) {
1293
+ const matches = payload.data.limits.filter((entry) => entry?.type === type)
1294
+ if (matches.length > 1) return { windows: [] }
1295
+ const entry = matches[0]
1296
+ if (typeof entry?.percentUsed !== 'number' || !Number.isFinite(entry.percentUsed) || entry.percentUsed < 0) continue
1297
+ const resetsAt = typeof entry.resetsAt === 'string' && /^\d{4}-\d{2}-\d{2}T.*(?:Z|[+-]\d{2}:\d{2})$/.test(entry.resetsAt)
1298
+ ? normalizeResetTimestamp(entry.resetsAt) : undefined
1299
+ windows.push({ id, percent: Math.min(100, Math.round(entry.percentUsed)), ...(resetsAt !== undefined ? { resetsAt } : {}) })
1300
+ }
1301
+ return { windows }
1302
+ }
1303
+
1273
1304
  function createQuotaAdapterCatalog() {
1274
1305
  return Object.freeze([
1275
1306
  createEndpointAdapter({
@@ -1330,6 +1361,30 @@ function createQuotaAdapterCatalog() {
1330
1361
  keyHints: ['SILICONFLOW_API_KEY'],
1331
1362
  hosts: ['siliconflow.cn'],
1332
1363
  }),
1364
+ createEndpointAdapter({
1365
+ kind: 'vercel-ai-gateway',
1366
+ normalize: normalizeVercelCredits,
1367
+ endpoints: ['https://ai-gateway.vercel.sh/v1/credits'],
1368
+ keyHints: ['AI_GATEWAY_API_KEY'],
1369
+ hosts: ['ai-gateway.vercel.sh'],
1370
+ routeIds: ['vercel-ai-gateway'],
1371
+ }),
1372
+ createEndpointAdapter({
1373
+ kind: 'poe',
1374
+ normalize: normalizePoePoints,
1375
+ endpoints: ['https://api.poe.com/usage/current_balance'],
1376
+ keyHints: ['POE_API_KEY'],
1377
+ hosts: ['api.poe.com'],
1378
+ routeIds: ['poe'],
1379
+ }),
1380
+ createEndpointAdapter({
1381
+ kind: 'clinepass',
1382
+ normalize: normalizeClinePassUsage,
1383
+ endpoints: ['https://api.cline.bot/api/v1/users/me/plan/usage-limits'],
1384
+ keyHints: ['CLINEPASS_API_KEY'],
1385
+ hosts: ['api.cline.bot'],
1386
+ routeIds: ['clinepass'],
1387
+ }),
1333
1388
  createEndpointAdapter({
1334
1389
  kind: 'deepseek',
1335
1390
  normalize: normalizeDeepseekBalance,
@@ -16,9 +16,10 @@ README 使用的中英文界面截图。文件名与对应功能如下:
16
16
  | `session-manager.png` | `session-manager_en.png` | 会话管理 |
17
17
  | `mobile-adaptation.png` | `mobile-adaptation_en.png` | 移动端适配 |
18
18
  | `plugin-config.png` | `plugin-config_en.png` | 插件配置 |
19
- | `maintenance.png` | `maintenance_en.png` | 六页导航的「维护」聚合页 |
20
- | `configuration.png` | `configuration_en.png` | 六页导航的「配置」聚合页 |
19
+ | `maintenance.png` | `maintenance_en.png` | 维护聚合页 |
20
+ | `configuration.png` | `configuration_en.png` | 配置聚合页 |
21
+ | `model-configuration.png` | `model-configuration_en.png` | 模型配置:渠道与模型 |
21
22
 
22
23
  截图宽度建议 800–1200px,PNG 格式。README 新增截图引用时,必须同时加入本目录并通过 `test/docs-static.test.js`;本目录由 `package.json#files` 打入 npm 包。
23
24
 
24
- 本目录截图已按 v0.40 六页信息架构更新。后续替换时保持现有文件名不变,并确保截图不暴露 API key、Cookie、Oasis-Token、管理密钥、会话正文或其他凭据/隐私数据。
25
+ 后续替换保持现有文件名,并确保截图不暴露 API key、Cookie、Oasis-Token、管理密钥、会话正文或其他凭据/隐私数据。
Binary file
Binary file
Binary file
Binary file
Binary file
@@ -17,11 +17,13 @@ export function createSubagentRoutes({
17
17
  listSubagentDispatches,
18
18
  listSubagentModels,
19
19
  rpcTechnicalFailure,
20
+ providerAvailability,
20
21
  }) {
21
22
  return {
22
23
  'subagent-route': { feature: 'subagentRoute', handle: async (payload, rpcEndpoint) => {
23
24
  try {
24
25
  await subagentRouteLoadPromise
26
+ if (providerAvailability) await providerAvailability.loading
25
27
  const llm = ctx.get('llm')
26
28
  // 模型清单沿用 skills-models 的白名单口径(llm.listProviders × listModels,单渠道失败跳过),
27
29
  // 并对每个精确模型附加 adapter 的 reasoning metadata(resolveModelInfo 缺席时保留原目录项);
@@ -34,6 +36,24 @@ export function createSubagentRoutes({
34
36
  current = catalog.current
35
37
  }
36
38
  const config = subagentRouteRef.current
39
+ // 独立 Agent 预设(v2.0.9):清单来自宿主 agentPresets 服务(0.2.1-alpha.3+;旧宿主缺席
40
+ // ⇒ 空清单 + presetSupported:false,客户端禁用选择器)。broken 的声明照常下发但带标记,
41
+ // 保存端拒绝选中它。
42
+ let presets = []
43
+ let presetSupported = false
44
+ const presetRegistry = ctx.get('agentPresets')
45
+ if (presetRegistry !== undefined && presetRegistry !== null && typeof presetRegistry.list === 'function') {
46
+ presetSupported = true
47
+ try {
48
+ const declared = await presetRegistry.list()
49
+ presets = (Array.isArray(declared) ? declared : [])
50
+ .filter((entry) => entry !== null && typeof entry === 'object' && typeof entry.id === 'string' && entry.id !== '')
51
+ .map((entry) => ({ id: entry.id, ...(typeof entry.name === 'string' && entry.name !== '' ? { name: entry.name } : {}), ...(typeof entry.broken === 'string' && entry.broken !== '' ? { broken: entry.broken } : {}) }))
52
+ .sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0)
53
+ } catch (_) {
54
+ presets = []
55
+ }
56
+ }
37
57
  // 自定义路由草稿(v1.4.12):三模式统一下发——客户端据此回填表单,切换模式不丢配置。
38
58
  const routePresent = typeof config.provider === 'string' && config.provider !== '' && typeof config.model === 'string' && config.model !== ''
39
59
  return {
@@ -47,6 +67,9 @@ export function createSubagentRoutes({
47
67
  ...(typeof config.reasoningEffort === 'string' && config.reasoningEffort !== '' ? { reasoningEffort: config.reasoningEffort } : {}),
48
68
  } : {}),
49
69
  ...(Array.isArray(config.fallbacks) && config.fallbacks.length > 0 ? { fallbacks: config.fallbacks } : {}),
70
+ ...(typeof config.preset === 'string' && config.preset !== '' ? { preset: config.preset } : {}),
71
+ presetSupported,
72
+ presets,
50
73
  models,
51
74
  ...(current !== undefined ? { current } : {}),
52
75
  },
@@ -127,7 +150,42 @@ export function createSubagentRoutes({
127
150
  fallbacks.push(normalized)
128
151
  }
129
152
  }
153
+ // 独立 Agent 预设(v2.0.9):与模式无关,payload 携带 `preset` 键才生效(缺省 = 保留原值,
154
+ // 与 fallbacks 的草稿语义一致);空串 = 清除(跟随父代)。非空值必须在 agentPresets 清单内
155
+ // 且未 broken——宿主在派生端还会按 provider 能力二次把关,这里只挡「明显无效」的引用。
156
+ let preset
157
+ if (Object.prototype.hasOwnProperty.call(payload ?? {}, 'preset')) {
158
+ const rawPreset = payload.preset
159
+ if (rawPreset === undefined || rawPreset === null || rawPreset === '') {
160
+ preset = ''
161
+ } else if (typeof rawPreset !== 'string') {
162
+ return { ok: false, error: 'invalid-preset' }
163
+ } else {
164
+ const candidate = rawPreset.trim().slice(0, MAX_SUBAGENT_ROUTE_FIELD)
165
+ if (candidate === '') {
166
+ preset = ''
167
+ } else {
168
+ const registry = ctx.get('agentPresets')
169
+ if (registry === undefined || registry === null || typeof registry.list !== 'function') return { ok: false, error: 'preset-unavailable' }
170
+ let declared = []
171
+ try {
172
+ declared = await registry.list()
173
+ } catch (_) {
174
+ declared = []
175
+ }
176
+ const hit = (Array.isArray(declared) ? declared : []).find((entry) => entry !== null && typeof entry === 'object' && entry.id === candidate)
177
+ if (hit === undefined) return { ok: false, error: 'invalid-preset' }
178
+ if (typeof hit.broken === 'string' && hit.broken !== '') return { ok: false, error: 'invalid-preset' }
179
+ preset = candidate
180
+ }
181
+ }
182
+ }
130
183
  return await serializeSubagentRouteWrite(async (config) => {
184
+ if (providerAvailability) {
185
+ await providerAvailability.loading
186
+ if (primary.provider) providerAvailability.assertEnabled(primary.provider, primary.model)
187
+ for (const fallback of fallbacks) providerAvailability.assertEnabled(fallback.provider, fallback.model)
188
+ }
131
189
  config.mode = mode
132
190
  if (mode === 'custom') {
133
191
  config.provider = primary.provider
@@ -145,12 +203,19 @@ export function createSubagentRoutes({
145
203
  if (fallbacks.length > 0) config.fallbacks = fallbacks
146
204
  else delete config.fallbacks
147
205
  }
206
+ // 预设:与模式无关;只有本轮 payload 携带了 `preset` 键才写/清。空值一律不落盘
207
+ // (磁盘形状与不认识该字段的旧版本逐字节兼容;注入端 absent = 跟随父代)。
208
+ if (preset !== undefined) {
209
+ if (preset === '') delete config.preset
210
+ else config.preset = preset
211
+ }
212
+ if (config.preset === '') delete config.preset
148
213
  const routePresent = typeof config.provider === 'string' && config.provider !== '' && typeof config.model === 'string' && config.model !== ''
149
214
  return { value: { ok: true, mode: config.mode, ...(routePresent ? {
150
215
  provider: config.provider,
151
216
  model: config.model,
152
217
  ...(typeof config.reasoningEffort === 'string' && config.reasoningEffort !== '' ? { reasoningEffort: config.reasoningEffort } : {}),
153
- } : {}), ...(Array.isArray(config.fallbacks) && config.fallbacks.length > 0 ? { fallbacks: config.fallbacks } : {}) } }
218
+ } : {}), ...(Array.isArray(config.fallbacks) && config.fallbacks.length > 0 ? { fallbacks: config.fallbacks } : {}), ...(typeof config.preset === 'string' && config.preset !== '' ? { preset: config.preset } : {}) } }
154
219
  })
155
220
  } catch (error) {
156
221
  return rpcTechnicalFailure(error)