@aalis/plugin-webui-server 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +21 -0
  2. package/dist/auth.d.ts +43 -0
  3. package/dist/auth.d.ts.map +1 -0
  4. package/dist/auth.js +279 -0
  5. package/dist/auth.js.map +1 -0
  6. package/dist/gate.d.ts +39 -0
  7. package/dist/gate.d.ts.map +1 -0
  8. package/dist/gate.js +43 -0
  9. package/dist/gate.js.map +1 -0
  10. package/dist/i18n.d.ts +3 -0
  11. package/dist/i18n.d.ts.map +1 -0
  12. package/dist/i18n.js +37 -0
  13. package/dist/i18n.js.map +1 -0
  14. package/dist/index.d.ts +22 -0
  15. package/dist/index.d.ts.map +1 -0
  16. package/dist/index.js +1341 -0
  17. package/dist/index.js.map +1 -0
  18. package/dist/protocol.d.ts +113 -0
  19. package/dist/protocol.d.ts.map +1 -0
  20. package/dist/protocol.js +59 -0
  21. package/dist/protocol.js.map +1 -0
  22. package/dist/routes/files.d.ts +13 -0
  23. package/dist/routes/files.d.ts.map +1 -0
  24. package/dist/routes/files.js +127 -0
  25. package/dist/routes/files.js.map +1 -0
  26. package/dist/routes/marketplace.d.ts +56 -0
  27. package/dist/routes/marketplace.d.ts.map +1 -0
  28. package/dist/routes/marketplace.js +101 -0
  29. package/dist/routes/marketplace.js.map +1 -0
  30. package/dist/routes/plugins.d.ts +11 -0
  31. package/dist/routes/plugins.d.ts.map +1 -0
  32. package/dist/routes/plugins.js +388 -0
  33. package/dist/routes/plugins.js.map +1 -0
  34. package/dist/routes/proxy.d.ts +12 -0
  35. package/dist/routes/proxy.d.ts.map +1 -0
  36. package/dist/routes/proxy.js +114 -0
  37. package/dist/routes/proxy.js.map +1 -0
  38. package/dist/routes/services.d.ts +14 -0
  39. package/dist/routes/services.d.ts.map +1 -0
  40. package/dist/routes/services.js +237 -0
  41. package/dist/routes/services.js.map +1 -0
  42. package/dist/routes/system.d.ts +7 -0
  43. package/dist/routes/system.d.ts.map +1 -0
  44. package/dist/routes/system.js +95 -0
  45. package/dist/routes/system.js.map +1 -0
  46. package/dist/routes/uploaded-files.d.ts +10 -0
  47. package/dist/routes/uploaded-files.d.ts.map +1 -0
  48. package/dist/routes/uploaded-files.js +162 -0
  49. package/dist/routes/uploaded-files.js.map +1 -0
  50. package/package.json +63 -0
package/dist/index.js ADDED
@@ -0,0 +1,1341 @@
1
+ // node:fs/node:path 仅用于发现前端 dist 静态目录(位于工作区外部),
2
+ // 已在 biome.json noRestrictedImports 中作为基础设施例外列出。
3
+ import { Buffer } from 'node:buffer';
4
+ import { existsSync } from 'node:fs';
5
+ import { readFile } from 'node:fs/promises';
6
+ import { createServer } from 'node:http';
7
+ import { dirname, resolve } from 'node:path';
8
+ import { fileURLToPath } from 'node:url';
9
+ import { LogHub, parseLogLine } from '@aalis/core';
10
+ import { aggregatePlatformDetails, getPlatformAdapters, getPlatformNames, } from '@aalis/plugin-platform-api';
11
+ import { createProcessGateway } from '@aalis/plugin-process-api';
12
+ import { createStorageGateway } from '@aalis/plugin-storage-api';
13
+ import { DEFAULT_SUBSYSTEM_METADATA } from '@aalis/plugin-webui-api';
14
+ import express from 'express';
15
+ import { WebSocket, WebSocketServer } from 'ws';
16
+ import { createAuthSystem, openBrowser } from './auth.js';
17
+ import { createRouteGate } from './gate.js';
18
+ import { registerFileRoutes } from './routes/files.js';
19
+ import { registerMarketplaceRoutes } from './routes/marketplace.js';
20
+ import { registerPluginRoutes } from './routes/plugins.js';
21
+ import { registerProxyRoutes } from './routes/proxy.js';
22
+ import { registerUploadedFilesRoutes } from './routes/uploaded-files.js';
23
+ // ===== 插件元数据 =====
24
+ export const name = '@aalis/plugin-webui-server';
25
+ export const displayName = 'WebUI 服务端';
26
+ export const subsystem = 'platform';
27
+ export const provides = ['webui-server'];
28
+ export const inject = {
29
+ // storage 改为 optional:避免 plugin-storage-local bounce 时级联重启 webui-server
30
+ // (若 required,存储服务暂时消失会让 webui-server 进入 pending,导致 registeredPages
31
+ // 被清空,其他插件的 sidebar 页面在 webui-server 重新激活后无法恢复)。
32
+ // storage gateway 的各操作已有 try-catch,暂时不可用时仅个别文件操作失败,不影响主功能。
33
+ optional: ['storage', 'authority', 'commands', 'platform', 'process'],
34
+ };
35
+ const webuiPages = [
36
+ { key: 'dashboard', label: '仪表盘', icon: 'dashboard', order: 10, renderer: 'dashboard' },
37
+ { key: 'marketplace', label: '插件市场', icon: 'marketplace', order: 20, renderer: 'marketplace' },
38
+ { key: 'plugin-config', label: '插件配置', icon: 'plugin-config', order: 30, renderer: 'plugin-config' },
39
+ { key: 'platforms', label: '平台接入', icon: 'platforms', order: 40, renderer: 'platforms' },
40
+ { key: 'files', label: '文件管理', icon: 'files', order: 50, renderer: 'files' },
41
+ { key: 'logs', label: '日志', icon: 'logs', order: 60, renderer: 'logs' },
42
+ ];
43
+ export const configSchema = {
44
+ port: { type: 'number', label: '端口', default: 3000, description: 'Web 管理界面的 HTTP 端口' },
45
+ host: { type: 'string', label: '监听地址', default: '127.0.0.1', description: '绑定的 IP 地址,0.0.0.0 可对外访问' },
46
+ fileRoot: {
47
+ type: 'string',
48
+ label: '文件浏览根',
49
+ default: 'workspace',
50
+ description: '文件管理页面使用的 storage 根 ID,默认 workspace',
51
+ },
52
+ autoOpen: {
53
+ type: 'boolean',
54
+ label: '启动时自动打开浏览器',
55
+ default: true,
56
+ description: '启动时以含 token 的 URL 自动开启默认浏览器;SSH/headless 环境建议关闭',
57
+ },
58
+ tokenMode: {
59
+ type: 'select',
60
+ label: 'Token 策略',
61
+ default: 'persist',
62
+ options: [
63
+ { label: '每次启动随机生成(重启即轮换)', value: 'ephemeral' },
64
+ { label: '首次生成后持久化(重启不掉登录)', value: 'persist' },
65
+ { label: '使用下方固定 token', value: 'fixed' },
66
+ { label: '禁用 token 登录(仅账户密码;无账户时 token 兜底生效)', value: 'disabled' },
67
+ ],
68
+ description: 'ephemeral=旧行为;persist=token 写入 data/.webui-token,读取复用;fixed=使用 fixedToken 字段;disabled=多用户部署收口——存在登录账户时 token 全面失效(防 root 后门),无账户时仍以 persist 语义兜底防锁死。所有模式下都会同时写出便利文件 data/webui-access.txt 包含访问 URL。',
69
+ },
70
+ fixedToken: {
71
+ type: 'string',
72
+ label: '固定 Token(仅 tokenMode=fixed 生效)',
73
+ default: '',
74
+ description: '请使用足够长的随机字符串。生产环境强烈建议通过环境变量或受限文件保存。',
75
+ },
76
+ relationGraphDefaultSpacing: {
77
+ type: 'number',
78
+ label: '关系图默认密度',
79
+ default: 120,
80
+ description: '关系图(RelationGraph)布局密度的服务器默认值(约等于理想边长 px,建议 60–250;越大越稀疏)。前端每个用户可在图工具栏现场覆盖并保存到本地浏览器;改完此项后,刷新关系图页面或新会话生效。',
81
+ },
82
+ marketplaceRegistry: {
83
+ type: 'string',
84
+ label: '插件市场 npm 源',
85
+ default: 'https://registry.npmjs.org',
86
+ description: '插件市场检索用的 npm registry 基址。注意 npm 的 search API 并非所有镜像都支持(淘宝等国内源不支持),默认官方源;国内可填支持 search 的镜像或代理。安装走 package-manager(遵循本机 npm 配置)。',
87
+ },
88
+ };
89
+ export const defaultConfig = {
90
+ port: 3000,
91
+ host: '127.0.0.1',
92
+ fileRoot: 'workspace',
93
+ autoOpen: true,
94
+ tokenMode: 'persist',
95
+ fixedToken: '',
96
+ relationGraphDefaultSpacing: 120,
97
+ marketplaceRegistry: 'https://registry.npmjs.org',
98
+ };
99
+ // ===== WebSocket 消息协议 =====
100
+ // 入站消息类型 + 校验 schema 见 ./protocol.ts(zod 强校验)
101
+ import { WSIncomingSchema } from './protocol.js';
102
+ export { WSIncomingSchema } from './protocol.js';
103
+ // ===== 日志文件读取(与 src/runtime/file-logger.ts 的格式对偶)=====
104
+ // 行格式契约(format ↔ parse)由 @aalis/core 的 parseLogLine 唯一持有,此处只负责
105
+ // 读取 data/latest.log(启动覆盖的单一历史源)并按 cursor 尾读分页。
106
+ const LOG_FILE_PATH = resolve(process.cwd(), 'data/latest.log');
107
+ async function readAllLogEntries() {
108
+ if (!existsSync(LOG_FILE_PATH))
109
+ return [];
110
+ const raw = await readFile(LOG_FILE_PATH, 'utf8');
111
+ const out = [];
112
+ for (const line of raw.split('\n')) {
113
+ if (!line)
114
+ continue;
115
+ const entry = parseLogLine(line);
116
+ if (entry)
117
+ out.push(entry);
118
+ }
119
+ return out;
120
+ }
121
+ async function readLogFileTail(limit) {
122
+ const all = await readAllLogEntries();
123
+ return all.slice(-limit);
124
+ }
125
+ async function readLogFileBefore(beforeSeq, limit) {
126
+ const all = await readAllLogEntries();
127
+ const filtered = all.filter(e => e.seq < beforeSeq);
128
+ return filtered.slice(-limit);
129
+ }
130
+ // ===== 插件入口 =====
131
+ export async function apply(ctx, config) {
132
+ const uiConfig = {
133
+ port: config.port ?? 3000,
134
+ host: config.host ?? '127.0.0.1',
135
+ fileRoot: config.fileRoot || 'workspace',
136
+ autoOpen: config.autoOpen ?? true,
137
+ tokenMode: ['ephemeral', 'fixed', 'disabled'].includes(config.tokenMode)
138
+ ? config.tokenMode
139
+ : 'persist',
140
+ fixedToken: config.fixedToken ?? '',
141
+ relationGraphDefaultSpacing: (() => {
142
+ const v = Number(config.relationGraphDefaultSpacing);
143
+ return Number.isFinite(v) && v > 0 ? v : 120;
144
+ })(),
145
+ marketplaceRegistry: config.marketplaceRegistry?.trim() || 'https://registry.npmjs.org',
146
+ };
147
+ // 创建 storage gateway;所有文件读写(token、access、文件管理)都走这里
148
+ const storage = createStorageGateway(ctx);
149
+ // ---- Token 解析 ----
150
+ // - ephemeral:每次启动随机生成(旧行为)
151
+ // - persist:首次生成后写入 storage data:/webui/token,重启复用(默认)
152
+ // - fixed:使用 fixedToken 配置项;为空时降级为 persist
153
+ // 所有模式都会写出 data:/webui/access.txt 便于查找访问 URL
154
+ const tokenFileUri = 'data:/webui/token';
155
+ const accessFileUri = 'data:/webui/access.txt';
156
+ async function resolveAuthToken() {
157
+ if (uiConfig.tokenMode === 'fixed' && uiConfig.fixedToken.trim()) {
158
+ return uiConfig.fixedToken.trim();
159
+ }
160
+ // disabled 模式仍按 persist 语义产出 token:无账户时作为兜底登录方式
161
+ if (uiConfig.tokenMode === 'persist' || uiConfig.tokenMode === 'fixed' || uiConfig.tokenMode === 'disabled') {
162
+ try {
163
+ const raw = await storage.readFile(tokenFileUri, 'utf-8');
164
+ const existing = (typeof raw === 'string' ? raw : raw.toString('utf-8')).trim();
165
+ if (existing)
166
+ return existing;
167
+ }
168
+ catch {
169
+ /* not exists or unreadable */
170
+ }
171
+ const fresh = Buffer.from(crypto.getRandomValues(new Uint8Array(24))).toString('hex');
172
+ try {
173
+ await storage.writeFile(tokenFileUri, fresh);
174
+ }
175
+ catch (err) {
176
+ ctx.logger.warn(`持久化 token 失败,本次仍可使用但重启会再生成: ${err.message}`);
177
+ }
178
+ return fresh;
179
+ }
180
+ // ephemeral
181
+ return Buffer.from(crypto.getRandomValues(new Uint8Array(24))).toString('hex');
182
+ }
183
+ async function writeAccessFile(url, token) {
184
+ const lines = [
185
+ '# Aalis WebUI 访问凭据(自动生成)',
186
+ `# 生成时间: ${new Date().toISOString()}`,
187
+ `# Token 模式: ${uiConfig.tokenMode}`,
188
+ '',
189
+ `URL: ${url}`,
190
+ `Token: ${token}`,
191
+ `一键登录: ${url}?token=${token}`,
192
+ '',
193
+ '# 提示:将该一键登录链接粘贴到浏览器即可自动设置 cookie',
194
+ ];
195
+ try {
196
+ await storage.writeFile(accessFileUri, `${lines.join('\n')}\n`);
197
+ }
198
+ catch (err) {
199
+ ctx.logger.warn(`写入访问文件失败: ${err.message}`);
200
+ }
201
+ }
202
+ const authToken = await resolveAuthToken();
203
+ // 账户校验惰性委托 authority 服务(可能晚于本插件激活/被热替换),账户即
204
+ // platform='webui' 且带密码凭据的用户记录。
205
+ const auth = createAuthSystem(authToken, ctx.logger.child('auth'), {
206
+ verify: (username, password) => ctx.getService('authority')?.verifyPassword('webui', username, password) ?? false,
207
+ hasAccounts: () => (ctx.getService('authority')?.listUsers() ?? []).some(u => u.platform === 'webui' && u.hasPassword),
208
+ },
209
+ // disabled:存在账户时 token 全面失效(auth 内部留有"无账户兜底"防锁死)
210
+ () => uiConfig.tokenMode !== 'disabled');
211
+ const expressApp = express();
212
+ expressApp.use(express.json({ limit: '10mb' }));
213
+ expressApp.use(auth.middleware);
214
+ // REST 路由权限闸(连接级认证之上的身份级裁决,见 gate.ts 分层说明)
215
+ const gate = createRouteGate(ctx, auth.identify);
216
+ // 当前调用者信息(middleware 已拦未认证;identity 必存在)
217
+ expressApp.get('/api/auth/me', (req, res) => {
218
+ const identity = auth.identify(req) ?? { platform: 'webui', userId: 'console' };
219
+ const authority = ctx.getService('authority');
220
+ res.json({
221
+ identity,
222
+ authority: authority?.getAuthority(identity.platform, identity.userId) ?? null,
223
+ isOwner: authority?.isOwner(identity.platform, identity.userId) ?? false,
224
+ });
225
+ });
226
+ const server = createServer(expressApp);
227
+ const wss = new WebSocketServer({
228
+ server,
229
+ path: '/ws',
230
+ verifyClient: (info, cb) => {
231
+ if (auth.verifyWsClient(info.req))
232
+ cb(true);
233
+ else
234
+ cb(false, 401, 'unauthenticated');
235
+ },
236
+ });
237
+ const sessions = new Map();
238
+ const logSubscribers = new Set();
239
+ const allClients = new Set();
240
+ const streamBuffers = new Map();
241
+ // 延迟清理 streamBuffers:捕获要删的 buf 引用,10s 后仅当 (a) 该 session 仍是
242
+ // 同一个 buf 且 (b) 未重新进入生成态 时才删——防止 10s 内开始的新一轮生成被旧
243
+ // 定时器误删(审计 HIGH #8 竞态)。
244
+ const scheduleBufferCleanup = (sid, captured) => {
245
+ setTimeout(() => {
246
+ if (streamBuffers.get(sid) === captured && !captured.generating)
247
+ streamBuffers.delete(sid);
248
+ }, 10_000);
249
+ };
250
+ // Token 用量缓存:记录每个 session 最近一次的 token 用量,用于刷新/切换会话后立即展示
251
+ const tokenUsageCache = new Map();
252
+ // 获取核心服务(通过服务注册获取)
253
+ const getApp = () => ctx.getService('app');
254
+ const getPluginMgr = () => ctx.getService('plugins');
255
+ // 前端静态文件托管(由 webui-client 插件通过 setClientDir 挂载)
256
+ // server 注册服务名 'webui-server',client 通过 capabilities 匹配版本
257
+ let clientDist = '';
258
+ let staticMiddleware = null;
259
+ function mountStaticDir(dir) {
260
+ if (existsSync(dir)) {
261
+ staticMiddleware = express.static(dir);
262
+ ctx.logger.info(`前端静态目录: ${dir}`);
263
+ }
264
+ else {
265
+ staticMiddleware = null;
266
+ ctx.logger.warn(`前端目录不存在: ${dir}`);
267
+ }
268
+ }
269
+ // 动态静态文件中间件(支持运行时切换前端)
270
+ expressApp.use((req, res, next) => {
271
+ if (staticMiddleware) {
272
+ staticMiddleware(req, res, next);
273
+ }
274
+ else {
275
+ next();
276
+ }
277
+ });
278
+ // ---------- REST API ----------
279
+ // 获取系统状态
280
+ expressApp.get('/api/status', gate('webui:status:read', 1), (_req, res) => {
281
+ const persona = ctx.getService('persona');
282
+ // 判断上传能力
283
+ const hasMedia = ctx.hasService('media');
284
+ const llmHasVision = ctx.getServiceCapabilities('llm').includes('vision');
285
+ const hasFileReader = ctx.hasService('file-reader');
286
+ res.json({
287
+ name: persona?.getPersonaName() ?? ctx.config.get('name'),
288
+ services: {
289
+ 'webui-server': ctx.hasService('webui-server'),
290
+ cli: ctx.hasService('cli'),
291
+ llm: ctx.hasService('llm'),
292
+ agent: ctx.hasService('agent'),
293
+ memory: ctx.hasService('memory'),
294
+ persona: ctx.hasService('persona'),
295
+ },
296
+ /** 上传能力:客户端据此决定显示哪些上传按钮 */
297
+ uploadCapabilities: {
298
+ /** 是否支持图片上传(media 可用 或 LLM 声明了 vision) */
299
+ image: hasMedia || llmHasVision,
300
+ /** 是否支持文件上传(file-reader 可用) */
301
+ file: hasFileReader,
302
+ },
303
+ tools: ctx
304
+ .getService('tools')
305
+ ?.getAll()
306
+ .map(t => t.name) ?? [],
307
+ commands: ctx
308
+ .getService('commands')
309
+ ?.getAll()
310
+ .map(c => ({
311
+ name: c.name,
312
+ description: c.description,
313
+ authority: c.authority,
314
+ safety: c.safety,
315
+ })),
316
+ });
317
+ });
318
+ // ---------- 插件管理 + 全局配置 ----------
319
+ registerPluginRoutes(expressApp, ctx, getApp, getPluginMgr, auth.identify, gate);
320
+ registerMarketplaceRoutes(expressApp, ctx, getPluginMgr, gate, uiConfig.marketplaceRegistry);
321
+ // 获取历史日志:从 data/latest.log 读尾部 N 条(lazy load)。
322
+ // 单进程内 LogHub 不再缓存 buffer——历史以文件为单一数据源。
323
+ expressApp.get('/api/logs', gate('webui:logs:read', 4), async (_req, res) => {
324
+ try {
325
+ const entries = await readLogFileTail(200);
326
+ res.json(entries);
327
+ }
328
+ catch (err) {
329
+ res.status(500).json({ error: err.message });
330
+ }
331
+ });
332
+ // 尾部 N 条(首屏 / 显式刷新)
333
+ expressApp.get('/api/logs/tail', gate('webui:logs:read', 4), async (req, res) => {
334
+ const limit = Math.min(Math.max(Number(req.query.limit) || 200, 1), 5000);
335
+ try {
336
+ res.json(await readLogFileTail(limit));
337
+ }
338
+ catch (err) {
339
+ res.status(500).json({ error: err.message });
340
+ }
341
+ });
342
+ // 历史分页:返回 seq < before 的最近 limit 条(向上滚动加载更早)
343
+ expressApp.get('/api/logs/range', gate('webui:logs:read', 4), async (req, res) => {
344
+ const before = Number(req.query.before);
345
+ const limit = Math.min(Math.max(Number(req.query.limit) || 200, 1), 5000);
346
+ if (!Number.isFinite(before)) {
347
+ res.status(400).json({ error: 'before query param required' });
348
+ return;
349
+ }
350
+ try {
351
+ res.json(await readLogFileBefore(before, limit));
352
+ }
353
+ catch (err) {
354
+ res.status(500).json({ error: err.message });
355
+ }
356
+ });
357
+ // 获取服务列表(含提供者信息)
358
+ expressApp.get('/api/services', gate('webui:services:read', 1), async (_req, res) => {
359
+ const pluginMgr = getPluginMgr();
360
+ const pluginStatus = pluginMgr ? pluginMgr.getStatus() : [];
361
+ const displayNameMap = new Map();
362
+ for (const p of pluginStatus) {
363
+ if (p.displayName) {
364
+ displayNameMap.set(p.name, p.displayName);
365
+ displayNameMap.set(p.instanceId, p.displayName);
366
+ }
367
+ }
368
+ const serviceNames = ctx.getServiceNames();
369
+ const services = {};
370
+ for (const svcName of serviceNames) {
371
+ // getServiceEntries 已经按「偏好 > 优先级 > 注册顺序」排序,附带 priority 字段
372
+ const entries = ctx.getServiceEntries(svcName);
373
+ services[svcName] = {
374
+ providers: entries.map(e => ({
375
+ contextId: e.contextId,
376
+ capabilities: [...e.capabilities],
377
+ displayName: displayNameMap.get(e.contextId),
378
+ label: e.label,
379
+ priority: e.priority,
380
+ })),
381
+ preferred: ctx.getPreferredService(svcName) ?? null,
382
+ };
383
+ }
384
+ res.json({ services });
385
+ });
386
+ /**
387
+ * 设置服务偏好。body: { contextId: string }
388
+ * 偏好语义:`preferred > priority > 注册顺序`。持久化到 aalis.config.yaml 的 servicePreferences。
389
+ */
390
+ expressApp.post('/api/services/:name/prefer', gate('webui:services:manage', 'owner'), async (req, res) => {
391
+ const svcName = String(req.params.name);
392
+ const contextId = String(req.body?.contextId ?? '').trim();
393
+ if (!contextId) {
394
+ res.status(400).json({ ok: false, error: 'contextId required' });
395
+ return;
396
+ }
397
+ // 校验 entry 存在
398
+ const entries = ctx.getServiceEntries(svcName);
399
+ if (!entries.some(e => e.contextId === contextId)) {
400
+ res.status(404).json({ ok: false, error: `service "${svcName}" has no provider with contextId "${contextId}"` });
401
+ return;
402
+ }
403
+ ctx.preferService(svcName, contextId);
404
+ ctx.config.setServicePreference(svcName, contextId);
405
+ ctx.config.save();
406
+ res.json({ ok: true });
407
+ });
408
+ /** 清除服务偏好 */
409
+ expressApp.delete('/api/services/:name/prefer', gate('webui:services:manage', 'owner'), async (req, res) => {
410
+ const svcName = String(req.params.name);
411
+ ctx.unpreferService(svcName);
412
+ ctx.config.removeServicePreference(svcName);
413
+ ctx.config.save();
414
+ res.json({ ok: true });
415
+ });
416
+ // 获取所有平台适配器及其连接状态
417
+ expressApp.get('/api/platforms', gate('webui:status:read', 1), (_req, res) => {
418
+ res.json({ platforms: aggregatePlatformDetails(ctx) });
419
+ });
420
+ // 获取已注册的工具分组(含元数据 + 各组工具数量 + 贡献插件列表)
421
+ expressApp.get('/api/tool-groups', gate('webui:status:read', 1), (_req, res) => {
422
+ const groups = ctx.getService('tools')?.getGroups() ?? [];
423
+ const allTools = ctx.getService('tools')?.getAll() ?? [];
424
+ const knownNames = new Set(groups.map(g => g.name));
425
+ const result = groups.map(g => {
426
+ const toolsInGroup = allTools.filter(t => t.groups?.includes(g.name));
427
+ // 贡献插件 = 该分组下所有工具的 pluginName 去重(可能跨多个插件,
428
+ // 例如 'session-history' 同时被 plugin-tool-session 与 plugin-memory-history 贡献)
429
+ const contributingPlugins = [...new Set(toolsInGroup.map(t => t.pluginName))].sort();
430
+ return { ...g, toolCount: toolsInGroup.length, contributingPlugins };
431
+ });
432
+ // 兜底:未声明任何 group 或 group 不在已注册集合中的工具,聚成 "other" 组
433
+ const orphans = allTools.filter(t => {
434
+ const gs = t.groups ?? [];
435
+ return gs.length === 0 || gs.every(n => !knownNames.has(n));
436
+ });
437
+ if (orphans.length > 0) {
438
+ result.push({
439
+ name: 'other',
440
+ label: '其他',
441
+ description: '未声明分组的工具',
442
+ pluginName: '(system)',
443
+ toolCount: orphans.length,
444
+ contributingPlugins: [...new Set(orphans.map(t => t.pluginName))].sort(),
445
+ });
446
+ }
447
+ res.json({ groups: result });
448
+ });
449
+ // 获取服务分组(manifest 驱动:每个插件自己声明 subsystem,本路由仅聚合)
450
+ expressApp.get('/api/service-groups', gate('webui:status:read', 1), (_req, res) => {
451
+ const pluginMgr = getPluginMgr();
452
+ const pluginStatus = pluginMgr ? pluginMgr.getStatus() : [];
453
+ // 按 plugin.subsystem 归组(未声明 → 'external')
454
+ const groupsMap = new Map();
455
+ for (const p of pluginStatus) {
456
+ const sub = p.subsystem ?? 'external';
457
+ if (!groupsMap.has(sub))
458
+ groupsMap.set(sub, []);
459
+ groupsMap.get(sub).push({ name: p.name, provides: p.provides ?? [] });
460
+ }
461
+ // 排序:DEFAULT_SUBSYSTEM_METADATA 已知 id 优先按 order 排,未知 id 以 id 作为 label,order=9999
462
+ const meta = new Map(DEFAULT_SUBSYSTEM_METADATA.map(e => [e.id, e]));
463
+ const sorted = [...groupsMap.keys()]
464
+ .map(id => {
465
+ const m = meta.get(id);
466
+ return m ? { id, label: m.label, order: m.order } : { id, label: id, order: 9999 };
467
+ })
468
+ .sort((a, b) => a.order - b.order || a.id.localeCompare(b.id));
469
+ // First-claim-wins:服务名只归入最先(order 最小)声明它的组,避免同一服务出现在多个分组
470
+ const servicesClaimed = new Set();
471
+ const groups = sorted.map(({ id, label }) => {
472
+ const plugins = groupsMap.get(id);
473
+ const services = [...new Set(plugins.flatMap(p => p.provides))].filter(s => {
474
+ if (servicesClaimed.has(s))
475
+ return false;
476
+ servicesClaimed.add(s);
477
+ return true;
478
+ });
479
+ return { id, label, plugins, services };
480
+ });
481
+ // 系统内建服务(app、plugins 等由 core 直接 provide,不属于任何插件)
482
+ const systemServices = ctx.getServiceNames().filter(n => !servicesClaimed.has(n));
483
+ if (systemServices.length > 0) {
484
+ const sysLabel = meta.get('system')?.label ?? '系统';
485
+ groups.unshift({ id: 'system', label: sysLabel, plugins: [], services: systemServices });
486
+ }
487
+ res.json({ groups });
488
+ });
489
+ // 获取所有 LLM 模型(枚举所有注册的 per-model entry)
490
+ expressApp.get('/api/llm-models', gate('webui:llm:read', 4), async (_req, res) => {
491
+ try {
492
+ const entries = ctx.getAllServices('llm');
493
+ const models = entries.map(e => ({
494
+ id: e.instance.id,
495
+ capabilities: e.capabilities,
496
+ provider: e.instance.providerId,
497
+ contextId: e.contextId,
498
+ }));
499
+ res.json({ models });
500
+ }
501
+ catch {
502
+ res.json({ models: [] });
503
+ }
504
+ });
505
+ // LLM providers + per-provider models(供 schema type='llm-ref' 联动 select 使用)
506
+ expressApp.get('/api/llm-providers', gate('webui:llm:read', 4), async (_req, res) => {
507
+ try {
508
+ const entries = ctx.getAllServices('llm');
509
+ const byProvider = new Map();
510
+ // entry.label 形如 "Ollama (localhost:11434) / qwen3.6:35b-mlx";
511
+ // 取第一个 " / " 之前的部分作为 providerLabel(避免把 model id 也带进去)。
512
+ const extractProviderLabel = (raw) => {
513
+ if (!raw)
514
+ return undefined;
515
+ const slash = raw.indexOf(' / ');
516
+ return slash > 0 ? raw.slice(0, slash) : raw;
517
+ };
518
+ for (const e of entries) {
519
+ const providerId = e.instance.providerId;
520
+ let agg = byProvider.get(providerId);
521
+ if (!agg) {
522
+ agg = { contextId: providerId, label: extractProviderLabel(e.label), models: [] };
523
+ byProvider.set(providerId, agg);
524
+ }
525
+ else if (!agg.label) {
526
+ agg.label = extractProviderLabel(e.label);
527
+ }
528
+ agg.models.push({
529
+ id: e.instance.id,
530
+ capabilities: e.capabilities,
531
+ contextLength: e.instance.contextLength,
532
+ });
533
+ }
534
+ res.json({ providers: [...byProvider.values()] });
535
+ }
536
+ catch {
537
+ res.json({ providers: [] });
538
+ }
539
+ });
540
+ // 触发指定 provider 重新探测远端模型列表(用于 webui 上的"刷新模型"按钮)
541
+ // 仅对在 LLMModel 上实现了 refresh() 的 provider 生效(远端动态发现型,如 Ollama / OpenAI)。
542
+ // 同 provider 下所有 model entries 共享同一份 refresh 闭包,调任一个 entry 即可。
543
+ expressApp.post('/api/llm-providers/:contextId/refresh', gate('webui:llm:manage', 'owner'), async (req, res) => {
544
+ const contextId = req.params.contextId;
545
+ if (!contextId) {
546
+ res.status(400).json({ error: 'contextId is required' });
547
+ return;
548
+ }
549
+ try {
550
+ const llmEntries = ctx.getAllServices('llm');
551
+ const target = llmEntries.find(e => e.contextId === contextId && typeof e.instance.refresh === 'function');
552
+ if (!target) {
553
+ res.status(404).json({
554
+ error: `no refreshable LLM provider registered for contextId="${contextId}" (provider 可能为静态注册型,不支持运行时刷新)`,
555
+ });
556
+ return;
557
+ }
558
+ const result = await target.instance.refresh();
559
+ res.json({ ok: true, ...result });
560
+ }
561
+ catch (err) {
562
+ res.status(500).json({ error: err.message });
563
+ }
564
+ });
565
+ // 获取某个服务的可用模型/选项列表
566
+ expressApp.get('/api/models/:service', gate('webui:models:read', 1), async (req, res) => {
567
+ const serviceName = req.params.service;
568
+ // 特殊处理 platform:通过 helper 获取已注册的平台名称
569
+ if (serviceName === 'platform') {
570
+ res.json({ models: getPlatformNames(ctx) });
571
+ return;
572
+ }
573
+ // 特殊处理 gateway-scopes:基于已注册 adapter.sessionTypes 真实声明生成
574
+ // platform×sessionType 的笛卡尔积。无声明的 adapter 视为单会话(不展开 sessionType)。
575
+ if (serviceName === 'gateway-scopes') {
576
+ const adapters = getPlatformAdapters(ctx);
577
+ const platformTypes = new Map();
578
+ const allTypes = new Set();
579
+ for (const a of adapters) {
580
+ const types = a.sessionTypes ?? [];
581
+ platformTypes.set(a.platform, types);
582
+ for (const t of types)
583
+ allTypes.add(t);
584
+ }
585
+ const scopes = [];
586
+ scopes.push({ value: '*', label: '* (全部平台 × 全部类型)' });
587
+ for (const t of [...allTypes].sort()) {
588
+ scopes.push({ value: `*:${t}`, label: `*:${t} (所有平台的 ${t} 会话)` });
589
+ }
590
+ for (const [p, types] of platformTypes) {
591
+ if (types.length === 0) {
592
+ // 单会话平台 (cli/webui):只列 platform 通配
593
+ scopes.push({ value: `${p}:*`, label: `${p}:* (${p} 单会话)` });
594
+ }
595
+ else {
596
+ scopes.push({ value: `${p}:*`, label: `${p}:* (${p} 全部类型)` });
597
+ for (const t of types)
598
+ scopes.push({ value: `${p}:${t}`, label: `${p}:${t}` });
599
+ }
600
+ }
601
+ res.json({ models: scopes.map(s => s.value), details: scopes });
602
+ return;
603
+ }
604
+ // 特殊处理 toolGroups:优先从工具分组注册表获取,回退到扫描工具
605
+ if (serviceName === 'toolGroups') {
606
+ const groups = ctx.getService('tools')?.getGroups() ?? [];
607
+ if (groups.length > 0) {
608
+ res.json({
609
+ models: groups.map(g => g.name).sort(),
610
+ details: groups.map(g => ({
611
+ value: g.name,
612
+ label: g.label,
613
+ description: g.description,
614
+ pluginName: g.pluginName,
615
+ })),
616
+ });
617
+ }
618
+ else {
619
+ // 回退:从已注册工具中提取分组名称
620
+ const tools = ctx.getService('tools')?.getAll() ?? [];
621
+ const groupSet = new Set();
622
+ for (const t of tools) {
623
+ t.groups?.forEach((g) => {
624
+ groupSet.add(g);
625
+ });
626
+ }
627
+ res.json({ models: [...groupSet].sort() });
628
+ }
629
+ return;
630
+ }
631
+ // LLM 走 per-model entry 枚举(每个 entry 已对应一个具体 model,不再依赖 provider.listModels)
632
+ if (serviceName === 'llm') {
633
+ try {
634
+ const entries = ctx.getAllServices('llm');
635
+ const aggregated = entries.map(e => ({
636
+ value: `${e.contextId}::${e.instance.id}`,
637
+ model: e.instance.id,
638
+ provider: e.label ?? e.contextId,
639
+ contextId: e.contextId,
640
+ capabilities: e.capabilities,
641
+ }));
642
+ res.json({ models: aggregated.map(a => a.value), providers: aggregated });
643
+ }
644
+ catch {
645
+ res.json({ models: [] });
646
+ }
647
+ return;
648
+ }
649
+ const service = ctx.getService(serviceName);
650
+ if (!service || typeof service.listModels !== 'function') {
651
+ res.json({ models: [] });
652
+ return;
653
+ }
654
+ try {
655
+ // 聚合所有提供者的模型列表(embedding 等服务仍走 listModels())。
656
+ const allProviders = ctx.getAllServices(serviceName);
657
+ const aggregated = [];
658
+ const flatValues = [];
659
+ for (const provider of allProviders) {
660
+ if (typeof provider.instance.listModels !== 'function')
661
+ continue;
662
+ try {
663
+ const models = await provider.instance.listModels();
664
+ for (const m of models) {
665
+ const isModelInfo = typeof m === 'object' && m !== null && 'id' in m;
666
+ const modelId = isModelInfo ? m.id : String(m);
667
+ const modelCaps = isModelInfo
668
+ ? m.capabilities
669
+ : provider.capabilities;
670
+ aggregated.push({
671
+ value: modelId,
672
+ model: modelId,
673
+ provider: provider.label ?? provider.contextId,
674
+ contextId: provider.contextId,
675
+ capabilities: modelCaps,
676
+ });
677
+ flatValues.push(modelId);
678
+ }
679
+ }
680
+ catch {
681
+ // 单个提供者获取模型失败不影响整体
682
+ }
683
+ }
684
+ res.json({ models: flatValues, providers: aggregated });
685
+ }
686
+ catch {
687
+ res.json({ models: [] });
688
+ }
689
+ });
690
+ // ---------- 高危操作交互式确认(内联对话式) ----------
691
+ const CONFIRM_TIMEOUT = 60_000; // 60 秒超时
692
+ const pendingSessionConfirms = new Map();
693
+ ctx.getService('authority')?.setConfirmHandler('webui', async (request) => {
694
+ const typeLabel = request.type === 'command' ? '指令' : '工具';
695
+ const nameStr = request.type === 'command' ? `/${request.name}` : request.name;
696
+ const prompt = `⚠️ ${typeLabel} ${nameStr} 是高危操作。回复 Y 仅允许本次;回复 YS 允许本会话 10 分钟;其他任意输入取消。`;
697
+ // 以确认消息形式发送(不影响客户端 loading/streaming 状态)
698
+ const payload = {
699
+ type: 'confirm',
700
+ content: prompt,
701
+ sessionId: request.sessionId,
702
+ };
703
+ const json = JSON.stringify(payload);
704
+ const sockets = sessions.get(request.sessionId);
705
+ const targets = sockets && sockets.size > 0 ? sockets : allClients;
706
+ let sent = false;
707
+ for (const ws of targets) {
708
+ if (ws.readyState === WebSocket.OPEN) {
709
+ ws.send(json);
710
+ sent = true;
711
+ }
712
+ }
713
+ if (!sent)
714
+ return false;
715
+ // 同一 session 已有未决确认时,先取消旧的(清 timer + resolve false),避免新
716
+ // set 覆盖后旧 Promise 永远 pending 且旧 timer 误删新条目(审计 HIGH #7 竞态)。
717
+ const stale = pendingSessionConfirms.get(request.sessionId);
718
+ if (stale) {
719
+ clearTimeout(stale.timer);
720
+ pendingSessionConfirms.delete(request.sessionId);
721
+ stale.resolve(false);
722
+ }
723
+ return new Promise(resolve => {
724
+ const timer = setTimeout(() => {
725
+ pendingSessionConfirms.delete(request.sessionId);
726
+ // 超时后向会话发送提示
727
+ const timeoutPayload = {
728
+ type: 'confirm',
729
+ content: '⏰ 高危操作确认已超时,已自动取消。',
730
+ sessionId: request.sessionId,
731
+ };
732
+ const timeoutJson = JSON.stringify(timeoutPayload);
733
+ for (const ws of targets) {
734
+ if (ws.readyState === WebSocket.OPEN)
735
+ ws.send(timeoutJson);
736
+ }
737
+ resolve(false);
738
+ }, CONFIRM_TIMEOUT);
739
+ pendingSessionConfirms.set(request.sessionId, { resolve, timer });
740
+ });
741
+ });
742
+ // ---------- 文件管理 API ----------
743
+ registerFileRoutes(expressApp, ctx, { storage, fileRoot: uiConfig.fileRoot }, gate);
744
+ registerUploadedFilesRoutes(expressApp, ctx, { storage }, gate);
745
+ registerProxyRoutes(expressApp, ctx, gate);
746
+ // ---------- WebSocket ----------
747
+ wss.on('connection', (ws, req) => {
748
+ // 连接建立时解析一次调用者身份(cookie 在升级请求里;verifyClient 已保证已认证)
749
+ const wsIdentity = auth.identify(req) ?? { platform: 'webui', userId: 'console' };
750
+ ctx.logger.debug(`WebUI 客户端已连接: ${wsIdentity.platform}:${wsIdentity.userId}`);
751
+ allClients.add(ws);
752
+ ws.on('message', async (data) => {
753
+ try {
754
+ const parseResult = WSIncomingSchema.safeParse(JSON.parse(data.toString()));
755
+ if (!parseResult.success) {
756
+ ctx.logger.warn(`WebUI 收到协议违规消息: ${parseResult.error.issues.map(i => `${i.path.join('.')}: ${i.message}`).join('; ')}`);
757
+ return;
758
+ }
759
+ const msg = parseResult.data;
760
+ if (msg.type === 'subscribe_logs') {
761
+ logSubscribers.add(ws);
762
+ return;
763
+ }
764
+ // 客户端连接时主动注册会话,确保 scheduler 等异步消息能推送
765
+ if (msg.type === 'subscribe_session') {
766
+ const sid = msg.sessionId || 'webui-default';
767
+ if (!sessions.has(sid)) {
768
+ sessions.set(sid, new Set());
769
+ }
770
+ sessions.get(sid).add(ws);
771
+ // 如果该会话正在生成中,发送已累积的内容供客户端恢复
772
+ const buf = streamBuffers.get(sid);
773
+ if (buf &&
774
+ (buf.content || buf.reasoningContent || buf.segments.length > 0 || buf.toolCallsProgress.size > 0)) {
775
+ const resume = {
776
+ type: 'stream_resume',
777
+ sessionId: sid,
778
+ content: buf.content,
779
+ reasoningContent: buf.reasoningContent,
780
+ segments: buf.segments.length > 0 ? buf.segments : undefined,
781
+ toolCallsProgress: buf.toolCallsProgress.size > 0
782
+ ? [...buf.toolCallsProgress.entries()]
783
+ .sort((a, b) => a[0] - b[0])
784
+ .map(([index, v]) => ({
785
+ index,
786
+ name: v.name,
787
+ charsAccumulated: v.charsAccumulated,
788
+ startedAt: v.startedAt,
789
+ }))
790
+ : undefined,
791
+ done: !buf.generating,
792
+ };
793
+ ws.send(JSON.stringify(resume));
794
+ }
795
+ // 发送缓存的 token 用量,刷新/切换会话后立即展示
796
+ const cachedUsage = tokenUsageCache.get(sid);
797
+ if (cachedUsage) {
798
+ ws.send(JSON.stringify(cachedUsage));
799
+ }
800
+ else {
801
+ // 无缓存(服务重启后),请求 agent 重新计算
802
+ ctx.emit('token:request', { sessionId: sid }).catch(() => { });
803
+ }
804
+ return;
805
+ }
806
+ if (msg.type === 'unsubscribe_session') {
807
+ const sid = msg.sessionId || 'webui-default';
808
+ sessions.get(sid)?.delete(ws);
809
+ return;
810
+ }
811
+ if (msg.type === 'abort') {
812
+ const sessionId = msg.sessionId || 'webui-default';
813
+ const agent = ctx.getService('agent');
814
+ if (agent?.abort)
815
+ agent.abort(sessionId);
816
+ return;
817
+ }
818
+ if (msg.type === 'compress') {
819
+ const sessionId = msg.sessionId || 'webui-default';
820
+ ctx.logger.info(`收到手动压缩请求: session=${sessionId}`);
821
+ // 触发压缩事件(memory-summary 监听此事件,并发出 session:compressing 通知)
822
+ ctx
823
+ .emit('session:compress', { sessionId, reason: 'manual' })
824
+ .then(() => {
825
+ // 压缩完成后重新计算 token 用量并推送给客户端
826
+ ctx.emit('token:request', { sessionId }).catch(() => { });
827
+ })
828
+ .catch(() => { });
829
+ return;
830
+ }
831
+ if (msg.type !== 'message' || !msg.content)
832
+ return;
833
+ const sessionId = msg.sessionId || 'webui-default';
834
+ const trimmed = msg.content.trim();
835
+ if (!sessions.has(sessionId)) {
836
+ sessions.set(sessionId, new Set());
837
+ }
838
+ sessions.get(sessionId).add(ws);
839
+ // 检查是否有待确认的高危操作(拦截用户输入作为确认/取消)
840
+ const pending = pendingSessionConfirms.get(sessionId);
841
+ if (pending) {
842
+ clearTimeout(pending.timer);
843
+ pendingSessionConfirms.delete(sessionId);
844
+ const answer = trimmed.toLowerCase();
845
+ if (answer === 'ys' || answer === 'y session') {
846
+ pending.resolve({ allowed: true, grant: { scope: 'session', durationSeconds: 600, maxUses: 30 } });
847
+ }
848
+ else {
849
+ pending.resolve(answer === 'y');
850
+ }
851
+ // 不继续处理此消息,交给原始命令/工具执行流返回结果
852
+ return;
853
+ }
854
+ // 指令解析已统一到全局 inbound:command 相位(plugin-commands 注册)。
855
+ // 适配器不再内联解析命令——未注册的命令会被该相位放行为普通消息进入
856
+ // agent,命中的命令由该相位执行并经 outbound:message 回送,与 onebot
857
+ // 平台行为完全一致。客户端只通过 attachments 发送多模态内容。
858
+ await ctx.emit('inbound:message', {
859
+ content: trimmed,
860
+ sessionId,
861
+ platform: wsIdentity.platform,
862
+ userId: wsIdentity.userId,
863
+ nickname: undefined,
864
+ ...(msg.attachments && msg.attachments.length > 0 ? { attachments: msg.attachments } : {}),
865
+ });
866
+ }
867
+ catch (err) {
868
+ ctx.logger.warn('WebUI 消息处理失败:', err);
869
+ }
870
+ });
871
+ ws.on('close', () => {
872
+ allClients.delete(ws);
873
+ logSubscribers.delete(ws);
874
+ for (const [sid, sockets] of sessions) {
875
+ sockets.delete(ws);
876
+ if (sockets.size === 0)
877
+ sessions.delete(sid);
878
+ }
879
+ });
880
+ });
881
+ // 实时推送日志给订阅者
882
+ const removeLogListener = LogHub.default.onEntry((entry) => {
883
+ const payload = { type: 'log', log: entry };
884
+ const json = JSON.stringify(payload);
885
+ for (const ws of logSubscribers) {
886
+ if (ws.readyState === WebSocket.OPEN) {
887
+ ws.send(json);
888
+ }
889
+ }
890
+ });
891
+ // 插件状态级联变更后,广播通知所有前端刷新
892
+ ctx.on('plugins:changed', () => {
893
+ const payload = { type: 'state_changed' };
894
+ const json = JSON.stringify(payload);
895
+ for (const ws of allClients) {
896
+ if (ws.readyState === WebSocket.OPEN) {
897
+ ws.send(json);
898
+ }
899
+ }
900
+ });
901
+ // 重启通知:广播给所有客户端
902
+ ctx.on('restarting', () => {
903
+ const payload = { type: 'restarting' };
904
+ const json = JSON.stringify(payload);
905
+ for (const ws of allClients) {
906
+ if (ws.readyState === WebSocket.OPEN) {
907
+ ws.send(json);
908
+ }
909
+ }
910
+ });
911
+ // 会话列表变更:广播给所有客户端,让前端即时刷新
912
+ const broadcastSessionsChanged = () => {
913
+ const payload = { type: 'sessions_changed' };
914
+ const json = JSON.stringify(payload);
915
+ for (const ws of allClients) {
916
+ if (ws.readyState === WebSocket.OPEN) {
917
+ ws.send(json);
918
+ }
919
+ }
920
+ };
921
+ // Todo 列表变更:推送给订阅了该会话的客户端
922
+ ctx.on('todo:updated', (...args) => {
923
+ const sessionId = args[0];
924
+ const items = args[1];
925
+ const sockets = sessions.get(sessionId);
926
+ if (!sockets)
927
+ return;
928
+ const payload = { type: 'todo_updated', sessionId, todoItems: items };
929
+ const json = JSON.stringify(payload);
930
+ for (const ws of sockets) {
931
+ if (ws.readyState === WebSocket.OPEN) {
932
+ ws.send(json);
933
+ }
934
+ }
935
+ });
936
+ ctx.on('session:created', broadcastSessionsChanged);
937
+ ctx.on('session:updated', broadcastSessionsChanged);
938
+ ctx.on('session:deleted', broadcastSessionsChanged);
939
+ ctx.on('session:completed', broadcastSessionsChanged);
940
+ // 动态页面刷新通知:插件可通过广播 'page_refresh' 让前端无感刷新对应页面数据
941
+ // 当前已知发射方:plugin-doctor('doctor:updated')。新增同类需求时按相同模式订阅即可。
942
+ const broadcastPageRefresh = (pluginName) => {
943
+ const payload = { type: 'page_refresh', pluginName };
944
+ const json = JSON.stringify(payload);
945
+ for (const ws of allClients) {
946
+ if (ws.readyState === WebSocket.OPEN)
947
+ ws.send(json);
948
+ }
949
+ };
950
+ ctx.on('doctor:updated', () => broadcastPageRefresh('@aalis/plugin-doctor'));
951
+ // 会话历史变更(如 checkpoint 回滚整轮对话):推送给订阅该会话的客户端,让前端重新拉取历史
952
+ ctx.on('history:changed', (...args) => {
953
+ const data = args[0];
954
+ const sessionId = data?.sessionId;
955
+ if (!sessionId)
956
+ return;
957
+ const sockets = sessions.get(sessionId);
958
+ if (!sockets)
959
+ return;
960
+ const payload = { type: 'history_changed', sessionId };
961
+ const json = JSON.stringify(payload);
962
+ for (const ws of sockets) {
963
+ if (ws.readyState === WebSocket.OPEN) {
964
+ ws.send(json);
965
+ }
966
+ }
967
+ });
968
+ // 压缩状态通知:memory-summary 发出 session:compressing 事件,广播给订阅该会话的客户端
969
+ ctx.on('session:compressing', (...args) => {
970
+ const data = args[0];
971
+ const sockets = sessions.get(data.sessionId);
972
+ if (!sockets)
973
+ return;
974
+ const payload = { type: 'compressing', sessionId: data.sessionId, content: data.status };
975
+ const json = JSON.stringify(payload);
976
+ for (const ws of sockets) {
977
+ if (ws.readyState === WebSocket.OPEN) {
978
+ ws.send(json);
979
+ }
980
+ }
981
+ });
982
+ // 会话切换不再跨客户端广播:每个 webui 客户端独立持有自己的活跃会话(localStorage
983
+ // 持久化 + 每条消息自带 sessionId),互不干扰。全局 activeSessionId 退化为 CLI 概念,
984
+ // 不再驱动 webui。这样多人同时使用时,他人新建/切换会话不会把你的窗口切走。
985
+ // 监听 AI 回复
986
+ ctx.on('outbound:message', (msg) => {
987
+ // 生成完成,延迟清理缓冲区(给客户端重连拉取历史留出时间窗口)
988
+ const buf = streamBuffers.get(msg.sessionId);
989
+ if (buf) {
990
+ buf.generating = false;
991
+ buf.content = msg.content ?? buf.content;
992
+ buf.reasoningContent = msg.reasoningContent ?? buf.reasoningContent;
993
+ scheduleBufferCleanup(msg.sessionId, buf);
994
+ }
995
+ const sockets = sessions.get(msg.sessionId);
996
+ if (!sockets)
997
+ return;
998
+ // 仅信任 msg 本身携带的 segments。不要回退到 buf?.segments:
999
+ // 工具循环中 agent tool(如 send_attachment / commands 回复)会直接 emit 'outbound:message',
1000
+ // 但 streamBuffers[sid].segments 累积的是当前回合的完整时间线(含本工具之前的 reasoning/tool_call)。
1001
+ // 若回退使用 buf,会把整条时间线"借尸还魂"挂到工具消息上 → 前端 REPLACE 当前 assistant 后,
1002
+ // 真正的 agent final outbound:message 再到达时被识别为新消息 APPEND,导致整条时间线渲染两遍。
1003
+ // agent 的 final emit 已显式带 segments=turnSegments,不需要这个隐式 fallback。
1004
+ const segments = msg.segments;
1005
+ const payload = {
1006
+ type: 'message',
1007
+ content: msg.content,
1008
+ sessionId: msg.sessionId,
1009
+ reasoningContent: msg.reasoningContent,
1010
+ segments: segments && segments.length > 0 ? segments : undefined,
1011
+ attachments: msg.attachments?.length
1012
+ ? msg.attachments.map(a => ({
1013
+ kind: a.kind,
1014
+ data: a.data,
1015
+ mimeType: a.mimeType,
1016
+ name: a.name,
1017
+ }))
1018
+ : undefined,
1019
+ modelInfo: msg.modelInfo,
1020
+ };
1021
+ const json = JSON.stringify(payload);
1022
+ for (const ws of sockets) {
1023
+ if (ws.readyState === WebSocket.OPEN) {
1024
+ ws.send(json);
1025
+ }
1026
+ }
1027
+ });
1028
+ // 监听流式增量推送
1029
+ ctx.on('outbound:stream', (chunk) => {
1030
+ // 累积到缓冲区
1031
+ if (!chunk.done) {
1032
+ let buf = streamBuffers.get(chunk.sessionId);
1033
+ if (!buf) {
1034
+ buf = { content: '', reasoningContent: '', segments: [], generating: true, toolCallsProgress: new Map() };
1035
+ streamBuffers.set(chunk.sessionId, buf);
1036
+ }
1037
+ if (chunk.contentDelta) {
1038
+ // 进入文本生成阶段:清空所有 tool 进度
1039
+ buf.toolCallsProgress.clear();
1040
+ buf.content += chunk.contentDelta;
1041
+ // 追加到 segments:合并连续 text
1042
+ const last = buf.segments[buf.segments.length - 1];
1043
+ if (last && last.type === 'text') {
1044
+ last.content += chunk.contentDelta;
1045
+ }
1046
+ else {
1047
+ buf.segments.push({ type: 'text', content: chunk.contentDelta });
1048
+ }
1049
+ }
1050
+ if (chunk.reasoningDelta) {
1051
+ buf.toolCallsProgress.clear();
1052
+ buf.reasoningContent += chunk.reasoningDelta;
1053
+ // 同样追加到统一时间线:合并连续 reasoning_text
1054
+ const last = buf.segments[buf.segments.length - 1];
1055
+ if (last && last.type === 'reasoning_text') {
1056
+ last.content += chunk.reasoningDelta;
1057
+ }
1058
+ else {
1059
+ buf.segments.push({ type: 'reasoning_text', content: chunk.reasoningDelta });
1060
+ }
1061
+ }
1062
+ if (chunk.toolCallProgress) {
1063
+ const { index, name, charsAccumulated } = chunk.toolCallProgress;
1064
+ const prev = buf.toolCallsProgress.get(index);
1065
+ buf.toolCallsProgress.set(index, {
1066
+ name,
1067
+ charsAccumulated,
1068
+ startedAt: prev?.startedAt ?? Date.now(),
1069
+ });
1070
+ }
1071
+ }
1072
+ else {
1073
+ // done:回合终态收口。无论本回合是正常完成、空回复、还是被用户中止,
1074
+ // agent 都会发一次 outbound:stream{done:true}(见 plugin-agent 的成功/中止/未处理三条路径)。
1075
+ // 这里必须把 generating 翻 false 并安排清理,否则刷新后 subscribe_session 仍读到
1076
+ // generating=true 的残留 buffer → 回 stream_resume{done:false} → 前端永远显示"可停止"。
1077
+ // (正常路径的 outbound:message 也会做同样的事;此处补全保证中止/静默路径对称。)
1078
+ const buf = streamBuffers.get(chunk.sessionId);
1079
+ if (buf) {
1080
+ buf.toolCallsProgress.clear();
1081
+ buf.generating = false;
1082
+ scheduleBufferCleanup(chunk.sessionId, buf);
1083
+ }
1084
+ }
1085
+ const sockets = sessions.get(chunk.sessionId);
1086
+ if (!sockets)
1087
+ return;
1088
+ const payload = {
1089
+ type: 'stream',
1090
+ sessionId: chunk.sessionId,
1091
+ contentDelta: chunk.contentDelta,
1092
+ reasoningDelta: chunk.reasoningDelta,
1093
+ toolCallProgress: chunk.toolCallProgress,
1094
+ done: chunk.done,
1095
+ toolLimitReached: chunk.toolLimitReached,
1096
+ };
1097
+ const json = JSON.stringify(payload);
1098
+ for (const ws of sockets) {
1099
+ if (ws.readyState === WebSocket.OPEN) {
1100
+ ws.send(json);
1101
+ }
1102
+ }
1103
+ });
1104
+ // 监听工具调用事件
1105
+ ctx.on('tool:execute', (info) => {
1106
+ // 缓存工具调用到 segments
1107
+ let buf = streamBuffers.get(info.sessionId);
1108
+ if (!buf) {
1109
+ buf = { content: '', reasoningContent: '', segments: [], generating: true, toolCallsProgress: new Map() };
1110
+ streamBuffers.set(info.sessionId, buf);
1111
+ }
1112
+ if (info.phase === 'start') {
1113
+ // 工具进入实际执行阶段:清掉生成中进度(占位卡 → ToolCallBlock 切换)
1114
+ buf.toolCallsProgress.clear();
1115
+ buf.segments.push({ type: 'tool_call', name: info.toolName, args: info.args ?? {}, startTime: Date.now() });
1116
+ }
1117
+ else if (info.phase === 'end') {
1118
+ // 找到最后一个同名且无结果的 tool_call segment,填充结果
1119
+ for (let i = buf.segments.length - 1; i >= 0; i--) {
1120
+ const seg = buf.segments[i];
1121
+ if (seg.type === 'tool_call' && seg.name === info.toolName && seg.result == null) {
1122
+ seg.result = info.result;
1123
+ seg.endTime = Date.now();
1124
+ break;
1125
+ }
1126
+ }
1127
+ }
1128
+ const sockets = sessions.get(info.sessionId);
1129
+ if (!sockets)
1130
+ return;
1131
+ const payload = {
1132
+ type: 'tool_call',
1133
+ sessionId: info.sessionId,
1134
+ toolName: info.toolName,
1135
+ toolArgs: info.args,
1136
+ toolPhase: info.phase,
1137
+ toolResult: info.result,
1138
+ };
1139
+ const json = JSON.stringify(payload);
1140
+ for (const ws of sockets) {
1141
+ if (ws.readyState === WebSocket.OPEN) {
1142
+ ws.send(json);
1143
+ }
1144
+ }
1145
+ });
1146
+ // 监听 token 使用量统计事件
1147
+ ctx.on('token:usage', (...args) => {
1148
+ const usage = args[0];
1149
+ const sockets = sessions.get(usage.sessionId);
1150
+ if (!sockets)
1151
+ return;
1152
+ const payload = {
1153
+ type: 'token_usage',
1154
+ sessionId: usage.sessionId,
1155
+ tokenUsage: {
1156
+ contextWindow: usage.contextWindow,
1157
+ maxTokens: usage.maxTokens,
1158
+ tokenBudget: usage.tokenBudget,
1159
+ used: usage.used,
1160
+ usageRatio: usage.usageRatio,
1161
+ breakdown: usage.breakdown,
1162
+ },
1163
+ };
1164
+ // 缓存最新的 token 用量
1165
+ tokenUsageCache.set(usage.sessionId, payload);
1166
+ const json = JSON.stringify(payload);
1167
+ for (const ws of sockets) {
1168
+ if (ws.readyState === WebSocket.OPEN) {
1169
+ ws.send(json);
1170
+ }
1171
+ }
1172
+ });
1173
+ // SPA fallback: 所有非 API 路径返回 index.html
1174
+ expressApp.get('{*path}', (_req, res) => {
1175
+ const indexPath = resolve(clientDist, 'index.html');
1176
+ if (existsSync(indexPath)) {
1177
+ res.sendFile(indexPath);
1178
+ }
1179
+ else {
1180
+ res.status(404).json({ error: '前端未就绪,请安装 webui-client 插件 (如 @aalis/plugin-webui-client)' });
1181
+ }
1182
+ });
1183
+ // 启动服务器
1184
+ ctx.on('ready', () => {
1185
+ // 自动发现同级 webui-client 包并注册为 webui-client 服务提供者
1186
+ const __dirname = dirname(fileURLToPath(import.meta.url));
1187
+ const clientCandidates = [
1188
+ {
1189
+ id: '@aalis/plugin-webui-client',
1190
+ label: 'Aalis 默认前端',
1191
+ dir: resolve(__dirname, '../../plugin-webui-client/dist'),
1192
+ },
1193
+ {
1194
+ id: '@aalis/plugin-webui-client-napcat',
1195
+ label: 'NapCat 前端',
1196
+ dir: resolve(__dirname, '../../plugin-webui-client-napcat/dist'),
1197
+ },
1198
+ ];
1199
+ // 如果已有外部插件注册了 webui-client 服务,则跳过自动发现
1200
+ const hasExternalClient = ctx.hasService('webui-client');
1201
+ if (!hasExternalClient) {
1202
+ let isFirst = true;
1203
+ for (const candidate of clientCandidates) {
1204
+ if (existsSync(candidate.dir) && existsSync(resolve(candidate.dir, 'index.html'))) {
1205
+ const childCtx = ctx.fork(candidate.id);
1206
+ childCtx.provide('webui-client', {
1207
+ getClientDir: () => candidate.dir,
1208
+ }, { label: candidate.label });
1209
+ ctx.logger.info(`发现前端: ${candidate.label} (${candidate.dir})`);
1210
+ if (isFirst) {
1211
+ clientDist = candidate.dir;
1212
+ mountStaticDir(candidate.dir);
1213
+ ctx.logger.info(`活跃前端: ${candidate.label}`);
1214
+ isFirst = false;
1215
+ }
1216
+ }
1217
+ }
1218
+ }
1219
+ // 若已有外部 webui-client 服务,使用其提供的目录
1220
+ if (hasExternalClient) {
1221
+ const activeClient = ctx.getService('webui-client');
1222
+ if (activeClient?.getClientDir) {
1223
+ const dir = activeClient.getClientDir();
1224
+ clientDist = dir;
1225
+ mountStaticDir(dir);
1226
+ ctx.logger.info(`活跃前端(服务): ${dir}`);
1227
+ }
1228
+ }
1229
+ server.listen(uiConfig.port, uiConfig.host, () => {
1230
+ const url = `http://${uiConfig.host}:${uiConfig.port}/`;
1231
+ const accessUrl = `${url}?token=${authToken}`;
1232
+ void writeAccessFile(url, authToken);
1233
+ ctx.logger.info(`WebUI 已启动: ${url}`);
1234
+ const tokenHint = uiConfig.tokenMode === 'ephemeral'
1235
+ ? 'token 仅本次启动有效,重启轮换'
1236
+ : uiConfig.tokenMode === 'fixed'
1237
+ ? 'token 来自配置 fixedToken,固定不变'
1238
+ : 'token 已持久化到 storage data:/webui/token,重启沿用';
1239
+ ctx.logger.info(`首次访问请使用以下 URL(${tokenHint}): ${accessUrl}`);
1240
+ void (async () => {
1241
+ let absHint = accessFileUri;
1242
+ try {
1243
+ if (storage.resolveLocalPath) {
1244
+ absHint = await storage.resolveLocalPath(accessFileUri, 'read');
1245
+ }
1246
+ }
1247
+ catch {
1248
+ /* ignore */
1249
+ }
1250
+ ctx.logger.info(`访问凭据已写入: ${accessFileUri}(绝对路径: ${absHint})`);
1251
+ })();
1252
+ if (uiConfig.autoOpen)
1253
+ openBrowser(accessUrl, createProcessGateway(ctx));
1254
+ });
1255
+ });
1256
+ ctx.onDispose(() => {
1257
+ removeLogListener();
1258
+ wss.close();
1259
+ server.close();
1260
+ });
1261
+ // 构造 PlatformAdapter 实例
1262
+ const adapter = {
1263
+ adapterName: 'WebUI',
1264
+ platform: 'webui',
1265
+ sessionTypes: [], // WebUI 单会话,不区分 sessionType
1266
+ getConnections() {
1267
+ // 统计所有活跃 WebSocket 客户端
1268
+ const activeCount = [...allClients].filter(ws => ws.readyState === WebSocket.OPEN).length;
1269
+ if (activeCount === 0)
1270
+ return [];
1271
+ return [
1272
+ {
1273
+ id: 'webui',
1274
+ platform: 'webui',
1275
+ status: 'online',
1276
+ detail: { clients: activeCount },
1277
+ },
1278
+ ];
1279
+ },
1280
+ async sendMessage(sessionId, content) {
1281
+ const sockets = sessions.get(sessionId);
1282
+ if (!sockets)
1283
+ return;
1284
+ const payload = {
1285
+ type: 'message',
1286
+ content,
1287
+ sessionId,
1288
+ };
1289
+ const json = JSON.stringify(payload);
1290
+ for (const ws of sockets) {
1291
+ if (ws.readyState === WebSocket.OPEN) {
1292
+ ws.send(json);
1293
+ }
1294
+ }
1295
+ },
1296
+ };
1297
+ ctx.provide('platform', adapter, { capabilities: ['webui', 'text', 'image', 'file'] });
1298
+ // === 注册 WebUI 服务 ===
1299
+ const registeredPages = new Map();
1300
+ // 内建页面归属为 webui-server 本身
1301
+ for (const page of webuiPages) {
1302
+ const list = registeredPages.get(name) ?? [];
1303
+ list.push({ ...page, pluginName: name });
1304
+ registeredPages.set(name, list);
1305
+ }
1306
+ const webuiService = {
1307
+ getPort: () => uiConfig.port,
1308
+ getHost: () => uiConfig.host,
1309
+ setClientDir(dir) {
1310
+ clientDist = dir;
1311
+ mountStaticDir(dir);
1312
+ ctx.logger.info(`前端已切换: ${dir}`);
1313
+ },
1314
+ registerPage(page, pluginName) {
1315
+ const list = registeredPages.get(pluginName) ?? [];
1316
+ list.push({ ...page, pluginName });
1317
+ registeredPages.set(pluginName, list);
1318
+ return () => {
1319
+ const cur = registeredPages.get(pluginName);
1320
+ if (!cur)
1321
+ return;
1322
+ const idx = cur.findIndex(p => p.key === page.key);
1323
+ if (idx >= 0)
1324
+ cur.splice(idx, 1);
1325
+ if (cur.length === 0)
1326
+ registeredPages.delete(pluginName);
1327
+ };
1328
+ },
1329
+ getPages() {
1330
+ const out = [];
1331
+ for (const list of registeredPages.values())
1332
+ out.push(...list);
1333
+ return out;
1334
+ },
1335
+ unregisterByPlugin(pluginName) {
1336
+ registeredPages.delete(pluginName);
1337
+ },
1338
+ };
1339
+ ctx.provide('webui-server', webuiService, { capabilities: ['api-v1'] });
1340
+ }
1341
+ //# sourceMappingURL=index.js.map