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