dsh-paoding 0.4.4 → 0.4.6
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.md +5 -4
- package/README_en.md +5 -4
- package/bin/dsh-paoding.mjs +22 -10
- package/docs/architecture.md +6 -5
- package/docs/architecture_en.md +8 -7
- package/docs/configuration.md +9 -1
- package/docs/configuration_en.md +9 -1
- package/docs/installation.md +17 -7
- package/docs/installation_en.md +14 -6
- package/docs/orchestration.md +2 -2
- package/docs/orchestration_en.md +2 -2
- package/package.json +3 -2
- package/plugins/paoding-config-ui/api-core.mjs +171 -2
- package/plugins/paoding-config-ui/icon.svg +8 -0
- package/plugins/paoding-config-ui/index.mjs +88 -6
- package/plugins/paoding-config-ui/lib/base.css +244 -0
- package/plugins/paoding-config-ui/lib/client.js +517 -46
- package/plugins/paoding-config-ui/package.json +7 -2
- package/plugins/paoding-config-ui/runtime-inventory.mjs +22 -0
- package/plugins/paoding-config-ui/stats-core.mjs +362 -0
- package/presets/orchestrator/agent.cordis.yml +2 -1
- package/presets/orchestrator/restrict.mjs +1 -1
- package/tools/lib/cli.mjs +298 -8
- package/tools/lib/config.mjs +117 -0
- package/tools/lib/doctor.mjs +3 -13
- package/tools/lib/lifecycle.mjs +264 -0
|
@@ -8,14 +8,15 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import os from 'node:os'
|
|
10
10
|
import path from 'node:path'
|
|
11
|
-
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
|
11
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
|
12
12
|
import { readFile } from 'node:fs/promises'
|
|
13
13
|
import { assignSlugs, collectState, generateAndInstall, presetIdOf } from '../../tools/install.mjs'
|
|
14
14
|
import { autoAssignments, baseAssignments } from '../../tools/lib/alloc.mjs'
|
|
15
|
+
import { computeRestrictAllow } from '../../tools/lib/compose.mjs'
|
|
15
16
|
import { loadConfig, normalizeConfig, normalizeTargetFields, saveConfig, serializeConfig, stripNonTargetKeys } from '../../tools/lib/config.mjs'
|
|
16
17
|
import { parseYamlSubset, preprocessPatchText } from '../../tools/lib/yaml.mjs'
|
|
17
18
|
import { captureWarnings, healthCheck } from '../../tools/lib/health.mjs'
|
|
18
|
-
import { warn } from '../../tools/lib/util.mjs'
|
|
19
|
+
import { ROLES, warn } from '../../tools/lib/util.mjs'
|
|
19
20
|
import {
|
|
20
21
|
backfillHomePatchRows,
|
|
21
22
|
detectAgentRolesFlavor,
|
|
@@ -288,10 +289,178 @@ export async function importConfigContent(content) {
|
|
|
288
289
|
error.statusCode = 422
|
|
289
290
|
throw error
|
|
290
291
|
}
|
|
292
|
+
// 写盘前自动轮换备份(当前配置推入 bak-1,旧 bak 依次后移;首次保存无
|
|
293
|
+
// 「当前」则不备份)——apply / 工作区预设写盘 / 回滚同一口径,见下方备份节。
|
|
294
|
+
rotateConfigBackups()
|
|
291
295
|
saveConfig(resolveConfigFile(), normalizeConfig(data))
|
|
292
296
|
return { ok: true }
|
|
293
297
|
}
|
|
294
298
|
|
|
299
|
+
// ── 配置自动备份与回滚(配置文件写路径的护栏,页头「更多」菜单服务端核心)──────
|
|
300
|
+
//
|
|
301
|
+
// 语义:凡面板写配置(apply / import / 工作区预设写盘 / 回滚本身)在落盘前先把
|
|
302
|
+
// 当前配置推入备份链:当前 → bak-1(最新),旧 bak 依次后移(bak-1→bak-2、
|
|
303
|
+
// bak-2→bak-3),bak-3(最老)淘汰。首次保存(配置文件尚不存在)没有「当前」
|
|
304
|
+
// 可备份,不产生任何 bak 文件。备份名 = 配置文件名 + .bak-N(默认配置即
|
|
305
|
+
// dsh-paoding.config.yml.bak-1/2/3),自定义配置名(DSH_PAODING_CONFIG)随之
|
|
306
|
+
// 派生,清单与还原按同一派生口径校验。
|
|
307
|
+
|
|
308
|
+
/** 备份名校验用正则:配置文件名转义后拼 .bak-N 后缀(N = 1..3 层)。 */
|
|
309
|
+
function configBackupRe() {
|
|
310
|
+
const stem = path.basename(resolveConfigFile()).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
311
|
+
return new RegExp(`^${stem}\\.bak-([123])$`)
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** 第 n 层备份的绝对路径(与配置文件同目录,文件名恒为单层 basename)。 */
|
|
315
|
+
function configBackupPath(n) {
|
|
316
|
+
return `${resolveConfigFile()}.bak-${n}`
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* 备份轮换:把当前配置推入 bak-1,旧 bak 依次后移,bak-3 淘汰。当前配置不
|
|
321
|
+
* 存在(首次保存)时不写任何文件、返回 false;轮换完成返回 true。当前文件
|
|
322
|
+
* 用拷贝而非改名进 bak-1——原地保留,读侧(生成管线 / 回滚校验)随时可读;
|
|
323
|
+
* 后移用改名,同一目录内 rename 是原子操作,中断也不产生半个备份。
|
|
324
|
+
*/
|
|
325
|
+
export function rotateConfigBackups() {
|
|
326
|
+
const config = resolveConfigFile()
|
|
327
|
+
if (!existsSync(config)) return false
|
|
328
|
+
rmSync(configBackupPath(3), { force: true }) // 最老一层直接淘汰(不存在也无妨)
|
|
329
|
+
for (const [from, to] of [[2, 3], [1, 2]]) {
|
|
330
|
+
try {
|
|
331
|
+
renameSync(configBackupPath(from), configBackupPath(to))
|
|
332
|
+
} catch (err) {
|
|
333
|
+
if (err.code !== 'ENOENT') throw err // 该层尚无备份:正常,跳过
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
copyFileSync(config, configBackupPath(1))
|
|
337
|
+
return true
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* 备份清单(GET config/backups):{ file, mtimeMs, size }[],按新→旧排序
|
|
342
|
+
*(mtime 降序,同刻并列时层号小者在前)。只读不写;配置目录读不了 / 无备份
|
|
343
|
+
* 一律返回空数组,绝不让清单页 5xx。file 恒为 basename(还原路由只认配置
|
|
344
|
+
* 目录内的备份名,回传它即可防路径穿越)。
|
|
345
|
+
*/
|
|
346
|
+
export function listConfigBackups() {
|
|
347
|
+
let names = []
|
|
348
|
+
try {
|
|
349
|
+
names = readdirSync(path.dirname(resolveConfigFile()))
|
|
350
|
+
} catch {
|
|
351
|
+
return [] // 目录不存在 / 不可读 = 无备份可言
|
|
352
|
+
}
|
|
353
|
+
const backupRe = configBackupRe()
|
|
354
|
+
return names
|
|
355
|
+
.map((name) => {
|
|
356
|
+
const m = backupRe.exec(name)
|
|
357
|
+
if (!m) return null
|
|
358
|
+
try {
|
|
359
|
+
const st = statSync(path.join(path.dirname(resolveConfigFile()), name))
|
|
360
|
+
return { file: name, mtimeMs: st.mtimeMs, size: st.size, layer: Number(m[1]) }
|
|
361
|
+
} catch {
|
|
362
|
+
return null // 列举途中被删 / stat 失败:跳过该条
|
|
363
|
+
}
|
|
364
|
+
})
|
|
365
|
+
.filter(Boolean)
|
|
366
|
+
.sort((a, b) => b.mtimeMs - a.mtimeMs || a.layer - b.layer)
|
|
367
|
+
.map(({ file, mtimeMs, size }) => ({ file, mtimeMs, size }))
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* 回滚配置(POST config/restore):file 须精确匹配「配置文件名.bak-1/2/3」,
|
|
372
|
+
* 显式拒任何含分隔符 / 上跳段的名字(防路径穿越);通过后先按 loadConfig 同一
|
|
373
|
+
* 口径验证备份可解析(坏备份拒绝覆盖,422;备份不存在 404——statusCode 由
|
|
374
|
+
* 路由层外层 catch 按其回状态码 + { error } 信封),再把「当前配置」按同款
|
|
375
|
+
* 轮换推入备份链(滚错了还能再滚回来),最后拷贝备份为当前。落盘即改 mtime,
|
|
376
|
+
* state 缓存靠既有对账自动失效,无需手动清理。
|
|
377
|
+
*/
|
|
378
|
+
export async function restoreConfigBackup(file) {
|
|
379
|
+
const name = typeof file === 'string' ? file : ''
|
|
380
|
+
if (name === '' || name.includes('/') || name.includes('..') || !configBackupRe().test(name)) {
|
|
381
|
+
const error = new Error(`备份文件名不合法(须为 ${path.basename(resolveConfigFile())}.bak-1/2/3):${name === '' ? '(空)' : name}`)
|
|
382
|
+
error.statusCode = 422
|
|
383
|
+
throw error
|
|
384
|
+
}
|
|
385
|
+
const backupPath = path.join(path.dirname(resolveConfigFile()), name)
|
|
386
|
+
// 宿主 yaml 模块从 state 顺路取(collectState 已探测好);state 通道异常时
|
|
387
|
+
// 退化为仅内置解析器校验,与 importConfigContent 同款降级——宁严勿漏。
|
|
388
|
+
let yamlMod = null
|
|
389
|
+
try {
|
|
390
|
+
yamlMod = (await getState())?.yamlMod ?? null
|
|
391
|
+
} catch { /* 校验口径降级,见上 */ }
|
|
392
|
+
let parsed = null
|
|
393
|
+
try {
|
|
394
|
+
parsed = loadConfig(backupPath, yamlMod)
|
|
395
|
+
} catch (err) {
|
|
396
|
+
const error = new Error(`备份无法解析为合法配置,已拒绝回滚:${err.message}`)
|
|
397
|
+
error.statusCode = 422
|
|
398
|
+
throw error
|
|
399
|
+
}
|
|
400
|
+
if (parsed === null) {
|
|
401
|
+
const error = new Error(`备份不存在:${name}`)
|
|
402
|
+
error.statusCode = 404
|
|
403
|
+
throw error
|
|
404
|
+
}
|
|
405
|
+
// 先把选定备份的内容读进内存再轮换:轮换会把整条备份链后移一层,固定层号
|
|
406
|
+
// (bak-N)路径下的内容随之改变——后读的话落到「当前」的就不是用户选的
|
|
407
|
+
// 那份快照了。配置文件很小,整读零成本,顺序必须遵守。
|
|
408
|
+
const content = readFileSync(backupPath)
|
|
409
|
+
// 回滚本身也是一次写配置:先把「当前」按同款轮换推入备份链(滚错了还能
|
|
410
|
+
// 再滚回来),再落选定快照。mtime 变化令 state 缓存按既有对账自动失效,
|
|
411
|
+
// 无需手动清理。
|
|
412
|
+
rotateConfigBackups()
|
|
413
|
+
writeFileSync(resolveConfigFile(), content, { mode: 0o600 }) // 权限口径与 saveConfig 一致
|
|
414
|
+
return { ok: true }
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// ── 工具面瘦身统计(slim:state / preview / apply 响应的派生字段)─────────────
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* 主 agent 工具面瘦身统计,口径与生成管线严格同源(composeGenerated):
|
|
421
|
+
* - total = 可用工具全集 = presetUniverse ∪ inventory 的名字数。presetUniverse
|
|
422
|
+
* = restrict.mjs 主 agent 白名单(restrictBase)∪ 源 preset 各角色 allow
|
|
423
|
+
* 自带面;inventory = 检测到的可用 MCP / 插件工具名。这正是单 agent 模式下
|
|
424
|
+
* 主 agent 名下可能挂着的一切工具(computeRestrictAllow / usableWith 判定
|
|
425
|
+
* 用的同一候选全集),即「不拆角色时的完整工具面」;
|
|
426
|
+
* - main = 当前 assignments 下主 agent 的有效 allow 计数:computeRestrictAllow
|
|
427
|
+
* (base ∪ extra ∪ 自定义角色名 − main_agent_remove − roles_remove,过可用
|
|
428
|
+
* 性过滤)的结果长度;结果与 base 全同时该函数返回 null,按 base 计数。
|
|
429
|
+
* state 收原始通道(blocks 为 Map、inventory 为 Set)与序列化视图(blocks 为
|
|
430
|
+
* 普通对象、inventory 为数组)两态,一律只读。assignments 先剥 profile /
|
|
431
|
+
* workspaces、过 normalizeTargetFields 再算——手写未规范化的提交也得到与
|
|
432
|
+
* 落盘一致的口径。
|
|
433
|
+
*/
|
|
434
|
+
export function slimStatsOf(state, assignments) {
|
|
435
|
+
const restrictBase = Array.isArray(state?.restrictBase) ? state.restrictBase : []
|
|
436
|
+
// blocks 两种形态:原始 state 的 Map 值带 names 数组;序列化视图的值即名字数组
|
|
437
|
+
const blockValues = state?.blocks instanceof Map
|
|
438
|
+
? [...state.blocks.values()]
|
|
439
|
+
: (state?.blocks && typeof state.blocks === 'object' ? Object.values(state.blocks) : [])
|
|
440
|
+
const presetUniverse = new Set(restrictBase)
|
|
441
|
+
for (const block of blockValues) {
|
|
442
|
+
for (const name of Array.isArray(block?.names) ? block.names : Array.isArray(block) ? block : []) {
|
|
443
|
+
presetUniverse.add(name)
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
const inventory = state?.inventory instanceof Set
|
|
447
|
+
? state.inventory
|
|
448
|
+
: new Set(Array.isArray(state?.inventory) ? state.inventory : [])
|
|
449
|
+
const total = new Set([...presetUniverse, ...inventory]).size
|
|
450
|
+
const target = normalizeTargetFields(stripNonTargetKeys(assignments ?? {}))
|
|
451
|
+
const customToolNames = Object.keys(target.roles).filter((n) => !ROLES.includes(n))
|
|
452
|
+
const allow = computeRestrictAllow(
|
|
453
|
+
restrictBase,
|
|
454
|
+
presetUniverse,
|
|
455
|
+
inventory,
|
|
456
|
+
target.main_agent_extra,
|
|
457
|
+
target.main_agent_remove,
|
|
458
|
+
customToolNames,
|
|
459
|
+
target.roles_remove,
|
|
460
|
+
) ?? restrictBase
|
|
461
|
+
return { total, main: allow.length }
|
|
462
|
+
}
|
|
463
|
+
|
|
295
464
|
// ── 预设 CRUD(presets 路由族的服务端核心)──────────────────────────────────
|
|
296
465
|
//
|
|
297
466
|
// 面板 apply 只覆盖「全局 / 单个工作区视角的生成」,这里补齐预设对象本身的
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
|
2
|
+
<rect width="64" height="64" rx="14" fill="#4D6BFE"/>
|
|
3
|
+
<g transform="rotate(-45 32 32)">
|
|
4
|
+
<rect x="14" y="11" width="32" height="22" rx="3" fill="#FFFFFF"/>
|
|
5
|
+
<circle cx="41" cy="16.5" r="2" fill="#4D6BFE"/>
|
|
6
|
+
<rect x="44" y="17" width="13" height="10" rx="4" fill="#FFFFFF" opacity="0.85"/>
|
|
7
|
+
</g>
|
|
8
|
+
</svg>
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
*
|
|
4
4
|
* 把 dsh-paoding 可视化配置器整合进 DSH Web GUI(左侧栏全页配置,无独立服务端):
|
|
5
5
|
* - Node 侧:注册 /api/paoding/* 前缀路由(state/models/workspaces/presets/
|
|
6
|
-
* version/upgrade/preview/apply/rescan/config/export/config/import
|
|
6
|
+
* version/upgrade/preview/apply/rescan/config/export/config/import/
|
|
7
|
+
* config/backups/config/restore +
|
|
7
8
|
* client.css 静态样式),经
|
|
8
9
|
* ./api-core.mjs 复用 tools/install.mjs 的检测与生成管线;workspaces 直连
|
|
9
10
|
* 宿主 workspaceRegistry 服务(list / create,幂等);presets 提供预设
|
|
@@ -39,10 +40,12 @@ import { readFile } from 'node:fs/promises'
|
|
|
39
40
|
import os from 'node:os'
|
|
40
41
|
import path from 'node:path'
|
|
41
42
|
import { fileURLToPath } from 'node:url'
|
|
42
|
-
import { getState, refreshState, isStateCacheStale, serializeState, installAssignments, resolveDshHome, discoverWorkspaceCandidates, ensurePresetInstalled, pluginVersion, exportConfigPayload, importConfigContent, listPresets, resolveWorkspacePathByPresetId, deleteWorkspacePreset } from './api-core.mjs'
|
|
43
|
+
import { getState, refreshState, isStateCacheStale, serializeState, installAssignments, resolveDshHome, discoverWorkspaceCandidates, ensurePresetInstalled, pluginVersion, exportConfigPayload, importConfigContent, listConfigBackups, restoreConfigBackup, rotateConfigBackups, slimStatsOf, listPresets, resolveWorkspacePathByPresetId, deleteWorkspacePreset } from './api-core.mjs'
|
|
43
44
|
import { collectModelRefs } from '../../tools/lib/health.mjs'
|
|
45
|
+
import { diffAssignments, loadConfig } from '../../tools/lib/config.mjs'
|
|
44
46
|
import { detectSkills } from '../../tools/lib/skills.mjs'
|
|
45
|
-
import { collectRuntimeFacts } from './runtime-inventory.mjs'
|
|
47
|
+
import { collectRuntimeFacts, detectHmrOf } from './runtime-inventory.mjs'
|
|
48
|
+
import { collectDelegationStats } from './stats-core.mjs'
|
|
46
49
|
import { getVersionInfo } from '../../tools/lib/version.mjs'
|
|
47
50
|
import { runUpgrade } from '../../tools/lib/upgrade.mjs'
|
|
48
51
|
|
|
@@ -216,9 +219,17 @@ function persistenceAvailableOf(ctx) {
|
|
|
216
219
|
}
|
|
217
220
|
}
|
|
218
221
|
|
|
219
|
-
/** 面板 state 响应统一挂 persistenceAvailable(state / rescan 同一形状)。
|
|
222
|
+
/** 面板 state 响应统一挂 persistenceAvailable(state / rescan 同一形状)。
|
|
223
|
+
* slim:工具面瘦身统计(total = 可用工具全集名数、main = 已保存配置下主 agent
|
|
224
|
+
* 有效 allow 计数),按磁盘已保存配置算——state 是序列化视图(blocks 普通对象 /
|
|
225
|
+
* inventory 数组),slimStatsOf 两态通吃;无配置文件时 existing 为 null,按空
|
|
226
|
+
* 目标字段算(main = restrict.mjs 基准白名单长度)。 */
|
|
220
227
|
function stateResponse(ctx, state) {
|
|
221
|
-
return {
|
|
228
|
+
return {
|
|
229
|
+
...state,
|
|
230
|
+
persistenceAvailable: persistenceAvailableOf(ctx),
|
|
231
|
+
slim: slimStatsOf(state, state.existing ?? {}),
|
|
232
|
+
}
|
|
222
233
|
}
|
|
223
234
|
|
|
224
235
|
// ── state 读取共享 helper(「采集运行时事实 → 重刷 → 读缓存 →〔按需〕序列化」)──
|
|
@@ -322,6 +333,9 @@ async function writeWorkspacePreset(ctx, res, { workspacePath, assignments, with
|
|
|
322
333
|
}
|
|
323
334
|
}
|
|
324
335
|
try {
|
|
336
|
+
// 写盘前自动轮换备份(当前配置推入 bak-1;首次保存无「当前」则不备份),
|
|
337
|
+
// 与 apply / import / 回滚同一口径——工作区预设写盘同样是配置写路径。
|
|
338
|
+
rotateConfigBackups()
|
|
325
339
|
const result = installAssignments(s, assignments, {
|
|
326
340
|
dryRun: false,
|
|
327
341
|
saveConfigOnWrite: true,
|
|
@@ -436,6 +450,23 @@ export async function apply(ctx) {
|
|
|
436
450
|
return
|
|
437
451
|
}
|
|
438
452
|
|
|
453
|
+
if (route === 'stats' && req.method === 'GET') {
|
|
454
|
+
// 委派效果统计(GET stats?days=30&project=<slug>):宿主
|
|
455
|
+
// sessionPersistence 服务逐会话冷读抽摘要聚合(A 路,见
|
|
456
|
+
// stats-core.mjs 文件头——服务面、slug 口径与失败判据的实测证据都在
|
|
457
|
+
// 那里)。服务经 ctx.reflect 软探测(绝不进 inject 数组:硬注入会让
|
|
458
|
+
// 缺服务的老宿主在挂载时就炸),拿不到回 { ok:false,
|
|
459
|
+
// reason:'persistence-unavailable' },客户端整卡不渲染。project
|
|
460
|
+
// 缺省 = 当前面板项目(cwd → projectKey 换算),days 缺省 30 上限
|
|
461
|
+
// 365;project 非法在核心层抛 422(外层 catch 统一回 { error })。
|
|
462
|
+
// 路由注册在既有 /api/paoding 前缀下,上方信任围栏自动覆盖本路由。
|
|
463
|
+
json(res, 200, await collectDelegationStats(ctx, {
|
|
464
|
+
project: url.searchParams.get('project') ?? undefined,
|
|
465
|
+
days: url.searchParams.get('days') ?? undefined,
|
|
466
|
+
}))
|
|
467
|
+
return
|
|
468
|
+
}
|
|
469
|
+
|
|
439
470
|
if (route === 'models' && req.method === 'GET') {
|
|
440
471
|
// 模型目录:DSH 设置页模型选择器同款目录(deepseek-official 三模型、
|
|
441
472
|
// pi-ai 各路由等),JSON-safe 纯对象。与 state 缓存无关,每次实调——
|
|
@@ -608,6 +639,14 @@ export async function apply(ctx) {
|
|
|
608
639
|
}
|
|
609
640
|
}
|
|
610
641
|
try {
|
|
642
|
+
// diff 旧值先读:apply 的落盘会覆盖磁盘配置,改前这一读才拿得到
|
|
643
|
+
// 磁盘真实内容(preview 不落盘,先后等价,统一走改前口径)。生成
|
|
644
|
+
// 管线随后对同一文件做同样的 loadConfig 护栏,解析失败同样 422。
|
|
645
|
+
const diskAssignments = loadConfig(s.configFile, s.yamlMod)
|
|
646
|
+
// 写盘前自动轮换备份(当前配置推入 bak-1;首次保存无「当前」则不
|
|
647
|
+
// 备份),与 import / 工作区预设写盘 / 回滚同一口径。预览不落盘,
|
|
648
|
+
// 不轮换。
|
|
649
|
+
if (!dryRun) rotateConfigBackups()
|
|
611
650
|
const result = installAssignments(s, body.assignments, {
|
|
612
651
|
dryRun,
|
|
613
652
|
saveConfigOnWrite: !dryRun,
|
|
@@ -639,6 +678,12 @@ export async function apply(ctx) {
|
|
|
639
678
|
// 结构化体检结果(tools/lib/health.mjs):未挂载工具 / 零工具角色 /
|
|
640
679
|
// 未知模型 / 预设漂移 / 生成期告警。纯增量字段,客户端批次负责渲染。
|
|
641
680
|
warnings: result.warnings,
|
|
681
|
+
// 本次提交相对磁盘现配置的差分(tools/lib/config.mjs 的纯结构化
|
|
682
|
+
// 结果,零文案,客户端 i18n 渲染)与工具面瘦身统计(total = 可用
|
|
683
|
+
// 工具全集,main = 本次提交下主 agent 有效 allow 计数)。均为纯
|
|
684
|
+
// 增量字段。
|
|
685
|
+
diff: diffAssignments(diskAssignments, body.assignments),
|
|
686
|
+
slim: slimStatsOf(s, body.assignments),
|
|
642
687
|
})
|
|
643
688
|
} catch (err) {
|
|
644
689
|
// 422 保持既有 { error } 形状;体检结果(如零工具角色的可读说明)
|
|
@@ -688,13 +733,50 @@ export async function apply(ctx) {
|
|
|
688
733
|
return
|
|
689
734
|
}
|
|
690
735
|
|
|
736
|
+
if (route === 'config/backups' && req.method === 'GET') {
|
|
737
|
+
// 备份清单(新→旧):{ file, mtimeMs, size }[],无备份返回空数组。
|
|
738
|
+
// 纯读不写;file 恒为 basename,客户端回传它即够(还原路由只认配置
|
|
739
|
+
// 目录内的备份名)。
|
|
740
|
+
json(res, 200, { ok: true, backups: listConfigBackups() })
|
|
741
|
+
return
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
if (route === 'config/restore' && req.method === 'POST') {
|
|
745
|
+
// 回滚配置:{ file } 须精确匹配「配置文件名.bak-1/2/3」。与 apply /
|
|
746
|
+
// import 共用同一把 in-flight 闸(写盘路径防并发交错)。核心函数先
|
|
747
|
+
// loadConfig 验证备份可解析(坏备份 422 / 备份不存在 404,statusCode
|
|
748
|
+
// 由外层 catch 回 { error } 信封,不写盘),再把「当前配置」按同款
|
|
749
|
+
// 轮换推入备份链后拷贝备份为当前;mtime 变化令 state 缓存按既有对账
|
|
750
|
+
// 自动失效,无需手动清理。
|
|
751
|
+
const body = await readBody(req)
|
|
752
|
+
if (applyInFlight) {
|
|
753
|
+
json(res, 409, { error: '已有一次应用(apply)正在进行,请稍后重试' })
|
|
754
|
+
return
|
|
755
|
+
}
|
|
756
|
+
applyInFlight = true
|
|
757
|
+
try {
|
|
758
|
+
json(res, 200, await restoreConfigBackup(typeof body.file === 'string' ? body.file : ''))
|
|
759
|
+
} finally {
|
|
760
|
+
applyInFlight = false
|
|
761
|
+
}
|
|
762
|
+
return
|
|
763
|
+
}
|
|
764
|
+
|
|
691
765
|
if (route === 'version' && req.method === 'GET') {
|
|
692
766
|
// 版本提示:npm registry 优先,npmmirror / GitHub 依次兜底(version.mjs,
|
|
693
767
|
// 带 1 小时内存缓存,命中不发外网请求)。绝不 5xx——检测失败也回
|
|
694
768
|
// 200 + error 字段,面板据此静默降级(不打扰使用)。timeoutMs 3000:
|
|
695
769
|
// 检测只是锦上添花,不值得让面板等更久。layout 告知客户端挂载形态
|
|
696
770
|
//(升级确认框按此分流警示文案),见 MOUNT_LAYOUT 注释。
|
|
697
|
-
|
|
771
|
+
// hotReloadable:宿主插件清单里有启用的 dsh-hmr 插件行(0.2.1+ web/
|
|
772
|
+
// desktop 默认)→ 升级后刷新页面即生效,确认框 / 成功 note 换
|
|
773
|
+
// 「刷新即生效」文案;探测失败 / 非 web 部署拿不到运行时 → false,
|
|
774
|
+
// 回落「需重启 DSH」文案。永不抛出(detectHmrOf 自捕)。
|
|
775
|
+
json(res, 200, {
|
|
776
|
+
...(await getVersionInfo({ timeoutMs: 3000 })),
|
|
777
|
+
layout: MOUNT_LAYOUT,
|
|
778
|
+
hotReloadable: detectHmrOf(ctx),
|
|
779
|
+
})
|
|
698
780
|
return
|
|
699
781
|
}
|
|
700
782
|
|
|
@@ -364,6 +364,37 @@
|
|
|
364
364
|
padding: 2px 0;
|
|
365
365
|
}
|
|
366
366
|
|
|
367
|
+
/* 工具分组筛选框:分组超过 8 项时,网格上方一行小输入框。oninput 直改
|
|
368
|
+
网格单项显隐(纯 DOM,不走重渲染——重渲染会吃掉输入框焦点),观感取
|
|
369
|
+
omd-nameInput 同族(边框 / 圆角 / 字号 token 一致),尺寸更紧凑。 */
|
|
370
|
+
.omd-toolFilterWrap {
|
|
371
|
+
display: flex;
|
|
372
|
+
flex-direction: column;
|
|
373
|
+
gap: 2px;
|
|
374
|
+
padding: 2px 0;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
.omd-toolFilter {
|
|
378
|
+
width: 100%;
|
|
379
|
+
box-sizing: border-box;
|
|
380
|
+
padding: 5px 8px;
|
|
381
|
+
border: 1px solid var(--dsw-alias-border-l2);
|
|
382
|
+
border-radius: 8px;
|
|
383
|
+
background: var(--dsw-alias-bg-layer-2);
|
|
384
|
+
color: var(--dsw-alias-label-primary);
|
|
385
|
+
font: inherit;
|
|
386
|
+
font-size: 12px;
|
|
387
|
+
line-height: 18px;
|
|
388
|
+
transition: border-color 0.16s ease;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
.omd-toolFilter:focus-visible {
|
|
392
|
+
outline: 2px solid var(--dsw-alias-state-business-primary);
|
|
393
|
+
outline-offset: 1px;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
.omd-toolFilter::placeholder { color: var(--dsw-alias-label-tertiary); }
|
|
397
|
+
|
|
367
398
|
.omd-toolItem {
|
|
368
399
|
display: flex;
|
|
369
400
|
align-items: center;
|
|
@@ -603,6 +634,64 @@
|
|
|
603
634
|
overflow-wrap: anywhere;
|
|
604
635
|
}
|
|
605
636
|
|
|
637
|
+
/* 预览「本次变更」区块:preview.diff 的结构化差分行(净新增绿 / 净移除红 /
|
|
638
|
+
变更明细中性),卡片观感同 omd-pre(描边浅底圆角),行多随预览区滚动。 */
|
|
639
|
+
.omd-diffBlock {
|
|
640
|
+
display: flex;
|
|
641
|
+
flex-direction: column;
|
|
642
|
+
gap: 4px;
|
|
643
|
+
margin: 6px 0 0;
|
|
644
|
+
padding: 8px 12px;
|
|
645
|
+
box-sizing: border-box;
|
|
646
|
+
border: 1px solid var(--dsw-alias-border-l1);
|
|
647
|
+
border-radius: 12px;
|
|
648
|
+
background: var(--dsw-alias-bg-layer-2);
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
.omd-diffHeading {
|
|
652
|
+
font-size: 11px;
|
|
653
|
+
line-height: 16px;
|
|
654
|
+
font-weight: 600;
|
|
655
|
+
letter-spacing: 0.05em;
|
|
656
|
+
text-transform: uppercase;
|
|
657
|
+
color: var(--dsw-alias-label-secondary);
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
.omd-diffLines {
|
|
661
|
+
display: flex;
|
|
662
|
+
flex-direction: column;
|
|
663
|
+
gap: 2px;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
.omd-diffLine {
|
|
667
|
+
font-size: 12px;
|
|
668
|
+
line-height: 18px;
|
|
669
|
+
color: var(--dsw-alias-label-secondary);
|
|
670
|
+
overflow-wrap: anywhere;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
.omd-diffLineAdd { color: var(--dsw-alias-state-success-primary); }
|
|
674
|
+
|
|
675
|
+
.omd-diffLineDel { color: var(--dsw-alias-state-error-primary); }
|
|
676
|
+
|
|
677
|
+
.omd-diffNone {
|
|
678
|
+
font-size: 12px;
|
|
679
|
+
line-height: 18px;
|
|
680
|
+
color: var(--dsw-alias-label-tertiary);
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/* 预览里的瘦身统计一行(按本次提交配置算):与页头 pd-slimLine 同语义,
|
|
684
|
+
尾随「按当前未保存配置」小注再弱一档。 */
|
|
685
|
+
.omd-slimLine {
|
|
686
|
+
margin: 6px 0 0;
|
|
687
|
+
font-size: 11px;
|
|
688
|
+
line-height: 16px;
|
|
689
|
+
color: var(--dsw-alias-label-secondary);
|
|
690
|
+
font-variant-numeric: tabular-nums;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
.omd-slimNote { color: var(--dsw-alias-label-tertiary); }
|
|
694
|
+
|
|
606
695
|
.omd-pre {
|
|
607
696
|
box-sizing: border-box;
|
|
608
697
|
margin: 0;
|
|
@@ -1286,6 +1375,85 @@ html[data-dsh-paoding-active]:not([data-dsh-mnemon-active]):not([data-dsh-taskbo
|
|
|
1286
1375
|
color: var(--dsw-alias-label-tertiary);
|
|
1287
1376
|
}
|
|
1288
1377
|
|
|
1378
|
+
/* 工具面瘦身统计一行:副标题之下(版本号 / 更新卡同区),按已保存配置算
|
|
1379
|
+
的一行弱化小字;数字用等宽数位避免加载 / 应用后跳动。 */
|
|
1380
|
+
.pd-slimLine {
|
|
1381
|
+
margin: 2px 0 0;
|
|
1382
|
+
font-size: 11px;
|
|
1383
|
+
line-height: 16px;
|
|
1384
|
+
color: var(--dsw-alias-label-tertiary);
|
|
1385
|
+
font-variant-numeric: tabular-nums;
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
/* 委派效果卡:页头瘦身统计行之下的只读紧凑表(GET stats 懒加载,服务缺失 /
|
|
1389
|
+
拉取失败整卡不渲染)。行内五列网格——角色名占余宽可截断,数字列定宽右对
|
|
1390
|
+
齐,最近使用列偏右;数字等宽数位防跳动,失败 >0 标红提醒。 */
|
|
1391
|
+
.pd-delegCard {
|
|
1392
|
+
margin: 10px 0 0;
|
|
1393
|
+
padding: 10px 12px 8px;
|
|
1394
|
+
border: 1px solid var(--dsw-alias-border-l1);
|
|
1395
|
+
border-radius: 12px;
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
.pd-delegTitle {
|
|
1399
|
+
font-size: 12px;
|
|
1400
|
+
line-height: 18px;
|
|
1401
|
+
font-weight: 600;
|
|
1402
|
+
color: var(--dsw-alias-label-primary);
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
.pd-delegTable {
|
|
1406
|
+
margin-top: 6px;
|
|
1407
|
+
font-size: 11px;
|
|
1408
|
+
line-height: 20px;
|
|
1409
|
+
color: var(--dsw-alias-label-secondary);
|
|
1410
|
+
font-variant-numeric: tabular-nums;
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
.pd-delegRow {
|
|
1414
|
+
display: grid;
|
|
1415
|
+
grid-template-columns: minmax(0, 1fr) 44px 56px 44px 88px;
|
|
1416
|
+
gap: 0 10px;
|
|
1417
|
+
align-items: baseline;
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
.pd-delegHeaderRow {
|
|
1421
|
+
color: var(--dsw-alias-label-tertiary);
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
.pd-delegRole {
|
|
1425
|
+
overflow: hidden;
|
|
1426
|
+
white-space: nowrap;
|
|
1427
|
+
text-overflow: ellipsis;
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
.pd-delegNum {
|
|
1431
|
+
text-align: right;
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
.pd-delegFail {
|
|
1435
|
+
color: var(--dsw-alias-state-error-primary);
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
.pd-delegWhen {
|
|
1439
|
+
text-align: right;
|
|
1440
|
+
color: var(--dsw-alias-label-tertiary);
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
.pd-delegMore {
|
|
1444
|
+
margin-top: 2px;
|
|
1445
|
+
font-size: 11px;
|
|
1446
|
+
line-height: 16px;
|
|
1447
|
+
color: var(--dsw-alias-label-tertiary);
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
.pd-delegEmpty {
|
|
1451
|
+
margin-top: 4px;
|
|
1452
|
+
font-size: 11px;
|
|
1453
|
+
line-height: 16px;
|
|
1454
|
+
color: var(--dsw-alias-label-tertiary);
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1289
1457
|
.pd-closeBtn {
|
|
1290
1458
|
flex: none;
|
|
1291
1459
|
display: inline-flex;
|
|
@@ -1457,6 +1625,82 @@ html[data-dsh-paoding-active]:not([data-dsh-mnemon-active]):not([data-dsh-taskbo
|
|
|
1457
1625
|
cursor: not-allowed;
|
|
1458
1626
|
}
|
|
1459
1627
|
|
|
1628
|
+
/* ── 恢复备份弹层:备份清单行(bak 徽标 + 本地时间 + 大小,整行可点)──── */
|
|
1629
|
+
/* 列表高度封顶出滚:备份链随每次 apply / restore 前滚一格,再长不顶破视口。 */
|
|
1630
|
+
.pd-backupList {
|
|
1631
|
+
display: flex;
|
|
1632
|
+
flex-direction: column;
|
|
1633
|
+
gap: 6px;
|
|
1634
|
+
max-height: 320px;
|
|
1635
|
+
overflow-y: auto;
|
|
1636
|
+
padding: 2px;
|
|
1637
|
+
}
|
|
1638
|
+
|
|
1639
|
+
.pd-backupItem {
|
|
1640
|
+
display: flex;
|
|
1641
|
+
align-items: center;
|
|
1642
|
+
gap: 10px;
|
|
1643
|
+
width: 100%;
|
|
1644
|
+
padding: 8px 10px;
|
|
1645
|
+
box-sizing: border-box;
|
|
1646
|
+
border: 1px solid var(--dsw-alias-border-l2);
|
|
1647
|
+
border-radius: 10px;
|
|
1648
|
+
background: var(--dsw-alias-bg-layer-2);
|
|
1649
|
+
color: var(--dsw-alias-label-primary);
|
|
1650
|
+
font: inherit;
|
|
1651
|
+
font-size: 12px;
|
|
1652
|
+
line-height: 18px;
|
|
1653
|
+
text-align: left;
|
|
1654
|
+
cursor: pointer;
|
|
1655
|
+
transition: border-color 0.12s ease, background 0.12s ease;
|
|
1656
|
+
}
|
|
1657
|
+
|
|
1658
|
+
.pd-backupItem:hover:not(:disabled) {
|
|
1659
|
+
border-color: var(--dsw-alias-state-business-primary);
|
|
1660
|
+
background: color-mix(in srgb, var(--dsw-alias-state-business-primary) 6%, transparent);
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
.pd-backupItem:focus-visible {
|
|
1664
|
+
outline: 2px solid var(--dsw-alias-state-business-primary);
|
|
1665
|
+
outline-offset: 1px;
|
|
1666
|
+
}
|
|
1667
|
+
|
|
1668
|
+
.pd-backupItem:disabled { opacity: 0.55; cursor: default; }
|
|
1669
|
+
|
|
1670
|
+
.pd-backupTag {
|
|
1671
|
+
flex: none;
|
|
1672
|
+
padding: 1px 8px;
|
|
1673
|
+
border-radius: 999px;
|
|
1674
|
+
background: color-mix(in srgb, var(--dsw-alias-state-business-primary) 10%, transparent);
|
|
1675
|
+
color: var(--dsw-alias-state-business-primary);
|
|
1676
|
+
font-size: 11px;
|
|
1677
|
+
line-height: 16px;
|
|
1678
|
+
font-weight: 500;
|
|
1679
|
+
font-variant-numeric: tabular-nums;
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1682
|
+
.pd-backupTime {
|
|
1683
|
+
flex: 1;
|
|
1684
|
+
min-width: 0;
|
|
1685
|
+
overflow-wrap: anywhere;
|
|
1686
|
+
text-align: left;
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
.pd-backupSize {
|
|
1690
|
+
flex: none;
|
|
1691
|
+
color: var(--dsw-alias-label-tertiary);
|
|
1692
|
+
font-variant-numeric: tabular-nums;
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1695
|
+
/* 加载中 / 暂无备份:一行居中弱化文案(omd-empty 的弹窗版)。 */
|
|
1696
|
+
.pd-backupEmpty {
|
|
1697
|
+
padding: 12px 0 8px;
|
|
1698
|
+
font-size: 12px;
|
|
1699
|
+
line-height: 18px;
|
|
1700
|
+
color: var(--dsw-alias-label-tertiary);
|
|
1701
|
+
text-align: center;
|
|
1702
|
+
}
|
|
1703
|
+
|
|
1460
1704
|
/* ── Toast 反馈(fixed 悬浮于操作坞上方)──────────────────────────────── */
|
|
1461
1705
|
.pd-toastHost {
|
|
1462
1706
|
position: fixed;
|