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