@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/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 { listLLMModels } from '@aalis/api-llm';
10
- import { aggregatePlatformDetails, getPlatformAdapters, getPlatformNames, } from '@aalis/api-platform';
11
- import { createProcessGateway } from '@aalis/api-process';
12
- import { createStorageGateway, readTailLines } from '@aalis/api-storage';
13
- import { DEFAULT_SUBSYSTEM_METADATA } from '@aalis/api-webui';
14
- import { LogHub, parseLogLine } from '@aalis/core';
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
- export const name = '@aalis/plugin-webui-server';
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
- export const configSchema = {
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/core 的 parseLogLine 唯一持有,此处只负责
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(ctx)` 取(adapter 可热插拔,每次现取)。
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
- export async function apply(ctx, config) {
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(ctx);
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
- async function resolveAuthToken() {
190
- if (uiConfig.tokenMode === 'fixed' && uiConfig.fixedToken.trim()) {
191
- return uiConfig.fixedToken.trim();
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
- ctx.logger.warn(`写入访问文件失败: ${err.message}`);
244
+ logger.warn(`写入访问文件失败: ${err.message}`);
234
245
  return false;
235
246
  }
236
247
  }
237
- const authToken = await resolveAuthToken();
238
- const auth = createAuthSystem(authToken, ctx.logger.child('auth'));
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 => ctx.logger.debug(`WebUI 客户端心跳超时,回收半开连接 (bufferedAmount=${ws.bufferedAmount})`),
263
- onError: (_ws, err) => ctx.logger.warn(`WebUI 客户端连接错误: ${err.message}`),
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
- ctx.logger.info(`前端静态目录: ${dir}`);
334
+ logger.info(`前端静态目录: ${dir}`);
289
335
  }
290
336
  else {
291
337
  staticMiddleware = null;
292
- ctx.logger.warn(`前端目录不存在: ${dir}`);
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 ctx.getService('webui-client')?.getClientDir?.();
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 persona = ctx.getService('persona');
338
- // 判断上传能力
339
- const hasMedia = ctx.getService('media') !== undefined;
340
- const llmHasVision = listLLMModels(ctx).some(e => e.instance.capabilities.includes('vision'));
341
- const hasFileReader = ctx.getService('file-reader') !== undefined;
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: persona?.getPersonaName() ?? ctx.config.get('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: ctx
360
- .getService('tools')
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
- registerPluginRoutes(expressApp, ctx, getApp, getPluginMgr, auth.identify, gate);
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, ctx, getPluginMgr, gate, uiConfig.marketplaceRegistry, () => collectLocalPackageDeps(localScanDirs, fsScanEnv), () => discoverAndProvideClients());
400
- // 获取历史日志:从 data/latest.log 读尾部 N 条(lazy load)。
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 = getPluginMgr();
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
- // root context 即内核自身(app / plugins 两个服务由它 provide)——显示包名,别把内部 id 'root' 裸露给用户
448
- displayNameMap.set('root', '@aalis/core');
449
- const serviceNames = ctx.getServiceNames();
450
- const services = {};
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
- // getAllServices 已按「偏好 > 优先级 > 注册顺序」排序,附带 priority 字段
453
- const entries = ctx.getAllServices(svcName);
454
- services[svcName] = {
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: ctx.getPreferredService(svcName) ?? null,
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 = ctx.getAllServices(svcName);
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
- ctx.preferService(svcName, contextId);
484
- ctx.config.setServicePreference(svcName, contextId);
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 ctx.config.save();
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
- ctx.unpreferService(svcName);
496
- ctx.config.removeServicePreference(svcName);
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 ctx.config.save();
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(ctx) });
565
+ res.json({ platforms: aggregatePlatformDetails(caps.platform) });
508
566
  });
509
567
  // 获取已注册的工具分组(含元数据 + 各组工具数量 + 贡献插件列表)
510
568
  expressApp.get('/api/tool-groups', gate(), (_req, res) => {
511
- const groups = ctx.getService('tools')?.getGroups() ?? [];
512
- const allTools = ctx.getService('tools')?.getAll() ?? [];
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 = getPluginMgr();
599
+ const pluginMgr = caps.plugins.current;
541
600
  const pluginStatus = pluginMgr ? pluginMgr.getStatus() : [];
542
- // 按插件 subsystem 归组(未声明 → 'external')。subsystem 是 WebUI 展示概念、
543
- // 由 @aalis/api-webui 声明合并到 PluginModule,core 状态契约不含——从 module 直接读。
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)?.module?.subsystem ?? 'external';
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 services = [...new Set(plugins.flatMap(p => p.provides))].filter(s => {
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 = ctx.getServiceNames().filter(n => !servicesClaimed.has(n));
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 = ctx.getAllServices('llm');
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 = ctx.getAllServices('llm');
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 = ctx.getAllServices('llm');
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(ctx) });
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(ctx);
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 = ctx.getService('tools')?.getGroups() ?? [];
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 tools = ctx.getService('tools')?.getAll() ?? [];
769
+ const allTools = caps.tools.current?.getAll() ?? [];
715
770
  const groupSet = new Set();
716
- for (const t of tools) {
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 = ctx.getAllServices('llm');
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 = ctx.getService(serviceName);
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 = ctx.getAllServices(serviceName);
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 isModelInfo = typeof m === 'object' && m !== null && 'id' in m;
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: modelCaps,
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
- ctx.whenService('session-confirm', confirmSvc => {
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 后重挂),所以嵌套一层 whenService;
798
- // 外层 cleanup 即内层订阅的 dispose:session-confirm 换胜者或本插件 dispose 时一并注销
850
+ // 挂到 authority 上要跟着它的胜者走(bounce 后重挂),所以嵌套一层跟随;
851
+ // 外层 cleanup 即内层订阅的退订:session-confirm 换胜者或本插件拆卸时一并注销
799
852
  confirmChannel = channel;
800
- const offAuthority = ctx.whenService('authority', authority => authority.setConfirmHandler('webui', channel.handler));
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, ctx, { storage, fileRoot: uiConfig.fileRoot }, gate);
811
- registerUploadedFilesRoutes(expressApp, ctx, { storage }, gate);
812
- registerProxyRoutes(expressApp, ctx, gate);
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
- ctx.logger.debug(`WebUI 客户端已连接: ${wsIdentity.platform}:${wsIdentity.userId}`);
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(JSON.parse(data.toString()));
888
+ const parseResult = WSIncomingSchema.safeParse(raw);
823
889
  if (!parseResult.success) {
824
- ctx.logger.warn(`WebUI 收到协议违规消息: ${parseResult.error.issues.map(i => `${i.path.join('.')}: ${i.message}`).join('; ')}`);
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
- ctx
936
+ events
871
937
  .emit('token:request', {
872
938
  sessionId: sid,
873
- platform: resolveSessionPlatform(sid, new Set(getPlatformNames(ctx))),
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 agent = ctx.getService('agent');
887
- if (agent?.abort)
888
- agent.abort(sessionId);
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
- ctx.logger.info(`收到手动压缩请求: session=${sessionId}`);
959
+ logger.info(`收到手动压缩请求: session=${sessionId}`);
894
960
  // 触发压缩事件(memory-summary 监听此事件,并发出 session:compressing 通知)
895
- ctx
961
+ events
896
962
  .emit('session:compress', { sessionId, reason: 'manual' })
897
963
  .then(() => {
898
964
  // 压缩完成后重新计算 token 用量并推送给客户端
899
- ctx
965
+ events
900
966
  .emit('token:request', {
901
967
  sessionId,
902
- platform: resolveSessionPlatform(sessionId, new Set(getPlatformNames(ctx))),
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 ctx.emit('inbound:message', {
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
- ctx.logger.warn('WebUI 消息处理失败:', err);
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
- ctx.on('plugins:changed', () => {
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
- ctx.on('app:restarting', () => {
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
- ctx.on('todo:updated', (...args) => {
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
- ctx.on('session:created', broadcastSessionsChanged);
1007
- ctx.on('session:updated', broadcastSessionsChanged);
1008
- ctx.on('session:deleted', broadcastSessionsChanged);
1009
- ctx.on('session:completed', broadcastSessionsChanged);
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
- ctx.on('doctor:updated', () => broadcastPageRefresh('@aalis/plugin-doctor'));
1084
+ events.on('doctor:updated', () => broadcastPageRefresh('@aalis/plugin-doctor'));
1021
1085
  // 会话历史变更(如 checkpoint 回滚整轮对话):推送给订阅该会话的客户端,让前端重新拉取历史
1022
- ctx.on('history:changed', (...args) => {
1023
- const data = args[0];
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
- ctx.on('session:compressing', (...args) => {
1040
- const data = args[0];
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: data.sessionId, content: data.status };
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
- ctx.on('outbound:message', (msg) => {
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
- ctx.on('outbound:stream', (chunk) => {
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
- ctx.on('tool:execute', (info) => {
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
- ctx.on('token:usage', (...args) => {
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
- * (与 runtime 加载器的 marker 驱动一致;忒修斯之船——任意第三方前端带标记+dist 即被发现)。
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
- ctx.logger.info(`前端已消失,摘除候选: ${stale.label} (${stale.dir})`);
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 里主动 provide('webui-client') 的也已在服务池中(注册更早 → 默认胜出,仍可被偏好切换)。
1387
+ // 外部插件在 apply 里主动提供 webui-client 的也已在服务池中(注册更早 → 默认胜出,仍可被偏好切换)。
1388
+ //
1389
+ // 代前端包登记:条目身份是前端**包名本身**——它是服务页展示的 contextId,也是
1390
+ // servicePreferences['webui-client'] 里持久化的键,还是装卸闸按包名比对的对象。
1329
1391
  for (const candidate of fresh) {
1330
- clientProviderDisposers.set(candidate.id, ctx
1331
- .fork(candidate.id)
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
- ctx.on('app:ready', () => {
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
- ctx.logger.info(`活跃前端: ${activeDir}`);
1408
+ logger.info(`活跃前端: ${activeDir}`);
1346
1409
  }
1347
1410
  else {
1348
- ctx.logger.warn('未发现任何前端(webui-client provider);前端路由将 404,请安装一个 aalis.client 包');
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
- ctx.logger.error(`WebUI 监听 ${uiConfig.host}:${uiConfig.port} 失败: ${err.message};WebUI 不可用,其余功能照常运行`);
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
- ctx.logger.info(`WebUI 已启动: ${url}`);
1433
+ logger.info(`WebUI 已启动: ${url}`);
1370
1434
  // 多前端时提示恢复页 URL:万一切到无切换 UI 的前端被卡住,可在此切回(详见 client-switch-page.ts)。
1371
1435
  if (clientCandidates.length > 1) {
1372
- ctx.logger.info(`检测到多个前端;若卡在某前端无切换入口,可访问恢复页切回: ${url}__clients`);
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
- ctx.logger.info(`首次访问 URL(${tokenHint}): ${url} —— 完整一键登录链接见 ${accessFileUri}`);
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
- ctx.logger.warn(`访问凭据未能写入 ${accessFileUri};请用 WebUI 登录页手工粘贴 token`);
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
- ctx.logger.debug(`解析访问凭据文件绝对路径失败: ${err.message}`);
1462
+ logger.debug(`解析访问凭据文件绝对路径失败: ${err.message}`);
1399
1463
  }
1400
- ctx.logger.info(`访问凭据已写入: ${accessFileUri}(绝对路径: ${absHint})`);
1464
+ logger.info(`访问凭据已写入: ${accessFileUri}(绝对路径: ${absHint})`);
1401
1465
  })();
1402
1466
  if (uiConfig.autoOpen)
1403
- openBrowser(accessUrl, createProcessGateway(ctx));
1467
+ openBrowser(accessUrl, createProcessGateway(caps.process));
1404
1468
  });
1405
1469
  });
1406
- ctx.onDispose(() => {
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 闭包在插件卸载后仍然活着,照常 ctx.emit('inbound:message')——而
1413
- // Context.emit 不设关闭守卫(同文件 on/provide/fork 都有)。
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
- ctx.provide('platform', adapter);
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
- unregisterByPlugin(contextId) {
1504
- registeredPages.delete(contextId);
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
- ctx.provide('webui-server', webuiService);
1576
+ provide(webuiServer, webuiService);
1508
1577
  }
1509
1578
  //# sourceMappingURL=index.js.map