dsh-paoding 0.2.0
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/LICENSE +21 -0
- package/README.md +95 -0
- package/README_en.md +95 -0
- package/bin/dsh-paoding.mjs +12 -0
- package/cordis.patch.yml +6 -0
- package/docs/architecture.md +292 -0
- package/docs/architecture_en.md +363 -0
- package/docs/configuration.md +551 -0
- package/docs/configuration_en.md +560 -0
- package/docs/images/1.png +0 -0
- package/docs/images/2.png +0 -0
- package/docs/images/3.png +0 -0
- package/docs/images/4.png +0 -0
- package/docs/images/5.png +0 -0
- package/docs/images/6.png +0 -0
- package/docs/installation.md +364 -0
- package/docs/installation_en.md +367 -0
- package/docs/orchestration.md +254 -0
- package/docs/orchestration_en.md +255 -0
- package/install.sh +31 -0
- package/package.json +29 -0
- package/plugins/paoding-config-ui/api-core.mjs +100 -0
- package/plugins/paoding-config-ui/index.mjs +327 -0
- package/plugins/paoding-config-ui/lib/base.css +615 -0
- package/plugins/paoding-config-ui/lib/client.js +1154 -0
- package/plugins/paoding-config-ui/package.json +23 -0
- package/plugins/paoding-config-ui/runtime-inventory.mjs +212 -0
- package/presets/orchestrator/agent.cordis.yml +323 -0
- package/presets/orchestrator/preset.yml +6 -0
- package/presets/orchestrator/restrict.mjs +191 -0
- package/tools/install.mjs +24 -0
- package/tools/lib/alloc.mjs +138 -0
- package/tools/lib/cli.mjs +522 -0
- package/tools/lib/compose.mjs +647 -0
- package/tools/lib/config.mjs +297 -0
- package/tools/lib/host.mjs +288 -0
- package/tools/lib/skills.mjs +146 -0
- package/tools/lib/spans.mjs +188 -0
- package/tools/lib/state.mjs +328 -0
- package/tools/lib/util.mjs +26 -0
- package/tools/lib/wizard.mjs +516 -0
- package/tools/lib/yaml.mjs +266 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-paoding 配置后端核心 —— DSH Web 插件 (plugins/paoding-config-ui)
|
|
3
|
+
* 的路由层适配:复用 tools/install.mjs 的检测与生成管线。
|
|
4
|
+
*
|
|
5
|
+
* 提供:检测状态收集(缓存)、状态序列化、生成/安装。零运行时依赖。
|
|
6
|
+
*/
|
|
7
|
+
import os from 'node:os'
|
|
8
|
+
import path from 'node:path'
|
|
9
|
+
import { collectState, generateAndInstall } from '../../tools/install.mjs'
|
|
10
|
+
|
|
11
|
+
export function resolveDshHome() {
|
|
12
|
+
return process.env.DSH_HOME || path.join(os.homedir(), '.dsh')
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function resolveConfigFile() {
|
|
16
|
+
return process.env.DSH_PAODING_CONFIG || path.join(resolveDshHome(), 'dsh-paoding.config.yml')
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// ── state cache ─────────────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
let stateCache = null
|
|
22
|
+
let stateError = null
|
|
23
|
+
|
|
24
|
+
export async function getState(runtimeFacts = null) {
|
|
25
|
+
// 传入运行时事实(rescan / 首屏首次加载)时总是先带着最新事实重刷一次,
|
|
26
|
+
// 再返回缓存;不传则沿用旧语义(命中缓存直接返回,未命中再刷)。
|
|
27
|
+
if (runtimeFacts !== null) {
|
|
28
|
+
await refreshState(runtimeFacts)
|
|
29
|
+
if (stateError) throw stateError
|
|
30
|
+
return stateCache
|
|
31
|
+
}
|
|
32
|
+
if (stateCache) return stateCache
|
|
33
|
+
await refreshState()
|
|
34
|
+
if (stateError) throw stateError
|
|
35
|
+
return stateCache
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** 是否有可用的状态缓存(供路由层决定是否值得带运行时事实重刷)。 */
|
|
39
|
+
export function hasStateCache() {
|
|
40
|
+
return stateCache !== null
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function refreshState(runtimeFacts = null) {
|
|
44
|
+
stateError = null
|
|
45
|
+
try {
|
|
46
|
+
stateCache = await collectState({
|
|
47
|
+
dshHome: resolveDshHome(),
|
|
48
|
+
configFile: resolveConfigFile(),
|
|
49
|
+
cwd: process.env.DSH_PAODING_CWD || process.cwd(),
|
|
50
|
+
// 运行时事实透传给 collectState(不传时默认 null,走纯文件扫描旧逻辑)
|
|
51
|
+
runtimeFacts: runtimeFacts ?? null,
|
|
52
|
+
})
|
|
53
|
+
} catch (err) {
|
|
54
|
+
stateCache = null
|
|
55
|
+
stateError = err
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Serialize the state into a JSON-safe view for the UI. */
|
|
60
|
+
export function serializeState(s) {
|
|
61
|
+
const staticBase = {}
|
|
62
|
+
for (const role of Object.keys(s.staticBase)) staticBase[role] = [...s.staticBase[role]]
|
|
63
|
+
|
|
64
|
+
const blocks = {}
|
|
65
|
+
for (const [role, b] of s.blocks) blocks[role] = [...b.names]
|
|
66
|
+
|
|
67
|
+
const personas = {}
|
|
68
|
+
for (const [role, p] of s.personaBlocks) personas[role] = p.text
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
dshHome: s.dshHome,
|
|
72
|
+
configFile: s.configFile,
|
|
73
|
+
effectiveProfile: s.effectiveProfile,
|
|
74
|
+
inventory: [...s.inventory].sort(),
|
|
75
|
+
mcpReports: s.mcpReports,
|
|
76
|
+
pluginReports: s.pluginReports,
|
|
77
|
+
scanned: s.scanned,
|
|
78
|
+
skills: s.skills,
|
|
79
|
+
staticBase,
|
|
80
|
+
blocks,
|
|
81
|
+
personas,
|
|
82
|
+
restrictBase: [...s.restrictBase],
|
|
83
|
+
existing: s.existing,
|
|
84
|
+
suggested: s.suggested,
|
|
85
|
+
// 主 persona 尾部追加的默认常量(供 UI「人设追加」默认展示 / 恢复默认)
|
|
86
|
+
mainPersonaExtraDefault: s.mainPersonaExtraDefault ?? null,
|
|
87
|
+
// 运行时识别结果透传(字段缺失视为未启用运行时检测,与旧版 UI 行为一致)
|
|
88
|
+
runtimePlugins: Array.isArray(s.runtimePlugins) ? s.runtimePlugins : [],
|
|
89
|
+
runtimeOk: !!s.runtimeOk,
|
|
90
|
+
runtimeError: s.runtimeError ?? null,
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Compose + (optionally) install from a UI-supplied assignments object. */
|
|
95
|
+
export function installAssignments(state, assignments, { dryRun = false, saveConfigOnWrite = false } = {}) {
|
|
96
|
+
if (!assignments || typeof assignments !== 'object') {
|
|
97
|
+
throw new Error('missing assignments object')
|
|
98
|
+
}
|
|
99
|
+
return generateAndInstall(state, assignments, { dryRun, saveConfigOnWrite })
|
|
100
|
+
}
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* paoding-config-ui — DSH Web 插件 (cordis)
|
|
3
|
+
*
|
|
4
|
+
* 把 dsh-paoding 可视化配置器整合进 DSH Web GUI(设置页分区,无独立服务端):
|
|
5
|
+
* - Node 侧:注册 /api/paoding/* 前缀路由(state/models/preview/apply/rescan +
|
|
6
|
+
* client.css 静态样式),经 ./api-core.mjs 复用 tools/install.mjs 的
|
|
7
|
+
* 检测与生成管线。
|
|
8
|
+
* - Client 侧:lib/client.js 将配置面板挂到设置页的 settings.section 槽位。
|
|
9
|
+
*
|
|
10
|
+
* 挂载(~/.dsh/cordis.patch.yml):
|
|
11
|
+
* - id: paoding-config-ui
|
|
12
|
+
* name: paoding-config-ui # 解析自 $DSH_HOME/node_modules(或插件市场安装)
|
|
13
|
+
*
|
|
14
|
+
* 借鉴 dsh-better-sidebar 的机制(详见其 docs/plans 与 AGENTS.md):
|
|
15
|
+
* - 客户端 bundle 以 window.__ModuleLoader__.load({id, factory}) 注册,由
|
|
16
|
+
* dsh-client-modules 节点半扫描 dsh.client 声明并以 /plugins/<id>/client.js
|
|
17
|
+
* 下发;bundle 的注册 id 必须等于包名(图行 id)。
|
|
18
|
+
* - 设置分区经 ctx.slots.inject('settings.section') + slots.register() 挂载,
|
|
19
|
+
* id/order/label 驱动设置壳导航(与 ui-settings-general 的 general 分区同款)。
|
|
20
|
+
* - 宿主路由自带信任围栏:/api/paoding 长于 /api 前缀,webserver 最长前缀
|
|
21
|
+
* 优先会命中本插件而绕过 /api 网关的围栏,故在此复刻同一围栏语义
|
|
22
|
+
* (Host 回环或 webRuntime.trustedHosts,拒绝 cross-site 浏览器请求)。
|
|
23
|
+
*/
|
|
24
|
+
import { readFile } from 'node:fs/promises'
|
|
25
|
+
import { getState, refreshState, hasStateCache, serializeState, installAssignments } from './api-core.mjs'
|
|
26
|
+
import { collectRuntimeFacts } from './runtime-inventory.mjs'
|
|
27
|
+
|
|
28
|
+
export const name = 'paoding-config-ui'
|
|
29
|
+
// apiProxy:宿主 API 代理服务——models 路由经 ctx.apiProxy.llm.models({})
|
|
30
|
+
// 取 DSH 已配置/已注册的模型目录(设置页模型选择器同款目录,JSON-safe 纯
|
|
31
|
+
// 对象;失败组在 failures 里,不抛错)。
|
|
32
|
+
export const inject = ['webServer', 'apiProxy']
|
|
33
|
+
|
|
34
|
+
// ── 浏览器信任围栏(复刻 dsh-client-connection 的 /api 网关围栏语义,
|
|
35
|
+
// 参考 dsh-better-sidebar/src/trust-fence.ts,BSD-3-Clause 同源实现)──────
|
|
36
|
+
|
|
37
|
+
function header(headers, name) {
|
|
38
|
+
const value = headers[name]
|
|
39
|
+
return typeof value === 'string' ? value : undefined
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function parseAuthority(authority) {
|
|
43
|
+
try { return new URL(`http://${authority}`) } catch { return undefined }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function isLoopbackHostname(hostname) {
|
|
47
|
+
if (hostname === 'localhost' || hostname === '[::1]') return true
|
|
48
|
+
const parts = hostname.split('.')
|
|
49
|
+
return parts.length === 4 && parts[0] === '127'
|
|
50
|
+
&& parts.every((p) => /^\d{1,3}$/.test(p) && Number(p) <= 255)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Host 头是否落在部署信任列表(精确 host:port 或裸 hostname)。 */
|
|
54
|
+
function isTrustedAuthority(hostUrl, trustedHosts) {
|
|
55
|
+
return (trustedHosts ?? []).some((entry) => {
|
|
56
|
+
const entryUrl = parseAuthority(entry)
|
|
57
|
+
if (entryUrl === undefined) return false
|
|
58
|
+
return entryUrl.port === ''
|
|
59
|
+
? entryUrl.hostname === hostUrl.hostname
|
|
60
|
+
: entryUrl.host === hostUrl.host
|
|
61
|
+
})
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** DNS-rebinding / cross-site 防御(非认证):Host 回环或受信 + 非 cross-site。 */
|
|
65
|
+
function isTrustedApiRequest(req, trustedHosts) {
|
|
66
|
+
const host = header(req.headers, 'host')
|
|
67
|
+
if (host === undefined) return false
|
|
68
|
+
const hostUrl = parseAuthority(host)
|
|
69
|
+
if (hostUrl === undefined) return false
|
|
70
|
+
if (!isLoopbackHostname(hostUrl.hostname) && !isTrustedAuthority(hostUrl, trustedHosts)) return false
|
|
71
|
+
if (header(req.headers, 'sec-fetch-site') === 'cross-site') return false
|
|
72
|
+
const origin = header(req.headers, 'origin')
|
|
73
|
+
if (origin === undefined) return true
|
|
74
|
+
try { return new URL(origin).hostname === hostUrl.hostname } catch { return false }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Read a JSON request body (bounded). */
|
|
78
|
+
function readBody(req) {
|
|
79
|
+
return new Promise((resolve, reject) => {
|
|
80
|
+
const chunks = []
|
|
81
|
+
let size = 0
|
|
82
|
+
req.on('data', (c) => {
|
|
83
|
+
size += c.length
|
|
84
|
+
if (size > 5 * 1024 * 1024) {
|
|
85
|
+
reject(new Error('body too large'))
|
|
86
|
+
req.destroy()
|
|
87
|
+
return
|
|
88
|
+
}
|
|
89
|
+
chunks.push(c)
|
|
90
|
+
})
|
|
91
|
+
req.on('end', () => {
|
|
92
|
+
try {
|
|
93
|
+
resolve(chunks.length ? JSON.parse(Buffer.concat(chunks).toString('utf8')) : {})
|
|
94
|
+
} catch {
|
|
95
|
+
reject(new Error('invalid JSON body'))
|
|
96
|
+
}
|
|
97
|
+
})
|
|
98
|
+
req.on('error', reject)
|
|
99
|
+
})
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function json(res, status, payload) {
|
|
103
|
+
const text = JSON.stringify(payload, null, 2)
|
|
104
|
+
res.writeHead(status, {
|
|
105
|
+
'content-type': 'application/json; charset=utf-8',
|
|
106
|
+
'content-length': Buffer.byteLength(text),
|
|
107
|
+
})
|
|
108
|
+
res.end(text)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function apply(ctx) {
|
|
112
|
+
const { webServer } = ctx
|
|
113
|
+
// webRuntime 由 dsh-web-app 的 web-runtime 行提供;非 web 部署下缺失时退化为
|
|
114
|
+
// 仅回环信任(每次请求读实时值,trustedHosts 变化无需重启插件)。
|
|
115
|
+
const trustedHostsOf = () => ctx.get('webRuntime')?.trustedHosts ?? []
|
|
116
|
+
|
|
117
|
+
const disposer = webServer.register({
|
|
118
|
+
kind: 'prefix',
|
|
119
|
+
path: '/api/paoding',
|
|
120
|
+
handler: async (req, res) => {
|
|
121
|
+
if (!isTrustedApiRequest(req, trustedHostsOf())) {
|
|
122
|
+
json(res, 403, { error: 'forbidden' })
|
|
123
|
+
return
|
|
124
|
+
}
|
|
125
|
+
const url = new URL(req.url, 'http://dsh.internal')
|
|
126
|
+
const route = url.pathname.replace(/^\/api\/paoding\/?/, '')
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
// ── 静态样式:面板 CSS 正文(lib/base.css)─────────────────────────
|
|
130
|
+
// 客户端 mountCss 只注入 <link href="/api/paoding/client.css">,正文
|
|
131
|
+
// 由此按请求现读——改 base.css 无需重启插件,GUI 刷新即生效(响应带
|
|
132
|
+
// no-cache)。落在同前缀信任围栏内(上方 isTrustedApiRequest 已把关)。
|
|
133
|
+
if (route === 'client.css' && req.method === 'GET') {
|
|
134
|
+
try {
|
|
135
|
+
const css = await readFile(new URL('./lib/base.css', import.meta.url), 'utf8')
|
|
136
|
+
res.writeHead(200, {
|
|
137
|
+
'content-type': 'text/css; charset=utf-8',
|
|
138
|
+
'content-length': Buffer.byteLength(css),
|
|
139
|
+
'cache-control': 'no-cache',
|
|
140
|
+
})
|
|
141
|
+
res.end(css)
|
|
142
|
+
} catch (err) {
|
|
143
|
+
json(res, 500, { error: `client.css unavailable: ${err.message}` })
|
|
144
|
+
}
|
|
145
|
+
return
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (route === 'state' && req.method === 'GET') {
|
|
149
|
+
// 仅缓存缺失时带实时运行时事实做一次全量检测(成本同旧版首次 GET),
|
|
150
|
+
// 命中缓存直接返回——运行时注册表只在 DSH 重启/插件启停时变化,
|
|
151
|
+
// 没必要让每次打开面板都重握手 MCP。
|
|
152
|
+
if (!hasStateCache()) {
|
|
153
|
+
const facts = await collectRuntimeFacts(ctx)
|
|
154
|
+
await refreshState(facts)
|
|
155
|
+
}
|
|
156
|
+
const s = await getState()
|
|
157
|
+
json(res, 200, serializeState(s))
|
|
158
|
+
return
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (route === 'models' && req.method === 'GET') {
|
|
162
|
+
// 模型目录:DSH 设置页模型选择器同款目录(deepseek-official 三模型、
|
|
163
|
+
// pi-ai 各路由等),JSON-safe 纯对象。与 state 缓存无关,每次实调——
|
|
164
|
+
// 目录发现可能稍慢,属正常。失败组在 failures 里,不抛错;这里自捕
|
|
165
|
+
// 一层,保证错误也走 { ok: false, error } 形状(外层 catch 是 { error })。
|
|
166
|
+
try {
|
|
167
|
+
// apiProxy 的 llm.* 是裸 RPC handler:入参 { rpcId, payload } 信封、
|
|
168
|
+
// 返回 { rpcId, result: { ok, value | error } } 信封(ok()/err() 同源
|
|
169
|
+
// 形状),不是直接吐 { groups, failures }——必须拆一层信封再取 value。
|
|
170
|
+
const envelope = await ctx.apiProxy.llm.models({ rpcId: 'paoding-models', payload: {} })
|
|
171
|
+
const result = envelope && typeof envelope.result === 'object' && envelope.result !== null
|
|
172
|
+
? envelope.result
|
|
173
|
+
: null
|
|
174
|
+
if (!result || !result.ok) {
|
|
175
|
+
const message = result && result.error && result.error.message ? result.error.message : '模型目录调用失败'
|
|
176
|
+
throw new Error(message)
|
|
177
|
+
}
|
|
178
|
+
const catalog = result.value
|
|
179
|
+
if (!catalog || !Array.isArray(catalog.groups)) throw new Error('模型目录响应缺少 groups')
|
|
180
|
+
json(res, 200, { ok: true, groups: catalog.groups, failures: Array.isArray(catalog.failures) ? catalog.failures : [] })
|
|
181
|
+
} catch (err) {
|
|
182
|
+
json(res, 500, { ok: false, error: err.message })
|
|
183
|
+
}
|
|
184
|
+
return
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (route === 'rescan' && req.method === 'POST') {
|
|
188
|
+
// 重新检测 = 采集实时事实 → 带事实重刷状态(getState 不再自己重刷)
|
|
189
|
+
const facts = await collectRuntimeFacts(ctx)
|
|
190
|
+
await refreshState(facts)
|
|
191
|
+
const s = await getState()
|
|
192
|
+
json(res, 200, serializeState(s))
|
|
193
|
+
return
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ── TEMP PROBE (generic runtime detection groundwork; remove later) ──
|
|
197
|
+
// 崩溃安全:任何一步失败都记入 errors 而非 500;访问路径分别报告
|
|
198
|
+
// reflect.get / ctx.get / ctx[key] 三种取法的可用性(cordis 的 ctx[key]
|
|
199
|
+
// 受 inject 护栏约束,ctx.reflect.get 是官方无 inject 读取口)。
|
|
200
|
+
if (route === 'probe' && req.method === 'GET') {
|
|
201
|
+
const cap = (arr, n) => (Array.isArray(arr) ? arr.slice(0, n) : arr)
|
|
202
|
+
const methodNamesOf = (svc) => {
|
|
203
|
+
const methods = []
|
|
204
|
+
for (let p = Object.getPrototypeOf(svc); p && p !== Object.prototype && methods.length < 60; p = Object.getPrototypeOf(p)) {
|
|
205
|
+
for (const m of Object.getOwnPropertyNames(p)) {
|
|
206
|
+
if (m !== 'constructor' && typeof svc[m] === 'function' && !methods.includes(m)) methods.push(m)
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return methods
|
|
210
|
+
}
|
|
211
|
+
const probe = { errors: [] }
|
|
212
|
+
const tryReport = (label, fn) => {
|
|
213
|
+
try { probe[label] = fn() } catch (err) { probe[label] = 'error: ' + String(err?.message ?? err) }
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// 1) 每类服务三种取法的可用性 + 方法表面
|
|
217
|
+
// ⚠️ 切勿把服务实例本身放进结果:ctx 服务是 cordis 代理,JSON
|
|
218
|
+
// stringify 访问其 toJSON 会触发 inject 护栏抛错。只留纯数据。
|
|
219
|
+
const svcNames = ['tools', 'pluginInventory', 'loader', 'commands', 'mnemon', 'codegraph',
|
|
220
|
+
'agentLoop', 'systemPrompt', 'settings', 'webRuntime', 'webServer', 'llm', 'mcp', 'attachments', 'reflect']
|
|
221
|
+
const services = {}
|
|
222
|
+
for (const key of svcNames) {
|
|
223
|
+
const row = {}
|
|
224
|
+
let found = undefined
|
|
225
|
+
for (const [path, access] of [
|
|
226
|
+
['reflect', () => ctx.reflect?.get?.(key, false)],
|
|
227
|
+
['get', () => ctx.get?.(key)],
|
|
228
|
+
['prop', () => ctx[key]],
|
|
229
|
+
]) {
|
|
230
|
+
try {
|
|
231
|
+
const v = access()
|
|
232
|
+
if (v !== undefined && v !== null) {
|
|
233
|
+
row[path] = 'ok'
|
|
234
|
+
if (found === undefined) found = v
|
|
235
|
+
} else {
|
|
236
|
+
row[path] = 'missing'
|
|
237
|
+
}
|
|
238
|
+
} catch (err) { row[path] = 'throw: ' + String(err?.message ?? err) }
|
|
239
|
+
}
|
|
240
|
+
if (found !== undefined) row.methods = methodNamesOf(found)
|
|
241
|
+
services[key] = row
|
|
242
|
+
}
|
|
243
|
+
probe.services = services
|
|
244
|
+
|
|
245
|
+
// 2) pluginInventory.list() 直接采样
|
|
246
|
+
tryReport('pluginInventory', () => {
|
|
247
|
+
const pi = ctx.reflect?.get?.('pluginInventory', false) ?? ctx.get?.('pluginInventory')
|
|
248
|
+
if (!pi || typeof pi.list !== 'function') return 'no service / no list()'
|
|
249
|
+
const entries = pi.list()?.entries
|
|
250
|
+
if (!Array.isArray(entries)) return 'list() shape: ' + typeof entries
|
|
251
|
+
return entries.slice(0, 60).map((e) => ({ entryId: e.entryId, moduleName: e.moduleName, enabled: e.enabled, fiberPhase: e.fiberPhase }))
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
// 3) tools 服务的枚举口 + 工具名采样
|
|
255
|
+
tryReport('toolsMethods', () => {
|
|
256
|
+
const t = ctx.reflect?.get?.('tools', false) ?? ctx.get?.('tools')
|
|
257
|
+
return t ? methodNamesOf(t) : 'no service'
|
|
258
|
+
})
|
|
259
|
+
for (const [label, method] of [
|
|
260
|
+
['toolsSchemas', 'schemas'],
|
|
261
|
+
['toolsEntries', 'entries'],
|
|
262
|
+
]) {
|
|
263
|
+
tryReport(label, () => {
|
|
264
|
+
const t = ctx.reflect?.get?.('tools', false) ?? ctx.get?.('tools')
|
|
265
|
+
if (!t || typeof t[method] !== 'function') return 'no ' + method + '()'
|
|
266
|
+
const result = t[method]()
|
|
267
|
+
if (result == null || typeof result[Symbol.iterator] !== 'function') return 'not iterable: ' + typeof result
|
|
268
|
+
const names = []
|
|
269
|
+
for (const item of result) {
|
|
270
|
+
const name = typeof item === 'string' ? item : (item && typeof item === 'object' ? (typeof item.name === 'string' ? item.name : (Array.isArray(item) ? item[0] : undefined)) : undefined)
|
|
271
|
+
if (typeof name === 'string') names.push(name)
|
|
272
|
+
if (names.length >= 120) break
|
|
273
|
+
}
|
|
274
|
+
return { count: names.length, sample: cap(names, 120) }
|
|
275
|
+
})
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
json(res, 200, probe)
|
|
279
|
+
return
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if ((route === 'preview' || route === 'apply') && req.method === 'POST') {
|
|
283
|
+
const body = await readBody(req)
|
|
284
|
+
const s = await getState()
|
|
285
|
+
const dryRun = route === 'preview'
|
|
286
|
+
try {
|
|
287
|
+
const result = installAssignments(s, body.assignments, {
|
|
288
|
+
dryRun,
|
|
289
|
+
saveConfigOnWrite: !dryRun,
|
|
290
|
+
})
|
|
291
|
+
// apply 落盘后必须重刷状态缓存:stateCache 仍是应用前的快照(existing
|
|
292
|
+
// 还是旧 config 的 roles),浏览器刷新读到的就是它——角色专用模型 /
|
|
293
|
+
// 显示名等会“看起来保存丢失、回落默认”。这里与 rescan 同款带运行时
|
|
294
|
+
// 事实重刷;best-effort:刷新失败不影响 apply 已成功的落盘结果
|
|
295
|
+
// (refreshState 自捕错误置空缓存,下次 GET state 会带事实重建)。
|
|
296
|
+
if (!dryRun && result.wrote) {
|
|
297
|
+
try {
|
|
298
|
+
const facts = await collectRuntimeFacts(ctx)
|
|
299
|
+
await refreshState(facts)
|
|
300
|
+
} catch (err) {
|
|
301
|
+
ctx.logger?.warn?.('paoding-config-ui: post-apply state refresh failed: ' + String(err?.message ?? err))
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
json(res, 200, {
|
|
305
|
+
dryRun,
|
|
306
|
+
wrote: result.wrote,
|
|
307
|
+
staleNote: result.staleNote,
|
|
308
|
+
roleResults: result.roleResults,
|
|
309
|
+
text: result.text,
|
|
310
|
+
configFile: s.configFile,
|
|
311
|
+
})
|
|
312
|
+
} catch (err) {
|
|
313
|
+
json(res, 422, { error: err.message })
|
|
314
|
+
}
|
|
315
|
+
return
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
json(res, 404, { error: `no such route: ${req.method} /api/paoding/${route}` })
|
|
319
|
+
} catch (err) {
|
|
320
|
+
json(res, 500, { error: err.message })
|
|
321
|
+
}
|
|
322
|
+
},
|
|
323
|
+
})
|
|
324
|
+
|
|
325
|
+
ctx.logger?.info?.('paoding-config-ui: /api/paoding/* routes registered')
|
|
326
|
+
return disposer
|
|
327
|
+
}
|