@aalis/plugin-webui-server 0.15.0 → 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.
- package/dist/attention.d.ts +2 -2
- package/dist/attention.d.ts.map +1 -1
- package/dist/attention.js +5 -4
- package/dist/attention.js.map +1 -1
- package/dist/auth.d.ts +13 -4
- package/dist/auth.d.ts.map +1 -1
- package/dist/auth.js +77 -27
- package/dist/auth.js.map +1 -1
- package/dist/client-switch-page.d.ts +25 -3
- package/dist/client-switch-page.d.ts.map +1 -1
- package/dist/client-switch-page.js +49 -19
- package/dist/client-switch-page.js.map +1 -1
- package/dist/gate.d.ts.map +1 -1
- package/dist/gate.js +2 -1
- package/dist/gate.js.map +1 -1
- package/dist/html.d.ts +15 -0
- package/dist/html.d.ts.map +1 -0
- package/dist/html.js +38 -0
- package/dist/html.js.map +1 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +223 -244
- package/dist/index.js.map +1 -1
- package/dist/layout.d.ts +2 -1
- package/dist/layout.d.ts.map +1 -1
- package/dist/layout.js +5 -3
- package/dist/layout.js.map +1 -1
- package/dist/locales/en.d.ts +191 -0
- package/dist/locales/en.d.ts.map +1 -0
- package/dist/locales/en.js +191 -0
- package/dist/locales/en.js.map +1 -0
- package/dist/localize.d.ts +24 -0
- package/dist/localize.d.ts.map +1 -0
- package/dist/localize.js +68 -0
- package/dist/localize.js.map +1 -0
- package/dist/page-check.d.ts.map +1 -1
- package/dist/page-check.js +32 -25
- package/dist/page-check.js.map +1 -1
- package/dist/pages.d.ts +9 -7
- package/dist/pages.d.ts.map +1 -1
- package/dist/pages.js +77 -61
- package/dist/pages.js.map +1 -1
- package/dist/route.d.ts +5 -2
- package/dist/route.d.ts.map +1 -1
- package/dist/route.js +5 -2
- package/dist/route.js.map +1 -1
- package/dist/routes/files.d.ts.map +1 -1
- package/dist/routes/files.js +4 -3
- package/dist/routes/files.js.map +1 -1
- package/dist/routes/frames.js +3 -3
- package/dist/routes/frames.js.map +1 -1
- package/dist/routes/i18n.d.ts +20 -0
- package/dist/routes/i18n.d.ts.map +1 -0
- package/dist/routes/i18n.js +78 -0
- package/dist/routes/i18n.js.map +1 -0
- package/dist/routes/marketplace.d.ts.map +1 -1
- package/dist/routes/marketplace.js +27 -29
- package/dist/routes/marketplace.js.map +1 -1
- package/dist/routes/media.d.ts +3 -0
- package/dist/routes/media.d.ts.map +1 -1
- package/dist/routes/media.js +29 -26
- package/dist/routes/media.js.map +1 -1
- package/dist/routes/pages.d.ts +4 -2
- package/dist/routes/pages.d.ts.map +1 -1
- package/dist/routes/pages.js +36 -18
- package/dist/routes/pages.js.map +1 -1
- package/dist/routes/plugins.d.ts +5 -9
- package/dist/routes/plugins.d.ts.map +1 -1
- package/dist/routes/plugins.js +114 -129
- package/dist/routes/plugins.js.map +1 -1
- package/dist/routes/proxy.d.ts +3 -2
- package/dist/routes/proxy.d.ts.map +1 -1
- package/dist/routes/proxy.js +62 -41
- package/dist/routes/proxy.js.map +1 -1
- package/dist/texts.d.ts +381 -0
- package/dist/texts.d.ts.map +1 -0
- package/dist/texts.js +194 -0
- package/dist/texts.js.map +1 -0
- 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 {
|
|
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,
|
|
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 {
|
|
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 {
|
|
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: '
|
|
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: '
|
|
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: '
|
|
120
|
+
description: t('autoOpenDescription'),
|
|
124
121
|
},
|
|
125
122
|
tokenMode: {
|
|
126
123
|
type: 'select',
|
|
127
|
-
label: '
|
|
124
|
+
label: t('tokenModeLabel'),
|
|
128
125
|
default: 'persist',
|
|
129
126
|
onInvalid: 'error',
|
|
130
127
|
secret: false,
|
|
131
128
|
options: [
|
|
132
|
-
{ label: '
|
|
133
|
-
{ label: '
|
|
134
|
-
{ label: '
|
|
129
|
+
{ label: t('tokenEphemeral'), value: 'ephemeral' },
|
|
130
|
+
{ label: t('tokenPersist'), value: 'persist' },
|
|
131
|
+
{ label: t('tokenFixed'), value: 'fixed' },
|
|
135
132
|
],
|
|
136
|
-
description: '
|
|
133
|
+
description: t('tokenModeDescription'),
|
|
137
134
|
},
|
|
138
135
|
fixedToken: {
|
|
139
136
|
type: 'string',
|
|
140
|
-
label: '
|
|
137
|
+
label: t('fixedTokenLabel'),
|
|
141
138
|
default: '',
|
|
142
139
|
onInvalid: 'error',
|
|
143
140
|
secret: true,
|
|
144
|
-
description:
|
|
141
|
+
description: t('fixedTokenDescription', { min: FIXED_TOKEN_MIN_LENGTH }),
|
|
145
142
|
},
|
|
146
143
|
marketplaceRegistry: {
|
|
147
144
|
type: 'string',
|
|
148
|
-
label: '
|
|
145
|
+
label: t('registryLabel'),
|
|
149
146
|
default: 'https://registry.npmjs.org',
|
|
150
147
|
onInvalid: 'error',
|
|
151
|
-
description: '
|
|
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
|
-
|
|
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: '
|
|
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('
|
|
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(
|
|
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
|
-
'
|
|
298
|
-
|
|
299
|
-
|
|
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
|
-
|
|
264
|
+
t('accessLogin', { url: `${url}?token=${token}` }),
|
|
304
265
|
'',
|
|
305
|
-
'
|
|
306
|
-
];
|
|
266
|
+
t('accessHint'),
|
|
267
|
+
]);
|
|
307
268
|
try {
|
|
308
|
-
await storage.writeFile(accessFileUri, `${
|
|
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
|
-
|
|
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
|
-
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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
|
-
|
|
583
|
-
|
|
584
|
-
displayNameMap.set(p.
|
|
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:
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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:
|
|
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
|
-
?
|
|
699
|
-
:
|
|
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
|
|
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
|
-
//
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
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
|
-
//
|
|
744
|
-
//
|
|
713
|
+
// 深度思考的档位:可选项依赖「模型」这一项,dependency 带它的值(条目 id,按 api-llm 的写法拆);没带时没有可选项,
|
|
714
|
+
// 不替调用方挑模型。默认档标 default(没设时界面据它写按哪一档跑),模型不能调时没有可选项。labels 是各模型声明过的
|
|
715
|
+
// 档位的名字(同一个 id 取先登记的):换了模型以后存着的档位不在可选项里,界面按它写出名字
|
|
745
716
|
if (serviceName === 'llm-thinking') {
|
|
746
|
-
const
|
|
747
|
-
|
|
748
|
-
|
|
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
|
-
//
|
|
739
|
+
// 工具分组:已登记的分组与工具上出现过的分组取并集,按名字排;登记过的写名字、说明取 summary(description 只给模型),
|
|
740
|
+
// 没登记的写原名。'*' 是全部分组(api-tools 的分组约定),总在最前、不带成员。有工具的分组带组内工具的名字(members,按
|
|
741
|
+
// 名字排,一个工具在几组就在每组都列,同一组写了两遍的只列一次);不属于任何分组的工具另交 others,没有时不交
|
|
768
742
|
if (serviceName === 'toolGroups') {
|
|
769
|
-
const
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
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') }, ...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
|
-
//
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
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
|
-
|
|
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:
|
|
878
|
+
res.status(404).json({ error: t('unknownApi', { method: req.method, path: req.path }) });
|
|
908
879
|
});
|
|
909
|
-
// SPA fallback: 所有非 API 路径返回 index.html
|
|
910
|
-
//
|
|
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
|
-
|
|
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: '
|
|
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,
|
|
931
|
-
const
|
|
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 =
|
|
937
|
-
?
|
|
938
|
-
:
|
|
939
|
-
?
|
|
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))) {
|