@aalis/plugin-webui-server 0.4.2 → 0.5.1
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 +6 -22
- package/dist/auth.d.ts.map +1 -1
- package/dist/auth.js +29 -140
- package/dist/auth.js.map +1 -1
- package/dist/client-discovery.d.ts +8 -0
- package/dist/client-discovery.d.ts.map +1 -1
- package/dist/client-discovery.js +26 -0
- package/dist/client-discovery.js.map +1 -1
- package/dist/client-switch-page.d.ts +3 -0
- package/dist/client-switch-page.d.ts.map +1 -0
- package/dist/client-switch-page.js +106 -0
- package/dist/client-switch-page.js.map +1 -0
- package/dist/gate.d.ts +9 -15
- package/dist/gate.d.ts.map +1 -1
- package/dist/gate.js +11 -29
- package/dist/gate.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +128 -148
- package/dist/index.js.map +1 -1
- package/dist/routes/files.js +5 -5
- package/dist/routes/files.js.map +1 -1
- package/dist/routes/marketplace.d.ts +45 -18
- package/dist/routes/marketplace.d.ts.map +1 -1
- package/dist/routes/marketplace.js +141 -84
- package/dist/routes/marketplace.js.map +1 -1
- package/dist/routes/plugins.d.ts.map +1 -1
- package/dist/routes/plugins.js +27 -48
- package/dist/routes/plugins.js.map +1 -1
- package/dist/routes/proxy.d.ts.map +1 -1
- package/dist/routes/proxy.js +8 -25
- package/dist/routes/proxy.js.map +1 -1
- package/dist/routes/uploaded-files.js +3 -3
- package/dist/routes/uploaded-files.js.map +1 -1
- package/package.json +39 -27
package/dist/gate.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"gate.js","sourceRoot":"","sources":["../src/gate.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"gate.js","sourceRoot":"","sources":["../src/gate.ts"],"names":[],"mappings":"AAgBA;;;;;;;;;;GAUG;AACH,MAAM,UAAU,eAAe,CAAC,QAAoB;IAClD,OAAO,GAAmB,EAAE,CAAC,CAAC,GAAG,EAAE,GAAG,EAAE,IAAI,EAAE,EAAE;QAC9C,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,EAAE,CAAC;YACnB,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,aAAa,EAAE,CAAC,CAAC;YAC/C,OAAO;QACT,CAAC;QACD,IAAI,EAAE,CAAC;IACT,CAAC,CAAC;AACJ,CAAC"}
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AASA,OAAO,KAAK,EAAc,YAAY,EAAE,OAAO,EAAkC,MAAM,aAAa,CAAC;AAmDrG,eAAO,MAAM,IAAI,+BAA+B,CAAC;AACjD,eAAO,MAAM,WAAW,6BAAc,CAAC;AACvC,eAAO,MAAM,SAAS,aAAa,CAAC;AACpC,eAAO,MAAM,QAAQ,UAA+B,CAAC;AACrD,eAAO,MAAM,MAAM;;CAMlB,CAAC;AAWF,eAAO,MAAM,YAAY,EAAE,YAiD1B,CAAC;AAEF,eAAO,MAAM,aAAa;;;;;;;;;CASzB,CAAC;AAoBF,OAAO,EAAE,KAAK,UAAU,EAAE,gBAAgB,EAAE,MAAM,eAAe,CAAC;AA6IlE,wBAAsB,KAAK,CAAC,GAAG,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC,CA4yCxF"}
|
package/dist/index.js
CHANGED
|
@@ -2,12 +2,12 @@
|
|
|
2
2
|
// 已在 biome.json noRestrictedImports 中作为基础设施例外列出。
|
|
3
3
|
import { Buffer } from 'node:buffer';
|
|
4
4
|
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
5
|
-
import { readFile } from 'node:fs/promises';
|
|
6
5
|
import { createServer } from 'node:http';
|
|
7
6
|
import { createRequire } from 'node:module';
|
|
8
7
|
import { dirname, resolve } from 'node:path';
|
|
9
8
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
10
9
|
import { LogHub, parseLogLine } from '@aalis/core';
|
|
10
|
+
import { listLLMModels } from '@aalis/plugin-llm-api';
|
|
11
11
|
import { aggregatePlatformDetails, getPlatformAdapters, getPlatformNames, } from '@aalis/plugin-platform-api';
|
|
12
12
|
import { createProcessGateway } from '@aalis/plugin-process-api';
|
|
13
13
|
import { createStorageGateway } from '@aalis/plugin-storage-api';
|
|
@@ -15,13 +15,24 @@ import { DEFAULT_SUBSYSTEM_METADATA } from '@aalis/plugin-webui-api';
|
|
|
15
15
|
import express from 'express';
|
|
16
16
|
import { WebSocket, WebSocketServer } from 'ws';
|
|
17
17
|
import { createAuthSystem, openBrowser } from './auth.js';
|
|
18
|
-
import { discoverClients } from './client-discovery.js';
|
|
18
|
+
import { collectLocalPackageDeps, discoverClients } from './client-discovery.js';
|
|
19
|
+
import { renderClientSwitchPage } from './client-switch-page.js';
|
|
19
20
|
import { createRouteGate } from './gate.js';
|
|
20
21
|
import { registerFileRoutes } from './routes/files.js';
|
|
21
22
|
import { registerMarketplaceRoutes } from './routes/marketplace.js';
|
|
22
23
|
import { registerPluginRoutes } from './routes/plugins.js';
|
|
23
24
|
import { registerProxyRoutes } from './routes/proxy.js';
|
|
24
25
|
import { registerUploadedFilesRoutes } from './routes/uploaded-files.js';
|
|
26
|
+
/**
|
|
27
|
+
* 工具 / 指令 / 工具组 description 约定:首行 = 人类可读摘要,其余 = 给 agent 的长使用指南。
|
|
28
|
+
* WebUI 管理页只展示首行摘要(避免倾倒整段指南);完整 description 仍由工具/指令定义抵达 LLM。
|
|
29
|
+
*/
|
|
30
|
+
function descSummary(d) {
|
|
31
|
+
if (!d)
|
|
32
|
+
return d;
|
|
33
|
+
const s = d.split('\n')[0].trim();
|
|
34
|
+
return s.length > 200 ? `${s.slice(0, 200)}…` : s;
|
|
35
|
+
}
|
|
25
36
|
// ===== 插件元数据 =====
|
|
26
37
|
export const name = '@aalis/plugin-webui-server';
|
|
27
38
|
export const displayName = 'WebUI 服务端';
|
|
@@ -65,9 +76,8 @@ export const configSchema = {
|
|
|
65
76
|
{ label: '每次启动随机生成(重启即轮换)', value: 'ephemeral' },
|
|
66
77
|
{ label: '首次生成后持久化(重启不掉登录)', value: 'persist' },
|
|
67
78
|
{ label: '使用下方固定 token', value: 'fixed' },
|
|
68
|
-
{ label: '禁用 token 登录(仅账户密码;无账户时 token 兜底生效)', value: 'disabled' },
|
|
69
79
|
],
|
|
70
|
-
description: 'ephemeral
|
|
80
|
+
description: 'ephemeral=每次启动随机;persist=token 写入 data/.webui-token,读取复用;fixed=使用 fixedToken 字段。所有模式都会写出便利文件 data/webui-access.txt 含访问 URL。',
|
|
71
81
|
},
|
|
72
82
|
fixedToken: {
|
|
73
83
|
type: 'string',
|
|
@@ -104,14 +114,22 @@ export const defaultConfig = {
|
|
|
104
114
|
// 入站消息类型 + 校验 schema 见 ./protocol.ts(zod 强校验)
|
|
105
115
|
import { WSIncomingSchema } from './protocol.js';
|
|
106
116
|
export { WSIncomingSchema } from './protocol.js';
|
|
107
|
-
// ===== 日志文件读取(与
|
|
117
|
+
// ===== 日志文件读取(与 @aalis/runtime 的 file-logger 格式对偶)=====
|
|
108
118
|
// 行格式契约(format ↔ parse)由 @aalis/core 的 parseLogLine 唯一持有,此处只负责
|
|
109
|
-
//
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
119
|
+
// 读取「持久化日志单一数据源」并按 cursor 尾读分页。
|
|
120
|
+
//
|
|
121
|
+
// 路径(宿主目录布局)是「环境知识」,归 storage 的 logs 根所有;本插件只用 storage
|
|
122
|
+
// URI 寻址、对 cwd/落盘位置无知。写入方(runtime/file-logger)跑在 storage 起来之前,
|
|
123
|
+
// 是 bootstrap 期的合法 raw-fs 例外,不在此处管辖(默认 logs 根 = data/,与之对偶)。
|
|
124
|
+
const LOG_FILE_URI = 'logs:/latest.log';
|
|
125
|
+
async function readAllLogEntries(storage) {
|
|
126
|
+
let raw;
|
|
127
|
+
try {
|
|
128
|
+
raw = (await storage.readFile(LOG_FILE_URI, 'utf-8'));
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
return []; // 文件未就绪 / storage 暂不可用 → 无历史
|
|
132
|
+
}
|
|
115
133
|
const out = [];
|
|
116
134
|
for (const line of raw.split('\n')) {
|
|
117
135
|
if (!line)
|
|
@@ -122,12 +140,12 @@ async function readAllLogEntries() {
|
|
|
122
140
|
}
|
|
123
141
|
return out;
|
|
124
142
|
}
|
|
125
|
-
async function readLogFileTail(limit) {
|
|
126
|
-
const all = await readAllLogEntries();
|
|
143
|
+
async function readLogFileTail(storage, limit) {
|
|
144
|
+
const all = await readAllLogEntries(storage);
|
|
127
145
|
return all.slice(-limit);
|
|
128
146
|
}
|
|
129
|
-
async function readLogFileBefore(beforeSeq, limit) {
|
|
130
|
-
const all = await readAllLogEntries();
|
|
147
|
+
async function readLogFileBefore(storage, beforeSeq, limit) {
|
|
148
|
+
const all = await readAllLogEntries(storage);
|
|
131
149
|
const filtered = all.filter(e => e.seq < beforeSeq);
|
|
132
150
|
return filtered.slice(-limit);
|
|
133
151
|
}
|
|
@@ -138,7 +156,7 @@ export async function apply(ctx, config) {
|
|
|
138
156
|
host: config.host ?? '127.0.0.1',
|
|
139
157
|
fileRoot: config.fileRoot || 'workspace',
|
|
140
158
|
autoOpen: config.autoOpen ?? true,
|
|
141
|
-
tokenMode: ['ephemeral', 'fixed'
|
|
159
|
+
tokenMode: ['ephemeral', 'fixed'].includes(config.tokenMode)
|
|
142
160
|
? config.tokenMode
|
|
143
161
|
: 'persist',
|
|
144
162
|
fixedToken: config.fixedToken ?? '',
|
|
@@ -161,8 +179,7 @@ export async function apply(ctx, config) {
|
|
|
161
179
|
if (uiConfig.tokenMode === 'fixed' && uiConfig.fixedToken.trim()) {
|
|
162
180
|
return uiConfig.fixedToken.trim();
|
|
163
181
|
}
|
|
164
|
-
|
|
165
|
-
if (uiConfig.tokenMode === 'persist' || uiConfig.tokenMode === 'fixed' || uiConfig.tokenMode === 'disabled') {
|
|
182
|
+
if (uiConfig.tokenMode === 'persist' || uiConfig.tokenMode === 'fixed') {
|
|
166
183
|
try {
|
|
167
184
|
const raw = await storage.readFile(tokenFileUri, 'utf-8');
|
|
168
185
|
const existing = (typeof raw === 'string' ? raw : raw.toString('utf-8')).trim();
|
|
@@ -204,28 +221,13 @@ export async function apply(ctx, config) {
|
|
|
204
221
|
}
|
|
205
222
|
}
|
|
206
223
|
const authToken = await resolveAuthToken();
|
|
207
|
-
|
|
208
|
-
// platform='webui' 且带密码凭据的用户记录。
|
|
209
|
-
const auth = createAuthSystem(authToken, ctx.logger.child('auth'), {
|
|
210
|
-
verify: (username, password) => ctx.getService('authority')?.verifyPassword('webui', username, password) ?? false,
|
|
211
|
-
hasAccounts: () => (ctx.getService('authority')?.listUsers() ?? []).some(u => u.platform === 'webui' && u.hasPassword),
|
|
212
|
-
},
|
|
213
|
-
// disabled:存在账户时 token 全面失效(auth 内部留有"无账户兜底"防锁死)
|
|
214
|
-
() => uiConfig.tokenMode !== 'disabled');
|
|
224
|
+
const auth = createAuthSystem(authToken, ctx.logger.child('auth'));
|
|
215
225
|
const expressApp = express();
|
|
216
226
|
expressApp.use(express.json({ limit: '10mb' }));
|
|
217
227
|
expressApp.use(auth.middleware);
|
|
218
|
-
// REST
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
expressApp.get('/api/auth/me', (req, res) => {
|
|
222
|
-
const identity = auth.identify(req) ?? { platform: 'webui', userId: 'console' };
|
|
223
|
-
const authority = ctx.getService('authority');
|
|
224
|
-
res.json({
|
|
225
|
-
identity,
|
|
226
|
-
isOwner: authority?.isOwner(identity.platform, identity.userId) ?? false,
|
|
227
|
-
});
|
|
228
|
-
});
|
|
228
|
+
// REST 路由 owner 闸(连接级认证之上的显式 owner 复核,见 gate.ts)。
|
|
229
|
+
// 当前身份 / 登录态查询统一走 auth.ts 的 GET /api/auth/status(含 identity)。
|
|
230
|
+
const gate = createRouteGate(auth.identify);
|
|
229
231
|
const server = createServer(expressApp);
|
|
230
232
|
const wss = new WebSocketServer({
|
|
231
233
|
server,
|
|
@@ -302,11 +304,11 @@ export async function apply(ctx, config) {
|
|
|
302
304
|
});
|
|
303
305
|
// ---------- REST API ----------
|
|
304
306
|
// 获取系统状态
|
|
305
|
-
expressApp.get('/api/status', gate(
|
|
307
|
+
expressApp.get('/api/status', gate(), (_req, res) => {
|
|
306
308
|
const persona = ctx.getService('persona');
|
|
307
309
|
// 判断上传能力
|
|
308
310
|
const hasMedia = ctx.hasService('media');
|
|
309
|
-
const llmHasVision = ctx.
|
|
311
|
+
const llmHasVision = listLLMModels(ctx).some(e => e.instance.capabilities.includes('vision'));
|
|
310
312
|
const hasFileReader = ctx.hasService('file-reader');
|
|
311
313
|
res.json({
|
|
312
314
|
name: persona?.getPersonaName() ?? ctx.config.get('name'),
|
|
@@ -334,19 +336,47 @@ export async function apply(ctx, config) {
|
|
|
334
336
|
?.getAll()
|
|
335
337
|
.map(c => ({
|
|
336
338
|
name: c.name,
|
|
337
|
-
description: c.description,
|
|
339
|
+
description: descSummary(c.description),
|
|
338
340
|
visibility: c.visibility,
|
|
339
341
|
})),
|
|
340
342
|
});
|
|
341
343
|
});
|
|
342
344
|
// ---------- 插件管理 + 全局配置 ----------
|
|
345
|
+
// fs 扫描 env:discoverClients(ready 时)与市场「已装」兜底共用一份,避免两处重复。
|
|
346
|
+
const fsScanEnv = {
|
|
347
|
+
existsSync,
|
|
348
|
+
readdirSync: (p) => {
|
|
349
|
+
try {
|
|
350
|
+
return readdirSync(p);
|
|
351
|
+
}
|
|
352
|
+
catch {
|
|
353
|
+
return [];
|
|
354
|
+
}
|
|
355
|
+
},
|
|
356
|
+
readJson: (p) => {
|
|
357
|
+
try {
|
|
358
|
+
return JSON.parse(readFileSync(p, 'utf-8'));
|
|
359
|
+
}
|
|
360
|
+
catch {
|
|
361
|
+
return undefined;
|
|
362
|
+
}
|
|
363
|
+
},
|
|
364
|
+
join: (...parts) => resolve(...parts),
|
|
365
|
+
};
|
|
343
366
|
registerPluginRoutes(expressApp, ctx, getApp, getPluginMgr, auth.identify, gate);
|
|
344
|
-
|
|
367
|
+
// 市场「已装」判定 + 依赖图都吃这一份本地扫描(pnpm 工作区下 require.resolve 从仓库根解析不到工作区包):
|
|
368
|
+
// 与 discoverClients 同套 scanDirs(monorepo packages/ 同级 + node_modules/@aalis),每请求懒扫(量小)。
|
|
369
|
+
// 返回 name→依赖名[]:keys=已装包名,values=依赖图边。
|
|
370
|
+
const localScanDirs = [
|
|
371
|
+
resolve(dirname(fileURLToPath(import.meta.url)), '../../'),
|
|
372
|
+
resolve(process.cwd(), 'node_modules/@aalis'),
|
|
373
|
+
];
|
|
374
|
+
registerMarketplaceRoutes(expressApp, ctx, getPluginMgr, gate, uiConfig.marketplaceRegistry, () => collectLocalPackageDeps(localScanDirs, fsScanEnv));
|
|
345
375
|
// 获取历史日志:从 data/latest.log 读尾部 N 条(lazy load)。
|
|
346
376
|
// 单进程内 LogHub 不再缓存 buffer——历史以文件为单一数据源。
|
|
347
|
-
expressApp.get('/api/logs', gate(
|
|
377
|
+
expressApp.get('/api/logs', gate(), async (_req, res) => {
|
|
348
378
|
try {
|
|
349
|
-
const entries = await readLogFileTail(200);
|
|
379
|
+
const entries = await readLogFileTail(storage, 200);
|
|
350
380
|
res.json(entries);
|
|
351
381
|
}
|
|
352
382
|
catch (err) {
|
|
@@ -354,17 +384,17 @@ export async function apply(ctx, config) {
|
|
|
354
384
|
}
|
|
355
385
|
});
|
|
356
386
|
// 尾部 N 条(首屏 / 显式刷新)
|
|
357
|
-
expressApp.get('/api/logs/tail', gate(
|
|
387
|
+
expressApp.get('/api/logs/tail', gate(), async (req, res) => {
|
|
358
388
|
const limit = Math.min(Math.max(Number(req.query.limit) || 200, 1), 5000);
|
|
359
389
|
try {
|
|
360
|
-
res.json(await readLogFileTail(limit));
|
|
390
|
+
res.json(await readLogFileTail(storage, limit));
|
|
361
391
|
}
|
|
362
392
|
catch (err) {
|
|
363
393
|
res.status(500).json({ error: err.message });
|
|
364
394
|
}
|
|
365
395
|
});
|
|
366
396
|
// 历史分页:返回 seq < before 的最近 limit 条(向上滚动加载更早)
|
|
367
|
-
expressApp.get('/api/logs/range', gate(
|
|
397
|
+
expressApp.get('/api/logs/range', gate(), async (req, res) => {
|
|
368
398
|
const before = Number(req.query.before);
|
|
369
399
|
const limit = Math.min(Math.max(Number(req.query.limit) || 200, 1), 5000);
|
|
370
400
|
if (!Number.isFinite(before)) {
|
|
@@ -372,14 +402,14 @@ export async function apply(ctx, config) {
|
|
|
372
402
|
return;
|
|
373
403
|
}
|
|
374
404
|
try {
|
|
375
|
-
res.json(await readLogFileBefore(before, limit));
|
|
405
|
+
res.json(await readLogFileBefore(storage, before, limit));
|
|
376
406
|
}
|
|
377
407
|
catch (err) {
|
|
378
408
|
res.status(500).json({ error: err.message });
|
|
379
409
|
}
|
|
380
410
|
});
|
|
381
411
|
// 获取服务列表(含提供者信息)
|
|
382
|
-
expressApp.get('/api/services', gate(
|
|
412
|
+
expressApp.get('/api/services', gate(), async (_req, res) => {
|
|
383
413
|
const pluginMgr = getPluginMgr();
|
|
384
414
|
const pluginStatus = pluginMgr ? pluginMgr.getStatus() : [];
|
|
385
415
|
const displayNameMap = new Map();
|
|
@@ -397,7 +427,6 @@ export async function apply(ctx, config) {
|
|
|
397
427
|
services[svcName] = {
|
|
398
428
|
providers: entries.map(e => ({
|
|
399
429
|
contextId: e.contextId,
|
|
400
|
-
capabilities: [...e.capabilities],
|
|
401
430
|
displayName: displayNameMap.get(e.contextId),
|
|
402
431
|
label: e.label,
|
|
403
432
|
priority: e.priority,
|
|
@@ -411,7 +440,7 @@ export async function apply(ctx, config) {
|
|
|
411
440
|
* 设置服务偏好。body: { contextId: string }
|
|
412
441
|
* 偏好语义:`preferred > priority > 注册顺序`。持久化到 aalis.config.yaml 的 servicePreferences。
|
|
413
442
|
*/
|
|
414
|
-
expressApp.post('/api/services/:name/prefer', gate(
|
|
443
|
+
expressApp.post('/api/services/:name/prefer', gate(), async (req, res) => {
|
|
415
444
|
const svcName = String(req.params.name);
|
|
416
445
|
const contextId = String(req.body?.contextId ?? '').trim();
|
|
417
446
|
if (!contextId) {
|
|
@@ -433,7 +462,7 @@ export async function apply(ctx, config) {
|
|
|
433
462
|
res.json({ ok: true });
|
|
434
463
|
});
|
|
435
464
|
/** 清除服务偏好 */
|
|
436
|
-
expressApp.delete('/api/services/:name/prefer', gate(
|
|
465
|
+
expressApp.delete('/api/services/:name/prefer', gate(), async (req, res) => {
|
|
437
466
|
const svcName = String(req.params.name);
|
|
438
467
|
ctx.unpreferService(svcName);
|
|
439
468
|
ctx.config.removeServicePreference(svcName);
|
|
@@ -446,11 +475,11 @@ export async function apply(ctx, config) {
|
|
|
446
475
|
// POST /api/services/webui-client/prefer(见上),webui-server 在该偏好变更时重挂 + 广播刷新。
|
|
447
476
|
// 故不再有独立的 /api/clients* 路由。
|
|
448
477
|
// 获取所有平台适配器及其连接状态
|
|
449
|
-
expressApp.get('/api/platforms', gate(
|
|
478
|
+
expressApp.get('/api/platforms', gate(), (_req, res) => {
|
|
450
479
|
res.json({ platforms: aggregatePlatformDetails(ctx) });
|
|
451
480
|
});
|
|
452
481
|
// 获取已注册的工具分组(含元数据 + 各组工具数量 + 贡献插件列表)
|
|
453
|
-
expressApp.get('/api/tool-groups', gate(
|
|
482
|
+
expressApp.get('/api/tool-groups', gate(), (_req, res) => {
|
|
454
483
|
const groups = ctx.getService('tools')?.getGroups() ?? [];
|
|
455
484
|
const allTools = ctx.getService('tools')?.getAll() ?? [];
|
|
456
485
|
const knownNames = new Set(groups.map(g => g.name));
|
|
@@ -459,7 +488,7 @@ export async function apply(ctx, config) {
|
|
|
459
488
|
// 贡献插件 = 该分组下所有工具的 pluginName 去重(可能跨多个插件,
|
|
460
489
|
// 例如 'session-history' 同时被 plugin-tool-session 与 plugin-memory-history 贡献)
|
|
461
490
|
const contributingPlugins = [...new Set(toolsInGroup.map(t => t.pluginName))].sort();
|
|
462
|
-
return { ...g, toolCount: toolsInGroup.length, contributingPlugins };
|
|
491
|
+
return { ...g, description: descSummary(g.description), toolCount: toolsInGroup.length, contributingPlugins };
|
|
463
492
|
});
|
|
464
493
|
// 兜底:未声明任何 group 或 group 不在已注册集合中的工具,聚成 "other" 组
|
|
465
494
|
const orphans = allTools.filter(t => {
|
|
@@ -479,7 +508,7 @@ export async function apply(ctx, config) {
|
|
|
479
508
|
res.json({ groups: result });
|
|
480
509
|
});
|
|
481
510
|
// 获取服务分组(manifest 驱动:每个插件自己声明 subsystem,本路由仅聚合)
|
|
482
|
-
expressApp.get('/api/service-groups', gate(
|
|
511
|
+
expressApp.get('/api/service-groups', gate(), (_req, res) => {
|
|
483
512
|
const pluginMgr = getPluginMgr();
|
|
484
513
|
const pluginStatus = pluginMgr ? pluginMgr.getStatus() : [];
|
|
485
514
|
// 按插件 subsystem 归组(未声明 → 'external')。subsystem 是 WebUI 展示概念、
|
|
@@ -520,12 +549,12 @@ export async function apply(ctx, config) {
|
|
|
520
549
|
res.json({ groups });
|
|
521
550
|
});
|
|
522
551
|
// 获取所有 LLM 模型(枚举所有注册的 per-model entry)
|
|
523
|
-
expressApp.get('/api/llm-models', gate(
|
|
552
|
+
expressApp.get('/api/llm-models', gate(), async (_req, res) => {
|
|
524
553
|
try {
|
|
525
554
|
const entries = ctx.getAllServices('llm');
|
|
526
555
|
const models = entries.map(e => ({
|
|
527
556
|
id: e.instance.id,
|
|
528
|
-
capabilities: e.capabilities,
|
|
557
|
+
capabilities: [...e.instance.capabilities],
|
|
529
558
|
provider: e.instance.providerId,
|
|
530
559
|
contextId: e.contextId,
|
|
531
560
|
}));
|
|
@@ -536,7 +565,7 @@ export async function apply(ctx, config) {
|
|
|
536
565
|
}
|
|
537
566
|
});
|
|
538
567
|
// LLM providers + per-provider models(供 schema type='llm-ref' 联动 select 使用)
|
|
539
|
-
expressApp.get('/api/llm-providers', gate(
|
|
568
|
+
expressApp.get('/api/llm-providers', gate(), async (_req, res) => {
|
|
540
569
|
try {
|
|
541
570
|
const entries = ctx.getAllServices('llm');
|
|
542
571
|
const byProvider = new Map();
|
|
@@ -560,7 +589,7 @@ export async function apply(ctx, config) {
|
|
|
560
589
|
}
|
|
561
590
|
agg.models.push({
|
|
562
591
|
id: e.instance.id,
|
|
563
|
-
capabilities: e.capabilities,
|
|
592
|
+
capabilities: [...e.instance.capabilities],
|
|
564
593
|
contextLength: e.instance.contextLength,
|
|
565
594
|
});
|
|
566
595
|
}
|
|
@@ -573,7 +602,7 @@ export async function apply(ctx, config) {
|
|
|
573
602
|
// 触发指定 provider 重新探测远端模型列表(用于 webui 上的"刷新模型"按钮)
|
|
574
603
|
// 仅对在 LLMModel 上实现了 refresh() 的 provider 生效(远端动态发现型,如 Ollama / OpenAI)。
|
|
575
604
|
// 同 provider 下所有 model entries 共享同一份 refresh 闭包,调任一个 entry 即可。
|
|
576
|
-
expressApp.post('/api/llm-providers/:contextId/refresh', gate(
|
|
605
|
+
expressApp.post('/api/llm-providers/:contextId/refresh', gate(), async (req, res) => {
|
|
577
606
|
const contextId = req.params.contextId;
|
|
578
607
|
if (!contextId) {
|
|
579
608
|
res.status(400).json({ error: 'contextId is required' });
|
|
@@ -596,7 +625,7 @@ export async function apply(ctx, config) {
|
|
|
596
625
|
}
|
|
597
626
|
});
|
|
598
627
|
// 获取某个服务的可用模型/选项列表
|
|
599
|
-
expressApp.get('/api/models/:service', gate(
|
|
628
|
+
expressApp.get('/api/models/:service', gate(), async (req, res) => {
|
|
600
629
|
const serviceName = req.params.service;
|
|
601
630
|
// 特殊处理 platform:通过 helper 获取已注册的平台名称
|
|
602
631
|
if (serviceName === 'platform') {
|
|
@@ -643,7 +672,7 @@ export async function apply(ctx, config) {
|
|
|
643
672
|
details: groups.map(g => ({
|
|
644
673
|
value: g.name,
|
|
645
674
|
label: g.label,
|
|
646
|
-
description: g.description,
|
|
675
|
+
description: descSummary(g.description),
|
|
647
676
|
pluginName: g.pluginName,
|
|
648
677
|
})),
|
|
649
678
|
});
|
|
@@ -670,7 +699,7 @@ export async function apply(ctx, config) {
|
|
|
670
699
|
model: e.instance.id,
|
|
671
700
|
provider: e.label ?? e.contextId,
|
|
672
701
|
contextId: e.contextId,
|
|
673
|
-
capabilities: e.capabilities,
|
|
702
|
+
capabilities: [...e.instance.capabilities],
|
|
674
703
|
}));
|
|
675
704
|
res.json({ models: aggregated.map(a => a.value), providers: aggregated });
|
|
676
705
|
}
|
|
@@ -697,9 +726,7 @@ export async function apply(ctx, config) {
|
|
|
697
726
|
for (const m of models) {
|
|
698
727
|
const isModelInfo = typeof m === 'object' && m !== null && 'id' in m;
|
|
699
728
|
const modelId = isModelInfo ? m.id : String(m);
|
|
700
|
-
const modelCaps = isModelInfo
|
|
701
|
-
? m.capabilities
|
|
702
|
-
: provider.capabilities;
|
|
729
|
+
const modelCaps = isModelInfo ? m.capabilities : [];
|
|
703
730
|
aggregated.push({
|
|
704
731
|
value: modelId,
|
|
705
732
|
model: modelId,
|
|
@@ -720,57 +747,22 @@ export async function apply(ctx, config) {
|
|
|
720
747
|
res.json({ models: [] });
|
|
721
748
|
}
|
|
722
749
|
});
|
|
723
|
-
// ----------
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
const json = JSON.stringify(payload);
|
|
737
|
-
const sockets = sessions.get(request.sessionId);
|
|
738
|
-
const targets = sockets && sockets.size > 0 ? sockets : allClients;
|
|
739
|
-
let sent = false;
|
|
740
|
-
for (const ws of targets) {
|
|
741
|
-
if (ws.readyState === WebSocket.OPEN) {
|
|
742
|
-
ws.send(json);
|
|
743
|
-
sent = true;
|
|
750
|
+
// ---------- 受限操作交互式确认(复用 session-confirm 协调器;WebUI 传输=WS type:'confirm',流式友好) ----------
|
|
751
|
+
// 协调器逻辑(待确认/超时/解析/文案)由 session-confirm 服务统一实现;WebUI 只注入自己的 WS 投递,
|
|
752
|
+
// 并保留 type:'confirm'(前端「确认模式」信号,抑制富客户端的「打字即打断」)。回复在 WS-onmessage 调 feed(下方)。
|
|
753
|
+
let confirmChannel;
|
|
754
|
+
ctx.whenService('session-confirm', confirmSvc => {
|
|
755
|
+
confirmChannel = confirmSvc.createChannel((request, text) => {
|
|
756
|
+
const payload = { type: 'confirm', content: text, sessionId: request.sessionId };
|
|
757
|
+
const json = JSON.stringify(payload);
|
|
758
|
+
const sockets = sessions.get(request.sessionId);
|
|
759
|
+
const targets = sockets && sockets.size > 0 ? sockets : allClients;
|
|
760
|
+
for (const ws of targets) {
|
|
761
|
+
if (ws.readyState === WebSocket.OPEN)
|
|
762
|
+
ws.send(json);
|
|
744
763
|
}
|
|
745
|
-
}
|
|
746
|
-
if (!sent)
|
|
747
|
-
return false;
|
|
748
|
-
// 同一 session 已有未决确认时,先取消旧的(清 timer + resolve false),避免新
|
|
749
|
-
// set 覆盖后旧 Promise 永远 pending 且旧 timer 误删新条目(审计 HIGH #7 竞态)。
|
|
750
|
-
const stale = pendingSessionConfirms.get(request.sessionId);
|
|
751
|
-
if (stale) {
|
|
752
|
-
clearTimeout(stale.timer);
|
|
753
|
-
pendingSessionConfirms.delete(request.sessionId);
|
|
754
|
-
stale.resolve(false);
|
|
755
|
-
}
|
|
756
|
-
return new Promise(resolve => {
|
|
757
|
-
const timer = setTimeout(() => {
|
|
758
|
-
pendingSessionConfirms.delete(request.sessionId);
|
|
759
|
-
// 超时后向会话发送提示
|
|
760
|
-
const timeoutPayload = {
|
|
761
|
-
type: 'confirm',
|
|
762
|
-
content: '⏰ 受限操作确认已超时,已自动取消。',
|
|
763
|
-
sessionId: request.sessionId,
|
|
764
|
-
};
|
|
765
|
-
const timeoutJson = JSON.stringify(timeoutPayload);
|
|
766
|
-
for (const ws of targets) {
|
|
767
|
-
if (ws.readyState === WebSocket.OPEN)
|
|
768
|
-
ws.send(timeoutJson);
|
|
769
|
-
}
|
|
770
|
-
resolve(false);
|
|
771
|
-
}, CONFIRM_TIMEOUT);
|
|
772
|
-
pendingSessionConfirms.set(request.sessionId, { resolve, timer });
|
|
773
764
|
});
|
|
765
|
+
ctx.getService('authority')?.setConfirmHandler('webui', confirmChannel.handler);
|
|
774
766
|
});
|
|
775
767
|
// ---------- 文件管理 API ----------
|
|
776
768
|
registerFileRoutes(expressApp, ctx, { storage, fileRoot: uiConfig.fileRoot }, gate);
|
|
@@ -869,18 +861,9 @@ export async function apply(ctx, config) {
|
|
|
869
861
|
sessions.set(sessionId, new Set());
|
|
870
862
|
}
|
|
871
863
|
sessions.get(sessionId).add(ws);
|
|
872
|
-
//
|
|
873
|
-
|
|
874
|
-
if (
|
|
875
|
-
clearTimeout(pending.timer);
|
|
876
|
-
pendingSessionConfirms.delete(sessionId);
|
|
877
|
-
const answer = trimmed.toLowerCase();
|
|
878
|
-
if (answer === 'ys' || answer === 'y session') {
|
|
879
|
-
pending.resolve({ allowed: true, grant: { scope: 'session', durationSeconds: 600, maxUses: 30 } });
|
|
880
|
-
}
|
|
881
|
-
else {
|
|
882
|
-
pending.resolve(answer === 'y');
|
|
883
|
-
}
|
|
864
|
+
// 检查待确认的受限操作(拦截输入作为确认/取消;协调器逻辑由 session-confirm 服务统一处理)
|
|
865
|
+
// 传应答者身份:仅确认的触发者本人能应答(防第三方抢答;webui 同账户天然同人)。
|
|
866
|
+
if (confirmChannel?.feed(sessionId, trimmed, wsIdentity.userId)) {
|
|
884
867
|
// 不继续处理此消息,交给原始命令/工具执行流返回结果
|
|
885
868
|
return;
|
|
886
869
|
}
|
|
@@ -1203,6 +1186,13 @@ export async function apply(ctx, config) {
|
|
|
1203
1186
|
}
|
|
1204
1187
|
}
|
|
1205
1188
|
});
|
|
1189
|
+
// 前端切换「逃生页」:由 webui-server 直出、独立于任何可被切换的前端,故任意前端(哪怕
|
|
1190
|
+
// 极简第三方前端无切换 UI)卡住时都能在此切回。受全局 auth 中间件登录保护;切换走既有 owner
|
|
1191
|
+
// 闸的 POST /api/services/webui-client/prefer,本页零新增后端逻辑(见 client-switch-page.ts)。
|
|
1192
|
+
// 注册在 SPA 兜底之前,故 `/__clients` 被本路由先接走、不落到前端 index.html。
|
|
1193
|
+
expressApp.get('/__clients', (_req, res) => {
|
|
1194
|
+
res.type('html').send(renderClientSwitchPage());
|
|
1195
|
+
});
|
|
1206
1196
|
// SPA fallback: 所有非 API 路径返回 index.html
|
|
1207
1197
|
expressApp.get('{*path}', (_req, res) => {
|
|
1208
1198
|
const indexPath = resolve(clientDist, 'index.html');
|
|
@@ -1221,24 +1211,7 @@ export async function apply(ctx, config) {
|
|
|
1221
1211
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
1222
1212
|
const projectRequire = createRequire(pathToFileURL(resolve(process.cwd(), 'package.json')));
|
|
1223
1213
|
const env = {
|
|
1224
|
-
|
|
1225
|
-
readdirSync: p => {
|
|
1226
|
-
try {
|
|
1227
|
-
return readdirSync(p);
|
|
1228
|
-
}
|
|
1229
|
-
catch {
|
|
1230
|
-
return [];
|
|
1231
|
-
}
|
|
1232
|
-
},
|
|
1233
|
-
readJson: p => {
|
|
1234
|
-
try {
|
|
1235
|
-
return JSON.parse(readFileSync(p, 'utf-8'));
|
|
1236
|
-
}
|
|
1237
|
-
catch {
|
|
1238
|
-
return undefined;
|
|
1239
|
-
}
|
|
1240
|
-
},
|
|
1241
|
-
join: (...parts) => resolve(...parts),
|
|
1214
|
+
...fsScanEnv,
|
|
1242
1215
|
dirname,
|
|
1243
1216
|
resolvePkgJson: id => {
|
|
1244
1217
|
try {
|
|
@@ -1281,12 +1254,18 @@ export async function apply(ctx, config) {
|
|
|
1281
1254
|
const accessUrl = `${url}?token=${authToken}`;
|
|
1282
1255
|
void writeAccessFile(url, authToken);
|
|
1283
1256
|
ctx.logger.info(`WebUI 已启动: ${url}`);
|
|
1257
|
+
// 多前端时提示恢复页 URL:万一切到无切换 UI 的前端被卡住,可在此切回(详见 client-switch-page.ts)。
|
|
1258
|
+
if (clientCandidates.length > 1) {
|
|
1259
|
+
ctx.logger.info(`检测到多个前端;若卡在某前端无切换入口,可访问恢复页切回: ${url}__clients`);
|
|
1260
|
+
}
|
|
1284
1261
|
const tokenHint = uiConfig.tokenMode === 'ephemeral'
|
|
1285
1262
|
? 'token 仅本次启动有效,重启轮换'
|
|
1286
1263
|
: uiConfig.tokenMode === 'fixed'
|
|
1287
1264
|
? 'token 来自配置 fixedToken,固定不变'
|
|
1288
1265
|
: 'token 已持久化到 storage data:/webui/token,重启沿用';
|
|
1289
|
-
|
|
1266
|
+
// 不把 token 打进日志(会落 latest.log / 被 /api/logs 回放 / 贴日志求助时外泄)。
|
|
1267
|
+
// 仅打不带 token 的 URL,完整一键登录链接见 access.txt(该文件应 0o600,见 token 落盘)。
|
|
1268
|
+
ctx.logger.info(`首次访问 URL(${tokenHint}): ${url} —— 完整一键登录链接见 ${accessFileUri}`);
|
|
1290
1269
|
void (async () => {
|
|
1291
1270
|
let absHint = accessFileUri;
|
|
1292
1271
|
try {
|
|
@@ -1304,6 +1283,7 @@ export async function apply(ctx, config) {
|
|
|
1304
1283
|
});
|
|
1305
1284
|
});
|
|
1306
1285
|
ctx.onDispose(() => {
|
|
1286
|
+
confirmChannel?.dispose(); // 清 WebUI 确认通道里挂起的待确认(安全拒),避免 Promise 永挂
|
|
1307
1287
|
removeLogListener();
|
|
1308
1288
|
wss.close();
|
|
1309
1289
|
server.close();
|
|
@@ -1344,7 +1324,7 @@ export async function apply(ctx, config) {
|
|
|
1344
1324
|
}
|
|
1345
1325
|
},
|
|
1346
1326
|
};
|
|
1347
|
-
ctx.provide('platform', adapter
|
|
1327
|
+
ctx.provide('platform', adapter);
|
|
1348
1328
|
// === 注册 WebUI 服务 ===
|
|
1349
1329
|
const registeredPages = new Map();
|
|
1350
1330
|
// 内建页面归属为 webui-server 本身
|
|
@@ -1386,6 +1366,6 @@ export async function apply(ctx, config) {
|
|
|
1386
1366
|
registeredPages.delete(pluginName);
|
|
1387
1367
|
},
|
|
1388
1368
|
};
|
|
1389
|
-
ctx.provide('webui-server', webuiService
|
|
1369
|
+
ctx.provide('webui-server', webuiService);
|
|
1390
1370
|
}
|
|
1391
1371
|
//# sourceMappingURL=index.js.map
|