@aalis/plugin-webui-server 0.14.0 → 0.15.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/README.md +5 -1
- package/dist/admin-channel.d.ts +10 -0
- package/dist/admin-channel.d.ts.map +1 -0
- package/dist/admin-channel.js +57 -0
- package/dist/admin-channel.js.map +1 -0
- package/dist/attention.d.ts +36 -0
- package/dist/attention.d.ts.map +1 -0
- package/dist/attention.js +73 -0
- package/dist/attention.js.map +1 -0
- package/dist/auth.d.ts +22 -3
- package/dist/auth.d.ts.map +1 -1
- package/dist/auth.js +192 -68
- package/dist/auth.js.map +1 -1
- package/dist/client-discovery.d.ts +7 -3
- package/dist/client-discovery.d.ts.map +1 -1
- package/dist/client-discovery.js +9 -3
- package/dist/client-discovery.js.map +1 -1
- package/dist/client-switch-page.d.ts +2 -0
- package/dist/client-switch-page.d.ts.map +1 -1
- package/dist/client-switch-page.js +37 -36
- package/dist/client-switch-page.js.map +1 -1
- package/dist/gate.d.ts +3 -3
- package/dist/gate.js +3 -3
- package/dist/index.d.ts +21 -26
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +577 -1018
- package/dist/index.js.map +1 -1
- package/dist/layout.d.ts +18 -0
- package/dist/layout.d.ts.map +1 -0
- package/dist/layout.js +198 -0
- package/dist/layout.js.map +1 -0
- package/dist/logs.d.ts +10 -0
- package/dist/logs.d.ts.map +1 -0
- package/dist/logs.js +50 -0
- package/dist/logs.js.map +1 -0
- package/dist/page-check.d.ts +56 -0
- package/dist/page-check.d.ts.map +1 -0
- package/dist/page-check.js +369 -0
- package/dist/page-check.js.map +1 -0
- package/dist/page-files.d.ts +13 -0
- package/dist/page-files.d.ts.map +1 -0
- package/dist/page-files.js +39 -0
- package/dist/page-files.js.map +1 -0
- package/dist/pages.d.ts +60 -0
- package/dist/pages.d.ts.map +1 -0
- package/dist/pages.js +470 -0
- package/dist/pages.js.map +1 -0
- package/dist/route.d.ts +11 -0
- package/dist/route.d.ts.map +1 -0
- package/dist/route.js +13 -0
- package/dist/route.js.map +1 -0
- package/dist/routes/files.d.ts +8 -9
- package/dist/routes/files.d.ts.map +1 -1
- package/dist/routes/files.js +32 -119
- package/dist/routes/files.js.map +1 -1
- package/dist/routes/frames.d.ts +4 -0
- package/dist/routes/frames.d.ts.map +1 -0
- package/dist/routes/frames.js +103 -0
- package/dist/routes/frames.js.map +1 -0
- package/dist/routes/marketplace.d.ts +46 -129
- package/dist/routes/marketplace.d.ts.map +1 -1
- package/dist/routes/marketplace.js +138 -86
- package/dist/routes/marketplace.js.map +1 -1
- package/dist/routes/media.d.ts +7 -0
- package/dist/routes/media.d.ts.map +1 -0
- package/dist/routes/media.js +63 -0
- package/dist/routes/media.js.map +1 -0
- package/dist/routes/pages.d.ts +20 -0
- package/dist/routes/pages.d.ts.map +1 -0
- package/dist/routes/pages.js +86 -0
- package/dist/routes/pages.js.map +1 -0
- package/dist/routes/pipe-to-response.d.ts +9 -0
- package/dist/routes/pipe-to-response.d.ts.map +1 -0
- package/dist/routes/pipe-to-response.js +16 -0
- package/dist/routes/pipe-to-response.js.map +1 -0
- package/dist/routes/plugins.d.ts +22 -24
- package/dist/routes/plugins.d.ts.map +1 -1
- package/dist/routes/plugins.js +384 -361
- package/dist/routes/plugins.js.map +1 -1
- package/dist/routes/proxy.d.ts.map +1 -1
- package/dist/routes/proxy.js +2 -1
- package/dist/routes/proxy.js.map +1 -1
- package/dist/session-transport.d.ts +6 -0
- package/dist/session-transport.d.ts.map +1 -0
- package/dist/session-transport.js +32 -0
- package/dist/session-transport.js.map +1 -0
- package/package.json +34 -31
- package/dist/protocol.d.ts +0 -113
- package/dist/protocol.d.ts.map +0 -1
- package/dist/protocol.js +0 -59
- package/dist/protocol.js.map +0 -1
- package/dist/routes/uploaded-files.d.ts +0 -20
- package/dist/routes/uploaded-files.d.ts.map +0 -1
- package/dist/routes/uploaded-files.js +0 -161
- package/dist/routes/uploaded-files.js.map +0 -1
package/dist/routes/plugins.js
CHANGED
|
@@ -1,17 +1,20 @@
|
|
|
1
1
|
import { isDeepStrictEqual } from 'node:util';
|
|
2
|
+
import { describeUnmet, unmetDependencies } from '@aalis/api-doctor';
|
|
2
3
|
import { isConfigSaveRefused } from '@aalis/api-host-config';
|
|
3
4
|
import { parseInstanceId } from '@aalis/core';
|
|
4
|
-
import { CORE_CONFIG_SCHEMA,
|
|
5
|
+
import { CORE_CONFIG_SCHEMA, deepMergeDefaults, defaultsFrom, isSecretRef, mapSecretFields, parseConfig, removeExtraFields, validateConfig, } from '@aalis/schema-config';
|
|
6
|
+
import { blockedStatus } from '../attention.js';
|
|
7
|
+
import { route } from '../route.js';
|
|
5
8
|
function errorMessage(err) {
|
|
6
9
|
return err instanceof Error ? err.message : String(err);
|
|
7
10
|
}
|
|
8
11
|
/**
|
|
9
|
-
*
|
|
10
|
-
*
|
|
12
|
+
* 落盘被拒后运行态怎样与文件对齐:插件配置、新建的实例与服务偏好随配置文件热重载(文件里没有配置段的后缀实例会被卸载);
|
|
13
|
+
* 启停只在重启时按文件登记。被拒的可能是配置文件,也可能是密钥文件,拒写原因里点了名,这里不指具体哪个文件
|
|
11
14
|
*/
|
|
12
15
|
const RECONCILE = {
|
|
13
|
-
reload: '
|
|
14
|
-
restart: '
|
|
16
|
+
reload: '修好该文件后会按文件内容重载',
|
|
17
|
+
restart: '修好该文件后,重启时以文件内容为准',
|
|
15
18
|
};
|
|
16
19
|
/**
|
|
17
20
|
* 管理动作改完运行态之后落盘;失败时回 `applied: true` 并返回 false:改动已在运行态生效、只是没写进文件,
|
|
@@ -26,22 +29,40 @@ export async function saveAfterApply(doc, res, reconcile, outcome) {
|
|
|
26
29
|
return true;
|
|
27
30
|
}
|
|
28
31
|
catch (err) {
|
|
29
|
-
|
|
30
|
-
if (isConfigSaveRefused(err)) {
|
|
31
|
-
res.status(409).json({
|
|
32
|
-
error: `${lead}已在运行态生效,但未写入配置文件(${errorMessage(err)});${RECONCILE[reconcile]}`,
|
|
33
|
-
applied: true,
|
|
34
|
-
});
|
|
35
|
-
}
|
|
36
|
-
else {
|
|
37
|
-
res.status(500).json({
|
|
38
|
-
error: `${lead}已在运行态生效,但写入配置文件失败(${errorMessage(err)});改动保留在文档里,下次保存成功时一并写入`,
|
|
39
|
-
applied: true,
|
|
40
|
-
});
|
|
41
|
-
}
|
|
32
|
+
reportSaveFailure(res, err, reconcile, outcome);
|
|
42
33
|
return false;
|
|
43
34
|
}
|
|
44
35
|
}
|
|
36
|
+
/** 落盘失败的回执,口径见 {@link saveAfterApply};applyPluginConfig 带回的 saveError 同样用它 */
|
|
37
|
+
function reportSaveFailure(res, err, reconcile, outcome) {
|
|
38
|
+
const lead = outcome ? `${outcome};` : '';
|
|
39
|
+
if (isConfigSaveRefused(err)) {
|
|
40
|
+
res.status(409).json({
|
|
41
|
+
error: `${lead}已在运行态生效,但未写入配置文件(${errorMessage(err)});${RECONCILE[reconcile]}`,
|
|
42
|
+
applied: true,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
res.status(500).json({
|
|
47
|
+
error: `${lead}已在运行态生效,但写入配置文件失败(${errorMessage(err)});改动保留在文档里,下次保存成功时一并写入`,
|
|
48
|
+
applied: true,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/** 宿主配置里要重启才生效的键:runtime 与 core 只在启动时读它们。name 只是显示用的应用名称,保存即生效 */
|
|
53
|
+
const RESTART_KEYS = ['logLevel', 'slowThresholdMs'];
|
|
54
|
+
/** 文档里每个密钥键被引用的处数:表单据此提示「替换会同时改到另外 N 处」 */
|
|
55
|
+
function countSecretRefs(value, into = {}) {
|
|
56
|
+
if (isSecretRef(value))
|
|
57
|
+
into[value.$secret] = (into[value.$secret] ?? 0) + 1;
|
|
58
|
+
else if (Array.isArray(value))
|
|
59
|
+
for (const item of value)
|
|
60
|
+
countSecretRefs(item, into);
|
|
61
|
+
else if (value !== null && typeof value === 'object')
|
|
62
|
+
for (const v of Object.values(value))
|
|
63
|
+
countSecretRefs(v, into);
|
|
64
|
+
return into;
|
|
65
|
+
}
|
|
45
66
|
/**
|
|
46
67
|
* 管理动作受理后等重算落定,再读实例状态写回执:动作撞上在飞的重算时只排队、立即返回,立即读到的还是 pending,
|
|
47
68
|
* 之后的激活失败就漏报了。idle 不等转入后台的慢激活:重算对单个激活至多等到慢激活阈值。
|
|
@@ -50,212 +71,211 @@ async function settledEntry(pm, instanceId) {
|
|
|
50
71
|
await pm.idle();
|
|
51
72
|
return pm.getPlugin(instanceId);
|
|
52
73
|
}
|
|
53
|
-
/**
|
|
54
|
-
|
|
74
|
+
/**
|
|
75
|
+
* 落定后既没激活也没失败的说明:慢激活转入后台仍在进行,或 required 依赖没满足(缺什么、在等谁,按 api-doctor 的共享判据
|
|
76
|
+
* 沿链追到根,与插件列表同一说法),不缺服务时在等自己的旧实例停下(同插件列表,说法同依赖链的 lingering 根);依赖都在、
|
|
77
|
+
* 只是提供者正在关停换代时没有可说的;其余状态由各路由自己说明
|
|
78
|
+
*/
|
|
79
|
+
function notYetActive(pm, services, instanceId, state) {
|
|
55
80
|
if (state === 'activating')
|
|
56
81
|
return '仍在激活(超过慢激活阈值,已转入后台),结果以插件列表为准';
|
|
57
|
-
if (state
|
|
58
|
-
return
|
|
59
|
-
|
|
82
|
+
if (state !== 'pending')
|
|
83
|
+
return undefined;
|
|
84
|
+
const status = pm.getStatus();
|
|
85
|
+
const unmet = unmetDependencies(status, services.names()).get(instanceId);
|
|
86
|
+
const lingering = !unmet?.lacking.length && status.find(entry => entry.instanceId === instanceId)?.lingering;
|
|
87
|
+
const why = lingering ? `等待 ${instanceId} 的旧实例停下` : unmet && describeUnmet(unmet);
|
|
88
|
+
return why ? `尚未激活:${why}` : '尚未激活,结果以插件列表为准';
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* 管理动作返回 false 时的回执。条目还在注册表里,说明是停机中或正在卸载拒绝了这次操作,回 409;
|
|
92
|
+
* 只有条目确实不存在才回 404。两种情况都没有写配置文档。
|
|
93
|
+
*/
|
|
94
|
+
function refuse(pm, res, instanceId, action) {
|
|
95
|
+
if (pm.getPlugin(instanceId)) {
|
|
96
|
+
res.status(409).json({ error: `插件 ${instanceId} 当前不接受${action}(停机中或正在卸载),配置未更改` });
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
res.status(404).json({ error: `插件 ${instanceId} 不存在` });
|
|
100
|
+
}
|
|
60
101
|
}
|
|
61
102
|
/** 注册插件管理 + 全局配置相关 REST 路由 */
|
|
62
|
-
export function registerPluginRoutes(expressApp, caps,
|
|
63
|
-
const getApp = () => caps.app.current;
|
|
64
|
-
const getPluginMgr = () => caps.plugins.current;
|
|
103
|
+
export function registerPluginRoutes(expressApp, caps, gate) {
|
|
65
104
|
/** 配置文档由宿主提供;没有时读写文档的路由一律 503,与扫描、市场路由的缺席口径一致 */
|
|
66
105
|
const docOr503 = (res) => {
|
|
67
|
-
const doc = caps.hostConfig.
|
|
106
|
+
const doc = caps.hostConfig.present ? caps.hostConfig.use : undefined;
|
|
68
107
|
if (!doc)
|
|
69
108
|
res.status(503).json({ error: '宿主未提供配置文档(host-config),无法读写配置' });
|
|
70
109
|
return doc;
|
|
71
110
|
};
|
|
111
|
+
/** 提供这个界面的插件经界面停掉就回不来了:拒绝停用它 */
|
|
112
|
+
const refuseSelf = (res, name) => {
|
|
113
|
+
if (name !== caps.self)
|
|
114
|
+
return false;
|
|
115
|
+
res.status(409).json({ error: `插件 ${name} 提供这个界面,不能从界面停用` });
|
|
116
|
+
return true;
|
|
117
|
+
};
|
|
118
|
+
/**
|
|
119
|
+
* 把提交的插件配置合进基线(改配置时是配置文档里的原配置,建实例时是空对象),改配置与建实例同一套:
|
|
120
|
+
* 提交里的顶层键整键替换基线的同名键(分组、数组整块替换),没提交的保持原值;值为 null 的顶层键视为删除,
|
|
121
|
+
* 有默认值的回到默认值、没有的不写(前端清空数字、llm-ref 选「继承默认」时发 null,JSON 丢掉的键只是没提交)。
|
|
122
|
+
* 之后按 schema 默认值深合并补齐缺的键,只写了半块的分组也补齐其余默认子键。基线的键在前、缺的默认键追加到末尾,
|
|
123
|
+
* 写回时保留配置文件里原有的键序。
|
|
124
|
+
* 宿主裁剪且有 schema 时再裁掉未知键并 warn(与宿主的配置同步同一政策,host-config 的 trimUnknownFields):
|
|
125
|
+
* 本次提交里的记 ignored,基线里原有、本次没提交的记 removed,由调用方回给请求方,不静默吞掉却回复成功
|
|
126
|
+
*/
|
|
127
|
+
const mergeSubmittedConfig = (doc, instanceId, schema, base, submitted) => {
|
|
128
|
+
const defaults = defaultsFrom(schema);
|
|
129
|
+
const replaced = { ...base, ...submitted };
|
|
130
|
+
for (const [key, value] of Object.entries(submitted)) {
|
|
131
|
+
if (value !== null)
|
|
132
|
+
continue;
|
|
133
|
+
if (Object.hasOwn(defaults, key))
|
|
134
|
+
replaced[key] = defaults[key];
|
|
135
|
+
else
|
|
136
|
+
delete replaced[key];
|
|
137
|
+
}
|
|
138
|
+
let merged = deepMergeDefaults(defaults, replaced);
|
|
139
|
+
const ignored = [];
|
|
140
|
+
const removed = [];
|
|
141
|
+
if (doc.trimUnknownFields !== false && schema && Object.keys(schema).length > 0) {
|
|
142
|
+
const shape = schema;
|
|
143
|
+
for (const [key, value] of Object.entries(merged)) {
|
|
144
|
+
removeExtraFields({ [key]: value }, shape, Object.hasOwn(submitted, key) ? ignored : removed);
|
|
145
|
+
}
|
|
146
|
+
merged = removeExtraFields(merged, shape);
|
|
147
|
+
if (ignored.length + removed.length > 0) {
|
|
148
|
+
caps.logger?.warn(`配置同步:${instanceId} 裁掉 schema 外字段 [${[...ignored, ...removed].join(', ')}]`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return { merged, ignored, removed };
|
|
152
|
+
};
|
|
72
153
|
// 获取插件列表及状态
|
|
73
|
-
expressApp
|
|
74
|
-
const app =
|
|
75
|
-
const pm =
|
|
154
|
+
route(expressApp, 'GET /api/plugins', gate(), (_req, res) => {
|
|
155
|
+
const app = caps.app.present ? caps.app.use : undefined;
|
|
156
|
+
const pm = caps.plugins.present ? caps.plugins.use : undefined;
|
|
76
157
|
if (!app || !pm) {
|
|
77
|
-
res.json({ plugins: [] });
|
|
158
|
+
res.json({ plugins: [], secretRefCounts: {} });
|
|
78
159
|
return;
|
|
79
160
|
}
|
|
80
|
-
// 反向索引:instanceId -> tools / commands
|
|
81
|
-
//
|
|
82
|
-
// 会让 name:suffix 误挂主实例的工具。能力披露用聚合 capability。
|
|
161
|
+
// 反向索引:instanceId -> tools / commands。生产登记的 pluginName 就是 contextId(= instanceId);
|
|
162
|
+
// 按 definition.name 索引会让 name:suffix 误挂主实例的工具
|
|
83
163
|
const toolsByPlugin = new Map();
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
if (visibility !== 'restricted')
|
|
87
|
-
return;
|
|
88
|
-
const set = capsByPlugin.get(plugin) ?? new Set();
|
|
89
|
-
set.add('visibility:restricted');
|
|
90
|
-
capsByPlugin.set(plugin, set);
|
|
91
|
-
};
|
|
92
|
-
const tools = caps.tools.current?.getAll() ?? [];
|
|
93
|
-
for (const t of tools) {
|
|
94
|
-
const list = toolsByPlugin.get(t.pluginName) ?? [];
|
|
95
|
-
list.push(t.name);
|
|
96
|
-
toolsByPlugin.set(t.pluginName, list);
|
|
97
|
-
addCaps(t.pluginName, t.visibility);
|
|
164
|
+
for (const t of caps.tools.present ? caps.tools.use.getAll() : []) {
|
|
165
|
+
toolsByPlugin.set(t.pluginName, [...(toolsByPlugin.get(t.pluginName) ?? []), t.name]);
|
|
98
166
|
}
|
|
99
167
|
const commandsByPlugin = new Map();
|
|
100
|
-
const
|
|
101
|
-
for (const c of cmds) {
|
|
168
|
+
for (const c of caps.commands.present ? caps.commands.use.getAll() : []) {
|
|
102
169
|
const owner = c.pluginName ?? 'unknown';
|
|
103
|
-
|
|
104
|
-
list.push(c.name);
|
|
105
|
-
commandsByPlugin.set(owner, list);
|
|
106
|
-
addCaps(owner, c.visibility);
|
|
170
|
+
commandsByPlugin.set(owner, [...(commandsByPlugin.get(owner) ?? []), c.name]);
|
|
107
171
|
}
|
|
108
|
-
const
|
|
172
|
+
const doc = caps.hostConfig.present ? caps.hostConfig.use : undefined;
|
|
173
|
+
const sections = doc?.getAll().plugins ?? {};
|
|
174
|
+
const versionOf = caps.versions();
|
|
175
|
+
const plugins = blockedStatus({ plugins: pm, services: caps.services, doc }).map(p => {
|
|
109
176
|
const entry = pm.getPlugin(p.instanceId);
|
|
110
177
|
const schema = entry?.definition?.configSchema;
|
|
111
|
-
//
|
|
112
|
-
const
|
|
178
|
+
// 线上格式照旧给 requiredServices / optionalServices:从 core 状态里唯一的依赖信息 uses 派生
|
|
179
|
+
const requiredServices = p.uses.filter(use => use.kind === 'required').map(use => use.service);
|
|
180
|
+
const optionalServices = p.uses.filter(use => use.kind === 'optional').map(use => use.service);
|
|
181
|
+
const declared = [...requiredServices, ...optionalServices];
|
|
113
182
|
return {
|
|
114
183
|
name: p.name,
|
|
115
184
|
instanceId: p.instanceId,
|
|
116
185
|
displayName: p.displayName,
|
|
186
|
+
// 展示元数据:子系统按插件声明原样给,归哪组、组名与顺序由界面定;版本取已装的包
|
|
187
|
+
subsystem: entry?.definition?.subsystem,
|
|
188
|
+
version: versionOf(p.name),
|
|
117
189
|
state: p.state,
|
|
118
190
|
slow: p.slow,
|
|
191
|
+
lingering: p.lingering,
|
|
192
|
+
self: p.instanceId === caps.self || undefined,
|
|
119
193
|
provides: p.provides ?? [],
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
194
|
+
requiredServices,
|
|
195
|
+
optionalServices,
|
|
196
|
+
servicePreferences: Object.fromEntries(declared.flatMap(name => {
|
|
197
|
+
const preferred = caps.services.preferred(name, { for: p.instanceId });
|
|
198
|
+
return preferred === undefined ? [] : [[name, preferred]];
|
|
199
|
+
})),
|
|
200
|
+
serviceSelections: Object.fromEntries(declared.flatMap(name => {
|
|
201
|
+
const [selected] = caps.services.inspect(name, { for: p.instanceId });
|
|
202
|
+
return selected ? [[name, { contextId: selected.contextId, basis: selected.basis }]] : [];
|
|
203
|
+
})),
|
|
204
|
+
lacking: p.lacking,
|
|
205
|
+
waiting: p.waiting,
|
|
206
|
+
unconfigured: p.unconfigured,
|
|
125
207
|
tools: toolsByPlugin.get(p.instanceId) ?? [],
|
|
126
208
|
commands: commandsByPlugin.get(p.instanceId) ?? [],
|
|
127
209
|
reusable: p.reusable ?? false,
|
|
128
210
|
// extends / config / configSchema / defaultConfig 非内核状态摘要字段
|
|
129
|
-
// (getStatus
|
|
211
|
+
// (getStatus 只含内核事实):从配置文档与 entry.definition 补齐给前端。
|
|
212
|
+
// 配置给文档形态:密钥字段上是引用,不是原值;前端据它建编辑草稿,引用原样发回即保持原值。
|
|
213
|
+
// 宿主不提供配置文档时给运行态,去掉密钥字段(运行态里是原值)
|
|
130
214
|
extends: entry?.definition?.extends,
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
215
|
+
configSection: Object.hasOwn(sections, p.instanceId),
|
|
216
|
+
config: doc ? doc.getPluginConfig(p.instanceId) : mapSecretFields(schema, entry?.config ?? {}, () => undefined),
|
|
217
|
+
configSchema: schema,
|
|
218
|
+
defaultConfig: defaultsFrom(schema),
|
|
134
219
|
error: p.error,
|
|
135
220
|
};
|
|
136
221
|
});
|
|
137
|
-
res.json({ plugins });
|
|
222
|
+
res.json({ plugins, secretRefCounts: doc ? countSecretRefs(doc.getAll().plugins) : {} });
|
|
138
223
|
});
|
|
139
|
-
//
|
|
140
|
-
expressApp
|
|
141
|
-
const app = getApp();
|
|
142
|
-
const pm = getPluginMgr();
|
|
143
|
-
if (!app || !pm) {
|
|
144
|
-
res.json([]);
|
|
145
|
-
return;
|
|
146
|
-
}
|
|
147
|
-
const webuiSvc = caps.webui();
|
|
148
|
-
if (!webuiSvc) {
|
|
149
|
-
res.json([]);
|
|
150
|
-
return;
|
|
151
|
-
}
|
|
152
|
-
const displayNameByPlugin = new Map();
|
|
153
|
-
for (const p of pm.getStatus())
|
|
154
|
-
displayNameByPlugin.set(p.instanceId, p.displayName);
|
|
155
|
-
const pages = [];
|
|
156
|
-
for (const page of webuiSvc.getPages()) {
|
|
157
|
-
pages.push({ ...page, plugin: page.pluginName, pluginDisplayName: displayNameByPlugin.get(page.pluginName) });
|
|
158
|
-
}
|
|
159
|
-
pages.sort((a, b) => (a.order ?? 99) - (b.order ?? 99));
|
|
160
|
-
res.json(pages);
|
|
161
|
-
});
|
|
162
|
-
// 通用声明式页面操作:调用插件经 webuiServer.registerAction 登记的处理函数
|
|
163
|
-
expressApp.post('/api/page-action/:plugin/:method', async (req, res) => {
|
|
164
|
-
const { plugin: pluginName, method } = req.params;
|
|
165
|
-
const args = req.body ?? {};
|
|
166
|
-
const app = getApp();
|
|
167
|
-
const pm = getPluginMgr();
|
|
168
|
-
if (!app || !pm) {
|
|
169
|
-
res.status(500).json({ error: 'App 不可用' });
|
|
170
|
-
return;
|
|
171
|
-
}
|
|
172
|
-
const entry = pm.getPlugin(pluginName);
|
|
173
|
-
if (!entry || entry.state !== 'active') {
|
|
174
|
-
res.status(404).json({ error: `插件 ${pluginName} 不存在或未激活` });
|
|
175
|
-
return;
|
|
176
|
-
}
|
|
177
|
-
const handler = getAction(pluginName, method);
|
|
178
|
-
if (!handler) {
|
|
179
|
-
res.status(404).json({ error: `处理器 ${method} 不存在` });
|
|
180
|
-
return;
|
|
181
|
-
}
|
|
182
|
-
// ===== owner 闸 + 调用者身份(单 owner 终态)=====
|
|
183
|
-
// 单 token ⟺ webui:console ⟺ owner;auth.middleware 已校 token,这里复核身份并把
|
|
184
|
-
// caller 传给 action。各 action 自身对敏感操作再做 owner 自检(如 setUserTier)。
|
|
185
|
-
// 多账户的 action: 能力委托(per-user grant/deny / actionsMeta 可见性)已剥离。
|
|
186
|
-
const caller = identify(req);
|
|
187
|
-
if (!caller) {
|
|
188
|
-
res.status(403).json({ error: `操作 ${pluginName}/${method} 需要 owner 权限` });
|
|
189
|
-
return;
|
|
190
|
-
}
|
|
191
|
-
try {
|
|
192
|
-
res.json({ ok: true, data: await handler(args, caller) });
|
|
193
|
-
}
|
|
194
|
-
catch (err) {
|
|
195
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
196
|
-
res.status(500).json({ error: msg });
|
|
197
|
-
}
|
|
198
|
-
});
|
|
199
|
-
// 获取当前全局配置
|
|
200
|
-
expressApp.get('/api/config', gate(), (_req, res) => {
|
|
224
|
+
// 宿主配置:文档里写了的核心键、它们的 schema、要重启才生效的键与家目录
|
|
225
|
+
route(expressApp, 'GET /api/config', gate(), (_req, res) => {
|
|
201
226
|
const doc = docOr503(res);
|
|
202
227
|
if (!doc)
|
|
203
228
|
return;
|
|
204
|
-
|
|
229
|
+
const all = doc.getAll();
|
|
230
|
+
const config = Object.fromEntries(Object.keys(CORE_CONFIG_SCHEMA)
|
|
231
|
+
.filter(key => all[key] !== undefined)
|
|
232
|
+
.map(key => [key, all[key]]));
|
|
233
|
+
res.json({ config, schema: CORE_CONFIG_SCHEMA, restartKeys: [...RESTART_KEYS], home: caps.home });
|
|
205
234
|
});
|
|
206
|
-
//
|
|
207
|
-
expressApp
|
|
235
|
+
// 改宿主配置的核心键:只保存,要重启才生效的键改了时回执写明,由界面出「重启以生效」,重启走 POST /api/restart
|
|
236
|
+
route(expressApp, 'PUT /api/config', gate(), async (req, res) => {
|
|
208
237
|
const updates = req.body;
|
|
209
238
|
if (!updates || typeof updates !== 'object') {
|
|
210
239
|
res.status(400).json({ error: '请求体必须是对象' });
|
|
211
240
|
return;
|
|
212
241
|
}
|
|
213
|
-
const app = getApp();
|
|
214
|
-
if (!app) {
|
|
215
|
-
res.status(500).json({ error: 'App 不可用' });
|
|
216
|
-
return;
|
|
217
|
-
}
|
|
218
242
|
const doc = docOr503(res);
|
|
219
243
|
if (!doc)
|
|
220
244
|
return;
|
|
221
|
-
//
|
|
222
|
-
// _schema 原样回传,其中 plugins 等还可能是过期快照(插件配置页保存后不刷新全局 config),
|
|
223
|
-
// 所以不能按键报错:其余键一律不应用,但把真有改动的点名回给调用方——不静默吞掉却回复「已保存」。
|
|
245
|
+
// 只收 CORE_CONFIG_SCHEMA 的键:别的顶层键(插件配置、禁用名单等)各有自己的路由,写在这里的一律拒收
|
|
224
246
|
const allowed = Object.keys(CORE_CONFIG_SCHEMA);
|
|
247
|
+
const foreign = Object.keys(updates).filter(k => !allowed.includes(k));
|
|
248
|
+
if (foreign.length > 0) {
|
|
249
|
+
res.status(400).json({ error: `只能改核心键(${allowed.join('、')}),不收:${foreign.join('、')}` });
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
225
252
|
const current = doc.getAll();
|
|
226
|
-
|
|
227
|
-
//
|
|
228
|
-
const currentOf = (k) => current[k] ??
|
|
229
|
-
|
|
230
|
-
const
|
|
231
|
-
const changed = allowed.filter(k => k in updates &&
|
|
232
|
-
//
|
|
233
|
-
|
|
234
|
-
const picked = Object.fromEntries(changed.map(k => [k, updates[k]]));
|
|
253
|
+
const defaultOf = (k) => CORE_CONFIG_SCHEMA[k]?.default;
|
|
254
|
+
// 文档里没写的核心键按 schema 默认值比较:回传的默认值不算改动,不写进文件
|
|
255
|
+
const currentOf = (k) => current[k] ?? defaultOf(k);
|
|
256
|
+
// 核心键提交 null 表示清空(恢复默认):回到默认值,并从文档里删掉这个键,不把默认值写死进文件
|
|
257
|
+
const submittedOf = (k) => (updates[k] === null ? defaultOf(k) : updates[k]);
|
|
258
|
+
const changed = allowed.filter(k => k in updates && !isDeepStrictEqual(submittedOf(k), currentOf(k)));
|
|
259
|
+
// 与插件配置路径同一把尺子:类型不符、越界或不在选项里的值(如 logLevel 传 nope)不落盘。
|
|
260
|
+
const picked = Object.fromEntries(changed.map(k => [k, submittedOf(k)]));
|
|
235
261
|
const invalid = validateConfig(CORE_CONFIG_SCHEMA, picked).map(i => `${i.path}: ${i.message}`);
|
|
236
|
-
for (const k of changed) {
|
|
237
|
-
const field = CORE_CONFIG_SCHEMA[k];
|
|
238
|
-
if (field.type === 'select' && field.options && !field.options.some(o => o.value === updates[k])) {
|
|
239
|
-
invalid.push(`${k}: 取值不在可选范围`);
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
262
|
if (invalid.length > 0) {
|
|
243
263
|
res.status(400).json({ error: invalid.join('; ') });
|
|
244
264
|
return;
|
|
245
265
|
}
|
|
266
|
+
// 写进文档的是按词汇换算后的值(加了引号的数字转成数字、写成数字的名称转成字符串):runtime 启动时
|
|
267
|
+
// 直接读这几个键,不经 parseConfig
|
|
268
|
+
const values = parseConfig(CORE_CONFIG_SCHEMA, picked);
|
|
246
269
|
const previous = Object.fromEntries(changed.map(k => [k, current[k]]));
|
|
270
|
+
// 提交 null 的键置为 undefined,写文件时省略
|
|
247
271
|
for (const key of changed)
|
|
248
|
-
doc.set(key, updates[key]);
|
|
249
|
-
|
|
250
|
-
// 装有人设时聊天显示人设名,应用名称只在仪表盘上看得到
|
|
251
|
-
const restartNeeded = changed.some(k => k === 'logLevel' || k === 'slowThresholdMs');
|
|
252
|
-
const note = ignored.length > 0 ? `(已忽略不可修改的字段: ${ignored.join(', ')})` : '';
|
|
272
|
+
doc.set(key, updates[key] === null ? undefined : values[key]);
|
|
273
|
+
const restartNeeded = changed.some(k => RESTART_KEYS.includes(k));
|
|
253
274
|
try {
|
|
254
275
|
await doc.save();
|
|
255
276
|
}
|
|
256
277
|
catch (err) {
|
|
257
|
-
//
|
|
258
|
-
// logLevel / slowThresholdMs 要重启才生效,name 由状态接口读文档:撤回后都回到修改前,运行态不留改动
|
|
278
|
+
// 拒写与写入失败都撤回文档里的改动,免得下一次任意保存把这次没存下的修改写进文件
|
|
259
279
|
for (const key of changed)
|
|
260
280
|
doc.set(key, previous[key]);
|
|
261
281
|
res
|
|
@@ -263,48 +283,40 @@ export function registerPluginRoutes(expressApp, caps, identify, gate, getAction
|
|
|
263
283
|
.json({ error: `未写入配置文件(${errorMessage(err)}),本次修改已撤回` });
|
|
264
284
|
return;
|
|
265
285
|
}
|
|
266
|
-
|
|
267
|
-
|
|
286
|
+
res.json({
|
|
287
|
+
ok: true,
|
|
288
|
+
message: restartNeeded ? '全局配置已保存,重启后生效' : '全局配置已更新并保存',
|
|
289
|
+
restartNeeded,
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
// 重启应用。发起之后的停机是异步的(core 先广播 app:restarting,何时停机由重启策略决定),这条回复来得及发出;
|
|
293
|
+
// 宿主没注入重启策略时 restart() 同步抛错,回 500,界面据此不去等待重连
|
|
294
|
+
route(expressApp, 'POST /api/restart', gate(), (_req, res) => {
|
|
295
|
+
const app = caps.app.present ? caps.app.use : undefined;
|
|
296
|
+
if (!app) {
|
|
297
|
+
res.status(500).json({ error: 'App 不可用' });
|
|
268
298
|
return;
|
|
269
299
|
}
|
|
270
|
-
// 先发起重启再回复:宿主没注入重启策略时 restart() 同步抛错,据此回 restart:false,前端才不会进入等待重连
|
|
271
|
-
// 却永远等不到。发起之后的停机是异步的(core 先广播 app:restarting,何时停机由重启策略决定,Node 宿主延迟
|
|
272
|
-
// 500ms),这条回复来得及发出。已写进文件,重启失败不撤回:改动在下次启动时生效
|
|
273
300
|
try {
|
|
274
301
|
app.restart();
|
|
275
302
|
}
|
|
276
303
|
catch (err) {
|
|
277
|
-
const reason =
|
|
304
|
+
const reason = `重启失败(${errorMessage(err)}),改动在下次启动时生效`;
|
|
278
305
|
caps.logger?.error(reason);
|
|
279
|
-
res.json({
|
|
306
|
+
res.status(500).json({ error: reason });
|
|
280
307
|
return;
|
|
281
308
|
}
|
|
282
|
-
res.json({ ok: true, message:
|
|
283
|
-
});
|
|
284
|
-
// 获取单个插件的原始配置(未脱敏,给编辑器回写用)。
|
|
285
|
-
// 列表 GET /api/plugins 已把 schema.secret 换成固定掩码;本接口必须是原文,否则保存会把掩码写回。
|
|
286
|
-
expressApp.get('/api/plugins/:name/config', gate(), (req, res) => {
|
|
287
|
-
const pluginName = req.params.name;
|
|
288
|
-
const doc = docOr503(res);
|
|
289
|
-
if (!doc)
|
|
290
|
-
return;
|
|
291
|
-
try {
|
|
292
|
-
const pluginConfig = doc.getPluginConfig(pluginName);
|
|
293
|
-
res.json({ name: pluginName, config: pluginConfig });
|
|
294
|
-
}
|
|
295
|
-
catch (err) {
|
|
296
|
-
res.status(400).json({ error: errorMessage(err) });
|
|
297
|
-
}
|
|
309
|
+
res.json({ ok: true, message: '正在重启应用' });
|
|
298
310
|
});
|
|
299
311
|
// 更新插件配置
|
|
300
|
-
expressApp
|
|
312
|
+
route(expressApp, 'PUT /api/plugins/:name/config', gate(), async (req, res) => {
|
|
301
313
|
const pluginName = req.params.name;
|
|
302
314
|
const newConfig = req.body?.config;
|
|
303
|
-
if (!newConfig || typeof newConfig !== 'object') {
|
|
315
|
+
if (!newConfig || typeof newConfig !== 'object' || Array.isArray(newConfig)) {
|
|
304
316
|
res.status(400).json({ error: 'config 字段必须是对象' });
|
|
305
317
|
return;
|
|
306
318
|
}
|
|
307
|
-
const pm =
|
|
319
|
+
const pm = caps.plugins.present ? caps.plugins.use : undefined;
|
|
308
320
|
if (!pm) {
|
|
309
321
|
res.status(500).json({ error: '插件管理服务不可用' });
|
|
310
322
|
return;
|
|
@@ -312,60 +324,56 @@ export function registerPluginRoutes(expressApp, caps, identify, gate, getAction
|
|
|
312
324
|
const doc = docOr503(res);
|
|
313
325
|
if (!doc)
|
|
314
326
|
return;
|
|
315
|
-
//
|
|
316
|
-
// entry.config = newConfig
|
|
317
|
-
//
|
|
327
|
+
// 合进原配置再交给宿主的 applyPluginConfig:配置是**整体替换**语义(core 的 orchestration/plugin.ts 里
|
|
328
|
+
// entry.config = newConfig 直接顶掉)。基线取配置文档里的原配置而非裸默认值:defaultsFrom 只收录声明了
|
|
329
|
+
// default 的键,而 apiKey / accessToken 这类 secret 多数**没有** default,以裸默认值打底时请求里没带 apiKey,
|
|
330
|
+
// 整体替换后用户的密钥就从内存态与 yaml 一起消失。叠上原配置后语义才是真正的部分更新:没提交的字段保持原样,
|
|
331
|
+
// 要清空得显式提交。
|
|
332
|
+
// 原配置是文档形态,密钥字段上是引用;前端原样发回引用即保持原值,发明文即替换(由宿主拆进密钥存储)。
|
|
318
333
|
const entry = pm.getPlugin(pluginName);
|
|
319
334
|
const schema = entry?.definition?.configSchema;
|
|
320
|
-
const defaults = defaultsFrom(schema);
|
|
321
|
-
// 基线取「默认值叠已存值」而非裸默认值:defaultsFrom 只收录声明了 default 的键,
|
|
322
|
-
// 而 apiKey / accessToken 这类 secret 多数**没有** default(deepseek、embedding-openai、
|
|
323
|
-
// llm-openai、serper、onebot 皆是)。用裸 defaults 打底时,请求里没带 apiKey 就等于
|
|
324
|
-
// merged 里根本没有这个键,整体替换后用户的密钥从内存态与 yaml 一起消失。
|
|
325
|
-
// 叠上已存值后语义才是真正的部分更新:没提交的字段保持原样,要清空得显式传空串。
|
|
326
335
|
let docConfig;
|
|
327
336
|
let stored;
|
|
328
337
|
let merged;
|
|
329
|
-
|
|
330
|
-
|
|
338
|
+
let ignored;
|
|
339
|
+
let removed;
|
|
331
340
|
try {
|
|
332
341
|
docConfig = doc.getPluginConfig(pluginName);
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
for (const [key, value] of Object.entries(defaults)) {
|
|
336
|
-
if (!Object.hasOwn(stored, key))
|
|
337
|
-
stored[key] = value;
|
|
338
|
-
}
|
|
339
|
-
const submitted = newConfig;
|
|
340
|
-
merged = { ...stored, ...submitted };
|
|
341
|
-
// 与宿主的配置同步同一政策(host-config 的 trimUnknownFields):宿主裁剪且有 schema 时裁掉未知键并 warn,
|
|
342
|
-
// 避免 WebUI 把手滑字段写进 stored,而 YAML watch 路径却会裁掉;宿主保留未知字段时这里也不裁。
|
|
343
|
-
// 被裁的字段按来源回给调用方,不静默吞掉却回复「已更新」:本次提交里的记 ignored(与 PUT /api/config
|
|
344
|
-
// 同一口径);文档里原有、本次没提交的不是用户这次写的,随整份写回从配置文件删掉,记 removed
|
|
345
|
-
if (doc.trimUnknownFields !== false && schema && Object.keys(schema).length > 0) {
|
|
346
|
-
const shape = schema;
|
|
347
|
-
for (const [key, value] of Object.entries(merged)) {
|
|
348
|
-
removeExtraFields({ [key]: value }, shape, Object.hasOwn(submitted, key) ? ignored : removed);
|
|
349
|
-
}
|
|
350
|
-
merged = removeExtraFields(merged, shape);
|
|
351
|
-
if (ignored.length + removed.length > 0) {
|
|
352
|
-
caps.logger?.warn(`配置同步:${pluginName} 裁掉 schema 外字段 [${[...ignored, ...removed].join(', ')}]`);
|
|
353
|
-
}
|
|
354
|
-
}
|
|
342
|
+
stored = deepMergeDefaults(defaultsFrom(schema), docConfig);
|
|
343
|
+
({ merged, ignored, removed } = mergeSubmittedConfig(doc, pluginName, schema, docConfig, newConfig));
|
|
355
344
|
}
|
|
356
345
|
catch (err) {
|
|
357
346
|
res.status(400).json({ error: errorMessage(err) });
|
|
358
347
|
return;
|
|
359
348
|
}
|
|
349
|
+
// 没登记的实例不经这里新建(宿主的 applyPluginConfig 会按同模块定义登记它),建实例走 POST instances
|
|
350
|
+
if (!entry) {
|
|
351
|
+
res.status(404).json({ error: `插件 ${pluginName} 不存在` });
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
360
354
|
const note = (ignored.length > 0 ? `(已忽略未声明的配置字段: ${ignored.join(', ')})` : '') +
|
|
361
355
|
(removed.length > 0 ? `(已从配置文件移除未声明的字段: ${removed.join(', ')})` : '');
|
|
362
356
|
// 保存前校验,拦新不追旧:只拒绝本次编辑**新引入**的 invalid(配错了)。
|
|
363
|
-
// 存量问题放行——否则带着历史脏值(或 schema
|
|
364
|
-
//
|
|
357
|
+
// 存量问题放行——否则带着历史脏值(或 schema 表达不了的多态字段,如字符串与数组两种写法都收的
|
|
358
|
+
// 字段)的插件会在 WebUI 永久存不了任何字段;启动侧 config-sync
|
|
365
359
|
// 对它们已有告警。missing(没配全)也放行:半成品配置是启用插件配到一半的
|
|
366
360
|
// 正常中间态。
|
|
367
|
-
|
|
368
|
-
|
|
361
|
+
// 存量按「去掉下标的路径 + 原因」计数,新配置里同类问题的条数超出存量才算新增:list / multiselect /
|
|
362
|
+
// 数组删掉前面的元素后,存量坏元素的下标会前移,按原路径比会把它误判成新增
|
|
363
|
+
const issueKey = (i) => `${i.path.replace(/\[\d+\]/g, '[]')}|${i.message}`;
|
|
364
|
+
const preExisting = new Map();
|
|
365
|
+
for (const i of validateConfig(schema, stored)) {
|
|
366
|
+
const key = issueKey(i);
|
|
367
|
+
preExisting.set(key, (preExisting.get(key) ?? 0) + 1);
|
|
368
|
+
}
|
|
369
|
+
const issues = validateConfig(schema, merged).filter(i => {
|
|
370
|
+
if (i.kind !== 'invalid')
|
|
371
|
+
return false;
|
|
372
|
+
const key = issueKey(i);
|
|
373
|
+
const left = preExisting.get(key) ?? 0;
|
|
374
|
+
preExisting.set(key, left - 1);
|
|
375
|
+
return left <= 0;
|
|
376
|
+
});
|
|
369
377
|
if (issues.length > 0) {
|
|
370
378
|
res.status(400).json({
|
|
371
379
|
error: `配置校验未通过:${issues.map(i => `${i.path}: ${i.message}`).join(';')}`,
|
|
@@ -373,54 +381,58 @@ export function registerPluginRoutes(expressApp, caps, identify, gate, getAction
|
|
|
373
381
|
});
|
|
374
382
|
return;
|
|
375
383
|
}
|
|
376
|
-
//
|
|
377
|
-
//
|
|
378
|
-
//
|
|
379
|
-
|
|
380
|
-
if (!(await saveAfterApply(doc, res, 'reload')))
|
|
381
|
-
return;
|
|
382
|
-
res.json({ ok: true, message: `插件 ${pluginName} 配置无改动,已写回配置文件${note}`, ignored, removed });
|
|
383
|
-
return;
|
|
384
|
-
}
|
|
385
|
-
let success;
|
|
384
|
+
// 运行态、文档、密钥与落盘由宿主一步完成:解析出的配置与运行态相同、实例在 active 时不重建插件
|
|
385
|
+
// (updateConfig 即 bounce,会连带重启依赖它所提供服务的下游),照常写回文档并落盘:上一次保存可能写文件失败(500),
|
|
386
|
+
// 原样重试要能补写;自写回不会触发 watch 重载。error 态插件靠原样保存来重试激活
|
|
387
|
+
let result;
|
|
386
388
|
try {
|
|
387
|
-
|
|
389
|
+
result = await doc.applyPluginConfig(pluginName, merged);
|
|
388
390
|
}
|
|
389
391
|
catch (err) {
|
|
390
392
|
res.status(400).json({ error: errorMessage(err) });
|
|
391
393
|
return;
|
|
392
394
|
}
|
|
393
|
-
if (
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
: undefined;
|
|
402
|
-
const aside = after?.state === 'disabled' ? '插件已禁用,启用时按新配置激活' : notYetActive(after?.state);
|
|
403
|
-
if (!(await saveAfterApply(doc, res, 'reload', failure ?? aside)))
|
|
404
|
-
return;
|
|
405
|
-
if (failure !== undefined) {
|
|
406
|
-
res.status(500).json({ error: `${failure};配置已写入配置文件${note}` });
|
|
395
|
+
if (result.status === 'refused') {
|
|
396
|
+
refuse(pm, res, pluginName, '配置更新');
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
// 运行态与文档都没变:回「无改动」。只有运行态没变(例如文档里补了配置段)照常按已更新回报,插件没有重建
|
|
400
|
+
if (!result.reloaded && isDeepStrictEqual(merged, docConfig)) {
|
|
401
|
+
if (result.saveError !== undefined) {
|
|
402
|
+
reportSaveFailure(res, result.saveError, 'reload');
|
|
407
403
|
return;
|
|
408
404
|
}
|
|
409
|
-
res.json({
|
|
410
|
-
|
|
411
|
-
message: `插件 ${pluginName} 配置已更新${note}${aside ? `;${aside}` : ''}`,
|
|
412
|
-
ignored,
|
|
413
|
-
removed,
|
|
414
|
-
});
|
|
415
|
-
}
|
|
416
|
-
else {
|
|
417
|
-
res.status(404).json({ error: `插件 ${pluginName} 不存在` });
|
|
405
|
+
res.json({ ok: true, message: `插件 ${pluginName} 配置无改动,已写回配置文件${note}`, ignored, removed });
|
|
406
|
+
return;
|
|
418
407
|
}
|
|
408
|
+
// 按新配置重新激活失败、转为 error:照样写入配置(原样再存即重试激活),回执按实际状态说明。
|
|
409
|
+
// 禁用态只换上新配置、保持禁用,启用时按它激活
|
|
410
|
+
const after = await settledEntry(pm, pluginName);
|
|
411
|
+
const failure = after?.state === 'error'
|
|
412
|
+
? `插件 ${pluginName} 按新配置重新激活失败,已转为 error 态(${after.error ?? '详见日志'})`
|
|
413
|
+
: undefined;
|
|
414
|
+
const aside = after?.state === 'disabled'
|
|
415
|
+
? '插件已禁用,启用时按新配置激活'
|
|
416
|
+
: notYetActive(pm, caps.services, pluginName, after?.state);
|
|
417
|
+
if (result.saveError !== undefined) {
|
|
418
|
+
reportSaveFailure(res, result.saveError, 'reload', failure ?? aside);
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
// 写进了配置文件就回 2xx,激活失败也一样:表单据状态码区分「没写成」(留着改)与「写成了」(收起、刷新)。
|
|
422
|
+
// 写成之后草稿已经过时,例如清除的密钥键已随这次保存从 secrets.yaml 删掉,再「撤销」只会引用不存在的键
|
|
423
|
+
res.json({
|
|
424
|
+
ok: true,
|
|
425
|
+
message: failure !== undefined
|
|
426
|
+
? `${failure};配置已写入配置文件${note}`
|
|
427
|
+
: `插件 ${pluginName} 配置已更新${note}${aside ? `;${aside}` : ''}`,
|
|
428
|
+
ignored,
|
|
429
|
+
removed,
|
|
430
|
+
});
|
|
419
431
|
});
|
|
420
432
|
// 启用插件
|
|
421
|
-
expressApp
|
|
433
|
+
route(expressApp, 'POST /api/plugins/:name/enable', gate(), async (req, res) => {
|
|
422
434
|
const pluginName = req.params.name;
|
|
423
|
-
const pm =
|
|
435
|
+
const pm = caps.plugins.present ? caps.plugins.use : undefined;
|
|
424
436
|
if (!pm) {
|
|
425
437
|
res.status(500).json({ error: '插件管理服务不可用' });
|
|
426
438
|
return;
|
|
@@ -438,28 +450,31 @@ export function registerPluginRoutes(expressApp, caps, identify, gate, getAction
|
|
|
438
450
|
}
|
|
439
451
|
if (success) {
|
|
440
452
|
doc.setPluginEnabled(pluginName, true);
|
|
441
|
-
// 激活失败、转为 error
|
|
453
|
+
// 激活失败、转为 error:照样记为启用(重启时再激活),回执按实际状态说明。写进了配置文件就回 2xx,口径同改配置
|
|
442
454
|
const after = await settledEntry(pm, pluginName);
|
|
443
455
|
const failure = after?.state === 'error'
|
|
444
456
|
? `插件 ${pluginName} 激活失败,已转为 error 态(${after.error ?? '详见日志'})`
|
|
445
457
|
: undefined;
|
|
446
|
-
const aside = notYetActive(after?.state);
|
|
458
|
+
const aside = notYetActive(pm, caps.services, pluginName, after?.state);
|
|
447
459
|
if (!(await saveAfterApply(doc, res, 'restart', failure ?? aside)))
|
|
448
460
|
return;
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
461
|
+
res.json({
|
|
462
|
+
ok: true,
|
|
463
|
+
message: failure !== undefined
|
|
464
|
+
? `${failure};配置文件已记为启用`
|
|
465
|
+
: `插件 ${pluginName} 已启用${aside ? `;${aside}` : ''}`,
|
|
466
|
+
});
|
|
454
467
|
}
|
|
455
468
|
else {
|
|
456
|
-
|
|
469
|
+
refuse(pm, res, pluginName, '启用');
|
|
457
470
|
}
|
|
458
471
|
});
|
|
459
472
|
// 禁用插件
|
|
460
|
-
expressApp
|
|
473
|
+
route(expressApp, 'POST /api/plugins/:name/disable', gate(), async (req, res) => {
|
|
461
474
|
const pluginName = req.params.name;
|
|
462
|
-
|
|
475
|
+
if (refuseSelf(res, pluginName))
|
|
476
|
+
return;
|
|
477
|
+
const pm = caps.plugins.present ? caps.plugins.use : undefined;
|
|
463
478
|
if (!pm) {
|
|
464
479
|
res.status(500).json({ error: '插件管理服务不可用' });
|
|
465
480
|
return;
|
|
@@ -476,30 +491,62 @@ export function registerPluginRoutes(expressApp, caps, identify, gate, getAction
|
|
|
476
491
|
return;
|
|
477
492
|
}
|
|
478
493
|
if (success) {
|
|
479
|
-
//
|
|
480
|
-
const stuck = pm.
|
|
481
|
-
? `插件 ${pluginName}
|
|
494
|
+
// 已禁用,但旧实例(如不响应 abort 的初始化)超过宽限仍没停下:回执说明这一点。写进了配置文件就回 2xx,口径同改配置
|
|
495
|
+
const stuck = pm.getStatus().find(entry => entry.instanceId === pluginName)?.lingering
|
|
496
|
+
? `插件 ${pluginName} 已禁用,但超过宽限仍未停下,详见日志`
|
|
482
497
|
: undefined;
|
|
483
498
|
doc.setPluginEnabled(pluginName, false);
|
|
484
499
|
if (!(await saveAfterApply(doc, res, 'restart', stuck)))
|
|
485
500
|
return;
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
}
|
|
490
|
-
res.json({ ok: true, message: `插件 ${pluginName} 已禁用` });
|
|
501
|
+
res.json({
|
|
502
|
+
ok: true,
|
|
503
|
+
message: stuck !== undefined ? `${stuck};配置文件已记为禁用` : `插件 ${pluginName} 已禁用`,
|
|
504
|
+
});
|
|
491
505
|
}
|
|
492
506
|
else {
|
|
493
|
-
|
|
507
|
+
refuse(pm, res, pluginName, '禁用');
|
|
508
|
+
}
|
|
509
|
+
});
|
|
510
|
+
// 重载:拆掉当前激活再按原配置重新激活,不写配置文件;失败的插件重载即重试
|
|
511
|
+
route(expressApp, 'POST /api/plugins/:name/reload', gate(), async (req, res) => {
|
|
512
|
+
const pluginName = req.params.name;
|
|
513
|
+
const pm = caps.plugins.present ? caps.plugins.use : undefined;
|
|
514
|
+
if (!pm) {
|
|
515
|
+
res.status(500).json({ error: '插件管理服务不可用' });
|
|
516
|
+
return;
|
|
494
517
|
}
|
|
518
|
+
if (pm.getPlugin(pluginName)?.state === 'disabled') {
|
|
519
|
+
res.status(409).json({ error: `插件 ${pluginName} 已停用,启用后才能重载` });
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
let success;
|
|
523
|
+
try {
|
|
524
|
+
success = await pm.bounce(pluginName);
|
|
525
|
+
}
|
|
526
|
+
catch (err) {
|
|
527
|
+
res.status(400).json({ error: errorMessage(err) });
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
if (!success) {
|
|
531
|
+
refuse(pm, res, pluginName, '重载');
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
const after = await settledEntry(pm, pluginName);
|
|
535
|
+
const aside = notYetActive(pm, caps.services, pluginName, after?.state);
|
|
536
|
+
res.json({
|
|
537
|
+
ok: true,
|
|
538
|
+
message: after?.state === 'error'
|
|
539
|
+
? `插件 ${pluginName} 重新激活失败,已转为 error 态(${after.error ?? '详见日志'})`
|
|
540
|
+
: `插件 ${pluginName} 已重载${aside ? `;${aside}` : ''}`,
|
|
541
|
+
});
|
|
495
542
|
});
|
|
496
543
|
// 经宿主 plugin-source 重新扫描并加载新插件
|
|
497
|
-
expressApp
|
|
498
|
-
|
|
499
|
-
if (!source) {
|
|
544
|
+
route(expressApp, 'POST /api/plugins/scan', gate(), async (_req, res) => {
|
|
545
|
+
if (!caps.source.present) {
|
|
500
546
|
res.status(503).json({ error: '宿主未提供插件来源,无法扫描' });
|
|
501
547
|
return;
|
|
502
548
|
}
|
|
549
|
+
const source = caps.source.use;
|
|
503
550
|
try {
|
|
504
551
|
const loaded = await source.rescan();
|
|
505
552
|
res.json({ ok: true, loaded, message: loaded.length > 0 ? `新加载 ${loaded.length} 个插件` : '无新插件' });
|
|
@@ -510,7 +557,7 @@ export function registerPluginRoutes(expressApp, caps, identify, gate, getAction
|
|
|
510
557
|
}
|
|
511
558
|
});
|
|
512
559
|
// 创建插件多实例
|
|
513
|
-
expressApp
|
|
560
|
+
route(expressApp, 'POST /api/plugins/:name/instances', gate(), async (req, res) => {
|
|
514
561
|
const moduleName = req.params.name;
|
|
515
562
|
const suffix = req.body?.suffix;
|
|
516
563
|
const config = req.body?.config ?? {};
|
|
@@ -522,7 +569,11 @@ export function registerPluginRoutes(expressApp, caps, identify, gate, getAction
|
|
|
522
569
|
res.status(400).json({ error: 'suffix 只能包含字母、数字、下划线和连字符' });
|
|
523
570
|
return;
|
|
524
571
|
}
|
|
525
|
-
|
|
572
|
+
if (typeof config !== 'object' || Array.isArray(config)) {
|
|
573
|
+
res.status(400).json({ error: 'config 字段必须是对象' });
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
const pm = caps.plugins.present ? caps.plugins.use : undefined;
|
|
526
577
|
if (!pm) {
|
|
527
578
|
res.status(500).json({ error: '插件管理服务不可用' });
|
|
528
579
|
return;
|
|
@@ -531,11 +582,14 @@ export function registerPluginRoutes(expressApp, caps, identify, gate, getAction
|
|
|
531
582
|
if (!doc)
|
|
532
583
|
return;
|
|
533
584
|
// 实例创建编排(配置文件编排属管理面,内核只出 register 机制):
|
|
534
|
-
// 查同名 module → reusable/查重校验 →
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
585
|
+
// 查同名 module → reusable/查重校验 → 合并默认配置、裁剪与校验 → 写入 → register 激活。
|
|
586
|
+
// 模块定义主实例优先、否则取任一在册实例的,与宿主登记新实例时取定义的规则一致:合并、裁剪与校验所用的 schema
|
|
587
|
+
// 就是登记时的那一份
|
|
588
|
+
const sourceModule = pm.getPlugin(moduleName)?.definition ??
|
|
589
|
+
pm
|
|
590
|
+
.getStatus()
|
|
591
|
+
.map(p => pm.getPlugin(p.instanceId)?.definition)
|
|
592
|
+
.find(m => m?.name === moduleName);
|
|
539
593
|
if (!sourceModule) {
|
|
540
594
|
res.status(400).json({ error: `无法创建实例:模块 "${moduleName}" 未找到` });
|
|
541
595
|
return;
|
|
@@ -549,45 +603,59 @@ export function registerPluginRoutes(expressApp, caps, identify, gate, getAction
|
|
|
549
603
|
res.status(400).json({ error: `无法创建实例:"${instanceId}" 已存在` });
|
|
550
604
|
return;
|
|
551
605
|
}
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
606
|
+
// 与改配置同一套合并与裁剪,基线是空对象。校验不分存量:新实例没有历史配置,类型不符的值一律拒绝,
|
|
607
|
+
// missing 照样放行(半成品配置是配到一半的正常中间态)
|
|
608
|
+
const schema = sourceModule.configSchema;
|
|
609
|
+
const { merged: mergedConfig, ignored } = mergeSubmittedConfig(doc, instanceId, schema, {}, config);
|
|
610
|
+
const issues = validateConfig(schema, mergedConfig).filter(i => i.kind === 'invalid');
|
|
611
|
+
if (issues.length > 0) {
|
|
612
|
+
res.status(400).json({
|
|
613
|
+
error: `配置校验未通过:${issues.map(i => `${i.path}: ${i.message}`).join(';')}`,
|
|
614
|
+
issues,
|
|
559
615
|
});
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
const note = ignored.length > 0 ? `(已忽略未声明的配置字段: ${ignored.join(', ')})` : '';
|
|
619
|
+
// 宿主先写文档再登记(文档里残留的禁用标记照旧生效,以禁用态登记),没登记上时撤回文档与密钥
|
|
620
|
+
let result;
|
|
621
|
+
try {
|
|
622
|
+
result = await doc.applyPluginConfig(instanceId, mergedConfig);
|
|
560
623
|
}
|
|
561
624
|
catch (err) {
|
|
562
625
|
res.status(400).json({ error: errorMessage(err) });
|
|
563
626
|
return;
|
|
564
627
|
}
|
|
565
|
-
if (
|
|
628
|
+
if (result.status === 'refused') {
|
|
566
629
|
// 前面已查过重名与 reusable,走到这里的实际只有停机进行中:配置不落盘,免得下次启动按文件登记
|
|
567
|
-
doc.removePluginConfig(instanceId);
|
|
568
630
|
res.status(409).json({ error: `实例 ${instanceId} 未登记(停机中或被拒,见日志),配置未写入` });
|
|
569
631
|
return;
|
|
570
632
|
}
|
|
571
|
-
// 激活失败、转为 error
|
|
633
|
+
// 激活失败、转为 error:实例与配置照样保留并落盘(改配置保存即重试激活),回执按实际状态说明。
|
|
634
|
+
// 写进了配置文件就回 2xx,口径同改配置:实例已登记,表单该收起,留着再点「创建」只会得到「已存在」
|
|
572
635
|
const after = await settledEntry(pm, instanceId);
|
|
573
636
|
const failure = after?.state === 'error'
|
|
574
637
|
? `已创建实例 ${instanceId},但激活失败,已转为 error 态(${after.error ?? '详见日志'})`
|
|
575
638
|
: undefined;
|
|
576
639
|
const aside = after?.state === 'disabled'
|
|
577
640
|
? '配置文件的 disabledPlugins 里有它,已按禁用态登记,启用后激活'
|
|
578
|
-
: notYetActive(after?.state);
|
|
579
|
-
if (
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
641
|
+
: notYetActive(pm, caps.services, instanceId, after?.state);
|
|
642
|
+
if (result.saveError !== undefined) {
|
|
643
|
+
reportSaveFailure(res, result.saveError, 'reload', failure ?? aside);
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
res.json({
|
|
647
|
+
ok: true,
|
|
648
|
+
instanceId,
|
|
649
|
+
message: failure !== undefined
|
|
650
|
+
? `${failure};配置已写入配置文件${note}`
|
|
651
|
+
: `已创建实例 ${instanceId}${note}${aside ? `;${aside}` : ''}`,
|
|
652
|
+
ignored,
|
|
653
|
+
});
|
|
586
654
|
});
|
|
587
655
|
// 删除插件多实例
|
|
588
|
-
expressApp
|
|
656
|
+
route(expressApp, 'DELETE /api/plugins/:instanceId/instance', gate(), async (req, res) => {
|
|
589
657
|
const instanceId = req.params.instanceId;
|
|
590
|
-
const pm =
|
|
658
|
+
const pm = caps.plugins.present ? caps.plugins.use : undefined;
|
|
591
659
|
if (!pm) {
|
|
592
660
|
res.status(500).json({ error: '插件管理服务不可用' });
|
|
593
661
|
return;
|
|
@@ -620,8 +688,9 @@ export function registerPluginRoutes(expressApp, caps, identify, gate, getAction
|
|
|
620
688
|
.json({ error: `未删除实例 ${instanceId}:未写入配置文件(${errorMessage(err)})` });
|
|
621
689
|
return;
|
|
622
690
|
}
|
|
691
|
+
let unloaded;
|
|
623
692
|
try {
|
|
624
|
-
await pm.unload(instanceId);
|
|
693
|
+
unloaded = await pm.unload(instanceId);
|
|
625
694
|
}
|
|
626
695
|
catch (err) {
|
|
627
696
|
res.status(500).json({
|
|
@@ -629,58 +698,12 @@ export function registerPluginRoutes(expressApp, caps, identify, gate, getAction
|
|
|
629
698
|
});
|
|
630
699
|
return;
|
|
631
700
|
}
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
// 回执因此只说「已保存」,不说保存到磁盘
|
|
636
|
-
expressApp.post('/api/config/save', gate(), async (_req, res) => {
|
|
637
|
-
const doc = docOr503(res);
|
|
638
|
-
if (!doc)
|
|
701
|
+
// 返回 false 而条目还在:停机中拒绝了卸载,配置段已删、实例仍在跑到停机为止。条目已不在的是别处先卸掉了,照常回已删除
|
|
702
|
+
if (!unloaded && pm.getPlugin(instanceId)) {
|
|
703
|
+
res.json({ ok: true, message: `已从配置文件删除实例 ${instanceId};停机中,未卸载运行中的实例` });
|
|
639
704
|
return;
|
|
640
|
-
try {
|
|
641
|
-
await doc.save();
|
|
642
|
-
res.json({ ok: true, message: '配置已保存' });
|
|
643
|
-
}
|
|
644
|
-
catch (err) {
|
|
645
|
-
res.status(isConfigSaveRefused(err) ? 409 : 500).json({ error: errorMessage(err) });
|
|
646
705
|
}
|
|
706
|
+
res.json({ ok: true, message: `已删除实例 ${instanceId}` });
|
|
647
707
|
});
|
|
648
708
|
}
|
|
649
|
-
/** 列表 payload 用的固定掩码。与 PUT 响应「不回显密钥」同一政策:概览面看不到明文。 */
|
|
650
|
-
const SECRET_MASK = '••••••';
|
|
651
|
-
/**
|
|
652
|
-
* 把 schema.secret === true 的字段换成固定掩码。入参须已是拷贝——就地改,避免写穿现场 config。
|
|
653
|
-
* 分组递归;数组元素若是对象则按 items 再走一遍。缺席的键不补掩码。
|
|
654
|
-
*/
|
|
655
|
-
function maskSecretFields(config, schema) {
|
|
656
|
-
if (!schema)
|
|
657
|
-
return config;
|
|
658
|
-
for (const [key, raw] of Object.entries(schema)) {
|
|
659
|
-
if (!raw || typeof raw !== 'object')
|
|
660
|
-
continue;
|
|
661
|
-
const def = raw;
|
|
662
|
-
if (def.secret === true) {
|
|
663
|
-
if (Object.hasOwn(config, key))
|
|
664
|
-
config[key] = SECRET_MASK;
|
|
665
|
-
continue;
|
|
666
|
-
}
|
|
667
|
-
const nested = config[key];
|
|
668
|
-
if (def.fields &&
|
|
669
|
-
typeof def.fields === 'object' &&
|
|
670
|
-
nested &&
|
|
671
|
-
typeof nested === 'object' &&
|
|
672
|
-
!Array.isArray(nested)) {
|
|
673
|
-
maskSecretFields(nested, def.fields);
|
|
674
|
-
continue;
|
|
675
|
-
}
|
|
676
|
-
if (def.type === 'array' && Array.isArray(nested) && def.items && typeof def.items === 'object') {
|
|
677
|
-
for (const item of nested) {
|
|
678
|
-
if (item && typeof item === 'object' && !Array.isArray(item)) {
|
|
679
|
-
maskSecretFields(item, def.items);
|
|
680
|
-
}
|
|
681
|
-
}
|
|
682
|
-
}
|
|
683
|
-
}
|
|
684
|
-
return config;
|
|
685
|
-
}
|
|
686
709
|
//# sourceMappingURL=plugins.js.map
|