@aalis/plugin-webui-server 0.1.2 → 0.2.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,CAo1CxF"}
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
  });
@@ -277,7 +285,7 @@ export async function apply(ctx, config) {
277
285
  });
278
286
  // ---------- REST API ----------
279
287
  // 获取系统状态
280
- expressApp.get('/api/status', gate('webui:status:read', 1), (_req, res) => {
288
+ expressApp.get('/api/status', gate('webui:status:read', 'public'), (_req, res) => {
281
289
  const persona = ctx.getService('persona');
282
290
  // 判断上传能力
283
291
  const hasMedia = ctx.hasService('media');
@@ -310,8 +318,7 @@ export async function apply(ctx, config) {
310
318
  .map(c => ({
311
319
  name: c.name,
312
320
  description: c.description,
313
- authority: c.authority,
314
- safety: c.safety,
321
+ visibility: c.visibility,
315
322
  })),
316
323
  });
317
324
  });
@@ -320,7 +327,7 @@ export async function apply(ctx, config) {
320
327
  registerMarketplaceRoutes(expressApp, ctx, getPluginMgr, gate, uiConfig.marketplaceRegistry);
321
328
  // 获取历史日志:从 data/latest.log 读尾部 N 条(lazy load)。
322
329
  // 单进程内 LogHub 不再缓存 buffer——历史以文件为单一数据源。
323
- expressApp.get('/api/logs', gate('webui:logs:read', 4), async (_req, res) => {
330
+ expressApp.get('/api/logs', gate('webui:logs:read', 'restricted'), async (_req, res) => {
324
331
  try {
325
332
  const entries = await readLogFileTail(200);
326
333
  res.json(entries);
@@ -330,7 +337,7 @@ export async function apply(ctx, config) {
330
337
  }
331
338
  });
332
339
  // 尾部 N 条(首屏 / 显式刷新)
333
- expressApp.get('/api/logs/tail', gate('webui:logs:read', 4), async (req, res) => {
340
+ expressApp.get('/api/logs/tail', gate('webui:logs:read', 'restricted'), async (req, res) => {
334
341
  const limit = Math.min(Math.max(Number(req.query.limit) || 200, 1), 5000);
335
342
  try {
336
343
  res.json(await readLogFileTail(limit));
@@ -340,7 +347,7 @@ export async function apply(ctx, config) {
340
347
  }
341
348
  });
342
349
  // 历史分页:返回 seq < before 的最近 limit 条(向上滚动加载更早)
343
- expressApp.get('/api/logs/range', gate('webui:logs:read', 4), async (req, res) => {
350
+ expressApp.get('/api/logs/range', gate('webui:logs:read', 'restricted'), async (req, res) => {
344
351
  const before = Number(req.query.before);
345
352
  const limit = Math.min(Math.max(Number(req.query.limit) || 200, 1), 5000);
346
353
  if (!Number.isFinite(before)) {
@@ -355,7 +362,7 @@ export async function apply(ctx, config) {
355
362
  }
356
363
  });
357
364
  // 获取服务列表(含提供者信息)
358
- expressApp.get('/api/services', gate('webui:services:read', 1), async (_req, res) => {
365
+ expressApp.get('/api/services', gate('webui:services:read', 'public'), async (_req, res) => {
359
366
  const pluginMgr = getPluginMgr();
360
367
  const pluginStatus = pluginMgr ? pluginMgr.getStatus() : [];
361
368
  const displayNameMap = new Map();
@@ -387,7 +394,7 @@ export async function apply(ctx, config) {
387
394
  * 设置服务偏好。body: { contextId: string }
388
395
  * 偏好语义:`preferred > priority > 注册顺序`。持久化到 aalis.config.yaml 的 servicePreferences。
389
396
  */
390
- expressApp.post('/api/services/:name/prefer', gate('webui:services:manage', 'owner'), async (req, res) => {
397
+ expressApp.post('/api/services/:name/prefer', gate('webui:services:manage', 'restricted'), async (req, res) => {
391
398
  const svcName = String(req.params.name);
392
399
  const contextId = String(req.body?.contextId ?? '').trim();
393
400
  if (!contextId) {
@@ -406,7 +413,7 @@ export async function apply(ctx, config) {
406
413
  res.json({ ok: true });
407
414
  });
408
415
  /** 清除服务偏好 */
409
- expressApp.delete('/api/services/:name/prefer', gate('webui:services:manage', 'owner'), async (req, res) => {
416
+ expressApp.delete('/api/services/:name/prefer', gate('webui:services:manage', 'restricted'), async (req, res) => {
410
417
  const svcName = String(req.params.name);
411
418
  ctx.unpreferService(svcName);
412
419
  ctx.config.removeServicePreference(svcName);
@@ -414,11 +421,11 @@ export async function apply(ctx, config) {
414
421
  res.json({ ok: true });
415
422
  });
416
423
  // 获取所有平台适配器及其连接状态
417
- expressApp.get('/api/platforms', gate('webui:status:read', 1), (_req, res) => {
424
+ expressApp.get('/api/platforms', gate('webui:status:read', 'public'), (_req, res) => {
418
425
  res.json({ platforms: aggregatePlatformDetails(ctx) });
419
426
  });
420
427
  // 获取已注册的工具分组(含元数据 + 各组工具数量 + 贡献插件列表)
421
- expressApp.get('/api/tool-groups', gate('webui:status:read', 1), (_req, res) => {
428
+ expressApp.get('/api/tool-groups', gate('webui:status:read', 'public'), (_req, res) => {
422
429
  const groups = ctx.getService('tools')?.getGroups() ?? [];
423
430
  const allTools = ctx.getService('tools')?.getAll() ?? [];
424
431
  const knownNames = new Set(groups.map(g => g.name));
@@ -447,7 +454,7 @@ export async function apply(ctx, config) {
447
454
  res.json({ groups: result });
448
455
  });
449
456
  // 获取服务分组(manifest 驱动:每个插件自己声明 subsystem,本路由仅聚合)
450
- expressApp.get('/api/service-groups', gate('webui:status:read', 1), (_req, res) => {
457
+ expressApp.get('/api/service-groups', gate('webui:status:read', 'public'), (_req, res) => {
451
458
  const pluginMgr = getPluginMgr();
452
459
  const pluginStatus = pluginMgr ? pluginMgr.getStatus() : [];
453
460
  // 按 plugin.subsystem 归组(未声明 → 'external')
@@ -487,7 +494,7 @@ export async function apply(ctx, config) {
487
494
  res.json({ groups });
488
495
  });
489
496
  // 获取所有 LLM 模型(枚举所有注册的 per-model entry)
490
- expressApp.get('/api/llm-models', gate('webui:llm:read', 4), async (_req, res) => {
497
+ expressApp.get('/api/llm-models', gate('webui:llm:read', 'restricted'), async (_req, res) => {
491
498
  try {
492
499
  const entries = ctx.getAllServices('llm');
493
500
  const models = entries.map(e => ({
@@ -503,7 +510,7 @@ export async function apply(ctx, config) {
503
510
  }
504
511
  });
505
512
  // LLM providers + per-provider models(供 schema type='llm-ref' 联动 select 使用)
506
- expressApp.get('/api/llm-providers', gate('webui:llm:read', 4), async (_req, res) => {
513
+ expressApp.get('/api/llm-providers', gate('webui:llm:read', 'restricted'), async (_req, res) => {
507
514
  try {
508
515
  const entries = ctx.getAllServices('llm');
509
516
  const byProvider = new Map();
@@ -540,7 +547,7 @@ export async function apply(ctx, config) {
540
547
  // 触发指定 provider 重新探测远端模型列表(用于 webui 上的"刷新模型"按钮)
541
548
  // 仅对在 LLMModel 上实现了 refresh() 的 provider 生效(远端动态发现型,如 Ollama / OpenAI)。
542
549
  // 同 provider 下所有 model entries 共享同一份 refresh 闭包,调任一个 entry 即可。
543
- expressApp.post('/api/llm-providers/:contextId/refresh', gate('webui:llm:manage', 'owner'), async (req, res) => {
550
+ expressApp.post('/api/llm-providers/:contextId/refresh', gate('webui:llm:manage', 'restricted'), async (req, res) => {
544
551
  const contextId = req.params.contextId;
545
552
  if (!contextId) {
546
553
  res.status(400).json({ error: 'contextId is required' });
@@ -563,7 +570,7 @@ export async function apply(ctx, config) {
563
570
  }
564
571
  });
565
572
  // 获取某个服务的可用模型/选项列表
566
- expressApp.get('/api/models/:service', gate('webui:models:read', 1), async (req, res) => {
573
+ expressApp.get('/api/models/:service', gate('webui:models:read', 'public'), async (req, res) => {
567
574
  const serviceName = req.params.service;
568
575
  // 特殊处理 platform:通过 helper 获取已注册的平台名称
569
576
  if (serviceName === 'platform') {
@@ -687,13 +694,13 @@ export async function apply(ctx, config) {
687
694
  res.json({ models: [] });
688
695
  }
689
696
  });
690
- // ---------- 高危操作交互式确认(内联对话式) ----------
697
+ // ---------- 受限操作交互式确认(内联对话式;restricted 能力的临时委托) ----------
691
698
  const CONFIRM_TIMEOUT = 60_000; // 60 秒超时
692
699
  const pendingSessionConfirms = new Map();
693
700
  ctx.getService('authority')?.setConfirmHandler('webui', async (request) => {
694
701
  const typeLabel = request.type === 'command' ? '指令' : '工具';
695
702
  const nameStr = request.type === 'command' ? `/${request.name}` : request.name;
696
- const prompt = `⚠️ ${typeLabel} ${nameStr} 是高危操作。回复 Y 仅允许本次;回复 YS 允许本会话 10 分钟;其他任意输入取消。`;
703
+ const prompt = `⚠️ ${typeLabel} ${nameStr} 是受限操作。回复 Y 仅允许本次;回复 YS 允许本会话 10 分钟;其他任意输入取消。`;
697
704
  // 以确认消息形式发送(不影响客户端 loading/streaming 状态)
698
705
  const payload = {
699
706
  type: 'confirm',
@@ -726,7 +733,7 @@ export async function apply(ctx, config) {
726
733
  // 超时后向会话发送提示
727
734
  const timeoutPayload = {
728
735
  type: 'confirm',
729
- content: '⏰ 高危操作确认已超时,已自动取消。',
736
+ content: '⏰ 受限操作确认已超时,已自动取消。',
730
737
  sessionId: request.sessionId,
731
738
  };
732
739
  const timeoutJson = JSON.stringify(timeoutPayload);
@@ -836,7 +843,7 @@ export async function apply(ctx, config) {
836
843
  sessions.set(sessionId, new Set());
837
844
  }
838
845
  sessions.get(sessionId).add(ws);
839
- // 检查是否有待确认的高危操作(拦截用户输入作为确认/取消)
846
+ // 检查是否有待确认的受限操作(拦截用户输入作为确认/取消)
840
847
  const pending = pendingSessionConfirms.get(sessionId);
841
848
  if (pending) {
842
849
  clearTimeout(pending.timer);
@@ -1182,39 +1189,68 @@ export async function apply(ctx, config) {
1182
1189
  });
1183
1190
  // 启动服务器
1184
1191
  ctx.on('ready', () => {
1185
- // 自动发现同级 webui-client 包并注册为 webui-client 服务提供者
1192
+ // 自动发现 webui-client 包并注册为 webui-client 服务提供者。
1193
+ // 解析以「项目根 package.json」为基准(与 runtime 的 node-modules 加载器同源),
1194
+ // 这样 npm 扁平 / pnpm 隔离 / monorepo 软链三种 node_modules 拓扑都自洽——
1195
+ // client 是项目顶层依赖(create-aalis 选 WebUI 时自动带),从项目根能 resolve 到。
1196
+ // 回退:webui-server 同级目录(cwd≠项目根等边角场景兜底)。
1186
1197
  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 服务,则跳过自动发现
1198
+ const projectRequire = createRequire(pathToFileURL(resolve(process.cwd(), 'package.json')));
1199
+ const resolveClientDir = (id) => {
1200
+ try {
1201
+ return resolve(dirname(projectRequire.resolve(`${id}/package.json`)), 'dist');
1202
+ }
1203
+ catch {
1204
+ const sibling = resolve(__dirname, `../../${id.replace('@aalis/', '')}/dist`);
1205
+ return existsSync(sibling) ? sibling : undefined;
1206
+ }
1207
+ };
1208
+ const clientCandidates = [];
1209
+ const addCandidate = (id, label, dir) => {
1210
+ if (!dir || clientCandidates.some(c => c.id === id))
1211
+ return;
1212
+ if (existsSync(dir) && existsSync(resolve(dir, 'index.html')))
1213
+ clientCandidates.push({ id, label, dir });
1214
+ };
1215
+ // ① 已知 @aalis 前端(保证 monorepo/默认拓扑可用——根 deps 未必列 client)
1216
+ addCandidate('@aalis/plugin-webui-client', 'Aalis 默认前端', resolveClientDir('@aalis/plugin-webui-client'));
1217
+ addCandidate('@aalis/plugin-webui-client-napcat', 'NapCat 前端', resolveClientDir('@aalis/plugin-webui-client-napcat'));
1218
+ // ② 追加发现:项目 deps 里任何带 aalis.client marker 的第三方前端(忒修斯之船可换)
1219
+ try {
1220
+ const rootPkg = JSON.parse(readFileSync(resolve(process.cwd(), 'package.json'), 'utf-8'));
1221
+ const deps = Object.keys({ ...rootPkg.dependencies, ...rootPkg.optionalDependencies });
1222
+ for (const dep of deps) {
1223
+ if (clientCandidates.some(c => c.id === dep))
1224
+ continue;
1225
+ try {
1226
+ const pkgPath = projectRequire.resolve(`${dep}/package.json`);
1227
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
1228
+ if (pkg?.aalis?.client !== true)
1229
+ continue;
1230
+ addCandidate(dep, pkg.displayName ?? pkg.description ?? dep, resolve(dirname(pkgPath), 'dist'));
1231
+ }
1232
+ catch {
1233
+ /* 该 dep 无法解析/读取,跳过 */
1234
+ }
1235
+ }
1236
+ }
1237
+ catch {
1238
+ /* 无项目根 package.json(如 monorepo 直跑),跳过 dep 扫描 */
1239
+ }
1240
+ // 如果已有外部插件主动注册了 webui-client 服务(apply 覆盖路径),则跳过自动发现
1200
1241
  const hasExternalClient = ctx.hasService('webui-client');
1201
- if (!hasExternalClient) {
1202
- let isFirst = true;
1242
+ if (!hasExternalClient && clientCandidates.length > 0) {
1243
+ // 活跃前端:config.client 指定者优先,否则取第一个(默认前端排在最前)
1244
+ const preferred = uiConfig.client && clientCandidates.find(c => c.id === uiConfig.client);
1245
+ const active = preferred || clientCandidates[0];
1203
1246
  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
- }
1247
+ const childCtx = ctx.fork(candidate.id);
1248
+ childCtx.provide('webui-client', { getClientDir: () => candidate.dir }, { label: candidate.label });
1249
+ ctx.logger.info(`发现前端: ${candidate.label} (${candidate.dir})`);
1217
1250
  }
1251
+ clientDist = active.dir;
1252
+ mountStaticDir(active.dir);
1253
+ ctx.logger.info(`活跃前端: ${active.label}${preferred ? '(config.client 指定)' : ''}`);
1218
1254
  }
1219
1255
  // 若已有外部 webui-client 服务,使用其提供的目录
1220
1256
  if (hasExternalClient) {