@aalis/plugin-webui-server 0.1.2 → 0.3.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.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { Context } from '@aalis/core';
2
- import type { UserIdentity } from '@aalis/plugin-authority-api';
2
+ import type { CapabilityVisibility, UserIdentity } from '@aalis/plugin-authority-api';
3
3
  /** 从请求解析调用者身份(auth.ts 的 AuthSystem.identify) */
4
4
  type IdentifyFn = (req: {
5
5
  headers: {
@@ -18,21 +18,22 @@ interface GateResponse {
18
18
  }
19
19
  type GateMiddleware = (req: GateRequest, res: GateResponse, next: () => void) => void;
20
20
  /**
21
- * REST 路由权限闸工厂。
21
+ * REST 路由能力闸工厂。
22
22
  *
23
23
  * 连接级认证(auth middleware)只回答"是否登录";本闸回答"该身份能否做这件事"——
24
- * 每条 REST 路由声明一个 capability 与基础等级门槛,过 authorize 统一闸
25
- * (与 tool/command/page-action 同一裁决:permissionPolicy > deny > grant > 角色链,
26
- * 因此可对单个账户 grant `webui:files:read` 这类细粒度放行)。
24
+ * 每条 REST 路由声明一个 capability 与默认可见性,过 authorize 统一闸
25
+ * (deny > owner(*) > public > granted)。owner 拥有一切;可对单账户 grant 某条
26
+ * restricted capability(如 `webui:files:read`)做细粒度委托。
27
27
  *
28
- * 分层缺省(可经 config.permissionAuthority per-user grant/deny 调整):
29
- * - 1:公共读(status / pages / 模型列表等,聊天面板可用即可)
30
- * - 4:管理读(插件清单含原始配置、日志、文件浏览——可能含密钥/敏感信息)
31
- * - 'owner':变更操作(配置写、插件启停装卸、服务偏好、文件删改)
28
+ * 可见性约定(颗粒度由 capability 字符串本身承载,owner 之外的细分靠委托 grant):
29
+ * - 'public':公共读(status / pages / 模型列表等,聊天面板可用即可)。
30
+ * - 'restricted':管理读(插件清单含原始配置、日志、文件浏览——可能含密钥)与
31
+ * 一切变更操作(配置写、插件启停装卸、服务偏好、文件删改);默认禁,须 owner /
32
+ * 被委托授予。
32
33
  *
33
- * authority 服务缺席时放行(与 tools/commands 守卫缺席语义一致)。
34
+ * authority 服务缺席时:public 放行、restricted 503(fail-closed,不在权限服务缺位时裸奔)。
34
35
  */
35
- export declare function createRouteGate(ctx: Context, identify: IdentifyFn): (capability: string, declared: number | "owner") => GateMiddleware;
36
+ export declare function createRouteGate(ctx: Context, identify: IdentifyFn): (capability: string, visibility: CapabilityVisibility) => GateMiddleware;
36
37
  /** createRouteGate 返回的闸生成函数类型(传给各 routes/ 注册器) */
37
38
  export type RouteGate = ReturnType<typeof createRouteGate>;
38
39
  export {};
@@ -1 +1 @@
1
- {"version":3,"file":"gate.d.ts","sourceRoot":"","sources":["../src/gate.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,aAAa,CAAC;AAC3C,OAAO,KAAK,EAAoB,YAAY,EAAE,MAAM,6BAA6B,CAAC;AAElF,gDAAgD;AAChD,KAAK,UAAU,GAAG,CAAC,GAAG,EAAE;IAAE,OAAO,EAAE;QAAE,MAAM,CAAC,EAAE,MAAM,CAAA;KAAE,CAAA;CAAE,KAAK,YAAY,GAAG,SAAS,CAAC;AAKtF,UAAU,WAAW;IACnB,OAAO,EAAE;QAAE,MAAM,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;CAC9B;AACD,UAAU,YAAY;IACpB,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG;QAAE,IAAI,CAAC,IAAI,EAAE,OAAO,GAAG,OAAO,CAAA;KAAE,CAAC;CACxD;AACD,KAAK,cAAc,GAAG,CAAC,GAAG,EAAE,WAAW,EAAE,GAAG,EAAE,YAAY,EAAE,IAAI,EAAE,MAAM,IAAI,KAAK,IAAI,CAAC;AAEtF;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,eAAe,CAAC,GAAG,EAAE,OAAO,EAAE,QAAQ,EAAE,UAAU,IAIxD,YAAY,MAAM,EAAE,UAAU,MAAM,GAAG,OAAO,KAAG,cAAc,CAuBxE;AAED,kDAAkD;AAClD,MAAM,MAAM,SAAS,GAAG,UAAU,CAAC,OAAO,eAAe,CAAC,CAAC"}
1
+ {"version":3,"file":"gate.d.ts","sourceRoot":"","sources":["../src/gate.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,aAAa,CAAC;AAC3C,OAAO,KAAK,EAAoB,oBAAoB,EAAE,YAAY,EAAE,MAAM,6BAA6B,CAAC;AAExG,gDAAgD;AAChD,KAAK,UAAU,GAAG,CAAC,GAAG,EAAE;IAAE,OAAO,EAAE;QAAE,MAAM,CAAC,EAAE,MAAM,CAAA;KAAE,CAAA;CAAE,KAAK,YAAY,GAAG,SAAS,CAAC;AAKtF,UAAU,WAAW;IACnB,OAAO,EAAE;QAAE,MAAM,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;CAC9B;AACD,UAAU,YAAY;IACpB,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG;QAAE,IAAI,CAAC,IAAI,EAAE,OAAO,GAAG,OAAO,CAAA;KAAE,CAAC;CACxD;AACD,KAAK,cAAc,GAAG,CAAC,GAAG,EAAE,WAAW,EAAE,GAAG,EAAE,YAAY,EAAE,IAAI,EAAE,MAAM,IAAI,KAAK,IAAI,CAAC;AAEtF;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,eAAe,CAAC,GAAG,EAAE,OAAO,EAAE,QAAQ,EAAE,UAAU,IACxD,YAAY,MAAM,EAAE,YAAY,oBAAoB,KAAG,cAAc,CAqB9E;AAED,kDAAkD;AAClD,MAAM,MAAM,SAAS,GAAG,UAAU,CAAC,OAAO,eAAe,CAAC,CAAC"}
package/dist/gate.js CHANGED
@@ -1,38 +1,34 @@
1
1
  /**
2
- * REST 路由权限闸工厂。
2
+ * REST 路由能力闸工厂。
3
3
  *
4
4
  * 连接级认证(auth middleware)只回答"是否登录";本闸回答"该身份能否做这件事"——
5
- * 每条 REST 路由声明一个 capability 与基础等级门槛,过 authorize 统一闸
6
- * (与 tool/command/page-action 同一裁决:permissionPolicy > deny > grant > 角色链,
7
- * 因此可对单个账户 grant `webui:files:read` 这类细粒度放行)。
5
+ * 每条 REST 路由声明一个 capability 与默认可见性,过 authorize 统一闸
6
+ * (deny > owner(*) > public > granted)。owner 拥有一切;可对单账户 grant 某条
7
+ * restricted capability(如 `webui:files:read`)做细粒度委托。
8
8
  *
9
- * 分层缺省(可经 config.permissionAuthority per-user grant/deny 调整):
10
- * - 1:公共读(status / pages / 模型列表等,聊天面板可用即可)
11
- * - 4:管理读(插件清单含原始配置、日志、文件浏览——可能含密钥/敏感信息)
12
- * - 'owner':变更操作(配置写、插件启停装卸、服务偏好、文件删改)
9
+ * 可见性约定(颗粒度由 capability 字符串本身承载,owner 之外的细分靠委托 grant):
10
+ * - 'public':公共读(status / pages / 模型列表等,聊天面板可用即可)。
11
+ * - 'restricted':管理读(插件清单含原始配置、日志、文件浏览——可能含密钥)与
12
+ * 一切变更操作(配置写、插件启停装卸、服务偏好、文件删改);默认禁,须 owner /
13
+ * 被委托授予。
13
14
  *
14
- * authority 服务缺席时放行(与 tools/commands 守卫缺席语义一致)。
15
+ * authority 服务缺席时:public 放行、restricted 503(fail-closed,不在权限服务缺位时裸奔)。
15
16
  */
16
17
  export function createRouteGate(ctx, identify) {
17
- // 公共读基线:authority 缺席时,门槛 <= 此值的路由仍放行(聊天面板可用即可),
18
- // 高于此值的(管理读/变更)一律拒绝——fail-closed,不在权限服务缺位时裸奔。
19
- const PUBLIC_BASELINE = 1;
20
- return (capability, declared) => (req, res, next) => {
21
- const ownerLevel = ctx.config.get('ownerAuthority') ?? 5;
22
- const declaredAuthority = declared === 'owner' ? ownerLevel : declared;
18
+ return (capability, visibility) => (req, res, next) => {
23
19
  const authority = ctx.getService('authority');
24
20
  if (!authority) {
25
- // authority 缺席(未配置/启动失败/bounce 窗口期)→ 无法裁决等级。
26
- // 公共读放行,其余 503(区别于 403:服务暂不可用,非权限不足)。
27
- if (declaredAuthority > PUBLIC_BASELINE) {
28
- res.status(503).json({ error: '权限服务不可用,敏感操作暂时被拒绝' });
21
+ // authority 缺席(未配置/启动失败/bounce 窗口期)→ 无法裁决。
22
+ // 公共读放行,受限操作 503(区别于 403:服务暂不可用,非权限不足)。
23
+ if (visibility === 'restricted') {
24
+ res.status(503).json({ error: '权限服务不可用,受限操作暂时被拒绝' });
29
25
  return;
30
26
  }
31
27
  next();
32
28
  return;
33
29
  }
34
30
  const caller = identify(req) ?? { platform: 'webui', userId: 'console' };
35
- const denied = authority.authorize(caller, { capabilities: [capability], declaredAuthority });
31
+ const denied = authority.authorize(caller, { capability, visibility });
36
32
  if (denied) {
37
33
  res.status(403).json({ error: denied });
38
34
  return;
package/dist/gate.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"gate.js","sourceRoot":"","sources":["../src/gate.ts"],"names":[],"mappings":"AAiBA;;;;;;;;;;;;;;GAcG;AACH,MAAM,UAAU,eAAe,CAAC,GAAY,EAAE,QAAoB;IAChE,gDAAgD;IAChD,8CAA8C;IAC9C,MAAM,eAAe,GAAG,CAAC,CAAC;IAC1B,OAAO,CAAC,UAAkB,EAAE,QAA0B,EAAkB,EAAE,CACxE,CAAC,GAAG,EAAE,GAAG,EAAE,IAAI,EAAE,EAAE;QACjB,MAAM,UAAU,GAAG,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,gBAAgB,CAAC,IAAI,CAAC,CAAC;QACzD,MAAM,iBAAiB,GAAG,QAAQ,KAAK,OAAO,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC,CAAC,QAAQ,CAAC;QACvE,MAAM,SAAS,GAAG,GAAG,CAAC,UAAU,CAAmB,WAAW,CAAC,CAAC;QAChE,IAAI,CAAC,SAAS,EAAE,CAAC;YACf,6CAA6C;YAC7C,sCAAsC;YACtC,IAAI,iBAAiB,GAAG,eAAe,EAAE,CAAC;gBACxC,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,YAAY,EAAE,CAAC,UAAU,CAAC,EAAE,iBAAiB,EAAE,CAAC,CAAC;QAC9F,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":"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"}
package/dist/index.d.ts CHANGED
@@ -16,6 +16,7 @@ export declare const defaultConfig: {
16
16
  fixedToken: string;
17
17
  relationGraphDefaultSpacing: number;
18
18
  marketplaceRegistry: string;
19
+ client: string;
19
20
  };
20
21
  export { type WSIncoming, WSIncomingSchema } from './protocol.js';
21
22
  export declare function apply(ctx: Context, config: Record<string, unknown>): Promise<void>;
@@ -1 +1 @@
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;AAqCrG,eAAO,MAAM,IAAI,+BAA+B,CAAC;AACjD,eAAO,MAAM,WAAW,6BAAc,CAAC;AACvC,eAAO,MAAM,SAAS,aAAa,CAAC;AACpC,eAAO,MAAM,QAAQ,UAAmB,CAAC;AACzC,eAAO,MAAM,MAAM;;CAMlB,CAAC;AAWF,eAAO,MAAM,YAAY,EAAE,YAgD1B,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,CAg0CxF"}
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;AAqCrG,eAAO,MAAM,IAAI,+BAA+B,CAAC;AACjD,eAAO,MAAM,WAAW,6BAAc,CAAC;AACvC,eAAO,MAAM,SAAS,aAAa,CAAC;AACpC,eAAO,MAAM,QAAQ,UAAmB,CAAC;AACzC,eAAO,MAAM,MAAM;;CAMlB,CAAC;AAWF,eAAO,MAAM,YAAY,EAAE,YAuD1B,CAAC;AAEF,eAAO,MAAM,aAAa;;;;;;;;;;CAUzB,CAAC;AAsBF,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,CAq3CxF"}
package/dist/index.js CHANGED
@@ -1,11 +1,12 @@
1
1
  // node:fs/node:path 仅用于发现前端 dist 静态目录(位于工作区外部),
2
2
  // 已在 biome.json noRestrictedImports 中作为基础设施例外列出。
3
3
  import { Buffer } from 'node:buffer';
4
- import { existsSync } from 'node:fs';
4
+ import { existsSync, readFileSync } from 'node:fs';
5
5
  import { readFile } from 'node:fs/promises';
6
6
  import { createServer } from 'node:http';
7
+ import { createRequire } from 'node:module';
7
8
  import { dirname, resolve } from 'node:path';
8
- import { fileURLToPath } from 'node:url';
9
+ import { fileURLToPath, pathToFileURL } from 'node:url';
9
10
  import { LogHub, parseLogLine } from '@aalis/core';
10
11
  import { aggregatePlatformDetails, getPlatformAdapters, getPlatformNames, } from '@aalis/plugin-platform-api';
11
12
  import { createProcessGateway } from '@aalis/plugin-process-api';
@@ -85,6 +86,12 @@ export const configSchema = {
85
86
  default: 'https://registry.npmjs.org',
86
87
  description: '插件市场检索用的 npm registry 基址。注意 npm 的 search API 并非所有镜像都支持(淘宝等国内源不支持),默认官方源;国内可填支持 search 的镜像或代理。安装走 package-manager(遵循本机 npm 配置)。',
87
88
  },
89
+ client: {
90
+ type: 'string',
91
+ label: '活跃前端(多前端时选用)',
92
+ default: '',
93
+ description: '装了多个前端(aalis.client 包)时指定哪个为活跃前端,填包名(如 @aalis/plugin-webui-client)。留空 = 自动选第一个发现到的(默认前端优先)。插件主动 provide(webui-client) 仍优先于此。',
94
+ },
88
95
  };
89
96
  export const defaultConfig = {
90
97
  port: 3000,
@@ -95,6 +102,7 @@ export const defaultConfig = {
95
102
  fixedToken: '',
96
103
  relationGraphDefaultSpacing: 120,
97
104
  marketplaceRegistry: 'https://registry.npmjs.org',
105
+ client: '',
98
106
  };
99
107
  // ===== WebSocket 消息协议 =====
100
108
  // 入站消息类型 + 校验 schema 见 ./protocol.ts(zod 强校验)
@@ -143,6 +151,7 @@ export async function apply(ctx, config) {
143
151
  return Number.isFinite(v) && v > 0 ? v : 120;
144
152
  })(),
145
153
  marketplaceRegistry: config.marketplaceRegistry?.trim() || 'https://registry.npmjs.org',
154
+ client: config.client?.trim() ?? '',
146
155
  };
147
156
  // 创建 storage gateway;所有文件读写(token、access、文件管理)都走这里
148
157
  const storage = createStorageGateway(ctx);
@@ -219,7 +228,6 @@ export async function apply(ctx, config) {
219
228
  const authority = ctx.getService('authority');
220
229
  res.json({
221
230
  identity,
222
- authority: authority?.getAuthority(identity.platform, identity.userId) ?? null,
223
231
  isOwner: authority?.isOwner(identity.platform, identity.userId) ?? false,
224
232
  });
225
233
  });
@@ -266,6 +274,22 @@ export async function apply(ctx, config) {
266
274
  ctx.logger.warn(`前端目录不存在: ${dir}`);
267
275
  }
268
276
  }
277
+ // 已发现的前端候选 + 当前活跃 id(供「切换活跃前端」UI 读/写;ready 时填充)。
278
+ const clientCandidates = [];
279
+ let activeClientId = '';
280
+ /** 切换活跃前端:实时重挂静态目录 + 更新 SPA 回退 + 持久化 webui.client,下次启动沿用。 */
281
+ function activateClient(id) {
282
+ const c = clientCandidates.find(x => x.id === id);
283
+ if (!c)
284
+ return false;
285
+ clientDist = c.dir;
286
+ mountStaticDir(c.dir);
287
+ activeClientId = id;
288
+ ctx.config.setPluginConfig(name, { ...ctx.config.getPluginConfig(name), client: id });
289
+ ctx.config.save();
290
+ ctx.logger.info(`活跃前端切换为: ${c.label} (${id})`);
291
+ return true;
292
+ }
269
293
  // 动态静态文件中间件(支持运行时切换前端)
270
294
  expressApp.use((req, res, next) => {
271
295
  if (staticMiddleware) {
@@ -277,7 +301,7 @@ export async function apply(ctx, config) {
277
301
  });
278
302
  // ---------- REST API ----------
279
303
  // 获取系统状态
280
- expressApp.get('/api/status', gate('webui:status:read', 1), (_req, res) => {
304
+ expressApp.get('/api/status', gate('webui:status:read', 'public'), (_req, res) => {
281
305
  const persona = ctx.getService('persona');
282
306
  // 判断上传能力
283
307
  const hasMedia = ctx.hasService('media');
@@ -310,8 +334,7 @@ export async function apply(ctx, config) {
310
334
  .map(c => ({
311
335
  name: c.name,
312
336
  description: c.description,
313
- authority: c.authority,
314
- safety: c.safety,
337
+ visibility: c.visibility,
315
338
  })),
316
339
  });
317
340
  });
@@ -320,7 +343,7 @@ export async function apply(ctx, config) {
320
343
  registerMarketplaceRoutes(expressApp, ctx, getPluginMgr, gate, uiConfig.marketplaceRegistry);
321
344
  // 获取历史日志:从 data/latest.log 读尾部 N 条(lazy load)。
322
345
  // 单进程内 LogHub 不再缓存 buffer——历史以文件为单一数据源。
323
- expressApp.get('/api/logs', gate('webui:logs:read', 4), async (_req, res) => {
346
+ expressApp.get('/api/logs', gate('webui:logs:read', 'restricted'), async (_req, res) => {
324
347
  try {
325
348
  const entries = await readLogFileTail(200);
326
349
  res.json(entries);
@@ -330,7 +353,7 @@ export async function apply(ctx, config) {
330
353
  }
331
354
  });
332
355
  // 尾部 N 条(首屏 / 显式刷新)
333
- expressApp.get('/api/logs/tail', gate('webui:logs:read', 4), async (req, res) => {
356
+ expressApp.get('/api/logs/tail', gate('webui:logs:read', 'restricted'), async (req, res) => {
334
357
  const limit = Math.min(Math.max(Number(req.query.limit) || 200, 1), 5000);
335
358
  try {
336
359
  res.json(await readLogFileTail(limit));
@@ -340,7 +363,7 @@ export async function apply(ctx, config) {
340
363
  }
341
364
  });
342
365
  // 历史分页:返回 seq < before 的最近 limit 条(向上滚动加载更早)
343
- expressApp.get('/api/logs/range', gate('webui:logs:read', 4), async (req, res) => {
366
+ expressApp.get('/api/logs/range', gate('webui:logs:read', 'restricted'), async (req, res) => {
344
367
  const before = Number(req.query.before);
345
368
  const limit = Math.min(Math.max(Number(req.query.limit) || 200, 1), 5000);
346
369
  if (!Number.isFinite(before)) {
@@ -355,7 +378,7 @@ export async function apply(ctx, config) {
355
378
  }
356
379
  });
357
380
  // 获取服务列表(含提供者信息)
358
- expressApp.get('/api/services', gate('webui:services:read', 1), async (_req, res) => {
381
+ expressApp.get('/api/services', gate('webui:services:read', 'public'), async (_req, res) => {
359
382
  const pluginMgr = getPluginMgr();
360
383
  const pluginStatus = pluginMgr ? pluginMgr.getStatus() : [];
361
384
  const displayNameMap = new Map();
@@ -387,7 +410,7 @@ export async function apply(ctx, config) {
387
410
  * 设置服务偏好。body: { contextId: string }
388
411
  * 偏好语义:`preferred > priority > 注册顺序`。持久化到 aalis.config.yaml 的 servicePreferences。
389
412
  */
390
- expressApp.post('/api/services/:name/prefer', gate('webui:services:manage', 'owner'), async (req, res) => {
413
+ expressApp.post('/api/services/:name/prefer', gate('webui:services:manage', 'restricted'), async (req, res) => {
391
414
  const svcName = String(req.params.name);
392
415
  const contextId = String(req.body?.contextId ?? '').trim();
393
416
  if (!contextId) {
@@ -406,19 +429,35 @@ export async function apply(ctx, config) {
406
429
  res.json({ ok: true });
407
430
  });
408
431
  /** 清除服务偏好 */
409
- expressApp.delete('/api/services/:name/prefer', gate('webui:services:manage', 'owner'), async (req, res) => {
432
+ expressApp.delete('/api/services/:name/prefer', gate('webui:services:manage', 'restricted'), async (req, res) => {
410
433
  const svcName = String(req.params.name);
411
434
  ctx.unpreferService(svcName);
412
435
  ctx.config.removeServicePreference(svcName);
413
436
  ctx.config.save();
414
437
  res.json({ ok: true });
415
438
  });
439
+ // 前端切换:列出已发现的前端 + 当前活跃(多前端时 UI 展示)。只回 id/label/active,不泄露 fs 路径。
440
+ expressApp.get('/api/clients', gate('webui:status:read', 'public'), (_req, res) => {
441
+ res.json({
442
+ clients: clientCandidates.map(c => ({ id: c.id, label: c.label, active: c.id === activeClientId })),
443
+ active: activeClientId,
444
+ });
445
+ });
446
+ // 设活跃前端(owner):实时重挂 + 持久化;前端切完自行 reload 即加载新前端。
447
+ expressApp.post('/api/clients/active', gate('webui:clients:manage', 'restricted'), (req, res) => {
448
+ const id = String(req.body?.id ?? '').trim();
449
+ if (!activateClient(id)) {
450
+ res.status(404).json({ ok: false, error: `未找到已发现的前端 "${id}"` });
451
+ return;
452
+ }
453
+ res.json({ ok: true, active: id });
454
+ });
416
455
  // 获取所有平台适配器及其连接状态
417
- expressApp.get('/api/platforms', gate('webui:status:read', 1), (_req, res) => {
456
+ expressApp.get('/api/platforms', gate('webui:status:read', 'public'), (_req, res) => {
418
457
  res.json({ platforms: aggregatePlatformDetails(ctx) });
419
458
  });
420
459
  // 获取已注册的工具分组(含元数据 + 各组工具数量 + 贡献插件列表)
421
- expressApp.get('/api/tool-groups', gate('webui:status:read', 1), (_req, res) => {
460
+ expressApp.get('/api/tool-groups', gate('webui:status:read', 'public'), (_req, res) => {
422
461
  const groups = ctx.getService('tools')?.getGroups() ?? [];
423
462
  const allTools = ctx.getService('tools')?.getAll() ?? [];
424
463
  const knownNames = new Set(groups.map(g => g.name));
@@ -447,7 +486,7 @@ export async function apply(ctx, config) {
447
486
  res.json({ groups: result });
448
487
  });
449
488
  // 获取服务分组(manifest 驱动:每个插件自己声明 subsystem,本路由仅聚合)
450
- expressApp.get('/api/service-groups', gate('webui:status:read', 1), (_req, res) => {
489
+ expressApp.get('/api/service-groups', gate('webui:status:read', 'public'), (_req, res) => {
451
490
  const pluginMgr = getPluginMgr();
452
491
  const pluginStatus = pluginMgr ? pluginMgr.getStatus() : [];
453
492
  // 按 plugin.subsystem 归组(未声明 → 'external')
@@ -487,7 +526,7 @@ export async function apply(ctx, config) {
487
526
  res.json({ groups });
488
527
  });
489
528
  // 获取所有 LLM 模型(枚举所有注册的 per-model entry)
490
- expressApp.get('/api/llm-models', gate('webui:llm:read', 4), async (_req, res) => {
529
+ expressApp.get('/api/llm-models', gate('webui:llm:read', 'restricted'), async (_req, res) => {
491
530
  try {
492
531
  const entries = ctx.getAllServices('llm');
493
532
  const models = entries.map(e => ({
@@ -503,7 +542,7 @@ export async function apply(ctx, config) {
503
542
  }
504
543
  });
505
544
  // LLM providers + per-provider models(供 schema type='llm-ref' 联动 select 使用)
506
- expressApp.get('/api/llm-providers', gate('webui:llm:read', 4), async (_req, res) => {
545
+ expressApp.get('/api/llm-providers', gate('webui:llm:read', 'restricted'), async (_req, res) => {
507
546
  try {
508
547
  const entries = ctx.getAllServices('llm');
509
548
  const byProvider = new Map();
@@ -540,7 +579,7 @@ export async function apply(ctx, config) {
540
579
  // 触发指定 provider 重新探测远端模型列表(用于 webui 上的"刷新模型"按钮)
541
580
  // 仅对在 LLMModel 上实现了 refresh() 的 provider 生效(远端动态发现型,如 Ollama / OpenAI)。
542
581
  // 同 provider 下所有 model entries 共享同一份 refresh 闭包,调任一个 entry 即可。
543
- expressApp.post('/api/llm-providers/:contextId/refresh', gate('webui:llm:manage', 'owner'), async (req, res) => {
582
+ expressApp.post('/api/llm-providers/:contextId/refresh', gate('webui:llm:manage', 'restricted'), async (req, res) => {
544
583
  const contextId = req.params.contextId;
545
584
  if (!contextId) {
546
585
  res.status(400).json({ error: 'contextId is required' });
@@ -563,7 +602,7 @@ export async function apply(ctx, config) {
563
602
  }
564
603
  });
565
604
  // 获取某个服务的可用模型/选项列表
566
- expressApp.get('/api/models/:service', gate('webui:models:read', 1), async (req, res) => {
605
+ expressApp.get('/api/models/:service', gate('webui:models:read', 'public'), async (req, res) => {
567
606
  const serviceName = req.params.service;
568
607
  // 特殊处理 platform:通过 helper 获取已注册的平台名称
569
608
  if (serviceName === 'platform') {
@@ -687,13 +726,13 @@ export async function apply(ctx, config) {
687
726
  res.json({ models: [] });
688
727
  }
689
728
  });
690
- // ---------- 高危操作交互式确认(内联对话式) ----------
729
+ // ---------- 受限操作交互式确认(内联对话式;restricted 能力的临时委托) ----------
691
730
  const CONFIRM_TIMEOUT = 60_000; // 60 秒超时
692
731
  const pendingSessionConfirms = new Map();
693
732
  ctx.getService('authority')?.setConfirmHandler('webui', async (request) => {
694
733
  const typeLabel = request.type === 'command' ? '指令' : '工具';
695
734
  const nameStr = request.type === 'command' ? `/${request.name}` : request.name;
696
- const prompt = `⚠️ ${typeLabel} ${nameStr} 是高危操作。回复 Y 仅允许本次;回复 YS 允许本会话 10 分钟;其他任意输入取消。`;
735
+ const prompt = `⚠️ ${typeLabel} ${nameStr} 是受限操作。回复 Y 仅允许本次;回复 YS 允许本会话 10 分钟;其他任意输入取消。`;
697
736
  // 以确认消息形式发送(不影响客户端 loading/streaming 状态)
698
737
  const payload = {
699
738
  type: 'confirm',
@@ -726,7 +765,7 @@ export async function apply(ctx, config) {
726
765
  // 超时后向会话发送提示
727
766
  const timeoutPayload = {
728
767
  type: 'confirm',
729
- content: '⏰ 高危操作确认已超时,已自动取消。',
768
+ content: '⏰ 受限操作确认已超时,已自动取消。',
730
769
  sessionId: request.sessionId,
731
770
  };
732
771
  const timeoutJson = JSON.stringify(timeoutPayload);
@@ -836,7 +875,7 @@ export async function apply(ctx, config) {
836
875
  sessions.set(sessionId, new Set());
837
876
  }
838
877
  sessions.get(sessionId).add(ws);
839
- // 检查是否有待确认的高危操作(拦截用户输入作为确认/取消)
878
+ // 检查是否有待确认的受限操作(拦截用户输入作为确认/取消)
840
879
  const pending = pendingSessionConfirms.get(sessionId);
841
880
  if (pending) {
842
881
  clearTimeout(pending.timer);
@@ -1182,39 +1221,68 @@ export async function apply(ctx, config) {
1182
1221
  });
1183
1222
  // 启动服务器
1184
1223
  ctx.on('ready', () => {
1185
- // 自动发现同级 webui-client 包并注册为 webui-client 服务提供者
1224
+ // 自动发现 webui-client 包并注册为 webui-client 服务提供者。
1225
+ // 解析以「项目根 package.json」为基准(与 runtime 的 node-modules 加载器同源),
1226
+ // 这样 npm 扁平 / pnpm 隔离 / monorepo 软链三种 node_modules 拓扑都自洽——
1227
+ // client 是项目顶层依赖(create-aalis 选 WebUI 时自动带),从项目根能 resolve 到。
1228
+ // 回退:webui-server 同级目录(cwd≠项目根等边角场景兜底)。
1186
1229
  const __dirname = dirname(fileURLToPath(import.meta.url));
1187
- const clientCandidates = [
1188
- {
1189
- id: '@aalis/plugin-webui-client',
1190
- label: 'Aalis 默认前端',
1191
- dir: resolve(__dirname, '../../plugin-webui-client/dist'),
1192
- },
1193
- {
1194
- id: '@aalis/plugin-webui-client-napcat',
1195
- label: 'NapCat 前端',
1196
- dir: resolve(__dirname, '../../plugin-webui-client-napcat/dist'),
1197
- },
1198
- ];
1199
- // 如果已有外部插件注册了 webui-client 服务,则跳过自动发现
1230
+ const projectRequire = createRequire(pathToFileURL(resolve(process.cwd(), 'package.json')));
1231
+ const resolveClientDir = (id) => {
1232
+ try {
1233
+ return resolve(dirname(projectRequire.resolve(`${id}/package.json`)), 'dist');
1234
+ }
1235
+ catch {
1236
+ const sibling = resolve(__dirname, `../../${id.replace('@aalis/', '')}/dist`);
1237
+ return existsSync(sibling) ? sibling : undefined;
1238
+ }
1239
+ };
1240
+ const addCandidate = (id, label, dir) => {
1241
+ if (!dir || clientCandidates.some(c => c.id === id))
1242
+ return;
1243
+ if (existsSync(dir) && existsSync(resolve(dir, 'index.html')))
1244
+ clientCandidates.push({ id, label, dir });
1245
+ };
1246
+ // ① 已知 @aalis 前端(保证 monorepo/默认拓扑可用——根 deps 未必列 client)
1247
+ addCandidate('@aalis/plugin-webui-client', 'Aalis 默认前端', resolveClientDir('@aalis/plugin-webui-client'));
1248
+ addCandidate('@aalis/plugin-webui-client-napcat', 'NapCat 前端', resolveClientDir('@aalis/plugin-webui-client-napcat'));
1249
+ // ② 追加发现:项目 deps 里任何带 aalis.client marker 的第三方前端(忒修斯之船可换)
1250
+ try {
1251
+ const rootPkg = JSON.parse(readFileSync(resolve(process.cwd(), 'package.json'), 'utf-8'));
1252
+ const deps = Object.keys({ ...rootPkg.dependencies, ...rootPkg.optionalDependencies });
1253
+ for (const dep of deps) {
1254
+ if (clientCandidates.some(c => c.id === dep))
1255
+ continue;
1256
+ try {
1257
+ const pkgPath = projectRequire.resolve(`${dep}/package.json`);
1258
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
1259
+ if (pkg?.aalis?.client !== true)
1260
+ continue;
1261
+ addCandidate(dep, pkg.displayName ?? pkg.description ?? dep, resolve(dirname(pkgPath), 'dist'));
1262
+ }
1263
+ catch {
1264
+ /* 该 dep 无法解析/读取,跳过 */
1265
+ }
1266
+ }
1267
+ }
1268
+ catch {
1269
+ /* 无项目根 package.json(如 monorepo 直跑),跳过 dep 扫描 */
1270
+ }
1271
+ // 如果已有外部插件主动注册了 webui-client 服务(apply 覆盖路径),则跳过自动发现
1200
1272
  const hasExternalClient = ctx.hasService('webui-client');
1201
- if (!hasExternalClient) {
1202
- let isFirst = true;
1273
+ if (!hasExternalClient && clientCandidates.length > 0) {
1274
+ // 活跃前端:config.client 指定者优先,否则取第一个(默认前端排在最前)
1275
+ const preferred = uiConfig.client && clientCandidates.find(c => c.id === uiConfig.client);
1276
+ const active = preferred || clientCandidates[0];
1203
1277
  for (const candidate of clientCandidates) {
1204
- if (existsSync(candidate.dir) && existsSync(resolve(candidate.dir, 'index.html'))) {
1205
- const childCtx = ctx.fork(candidate.id);
1206
- childCtx.provide('webui-client', {
1207
- getClientDir: () => candidate.dir,
1208
- }, { label: candidate.label });
1209
- ctx.logger.info(`发现前端: ${candidate.label} (${candidate.dir})`);
1210
- if (isFirst) {
1211
- clientDist = candidate.dir;
1212
- mountStaticDir(candidate.dir);
1213
- ctx.logger.info(`活跃前端: ${candidate.label}`);
1214
- isFirst = false;
1215
- }
1216
- }
1278
+ const childCtx = ctx.fork(candidate.id);
1279
+ childCtx.provide('webui-client', { getClientDir: () => candidate.dir }, { label: candidate.label });
1280
+ ctx.logger.info(`发现前端: ${candidate.label} (${candidate.dir})`);
1217
1281
  }
1282
+ clientDist = active.dir;
1283
+ mountStaticDir(active.dir);
1284
+ activeClientId = active.id;
1285
+ ctx.logger.info(`活跃前端: ${active.label}${preferred ? '(config.client 指定)' : ''}`);
1218
1286
  }
1219
1287
  // 若已有外部 webui-client 服务,使用其提供的目录
1220
1288
  if (hasExternalClient) {