@aalis/plugin-webui-server 0.13.0 → 0.14.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.
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +44 -20
- package/dist/index.js.map +1 -1
- package/dist/protocol.d.ts +4 -4
- package/dist/routes/marketplace.d.ts +3 -2
- package/dist/routes/marketplace.d.ts.map +1 -1
- package/dist/routes/marketplace.js +5 -3
- package/dist/routes/marketplace.js.map +1 -1
- package/dist/routes/plugins.d.ts +4 -2
- package/dist/routes/plugins.d.ts.map +1 -1
- package/dist/routes/plugins.js +203 -138
- package/dist/routes/plugins.js.map +1 -1
- package/package.json +8 -8
package/dist/routes/plugins.js
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { isDeepStrictEqual } from 'node:util';
|
|
2
2
|
import { isConfigSaveRefused } from '@aalis/api-host-config';
|
|
3
3
|
import { parseInstanceId } from '@aalis/core';
|
|
4
|
-
import { CORE_CONFIG_SCHEMA,
|
|
4
|
+
import { CORE_CONFIG_SCHEMA, deepMergeDefaults, defaultsFrom, parseConfig, removeExtraFields, validateConfig, } from '@aalis/schema-config';
|
|
5
5
|
function errorMessage(err) {
|
|
6
6
|
return err instanceof Error ? err.message : String(err);
|
|
7
7
|
}
|
|
8
8
|
/**
|
|
9
9
|
* 落盘被拒后运行态怎样与文件对齐:插件配置与新建的实例随配置文件热重载(文件里没有配置段的后缀实例会被卸载);
|
|
10
|
-
*
|
|
10
|
+
* 启停与服务偏好只在重启时按文件登记
|
|
11
11
|
*/
|
|
12
12
|
const RECONCILE = {
|
|
13
13
|
reload: '修好配置文件后会按文件内容重载',
|
|
@@ -17,28 +17,59 @@ const RECONCILE = {
|
|
|
17
17
|
* 管理动作改完运行态之后落盘;失败时回 `applied: true` 并返回 false:改动已在运行态生效、只是没写进文件,
|
|
18
18
|
* 调用方别当成「没改成」去重试。宿主拒写(配置文件有尚未生效的外部修改)回 409,之后以文件为准对账;
|
|
19
19
|
* 其它失败(权限、磁盘写满等)回 500,文件没变也不会触发重载,改动留在文档里,下一次成功保存时一并写入。
|
|
20
|
+
* `outcome` 是动作之后插件没有落在预期状态的说明(如激活失败转 error、仍在激活、保持禁用):落盘也失败时放在回执开头,
|
|
21
|
+
* 两件事一并说明。
|
|
20
22
|
*/
|
|
21
|
-
export async function saveAfterApply(doc, res, reconcile) {
|
|
23
|
+
export async function saveAfterApply(doc, res, reconcile, outcome) {
|
|
22
24
|
try {
|
|
23
25
|
await doc.save();
|
|
24
26
|
return true;
|
|
25
27
|
}
|
|
26
28
|
catch (err) {
|
|
29
|
+
const lead = outcome ? `${outcome};` : '';
|
|
27
30
|
if (isConfigSaveRefused(err)) {
|
|
28
31
|
res.status(409).json({
|
|
29
|
-
error:
|
|
32
|
+
error: `${lead}已在运行态生效,但未写入配置文件(${errorMessage(err)});${RECONCILE[reconcile]}`,
|
|
30
33
|
applied: true,
|
|
31
34
|
});
|
|
32
35
|
}
|
|
33
36
|
else {
|
|
34
37
|
res.status(500).json({
|
|
35
|
-
error:
|
|
38
|
+
error: `${lead}已在运行态生效,但写入配置文件失败(${errorMessage(err)});改动保留在文档里,下次保存成功时一并写入`,
|
|
36
39
|
applied: true,
|
|
37
40
|
});
|
|
38
41
|
}
|
|
39
42
|
return false;
|
|
40
43
|
}
|
|
41
44
|
}
|
|
45
|
+
/**
|
|
46
|
+
* 管理动作受理后等重算落定,再读实例状态写回执:动作撞上在飞的重算时只排队、立即返回,立即读到的还是 pending,
|
|
47
|
+
* 之后的激活失败就漏报了。idle 不等转入后台的慢激活:重算对单个激活至多等到慢激活阈值。
|
|
48
|
+
*/
|
|
49
|
+
async function settledEntry(pm, instanceId) {
|
|
50
|
+
await pm.idle();
|
|
51
|
+
return pm.getPlugin(instanceId);
|
|
52
|
+
}
|
|
53
|
+
/** 落定后既没激活也没失败的说明:慢激活转入后台仍在进行,或在等 required 依赖满足;其余状态由各路由自己说明 */
|
|
54
|
+
function notYetActive(state) {
|
|
55
|
+
if (state === 'activating')
|
|
56
|
+
return '仍在激活(超过慢激活阈值,已转入后台),结果以插件列表为准';
|
|
57
|
+
if (state === 'pending')
|
|
58
|
+
return '尚未激活,正在等待 required 依赖满足';
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* 管理动作返回 false 时的回执。条目还在注册表里,说明是停机中或正在卸载拒绝了这次操作,回 409;
|
|
63
|
+
* 只有条目确实不存在才回 404。两种情况都没有写配置文档。
|
|
64
|
+
*/
|
|
65
|
+
function refuse(pm, res, instanceId, action) {
|
|
66
|
+
if (pm.getPlugin(instanceId)) {
|
|
67
|
+
res.status(409).json({ error: `插件 ${instanceId} 当前不接受${action}(停机中或正在卸载),配置未更改` });
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
res.status(404).json({ error: `插件 ${instanceId} 不存在` });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
42
73
|
/** 注册插件管理 + 全局配置相关 REST 路由 */
|
|
43
74
|
export function registerPluginRoutes(expressApp, caps, identify, gate, getAction) {
|
|
44
75
|
const getApp = () => caps.app.current;
|
|
@@ -50,6 +81,41 @@ export function registerPluginRoutes(expressApp, caps, identify, gate, getAction
|
|
|
50
81
|
res.status(503).json({ error: '宿主未提供配置文档(host-config),无法读写配置' });
|
|
51
82
|
return doc;
|
|
52
83
|
};
|
|
84
|
+
/**
|
|
85
|
+
* 把提交的插件配置合进基线(改配置时是配置文档里的原配置,建实例时是空对象),改配置与建实例同一套:
|
|
86
|
+
* 提交里的顶层键整键替换基线的同名键(分组、数组整块替换),没提交的保持原值;值为 null 的顶层键视为删除,
|
|
87
|
+
* 有默认值的回到默认值、没有的不写(前端清空数字、llm-ref 选「继承默认」时发 null,JSON 丢掉的键只是没提交)。
|
|
88
|
+
* 之后按 schema 默认值深合并补齐缺的键,只写了半块的分组也补齐其余默认子键。基线的键在前、缺的默认键追加到末尾,
|
|
89
|
+
* 写回时保留配置文件里原有的键序。
|
|
90
|
+
* 宿主裁剪且有 schema 时再裁掉未知键并 warn(与宿主的配置同步同一政策,host-config 的 trimUnknownFields):
|
|
91
|
+
* 本次提交里的记 ignored,基线里原有、本次没提交的记 removed,由调用方回给请求方,不静默吞掉却回复成功
|
|
92
|
+
*/
|
|
93
|
+
const mergeSubmittedConfig = (doc, instanceId, schema, base, submitted) => {
|
|
94
|
+
const defaults = defaultsFrom(schema);
|
|
95
|
+
const replaced = { ...base, ...submitted };
|
|
96
|
+
for (const [key, value] of Object.entries(submitted)) {
|
|
97
|
+
if (value !== null)
|
|
98
|
+
continue;
|
|
99
|
+
if (Object.hasOwn(defaults, key))
|
|
100
|
+
replaced[key] = defaults[key];
|
|
101
|
+
else
|
|
102
|
+
delete replaced[key];
|
|
103
|
+
}
|
|
104
|
+
let merged = deepMergeDefaults(defaults, replaced);
|
|
105
|
+
const ignored = [];
|
|
106
|
+
const removed = [];
|
|
107
|
+
if (doc.trimUnknownFields !== false && schema && Object.keys(schema).length > 0) {
|
|
108
|
+
const shape = schema;
|
|
109
|
+
for (const [key, value] of Object.entries(merged)) {
|
|
110
|
+
removeExtraFields({ [key]: value }, shape, Object.hasOwn(submitted, key) ? ignored : removed);
|
|
111
|
+
}
|
|
112
|
+
merged = removeExtraFields(merged, shape);
|
|
113
|
+
if (ignored.length + removed.length > 0) {
|
|
114
|
+
caps.logger?.warn(`配置同步:${instanceId} 裁掉 schema 外字段 [${[...ignored, ...removed].join(', ')}]`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return { merged, ignored, removed };
|
|
118
|
+
};
|
|
53
119
|
// 获取插件列表及状态
|
|
54
120
|
expressApp.get('/api/plugins', gate(), (_req, res) => {
|
|
55
121
|
const app = getApp();
|
|
@@ -88,9 +154,6 @@ export function registerPluginRoutes(expressApp, caps, identify, gate, getAction
|
|
|
88
154
|
}
|
|
89
155
|
const plugins = pm.getStatus().map(p => {
|
|
90
156
|
const entry = pm.getPlugin(p.instanceId);
|
|
91
|
-
const schema = entry?.definition?.configSchema;
|
|
92
|
-
// 列表给概览用:schema.secret 字段换成固定掩码。编辑器走 GET /api/plugins/:name/config,那条不脱敏。
|
|
93
|
-
const config = maskSecretFields(cloneConfigObject((entry?.config ?? {})), schema);
|
|
94
157
|
return {
|
|
95
158
|
name: p.name,
|
|
96
159
|
instanceId: p.instanceId,
|
|
@@ -108,8 +171,9 @@ export function registerPluginRoutes(expressApp, caps, identify, gate, getAction
|
|
|
108
171
|
reusable: p.reusable ?? false,
|
|
109
172
|
// extends / config / configSchema / defaultConfig 非内核状态摘要字段
|
|
110
173
|
// (getStatus 只含内核事实):从 entry.config / entry.definition 补齐给前端。
|
|
174
|
+
// 配置按原值给出:前端据它建编辑草稿、整份保存,secret 字段只在显示时遮蔽
|
|
111
175
|
extends: entry?.definition?.extends,
|
|
112
|
-
config,
|
|
176
|
+
config: entry?.config ?? {},
|
|
113
177
|
configSchema: entry?.definition?.configSchema,
|
|
114
178
|
defaultConfig: defaultsFrom(entry?.definition?.configSchema),
|
|
115
179
|
error: p.error,
|
|
@@ -204,30 +268,31 @@ export function registerPluginRoutes(expressApp, caps, identify, gate, getAction
|
|
|
204
268
|
// 所以不能按键报错:其余键一律不应用,但把真有改动的点名回给调用方——不静默吞掉却回复「已保存」。
|
|
205
269
|
const allowed = Object.keys(CORE_CONFIG_SCHEMA);
|
|
206
270
|
const current = doc.getAll();
|
|
271
|
+
const defaultOf = (k) => CORE_CONFIG_SCHEMA[k]?.default;
|
|
207
272
|
// 文档里没写的核心键按 schema 默认值比较:内置前端把默认值回填进草稿后整份回传,不能据此判成改动(否则只改
|
|
208
273
|
// 名字也会把默认值写进文件并触发重启)
|
|
209
|
-
const currentOf = (k) => current[k] ??
|
|
274
|
+
const currentOf = (k) => current[k] ?? defaultOf(k);
|
|
210
275
|
const differs = (k) => !isDeepStrictEqual(updates[k], currentOf(k));
|
|
276
|
+
// 核心键提交 null 表示清空(前端清空数字框时发 null):回到默认值,并从文档里删掉这个键,不把默认值写死进文件
|
|
277
|
+
const submittedOf = (k) => (updates[k] === null ? defaultOf(k) : updates[k]);
|
|
211
278
|
const ignored = Object.keys(updates).filter(k => k !== '_schema' && !allowed.includes(k) && differs(k));
|
|
212
|
-
const changed = allowed.filter(k => k in updates &&
|
|
213
|
-
//
|
|
214
|
-
|
|
215
|
-
const picked = Object.fromEntries(changed.map(k => [k, updates[k]]));
|
|
279
|
+
const changed = allowed.filter(k => k in updates && !isDeepStrictEqual(submittedOf(k), currentOf(k)));
|
|
280
|
+
// 与插件配置路径同一把尺子:类型不符、越界或不在选项里的值(如 logLevel 传 nope)不落盘。
|
|
281
|
+
const picked = Object.fromEntries(changed.map(k => [k, submittedOf(k)]));
|
|
216
282
|
const invalid = validateConfig(CORE_CONFIG_SCHEMA, picked).map(i => `${i.path}: ${i.message}`);
|
|
217
|
-
for (const k of changed) {
|
|
218
|
-
const field = CORE_CONFIG_SCHEMA[k];
|
|
219
|
-
if (field.type === 'select' && field.options && !field.options.some(o => o.value === updates[k])) {
|
|
220
|
-
invalid.push(`${k}: 取值不在可选范围`);
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
283
|
if (invalid.length > 0) {
|
|
224
284
|
res.status(400).json({ error: invalid.join('; ') });
|
|
225
285
|
return;
|
|
226
286
|
}
|
|
287
|
+
// 写进文档的是按词汇换算后的值(加了引号的数字转成数字、写成数字的名称转成字符串):runtime 启动时
|
|
288
|
+
// 直接读这几个键,不经 parseConfig
|
|
289
|
+
const values = parseConfig(CORE_CONFIG_SCHEMA, picked);
|
|
227
290
|
const previous = Object.fromEntries(changed.map(k => [k, current[k]]));
|
|
291
|
+
// 提交 null 的键置为 undefined,写文件时省略
|
|
228
292
|
for (const key of changed)
|
|
229
|
-
doc.set(key, updates[key]);
|
|
230
|
-
// logLevel 与 slowThresholdMs 只在启动时读取,改了才重启;name 由 /api/status
|
|
293
|
+
doc.set(key, updates[key] === null ? undefined : values[key]);
|
|
294
|
+
// logLevel 与 slowThresholdMs 只在启动时读取,改了才重启;name 由 /api/status 每次实时读文档(appName),保存即生效。
|
|
295
|
+
// 装有人设时聊天显示人设名,应用名称只在仪表盘上看得到
|
|
231
296
|
const restartNeeded = changed.some(k => k === 'logLevel' || k === 'slowThresholdMs');
|
|
232
297
|
const note = ignored.length > 0 ? `(已忽略不可修改的字段: ${ignored.join(', ')})` : '';
|
|
233
298
|
try {
|
|
@@ -261,8 +326,7 @@ export function registerPluginRoutes(expressApp, caps, identify, gate, getAction
|
|
|
261
326
|
}
|
|
262
327
|
res.json({ ok: true, message: `全局配置已更新,正在重启应用以生效…${note}`, restart: true, ignored });
|
|
263
328
|
});
|
|
264
|
-
//
|
|
265
|
-
// 列表 GET /api/plugins 已把 schema.secret 换成固定掩码;本接口必须是原文,否则保存会把掩码写回。
|
|
329
|
+
// 获取单个插件在配置文档里的配置
|
|
266
330
|
expressApp.get('/api/plugins/:name/config', gate(), (req, res) => {
|
|
267
331
|
const pluginName = req.params.name;
|
|
268
332
|
const doc = docOr503(res);
|
|
@@ -280,7 +344,7 @@ export function registerPluginRoutes(expressApp, caps, identify, gate, getAction
|
|
|
280
344
|
expressApp.put('/api/plugins/:name/config', gate(), async (req, res) => {
|
|
281
345
|
const pluginName = req.params.name;
|
|
282
346
|
const newConfig = req.body?.config;
|
|
283
|
-
if (!newConfig || typeof newConfig !== 'object') {
|
|
347
|
+
if (!newConfig || typeof newConfig !== 'object' || Array.isArray(newConfig)) {
|
|
284
348
|
res.status(400).json({ error: 'config 字段必须是对象' });
|
|
285
349
|
return;
|
|
286
350
|
}
|
|
@@ -292,46 +356,22 @@ export function registerPluginRoutes(expressApp, caps, identify, gate, getAction
|
|
|
292
356
|
const doc = docOr503(res);
|
|
293
357
|
if (!doc)
|
|
294
358
|
return;
|
|
295
|
-
//
|
|
296
|
-
// entry.config = newConfig
|
|
297
|
-
//
|
|
359
|
+
// 合进原配置再交给 updateConfig:后者是**整体替换**语义(core 的 orchestration/plugin.ts 里
|
|
360
|
+
// entry.config = newConfig 直接顶掉)。基线取配置文档里的原配置而非裸默认值:defaultsFrom 只收录声明了
|
|
361
|
+
// default 的键,而 apiKey / accessToken 这类 secret 多数**没有** default(deepseek、embedding-openai、
|
|
362
|
+
// llm-openai、serper、onebot 皆是),以裸默认值打底时请求里没带 apiKey,整体替换后用户的密钥就从内存态与
|
|
363
|
+
// yaml 一起消失。叠上原配置后语义才是真正的部分更新:没提交的字段保持原样,要清空得显式提交。
|
|
298
364
|
const entry = pm.getPlugin(pluginName);
|
|
299
365
|
const schema = entry?.definition?.configSchema;
|
|
300
|
-
const defaults = defaultsFrom(schema);
|
|
301
|
-
// 基线取「默认值叠已存值」而非裸默认值:defaultsFrom 只收录声明了 default 的键,
|
|
302
|
-
// 而 apiKey / accessToken 这类 secret 多数**没有** default(deepseek、embedding-openai、
|
|
303
|
-
// llm-openai、serper、onebot 皆是)。用裸 defaults 打底时,请求里没带 apiKey 就等于
|
|
304
|
-
// merged 里根本没有这个键,整体替换后用户的密钥从内存态与 yaml 一起消失。
|
|
305
|
-
// 叠上已存值后语义才是真正的部分更新:没提交的字段保持原样,要清空得显式传空串。
|
|
306
366
|
let docConfig;
|
|
307
367
|
let stored;
|
|
308
368
|
let merged;
|
|
309
|
-
|
|
310
|
-
|
|
369
|
+
let ignored;
|
|
370
|
+
let removed;
|
|
311
371
|
try {
|
|
312
372
|
docConfig = doc.getPluginConfig(pluginName);
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
for (const [key, value] of Object.entries(defaults)) {
|
|
316
|
-
if (!Object.hasOwn(stored, key))
|
|
317
|
-
stored[key] = value;
|
|
318
|
-
}
|
|
319
|
-
const submitted = newConfig;
|
|
320
|
-
merged = { ...stored, ...submitted };
|
|
321
|
-
// 与宿主的配置同步同一政策(host-config 的 trimUnknownFields):宿主裁剪且有 schema 时裁掉未知键并 warn,
|
|
322
|
-
// 避免 WebUI 把手滑字段写进 stored,而 YAML watch 路径却会裁掉;宿主保留未知字段时这里也不裁。
|
|
323
|
-
// 被裁的字段按来源回给调用方,不静默吞掉却回复「已更新」:本次提交里的记 ignored(与 PUT /api/config
|
|
324
|
-
// 同一口径);文档里原有、本次没提交的不是用户这次写的,随整份写回从配置文件删掉,记 removed
|
|
325
|
-
if (doc.trimUnknownFields !== false && schema && Object.keys(schema).length > 0) {
|
|
326
|
-
const shape = schema;
|
|
327
|
-
for (const [key, value] of Object.entries(merged)) {
|
|
328
|
-
removeExtraFields({ [key]: value }, shape, Object.hasOwn(submitted, key) ? ignored : removed);
|
|
329
|
-
}
|
|
330
|
-
merged = removeExtraFields(merged, shape);
|
|
331
|
-
if (ignored.length + removed.length > 0) {
|
|
332
|
-
caps.logger?.warn(`配置同步:${pluginName} 裁掉 schema 外字段 [${[...ignored, ...removed].join(', ')}]`);
|
|
333
|
-
}
|
|
334
|
-
}
|
|
373
|
+
stored = deepMergeDefaults(defaultsFrom(schema), docConfig);
|
|
374
|
+
({ merged, ignored, removed } = mergeSubmittedConfig(doc, pluginName, schema, docConfig, newConfig));
|
|
335
375
|
}
|
|
336
376
|
catch (err) {
|
|
337
377
|
res.status(400).json({ error: errorMessage(err) });
|
|
@@ -343,10 +383,23 @@ export function registerPluginRoutes(expressApp, caps, identify, gate, getAction
|
|
|
343
383
|
// 存量问题放行——否则带着历史脏值(或 schema 表达不了的多态字段,如 mcp-client
|
|
344
384
|
// 的 args 数组形态)的插件会在 WebUI 永久存不了任何字段;启动侧 config-sync
|
|
345
385
|
// 对它们已有告警。missing(没配全)也放行:半成品配置是启用插件配到一半的
|
|
346
|
-
//
|
|
347
|
-
//
|
|
348
|
-
|
|
349
|
-
const
|
|
386
|
+
// 正常中间态。
|
|
387
|
+
// 存量按「去掉下标的路径 + 原因」计数,新配置里同类问题的条数超出存量才算新增:list / multiselect /
|
|
388
|
+
// 数组删掉前面的元素后,存量坏元素的下标会前移,按原路径比会把它误判成新增
|
|
389
|
+
const issueKey = (i) => `${i.path.replace(/\[\d+\]/g, '[]')}|${i.message}`;
|
|
390
|
+
const preExisting = new Map();
|
|
391
|
+
for (const i of validateConfig(schema, stored)) {
|
|
392
|
+
const key = issueKey(i);
|
|
393
|
+
preExisting.set(key, (preExisting.get(key) ?? 0) + 1);
|
|
394
|
+
}
|
|
395
|
+
const issues = validateConfig(schema, merged).filter(i => {
|
|
396
|
+
if (i.kind !== 'invalid')
|
|
397
|
+
return false;
|
|
398
|
+
const key = issueKey(i);
|
|
399
|
+
const left = preExisting.get(key) ?? 0;
|
|
400
|
+
preExisting.set(key, left - 1);
|
|
401
|
+
return left <= 0;
|
|
402
|
+
});
|
|
350
403
|
if (issues.length > 0) {
|
|
351
404
|
res.status(400).json({
|
|
352
405
|
error: `配置校验未通过:${issues.map(i => `${i.path}: ${i.message}`).join(';')}`,
|
|
@@ -372,27 +425,30 @@ export function registerPluginRoutes(expressApp, caps, identify, gate, getAction
|
|
|
372
425
|
return;
|
|
373
426
|
}
|
|
374
427
|
if (success) {
|
|
375
|
-
// 按新配置重新激活失败、转为 error:照样写入配置(原样再存即重试激活),但按实际状态回报
|
|
376
|
-
const after = pm.getPlugin(pluginName);
|
|
377
|
-
const failure = after?.state === 'error' ? (after.error ?? '详见日志') : undefined;
|
|
378
428
|
// 管理动作只改运行态;跨重启保留要本路由写文档并落盘
|
|
379
429
|
doc.setPluginConfig(pluginName, merged);
|
|
380
|
-
|
|
430
|
+
// 按新配置重新激活失败、转为 error:照样写入配置(原样再存即重试激活),但按实际状态回报。
|
|
431
|
+
// 禁用态只换上新配置、保持禁用,启用时按它激活
|
|
432
|
+
const after = await settledEntry(pm, pluginName);
|
|
433
|
+
const failure = after?.state === 'error'
|
|
434
|
+
? `插件 ${pluginName} 按新配置重新激活失败,已转为 error 态(${after.error ?? '详见日志'})`
|
|
435
|
+
: undefined;
|
|
436
|
+
const aside = after?.state === 'disabled' ? '插件已禁用,启用时按新配置激活' : notYetActive(after?.state);
|
|
437
|
+
if (!(await saveAfterApply(doc, res, 'reload', failure ?? aside)))
|
|
381
438
|
return;
|
|
382
439
|
if (failure !== undefined) {
|
|
383
|
-
res.status(500).json({
|
|
384
|
-
error: `插件 ${pluginName} 按新配置重新激活失败,已转为 error 态(${failure});配置已写入配置文件${note}`,
|
|
385
|
-
});
|
|
440
|
+
res.status(500).json({ error: `${failure};配置已写入配置文件${note}` });
|
|
386
441
|
return;
|
|
387
442
|
}
|
|
388
|
-
res.json({
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
443
|
+
res.json({
|
|
444
|
+
ok: true,
|
|
445
|
+
message: `插件 ${pluginName} 配置已更新${note}${aside ? `;${aside}` : ''}`,
|
|
446
|
+
ignored,
|
|
447
|
+
removed,
|
|
448
|
+
});
|
|
393
449
|
}
|
|
394
450
|
else {
|
|
395
|
-
|
|
451
|
+
refuse(pm, res, pluginName, '配置更新');
|
|
396
452
|
}
|
|
397
453
|
});
|
|
398
454
|
// 启用插件
|
|
@@ -415,22 +471,23 @@ export function registerPluginRoutes(expressApp, caps, identify, gate, getAction
|
|
|
415
471
|
return;
|
|
416
472
|
}
|
|
417
473
|
if (success) {
|
|
418
|
-
// 激活失败、转为 error:照样记为启用(重启时再激活),但按实际状态回报
|
|
419
|
-
const after = pm.getPlugin(pluginName);
|
|
420
|
-
const failure = after?.state === 'error' ? (after.error ?? '详见日志') : undefined;
|
|
421
474
|
doc.setPluginEnabled(pluginName, true);
|
|
422
|
-
|
|
475
|
+
// 激活失败、转为 error:照样记为启用(重启时再激活),但按实际状态回报
|
|
476
|
+
const after = await settledEntry(pm, pluginName);
|
|
477
|
+
const failure = after?.state === 'error'
|
|
478
|
+
? `插件 ${pluginName} 激活失败,已转为 error 态(${after.error ?? '详见日志'})`
|
|
479
|
+
: undefined;
|
|
480
|
+
const aside = notYetActive(after?.state);
|
|
481
|
+
if (!(await saveAfterApply(doc, res, 'restart', failure ?? aside)))
|
|
423
482
|
return;
|
|
424
483
|
if (failure !== undefined) {
|
|
425
|
-
res
|
|
426
|
-
.status(500)
|
|
427
|
-
.json({ error: `插件 ${pluginName} 激活失败,已转为 error 态(${failure});配置文件已记为启用` });
|
|
484
|
+
res.status(500).json({ error: `${failure};配置文件已记为启用` });
|
|
428
485
|
return;
|
|
429
486
|
}
|
|
430
|
-
res.json({ ok: true, message: `插件 ${pluginName}
|
|
487
|
+
res.json({ ok: true, message: `插件 ${pluginName} 已启用${aside ? `;${aside}` : ''}` });
|
|
431
488
|
}
|
|
432
489
|
else {
|
|
433
|
-
|
|
490
|
+
refuse(pm, res, pluginName, '启用');
|
|
434
491
|
}
|
|
435
492
|
});
|
|
436
493
|
// 禁用插件
|
|
@@ -454,20 +511,20 @@ export function registerPluginRoutes(expressApp, caps, identify, gate, getAction
|
|
|
454
511
|
}
|
|
455
512
|
if (success) {
|
|
456
513
|
// 仍在初始化、不响应 abort 的插件,宽限到期转 error 而非 disabled:照样记为禁用(重启时不再激活),但按实际状态回报
|
|
457
|
-
const stuck = pm.getPlugin(pluginName)?.state === 'error'
|
|
514
|
+
const stuck = pm.getPlugin(pluginName)?.state === 'error'
|
|
515
|
+
? `插件 ${pluginName} 未在宽限内停止,已转为 error 态,详见日志`
|
|
516
|
+
: undefined;
|
|
458
517
|
doc.setPluginEnabled(pluginName, false);
|
|
459
|
-
if (!(await saveAfterApply(doc, res, 'restart')))
|
|
518
|
+
if (!(await saveAfterApply(doc, res, 'restart', stuck)))
|
|
460
519
|
return;
|
|
461
|
-
if (stuck) {
|
|
462
|
-
res
|
|
463
|
-
.status(500)
|
|
464
|
-
.json({ error: `插件 ${pluginName} 未在宽限内停止,已转为 error 态,详见日志;配置文件已记为禁用` });
|
|
520
|
+
if (stuck !== undefined) {
|
|
521
|
+
res.status(500).json({ error: `${stuck};配置文件已记为禁用` });
|
|
465
522
|
return;
|
|
466
523
|
}
|
|
467
524
|
res.json({ ok: true, message: `插件 ${pluginName} 已禁用` });
|
|
468
525
|
}
|
|
469
526
|
else {
|
|
470
|
-
|
|
527
|
+
refuse(pm, res, pluginName, '禁用');
|
|
471
528
|
}
|
|
472
529
|
});
|
|
473
530
|
// 经宿主 plugin-source 重新扫描并加载新插件
|
|
@@ -499,6 +556,10 @@ export function registerPluginRoutes(expressApp, caps, identify, gate, getAction
|
|
|
499
556
|
res.status(400).json({ error: 'suffix 只能包含字母、数字、下划线和连字符' });
|
|
500
557
|
return;
|
|
501
558
|
}
|
|
559
|
+
if (typeof config !== 'object' || Array.isArray(config)) {
|
|
560
|
+
res.status(400).json({ error: 'config 字段必须是对象' });
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
502
563
|
const pm = getPluginMgr();
|
|
503
564
|
if (!pm) {
|
|
504
565
|
res.status(500).json({ error: '插件管理服务不可用' });
|
|
@@ -508,7 +569,7 @@ export function registerPluginRoutes(expressApp, caps, identify, gate, getAction
|
|
|
508
569
|
if (!doc)
|
|
509
570
|
return;
|
|
510
571
|
// 实例创建编排(配置文件编排属管理面,内核只出 register 机制):
|
|
511
|
-
// 查同名 module → reusable/查重校验 →
|
|
572
|
+
// 查同名 module → reusable/查重校验 → 合并默认配置、裁剪与校验 → 写入 → register 激活。
|
|
512
573
|
const sourceModule = pm
|
|
513
574
|
.getStatus()
|
|
514
575
|
.map(p => pm.getPlugin(p.instanceId)?.definition)
|
|
@@ -526,7 +587,19 @@ export function registerPluginRoutes(expressApp, caps, identify, gate, getAction
|
|
|
526
587
|
res.status(400).json({ error: `无法创建实例:"${instanceId}" 已存在` });
|
|
527
588
|
return;
|
|
528
589
|
}
|
|
529
|
-
|
|
590
|
+
// 与改配置同一套合并与裁剪,基线是空对象。校验不分存量:新实例没有历史配置,类型不符的值一律拒绝,
|
|
591
|
+
// missing 照样放行(半成品配置是配到一半的正常中间态)
|
|
592
|
+
const schema = sourceModule.configSchema;
|
|
593
|
+
const { merged: mergedConfig, ignored } = mergeSubmittedConfig(doc, instanceId, schema, {}, config);
|
|
594
|
+
const issues = validateConfig(schema, mergedConfig).filter(i => i.kind === 'invalid');
|
|
595
|
+
if (issues.length > 0) {
|
|
596
|
+
res.status(400).json({
|
|
597
|
+
error: `配置校验未通过:${issues.map(i => `${i.path}: ${i.message}`).join(';')}`,
|
|
598
|
+
issues,
|
|
599
|
+
});
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
const note = ignored.length > 0 ? `(已忽略未声明的配置字段: ${ignored.join(', ')})` : '';
|
|
530
603
|
let registered;
|
|
531
604
|
try {
|
|
532
605
|
doc.setPluginConfig(instanceId, mergedConfig);
|
|
@@ -545,9 +618,21 @@ export function registerPluginRoutes(expressApp, caps, identify, gate, getAction
|
|
|
545
618
|
res.status(409).json({ error: `实例 ${instanceId} 未登记(停机中或被拒,见日志),配置未写入` });
|
|
546
619
|
return;
|
|
547
620
|
}
|
|
548
|
-
|
|
621
|
+
// 激活失败、转为 error:实例与配置照样保留并落盘(改配置保存即重试激活),但按实际状态回报
|
|
622
|
+
const after = await settledEntry(pm, instanceId);
|
|
623
|
+
const failure = after?.state === 'error'
|
|
624
|
+
? `已创建实例 ${instanceId},但激活失败,已转为 error 态(${after.error ?? '详见日志'})`
|
|
625
|
+
: undefined;
|
|
626
|
+
const aside = after?.state === 'disabled'
|
|
627
|
+
? '配置文件的 disabledPlugins 里有它,已按禁用态登记,启用后激活'
|
|
628
|
+
: notYetActive(after?.state);
|
|
629
|
+
if (!(await saveAfterApply(doc, res, 'reload', failure ?? aside)))
|
|
630
|
+
return;
|
|
631
|
+
if (failure !== undefined) {
|
|
632
|
+
res.status(500).json({ error: `${failure};配置已写入配置文件${note}` });
|
|
549
633
|
return;
|
|
550
|
-
|
|
634
|
+
}
|
|
635
|
+
res.json({ ok: true, instanceId, message: `已创建实例 ${instanceId}${note}${aside ? `;${aside}` : ''}`, ignored });
|
|
551
636
|
});
|
|
552
637
|
// 删除插件多实例
|
|
553
638
|
expressApp.delete('/api/plugins/:instanceId/instance', gate(), async (req, res) => {
|
|
@@ -560,23 +645,40 @@ export function registerPluginRoutes(expressApp, caps, identify, gate, getAction
|
|
|
560
645
|
const doc = docOr503(res);
|
|
561
646
|
if (!doc)
|
|
562
647
|
return;
|
|
563
|
-
// 实例删除编排:主实例保护 → unload
|
|
648
|
+
// 实例删除编排:主实例保护 → 移除配置条目与禁用标记(同名重建时不再以禁用态登记)并落盘 → unload(内部含级联重算)。
|
|
649
|
+
// 与其它管理路由「先生效后落盘」相反:后缀实例由配置段定义,热重载也按文件登记。先卸载后落盘时,卸载途中外部改动
|
|
650
|
+
// 触发的热重载会按仍带配置段的文件把实例重新登记,落盘随后删掉配置段,留下文件里没有的在跑实例
|
|
564
651
|
const { suffix } = parseInstanceId(instanceId);
|
|
565
652
|
if (!suffix || !pm.getPlugin(instanceId)) {
|
|
566
653
|
res.status(400).json({ error: `无法删除(实例不存在或不允许删除主实例)` });
|
|
567
654
|
return;
|
|
568
655
|
}
|
|
656
|
+
const previous = Object.hasOwn(doc.getAll().plugins, instanceId) ? doc.getPluginConfig(instanceId) : undefined;
|
|
657
|
+
const wasDisabled = doc.isPluginDisabled(instanceId);
|
|
658
|
+
doc.removePluginConfig(instanceId);
|
|
659
|
+
doc.setPluginEnabled(instanceId, true);
|
|
569
660
|
try {
|
|
570
|
-
await
|
|
571
|
-
doc.removePluginConfig(instanceId);
|
|
572
|
-
doc.setPluginEnabled(instanceId, true);
|
|
661
|
+
await doc.save();
|
|
573
662
|
}
|
|
574
663
|
catch (err) {
|
|
575
|
-
|
|
664
|
+
// 没写进文件就不卸载,并撤回文档里的改动,免得下一次任意保存把这次没删成的删除写进文件
|
|
665
|
+
if (previous)
|
|
666
|
+
doc.setPluginConfig(instanceId, previous);
|
|
667
|
+
doc.setPluginEnabled(instanceId, !wasDisabled);
|
|
668
|
+
res
|
|
669
|
+
.status(isConfigSaveRefused(err) ? 409 : 500)
|
|
670
|
+
.json({ error: `未删除实例 ${instanceId}:未写入配置文件(${errorMessage(err)})` });
|
|
576
671
|
return;
|
|
577
672
|
}
|
|
578
|
-
|
|
673
|
+
try {
|
|
674
|
+
await pm.unload(instanceId);
|
|
675
|
+
}
|
|
676
|
+
catch (err) {
|
|
677
|
+
res.status(500).json({
|
|
678
|
+
error: `已从配置文件删除实例 ${instanceId},但卸载失败(${errorMessage(err)});重启后不再登记`,
|
|
679
|
+
});
|
|
579
680
|
return;
|
|
681
|
+
}
|
|
580
682
|
res.json({ ok: true, message: `已删除实例 ${instanceId}` });
|
|
581
683
|
});
|
|
582
684
|
// 保存配置文档:与其它落盘路由同一口径,宿主拒写回 409、其它失败回 500。宿主不持久化时 save 不写盘,
|
|
@@ -594,41 +696,4 @@ export function registerPluginRoutes(expressApp, caps, identify, gate, getAction
|
|
|
594
696
|
}
|
|
595
697
|
});
|
|
596
698
|
}
|
|
597
|
-
/** 列表 payload 用的固定掩码。与 PUT 响应「不回显密钥」同一政策:概览面看不到明文。 */
|
|
598
|
-
const SECRET_MASK = '••••••';
|
|
599
|
-
/**
|
|
600
|
-
* 把 schema.secret === true 的字段换成固定掩码。入参须已是拷贝——就地改,避免写穿现场 config。
|
|
601
|
-
* 分组递归;数组元素若是对象则按 items 再走一遍。缺席的键不补掩码。
|
|
602
|
-
*/
|
|
603
|
-
function maskSecretFields(config, schema) {
|
|
604
|
-
if (!schema)
|
|
605
|
-
return config;
|
|
606
|
-
for (const [key, raw] of Object.entries(schema)) {
|
|
607
|
-
if (!raw || typeof raw !== 'object')
|
|
608
|
-
continue;
|
|
609
|
-
const def = raw;
|
|
610
|
-
if (def.secret === true) {
|
|
611
|
-
if (Object.hasOwn(config, key))
|
|
612
|
-
config[key] = SECRET_MASK;
|
|
613
|
-
continue;
|
|
614
|
-
}
|
|
615
|
-
const nested = config[key];
|
|
616
|
-
if (def.fields &&
|
|
617
|
-
typeof def.fields === 'object' &&
|
|
618
|
-
nested &&
|
|
619
|
-
typeof nested === 'object' &&
|
|
620
|
-
!Array.isArray(nested)) {
|
|
621
|
-
maskSecretFields(nested, def.fields);
|
|
622
|
-
continue;
|
|
623
|
-
}
|
|
624
|
-
if (def.type === 'array' && Array.isArray(nested) && def.items && typeof def.items === 'object') {
|
|
625
|
-
for (const item of nested) {
|
|
626
|
-
if (item && typeof item === 'object' && !Array.isArray(item)) {
|
|
627
|
-
maskSecretFields(item, def.items);
|
|
628
|
-
}
|
|
629
|
-
}
|
|
630
|
-
}
|
|
631
|
-
}
|
|
632
|
-
return config;
|
|
633
|
-
}
|
|
634
699
|
//# sourceMappingURL=plugins.js.map
|