@aalis/plugin-webui-server 0.4.2 → 0.5.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/gate.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"gate.js","sourceRoot":"","sources":["../src/gate.ts"],"names":[],"mappings":"AAiBA;;;;;;;;;;;;;;;GAeG;AACH,MAAM,UAAU,eAAe,CAAC,GAAY,EAAE,QAAoB;IAChE,OAAO,CAAC,UAAkB,EAAE,UAAgC,EAAkB,EAAE,CAC9E,CAAC,GAAG,EAAE,GAAG,EAAE,IAAI,EAAE,EAAE;QACjB,MAAM,SAAS,GAAG,GAAG,CAAC,UAAU,CAAmB,WAAW,CAAC,CAAC;QAChE,IAAI,CAAC,SAAS,EAAE,CAAC;YACf,2CAA2C;YAC3C,wCAAwC;YACxC,IAAI,UAAU,KAAK,YAAY,EAAE,CAAC;gBAChC,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,mBAAmB,EAAE,CAAC,CAAC;gBACrD,OAAO;YACT,CAAC;YACD,IAAI,EAAE,CAAC;YACP,OAAO;QACT,CAAC;QACD,MAAM,MAAM,GAAG,QAAQ,CAAC,GAAG,CAAC,IAAI,EAAE,QAAQ,EAAE,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,CAAC;QACzE,MAAM,MAAM,GAAG,SAAS,CAAC,SAAS,CAAC,MAAM,EAAE,EAAE,UAAU,EAAE,UAAU,EAAE,CAAC,CAAC;QACvE,IAAI,MAAM,EAAE,CAAC;YACX,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,MAAM,EAAE,CAAC,CAAC;YACxC,OAAO;QACT,CAAC;QACD,IAAI,EAAE,CAAC;IACT,CAAC,CAAC;AACN,CAAC"}
1
+ {"version":3,"file":"gate.js","sourceRoot":"","sources":["../src/gate.ts"],"names":[],"mappings":"AAgBA;;;;;;;;;;GAUG;AACH,MAAM,UAAU,eAAe,CAAC,QAAoB;IAClD,OAAO,GAAmB,EAAE,CAAC,CAAC,GAAG,EAAE,GAAG,EAAE,IAAI,EAAE,EAAE;QAC9C,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,EAAE,CAAC;YACnB,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,aAAa,EAAE,CAAC,CAAC;YAC/C,OAAO;QACT,CAAC;QACD,IAAI,EAAE,CAAC;IACT,CAAC,CAAC;AACJ,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAAc,YAAY,EAAE,OAAO,EAAkC,MAAM,aAAa,CAAC;AAsCrG,eAAO,MAAM,IAAI,+BAA+B,CAAC;AACjD,eAAO,MAAM,WAAW,6BAAc,CAAC;AACvC,eAAO,MAAM,SAAS,aAAa,CAAC;AACpC,eAAO,MAAM,QAAQ,UAA+B,CAAC;AACrD,eAAO,MAAM,MAAM;;CAMlB,CAAC;AAWF,eAAO,MAAM,YAAY,EAAE,YAkD1B,CAAC;AAEF,eAAO,MAAM,aAAa;;;;;;;;;CASzB,CAAC;AAoBF,OAAO,EAAE,KAAK,UAAU,EAAE,gBAAgB,EAAE,MAAM,eAAe,CAAC;AAqIlE,wBAAsB,KAAK,CAAC,GAAG,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC,CAo2CxF"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AASA,OAAO,KAAK,EAAc,YAAY,EAAE,OAAO,EAAkC,MAAM,aAAa,CAAC;AAyCrG,eAAO,MAAM,IAAI,+BAA+B,CAAC;AACjD,eAAO,MAAM,WAAW,6BAAc,CAAC;AACvC,eAAO,MAAM,SAAS,aAAa,CAAC;AACpC,eAAO,MAAM,QAAQ,UAA+B,CAAC;AACrD,eAAO,MAAM,MAAM;;CAMlB,CAAC;AAWF,eAAO,MAAM,YAAY,EAAE,YAiD1B,CAAC;AAEF,eAAO,MAAM,aAAa;;;;;;;;;CASzB,CAAC;AAoBF,OAAO,EAAE,KAAK,UAAU,EAAE,gBAAgB,EAAE,MAAM,eAAe,CAAC;AA6IlE,wBAAsB,KAAK,CAAC,GAAG,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC,CA4yCxF"}
package/dist/index.js CHANGED
@@ -2,12 +2,12 @@
2
2
  // 已在 biome.json noRestrictedImports 中作为基础设施例外列出。
3
3
  import { Buffer } from 'node:buffer';
4
4
  import { existsSync, readdirSync, readFileSync } from 'node:fs';
5
- import { readFile } from 'node:fs/promises';
6
5
  import { createServer } from 'node:http';
7
6
  import { createRequire } from 'node:module';
8
7
  import { dirname, resolve } from 'node:path';
9
8
  import { fileURLToPath, pathToFileURL } from 'node:url';
10
9
  import { LogHub, parseLogLine } from '@aalis/core';
10
+ import { listLLMModels } from '@aalis/plugin-llm-api';
11
11
  import { aggregatePlatformDetails, getPlatformAdapters, getPlatformNames, } from '@aalis/plugin-platform-api';
12
12
  import { createProcessGateway } from '@aalis/plugin-process-api';
13
13
  import { createStorageGateway } from '@aalis/plugin-storage-api';
@@ -15,7 +15,8 @@ import { DEFAULT_SUBSYSTEM_METADATA } from '@aalis/plugin-webui-api';
15
15
  import express from 'express';
16
16
  import { WebSocket, WebSocketServer } from 'ws';
17
17
  import { createAuthSystem, openBrowser } from './auth.js';
18
- import { discoverClients } from './client-discovery.js';
18
+ import { collectLocalPackageDeps, discoverClients } from './client-discovery.js';
19
+ import { renderClientSwitchPage } from './client-switch-page.js';
19
20
  import { createRouteGate } from './gate.js';
20
21
  import { registerFileRoutes } from './routes/files.js';
21
22
  import { registerMarketplaceRoutes } from './routes/marketplace.js';
@@ -65,9 +66,8 @@ export const configSchema = {
65
66
  { label: '每次启动随机生成(重启即轮换)', value: 'ephemeral' },
66
67
  { label: '首次生成后持久化(重启不掉登录)', value: 'persist' },
67
68
  { label: '使用下方固定 token', value: 'fixed' },
68
- { label: '禁用 token 登录(仅账户密码;无账户时 token 兜底生效)', value: 'disabled' },
69
69
  ],
70
- description: 'ephemeral=旧行为;persist=token 写入 data/.webui-token,读取复用;fixed=使用 fixedToken 字段;disabled=多用户部署收口——存在登录账户时 token 全面失效(防 root 后门),无账户时仍以 persist 语义兜底防锁死。所有模式下都会同时写出便利文件 data/webui-access.txt 包含访问 URL。',
70
+ description: 'ephemeral=每次启动随机;persist=token 写入 data/.webui-token,读取复用;fixed=使用 fixedToken 字段。所有模式都会写出便利文件 data/webui-access.txt 含访问 URL。',
71
71
  },
72
72
  fixedToken: {
73
73
  type: 'string',
@@ -104,14 +104,22 @@ export const defaultConfig = {
104
104
  // 入站消息类型 + 校验 schema 见 ./protocol.ts(zod 强校验)
105
105
  import { WSIncomingSchema } from './protocol.js';
106
106
  export { WSIncomingSchema } from './protocol.js';
107
- // ===== 日志文件读取(与 src/runtime/file-logger.ts 的格式对偶)=====
107
+ // ===== 日志文件读取(与 @aalis/runtimefile-logger 格式对偶)=====
108
108
  // 行格式契约(format ↔ parse)由 @aalis/core 的 parseLogLine 唯一持有,此处只负责
109
- // 读取 data/latest.log(启动覆盖的单一历史源)并按 cursor 尾读分页。
110
- const LOG_FILE_PATH = resolve(process.cwd(), 'data/latest.log');
111
- async function readAllLogEntries() {
112
- if (!existsSync(LOG_FILE_PATH))
113
- return [];
114
- const raw = await readFile(LOG_FILE_PATH, 'utf8');
109
+ // 读取「持久化日志单一数据源」并按 cursor 尾读分页。
110
+ //
111
+ // 路径(宿主目录布局)是「环境知识」,归 storage 的 logs 根所有;本插件只用 storage
112
+ // URI 寻址、对 cwd/落盘位置无知。写入方(runtime/file-logger)跑在 storage 起来之前,
113
+ // 是 bootstrap 期的合法 raw-fs 例外,不在此处管辖(默认 logs 根 = data/,与之对偶)。
114
+ const LOG_FILE_URI = 'logs:/latest.log';
115
+ async function readAllLogEntries(storage) {
116
+ let raw;
117
+ try {
118
+ raw = (await storage.readFile(LOG_FILE_URI, 'utf-8'));
119
+ }
120
+ catch {
121
+ return []; // 文件未就绪 / storage 暂不可用 → 无历史
122
+ }
115
123
  const out = [];
116
124
  for (const line of raw.split('\n')) {
117
125
  if (!line)
@@ -122,12 +130,12 @@ async function readAllLogEntries() {
122
130
  }
123
131
  return out;
124
132
  }
125
- async function readLogFileTail(limit) {
126
- const all = await readAllLogEntries();
133
+ async function readLogFileTail(storage, limit) {
134
+ const all = await readAllLogEntries(storage);
127
135
  return all.slice(-limit);
128
136
  }
129
- async function readLogFileBefore(beforeSeq, limit) {
130
- const all = await readAllLogEntries();
137
+ async function readLogFileBefore(storage, beforeSeq, limit) {
138
+ const all = await readAllLogEntries(storage);
131
139
  const filtered = all.filter(e => e.seq < beforeSeq);
132
140
  return filtered.slice(-limit);
133
141
  }
@@ -138,7 +146,7 @@ export async function apply(ctx, config) {
138
146
  host: config.host ?? '127.0.0.1',
139
147
  fileRoot: config.fileRoot || 'workspace',
140
148
  autoOpen: config.autoOpen ?? true,
141
- tokenMode: ['ephemeral', 'fixed', 'disabled'].includes(config.tokenMode)
149
+ tokenMode: ['ephemeral', 'fixed'].includes(config.tokenMode)
142
150
  ? config.tokenMode
143
151
  : 'persist',
144
152
  fixedToken: config.fixedToken ?? '',
@@ -161,8 +169,7 @@ export async function apply(ctx, config) {
161
169
  if (uiConfig.tokenMode === 'fixed' && uiConfig.fixedToken.trim()) {
162
170
  return uiConfig.fixedToken.trim();
163
171
  }
164
- // disabled 模式仍按 persist 语义产出 token:无账户时作为兜底登录方式
165
- if (uiConfig.tokenMode === 'persist' || uiConfig.tokenMode === 'fixed' || uiConfig.tokenMode === 'disabled') {
172
+ if (uiConfig.tokenMode === 'persist' || uiConfig.tokenMode === 'fixed') {
166
173
  try {
167
174
  const raw = await storage.readFile(tokenFileUri, 'utf-8');
168
175
  const existing = (typeof raw === 'string' ? raw : raw.toString('utf-8')).trim();
@@ -204,28 +211,13 @@ export async function apply(ctx, config) {
204
211
  }
205
212
  }
206
213
  const authToken = await resolveAuthToken();
207
- // 账户校验惰性委托 authority 服务(可能晚于本插件激活/被热替换),账户即
208
- // platform='webui' 且带密码凭据的用户记录。
209
- const auth = createAuthSystem(authToken, ctx.logger.child('auth'), {
210
- verify: (username, password) => ctx.getService('authority')?.verifyPassword('webui', username, password) ?? false,
211
- hasAccounts: () => (ctx.getService('authority')?.listUsers() ?? []).some(u => u.platform === 'webui' && u.hasPassword),
212
- },
213
- // disabled:存在账户时 token 全面失效(auth 内部留有"无账户兜底"防锁死)
214
- () => uiConfig.tokenMode !== 'disabled');
214
+ const auth = createAuthSystem(authToken, ctx.logger.child('auth'));
215
215
  const expressApp = express();
216
216
  expressApp.use(express.json({ limit: '10mb' }));
217
217
  expressApp.use(auth.middleware);
218
- // REST 路由权限闸(连接级认证之上的身份级裁决,见 gate.ts 分层说明)
219
- const gate = createRouteGate(ctx, auth.identify);
220
- // 当前调用者信息(middleware 已拦未认证;identity 必存在)
221
- expressApp.get('/api/auth/me', (req, res) => {
222
- const identity = auth.identify(req) ?? { platform: 'webui', userId: 'console' };
223
- const authority = ctx.getService('authority');
224
- res.json({
225
- identity,
226
- isOwner: authority?.isOwner(identity.platform, identity.userId) ?? false,
227
- });
228
- });
218
+ // REST 路由 owner 闸(连接级认证之上的显式 owner 复核,见 gate.ts)。
219
+ // 当前身份 / 登录态查询统一走 auth.ts 的 GET /api/auth/status(含 identity)。
220
+ const gate = createRouteGate(auth.identify);
229
221
  const server = createServer(expressApp);
230
222
  const wss = new WebSocketServer({
231
223
  server,
@@ -302,11 +294,11 @@ export async function apply(ctx, config) {
302
294
  });
303
295
  // ---------- REST API ----------
304
296
  // 获取系统状态
305
- expressApp.get('/api/status', gate('webui:status:read', 'public'), (_req, res) => {
297
+ expressApp.get('/api/status', gate(), (_req, res) => {
306
298
  const persona = ctx.getService('persona');
307
299
  // 判断上传能力
308
300
  const hasMedia = ctx.hasService('media');
309
- const llmHasVision = ctx.getServiceCapabilities('llm').includes('vision');
301
+ const llmHasVision = listLLMModels(ctx).some(e => e.instance.capabilities.includes('vision'));
310
302
  const hasFileReader = ctx.hasService('file-reader');
311
303
  res.json({
312
304
  name: persona?.getPersonaName() ?? ctx.config.get('name'),
@@ -340,13 +332,41 @@ export async function apply(ctx, config) {
340
332
  });
341
333
  });
342
334
  // ---------- 插件管理 + 全局配置 ----------
335
+ // fs 扫描 env:discoverClients(ready 时)与市场「已装」兜底共用一份,避免两处重复。
336
+ const fsScanEnv = {
337
+ existsSync,
338
+ readdirSync: (p) => {
339
+ try {
340
+ return readdirSync(p);
341
+ }
342
+ catch {
343
+ return [];
344
+ }
345
+ },
346
+ readJson: (p) => {
347
+ try {
348
+ return JSON.parse(readFileSync(p, 'utf-8'));
349
+ }
350
+ catch {
351
+ return undefined;
352
+ }
353
+ },
354
+ join: (...parts) => resolve(...parts),
355
+ };
343
356
  registerPluginRoutes(expressApp, ctx, getApp, getPluginMgr, auth.identify, gate);
344
- registerMarketplaceRoutes(expressApp, ctx, getPluginMgr, gate, uiConfig.marketplaceRegistry);
357
+ // 市场「已装」判定 + 依赖图都吃这一份本地扫描(pnpm 工作区下 require.resolve 从仓库根解析不到工作区包):
358
+ // 与 discoverClients 同套 scanDirs(monorepo packages/ 同级 + node_modules/@aalis),每请求懒扫(量小)。
359
+ // 返回 name→依赖名[]:keys=已装包名,values=依赖图边。
360
+ const localScanDirs = [
361
+ resolve(dirname(fileURLToPath(import.meta.url)), '../../'),
362
+ resolve(process.cwd(), 'node_modules/@aalis'),
363
+ ];
364
+ registerMarketplaceRoutes(expressApp, ctx, getPluginMgr, gate, uiConfig.marketplaceRegistry, () => collectLocalPackageDeps(localScanDirs, fsScanEnv));
345
365
  // 获取历史日志:从 data/latest.log 读尾部 N 条(lazy load)。
346
366
  // 单进程内 LogHub 不再缓存 buffer——历史以文件为单一数据源。
347
- expressApp.get('/api/logs', gate('webui:logs:read', 'restricted'), async (_req, res) => {
367
+ expressApp.get('/api/logs', gate(), async (_req, res) => {
348
368
  try {
349
- const entries = await readLogFileTail(200);
369
+ const entries = await readLogFileTail(storage, 200);
350
370
  res.json(entries);
351
371
  }
352
372
  catch (err) {
@@ -354,17 +374,17 @@ export async function apply(ctx, config) {
354
374
  }
355
375
  });
356
376
  // 尾部 N 条(首屏 / 显式刷新)
357
- expressApp.get('/api/logs/tail', gate('webui:logs:read', 'restricted'), async (req, res) => {
377
+ expressApp.get('/api/logs/tail', gate(), async (req, res) => {
358
378
  const limit = Math.min(Math.max(Number(req.query.limit) || 200, 1), 5000);
359
379
  try {
360
- res.json(await readLogFileTail(limit));
380
+ res.json(await readLogFileTail(storage, limit));
361
381
  }
362
382
  catch (err) {
363
383
  res.status(500).json({ error: err.message });
364
384
  }
365
385
  });
366
386
  // 历史分页:返回 seq < before 的最近 limit 条(向上滚动加载更早)
367
- expressApp.get('/api/logs/range', gate('webui:logs:read', 'restricted'), async (req, res) => {
387
+ expressApp.get('/api/logs/range', gate(), async (req, res) => {
368
388
  const before = Number(req.query.before);
369
389
  const limit = Math.min(Math.max(Number(req.query.limit) || 200, 1), 5000);
370
390
  if (!Number.isFinite(before)) {
@@ -372,14 +392,14 @@ export async function apply(ctx, config) {
372
392
  return;
373
393
  }
374
394
  try {
375
- res.json(await readLogFileBefore(before, limit));
395
+ res.json(await readLogFileBefore(storage, before, limit));
376
396
  }
377
397
  catch (err) {
378
398
  res.status(500).json({ error: err.message });
379
399
  }
380
400
  });
381
401
  // 获取服务列表(含提供者信息)
382
- expressApp.get('/api/services', gate('webui:services:read', 'public'), async (_req, res) => {
402
+ expressApp.get('/api/services', gate(), async (_req, res) => {
383
403
  const pluginMgr = getPluginMgr();
384
404
  const pluginStatus = pluginMgr ? pluginMgr.getStatus() : [];
385
405
  const displayNameMap = new Map();
@@ -397,7 +417,6 @@ export async function apply(ctx, config) {
397
417
  services[svcName] = {
398
418
  providers: entries.map(e => ({
399
419
  contextId: e.contextId,
400
- capabilities: [...e.capabilities],
401
420
  displayName: displayNameMap.get(e.contextId),
402
421
  label: e.label,
403
422
  priority: e.priority,
@@ -411,7 +430,7 @@ export async function apply(ctx, config) {
411
430
  * 设置服务偏好。body: { contextId: string }
412
431
  * 偏好语义:`preferred > priority > 注册顺序`。持久化到 aalis.config.yaml 的 servicePreferences。
413
432
  */
414
- expressApp.post('/api/services/:name/prefer', gate('webui:services:manage', 'restricted'), async (req, res) => {
433
+ expressApp.post('/api/services/:name/prefer', gate(), async (req, res) => {
415
434
  const svcName = String(req.params.name);
416
435
  const contextId = String(req.body?.contextId ?? '').trim();
417
436
  if (!contextId) {
@@ -433,7 +452,7 @@ export async function apply(ctx, config) {
433
452
  res.json({ ok: true });
434
453
  });
435
454
  /** 清除服务偏好 */
436
- expressApp.delete('/api/services/:name/prefer', gate('webui:services:manage', 'restricted'), async (req, res) => {
455
+ expressApp.delete('/api/services/:name/prefer', gate(), async (req, res) => {
437
456
  const svcName = String(req.params.name);
438
457
  ctx.unpreferService(svcName);
439
458
  ctx.config.removeServicePreference(svcName);
@@ -446,11 +465,11 @@ export async function apply(ctx, config) {
446
465
  // POST /api/services/webui-client/prefer(见上),webui-server 在该偏好变更时重挂 + 广播刷新。
447
466
  // 故不再有独立的 /api/clients* 路由。
448
467
  // 获取所有平台适配器及其连接状态
449
- expressApp.get('/api/platforms', gate('webui:status:read', 'public'), (_req, res) => {
468
+ expressApp.get('/api/platforms', gate(), (_req, res) => {
450
469
  res.json({ platforms: aggregatePlatformDetails(ctx) });
451
470
  });
452
471
  // 获取已注册的工具分组(含元数据 + 各组工具数量 + 贡献插件列表)
453
- expressApp.get('/api/tool-groups', gate('webui:status:read', 'public'), (_req, res) => {
472
+ expressApp.get('/api/tool-groups', gate(), (_req, res) => {
454
473
  const groups = ctx.getService('tools')?.getGroups() ?? [];
455
474
  const allTools = ctx.getService('tools')?.getAll() ?? [];
456
475
  const knownNames = new Set(groups.map(g => g.name));
@@ -479,7 +498,7 @@ export async function apply(ctx, config) {
479
498
  res.json({ groups: result });
480
499
  });
481
500
  // 获取服务分组(manifest 驱动:每个插件自己声明 subsystem,本路由仅聚合)
482
- expressApp.get('/api/service-groups', gate('webui:status:read', 'public'), (_req, res) => {
501
+ expressApp.get('/api/service-groups', gate(), (_req, res) => {
483
502
  const pluginMgr = getPluginMgr();
484
503
  const pluginStatus = pluginMgr ? pluginMgr.getStatus() : [];
485
504
  // 按插件 subsystem 归组(未声明 → 'external')。subsystem 是 WebUI 展示概念、
@@ -520,12 +539,12 @@ export async function apply(ctx, config) {
520
539
  res.json({ groups });
521
540
  });
522
541
  // 获取所有 LLM 模型(枚举所有注册的 per-model entry)
523
- expressApp.get('/api/llm-models', gate('webui:llm:read', 'restricted'), async (_req, res) => {
542
+ expressApp.get('/api/llm-models', gate(), async (_req, res) => {
524
543
  try {
525
544
  const entries = ctx.getAllServices('llm');
526
545
  const models = entries.map(e => ({
527
546
  id: e.instance.id,
528
- capabilities: e.capabilities,
547
+ capabilities: [...e.instance.capabilities],
529
548
  provider: e.instance.providerId,
530
549
  contextId: e.contextId,
531
550
  }));
@@ -536,7 +555,7 @@ export async function apply(ctx, config) {
536
555
  }
537
556
  });
538
557
  // LLM providers + per-provider models(供 schema type='llm-ref' 联动 select 使用)
539
- expressApp.get('/api/llm-providers', gate('webui:llm:read', 'restricted'), async (_req, res) => {
558
+ expressApp.get('/api/llm-providers', gate(), async (_req, res) => {
540
559
  try {
541
560
  const entries = ctx.getAllServices('llm');
542
561
  const byProvider = new Map();
@@ -560,7 +579,7 @@ export async function apply(ctx, config) {
560
579
  }
561
580
  agg.models.push({
562
581
  id: e.instance.id,
563
- capabilities: e.capabilities,
582
+ capabilities: [...e.instance.capabilities],
564
583
  contextLength: e.instance.contextLength,
565
584
  });
566
585
  }
@@ -573,7 +592,7 @@ export async function apply(ctx, config) {
573
592
  // 触发指定 provider 重新探测远端模型列表(用于 webui 上的"刷新模型"按钮)
574
593
  // 仅对在 LLMModel 上实现了 refresh() 的 provider 生效(远端动态发现型,如 Ollama / OpenAI)。
575
594
  // 同 provider 下所有 model entries 共享同一份 refresh 闭包,调任一个 entry 即可。
576
- expressApp.post('/api/llm-providers/:contextId/refresh', gate('webui:llm:manage', 'restricted'), async (req, res) => {
595
+ expressApp.post('/api/llm-providers/:contextId/refresh', gate(), async (req, res) => {
577
596
  const contextId = req.params.contextId;
578
597
  if (!contextId) {
579
598
  res.status(400).json({ error: 'contextId is required' });
@@ -596,7 +615,7 @@ export async function apply(ctx, config) {
596
615
  }
597
616
  });
598
617
  // 获取某个服务的可用模型/选项列表
599
- expressApp.get('/api/models/:service', gate('webui:models:read', 'public'), async (req, res) => {
618
+ expressApp.get('/api/models/:service', gate(), async (req, res) => {
600
619
  const serviceName = req.params.service;
601
620
  // 特殊处理 platform:通过 helper 获取已注册的平台名称
602
621
  if (serviceName === 'platform') {
@@ -670,7 +689,7 @@ export async function apply(ctx, config) {
670
689
  model: e.instance.id,
671
690
  provider: e.label ?? e.contextId,
672
691
  contextId: e.contextId,
673
- capabilities: e.capabilities,
692
+ capabilities: [...e.instance.capabilities],
674
693
  }));
675
694
  res.json({ models: aggregated.map(a => a.value), providers: aggregated });
676
695
  }
@@ -697,9 +716,7 @@ export async function apply(ctx, config) {
697
716
  for (const m of models) {
698
717
  const isModelInfo = typeof m === 'object' && m !== null && 'id' in m;
699
718
  const modelId = isModelInfo ? m.id : String(m);
700
- const modelCaps = isModelInfo
701
- ? m.capabilities
702
- : provider.capabilities;
719
+ const modelCaps = isModelInfo ? m.capabilities : [];
703
720
  aggregated.push({
704
721
  value: modelId,
705
722
  model: modelId,
@@ -720,57 +737,22 @@ export async function apply(ctx, config) {
720
737
  res.json({ models: [] });
721
738
  }
722
739
  });
723
- // ---------- 受限操作交互式确认(内联对话式;restricted 能力的临时委托) ----------
724
- const CONFIRM_TIMEOUT = 60_000; // 60 秒超时
725
- const pendingSessionConfirms = new Map();
726
- ctx.getService('authority')?.setConfirmHandler('webui', async (request) => {
727
- const typeLabel = request.type === 'command' ? '指令' : '工具';
728
- const nameStr = request.type === 'command' ? `/${request.name}` : request.name;
729
- const prompt = `⚠️ ${typeLabel} ${nameStr} 是受限操作。回复 Y 仅允许本次;回复 YS 允许本会话 10 分钟;其他任意输入取消。`;
730
- // 以确认消息形式发送(不影响客户端 loading/streaming 状态)
731
- const payload = {
732
- type: 'confirm',
733
- content: prompt,
734
- sessionId: request.sessionId,
735
- };
736
- const json = JSON.stringify(payload);
737
- const sockets = sessions.get(request.sessionId);
738
- const targets = sockets && sockets.size > 0 ? sockets : allClients;
739
- let sent = false;
740
- for (const ws of targets) {
741
- if (ws.readyState === WebSocket.OPEN) {
742
- ws.send(json);
743
- sent = true;
740
+ // ---------- 受限操作交互式确认(复用 session-confirm 协调器;WebUI 传输=WS type:'confirm',流式友好) ----------
741
+ // 协调器逻辑(待确认/超时/解析/文案)由 session-confirm 服务统一实现;WebUI 只注入自己的 WS 投递,
742
+ // 并保留 type:'confirm'(前端「确认模式」信号,抑制富客户端的「打字即打断」)。回复在 WS-onmessage 调 feed(下方)。
743
+ let confirmChannel;
744
+ ctx.whenService('session-confirm', confirmSvc => {
745
+ confirmChannel = confirmSvc.createChannel((request, text) => {
746
+ const payload = { type: 'confirm', content: text, sessionId: request.sessionId };
747
+ const json = JSON.stringify(payload);
748
+ const sockets = sessions.get(request.sessionId);
749
+ const targets = sockets && sockets.size > 0 ? sockets : allClients;
750
+ for (const ws of targets) {
751
+ if (ws.readyState === WebSocket.OPEN)
752
+ ws.send(json);
744
753
  }
745
- }
746
- if (!sent)
747
- return false;
748
- // 同一 session 已有未决确认时,先取消旧的(清 timer + resolve false),避免新
749
- // set 覆盖后旧 Promise 永远 pending 且旧 timer 误删新条目(审计 HIGH #7 竞态)。
750
- const stale = pendingSessionConfirms.get(request.sessionId);
751
- if (stale) {
752
- clearTimeout(stale.timer);
753
- pendingSessionConfirms.delete(request.sessionId);
754
- stale.resolve(false);
755
- }
756
- return new Promise(resolve => {
757
- const timer = setTimeout(() => {
758
- pendingSessionConfirms.delete(request.sessionId);
759
- // 超时后向会话发送提示
760
- const timeoutPayload = {
761
- type: 'confirm',
762
- content: '⏰ 受限操作确认已超时,已自动取消。',
763
- sessionId: request.sessionId,
764
- };
765
- const timeoutJson = JSON.stringify(timeoutPayload);
766
- for (const ws of targets) {
767
- if (ws.readyState === WebSocket.OPEN)
768
- ws.send(timeoutJson);
769
- }
770
- resolve(false);
771
- }, CONFIRM_TIMEOUT);
772
- pendingSessionConfirms.set(request.sessionId, { resolve, timer });
773
754
  });
755
+ ctx.getService('authority')?.setConfirmHandler('webui', confirmChannel.handler);
774
756
  });
775
757
  // ---------- 文件管理 API ----------
776
758
  registerFileRoutes(expressApp, ctx, { storage, fileRoot: uiConfig.fileRoot }, gate);
@@ -869,18 +851,9 @@ export async function apply(ctx, config) {
869
851
  sessions.set(sessionId, new Set());
870
852
  }
871
853
  sessions.get(sessionId).add(ws);
872
- // 检查是否有待确认的受限操作(拦截用户输入作为确认/取消)
873
- const pending = pendingSessionConfirms.get(sessionId);
874
- if (pending) {
875
- clearTimeout(pending.timer);
876
- pendingSessionConfirms.delete(sessionId);
877
- const answer = trimmed.toLowerCase();
878
- if (answer === 'ys' || answer === 'y session') {
879
- pending.resolve({ allowed: true, grant: { scope: 'session', durationSeconds: 600, maxUses: 30 } });
880
- }
881
- else {
882
- pending.resolve(answer === 'y');
883
- }
854
+ // 检查待确认的受限操作(拦截输入作为确认/取消;协调器逻辑由 session-confirm 服务统一处理)
855
+ // 传应答者身份:仅确认的触发者本人能应答(防第三方抢答;webui 同账户天然同人)。
856
+ if (confirmChannel?.feed(sessionId, trimmed, wsIdentity.userId)) {
884
857
  // 不继续处理此消息,交给原始命令/工具执行流返回结果
885
858
  return;
886
859
  }
@@ -1203,6 +1176,13 @@ export async function apply(ctx, config) {
1203
1176
  }
1204
1177
  }
1205
1178
  });
1179
+ // 前端切换「逃生页」:由 webui-server 直出、独立于任何可被切换的前端,故任意前端(哪怕
1180
+ // 极简第三方前端无切换 UI)卡住时都能在此切回。受全局 auth 中间件登录保护;切换走既有 owner
1181
+ // 闸的 POST /api/services/webui-client/prefer,本页零新增后端逻辑(见 client-switch-page.ts)。
1182
+ // 注册在 SPA 兜底之前,故 `/__clients` 被本路由先接走、不落到前端 index.html。
1183
+ expressApp.get('/__clients', (_req, res) => {
1184
+ res.type('html').send(renderClientSwitchPage());
1185
+ });
1206
1186
  // SPA fallback: 所有非 API 路径返回 index.html
1207
1187
  expressApp.get('{*path}', (_req, res) => {
1208
1188
  const indexPath = resolve(clientDist, 'index.html');
@@ -1221,24 +1201,7 @@ export async function apply(ctx, config) {
1221
1201
  const here = dirname(fileURLToPath(import.meta.url));
1222
1202
  const projectRequire = createRequire(pathToFileURL(resolve(process.cwd(), 'package.json')));
1223
1203
  const env = {
1224
- existsSync,
1225
- readdirSync: p => {
1226
- try {
1227
- return readdirSync(p);
1228
- }
1229
- catch {
1230
- return [];
1231
- }
1232
- },
1233
- readJson: p => {
1234
- try {
1235
- return JSON.parse(readFileSync(p, 'utf-8'));
1236
- }
1237
- catch {
1238
- return undefined;
1239
- }
1240
- },
1241
- join: (...parts) => resolve(...parts),
1204
+ ...fsScanEnv,
1242
1205
  dirname,
1243
1206
  resolvePkgJson: id => {
1244
1207
  try {
@@ -1281,12 +1244,18 @@ export async function apply(ctx, config) {
1281
1244
  const accessUrl = `${url}?token=${authToken}`;
1282
1245
  void writeAccessFile(url, authToken);
1283
1246
  ctx.logger.info(`WebUI 已启动: ${url}`);
1247
+ // 多前端时提示恢复页 URL:万一切到无切换 UI 的前端被卡住,可在此切回(详见 client-switch-page.ts)。
1248
+ if (clientCandidates.length > 1) {
1249
+ ctx.logger.info(`检测到多个前端;若卡在某前端无切换入口,可访问恢复页切回: ${url}__clients`);
1250
+ }
1284
1251
  const tokenHint = uiConfig.tokenMode === 'ephemeral'
1285
1252
  ? 'token 仅本次启动有效,重启轮换'
1286
1253
  : uiConfig.tokenMode === 'fixed'
1287
1254
  ? 'token 来自配置 fixedToken,固定不变'
1288
1255
  : 'token 已持久化到 storage data:/webui/token,重启沿用';
1289
- ctx.logger.info(`首次访问请使用以下 URL(${tokenHint}): ${accessUrl}`);
1256
+ // 不把 token 打进日志(会落 latest.log / 被 /api/logs 回放 / 贴日志求助时外泄)。
1257
+ // 仅打不带 token 的 URL,完整一键登录链接见 access.txt(该文件应 0o600,见 token 落盘)。
1258
+ ctx.logger.info(`首次访问 URL(${tokenHint}): ${url} —— 完整一键登录链接见 ${accessFileUri}`);
1290
1259
  void (async () => {
1291
1260
  let absHint = accessFileUri;
1292
1261
  try {
@@ -1304,6 +1273,7 @@ export async function apply(ctx, config) {
1304
1273
  });
1305
1274
  });
1306
1275
  ctx.onDispose(() => {
1276
+ confirmChannel?.dispose(); // 清 WebUI 确认通道里挂起的待确认(安全拒),避免 Promise 永挂
1307
1277
  removeLogListener();
1308
1278
  wss.close();
1309
1279
  server.close();
@@ -1344,7 +1314,7 @@ export async function apply(ctx, config) {
1344
1314
  }
1345
1315
  },
1346
1316
  };
1347
- ctx.provide('platform', adapter, { capabilities: ['webui', 'text', 'image', 'file'] });
1317
+ ctx.provide('platform', adapter);
1348
1318
  // === 注册 WebUI 服务 ===
1349
1319
  const registeredPages = new Map();
1350
1320
  // 内建页面归属为 webui-server 本身
@@ -1386,6 +1356,6 @@ export async function apply(ctx, config) {
1386
1356
  registeredPages.delete(pluginName);
1387
1357
  },
1388
1358
  };
1389
- ctx.provide('webui-server', webuiService, { capabilities: ['api-v1'] });
1359
+ ctx.provide('webui-server', webuiService);
1390
1360
  }
1391
1361
  //# sourceMappingURL=index.js.map