dsh-paoding 0.4.5 → 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.
@@ -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
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 { ...state, persistenceAvailable: persistenceAvailableOf(ctx) }
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,6 +733,35 @@ 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——检测失败也回
@@ -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;