@aalis/plugin-webui-server 0.15.0 → 0.17.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 (79) hide show
  1. package/dist/attention.d.ts +2 -2
  2. package/dist/attention.d.ts.map +1 -1
  3. package/dist/attention.js +5 -4
  4. package/dist/attention.js.map +1 -1
  5. package/dist/auth.d.ts +13 -4
  6. package/dist/auth.d.ts.map +1 -1
  7. package/dist/auth.js +77 -27
  8. package/dist/auth.js.map +1 -1
  9. package/dist/client-switch-page.d.ts +25 -3
  10. package/dist/client-switch-page.d.ts.map +1 -1
  11. package/dist/client-switch-page.js +49 -19
  12. package/dist/client-switch-page.js.map +1 -1
  13. package/dist/gate.d.ts.map +1 -1
  14. package/dist/gate.js +2 -1
  15. package/dist/gate.js.map +1 -1
  16. package/dist/html.d.ts +15 -0
  17. package/dist/html.d.ts.map +1 -0
  18. package/dist/html.js +38 -0
  19. package/dist/html.js.map +1 -0
  20. package/dist/index.d.ts +4 -1
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +223 -244
  23. package/dist/index.js.map +1 -1
  24. package/dist/layout.d.ts +4 -3
  25. package/dist/layout.d.ts.map +1 -1
  26. package/dist/layout.js +5 -3
  27. package/dist/layout.js.map +1 -1
  28. package/dist/locales/en.d.ts +192 -0
  29. package/dist/locales/en.d.ts.map +1 -0
  30. package/dist/locales/en.js +192 -0
  31. package/dist/locales/en.js.map +1 -0
  32. package/dist/localize.d.ts +24 -0
  33. package/dist/localize.d.ts.map +1 -0
  34. package/dist/localize.js +68 -0
  35. package/dist/localize.js.map +1 -0
  36. package/dist/page-check.d.ts.map +1 -1
  37. package/dist/page-check.js +32 -25
  38. package/dist/page-check.js.map +1 -1
  39. package/dist/pages.d.ts +9 -7
  40. package/dist/pages.d.ts.map +1 -1
  41. package/dist/pages.js +77 -61
  42. package/dist/pages.js.map +1 -1
  43. package/dist/route.d.ts +5 -2
  44. package/dist/route.d.ts.map +1 -1
  45. package/dist/route.js +5 -2
  46. package/dist/route.js.map +1 -1
  47. package/dist/routes/files.d.ts.map +1 -1
  48. package/dist/routes/files.js +4 -3
  49. package/dist/routes/files.js.map +1 -1
  50. package/dist/routes/frames.js +3 -3
  51. package/dist/routes/frames.js.map +1 -1
  52. package/dist/routes/i18n.d.ts +20 -0
  53. package/dist/routes/i18n.d.ts.map +1 -0
  54. package/dist/routes/i18n.js +78 -0
  55. package/dist/routes/i18n.js.map +1 -0
  56. package/dist/routes/marketplace.d.ts.map +1 -1
  57. package/dist/routes/marketplace.js +27 -29
  58. package/dist/routes/marketplace.js.map +1 -1
  59. package/dist/routes/media.d.ts +3 -0
  60. package/dist/routes/media.d.ts.map +1 -1
  61. package/dist/routes/media.js +29 -26
  62. package/dist/routes/media.js.map +1 -1
  63. package/dist/routes/pages.d.ts +4 -2
  64. package/dist/routes/pages.d.ts.map +1 -1
  65. package/dist/routes/pages.js +36 -18
  66. package/dist/routes/pages.js.map +1 -1
  67. package/dist/routes/plugins.d.ts +5 -9
  68. package/dist/routes/plugins.d.ts.map +1 -1
  69. package/dist/routes/plugins.js +114 -129
  70. package/dist/routes/plugins.js.map +1 -1
  71. package/dist/routes/proxy.d.ts +3 -2
  72. package/dist/routes/proxy.d.ts.map +1 -1
  73. package/dist/routes/proxy.js +62 -41
  74. package/dist/routes/proxy.js.map +1 -1
  75. package/dist/texts.d.ts +383 -0
  76. package/dist/texts.d.ts.map +1 -0
  77. package/dist/texts.js +195 -0
  78. package/dist/texts.js.map +1 -0
  79. package/package.json +26 -23
package/dist/index.js CHANGED
@@ -12,70 +12,47 @@ import { contributions } from '@aalis/api-contributions';
12
12
  import { hostConfig } from '@aalis/api-host-config';
13
13
  import { hostLogs } from '@aalis/api-host-logs';
14
14
  import { hostPaths } from '@aalis/api-host-paths';
15
- import { llm, resolveLLMModel } from '@aalis/api-llm';
15
+ import { i18n } from '@aalis/api-i18n';
16
+ import { llm, modelRefOf, resolveLLMModel } from '@aalis/api-llm';
16
17
  import { messageArchive } from '@aalis/api-message-archive';
17
18
  import { packageManager } from '@aalis/api-package-manager';
18
- import { aggregatePlatformDetails, getPlatformAdapters, getPlatformNames, platform, sessionTypesOf, } from '@aalis/api-platform';
19
+ import { aggregatePlatformDetails, platform } from '@aalis/api-platform';
19
20
  import { pluginSource } from '@aalis/api-plugin-source';
20
21
  import { processService } from '@aalis/api-process';
21
22
  import { rootSessionOf, sessionManager } from '@aalis/api-session-manager';
22
23
  import { sessionProtocol } from '@aalis/api-session-protocol';
23
- import { isStorageUri, storage } from '@aalis/api-storage';
24
+ import { storage } from '@aalis/api-storage';
24
25
  import { tools } from '@aalis/api-tools';
25
- import { parseTheme, webuiClient, webuiServer, } from '@aalis/api-webui';
26
+ import { CLIENT_TEXTS_FILE, parseTheme, webuiClient, webuiServer, } from '@aalis/api-webui';
26
27
  import { appService, config, definePlugin, events, lifecycle, logger, optional, pluginsService, provide, services, } from '@aalis/core';
27
28
  import { configError, defineConfig, parseConfig } from '@aalis/schema-config';
28
- import { outboundMediaMessage, WellKnownKinds, WellKnownMetadataKeys, } from '@aalis/schema-message';
29
+ import { asText, isTextError, registerCatalogEntry, textLines, textOf, toSource, } from '@aalis/schema-i18n';
29
30
  import express from 'express';
30
31
  import { WebSocketServer } from 'ws';
31
32
  import { ADMIN_PATH, createAdminChannel } from './admin-channel.js';
32
33
  import { blockedStatus, createAttention } from './attention.js';
33
- import { createAuthSystem, openBrowser, originAllowed } from './auth.js';
34
+ import { createAuthSystem, isHostName, openBrowser, originAllowed } from './auth.js';
34
35
  import { collectLocalPackageDeps, discoverClients, pickFreshClients } from './client-discovery.js';
35
- import { CLIENT_SWITCH_SCRIPT, renderClientSwitchPage } from './client-switch-page.js';
36
+ import { CLIENT_SWITCH_SCRIPT, CLIENT_SWITCH_TEXTS, renderClientSwitchPage } from './client-switch-page.js';
36
37
  import { createRouteGate } from './gate.js';
38
+ import { pageLanguageOf, withPageLanguage } from './html.js';
37
39
  import { createLayoutStore } from './layout.js';
40
+ import { createLocalizer } from './localize.js';
38
41
  import { logMatcher, parseLogFilter } from './logs.js';
39
42
  import { createPageFiles } from './page-files.js';
40
43
  import { createPageRegistry } from './pages.js';
41
44
  import { route } from './route.js';
42
45
  import { registerFileRoutes } from './routes/files.js';
43
46
  import { registerFrameRoutes } from './routes/frames.js';
47
+ import { frameTexts, interfaceLanguage, registerI18nRoutes } from './routes/i18n.js';
44
48
  import { localPackageLookup, registerMarketplaceRoutes } from './routes/marketplace.js';
45
49
  import { registerMediaRoutes } from './routes/media.js';
46
50
  import { registerPageRoutes } from './routes/pages.js';
47
51
  import { registerPluginRoutes, saveAfterApply } from './routes/plugins.js';
48
52
  import { registerProxyRoutes } from './routes/proxy.js';
49
53
  import { acceptSessionSocket, SESSION_PATH } from './session-transport.js';
54
+ import { t } from './texts.js';
50
55
  import { createWsHeartbeat } from './ws-heartbeat.js';
51
- /**
52
- * 工具 / 指令 / 工具组 description 约定:首行 = 人类可读摘要,其余 = 给 agent 的长使用指南。
53
- * WebUI 管理页只展示首行摘要(避免倾倒整段指南);完整 description 仍由工具/指令定义抵达 LLM。
54
- */
55
- function descSummary(d) {
56
- if (!d)
57
- return d;
58
- const s = d.split('\n')[0].trim();
59
- return s.length > 200 ? `${s.slice(0, 200)}…` : s;
60
- }
61
- /** 可选项接口的 dependency 查询参数当模型引用读(llm-ref 的取值,JSON):没带为 undefined,写法不对为 null */
62
- function modelRefOf(raw) {
63
- if (raw === undefined)
64
- return undefined;
65
- let value;
66
- try {
67
- value = typeof raw === 'string' ? JSON.parse(raw) : undefined;
68
- }
69
- catch {
70
- return null;
71
- }
72
- if (typeof value !== 'object' || value === null || Array.isArray(value))
73
- return null;
74
- const { provider, model } = value;
75
- return (provider === undefined || typeof provider === 'string') && (model === undefined || typeof model === 'string')
76
- ? { provider, model }
77
- : null;
78
- }
79
56
  // ===== 插件元数据 =====
80
57
  const name = '@aalis/plugin-webui-server';
81
58
  // 按宿主身份记打开记录:插件 bounce 保留,新 App 独立;弱引用不延长宿主寿命。
@@ -96,116 +73,83 @@ const DOCUMENT_CSP = [
96
73
  "base-uri 'self'",
97
74
  "frame-ancestors 'none'",
98
75
  ].join('; ');
76
+ /** 请求体解析(express.json)的拒绝按 type 给的整句;别的 type 给 badRequest */
77
+ const BODY_REJECTIONS = {
78
+ 'entity.too.large': t('bodyTooLarge'),
79
+ 'entity.parse.failed': t('bodyNotJson'),
80
+ 'charset.unsupported': t('bodyEncoding'),
81
+ 'encoding.unsupported': t('bodyEncoding'),
82
+ };
99
83
  const configSchema = defineConfig({
100
84
  port: {
101
85
  type: 'number',
102
- label: '端口',
86
+ label: t('portLabel'),
103
87
  default: 3000,
104
88
  integer: true,
105
89
  min: 0,
106
90
  max: 65535,
107
91
  onInvalid: 'error',
108
- description: 'Web 管理界面的 HTTP 端口',
92
+ description: t('portDescription'),
109
93
  },
110
94
  host: {
111
95
  type: 'string',
112
- label: '监听地址',
96
+ label: t('hostLabel'),
113
97
  default: '127.0.0.1',
114
98
  pattern: '\\S',
115
99
  onInvalid: 'error',
116
- description: '绑定的 IP 地址,0.0.0.0 可对外访问',
100
+ description: t('hostDescription'),
101
+ },
102
+ allowedHosts: {
103
+ type: 'multiselect',
104
+ label: t('allowedHostsLabel'),
105
+ default: [],
106
+ onInvalid: 'error',
107
+ // 主机名由使用者填写,静态选项无法穷举。
108
+ allowCustom: true,
109
+ description: t('allowedHostsDescription'),
110
+ validate(value) {
111
+ const bad = value.map(String).find(entry => !isHostName(entry));
112
+ return bad === undefined ? undefined : t('badAllowedHost', { entry: bad });
113
+ },
117
114
  },
118
115
  autoOpen: {
119
116
  type: 'boolean',
120
- label: '启动时自动打开浏览器',
117
+ label: t('autoOpenLabel'),
121
118
  default: true,
122
119
  onInvalid: 'error',
123
- description: '每次应用启动后首次监听成功时打开默认浏览器;同一应用内沿用 token 的插件重载不重复打开,重新生成 token 时再次打开以便登录。SSH/headless 环境建议关闭',
120
+ description: t('autoOpenDescription'),
124
121
  },
125
122
  tokenMode: {
126
123
  type: 'select',
127
- label: 'Token 策略',
124
+ label: t('tokenModeLabel'),
128
125
  default: 'persist',
129
126
  onInvalid: 'error',
130
127
  secret: false,
131
128
  options: [
132
- { label: '每次启动随机生成(重启即轮换)', value: 'ephemeral' },
133
- { label: '首次生成后持久化(重启不掉登录)', value: 'persist' },
134
- { label: '使用下方固定 token', value: 'fixed' },
129
+ { label: t('tokenEphemeral'), value: 'ephemeral' },
130
+ { label: t('tokenPersist'), value: 'persist' },
131
+ { label: t('tokenFixed'), value: 'fixed' },
135
132
  ],
136
- description: 'ephemeral=每次启动随机;persist=token 写入 data:/webui/token,读取复用;fixed=使用 fixedToken 字段。所有模式都会写出便利文件 data:/webui/access.txt 含访问 URL。',
133
+ description: t('tokenModeDescription'),
137
134
  },
138
135
  fixedToken: {
139
136
  type: 'string',
140
- label: '固定 Token(仅 tokenMode=fixed 生效)',
137
+ label: t('fixedTokenLabel'),
141
138
  default: '',
142
139
  onInvalid: 'error',
143
140
  secret: true,
144
- description: `至少 ${FIXED_TOKEN_MIN_LENGTH} 个字符的随机字符串(去掉首尾空白后计),短了插件不启动;留空按 persist 处理。存进 secrets.yaml,WebUI 不回显。`,
141
+ description: t('fixedTokenDescription', { min: FIXED_TOKEN_MIN_LENGTH }),
145
142
  },
146
143
  marketplaceRegistry: {
147
144
  type: 'string',
148
- label: '插件市场 npm 源',
145
+ label: t('registryLabel'),
149
146
  default: 'https://registry.npmjs.org',
150
147
  onInvalid: 'error',
151
- description: '插件市场检索用的 npm registry 基址。注意 npm 的 search API 并非所有镜像都支持(淘宝等国内源不支持),默认官方源;国内可填支持 search 的镜像或代理。安装走 package-manager(遵循本机 npm 配置),卡片上的最新版与可更新也按安装使用的源查。',
148
+ description: t('registryDescription'),
152
149
  },
153
150
  // 活跃前端不在此配置:前端即 `webui-client` 服务的多 provider,活跃者由「服务偏好」
154
151
  // (servicePreferences['webui-client'])决定——在 WebUI 设置「系统」的服务偏好里切换。
155
152
  });
156
- // ===== WebUI 会话的收件箱 =====
157
- /**
158
- * 发往 WebUI 自己会话的出站消息里,要由本插件写进会话历史的部分。WebUI 的收件箱就是会话历史,记不记全不取决于
159
- * 别的插件是否在场。agent 的文字回复由 agent 存档,这里不再记,免得历史里重复。其余部分:
160
- * - 不是她说的文字(系统提示、指令回复、宿主通知)记成事件标记:回看时显示成分隔条,不进模型上下文,与其他平台一致;
161
- * - 图片、视频、音频用 outboundMediaMessage 记,回看时还原成附件;
162
- * - 给的是存储地址的文件(插件发出的成品等)同样用 outboundMediaMessage 记,描述位记文件名,会话服务据此签发下载句柄。
163
- * metadata 另记原样的地址与文件名(登记的键 SentRef、SentName):占位符里的地址遇到「]」会截断,描述位的「]」「|」换成了全角。这样的文件以
164
- * 「[文件: 名称 | ref:…]」进模型上下文与记忆,不看全局出站归档记不记文件。不是存储地址的文件
165
- * (网址、data URL)签不出句柄,记成事件标记「[文件] 名称」,内容不进历史;
166
- * - 快照里的 data 未经平台改写,就是页面上显示的那份,直接记它。带 skipArchive 的(history_ref 重发)原图已在
167
- * 历史里,不再记。她发出的媒体在别的平台由全局出站归档在 outbound:delivered 上记;发往 WebUI 的由这里记,
168
- * outbound:delivered 的快照给附件标上 skipArchive,免得全局出站归档再记一份。代价是这些网址图片与视频不再由
169
- * 全局出站归档在后台补视觉描述
170
- */
171
- function inboxRecords(msg) {
172
- const timestamp = Date.now();
173
- const marker = (content) => ({
174
- role: 'system',
175
- kind: WellKnownKinds.EventMarker,
176
- content,
177
- timestamp,
178
- });
179
- const records = [];
180
- if (msg.source !== 'agent' && msg.content.trim())
181
- records.push(marker(msg.content));
182
- for (const att of msg.attachments ?? []) {
183
- if (att.skipArchive)
184
- continue;
185
- if (att.kind !== 'file') {
186
- records.push({
187
- ...outboundMediaMessage(att.kind, att.data, att.description),
188
- timestamp,
189
- metadata: { [WellKnownMetadataKeys.Source]: 'webui-server', [WellKnownMetadataKeys.SentRef]: att.data },
190
- });
191
- }
192
- else if (isStorageUri(att.data)) {
193
- records.push({
194
- ...outboundMediaMessage('file', att.data, att.name),
195
- timestamp,
196
- metadata: {
197
- [WellKnownMetadataKeys.Source]: 'webui-server',
198
- [WellKnownMetadataKeys.SentRef]: att.data,
199
- [WellKnownMetadataKeys.SentName]: att.name,
200
- },
201
- });
202
- }
203
- else {
204
- records.push(marker(`[文件] ${att.name ?? att.data.split('/').pop()}`));
205
- }
206
- }
207
- return records;
208
- }
209
153
  // ===== 插件入口 =====
210
154
  /**
211
155
  * 本地包扫描目录:安装集(host-paths 的 `app`)下的 `packages/` 与 `node_modules/@aalis`,与插件加载器读的是同一处。
@@ -253,12 +197,14 @@ const uses = {
253
197
  llm: optional(llm),
254
198
  /** 插件经贡献点 webui:theme 提供的主题(GET /api/themes) */
255
199
  contributions: optional(contributions),
256
- // 发往 WebUI 自己会话的出站消息由本插件写进会话历史(见 inboxRecords)
200
+ /** 发往 WebUI 自己会话的出站消息经它的 recordOutbound 记进会话历史,记成了才算送达 */
257
201
  archive: optional(messageArchive),
202
+ /** 回应里的文字对象按全局层的语言渲染(localize.ts);缺席时按原文 */
203
+ i18n: optional(i18n),
258
204
  };
259
205
  export default definePlugin({
260
206
  name,
261
- displayName: 'WebUI 服务端',
207
+ displayName: t('displayName'),
262
208
  subsystem: 'platform',
263
209
  configSchema,
264
210
  provides: [webuiServer, platform],
@@ -273,9 +219,13 @@ async function startWebuiServer(caps) {
273
219
  marketplaceRegistry: cfg.marketplaceRegistry.trim() || configSchema.marketplaceRegistry.default,
274
220
  };
275
221
  if (!URL.canParse(uiConfig.marketplaceRegistry))
276
- throw configError('marketplaceRegistry 不是有效 URL');
222
+ throw configError(t('registryInvalid'));
277
223
  // 创建 storage gateway;所有文件读写(token、访问凭据、媒体与会话文件)都走这里
278
224
  const storage = caps.storage.use;
225
+ // 给人看的文字按全局层的语言渲染(localize.ts):回应、登录页与恢复页、access.txt
226
+ const localizer = createLocalizer(caps.i18n, logger.use);
227
+ /** 多语言路由与插件前端的字典要的:i18n、语言包的来源与全局层的语言链 */
228
+ const i18nDeps = { i18n: caps.i18n, contributions: caps.contributions, localizer };
279
229
  // ---- Token 解析 ----
280
230
  // - ephemeral:每次启动随机生成(旧行为)
281
231
  // - persist:首次生成后写入 storage data:/webui/token,重启复用(默认)
@@ -286,26 +236,37 @@ async function startWebuiServer(caps) {
286
236
  const accessFileUri = 'data:/webui/access.txt';
287
237
  const fixedToken = uiConfig.tokenMode === 'fixed' ? uiConfig.fixedToken.trim() : '';
288
238
  if (fixedToken && fixedToken.length < FIXED_TOKEN_MIN_LENGTH) {
289
- throw configError(`fixedToken 至少要 ${FIXED_TOKEN_MIN_LENGTH} 个字符(现在 ${fixedToken.length} 个)`);
239
+ throw configError(t('tokenTooShort', { min: FIXED_TOKEN_MIN_LENGTH, length: fixedToken.length }));
290
240
  }
291
241
  let authToken = fixedToken || Buffer.from(crypto.getRandomValues(new Uint8Array(24))).toString('hex');
292
242
  /** 监听成功后的访问 URL;持久化 token 晚于监听读回时,据此重写 access.txt */
293
243
  let listeningUrl;
294
- /** 返回是否真的写成了:调用方据此决定能不能宣称「凭据已写入」 */
244
+ // 本激活自己发起、没有请求在等的写盘(token 与 access.txt):收尾时等它们落定。停机、停用或重载返回之后本实例不再往
245
+ // 家目录里写,接手的实例写下的 access.txt 也不会被旧实例晚到的一次盖掉
246
+ const pendingWrites = new Set();
247
+ function trackWrite(work) {
248
+ const running = work().finally(() => pendingWrites.delete(running));
249
+ pendingWrites.add(running);
250
+ return running;
251
+ }
252
+ lifecycle.use.onDrain(async () => {
253
+ await Promise.all(pendingWrites);
254
+ }, '等 token 与访问凭据落盘');
255
+ /** 返回是否真的写成了:调用方据此决定能不能宣称「凭据已写入」。说明行按全局层的语言写 */
295
256
  async function writeAccessFile(url, token) {
296
- const lines = [
297
- '# Aalis WebUI 访问凭据(自动生成)',
298
- `# 生成时间: ${new Date().toISOString()}`,
299
- `# Token 模式: ${uiConfig.tokenMode}`,
257
+ const lines = textLines([
258
+ t('accessTitle'),
259
+ t('accessGenerated', { time: new Date().toISOString() }),
260
+ t('accessMode', { mode: uiConfig.tokenMode }),
300
261
  '',
301
262
  `URL: ${url}`,
302
263
  `Token: ${token}`,
303
- `一键登录: ${url}?token=${token}`,
264
+ t('accessLogin', { url: `${url}?token=${token}` }),
304
265
  '',
305
- '# 提示:将该一键登录链接粘贴到浏览器即可自动设置 cookie',
306
- ];
266
+ t('accessHint'),
267
+ ]);
307
268
  try {
308
- await storage.writeFile(accessFileUri, `${lines.join('\n')}\n`, { secret: true });
269
+ await storage.writeFile(accessFileUri, `${localizer.render(lines)}\n`, { secret: true });
309
270
  return true;
310
271
  }
311
272
  catch (err) {
@@ -325,7 +286,7 @@ async function startWebuiServer(caps) {
325
286
  caps.storage.follow(() => {
326
287
  if (tokenSettled)
327
288
  return;
328
- tokenLoading = (async () => {
289
+ tokenLoading = trackWrite(async () => {
329
290
  try {
330
291
  const raw = await storage.readFile(tokenFileUri, 'utf-8');
331
292
  const existing = (typeof raw === 'string' ? raw : raw.toString('utf-8')).trim();
@@ -335,7 +296,7 @@ async function startWebuiServer(caps) {
335
296
  if (existing !== authToken) {
336
297
  authToken = existing;
337
298
  if (listeningUrl)
338
- void writeAccessFile(listeningUrl, authToken);
299
+ await writeAccessFile(listeningUrl, authToken);
339
300
  }
340
301
  // 读回的照样按凭据重写一遍:文件原先的权限(别处拷来、按默认权限写下的)一律收紧
341
302
  await storage
@@ -354,13 +315,15 @@ async function startWebuiServer(caps) {
354
315
  catch (err) {
355
316
  logger.use.warn(`持久化 token 失败,本次仍可使用但重启会再生成: ${err.message}`);
356
317
  }
357
- })();
318
+ });
358
319
  });
359
320
  // storage 已在线时 follow 是同步首挂:等读回完成再继续,认证从第一个请求起就用持久化的 token
360
321
  if (tokenLoading)
361
322
  await tokenLoading;
362
- const auth = createAuthSystem(() => authToken);
323
+ const auth = createAuthSystem(() => authToken, localizer, [uiConfig.host, ...uiConfig.allowedHosts]);
363
324
  const expressApp = express();
325
+ // 回应里的文字按全局层的语言渲染:中间件最早挂上,之后的路由与中间件回的 JSON 都经过它
326
+ expressApp.use(localizer.middleware);
364
327
  // 文档级 CSP:脚本只许同源,样式放开内联(Mermaid 的输出带内联样式),图片只认同源与本地生成的;不许被别的页面嵌入。
365
328
  // 每个响应都带上,前端的 HTML、登录页与恢复页都在其中;媒体路由另设更严的沙箱,插件前端的文件另设框架 CSP
366
329
  expressApp.use((_req, res, next) => {
@@ -417,14 +380,39 @@ async function startWebuiServer(caps) {
417
380
  let staticMiddleware = null;
418
381
  function mountStaticDir(dir) {
419
382
  if (existsSync(dir)) {
420
- staticMiddleware = express.static(dir);
383
+ // 入口 index.html 不由它给:SPA 兜底写好 <html lang> 再发
384
+ staticMiddleware = express.static(dir, { index: false });
421
385
  logger.use.info(`前端静态目录: ${dir}`);
386
+ registerClientTexts(join(dir, CLIENT_TEXTS_FILE));
422
387
  }
423
388
  else {
424
389
  staticMiddleware = null;
425
390
  logger.use.warn(`前端目录不存在: ${dir}`);
426
391
  }
427
392
  }
393
+ /**
394
+ * 前端随它带的字典(api-webui 的 CLIENT_TEXTS_FILE)登记进服务进程的目录:「翻译」页列得出它们,语言包作者拿得到原文指纹。
395
+ * 目录没有删除,换掉的前端的字典留着。没有这个文件的前端照常挂载;读不出、不是数组时不登记,有条目不合格时合格的照登,都告警
396
+ */
397
+ function registerClientTexts(file) {
398
+ if (!existsSync(file))
399
+ return;
400
+ let entries;
401
+ try {
402
+ entries = JSON.parse(readFileSync(file, 'utf-8'));
403
+ }
404
+ catch (err) {
405
+ logger.use.warn(`前端的字典 ${file} 读不出,没有登记:`, err);
406
+ return;
407
+ }
408
+ if (!Array.isArray(entries)) {
409
+ logger.use.warn(`前端的字典 ${file} 不是数组,没有登记`);
410
+ return;
411
+ }
412
+ const refused = entries.filter(entry => !registerCatalogEntry(entry)).length;
413
+ if (refused > 0)
414
+ logger.use.warn(`前端的字典 ${file} 里有 ${refused} 条不合格,没有登记`);
415
+ }
428
416
  // 已发现的前端候选(app:ready 时填充,逐个注册为 webui-client 服务 provider)。
429
417
  const clientCandidates = [];
430
418
  /**
@@ -520,6 +508,7 @@ async function startWebuiServer(caps) {
520
508
  storageReady: () => caps.storage.present,
521
509
  send: event => admin.send(event),
522
510
  logger: logger.use,
511
+ texts: plugin => frameTexts(i18nDeps, plugin),
523
512
  }, auth.identify, gate);
524
513
  registerMarketplaceRoutes(expressApp, {
525
514
  logger: logger.use,
@@ -552,11 +541,11 @@ async function startWebuiServer(caps) {
552
541
  const sendLogs = async (query, res, before) => {
553
542
  const filter = parseLogFilter(query);
554
543
  if (!filter) {
555
- res.status(400).json({ error: '日志筛选写法不对:level 只收 debug、info、warn、error,每一项最多给一次' });
544
+ res.status(400).json({ error: t('logFilterInvalid') });
556
545
  return;
557
546
  }
558
547
  if (!caps.logs.present) {
559
- res.status(503).json({ error: '宿主未提供日志服务(host-logs),读不到日志' });
548
+ res.status(503).json({ error: t('noHostLogs') });
560
549
  return;
561
550
  }
562
551
  const limit = Math.min(Math.max(Number(query.limit) || 200, 1), 5000);
@@ -568,7 +557,7 @@ async function startWebuiServer(caps) {
568
557
  route(expressApp, 'GET /api/logs/range', gate(), (req, res) => {
569
558
  const before = Number(req.query.before);
570
559
  if (!Number.isFinite(before)) {
571
- res.status(400).json({ error: '往前翻日志要给数字参数 before(取 seq 比它小的那几条)' });
560
+ res.status(400).json({ error: t('logBeforeRequired') });
572
561
  return;
573
562
  }
574
563
  return sendLogs(req.query, res, before);
@@ -579,14 +568,15 @@ async function startWebuiServer(caps) {
579
568
  const pluginStatus = pluginMgr ? pluginMgr.getStatus() : [];
580
569
  const displayNameMap = new Map();
581
570
  for (const p of pluginStatus) {
582
- if (p.displayName) {
583
- displayNameMap.set(p.name, p.displayName);
584
- displayNameMap.set(p.instanceId, p.displayName);
571
+ const name = asText(p.displayName);
572
+ if (name) {
573
+ displayNameMap.set(p.name, name);
574
+ displayNameMap.set(p.instanceId, name);
585
575
  }
586
576
  }
587
577
  // 根激活归宿主:core 的内置服务与宿主登记的 host-config / plugin-source 都在这里(与市场依赖图同一标签)。
588
578
  // 别把内部 id 'root' 裸露给用户。
589
- displayNameMap.set('root', '宿主');
579
+ displayNameMap.set('root', t('host'));
590
580
  const serviceNames = services.use.names();
591
581
  const detail = {};
592
582
  for (const svcName of serviceNames) {
@@ -596,7 +586,7 @@ async function startWebuiServer(caps) {
596
586
  providers: entries.map(e => ({
597
587
  contextId: e.contextId,
598
588
  displayName: displayNameMap.get(e.contextId),
599
- label: e.label,
589
+ label: asText(e.label),
600
590
  priority: e.priority,
601
591
  exclusive: e.exclusive,
602
592
  })),
@@ -612,11 +602,11 @@ async function startWebuiServer(caps) {
612
602
  */
613
603
  const changePreference = async (svcName, contextId, scope, res) => {
614
604
  if (contextId !== null && !services.use.inspect(svcName).some(e => e.contextId === contextId)) {
615
- res.status(404).json({ error: `服务 ${svcName} 没有 id 为 ${contextId} 的提供者` });
605
+ res.status(404).json({ error: t('noProvider', { service: svcName, id: contextId }) });
616
606
  return;
617
607
  }
618
608
  if (!caps.hostConfig.present) {
619
- res.status(503).json({ error: '宿主未提供配置文档(host-config),无法保存偏好' });
609
+ res.status(503).json({ error: t('noHostConfigPreference') });
620
610
  return;
621
611
  }
622
612
  const host = caps.hostConfig.use;
@@ -640,7 +630,7 @@ async function startWebuiServer(caps) {
640
630
  route(expressApp, 'POST /api/services/:name/prefer', gate(), async (req, res) => {
641
631
  const contextId = preferredIn(req.body);
642
632
  if (!contextId) {
643
- res.status(400).json({ error: '请求体要给 contextId:偏好的那个提供者的 id' });
633
+ res.status(400).json({ error: t('contextIdRequired') });
644
634
  return;
645
635
  }
646
636
  await changePreference(String(req.params.name), contextId, {}, res);
@@ -649,12 +639,12 @@ async function startWebuiServer(caps) {
649
639
  route(expressApp, 'POST /api/plugins/:instanceId/services/:name/prefer', gate(), async (req, res) => {
650
640
  const contextId = preferredIn(req.body);
651
641
  if (!contextId) {
652
- res.status(400).json({ error: '请求体要给 contextId:偏好的那个提供者的 id' });
642
+ res.status(400).json({ error: t('contextIdRequired') });
653
643
  return;
654
644
  }
655
645
  const instanceId = String(req.params.instanceId);
656
646
  if (!caps.plugins.present || !caps.plugins.use.getPlugin(instanceId)) {
657
- res.status(404).json({ error: `插件 ${instanceId} 不存在` });
647
+ res.status(404).json({ error: t('pluginMissing', { instance: instanceId }) });
658
648
  return;
659
649
  }
660
650
  await changePreference(String(req.params.name), contextId, { for: instanceId }, res);
@@ -671,7 +661,7 @@ async function startWebuiServer(caps) {
671
661
  themes.push({ id: key, instance: key.slice(0, key.lastIndexOf('/')), theme: parsed.theme });
672
662
  else if (!warnedThemes.has(spec)) {
673
663
  warnedThemes.add(spec);
674
- logger.use.warn(`插件主题 ${key} 不合主题文件的格式,没有列出:${parsed.errors.join(';')}`);
664
+ logger.use.warn(`插件主题 ${key} 不合主题文件的格式,没有列出:${toSource(parsed.errors).join(';')}`);
675
665
  }
676
666
  }
677
667
  res.json({ themes });
@@ -695,8 +685,8 @@ async function startWebuiServer(caps) {
695
685
  if (!target) {
696
686
  res.status(404).json({
697
687
  error: llmEntries.length > 0
698
- ? `提供者 ${contextId} 不支持运行时刷新模型列表(例如关闭了模型发现)`
699
- : `提供者 ${contextId} 当前没有已注册的模型,无法刷新`,
688
+ ? t('refreshUnsupported', { provider: contextId })
689
+ : t('refreshNoModels', { provider: contextId }),
700
690
  });
701
691
  return;
702
692
  }
@@ -704,51 +694,33 @@ async function startWebuiServer(caps) {
704
694
  res.json({ ok: true, ...result });
705
695
  }
706
696
  catch (err) {
707
- res.status(500).json({ error: err.message });
697
+ res.status(500).json({ error: textOf(err) });
708
698
  }
709
699
  });
710
700
  // 获取某个服务的可用模型/选项列表
711
701
  route(expressApp, 'GET /api/models/:service', gate(), async (req, res) => {
712
702
  const serviceName = req.params.service;
713
- // 特殊处理 platform:通过 helper 获取已注册的平台名称
714
- if (serviceName === 'platform') {
715
- res.json({ models: getPlatformNames(caps.platform) });
716
- return;
717
- }
718
- // 特殊处理 gateway-scopes:已注册平台与它们的类型名(sessionTypesOf:没声明类型的平台只有一个类型 private)
719
- // 的笛卡尔积
720
- if (serviceName === 'gateway-scopes') {
721
- const platformTypes = new Map();
722
- const allTypes = new Set();
723
- for (const a of getPlatformAdapters(caps.platform)) {
724
- const types = sessionTypesOf(a).map(t => t.name);
725
- platformTypes.set(a.platform, types);
726
- for (const t of types)
727
- allTypes.add(t);
728
- }
729
- const scopes = [];
730
- scopes.push({ value: '*', label: '* (全部平台 × 全部类型)' });
731
- for (const t of [...allTypes].sort()) {
732
- scopes.push({ value: `*:${t}`, label: `*:${t} (所有平台的 ${t} 会话)` });
733
- }
734
- for (const [p, types] of platformTypes) {
735
- scopes.push({ value: `${p}:*`, label: `${p}:* (${p} 全部类型)` });
736
- for (const t of types)
737
- scopes.push({ value: `${p}:${t}`, label: `${p}:${t}` });
738
- }
739
- res.json({ models: scopes.map(s => s.value), details: scopes });
703
+ // 插件经贡献点 webui:options 登记的可选项来源先查:同名的取全局键最前的那家,dependency 原样交给它
704
+ const source = caps.contributions.present
705
+ ? caps.contributions.use.collect('webui:options').find(({ spec }) => spec.id === serviceName)
706
+ : undefined;
707
+ if (source) {
708
+ const { dependency } = req.query;
709
+ const options = await source.spec.list(typeof dependency === 'string' ? dependency : undefined);
710
+ res.json({ models: options.map(option => option.value), details: options });
740
711
  return;
741
712
  }
742
- // 深度思考的档位:可选项依赖「模型」这一项,dependency 带它的生效值;没带时按服务偏好解析,与对话时同一种解析。
743
- // 默认档标 default(没设时界面据它写按哪一档跑),模型不能调时没有可选项。labels 是各模型声明过的档位的名字
744
- // (同一个 id 取先登记的):换了模型以后存着的档位不在可选项里,界面按它写出名字
713
+ // 深度思考的档位:可选项依赖「模型」这一项,dependency 带它的值(条目 id,按 api-llm 的写法拆);没带时没有可选项,
714
+ // 不替调用方挑模型。默认档标 default(没设时界面据它写按哪一档跑),模型不能调时没有可选项。labels 是各模型声明过的
715
+ // 档位的名字(同一个 id 取先登记的):换了模型以后存着的档位不在可选项里,界面按它写出名字
745
716
  if (serviceName === 'llm-thinking') {
746
- const ref = modelRefOf(req.query.dependency);
747
- if (ref === null) {
748
- res.status(400).json({ error: 'dependency 要是模型引用 { provider, model } 的 JSON' });
717
+ const { dependency } = req.query;
718
+ const ref = typeof dependency === 'string' ? modelRefOf(dependency) : undefined;
719
+ if (dependency !== undefined && !ref) {
720
+ res.status(400).json({ error: t('dependencyInvalid') });
749
721
  return;
750
722
  }
751
- const thinking = resolveLLMModel(caps.llm, ref, ['chat'])?.use.thinking;
723
+ const thinking = ref && resolveLLMModel(caps.llm, ref, ['chat'])?.use.thinking;
752
724
  const levels = thinking?.levels ?? [];
753
725
  const labels = {};
754
726
  for (const entry of caps.llm.list()) {
@@ -764,35 +736,37 @@ async function startWebuiServer(caps) {
764
736
  });
765
737
  return;
766
738
  }
767
- // 特殊处理 toolGroups:优先从工具分组注册表获取,回退到扫描工具
739
+ // 工具分组:已登记的分组与工具上出现过的分组取并集,按名字排;登记过的写名字、说明取 summary(description 只给模型),
740
+ // 没登记的写原名。'*' 是全部分组(api-tools 的分组约定),总在最前、标 all、不带成员。有工具的分组带组内工具的名字(members,按
741
+ // 名字排,一个工具在几组就在每组都列,同一组写了两遍的只列一次);不属于任何分组的工具另交 others,没有时不交
768
742
  if (serviceName === 'toolGroups') {
769
- const groups = caps.tools.present ? caps.tools.use.getGroups() : [];
770
- // '*' = 全部分组(api-tools 的分组约定),与会话范围选项的「* (全部平台)」同一写法
771
- if (groups.length > 0) {
772
- res.json({
773
- models: ['*', ...groups.map(g => g.name).sort()],
774
- details: [
775
- { value: '*', label: '* (全部分组)' },
776
- ...groups.map(g => ({
777
- value: g.name,
778
- label: g.label,
779
- description: descSummary(g.description),
780
- pluginName: g.pluginName,
781
- })),
782
- ],
783
- });
784
- }
785
- else {
786
- // 回退:从已注册工具中提取分组名称
787
- const allTools = caps.tools.present ? caps.tools.use.getAll() : [];
788
- const groupSet = new Set();
789
- for (const t of allTools) {
790
- t.groups?.forEach((g) => {
791
- groupSet.add(g);
792
- });
743
+ const registered = new Map((caps.tools.present ? caps.tools.use.getGroups() : []).map(g => [g.name, g]));
744
+ const members = new Map();
745
+ const ungrouped = [];
746
+ for (const tool of caps.tools.present ? caps.tools.use.getAll() : []) {
747
+ if (!tool.groups?.length)
748
+ ungrouped.push(tool.name);
749
+ for (const g of new Set(tool.groups)) {
750
+ const list = members.get(g) ?? [];
751
+ list.push(tool.name);
752
+ members.set(g, list);
793
753
  }
794
- res.json({ models: ['*', ...[...groupSet].sort()] });
795
754
  }
755
+ const names = new Set([...registered.keys(), ...members.keys()]);
756
+ const details = [...names].sort().map(name => {
757
+ const group = registered.get(name);
758
+ return {
759
+ value: name,
760
+ label: group?.label ?? name,
761
+ description: group?.summary,
762
+ members: members.get(name)?.sort(),
763
+ };
764
+ });
765
+ res.json({
766
+ models: ['*', ...details.map(detail => detail.value)],
767
+ details: [{ value: '*', label: t('allGroups'), description: t('allGroupsNote'), all: true }, ...details],
768
+ ...(ungrouped.length > 0 ? { others: { label: t('ungroupedTools'), members: ungrouped.sort() } } : {}),
769
+ });
796
770
  return;
797
771
  }
798
772
  // LLM 走 per-model entry 枚举(每个 entry 已对应一个具体 model,不再依赖 provider.listModels)。
@@ -802,7 +776,7 @@ async function startWebuiServer(caps) {
802
776
  const entries = caps.llm.list();
803
777
  const providers = entries.map(e => ({
804
778
  model: e.use.id,
805
- provider: e.label ?? e.id,
779
+ provider: asText(e.label) ?? e.id,
806
780
  contextId: e.use.providerId,
807
781
  capabilities: [...e.use.capabilities],
808
782
  }));
@@ -829,17 +803,24 @@ async function startWebuiServer(caps) {
829
803
  }
830
804
  res.json({ models });
831
805
  });
806
+ registerI18nRoutes(expressApp, i18nDeps, gate);
832
807
  registerProxyRoutes(expressApp, logger.use, gate);
833
808
  registerMediaRoutes(expressApp, storage, logger.use, gate);
834
809
  registerFileRoutes(expressApp, storage, { sessions: caps.sessionProtocol, pages: pageFiles }, logger.use, gate);
835
810
  // ---------- WebSocket ----------
836
811
  wss.on('connection', (ws, req) => {
837
- // 连接建立时解析一次调用者身份(cookie 在升级请求里;verifyClient 已保证已认证)
838
- const wsIdentity = auth.identify(req) ?? { platform: 'webui', userId: 'console' };
839
- logger.use.debug(`WebUI 客户端已连接: ${wsIdentity.platform}:${wsIdentity.userId}`);
840
- // 每条连接都过心跳:回收半开连接,track 同时挂上 'error' 兜底监听(见 ws-heartbeat.ts)
812
+ // 每条连接都过心跳,关掉的也一样:回收半开连接,track 同时挂上 'error' 兜底监听(见 ws-heartbeat.ts)
841
813
  heartbeat.track(ws);
842
814
  ws.on('close', () => heartbeat.untrack(ws));
815
+ // 连接建立时解析一次调用者身份(cookie 在升级请求里)。verifyClient 与这里同步相继判同一个 cookie;认不出时按未认证
816
+ // 关掉,不补身份
817
+ const wsIdentity = auth.identify(req);
818
+ if (!wsIdentity) {
819
+ logger.use.warn('WebUI 连接认不出身份,已关闭这条连接');
820
+ ws.close(1008, '认不出身份');
821
+ return;
822
+ }
823
+ logger.use.debug(`WebUI 客户端已连接: ${wsIdentity.platform}:${wsIdentity.userId}`);
843
824
  if (wsPath(req) === ADMIN_PATH) {
844
825
  admin.accept(ws);
845
826
  return;
@@ -855,47 +836,37 @@ async function startWebuiServer(caps) {
855
836
  admin.send({ type: 'plugins.changed' });
856
837
  attention.pluginsChanged();
857
838
  });
839
+ // 宿主配置文档变了(插件详情保存、手改配置文件、会话设置写全局层与偏好):插件详情与界面语言据它重取
840
+ events.use.on('host-config:changed', () => admin.send({ type: 'plugins.changed' }));
858
841
  events.use.on('approval:created', () => attention.changed());
859
842
  events.use.on('approval:settled', () => attention.changed());
860
843
  events.use.on('app:restarting', () => admin.send({ type: 'app.restarting' }));
861
844
  // 发往 WebUI 自己会话的出站消息:会话历史就是 WebUI 的收件箱,页面只是查看器,打开着的页面由会话服务推送。
862
- // 回执表示这条消息已经进了会话历史,不看有没有页面在线。owner 从 WebUI 驱动的 IM 房间(根会话记下了出生平台)
863
- // 由那个平台的适配器认领
845
+ // 回执表示这条消息已经由存档记进会话历史(recordOutbound),不看有没有页面在线;送达就是写进历史,不另发 outbound:delivered。
846
+ // owner 从 WebUI 驱动的 IM 房间(根会话记下了出生平台)由那个平台的适配器认领,子会话没有窗口、由存档认领
864
847
  events.use.on('outbound:message', (msg) => {
865
848
  if (msg.delivery || msg.platform !== 'webui')
866
849
  return;
867
850
  const sm = caps.sessions.present ? caps.sessions.use : undefined;
868
- const root = sm && rootSessionOf(sm.getSession(msg.sessionId), id => sm.getSession(id));
851
+ const session = sm?.getSession(msg.sessionId);
852
+ if (session?.parentId)
853
+ return;
854
+ const root = sm && rootSessionOf(session, id => sm.getSession(id));
869
855
  if (root?.originPlatform !== undefined)
870
856
  return;
871
- // 同步挂上 Promise,始终 resolve,即使其他 outbound 监听器随后耗时,也不会产生未处理的 rejection。
872
- msg.delivery = (async () => {
873
- try {
874
- // 保存快照:下游处理不应受生产者或其他适配器后续改写影响。
875
- const snapshot = { ...msg, attachments: msg.attachments?.map(att => ({ ...att })) };
876
- if (!caps.archive.present)
877
- return { ok: false, error: '消息存档服务不在场,这条消息没有存进 WebUI 会话的历史' };
878
- const archive = caps.archive.use;
879
- for (const record of inboxRecords(snapshot))
880
- await archive.saveMessage(msg.sessionId, record);
881
- if (snapshot.attachments?.length) {
882
- // 附件已由上面记进会话历史(见 inboxRecords),标上 skipArchive,免得全局出站归档再记一份
883
- const attachments = snapshot.attachments.map(att => ({ ...att, skipArchive: true }));
884
- void events.use.emit('outbound:delivered', { ...snapshot, attachments, delivery: undefined });
885
- }
886
- return { ok: true };
887
- }
888
- catch (error) {
889
- return { ok: false, error: error instanceof Error ? error.message : String(error) };
890
- }
891
- })();
857
+ if (!caps.archive.present) {
858
+ msg.delivery = Promise.resolve({ ok: false, error: t('archiveAbsent') });
859
+ return;
860
+ }
861
+ msg.delivery = caps.archive.use.recordOutbound(msg).then(() => ({ ok: true }), error => ({ ok: false, error: textOf(error) }));
892
862
  });
893
863
  // 前端切换「逃生页」:由 webui-server 直出、独立于任何可被切换的前端,故任意前端(哪怕
894
864
  // 极简第三方前端无切换 UI)卡住时都能在此切回。受全局 auth 中间件登录保护;切换走既有 owner
895
865
  // 闸的 POST /api/services/webui-client/prefer,本页零新增后端逻辑(见 client-switch-page.ts)。
896
866
  // 注册在 SPA 兜底之前,故 `/__clients` 被本路由先接走、不落到前端 index.html。
897
867
  route(expressApp, 'GET /__clients', (_req, res) => {
898
- res.type('html').send(renderClientSwitchPage());
868
+ const text = localizer.render(CLIENT_SWITCH_TEXTS);
869
+ res.type('html').send(renderClientSwitchPage({ ...text, lang: pageLanguageOf(text.lang, localizer.languages()) }));
899
870
  });
900
871
  route(expressApp, 'GET /__clients.js', (_req, res) => {
901
872
  res.type('application/javascript').send(CLIENT_SWITCH_SCRIPT);
@@ -904,10 +875,10 @@ async function startWebuiServer(caps) {
904
875
  expressApp.use((req, res, next) => {
905
876
  if (!req.path.startsWith('/api/'))
906
877
  return next();
907
- res.status(404).json({ error: `未知 API: ${req.method} ${req.path}` });
878
+ res.status(404).json({ error: t('unknownApi', { method: req.method, path: req.path }) });
908
879
  });
909
- // SPA fallback: 所有非 API 路径返回 index.html;/assets/ 下缺失的文件回 404(旧页面引用的资源换了构建就不在了,
910
- // 回 index.html 只会让浏览器把 HTML 当脚本执行)
880
+ // SPA fallback: 所有非 API 路径返回 index.html,<html lang> 与界面的语言块按全局层的语言写好;/assets/ 下缺失的文件回
881
+ // 404(旧页面引用的资源换了构建就不在了,回 index.html 只会让浏览器把 HTML 当脚本执行)
911
882
  route(expressApp, 'GET /{*path}', (req, res) => {
912
883
  if (req.path.startsWith('/assets/')) {
913
884
  res.status(404).end();
@@ -916,32 +887,40 @@ async function startWebuiServer(caps) {
916
887
  // 还没有选中的前端时 clientDist 为空:不能拼出相对 cwd 的 index.html
917
888
  const indexPath = clientDist ? join(clientDist, 'index.html') : undefined;
918
889
  if (indexPath && existsSync(indexPath)) {
919
- res.sendFile(indexPath);
890
+ // 每次再验证:页面随全局层的语言变
891
+ const lang = pageLanguageOf(localizer.render(t('pageLanguage')), localizer.languages());
892
+ res.set('Cache-Control', 'public, max-age=0');
893
+ res.type('html').send(withPageLanguage(readFileSync(indexPath, 'utf-8'), lang, interfaceLanguage(localizer)));
920
894
  }
921
895
  else {
922
- res.status(404).json({ error: '前端未就绪:请安装一个带 aalis.client 标记的前端包' });
896
+ res.status(404).json({ error: t('noClient') });
923
897
  }
924
898
  });
925
899
  // 路由与中间件抛出的错误(请求体不是 JSON 或太大、路径参数解不开、处理函数出错):/api/ 下回 ErrorResponse,别处回纯文本,
926
- // 都不带堆栈,原因只进日志。挂在全部路由之后(插件前端的挂载另有自己的,紧跟在挂载之后)
900
+ // 都不带堆栈,原因只进日志。挂着文字对象的错误(TextError、withText)是写给人看的:状态取它的 status,没有时 400,
901
+ // 按读者语言给出它的文字。请求体解析的拒绝按 type 给字典里的整句,库的原句(英文、带解析位置)只进 debug 日志。
902
+ // 挂在全部路由之后(插件前端的挂载另有自己的,紧跟在挂载之后)
927
903
  expressApp.use((err, req, res, next) => {
928
904
  if (res.headersSent)
929
905
  return next(err);
930
- const { status, expose, message } = err;
931
- const code = typeof status === 'number' && status >= 400 && status < 600 ? status : 500;
906
+ const { status, type, message } = err;
907
+ const told = isTextError(err);
908
+ const code = typeof status === 'number' && status >= 400 && status < 600 ? status : told ? 400 : 500;
932
909
  if (code >= 500)
933
910
  logger.use.error(`${req.method} ${req.path} 处理出错:`, err);
934
911
  else
935
912
  logger.use.debug(`${req.method} ${req.path} 请求被拒 (${code}): ${String(message)}`);
936
- const reason = code >= 500
937
- ? '处理这个请求时出错,原因见日志'
938
- : expose === true && typeof message === 'string'
939
- ? message
940
- : '请求的写法不对';
913
+ const reason = told
914
+ ? err.text
915
+ : code >= 500
916
+ ? t('serverError')
917
+ : typeof type === 'string' && Object.hasOwn(BODY_REJECTIONS, type)
918
+ ? BODY_REJECTIONS[type]
919
+ : t('badRequest');
941
920
  if (req.path.startsWith('/api/'))
942
921
  res.status(code).json({ error: reason });
943
922
  else
944
- res.status(code).type('text').send(reason);
923
+ res.status(code).type('text').send(localizer.render(reason));
945
924
  });
946
925
  /**
947
926
  * 全动态发现前端:按 `aalis.client:true` 标记扫描,**不硬编码任何前端包名**
@@ -1073,7 +1052,7 @@ async function startWebuiServer(caps) {
1073
1052
  // 不把 token 打进日志(会落 latest.log / 被 /api/logs/* 回放 / 贴日志求助时外泄)。
1074
1053
  // 仅打不带 token 的 URL,完整一键登录链接见 access.txt(按凭据写,只给本机用户读)。
1075
1054
  logger.use.info(`首次访问 URL(${tokenHint}): ${url} —— 完整一键登录链接见 ${accessFileUri}`);
1076
- void (async () => {
1055
+ void trackWrite(async () => {
1077
1056
  // 先把文件写出来,再解析它的绝对路径:resolveLocalPath 走 realpath,文件尚未落盘会抛
1078
1057
  // ENOENT 被吞掉,于是「绝对路径」退化成原样重复一遍 URI——偏偏首启(新用户唯一需要
1079
1058
  // 这行的时刻)必然命中,二次启动因文件已在反而正常。两者原先都是 void,谁先到看调度。
@@ -1093,7 +1072,7 @@ async function startWebuiServer(caps) {
1093
1072
  logger.use.debug(`解析访问凭据文件绝对路径失败: ${err.message}`);
1094
1073
  }
1095
1074
  logger.use.info(`访问凭据已写入: ${accessFileUri}(绝对路径: ${absHint})`);
1096
- })();
1075
+ });
1097
1076
  // 记的是宿主实例本身(业务句柄每次激活各一个,认不出同一个宿主)
1098
1077
  const host = services.use.get(appService);
1099
1078
  if (uiConfig.autoOpen && (tokenFresh || !host || !browserOpenedFor.has(host))) {