@aalis/plugin-webui-server 0.11.10 → 0.12.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/index.d.ts +26 -12
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +223 -161
- package/dist/index.js.map +1 -1
- package/dist/routes/files.d.ts +1 -2
- package/dist/routes/files.d.ts.map +1 -1
- package/dist/routes/files.js +1 -1
- package/dist/routes/files.js.map +1 -1
- package/dist/routes/marketplace.d.ts +18 -5
- package/dist/routes/marketplace.d.ts.map +1 -1
- package/dist/routes/marketplace.js +16 -13
- package/dist/routes/marketplace.js.map +1 -1
- package/dist/routes/plugins.d.ts +23 -3
- package/dist/routes/plugins.d.ts.map +1 -1
- package/dist/routes/plugins.js +170 -65
- package/dist/routes/plugins.js.map +1 -1
- package/dist/routes/proxy.d.ts +2 -2
- package/dist/routes/proxy.d.ts.map +1 -1
- package/dist/routes/proxy.js +2 -2
- package/dist/routes/proxy.js.map +1 -1
- package/dist/routes/uploaded-files.d.ts +9 -2
- package/dist/routes/uploaded-files.d.ts.map +1 -1
- package/dist/routes/uploaded-files.js +7 -7
- package/dist/routes/uploaded-files.js.map +1 -1
- package/package.json +39 -22
package/dist/index.js
CHANGED
|
@@ -6,12 +6,20 @@ import { createServer } from 'node:http';
|
|
|
6
6
|
import { createRequire } from 'node:module';
|
|
7
7
|
import { dirname, resolve } from 'node:path';
|
|
8
8
|
import { pathToFileURL } from 'node:url';
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
9
|
+
import { agent } from '@aalis/api-agent';
|
|
10
|
+
import { authority } from '@aalis/api-authority';
|
|
11
|
+
import { commands } from '@aalis/api-commands';
|
|
12
|
+
import { listLLMModels, llm } from '@aalis/api-llm';
|
|
13
|
+
import { memory } from '@aalis/api-memory';
|
|
14
|
+
import { persona } from '@aalis/api-persona';
|
|
15
|
+
import { aggregatePlatformDetails, getPlatformAdapters, getPlatformNames, platform, } from '@aalis/api-platform';
|
|
16
|
+
import { createProcessGateway, processService } from '@aalis/api-process';
|
|
17
|
+
import { sessionConfirm } from '@aalis/api-session-confirm';
|
|
18
|
+
import { createStorageGateway, readTailLines, storage } from '@aalis/api-storage';
|
|
19
|
+
import { tools } from '@aalis/api-tools';
|
|
20
|
+
import { DEFAULT_SUBSYSTEM_METADATA, webuiClient, webuiServer } from '@aalis/api-webui';
|
|
21
|
+
import { appService, config, definePlugin, events, hostConfig, LogHub, lifecycle, logger, optional, pluginsService, provide, services, } from '@aalis/core';
|
|
22
|
+
import { parseLogLine } from '@aalis/schema-log';
|
|
15
23
|
import express from 'express';
|
|
16
24
|
import { WebSocket, WebSocketServer } from 'ws';
|
|
17
25
|
import { createAuthSystem, openBrowser } from './auth.js';
|
|
@@ -35,17 +43,7 @@ function descSummary(d) {
|
|
|
35
43
|
return s.length > 200 ? `${s.slice(0, 200)}…` : s;
|
|
36
44
|
}
|
|
37
45
|
// ===== 插件元数据 =====
|
|
38
|
-
|
|
39
|
-
export const displayName = 'WebUI 服务端';
|
|
40
|
-
export const subsystem = 'platform';
|
|
41
|
-
export const provides = ['webui-server', 'platform'];
|
|
42
|
-
export const inject = {
|
|
43
|
-
// storage 改为 optional:避免 plugin-storage-local bounce 时级联重启 webui-server
|
|
44
|
-
// (若 required,存储服务暂时消失会让 webui-server 进入 pending,导致 registeredPages
|
|
45
|
-
// 被清空,其他插件的 sidebar 页面在 webui-server 重新激活后无法恢复)。
|
|
46
|
-
// storage gateway 的各操作已有 try-catch,暂时不可用时仅个别文件操作失败,不影响主功能。
|
|
47
|
-
optional: ['storage', 'authority', 'commands', 'platform', 'process', 'session-confirm'],
|
|
48
|
-
};
|
|
46
|
+
const name = '@aalis/plugin-webui-server';
|
|
49
47
|
const webuiPages = [
|
|
50
48
|
{ key: 'dashboard', label: '仪表盘', icon: 'dashboard', order: 10, renderer: 'dashboard' },
|
|
51
49
|
{ key: 'marketplace', label: '插件市场', icon: 'marketplace', order: 20, renderer: 'marketplace' },
|
|
@@ -54,7 +52,7 @@ const webuiPages = [
|
|
|
54
52
|
{ key: 'files', label: '文件管理', icon: 'files', order: 50, renderer: 'files' },
|
|
55
53
|
{ key: 'logs', label: '日志', icon: 'logs', order: 60, renderer: 'logs' },
|
|
56
54
|
];
|
|
57
|
-
|
|
55
|
+
const configSchema = {
|
|
58
56
|
port: { type: 'number', label: '端口', default: 3000, description: 'Web 管理界面的 HTTP 端口' },
|
|
59
57
|
host: { type: 'string', label: '监听地址', default: '127.0.0.1', description: '绑定的 IP 地址,0.0.0.0 可对外访问' },
|
|
60
58
|
fileRoot: {
|
|
@@ -106,7 +104,7 @@ export const configSchema = {
|
|
|
106
104
|
import { WSIncomingSchema } from './protocol.js';
|
|
107
105
|
export { WSIncomingSchema } from './protocol.js';
|
|
108
106
|
// ===== 日志文件读取(与 @aalis/runtime 的 file-logger 格式对偶)=====
|
|
109
|
-
// 行格式契约(format ↔ parse)由 @aalis/
|
|
107
|
+
// 行格式契约(format ↔ parse)由 @aalis/schema-log 的 parseLogLine 唯一持有,此处只负责
|
|
110
108
|
// 读取「持久化日志单一数据源」并按 cursor 尾读分页。
|
|
111
109
|
//
|
|
112
110
|
// 路径(宿主目录布局)是「环境知识」,归 storage 的 logs 根所有;本插件只用 storage
|
|
@@ -152,7 +150,7 @@ const LOCAL_SCAN_DIRS = [resolve(process.cwd(), 'packages'), resolve(process.cwd
|
|
|
152
150
|
* 快照算进错的上下文窗口。前缀不认识时才落回 `'webui'`——CLI 的 `cli-default` 这类不带
|
|
153
151
|
* 前缀的会话,以及 webui 自己的会话,都归在这一档。
|
|
154
152
|
*
|
|
155
|
-
* 纯函数:平台名集合由调用方从 `getPlatformNames(
|
|
153
|
+
* 纯函数:平台名集合由调用方从 `getPlatformNames(platform)` 取(adapter 可热插拔,每次现取)。
|
|
156
154
|
*/
|
|
157
155
|
export function resolveSessionPlatform(sessionId, known) {
|
|
158
156
|
const idx = sessionId.indexOf(':');
|
|
@@ -161,7 +159,44 @@ export function resolveSessionPlatform(sessionId, known) {
|
|
|
161
159
|
const prefix = sessionId.slice(0, idx);
|
|
162
160
|
return known.has(prefix) ? prefix : 'webui';
|
|
163
161
|
}
|
|
164
|
-
|
|
162
|
+
// storage 等一律 optional:webui 是管理面,任何被管对象缺席都只该让对应页面降级,不该把
|
|
163
|
+
// 整个控制台拖进 pending。storage 尤其不能 required——plugin-storage-local bounce 会让它
|
|
164
|
+
// 暂时消失,webui-server 跟着重启就清空 registeredPages,其他插件的侧边栏页面再也回不来。
|
|
165
|
+
const uses = {
|
|
166
|
+
events,
|
|
167
|
+
logger,
|
|
168
|
+
lifecycle,
|
|
169
|
+
config,
|
|
170
|
+
provide,
|
|
171
|
+
services,
|
|
172
|
+
hostConfig: optional(hostConfig),
|
|
173
|
+
app: optional(appService),
|
|
174
|
+
plugins: optional(pluginsService),
|
|
175
|
+
storage: optional(storage),
|
|
176
|
+
authority: optional(authority),
|
|
177
|
+
commands: optional(commands),
|
|
178
|
+
// platform 既被本插件提供又被平台页枚举:required 会让激活等一个由自己提供的服务
|
|
179
|
+
platform: optional(platform),
|
|
180
|
+
process: optional(processService),
|
|
181
|
+
sessionConfirm: optional(sessionConfirm),
|
|
182
|
+
tools: optional(tools),
|
|
183
|
+
llm: optional(llm),
|
|
184
|
+
persona: optional(persona),
|
|
185
|
+
agent: optional(agent),
|
|
186
|
+
memory: optional(memory),
|
|
187
|
+
};
|
|
188
|
+
export default definePlugin({
|
|
189
|
+
name,
|
|
190
|
+
displayName: 'WebUI 服务端',
|
|
191
|
+
subsystem: 'platform',
|
|
192
|
+
configSchema,
|
|
193
|
+
provides: [webuiServer, platform],
|
|
194
|
+
uses,
|
|
195
|
+
apply: startWebuiServer,
|
|
196
|
+
});
|
|
197
|
+
async function startWebuiServer(caps) {
|
|
198
|
+
const { events, logger, lifecycle, provide, services } = caps;
|
|
199
|
+
const config = caps.config;
|
|
165
200
|
const uiConfig = {
|
|
166
201
|
port: config.port ?? 3000,
|
|
167
202
|
host: config.host ?? '127.0.0.1',
|
|
@@ -178,7 +213,7 @@ export async function apply(ctx, config) {
|
|
|
178
213
|
marketplaceRegistry: config.marketplaceRegistry?.trim() || 'https://registry.npmjs.org',
|
|
179
214
|
};
|
|
180
215
|
// 创建 storage gateway;所有文件读写(token、access、文件管理)都走这里
|
|
181
|
-
const storage = createStorageGateway(
|
|
216
|
+
const storage = createStorageGateway(caps.storage);
|
|
182
217
|
// ---- Token 解析 ----
|
|
183
218
|
// - ephemeral:每次启动随机生成(旧行为)
|
|
184
219
|
// - persist:首次生成后写入 storage data:/webui/token,重启复用(默认)
|
|
@@ -205,7 +240,7 @@ export async function apply(ctx, config) {
|
|
|
205
240
|
await storage.writeFile(tokenFileUri, fresh);
|
|
206
241
|
}
|
|
207
242
|
catch (err) {
|
|
208
|
-
|
|
243
|
+
logger.warn(`持久化 token 失败,本次仍可使用但重启会再生成: ${err.message}`);
|
|
209
244
|
}
|
|
210
245
|
return fresh;
|
|
211
246
|
}
|
|
@@ -230,12 +265,12 @@ export async function apply(ctx, config) {
|
|
|
230
265
|
return true;
|
|
231
266
|
}
|
|
232
267
|
catch (err) {
|
|
233
|
-
|
|
268
|
+
logger.warn(`写入访问文件失败: ${err.message}`);
|
|
234
269
|
return false;
|
|
235
270
|
}
|
|
236
271
|
}
|
|
237
272
|
const authToken = await resolveAuthToken();
|
|
238
|
-
const auth = createAuthSystem(authToken,
|
|
273
|
+
const auth = createAuthSystem(authToken, logger.child('auth'));
|
|
239
274
|
const expressApp = express();
|
|
240
275
|
expressApp.use(express.json({ limit: '10mb' }));
|
|
241
276
|
expressApp.use(auth.middleware);
|
|
@@ -259,8 +294,8 @@ export async function apply(ctx, config) {
|
|
|
259
294
|
// 半开连接回收:清理只挂 'close' 时,睡眠/断网的客户端永远等不到事件,
|
|
260
295
|
// 日志广播持续往死连接的发送缓冲堆数据。详见 ws-heartbeat.ts。
|
|
261
296
|
const heartbeat = createWsHeartbeat({
|
|
262
|
-
onStale: ws =>
|
|
263
|
-
onError: (_ws, err) =>
|
|
297
|
+
onStale: ws => logger.debug(`WebUI 客户端心跳超时,回收半开连接 (bufferedAmount=${ws.bufferedAmount})`),
|
|
298
|
+
onError: (_ws, err) => logger.warn(`WebUI 客户端连接错误: ${err.message}`),
|
|
264
299
|
});
|
|
265
300
|
const streamBuffers = new Map();
|
|
266
301
|
// 延迟清理 streamBuffers:捕获要删的 buf 引用,10s 后仅当 (a) 该 session 仍是
|
|
@@ -274,9 +309,6 @@ export async function apply(ctx, config) {
|
|
|
274
309
|
};
|
|
275
310
|
// Token 用量缓存:记录每个 session 最近一次的 token 用量,用于刷新/切换会话后立即展示
|
|
276
311
|
const tokenUsageCache = new Map();
|
|
277
|
-
// 获取核心服务(通过服务注册获取)
|
|
278
|
-
const getApp = () => ctx.getService('app');
|
|
279
|
-
const getPluginMgr = () => ctx.getService('plugins');
|
|
280
312
|
// 前端静态文件托管:前端包不是插件(无 apply,纯静态资源),托管权在本插件——
|
|
281
313
|
// app:ready 时由 client-discovery 按 `aalis.client` 标记发现候选,本插件把每个候选注册成
|
|
282
314
|
// 一条 webui-client 服务 provider,再由服务解析(偏好 > 优先级 > 注册顺序)定出活跃前端。
|
|
@@ -285,11 +317,11 @@ export async function apply(ctx, config) {
|
|
|
285
317
|
function mountStaticDir(dir) {
|
|
286
318
|
if (existsSync(dir)) {
|
|
287
319
|
staticMiddleware = express.static(dir);
|
|
288
|
-
|
|
320
|
+
logger.info(`前端静态目录: ${dir}`);
|
|
289
321
|
}
|
|
290
322
|
else {
|
|
291
323
|
staticMiddleware = null;
|
|
292
|
-
|
|
324
|
+
logger.warn(`前端目录不存在: ${dir}`);
|
|
293
325
|
}
|
|
294
326
|
}
|
|
295
327
|
// 已发现的前端候选(app:ready 时填充,逐个注册为 webui-client 服务 provider)。
|
|
@@ -304,7 +336,7 @@ export async function apply(ctx, config) {
|
|
|
304
336
|
const clientProviderDisposers = new Map();
|
|
305
337
|
/** 当前活跃前端目录 = 解析后的 webui-client 服务(servicePreferences 偏好 > 优先级 > 注册顺序)。 */
|
|
306
338
|
function currentClientDir() {
|
|
307
|
-
return
|
|
339
|
+
return services.get(webuiClient)?.getClientDir?.();
|
|
308
340
|
}
|
|
309
341
|
/**
|
|
310
342
|
* 切换 webui-client 服务偏好后调用:重挂当前活跃前端 + 广播 'reload'——所有客户端进入
|
|
@@ -334,20 +366,21 @@ export async function apply(ctx, config) {
|
|
|
334
366
|
// ---------- REST API ----------
|
|
335
367
|
// 获取系统状态
|
|
336
368
|
expressApp.get('/api/status', gate(), (_req, res) => {
|
|
337
|
-
const
|
|
338
|
-
//
|
|
339
|
-
|
|
340
|
-
const
|
|
341
|
-
const
|
|
369
|
+
const personaSvc = caps.persona.current;
|
|
370
|
+
// 判断上传能力。media / file-reader / cli 在本插件里只做在场探测,为一个布尔值反向依赖
|
|
371
|
+
// 它们的包不值当(file-reader 还是插件包),故按名动态查——动态查到的不算声明依赖。
|
|
372
|
+
const hasMedia = services.get('media') !== undefined;
|
|
373
|
+
const llmHasVision = listLLMModels(caps.llm).some(e => e.instance.capabilities.includes('vision'));
|
|
374
|
+
const hasFileReader = services.get('file-reader') !== undefined;
|
|
342
375
|
res.json({
|
|
343
|
-
name:
|
|
376
|
+
name: personaSvc?.getPersonaName() ?? caps.hostConfig.require().get('name'),
|
|
344
377
|
services: {
|
|
345
|
-
'webui-server':
|
|
346
|
-
cli:
|
|
347
|
-
llm:
|
|
348
|
-
agent:
|
|
349
|
-
memory:
|
|
350
|
-
persona:
|
|
378
|
+
'webui-server': services.get(webuiServer) !== undefined,
|
|
379
|
+
cli: services.get('cli') !== undefined,
|
|
380
|
+
llm: caps.llm.current !== undefined,
|
|
381
|
+
agent: caps.agent.current !== undefined,
|
|
382
|
+
memory: caps.memory.current !== undefined,
|
|
383
|
+
persona: personaSvc !== undefined,
|
|
351
384
|
},
|
|
352
385
|
/** 上传能力:客户端据此决定显示哪些上传按钮 */
|
|
353
386
|
uploadCapabilities: {
|
|
@@ -356,14 +389,8 @@ export async function apply(ctx, config) {
|
|
|
356
389
|
/** 是否支持文件上传(file-reader 可用) */
|
|
357
390
|
file: hasFileReader,
|
|
358
391
|
},
|
|
359
|
-
tools:
|
|
360
|
-
|
|
361
|
-
?.getAll()
|
|
362
|
-
.map(t => t.name) ?? [],
|
|
363
|
-
commands: ctx
|
|
364
|
-
.getService('commands')
|
|
365
|
-
?.getAll()
|
|
366
|
-
.map(c => ({
|
|
392
|
+
tools: caps.tools.current?.getAll().map(t => t.name) ?? [],
|
|
393
|
+
commands: caps.commands.current?.getAll().map(c => ({
|
|
367
394
|
name: c.name,
|
|
368
395
|
description: descSummary(c.description),
|
|
369
396
|
visibility: c.visibility,
|
|
@@ -392,11 +419,27 @@ export async function apply(ctx, config) {
|
|
|
392
419
|
},
|
|
393
420
|
join: (...parts) => resolve(...parts),
|
|
394
421
|
};
|
|
395
|
-
|
|
422
|
+
// 页面动作登记表:插件经 webui-server 的绑定接口登记,路由层按「插件 id + 方法名」查
|
|
423
|
+
const registeredActions = new Map();
|
|
424
|
+
registerPluginRoutes(expressApp, {
|
|
425
|
+
app: caps.app,
|
|
426
|
+
plugins: caps.plugins,
|
|
427
|
+
hostConfig: caps.hostConfig,
|
|
428
|
+
tools: caps.tools,
|
|
429
|
+
commands: caps.commands,
|
|
430
|
+
webui: () => services.get(webuiServer),
|
|
431
|
+
logger,
|
|
432
|
+
}, auth.identify, gate, (plugin, method) => registeredActions.get(plugin)?.get(method));
|
|
396
433
|
// 市场「已装」判定、依赖图、前端候选发现共用这一份扫描目录(pnpm 工作区下 require.resolve
|
|
397
434
|
// 从仓库根解析不到工作区包,只能扫盘)。每请求懒扫,量小。
|
|
398
435
|
const localScanDirs = LOCAL_SCAN_DIRS;
|
|
399
|
-
registerMarketplaceRoutes(expressApp,
|
|
436
|
+
registerMarketplaceRoutes(expressApp, {
|
|
437
|
+
logger,
|
|
438
|
+
plugins: caps.plugins,
|
|
439
|
+
services,
|
|
440
|
+
// package-manager 由插件提供,本包不反向依赖那个插件包:按名动态查,缺席时装卸路由回 503
|
|
441
|
+
packageManager: () => services.get('package-manager'),
|
|
442
|
+
}, gate, uiConfig.marketplaceRegistry, () => collectLocalPackageDeps(localScanDirs, fsScanEnv), () => discoverAndProvideClients());
|
|
400
443
|
// 获取历史日志:从 data/latest.log 读尾部 N 条(lazy load)。
|
|
401
444
|
// 单进程内 LogHub 不再缓存 buffer——历史以文件为单一数据源。
|
|
402
445
|
expressApp.get('/api/logs', gate(), async (_req, res) => {
|
|
@@ -435,7 +478,7 @@ export async function apply(ctx, config) {
|
|
|
435
478
|
});
|
|
436
479
|
// 获取服务列表(含提供者信息)
|
|
437
480
|
expressApp.get('/api/services', gate(), async (_req, res) => {
|
|
438
|
-
const pluginMgr =
|
|
481
|
+
const pluginMgr = caps.plugins.current;
|
|
439
482
|
const pluginStatus = pluginMgr ? pluginMgr.getStatus() : [];
|
|
440
483
|
const displayNameMap = new Map();
|
|
441
484
|
for (const p of pluginStatus) {
|
|
@@ -444,24 +487,25 @@ export async function apply(ctx, config) {
|
|
|
444
487
|
displayNameMap.set(p.instanceId, p.displayName);
|
|
445
488
|
}
|
|
446
489
|
}
|
|
447
|
-
//
|
|
490
|
+
// 根激活提供核心服务——显示包名,别把内部 id 'root' 裸露给用户。
|
|
448
491
|
displayNameMap.set('root', '@aalis/core');
|
|
449
|
-
const serviceNames =
|
|
450
|
-
const
|
|
492
|
+
const serviceNames = services.names();
|
|
493
|
+
const detail = {};
|
|
451
494
|
for (const svcName of serviceNames) {
|
|
452
|
-
//
|
|
453
|
-
const entries =
|
|
454
|
-
|
|
495
|
+
// 枚举已按「偏好 > 优先级 > 注册顺序」排序,附带 priority 字段;只取登记元数据,不取实例
|
|
496
|
+
const entries = services.inspect(svcName);
|
|
497
|
+
detail[svcName] = {
|
|
455
498
|
providers: entries.map(e => ({
|
|
456
499
|
contextId: e.contextId,
|
|
457
500
|
displayName: displayNameMap.get(e.contextId),
|
|
458
501
|
label: e.label,
|
|
459
502
|
priority: e.priority,
|
|
503
|
+
exclusive: e.exclusive,
|
|
460
504
|
})),
|
|
461
|
-
preferred:
|
|
505
|
+
preferred: services.preferred(svcName) ?? null,
|
|
462
506
|
};
|
|
463
507
|
}
|
|
464
|
-
res.json({ services });
|
|
508
|
+
res.json({ services: detail });
|
|
465
509
|
});
|
|
466
510
|
/**
|
|
467
511
|
* 设置服务偏好。body: { contextId: string }
|
|
@@ -475,28 +519,33 @@ export async function apply(ctx, config) {
|
|
|
475
519
|
return;
|
|
476
520
|
}
|
|
477
521
|
// 校验 entry 存在
|
|
478
|
-
const entries =
|
|
522
|
+
const entries = services.inspect(svcName);
|
|
479
523
|
if (!entries.some(e => e.contextId === contextId)) {
|
|
480
524
|
res.status(404).json({ ok: false, error: `service "${svcName}" has no provider with contextId "${contextId}"` });
|
|
481
525
|
return;
|
|
482
526
|
}
|
|
483
|
-
|
|
484
|
-
|
|
527
|
+
const host = caps.hostConfig.require();
|
|
528
|
+
if (!services.prefer(svcName, contextId)) {
|
|
529
|
+
res.status(409).json({ ok: false, error: `service "${svcName}" rejected provider preference "${contextId}"` });
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
host.setServicePreference(svcName, contextId);
|
|
485
533
|
// 切换前端:webui-client 是「前端」服务,偏好变更需重挂静态目录 + 通知客户端刷新。
|
|
486
534
|
// 重挂与偏好同属内存态,必须在等落盘之前一起生效:save 拒绝时才不会留下「解析选 B、静态挂 A」。
|
|
487
535
|
if (svcName === 'webui-client')
|
|
488
536
|
remountActiveClient();
|
|
489
|
-
await
|
|
537
|
+
await caps.app.require().saveConfig();
|
|
490
538
|
res.json({ ok: true });
|
|
491
539
|
});
|
|
492
540
|
/** 清除服务偏好 */
|
|
493
541
|
expressApp.delete('/api/services/:name/prefer', gate(), async (req, res) => {
|
|
494
542
|
const svcName = String(req.params.name);
|
|
495
|
-
|
|
496
|
-
|
|
543
|
+
const host = caps.hostConfig.require();
|
|
544
|
+
services.unprefer(svcName);
|
|
545
|
+
host.removeServicePreference(svcName);
|
|
497
546
|
if (svcName === 'webui-client')
|
|
498
547
|
remountActiveClient();
|
|
499
|
-
await
|
|
548
|
+
await caps.app.require().saveConfig();
|
|
500
549
|
res.json({ ok: true });
|
|
501
550
|
});
|
|
502
551
|
// 前端切换已统一到「服务偏好」:前端是 webui-client 服务的多 provider,切换走
|
|
@@ -504,12 +553,13 @@ export async function apply(ctx, config) {
|
|
|
504
553
|
// 故不再有独立的 /api/clients* 路由。
|
|
505
554
|
// 获取所有平台适配器及其连接状态
|
|
506
555
|
expressApp.get('/api/platforms', gate(), (_req, res) => {
|
|
507
|
-
res.json({ platforms: aggregatePlatformDetails(
|
|
556
|
+
res.json({ platforms: aggregatePlatformDetails(caps.platform) });
|
|
508
557
|
});
|
|
509
558
|
// 获取已注册的工具分组(含元数据 + 各组工具数量 + 贡献插件列表)
|
|
510
559
|
expressApp.get('/api/tool-groups', gate(), (_req, res) => {
|
|
511
|
-
const
|
|
512
|
-
const
|
|
560
|
+
const toolService = caps.tools.current;
|
|
561
|
+
const groups = toolService?.getGroups() ?? [];
|
|
562
|
+
const allTools = toolService?.getAll() ?? [];
|
|
513
563
|
const knownNames = new Set(groups.map(g => g.name));
|
|
514
564
|
const result = groups.map(g => {
|
|
515
565
|
const toolsInGroup = allTools.filter(t => t.groups?.includes(g.name));
|
|
@@ -537,13 +587,13 @@ export async function apply(ctx, config) {
|
|
|
537
587
|
});
|
|
538
588
|
// 获取服务分组(manifest 驱动:每个插件自己声明 subsystem,本路由仅聚合)
|
|
539
589
|
expressApp.get('/api/service-groups', gate(), (_req, res) => {
|
|
540
|
-
const pluginMgr =
|
|
590
|
+
const pluginMgr = caps.plugins.current;
|
|
541
591
|
const pluginStatus = pluginMgr ? pluginMgr.getStatus() : [];
|
|
542
|
-
// 按插件 subsystem 归组(未声明 → 'external')。subsystem
|
|
543
|
-
//
|
|
592
|
+
// 按插件 subsystem 归组(未声明 → 'external')。subsystem 是插件定义上的展示标签,
|
|
593
|
+
// core 状态契约不含——从定义直接读。
|
|
544
594
|
const groupsMap = new Map();
|
|
545
595
|
for (const p of pluginStatus) {
|
|
546
|
-
const sub = pluginMgr?.getPlugin(p.instanceId)?.
|
|
596
|
+
const sub = pluginMgr?.getPlugin(p.instanceId)?.definition?.subsystem ?? 'external';
|
|
547
597
|
if (!groupsMap.has(sub))
|
|
548
598
|
groupsMap.set(sub, []);
|
|
549
599
|
groupsMap.get(sub).push({ name: p.name, provides: p.provides ?? [] });
|
|
@@ -560,16 +610,16 @@ export async function apply(ctx, config) {
|
|
|
560
610
|
const servicesClaimed = new Set();
|
|
561
611
|
const groups = sorted.map(({ id, label }) => {
|
|
562
612
|
const plugins = groupsMap.get(id);
|
|
563
|
-
const
|
|
613
|
+
const owned = [...new Set(plugins.flatMap(p => p.provides))].filter(s => {
|
|
564
614
|
if (servicesClaimed.has(s))
|
|
565
615
|
return false;
|
|
566
616
|
servicesClaimed.add(s);
|
|
567
617
|
return true;
|
|
568
618
|
});
|
|
569
|
-
return { id, label, plugins, services };
|
|
619
|
+
return { id, label, plugins, services: owned };
|
|
570
620
|
});
|
|
571
621
|
// 系统内建服务(app、plugins 等由 core 直接 provide,不属于任何插件)
|
|
572
|
-
const systemServices =
|
|
622
|
+
const systemServices = services.names().filter(n => !servicesClaimed.has(n));
|
|
573
623
|
if (systemServices.length > 0) {
|
|
574
624
|
const sysLabel = meta.get('system')?.label ?? '系统';
|
|
575
625
|
groups.unshift({ id: 'system', label: sysLabel, plugins: [], services: systemServices });
|
|
@@ -579,7 +629,7 @@ export async function apply(ctx, config) {
|
|
|
579
629
|
// 获取所有 LLM 模型(枚举所有注册的 per-model entry)
|
|
580
630
|
expressApp.get('/api/llm-models', gate(), async (_req, res) => {
|
|
581
631
|
try {
|
|
582
|
-
const entries =
|
|
632
|
+
const entries = caps.llm.all();
|
|
583
633
|
const models = entries.map(e => ({
|
|
584
634
|
id: e.instance.id,
|
|
585
635
|
capabilities: [...e.instance.capabilities],
|
|
@@ -595,7 +645,7 @@ export async function apply(ctx, config) {
|
|
|
595
645
|
// LLM providers + per-provider models(供 schema type='llm-ref' 联动 select 使用)
|
|
596
646
|
expressApp.get('/api/llm-providers', gate(), async (_req, res) => {
|
|
597
647
|
try {
|
|
598
|
-
const entries =
|
|
648
|
+
const entries = caps.llm.all();
|
|
599
649
|
const byProvider = new Map();
|
|
600
650
|
// entry.label 形如 "Ollama (localhost:11434) / qwen3.6:35b-mlx";
|
|
601
651
|
// 取第一个 " / " 之前的部分作为 providerLabel(避免把 model id 也带进去)。
|
|
@@ -637,7 +687,7 @@ export async function apply(ctx, config) {
|
|
|
637
687
|
return;
|
|
638
688
|
}
|
|
639
689
|
try {
|
|
640
|
-
const llmEntries =
|
|
690
|
+
const llmEntries = caps.llm.all();
|
|
641
691
|
const target = llmEntries.find(e => e.contextId === contextId && typeof e.instance.refresh === 'function');
|
|
642
692
|
if (!target) {
|
|
643
693
|
res.status(404).json({
|
|
@@ -657,13 +707,13 @@ export async function apply(ctx, config) {
|
|
|
657
707
|
const serviceName = req.params.service;
|
|
658
708
|
// 特殊处理 platform:通过 helper 获取已注册的平台名称
|
|
659
709
|
if (serviceName === 'platform') {
|
|
660
|
-
res.json({ models: getPlatformNames(
|
|
710
|
+
res.json({ models: getPlatformNames(caps.platform) });
|
|
661
711
|
return;
|
|
662
712
|
}
|
|
663
713
|
// 特殊处理 gateway-scopes:基于已注册 adapter.sessionTypes 真实声明生成
|
|
664
714
|
// platform×sessionType 的笛卡尔积。无声明的 adapter 视为单会话(不展开 sessionType)。
|
|
665
715
|
if (serviceName === 'gateway-scopes') {
|
|
666
|
-
const adapters = getPlatformAdapters(
|
|
716
|
+
const adapters = getPlatformAdapters(caps.platform);
|
|
667
717
|
const platformTypes = new Map();
|
|
668
718
|
const allTypes = new Set();
|
|
669
719
|
for (const a of adapters) {
|
|
@@ -693,7 +743,7 @@ export async function apply(ctx, config) {
|
|
|
693
743
|
}
|
|
694
744
|
// 特殊处理 toolGroups:优先从工具分组注册表获取,回退到扫描工具
|
|
695
745
|
if (serviceName === 'toolGroups') {
|
|
696
|
-
const groups =
|
|
746
|
+
const groups = caps.tools.current?.getGroups() ?? [];
|
|
697
747
|
// '*' = 全部分组(plugin-tools 的分组过滤认它),与会话范围选项的「* (全部平台)」同一写法
|
|
698
748
|
if (groups.length > 0) {
|
|
699
749
|
res.json({
|
|
@@ -711,9 +761,9 @@ export async function apply(ctx, config) {
|
|
|
711
761
|
}
|
|
712
762
|
else {
|
|
713
763
|
// 回退:从已注册工具中提取分组名称
|
|
714
|
-
const
|
|
764
|
+
const allTools = caps.tools.current?.getAll() ?? [];
|
|
715
765
|
const groupSet = new Set();
|
|
716
|
-
for (const t of
|
|
766
|
+
for (const t of allTools) {
|
|
717
767
|
t.groups?.forEach((g) => {
|
|
718
768
|
groupSet.add(g);
|
|
719
769
|
});
|
|
@@ -725,7 +775,7 @@ export async function apply(ctx, config) {
|
|
|
725
775
|
// LLM 走 per-model entry 枚举(每个 entry 已对应一个具体 model,不再依赖 provider.listModels)
|
|
726
776
|
if (serviceName === 'llm') {
|
|
727
777
|
try {
|
|
728
|
-
const entries =
|
|
778
|
+
const entries = caps.llm.all();
|
|
729
779
|
const aggregated = entries.map(e => ({
|
|
730
780
|
value: `${e.contextId}::${e.instance.id}`,
|
|
731
781
|
model: e.instance.id,
|
|
@@ -740,14 +790,14 @@ export async function apply(ctx, config) {
|
|
|
740
790
|
}
|
|
741
791
|
return;
|
|
742
792
|
}
|
|
743
|
-
const service =
|
|
793
|
+
const service = services.get(serviceName);
|
|
744
794
|
if (!service || typeof service.listModels !== 'function') {
|
|
745
795
|
res.json({ models: [] });
|
|
746
796
|
return;
|
|
747
797
|
}
|
|
748
798
|
try {
|
|
749
799
|
// 聚合所有提供者的模型列表(embedding 等服务仍走 listModels())。
|
|
750
|
-
const allProviders =
|
|
800
|
+
const allProviders = services.all(serviceName);
|
|
751
801
|
const aggregated = [];
|
|
752
802
|
const flatValues = [];
|
|
753
803
|
for (const provider of allProviders) {
|
|
@@ -783,7 +833,7 @@ export async function apply(ctx, config) {
|
|
|
783
833
|
// 协调器逻辑(待确认/超时/解析/文案)由 session-confirm 服务统一实现;WebUI 只注入自己的 WS 投递,
|
|
784
834
|
// 并保留 type:'confirm'(前端「确认模式」信号,抑制富客户端的「打字即打断」)。回复在 WS-onmessage 调 feed(下方)。
|
|
785
835
|
let confirmChannel;
|
|
786
|
-
|
|
836
|
+
caps.sessionConfirm.follow(confirmSvc => {
|
|
787
837
|
const channel = confirmSvc.createChannel((request, text) => {
|
|
788
838
|
const payload = { type: 'confirm', content: text, sessionId: request.sessionId };
|
|
789
839
|
const json = JSON.stringify(payload);
|
|
@@ -794,10 +844,10 @@ export async function apply(ctx, config) {
|
|
|
794
844
|
ws.send(json);
|
|
795
845
|
}
|
|
796
846
|
});
|
|
797
|
-
// 挂到 authority 上要跟着它的胜者走(bounce
|
|
798
|
-
// 外层 cleanup
|
|
847
|
+
// 挂到 authority 上要跟着它的胜者走(bounce 后重挂),所以嵌套一层跟随;
|
|
848
|
+
// 外层 cleanup 即内层订阅的退订:session-confirm 换胜者或本插件拆卸时一并注销
|
|
799
849
|
confirmChannel = channel;
|
|
800
|
-
const offAuthority =
|
|
850
|
+
const offAuthority = caps.authority.follow(authoritySvc => authoritySvc.setConfirmHandler('webui', channel.handler));
|
|
801
851
|
// cleanup:摘 handler、dispose 旧通道(在飞确认按取消结算,不留到 60s 超时)、清引用
|
|
802
852
|
return () => {
|
|
803
853
|
offAuthority();
|
|
@@ -807,21 +857,25 @@ export async function apply(ctx, config) {
|
|
|
807
857
|
};
|
|
808
858
|
});
|
|
809
859
|
// ---------- 文件管理 API ----------
|
|
810
|
-
registerFileRoutes(expressApp,
|
|
811
|
-
registerUploadedFilesRoutes(expressApp,
|
|
812
|
-
|
|
860
|
+
registerFileRoutes(expressApp, { storage, fileRoot: uiConfig.fileRoot }, gate);
|
|
861
|
+
registerUploadedFilesRoutes(expressApp, {
|
|
862
|
+
storage,
|
|
863
|
+
logger,
|
|
864
|
+
fileIndex: () => services.get('file-reader'),
|
|
865
|
+
}, gate);
|
|
866
|
+
registerProxyRoutes(expressApp, logger, gate);
|
|
813
867
|
// ---------- WebSocket ----------
|
|
814
868
|
wss.on('connection', (ws, req) => {
|
|
815
869
|
// 连接建立时解析一次调用者身份(cookie 在升级请求里;verifyClient 已保证已认证)
|
|
816
870
|
const wsIdentity = auth.identify(req) ?? { platform: 'webui', userId: 'console' };
|
|
817
|
-
|
|
871
|
+
logger.debug(`WebUI 客户端已连接: ${wsIdentity.platform}:${wsIdentity.userId}`);
|
|
818
872
|
allClients.add(ws);
|
|
819
873
|
heartbeat.track(ws);
|
|
820
874
|
ws.on('message', async (data) => {
|
|
821
875
|
try {
|
|
822
876
|
const parseResult = WSIncomingSchema.safeParse(JSON.parse(data.toString()));
|
|
823
877
|
if (!parseResult.success) {
|
|
824
|
-
|
|
878
|
+
logger.warn(`WebUI 收到协议违规消息: ${parseResult.error.issues.map(i => `${i.path.join('.')}: ${i.message}`).join('; ')}`);
|
|
825
879
|
return;
|
|
826
880
|
}
|
|
827
881
|
const msg = parseResult.data;
|
|
@@ -867,10 +921,10 @@ export async function apply(ctx, config) {
|
|
|
867
921
|
}
|
|
868
922
|
else {
|
|
869
923
|
// 无缓存(服务重启后),请求 agent 重新计算
|
|
870
|
-
|
|
924
|
+
events
|
|
871
925
|
.emit('token:request', {
|
|
872
926
|
sessionId: sid,
|
|
873
|
-
platform: resolveSessionPlatform(sid, new Set(getPlatformNames(
|
|
927
|
+
platform: resolveSessionPlatform(sid, new Set(getPlatformNames(caps.platform))),
|
|
874
928
|
})
|
|
875
929
|
.catch(() => { });
|
|
876
930
|
}
|
|
@@ -883,23 +937,23 @@ export async function apply(ctx, config) {
|
|
|
883
937
|
}
|
|
884
938
|
if (msg.type === 'abort') {
|
|
885
939
|
const sessionId = msg.sessionId || 'webui-default';
|
|
886
|
-
const
|
|
887
|
-
if (
|
|
888
|
-
|
|
940
|
+
const agentSvc = caps.agent.current;
|
|
941
|
+
if (agentSvc?.abort)
|
|
942
|
+
agentSvc.abort(sessionId);
|
|
889
943
|
return;
|
|
890
944
|
}
|
|
891
945
|
if (msg.type === 'compress') {
|
|
892
946
|
const sessionId = msg.sessionId || 'webui-default';
|
|
893
|
-
|
|
947
|
+
logger.info(`收到手动压缩请求: session=${sessionId}`);
|
|
894
948
|
// 触发压缩事件(memory-summary 监听此事件,并发出 session:compressing 通知)
|
|
895
|
-
|
|
949
|
+
events
|
|
896
950
|
.emit('session:compress', { sessionId, reason: 'manual' })
|
|
897
951
|
.then(() => {
|
|
898
952
|
// 压缩完成后重新计算 token 用量并推送给客户端
|
|
899
|
-
|
|
953
|
+
events
|
|
900
954
|
.emit('token:request', {
|
|
901
955
|
sessionId,
|
|
902
|
-
platform: resolveSessionPlatform(sessionId, new Set(getPlatformNames(
|
|
956
|
+
platform: resolveSessionPlatform(sessionId, new Set(getPlatformNames(caps.platform))),
|
|
903
957
|
})
|
|
904
958
|
.catch(() => { });
|
|
905
959
|
})
|
|
@@ -924,7 +978,7 @@ export async function apply(ctx, config) {
|
|
|
924
978
|
// 适配器不再内联解析命令——未注册的命令会被该相位放行为普通消息进入
|
|
925
979
|
// agent,命中的命令由该相位执行并经 outbound:message 回送,与 onebot
|
|
926
980
|
// 平台行为完全一致。客户端只通过 attachments 发送多模态内容。
|
|
927
|
-
await
|
|
981
|
+
await events.emit('inbound:message', {
|
|
928
982
|
content: trimmed,
|
|
929
983
|
sessionId,
|
|
930
984
|
platform: wsIdentity.platform,
|
|
@@ -934,7 +988,7 @@ export async function apply(ctx, config) {
|
|
|
934
988
|
});
|
|
935
989
|
}
|
|
936
990
|
catch (err) {
|
|
937
|
-
|
|
991
|
+
logger.warn('WebUI 消息处理失败:', err);
|
|
938
992
|
}
|
|
939
993
|
});
|
|
940
994
|
ws.on('close', () => {
|
|
@@ -959,7 +1013,7 @@ export async function apply(ctx, config) {
|
|
|
959
1013
|
}
|
|
960
1014
|
});
|
|
961
1015
|
// 插件状态级联变更后,广播通知所有前端刷新
|
|
962
|
-
|
|
1016
|
+
events.on('plugins:changed', () => {
|
|
963
1017
|
const payload = { type: 'state_changed' };
|
|
964
1018
|
const json = JSON.stringify(payload);
|
|
965
1019
|
for (const ws of allClients) {
|
|
@@ -969,7 +1023,7 @@ export async function apply(ctx, config) {
|
|
|
969
1023
|
}
|
|
970
1024
|
});
|
|
971
1025
|
// 重启通知:广播给所有客户端。WS 报文的 type 是前端协议,与 core 事件名无关,保持 'restarting'。
|
|
972
|
-
|
|
1026
|
+
events.on('app:restarting', () => {
|
|
973
1027
|
const payload = { type: 'restarting' };
|
|
974
1028
|
const json = JSON.stringify(payload);
|
|
975
1029
|
for (const ws of allClients) {
|
|
@@ -989,9 +1043,7 @@ export async function apply(ctx, config) {
|
|
|
989
1043
|
}
|
|
990
1044
|
};
|
|
991
1045
|
// Todo 列表变更:推送给订阅了该会话的客户端
|
|
992
|
-
|
|
993
|
-
const sessionId = args[0];
|
|
994
|
-
const items = args[1];
|
|
1046
|
+
events.on('todo:updated', (sessionId, items) => {
|
|
995
1047
|
const sockets = sessions.get(sessionId);
|
|
996
1048
|
if (!sockets)
|
|
997
1049
|
return;
|
|
@@ -1003,10 +1055,10 @@ export async function apply(ctx, config) {
|
|
|
1003
1055
|
}
|
|
1004
1056
|
}
|
|
1005
1057
|
});
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1058
|
+
events.on('session:created', broadcastSessionsChanged);
|
|
1059
|
+
events.on('session:updated', broadcastSessionsChanged);
|
|
1060
|
+
events.on('session:deleted', broadcastSessionsChanged);
|
|
1061
|
+
events.on('session:completed', broadcastSessionsChanged);
|
|
1010
1062
|
// 动态页面刷新通知:插件可通过广播 'page_refresh' 让前端无感刷新对应页面数据
|
|
1011
1063
|
// 当前已知发射方:plugin-doctor('doctor:updated')。新增同类需求时按相同模式订阅即可。
|
|
1012
1064
|
const broadcastPageRefresh = (pluginName) => {
|
|
@@ -1017,11 +1069,10 @@ export async function apply(ctx, config) {
|
|
|
1017
1069
|
ws.send(json);
|
|
1018
1070
|
}
|
|
1019
1071
|
};
|
|
1020
|
-
|
|
1072
|
+
events.on('doctor:updated', () => broadcastPageRefresh('@aalis/plugin-doctor'));
|
|
1021
1073
|
// 会话历史变更(如 checkpoint 回滚整轮对话):推送给订阅该会话的客户端,让前端重新拉取历史
|
|
1022
|
-
|
|
1023
|
-
const
|
|
1024
|
-
const sessionId = data?.sessionId;
|
|
1074
|
+
events.on('history:changed', change => {
|
|
1075
|
+
const sessionId = change?.sessionId;
|
|
1025
1076
|
if (!sessionId)
|
|
1026
1077
|
return;
|
|
1027
1078
|
const sockets = sessions.get(sessionId);
|
|
@@ -1036,12 +1087,11 @@ export async function apply(ctx, config) {
|
|
|
1036
1087
|
}
|
|
1037
1088
|
});
|
|
1038
1089
|
// 压缩状态通知:memory-summary 发出 session:compressing 事件,广播给订阅该会话的客户端
|
|
1039
|
-
|
|
1040
|
-
const
|
|
1041
|
-
const sockets = sessions.get(data.sessionId);
|
|
1090
|
+
events.on('session:compressing', info => {
|
|
1091
|
+
const sockets = sessions.get(info.sessionId);
|
|
1042
1092
|
if (!sockets)
|
|
1043
1093
|
return;
|
|
1044
|
-
const payload = { type: 'compressing', sessionId:
|
|
1094
|
+
const payload = { type: 'compressing', sessionId: info.sessionId, content: info.status };
|
|
1045
1095
|
const json = JSON.stringify(payload);
|
|
1046
1096
|
for (const ws of sockets) {
|
|
1047
1097
|
if (ws.readyState === WebSocket.OPEN) {
|
|
@@ -1053,7 +1103,7 @@ export async function apply(ctx, config) {
|
|
|
1053
1103
|
// 持久化 + 每条消息自带 sessionId),互不干扰。全局 activeSessionId 退化为 CLI 概念,
|
|
1054
1104
|
// 不再驱动 webui。这样多人同时使用时,他人新建/切换会话不会把你的窗口切走。
|
|
1055
1105
|
// 监听 AI 回复
|
|
1056
|
-
|
|
1106
|
+
events.on('outbound:message', (msg) => {
|
|
1057
1107
|
// 生成完成,延迟清理缓冲区(给客户端重连拉取历史留出时间窗口)
|
|
1058
1108
|
const buf = streamBuffers.get(msg.sessionId);
|
|
1059
1109
|
if (buf) {
|
|
@@ -1096,7 +1146,7 @@ export async function apply(ctx, config) {
|
|
|
1096
1146
|
}
|
|
1097
1147
|
});
|
|
1098
1148
|
// 监听流式增量推送
|
|
1099
|
-
|
|
1149
|
+
events.on('outbound:stream', (chunk) => {
|
|
1100
1150
|
// 累积到缓冲区
|
|
1101
1151
|
if (!chunk.done) {
|
|
1102
1152
|
let buf = streamBuffers.get(chunk.sessionId);
|
|
@@ -1172,7 +1222,7 @@ export async function apply(ctx, config) {
|
|
|
1172
1222
|
}
|
|
1173
1223
|
});
|
|
1174
1224
|
// 监听工具调用事件
|
|
1175
|
-
|
|
1225
|
+
events.on('tool:execute', (info) => {
|
|
1176
1226
|
// 缓存工具调用到 segments
|
|
1177
1227
|
let buf = streamBuffers.get(info.sessionId);
|
|
1178
1228
|
if (!buf) {
|
|
@@ -1214,8 +1264,7 @@ export async function apply(ctx, config) {
|
|
|
1214
1264
|
}
|
|
1215
1265
|
});
|
|
1216
1266
|
// 监听 token 使用量统计事件
|
|
1217
|
-
|
|
1218
|
-
const usage = args[0];
|
|
1267
|
+
events.on('token:usage', usage => {
|
|
1219
1268
|
const sockets = sessions.get(usage.sessionId);
|
|
1220
1269
|
if (!sockets)
|
|
1221
1270
|
return;
|
|
@@ -1314,7 +1363,7 @@ export async function apply(ctx, config) {
|
|
|
1314
1363
|
clientProviderDisposers.delete(stale.id);
|
|
1315
1364
|
clientCandidates.splice(i, 1);
|
|
1316
1365
|
removed++;
|
|
1317
|
-
|
|
1366
|
+
logger.info(`前端已消失,摘除候选: ${stale.label} (${stale.dir})`);
|
|
1318
1367
|
}
|
|
1319
1368
|
// 摘掉 provider 只改了「服务解析结果」,**真正服务 HTTP 的是 clientDist / staticMiddleware**,
|
|
1320
1369
|
// 它们在 app:ready 时绑定、之后只由 remountActiveClient 更新。不重挂的话,被摘掉的正好是当前
|
|
@@ -1325,27 +1374,28 @@ export async function apply(ctx, config) {
|
|
|
1325
1374
|
const fresh = pickFreshClients(clientCandidates.map(c => c.id), scanned);
|
|
1326
1375
|
clientCandidates.push(...fresh);
|
|
1327
1376
|
// 把每个新发现的前端注册为 webui-client 服务的一个 provider(带 label,供「服务」页下拉切换)。
|
|
1328
|
-
// 外部插件在 apply
|
|
1377
|
+
// 外部插件在 apply 里主动提供 webui-client 的也已在服务池中(注册更早 → 默认胜出,仍可被偏好切换)。
|
|
1378
|
+
//
|
|
1379
|
+
// 代前端包登记:条目身份是前端**包名本身**——它是服务页展示的 contextId,也是
|
|
1380
|
+
// servicePreferences['webui-client'] 里持久化的键,还是装卸闸按包名比对的对象。
|
|
1329
1381
|
for (const candidate of fresh) {
|
|
1330
|
-
clientProviderDisposers.set(candidate.id,
|
|
1331
|
-
|
|
1332
|
-
.provide('webui-client', { getClientDir: () => candidate.dir }, { label: candidate.label }));
|
|
1333
|
-
ctx.logger.info(`发现前端: ${candidate.label} (${candidate.dir})`);
|
|
1382
|
+
clientProviderDisposers.set(candidate.id, provide(webuiClient, { getClientDir: () => candidate.dir }, { label: candidate.label, onBehalfOf: candidate.id }));
|
|
1383
|
+
logger.info(`发现前端: ${candidate.label} (${candidate.dir})`);
|
|
1334
1384
|
}
|
|
1335
1385
|
return fresh.length;
|
|
1336
1386
|
}
|
|
1337
1387
|
// 启动服务器
|
|
1338
|
-
|
|
1388
|
+
events.on('app:ready', () => {
|
|
1339
1389
|
discoverAndProvideClients();
|
|
1340
1390
|
// 活跃前端 = 解析后的 webui-client 服务(servicePreferences 偏好 > 优先级 > 注册顺序);零前端则 404。
|
|
1341
1391
|
const activeDir = currentClientDir();
|
|
1342
1392
|
if (activeDir) {
|
|
1343
1393
|
clientDist = activeDir;
|
|
1344
1394
|
mountStaticDir(activeDir);
|
|
1345
|
-
|
|
1395
|
+
logger.info(`活跃前端: ${activeDir}`);
|
|
1346
1396
|
}
|
|
1347
1397
|
else {
|
|
1348
|
-
|
|
1398
|
+
logger.warn('未发现任何前端(webui-client provider);前端路由将 404,请安装一个 aalis.client 包');
|
|
1349
1399
|
}
|
|
1350
1400
|
// 监听失败(典型 EADDRINUSE:3000 被别的 dev server 占了)是 bind 之后**异步 emit** 的,
|
|
1351
1401
|
// 不在任何调用栈内,外层 try/catch 接不住 —— 无人处理即 uncaughtException →
|
|
@@ -1357,7 +1407,7 @@ export async function apply(ctx, config) {
|
|
|
1357
1407
|
// wss)并排在最前;EventEmitter 按注册顺序调用,它 emit 到无监听器的 wss 上会同步抛出、
|
|
1358
1408
|
// 中断 emit 循环,后注册在 server 上的 handler 因此永远轮不到。
|
|
1359
1409
|
wss.on('error', (err) => {
|
|
1360
|
-
|
|
1410
|
+
logger.error(`WebUI 监听 ${uiConfig.host}:${uiConfig.port} 失败: ${err.message};WebUI 不可用,其余功能照常运行`);
|
|
1361
1411
|
});
|
|
1362
1412
|
server.listen(uiConfig.port, uiConfig.host, () => {
|
|
1363
1413
|
// 通配绑定地址(0.0.0.0 / ::)是"听在哪",不是"从哪访问"——直接拼进 URL 会让
|
|
@@ -1366,10 +1416,10 @@ export async function apply(ctx, config) {
|
|
|
1366
1416
|
const displayHost = uiConfig.host === '0.0.0.0' || uiConfig.host === '::' ? '127.0.0.1' : uiConfig.host;
|
|
1367
1417
|
const url = `http://${displayHost}:${uiConfig.port}/`;
|
|
1368
1418
|
const accessUrl = `${url}?token=${authToken}`;
|
|
1369
|
-
|
|
1419
|
+
logger.info(`WebUI 已启动: ${url}`);
|
|
1370
1420
|
// 多前端时提示恢复页 URL:万一切到无切换 UI 的前端被卡住,可在此切回(详见 client-switch-page.ts)。
|
|
1371
1421
|
if (clientCandidates.length > 1) {
|
|
1372
|
-
|
|
1422
|
+
logger.info(`检测到多个前端;若卡在某前端无切换入口,可访问恢复页切回: ${url}__clients`);
|
|
1373
1423
|
}
|
|
1374
1424
|
const tokenHint = uiConfig.tokenMode === 'ephemeral'
|
|
1375
1425
|
? 'token 仅本次启动有效,重启轮换'
|
|
@@ -1378,14 +1428,14 @@ export async function apply(ctx, config) {
|
|
|
1378
1428
|
: 'token 已持久化到 storage data:/webui/token,重启沿用';
|
|
1379
1429
|
// 不把 token 打进日志(会落 latest.log / 被 /api/logs 回放 / 贴日志求助时外泄)。
|
|
1380
1430
|
// 仅打不带 token 的 URL,完整一键登录链接见 access.txt(该文件应 0o600,见 token 落盘)。
|
|
1381
|
-
|
|
1431
|
+
logger.info(`首次访问 URL(${tokenHint}): ${url} —— 完整一键登录链接见 ${accessFileUri}`);
|
|
1382
1432
|
void (async () => {
|
|
1383
1433
|
// 先把文件写出来,再解析它的绝对路径:resolveLocalPath 走 realpath,文件尚未落盘会抛
|
|
1384
1434
|
// ENOENT 被吞掉,于是「绝对路径」退化成原样重复一遍 URI——偏偏首启(新用户唯一需要
|
|
1385
1435
|
// 这行的时刻)必然命中,二次启动因文件已在反而正常。两者原先都是 void,谁先到看调度。
|
|
1386
1436
|
if (!(await writeAccessFile(url, authToken))) {
|
|
1387
1437
|
// 写失败上面已 warn 过;再打一条「已写入」只会让人去找一个不存在的文件
|
|
1388
|
-
|
|
1438
|
+
logger.warn(`访问凭据未能写入 ${accessFileUri};请用 WebUI 登录页手工粘贴 token`);
|
|
1389
1439
|
return;
|
|
1390
1440
|
}
|
|
1391
1441
|
let absHint = accessFileUri;
|
|
@@ -1395,22 +1445,22 @@ export async function apply(ctx, config) {
|
|
|
1395
1445
|
}
|
|
1396
1446
|
}
|
|
1397
1447
|
catch (err) {
|
|
1398
|
-
|
|
1448
|
+
logger.debug(`解析访问凭据文件绝对路径失败: ${err.message}`);
|
|
1399
1449
|
}
|
|
1400
|
-
|
|
1450
|
+
logger.info(`访问凭据已写入: ${accessFileUri}(绝对路径: ${absHint})`);
|
|
1401
1451
|
})();
|
|
1402
1452
|
if (uiConfig.autoOpen)
|
|
1403
|
-
openBrowser(accessUrl, createProcessGateway(
|
|
1453
|
+
openBrowser(accessUrl, createProcessGateway(caps.process));
|
|
1404
1454
|
});
|
|
1405
1455
|
});
|
|
1406
|
-
|
|
1456
|
+
lifecycle.onDispose(() => {
|
|
1407
1457
|
confirmChannel?.dispose(); // 清 WebUI 确认通道里挂起的待确认(安全拒),避免 Promise 永挂
|
|
1408
1458
|
heartbeat.dispose();
|
|
1409
1459
|
removeLogListener();
|
|
1410
1460
|
// 先把已建立的连接收口再关服务端:ws 的 close() 在 {server} 模式下只摘监听器、对
|
|
1411
1461
|
// this.clients 一个都不动,server.close() 也只停止 accept。不主动关的话,旧 socket 上
|
|
1412
|
-
// 注册的 message
|
|
1413
|
-
//
|
|
1462
|
+
// 注册的 message 闭包在插件卸载后仍然活着,照常发 'inbound:message'——而事件发射
|
|
1463
|
+
// 不设关闭守卫(订阅与发布两侧不对称)。
|
|
1414
1464
|
// 发 1001 触发前端既有的 onclose 重连路径,bounce 之后自动接回新实例。
|
|
1415
1465
|
for (const ws of allClients) {
|
|
1416
1466
|
try {
|
|
@@ -1460,7 +1510,7 @@ export async function apply(ctx, config) {
|
|
|
1460
1510
|
}
|
|
1461
1511
|
},
|
|
1462
1512
|
};
|
|
1463
|
-
|
|
1513
|
+
provide(platform, adapter);
|
|
1464
1514
|
// === 注册 WebUI 服务 ===
|
|
1465
1515
|
const registeredPages = new Map();
|
|
1466
1516
|
// 内建页面归属为 webui-server 本身
|
|
@@ -1475,17 +1525,19 @@ export async function apply(ctx, config) {
|
|
|
1475
1525
|
setClientDir(dir) {
|
|
1476
1526
|
clientDist = dir;
|
|
1477
1527
|
mountStaticDir(dir);
|
|
1478
|
-
|
|
1528
|
+
logger.info(`前端已切换: ${dir}`);
|
|
1479
1529
|
},
|
|
1480
1530
|
registerPage(page, contextId) {
|
|
1481
1531
|
const list = registeredPages.get(contextId) ?? [];
|
|
1482
|
-
|
|
1532
|
+
const entry = { ...page, pluginName: contextId };
|
|
1533
|
+
list.push(entry);
|
|
1483
1534
|
registeredPages.set(contextId, list);
|
|
1535
|
+
// 退订按条目引用比对:同 key 重注册后,旧退订闭包不得摘掉新登记
|
|
1484
1536
|
return () => {
|
|
1485
1537
|
const cur = registeredPages.get(contextId);
|
|
1486
1538
|
if (!cur)
|
|
1487
1539
|
return;
|
|
1488
|
-
const idx = cur.
|
|
1540
|
+
const idx = cur.indexOf(entry);
|
|
1489
1541
|
if (idx >= 0)
|
|
1490
1542
|
cur.splice(idx, 1);
|
|
1491
1543
|
if (cur.length === 0)
|
|
@@ -1498,10 +1550,20 @@ export async function apply(ctx, config) {
|
|
|
1498
1550
|
out.push(...list);
|
|
1499
1551
|
return out;
|
|
1500
1552
|
},
|
|
1501
|
-
|
|
1502
|
-
|
|
1553
|
+
registerAction(method, handler, contextId) {
|
|
1554
|
+
const actions = registeredActions.get(contextId) ?? new Map();
|
|
1555
|
+
actions.set(method, handler);
|
|
1556
|
+
registeredActions.set(contextId, actions);
|
|
1557
|
+
// 退订按这一次登记比对:同名替换后旧退订闭包不得摘掉新登记
|
|
1558
|
+
return () => {
|
|
1559
|
+
if (actions.get(method) !== handler)
|
|
1560
|
+
return;
|
|
1561
|
+
actions.delete(method);
|
|
1562
|
+
if (actions.size === 0 && registeredActions.get(contextId) === actions)
|
|
1563
|
+
registeredActions.delete(contextId);
|
|
1564
|
+
};
|
|
1503
1565
|
},
|
|
1504
1566
|
};
|
|
1505
|
-
|
|
1567
|
+
provide(webuiServer, webuiService);
|
|
1506
1568
|
}
|
|
1507
1569
|
//# sourceMappingURL=index.js.map
|