@aalis/plugin-webui-server 0.11.11 → 0.12.0

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