dsh-paoding 0.3.10 → 0.4.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.
@@ -3,7 +3,8 @@
3
3
  *
4
4
  * 把 dsh-paoding 可视化配置器整合进 DSH Web GUI(左侧栏全页配置,无独立服务端):
5
5
  * - Node 侧:注册 /api/paoding/* 前缀路由(state/models/workspaces/version/
6
- * upgrade/preview/apply/rescan + client.css 静态样式),经
6
+ * upgrade/preview/apply/rescan/config/export/config/import + client.css
7
+ * 静态样式),经
7
8
  * ./api-core.mjs 复用 tools/install.mjs 的检测与生成管线;workspaces 直连
8
9
  * 宿主 workspaceRegistry 服务(list / create,幂等);version
9
10
  * 复用 tools/lib/version.mjs 的版本检测(npm registry 优先,npmmirror
@@ -36,7 +37,8 @@ import { readFile } from 'node:fs/promises'
36
37
  import os from 'node:os'
37
38
  import path from 'node:path'
38
39
  import { fileURLToPath } from 'node:url'
39
- import { getState, refreshState, isStateCacheStale, serializeState, installAssignments, resolveDshHome, discoverWorkspaceCandidates, ensurePresetInstalled, pluginVersion } from './api-core.mjs'
40
+ import { getState, refreshState, isStateCacheStale, serializeState, installAssignments, resolveDshHome, discoverWorkspaceCandidates, ensurePresetInstalled, pluginVersion, exportConfigPayload, importConfigContent } from './api-core.mjs'
41
+ import { collectModelRefs } from '../../tools/lib/health.mjs'
40
42
  import { detectSkills } from '../../tools/lib/skills.mjs'
41
43
  import { collectRuntimeFacts } from './runtime-inventory.mjs'
42
44
  import { getVersionInfo } from '../../tools/lib/version.mjs'
@@ -255,6 +257,38 @@ async function stateWithRuntimeFacts(ctx, { force = false } = {}) {
255
257
  // apply in-flight 闸(模块级):apply 落盘期间再来的 apply 直接 409,防并发写配置。
256
258
  let applyInFlight = false
257
259
 
260
+ /**
261
+ * 模型目录(面板 models 路由与 preview/apply 的模型体检共用同一份映射,防两处
262
+ * 漂移):ctx.llm(DSH ≥ 0.1.6 的宿主模型运行时)listProviders() 给已注册
263
+ * 适配器的 provider 路由({ id, ... }),listModels(provider) 给该路由的目录
264
+ *({ provider, id, name, ... })。逐 provider 并发实调映射成客户端的 groups
265
+ * 形状(group.id = provider 路由 id);单路由失败隔离记入 failures 不抛错,
266
+ * 与旧 apiProxy 时代的容错语义一致。ctx.llm 缺失(老宿主 / 测试假 ctx)时
267
+ * listProviders 直接 TypeError,由调用方自捕降级。
268
+ */
269
+ async function modelDirectoryOf(ctx) {
270
+ const providers = ctx.llm.listProviders()
271
+ const failures = []
272
+ const results = await Promise.all((Array.isArray(providers) ? providers : []).map(async (provider) => {
273
+ const id = provider && typeof provider.id === 'string' ? provider.id : null
274
+ if (!id) return null
275
+ try {
276
+ const models = await ctx.llm.listModels(id)
277
+ return {
278
+ id,
279
+ name: provider.name || provider.displayName || id,
280
+ models: (Array.isArray(models) ? models : [])
281
+ .filter((m) => m && typeof m.id === 'string' && m.id !== '')
282
+ .map((m) => ({ id: m.id, name: m.name || m.id })),
283
+ }
284
+ } catch (err) {
285
+ failures.push(`${id}: ${err.message}`)
286
+ return null
287
+ }
288
+ }))
289
+ return { groups: results.filter(Boolean), failures }
290
+ }
291
+
258
292
  // —— 宿主热切换竞态兜底 ————————————————————————————————————————————
259
293
  // dsh 0.1.7-rc.2 / 0.2.0-rc.x 插件列表「禁用→启用」会重建条目纤维:新 apply()
260
294
  // 先执行,旧 incarnation 的路由注销走异步结算,注册瞬间旧路由常仍在表里,
@@ -339,31 +373,8 @@ export async function apply(ctx) {
339
373
  // 目录发现可能稍慢,属正常。失败组在 failures 里,不抛错;这里自捕
340
374
  // 一层,保证错误也走 { ok: false, error } 形状(外层 catch 是 { error })。
341
375
  try {
342
- // ctx.llm(DSH ≥ 0.1.6 的宿主模型运行时):listProviders() 给已注册
343
- // 适配器的 provider 路由({ id, ... }),listModels(provider) 给该
344
- // 路由的目录({ provider, id, name, ... })。逐 provider 并发实调
345
- // 映射成客户端的 groups 形状(group.id = provider 路由 id);单路由
346
- // 失败隔离记入 failures 不抛错,与旧 apiProxy 时代的容错语义一致。
347
- const providers = ctx.llm.listProviders()
348
- const failures = []
349
- const results = await Promise.all((Array.isArray(providers) ? providers : []).map(async (provider) => {
350
- const id = provider && typeof provider.id === 'string' ? provider.id : null
351
- if (!id) return null
352
- try {
353
- const models = await ctx.llm.listModels(id)
354
- return {
355
- id,
356
- name: provider.name || provider.displayName || id,
357
- models: (Array.isArray(models) ? models : [])
358
- .filter((m) => m && typeof m.id === 'string' && m.id !== '')
359
- .map((m) => ({ id: m.id, name: m.name || m.id })),
360
- }
361
- } catch (err) {
362
- failures.push(`${id}: ${err.message}`)
363
- return null
364
- }
365
- }))
366
- json(res, 200, { ok: true, groups: results.filter(Boolean), failures })
376
+ const { groups, failures } = await modelDirectoryOf(ctx)
377
+ json(res, 200, { ok: true, groups, failures })
367
378
  } catch (err) {
368
379
  json(res, 500, { ok: false, error: err.message })
369
380
  }
@@ -516,11 +527,23 @@ export async function apply(ctx) {
516
527
  // 必须走原始 state 通道(Set/Map/dstDir/yamlMod 齐全)——序列化视图
517
528
  // 喂生成管线会炸 TypeError(契约由 tests/api-apply.test.mjs 钉死)。
518
529
  const s = await rawStateWithRuntimeFacts(ctx)
530
+ // 模型目录按需取(unknown_model 体检的比对名单):配置里没有任何
531
+ // 角色专用模型时不做目录发现(发现可能稍慢,别拖慢每次预览);
532
+ // 拿不到(老宿主无 ctx.llm / 全路由失败)传 null,体检自动跳过该项。
533
+ let models = null
534
+ if (collectModelRefs(body.assignments).length > 0) {
535
+ try {
536
+ models = (await modelDirectoryOf(ctx)).groups
537
+ } catch {
538
+ models = null
539
+ }
540
+ }
519
541
  try {
520
542
  const result = installAssignments(s, body.assignments, {
521
543
  dryRun,
522
544
  saveConfigOnWrite: !dryRun,
523
545
  workspacePath,
546
+ models,
524
547
  })
525
548
  // apply 落盘后必须重刷状态缓存:stateCache 仍是应用前的快照(existing
526
549
  // 还是旧 config 的 roles),浏览器刷新读到的就是它——角色专用模型 /
@@ -544,9 +567,17 @@ export async function apply(ctx) {
544
567
  // 本次落点:全局 orchestrator 或工作区专属 orchestrator-<slug>。
545
568
  presetId: result.presetId,
546
569
  configFile: s.configFile,
570
+ // 结构化体检结果(tools/lib/health.mjs):未挂载工具 / 零工具角色 /
571
+ // 未知模型 / 预设漂移 / 生成期告警。纯增量字段,客户端批次负责渲染。
572
+ warnings: result.warnings,
547
573
  })
548
574
  } catch (err) {
549
- json(res, 422, { error: err.message })
575
+ // 422 保持既有 { error } 形状;体检结果(如零工具角色的可读说明)
576
+ // 挂在 error.warnings 上时一并透传(同样纯增量)。
577
+ json(res, 422, {
578
+ error: err.message,
579
+ ...(Array.isArray(err?.warnings) ? { warnings: err.warnings } : {}),
580
+ })
550
581
  }
551
582
  } finally {
552
583
  if (!dryRun) applyInFlight = false
@@ -554,6 +585,40 @@ export async function apply(ctx) {
554
585
  return
555
586
  }
556
587
 
588
+ if (route === 'config/export' && req.method === 'GET') {
589
+ // 导出配置:配置文件原文(不存在时回落基础模板文本,同首装自愈写入
590
+ // 的内容,见 exportConfigPayload)。只读不写、不碰 state 缓存;失败
591
+ // 走外层 catch(500 + 脱敏 message)。
592
+ json(res, 200, await exportConfigPayload())
593
+ return
594
+ }
595
+
596
+ if (route === 'config/import' && req.method === 'POST') {
597
+ // 导入配置 = 用提交文本整体覆盖配置文件(含 workspaces 段——工作区
598
+ // 条目随整份文件一起被替换,客户端确认文案里已明说)。与 apply 共用
599
+ // 同一把 in-flight 闸:apply 落盘期间导入 409,导入落盘期间 apply 409,
600
+ // 防两条写盘路径并发交错。空文本先拦(语义是「没选内容」,不该报成
601
+ // 「顶层不是映射」);解析不合法由 importConfigContent 抛 422(外层
602
+ // catch 按 statusCode 回 { error },不写盘)。
603
+ const body = await readBody(req)
604
+ const content = typeof body.content === 'string' ? body.content : ''
605
+ if (content.trim() === '') {
606
+ json(res, 422, { error: '缺少配置文本(body.content)' })
607
+ return
608
+ }
609
+ if (applyInFlight) {
610
+ json(res, 409, { error: '已有一次应用(apply)正在进行,请稍后重试' })
611
+ return
612
+ }
613
+ applyInFlight = true
614
+ try {
615
+ json(res, 200, await importConfigContent(content))
616
+ } finally {
617
+ applyInFlight = false
618
+ }
619
+ return
620
+ }
621
+
557
622
  if (route === 'version' && req.method === 'GET') {
558
623
  // 版本提示:npm registry 优先,npmmirror / GitHub 依次兜底(version.mjs,
559
624
  // 带 1 小时内存缓存,命中不发外网请求)。绝不 5xx——检测失败也回