@aalis/plugin-webui-server 0.14.1 → 0.15.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.
Files changed (95) hide show
  1. package/README.md +5 -1
  2. package/dist/admin-channel.d.ts +10 -0
  3. package/dist/admin-channel.d.ts.map +1 -0
  4. package/dist/admin-channel.js +57 -0
  5. package/dist/admin-channel.js.map +1 -0
  6. package/dist/attention.d.ts +36 -0
  7. package/dist/attention.d.ts.map +1 -0
  8. package/dist/attention.js +73 -0
  9. package/dist/attention.js.map +1 -0
  10. package/dist/auth.d.ts +22 -3
  11. package/dist/auth.d.ts.map +1 -1
  12. package/dist/auth.js +192 -68
  13. package/dist/auth.js.map +1 -1
  14. package/dist/client-discovery.d.ts +7 -3
  15. package/dist/client-discovery.d.ts.map +1 -1
  16. package/dist/client-discovery.js +9 -3
  17. package/dist/client-discovery.js.map +1 -1
  18. package/dist/client-switch-page.d.ts +2 -0
  19. package/dist/client-switch-page.d.ts.map +1 -1
  20. package/dist/client-switch-page.js +37 -36
  21. package/dist/client-switch-page.js.map +1 -1
  22. package/dist/gate.d.ts +3 -3
  23. package/dist/gate.js +3 -3
  24. package/dist/index.d.ts +21 -26
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +561 -1015
  27. package/dist/index.js.map +1 -1
  28. package/dist/layout.d.ts +18 -0
  29. package/dist/layout.d.ts.map +1 -0
  30. package/dist/layout.js +198 -0
  31. package/dist/layout.js.map +1 -0
  32. package/dist/logs.d.ts +10 -0
  33. package/dist/logs.d.ts.map +1 -0
  34. package/dist/logs.js +50 -0
  35. package/dist/logs.js.map +1 -0
  36. package/dist/page-check.d.ts +56 -0
  37. package/dist/page-check.d.ts.map +1 -0
  38. package/dist/page-check.js +369 -0
  39. package/dist/page-check.js.map +1 -0
  40. package/dist/page-files.d.ts +13 -0
  41. package/dist/page-files.d.ts.map +1 -0
  42. package/dist/page-files.js +39 -0
  43. package/dist/page-files.js.map +1 -0
  44. package/dist/pages.d.ts +60 -0
  45. package/dist/pages.d.ts.map +1 -0
  46. package/dist/pages.js +470 -0
  47. package/dist/pages.js.map +1 -0
  48. package/dist/route.d.ts +11 -0
  49. package/dist/route.d.ts.map +1 -0
  50. package/dist/route.js +13 -0
  51. package/dist/route.js.map +1 -0
  52. package/dist/routes/files.d.ts +8 -9
  53. package/dist/routes/files.d.ts.map +1 -1
  54. package/dist/routes/files.js +32 -119
  55. package/dist/routes/files.js.map +1 -1
  56. package/dist/routes/frames.d.ts +4 -0
  57. package/dist/routes/frames.d.ts.map +1 -0
  58. package/dist/routes/frames.js +103 -0
  59. package/dist/routes/frames.js.map +1 -0
  60. package/dist/routes/marketplace.d.ts +46 -129
  61. package/dist/routes/marketplace.d.ts.map +1 -1
  62. package/dist/routes/marketplace.js +138 -86
  63. package/dist/routes/marketplace.js.map +1 -1
  64. package/dist/routes/media.d.ts +7 -0
  65. package/dist/routes/media.d.ts.map +1 -0
  66. package/dist/routes/media.js +63 -0
  67. package/dist/routes/media.js.map +1 -0
  68. package/dist/routes/pages.d.ts +20 -0
  69. package/dist/routes/pages.d.ts.map +1 -0
  70. package/dist/routes/pages.js +86 -0
  71. package/dist/routes/pages.js.map +1 -0
  72. package/dist/routes/pipe-to-response.d.ts +9 -0
  73. package/dist/routes/pipe-to-response.d.ts.map +1 -0
  74. package/dist/routes/pipe-to-response.js +16 -0
  75. package/dist/routes/pipe-to-response.js.map +1 -0
  76. package/dist/routes/plugins.d.ts +22 -24
  77. package/dist/routes/plugins.d.ts.map +1 -1
  78. package/dist/routes/plugins.js +285 -275
  79. package/dist/routes/plugins.js.map +1 -1
  80. package/dist/routes/proxy.d.ts.map +1 -1
  81. package/dist/routes/proxy.js +2 -1
  82. package/dist/routes/proxy.js.map +1 -1
  83. package/dist/session-transport.d.ts +6 -0
  84. package/dist/session-transport.d.ts.map +1 -0
  85. package/dist/session-transport.js +32 -0
  86. package/dist/session-transport.js.map +1 -0
  87. package/package.json +34 -31
  88. package/dist/protocol.d.ts +0 -113
  89. package/dist/protocol.d.ts.map +0 -1
  90. package/dist/protocol.js +0 -59
  91. package/dist/protocol.js.map +0 -1
  92. package/dist/routes/uploaded-files.d.ts +0 -20
  93. package/dist/routes/uploaded-files.d.ts.map +0 -1
  94. package/dist/routes/uploaded-files.js +0 -161
  95. package/dist/routes/uploaded-files.js.map +0 -1
package/dist/index.js CHANGED
@@ -4,36 +4,49 @@ import { Buffer } from 'node:buffer';
4
4
  import { existsSync, readdirSync, readFileSync } from 'node:fs';
5
5
  import { createServer } from 'node:http';
6
6
  import { createRequire } from 'node:module';
7
- import { dirname, resolve } from 'node:path';
7
+ import { dirname, join, resolve } from 'node:path';
8
8
  import { pathToFileURL } from 'node:url';
9
- import { agent } from '@aalis/api-agent';
10
- import { authority } from '@aalis/api-authority';
9
+ import { approvals } from '@aalis/api-approval';
11
10
  import { commands } from '@aalis/api-commands';
11
+ import { contributions } from '@aalis/api-contributions';
12
12
  import { hostConfig } from '@aalis/api-host-config';
13
- import { listLLMModels, llm } from '@aalis/api-llm';
13
+ import { hostLogs } from '@aalis/api-host-logs';
14
+ import { hostPaths } from '@aalis/api-host-paths';
15
+ import { llm, resolveLLMModel } from '@aalis/api-llm';
16
+ import { messageArchive } from '@aalis/api-message-archive';
14
17
  import { packageManager } from '@aalis/api-package-manager';
15
- import { persona } from '@aalis/api-persona';
16
- import { aggregatePlatformDetails, getPlatformAdapters, getPlatformNames, platform, } from '@aalis/api-platform';
18
+ import { aggregatePlatformDetails, getPlatformAdapters, getPlatformNames, platform, sessionTypesOf, } from '@aalis/api-platform';
17
19
  import { pluginSource } from '@aalis/api-plugin-source';
18
- import { createProcessGateway, processService } from '@aalis/api-process';
19
- import { sessionConfirm } from '@aalis/api-session-confirm';
20
- import { createStorageGateway, readTailLines, storage } from '@aalis/api-storage';
20
+ import { processService } from '@aalis/api-process';
21
+ import { rootSessionOf, sessionManager } from '@aalis/api-session-manager';
22
+ import { sessionProtocol } from '@aalis/api-session-protocol';
23
+ import { isStorageUri, storage } from '@aalis/api-storage';
21
24
  import { tools } from '@aalis/api-tools';
22
- import { DEFAULT_SUBSYSTEM_METADATA, webuiClient, webuiServer } from '@aalis/api-webui';
23
- import { appService, config, definePlugin, events, LogHub, lifecycle, logger, optional, pluginsService, provide, services, } from '@aalis/core';
25
+ import { parseTheme, webuiClient, webuiServer, } from '@aalis/api-webui';
26
+ import { appService, config, definePlugin, events, lifecycle, logger, optional, pluginsService, provide, services, } from '@aalis/core';
24
27
  import { configError, defineConfig, parseConfig } from '@aalis/schema-config';
25
- import { parseLogLine } from '@aalis/schema-log';
28
+ import { outboundMediaMessage, WellKnownKinds, WellKnownMetadataKeys, } from '@aalis/schema-message';
26
29
  import express from 'express';
27
- import { WebSocket, WebSocketServer } from 'ws';
28
- import { createAuthSystem, openBrowser } from './auth.js';
30
+ import { WebSocketServer } from 'ws';
31
+ import { ADMIN_PATH, createAdminChannel } from './admin-channel.js';
32
+ import { blockedStatus, createAttention } from './attention.js';
33
+ import { createAuthSystem, openBrowser, originAllowed } from './auth.js';
29
34
  import { collectLocalPackageDeps, discoverClients, pickFreshClients } from './client-discovery.js';
30
- import { renderClientSwitchPage } from './client-switch-page.js';
35
+ import { CLIENT_SWITCH_SCRIPT, renderClientSwitchPage } from './client-switch-page.js';
31
36
  import { createRouteGate } from './gate.js';
37
+ import { createLayoutStore } from './layout.js';
38
+ import { logMatcher, parseLogFilter } from './logs.js';
39
+ import { createPageFiles } from './page-files.js';
40
+ import { createPageRegistry } from './pages.js';
41
+ import { route } from './route.js';
32
42
  import { registerFileRoutes } from './routes/files.js';
33
- import { registerMarketplaceRoutes } from './routes/marketplace.js';
43
+ import { registerFrameRoutes } from './routes/frames.js';
44
+ import { localPackageLookup, registerMarketplaceRoutes } from './routes/marketplace.js';
45
+ import { registerMediaRoutes } from './routes/media.js';
46
+ import { registerPageRoutes } from './routes/pages.js';
34
47
  import { registerPluginRoutes, saveAfterApply } from './routes/plugins.js';
35
48
  import { registerProxyRoutes } from './routes/proxy.js';
36
- import { registerUploadedFilesRoutes } from './routes/uploaded-files.js';
49
+ import { acceptSessionSocket, SESSION_PATH } from './session-transport.js';
37
50
  import { createWsHeartbeat } from './ws-heartbeat.js';
38
51
  /**
39
52
  * 工具 / 指令 / 工具组 description 约定:首行 = 人类可读摘要,其余 = 给 agent 的长使用指南。
@@ -45,44 +58,76 @@ function descSummary(d) {
45
58
  const s = d.split('\n')[0].trim();
46
59
  return s.length > 200 ? `${s.slice(0, 200)}…` : s;
47
60
  }
61
+ /** 可选项接口的 dependency 查询参数当模型引用读(llm-ref 的取值,JSON):没带为 undefined,写法不对为 null */
62
+ function modelRefOf(raw) {
63
+ if (raw === undefined)
64
+ return undefined;
65
+ let value;
66
+ try {
67
+ value = typeof raw === 'string' ? JSON.parse(raw) : undefined;
68
+ }
69
+ catch {
70
+ return null;
71
+ }
72
+ if (typeof value !== 'object' || value === null || Array.isArray(value))
73
+ return null;
74
+ const { provider, model } = value;
75
+ return (provider === undefined || typeof provider === 'string') && (model === undefined || typeof model === 'string')
76
+ ? { provider, model }
77
+ : null;
78
+ }
48
79
  // ===== 插件元数据 =====
49
80
  const name = '@aalis/plugin-webui-server';
50
- const webuiPages = [
51
- { key: 'dashboard', label: '仪表盘', icon: 'dashboard', order: 10, renderer: 'dashboard' },
52
- { key: 'marketplace', label: '插件市场', icon: 'marketplace', order: 20, renderer: 'marketplace' },
53
- { key: 'plugin-config', label: '插件配置', icon: 'plugin-config', order: 30, renderer: 'plugin-config' },
54
- { key: 'platforms', label: '平台接入', icon: 'platforms', order: 40, renderer: 'platforms' },
55
- { key: 'files', label: '文件管理', icon: 'files', order: 50, renderer: 'files' },
56
- { key: 'logs', label: '日志', icon: 'logs', order: 60, renderer: 'logs' },
57
- ];
81
+ // 按宿主身份记打开记录:插件 bounce 保留,新 App 独立;弱引用不延长宿主寿命。
82
+ const browserOpenedFor = new WeakSet();
83
+ /**
84
+ * fixed 模式的 token 最短长度。持有 token 就是 owner,带 cookie 的请求又不限速(见 auth.ts 的登录限速),
85
+ * 防猜靠的是 token 本身够长:32 个十六进制字符是 128 位
86
+ */
87
+ const FIXED_TOKEN_MIN_LENGTH = 32;
88
+ /** 文档级 CSP */
89
+ const DOCUMENT_CSP = [
90
+ "default-src 'self'",
91
+ "script-src 'self'",
92
+ "style-src 'self' 'unsafe-inline'",
93
+ "img-src 'self' blob: data:",
94
+ "connect-src 'self'",
95
+ "object-src 'none'",
96
+ "base-uri 'self'",
97
+ "frame-ancestors 'none'",
98
+ ].join('; ');
58
99
  const configSchema = defineConfig({
59
- port: { type: 'number', label: '端口', default: 3000, onInvalid: 'error', description: 'Web 管理界面的 HTTP 端口' },
100
+ port: {
101
+ type: 'number',
102
+ label: '端口',
103
+ default: 3000,
104
+ integer: true,
105
+ min: 0,
106
+ max: 65535,
107
+ onInvalid: 'error',
108
+ description: 'Web 管理界面的 HTTP 端口',
109
+ },
60
110
  host: {
61
111
  type: 'string',
62
112
  label: '监听地址',
63
113
  default: '127.0.0.1',
114
+ pattern: '\\S',
64
115
  onInvalid: 'error',
65
116
  description: '绑定的 IP 地址,0.0.0.0 可对外访问',
66
117
  },
67
- fileRoot: {
68
- type: 'string',
69
- label: '文件浏览根',
70
- default: 'workspace',
71
- onInvalid: 'error',
72
- description: '文件管理页面使用的 storage 根 ID,默认 workspace',
73
- },
74
118
  autoOpen: {
75
119
  type: 'boolean',
76
120
  label: '启动时自动打开浏览器',
77
121
  default: true,
78
122
  onInvalid: 'error',
79
- description: '访问 token 为新生成时(persist 模式首次生成、ephemeral 模式每次生成)以含 token 的 URL 自动开启默认浏览器;沿用已有 token(persist 读回已持久化的 token、fixed 用配置的 fixedToken)时不打开;fixed 模式 fixedToken 为空时按 persist 处理。SSH/headless 环境建议关闭',
123
+ description: '每次应用启动后首次监听成功时打开默认浏览器;同一应用内沿用 token 的插件重载不重复打开,重新生成 token 时再次打开以便登录。SSH/headless 环境建议关闭',
80
124
  },
81
125
  tokenMode: {
82
126
  type: 'select',
83
127
  label: 'Token 策略',
84
128
  default: 'persist',
85
129
  onInvalid: 'error',
130
+ secret: false,
86
131
  options: [
87
132
  { label: '每次启动随机生成(重启即轮换)', value: 'ephemeral' },
88
133
  { label: '首次生成后持久化(重启不掉登录)', value: 'persist' },
@@ -95,13 +140,8 @@ const configSchema = defineConfig({
95
140
  label: '固定 Token(仅 tokenMode=fixed 生效)',
96
141
  default: '',
97
142
  onInvalid: 'error',
98
- description: '请使用足够长的随机字符串;配置文件不支持环境变量插值,占位符会被原样当作字面量。',
99
- },
100
- relationGraphDefaultSpacing: {
101
- type: 'number',
102
- label: '关系图默认密度',
103
- default: 120,
104
- description: '关系图(RelationGraph)布局密度的服务器默认值(约等于理想边长 px,建议 60–250;越大越稀疏)。前端每个用户可在图工具栏现场覆盖并保存到本地浏览器;改完此项后,刷新关系图页面或新会话生效。',
143
+ secret: true,
144
+ description: `至少 ${FIXED_TOKEN_MIN_LENGTH} 个字符的随机字符串(去掉首尾空白后计),短了插件不启动;留空按 persist 处理。存进 secrets.yaml,WebUI 不回显。`,
105
145
  },
106
146
  marketplaceRegistry: {
107
147
  type: 'string',
@@ -111,42 +151,65 @@ const configSchema = defineConfig({
111
151
  description: '插件市场检索用的 npm registry 基址。注意 npm 的 search API 并非所有镜像都支持(淘宝等国内源不支持),默认官方源;国内可填支持 search 的镜像或代理。安装走 package-manager(遵循本机 npm 配置),卡片上的最新版与可更新也按安装使用的源查。',
112
152
  },
113
153
  // 活跃前端不在此配置:前端即 `webui-client` 服务的多 provider,活跃者由「服务偏好」
114
- // (servicePreferences['webui-client'])决定——在 WebUI「服务」页的下拉框切换。
154
+ // (servicePreferences['webui-client'])决定——在 WebUI 设置「系统」的服务偏好里切换。
115
155
  });
116
- // ===== WebSocket 消息协议 =====
117
- // 入站消息类型 + 校验 schema 见 ./protocol.ts(zod 强校验)
118
- import { WSIncomingSchema } from './protocol.js';
119
- // ===== 日志文件读取(与 @aalis/runtime 的 file-logger 格式对偶)=====
120
- // 行格式契约(format ↔ parse)由 @aalis/schema-log 的 parseLogLine 唯一持有,此处只负责
121
- // 读取「持久化日志单一数据源」并按 cursor 尾读分页。
122
- //
123
- // 路径(宿主目录布局)是「环境知识」,归 storage 的 logs 根所有;本插件只用 storage
124
- // URI 寻址、对 cwd/落盘位置无知。写入方(runtime/file-logger)跑在 storage 起来之前,
125
- // 是 bootstrap 期的合法 raw-fs 例外,不在此处管辖(默认 logs 根 = data/,与之对偶)。
126
- const LOG_FILE_URI = 'logs:/latest.log';
127
- // 尾读/分页均走 readTailLines 的倒序分块读取——latest.log 可能几百 MB,
128
- // 旧实现每个请求整文件载入+全量解析,日志页一刷服务端就抖一次。
129
- async function readLogFileTail(storage, limit) {
130
- const lines = await readTailLines(storage, LOG_FILE_URI, limit);
131
- return lines.map(parseLogLine).filter((e) => e !== null);
132
- }
133
- async function readLogFileBefore(storage, beforeSeq, limit) {
134
- // seq 随文件位置单调递增:从尾部向前扫,过滤 seq >= before 的尾段后取 limit 条。
135
- // maxBytes 放宽到 16MiB 覆盖较深的向上分页;更深的考古走 data/latest.log 本体。
136
- const lines = await readTailLines(storage, LOG_FILE_URI, limit, {
137
- maxBytes: 16 * 1024 * 1024,
138
- filter: line => {
139
- const e = parseLogLine(line);
140
- return e !== null && e.seq < beforeSeq;
141
- },
156
+ // ===== WebUI 会话的收件箱 =====
157
+ /**
158
+ * 发往 WebUI 自己会话的出站消息里,要由本插件写进会话历史的部分。WebUI 的收件箱就是会话历史,记不记全不取决于
159
+ * 别的插件是否在场。agent 的文字回复由 agent 存档,这里不再记,免得历史里重复。其余部分:
160
+ * - 不是她说的文字(系统提示、指令回复、宿主通知)记成事件标记:回看时显示成分隔条,不进模型上下文,与其他平台一致;
161
+ * - 图片、视频、音频用 outboundMediaMessage 记,回看时还原成附件;
162
+ * - 给的是存储地址的文件(插件发出的成品等)同样用 outboundMediaMessage 记,描述位记文件名,会话服务据此签发下载句柄。
163
+ * metadata 另记原样的地址与文件名(登记的键 SentRef、SentName):占位符里的地址遇到「]」会截断,描述位的「]」「|」换成了全角。这样的文件以
164
+ * 「[文件: 名称 | ref:…]」进模型上下文与记忆,不看全局出站归档记不记文件。不是存储地址的文件
165
+ * (网址、data URL)签不出句柄,记成事件标记「[文件] 名称」,内容不进历史;
166
+ * - 快照里的 data 未经平台改写,就是页面上显示的那份,直接记它。带 skipArchive 的(history_ref 重发)原图已在
167
+ * 历史里,不再记。她发出的媒体在别的平台由全局出站归档在 outbound:delivered 上记;发往 WebUI 的由这里记,
168
+ * outbound:delivered 的快照给附件标上 skipArchive,免得全局出站归档再记一份。代价是这些网址图片与视频不再由
169
+ * 全局出站归档在后台补视觉描述
170
+ */
171
+ function inboxRecords(msg) {
172
+ const timestamp = Date.now();
173
+ const marker = (content) => ({
174
+ role: 'system',
175
+ kind: WellKnownKinds.EventMarker,
176
+ content,
177
+ timestamp,
142
178
  });
143
- return lines.map(parseLogLine).filter((e) => e !== null && e.seq < beforeSeq);
179
+ const records = [];
180
+ if (msg.source !== 'agent' && msg.content.trim())
181
+ records.push(marker(msg.content));
182
+ for (const att of msg.attachments ?? []) {
183
+ if (att.skipArchive)
184
+ continue;
185
+ if (att.kind !== 'file') {
186
+ records.push({
187
+ ...outboundMediaMessage(att.kind, att.data, att.description),
188
+ timestamp,
189
+ metadata: { [WellKnownMetadataKeys.Source]: 'webui-server', [WellKnownMetadataKeys.SentRef]: att.data },
190
+ });
191
+ }
192
+ else if (isStorageUri(att.data)) {
193
+ records.push({
194
+ ...outboundMediaMessage('file', att.data, att.name),
195
+ timestamp,
196
+ metadata: {
197
+ [WellKnownMetadataKeys.Source]: 'webui-server',
198
+ [WellKnownMetadataKeys.SentRef]: att.data,
199
+ [WellKnownMetadataKeys.SentName]: att.name,
200
+ },
201
+ });
202
+ }
203
+ else {
204
+ records.push(marker(`[文件] ${att.name ?? att.data.split('/').pop()}`));
205
+ }
206
+ }
207
+ return records;
144
208
  }
145
209
  // ===== 插件入口 =====
146
210
  /**
147
- * 本地包扫描目录:项目根的 `packages/` 与 `node_modules/@aalis`。
148
- *
149
- * 两者都以 **cwd(项目根)** 为基准——与 `projectRoot()`、node_modules 加载器的默认值同源。
211
+ * 本地包扫描目录:安装集(host-paths 的 `app`)下的 `packages/` 与 `node_modules/@aalis`,与插件加载器读的是同一处。
212
+ * 宿主不提供 host-paths 时没有可扫的目录。
150
213
  *
151
214
  * **不可按本文件位置推算**(如 `resolve(dirname(import.meta.url), '../../')`):那是个位置
152
215
  * 假设,只在 webui-server 恰好躺在 `<根>/packages/<包>/dist` 时成立。装进用户项目后它解析成
@@ -154,27 +217,12 @@ async function readLogFileBefore(storage, beforeSeq, limit) {
154
217
  * `packages/` 从来不在范围内** —— 「私有插件放 packages/、第三方插件从 npm 装」这种混合
155
218
  * 形态下,本地插件在市场里将不可见。
156
219
  */
157
- const LOCAL_SCAN_DIRS = [resolve(process.cwd(), 'packages'), resolve(process.cwd(), 'node_modules/@aalis')];
158
- /**
159
- * 会话归属的平台名:取 sessionId 首个 `:` 前的前缀,命中**已注册平台名**才采信,否则 `'webui'`。
160
- *
161
- * WebUI 是管理面,订阅的会话可以属于任何平台(`onebot:123`)。它发 `token:request` 时若
162
- * 一律报 `'webui'`,消费方(plugin-agent)就按 webui 档解析模型与会话配置,把别人的预算
163
- * 快照算进错的上下文窗口。前缀不认识时才落回 `'webui'`——CLI 的 `cli-default` 这类不带
164
- * 前缀的会话,以及 webui 自己的会话,都归在这一档。
165
- *
166
- * 纯函数:平台名集合由调用方从 `getPlatformNames(platform)` 取(adapter 可热插拔,每次现取)。
167
- */
168
- export function resolveSessionPlatform(sessionId, known) {
169
- const idx = sessionId.indexOf(':');
170
- if (idx <= 0)
171
- return 'webui';
172
- const prefix = sessionId.slice(0, idx);
173
- return known.has(prefix) ? prefix : 'webui';
220
+ function localScanDirs(appDir) {
221
+ return appDir ? [join(appDir, 'packages'), join(appDir, 'node_modules', '@aalis')] : [];
174
222
  }
175
223
  // storage 等一律 optional:webui 是管理面,任何被管对象缺席都只该让对应页面降级,不该把
176
- // 整个控制台拖进 pending。storage 尤其不能 required——plugin-storage-local bounce 会让它
177
- // 暂时消失,webui-server 跟着重启就清空 registeredPages,其他插件的侧边栏页面再也回不来。
224
+ // 整个控制台拖进 pending。storage 尤其不能 required——storage 的提供者 bounce 会让它
225
+ // 暂时消失,webui-server 跟着重启会断开所有连接、清空页面登记表(登记门面随后重挂)。
178
226
  const uses = {
179
227
  events,
180
228
  logger,
@@ -183,21 +231,30 @@ const uses = {
183
231
  provide,
184
232
  services,
185
233
  hostConfig: optional(hostConfig),
234
+ /** 日志页的历史(/api/logs/*)与 /admin 上的实时推送 */
235
+ logs: optional(hostLogs),
236
+ paths: optional(hostPaths),
186
237
  app: optional(appService),
187
238
  plugins: optional(pluginsService),
188
239
  source: optional(pluginSource),
189
240
  packageManager: optional(packageManager),
190
241
  storage: optional(storage),
191
- authority: optional(authority),
192
242
  commands: optional(commands),
193
243
  // platform 既被本插件提供又被平台页枚举:required 会让激活等一个由自己提供的服务
194
244
  platform: optional(platform),
195
245
  process: optional(processService),
196
- sessionConfirm: optional(sessionConfirm),
246
+ /** /session 的连接接到它;每条连接到达时现取,不在时关闭连接并写明原因。会话文件的下载请它核验句柄 */
247
+ sessionProtocol: optional(sessionProtocol),
248
+ /** 登记 webui 审批面:/session 连接上的在场上报与应答记在这个登记身份名下。推出未结的审批进「需要处理」 */
249
+ approvals: optional(approvals),
250
+ /** 会话面板:调用前核会话存在、按会话种类判断适用的页面 */
251
+ sessions: optional(sessionManager),
197
252
  tools: optional(tools),
198
253
  llm: optional(llm),
199
- persona: optional(persona),
200
- agent: optional(agent),
254
+ /** 插件经贡献点 webui:theme 提供的主题(GET /api/themes) */
255
+ contributions: optional(contributions),
256
+ // 发往 WebUI 自己会话的出站消息由本插件写进会话历史(见 inboxRecords)
257
+ archive: optional(messageArchive),
201
258
  };
202
259
  export default definePlugin({
203
260
  name,
@@ -210,29 +267,27 @@ export default definePlugin({
210
267
  });
211
268
  async function startWebuiServer(caps) {
212
269
  const { events, logger, lifecycle, provide, services } = caps;
213
- const cfg = parseConfig(configSchema, caps.config, logger);
270
+ const cfg = parseConfig(configSchema, caps.config.use, logger.use);
214
271
  const uiConfig = {
215
272
  ...cfg,
216
- fileRoot: cfg.fileRoot || configSchema.fileRoot.default,
217
273
  marketplaceRegistry: cfg.marketplaceRegistry.trim() || configSchema.marketplaceRegistry.default,
218
274
  };
219
- if (!uiConfig.host.trim())
220
- throw configError('host 不能为空');
221
- if (!Number.isInteger(uiConfig.port) || uiConfig.port < 0 || uiConfig.port > 65535) {
222
- throw configError('port 必须是 0-65535 的整数');
223
- }
224
275
  if (!URL.canParse(uiConfig.marketplaceRegistry))
225
276
  throw configError('marketplaceRegistry 不是有效 URL');
226
- // 创建 storage gateway;所有文件读写(token、access、文件管理)都走这里
227
- const storage = createStorageGateway(caps.storage);
277
+ // 创建 storage gateway;所有文件读写(token、访问凭据、媒体与会话文件)都走这里
278
+ const storage = caps.storage.use;
228
279
  // ---- Token 解析 ----
229
280
  // - ephemeral:每次启动随机生成(旧行为)
230
281
  // - persist:首次生成后写入 storage data:/webui/token,重启复用(默认)
231
- // - fixed:使用 fixedToken 配置项;为空时降级为 persist
282
+ // - fixed:使用 fixedToken 配置项,至少 FIXED_TOKEN_MIN_LENGTH 个字符;为空时降级为 persist
232
283
  // 所有模式都会写出 data:/webui/access.txt 便于查找访问 URL
284
+ // 两个文件都是凭据,按凭据写(只给本机用户读,0600)
233
285
  const tokenFileUri = 'data:/webui/token';
234
286
  const accessFileUri = 'data:/webui/access.txt';
235
287
  const fixedToken = uiConfig.tokenMode === 'fixed' ? uiConfig.fixedToken.trim() : '';
288
+ if (fixedToken && fixedToken.length < FIXED_TOKEN_MIN_LENGTH) {
289
+ throw configError(`fixedToken 至少要 ${FIXED_TOKEN_MIN_LENGTH} 个字符(现在 ${fixedToken.length} 个)`);
290
+ }
236
291
  let authToken = fixedToken || Buffer.from(crypto.getRandomValues(new Uint8Array(24))).toString('hex');
237
292
  /** 监听成功后的访问 URL;持久化 token 晚于监听读回时,据此重写 access.txt */
238
293
  let listeningUrl;
@@ -250,11 +305,11 @@ async function startWebuiServer(caps) {
250
305
  '# 提示:将该一键登录链接粘贴到浏览器即可自动设置 cookie',
251
306
  ];
252
307
  try {
253
- await storage.writeFile(accessFileUri, `${lines.join('\n')}\n`);
308
+ await storage.writeFile(accessFileUri, `${lines.join('\n')}\n`, { secret: true });
254
309
  return true;
255
310
  }
256
311
  catch (err) {
257
- logger.warn(`写入访问文件失败: ${err.message}`);
312
+ logger.use.warn(`写入访问文件失败: ${err.message}`);
258
313
  return false;
259
314
  }
260
315
  }
@@ -264,8 +319,7 @@ async function startWebuiServer(caps) {
264
319
  // follow 对已在线的 storage 同步首挂,晚上线时补读。持久化的那份优先(浏览器里 30 天的 cookie
265
320
  // 就是它);只认第一次成功,之后 storage 换人不再换 token。
266
321
  let tokenSettled = uiConfig.tokenMode === 'ephemeral' || fixedToken !== '';
267
- // autoOpen 只在 token 是本次新生成时打开浏览器:沿用已有 token(fixed、persist 读回)时浏览器里的 cookie 仍有效,
268
- // 再开一页只是重复。app:ready 是粘性事件,本插件每次 bounce 都会重新收到,不按此判断就每次重载多开一个标签页
322
+ // token 重新生成后旧页面失效,即使同一 App 已打开过,也要给出新的登录页。
269
323
  let tokenFresh = fixedToken === '';
270
324
  let tokenLoading;
271
325
  caps.storage.follow(() => {
@@ -283,6 +337,10 @@ async function startWebuiServer(caps) {
283
337
  if (listeningUrl)
284
338
  void writeAccessFile(listeningUrl, authToken);
285
339
  }
340
+ // 读回的照样按凭据重写一遍:文件原先的权限(别处拷来、按默认权限写下的)一律收紧
341
+ await storage
342
+ .writeFile(tokenFileUri, existing, { secret: true })
343
+ .catch((err) => logger.use.warn(`token 文件没能按凭据重写,别的系统用户可能读得到它: ${err.message}`));
286
344
  return;
287
345
  }
288
346
  }
@@ -290,11 +348,11 @@ async function startWebuiServer(caps) {
290
348
  /* 不存在或读不了 */
291
349
  }
292
350
  try {
293
- await storage.writeFile(tokenFileUri, authToken);
351
+ await storage.writeFile(tokenFileUri, authToken, { secret: true });
294
352
  tokenSettled = true;
295
353
  }
296
354
  catch (err) {
297
- logger.warn(`持久化 token 失败,本次仍可使用但重启会再生成: ${err.message}`);
355
+ logger.use.warn(`持久化 token 失败,本次仍可使用但重启会再生成: ${err.message}`);
298
356
  }
299
357
  })();
300
358
  });
@@ -303,43 +361,55 @@ async function startWebuiServer(caps) {
303
361
  await tokenLoading;
304
362
  const auth = createAuthSystem(() => authToken);
305
363
  const expressApp = express();
364
+ // 文档级 CSP:脚本只许同源,样式放开内联(Mermaid 的输出带内联样式),图片只认同源与本地生成的;不许被别的页面嵌入。
365
+ // 每个响应都带上,前端的 HTML、登录页与恢复页都在其中;媒体路由另设更严的沙箱,插件前端的文件另设框架 CSP
366
+ expressApp.use((_req, res, next) => {
367
+ res.setHeader('Content-Security-Policy', DOCUMENT_CSP);
368
+ next();
369
+ });
370
+ // 插件前端的文件:注册在认证之前,凭页面登记时的 token 取,不看 cookie
371
+ registerFrameRoutes(expressApp, token => pages.frameDir(token));
306
372
  expressApp.use(express.json({ limit: '10mb' }));
307
- expressApp.use(auth.middleware);
373
+ auth.install(expressApp);
308
374
  // REST 路由 owner 闸(连接级认证之上的显式 owner 复核,见 gate.ts)。
309
375
  // 当前身份 / 登录态查询统一走 auth.ts 的 GET /api/auth/status(含 identity)。
310
376
  const gate = createRouteGate(auth.identify);
311
377
  const server = createServer(expressApp);
378
+ // server.close() 只停止 accept 并关掉此刻闲着的连接;拆卸时还在处理请求的 keep-alive 连接(例如重载自己的那次)
379
+ // 应答完会留下来,后面的请求照样落到已拆卸的旧实例。拆卸之后每次应答完就关掉闲下来的连接,浏览器改开新连接
380
+ server.on('request', (_req, res) => res.on('finish', () => {
381
+ if (!server.listening)
382
+ server.closeIdleConnections();
383
+ }));
384
+ // 一个 WebSocketServer 接全部端点,连接按路径分给各自的处理,来源校验与认证对各端点一样。同一个 HTTP server 上
385
+ // 挂两个带 path 的 WebSocketServer 不行:先挂的那个会把别的路径的升级请求按 400 拒掉。
386
+ const wsPaths = new Set([SESSION_PATH, ADMIN_PATH]);
387
+ const wsPath = (req) => (req.url ?? '').split('?')[0];
388
+ const approvalSurface = caps.approvals.use.registerSurface('webui');
312
389
  const wss = new WebSocketServer({
313
390
  server,
314
- path: '/ws',
315
391
  verifyClient: (info, cb) => {
316
- if (auth.verifyWsClient(info.req))
317
- cb(true);
318
- else
392
+ if (!originAllowed(info.req))
393
+ cb(false, 403, 'origin not allowed');
394
+ else if (!auth.verifyWsClient(info.req))
319
395
  cb(false, 401, 'unauthenticated');
396
+ else if (!wsPaths.has(wsPath(info.req)))
397
+ cb(false, 404);
398
+ else
399
+ cb(true);
320
400
  },
321
401
  });
322
- const sessions = new Map();
323
- const logSubscribers = new Set();
324
- const allClients = new Set();
402
+ // 日志按插件筛时用登记表分开后缀实例与插件分出的子作用域
403
+ const matchLogs = logMatcher(instance => (caps.plugins.present ? caps.plugins.use.getPlugin(instance) : undefined) !== undefined);
404
+ const admin = createAdminChannel(logger.use, matchLogs);
405
+ /** 打开着的 /session 连接:webui 平台的连接状态按它数 */
406
+ const sessionSockets = new Set();
325
407
  // 半开连接回收:清理只挂 'close' 时,睡眠/断网的客户端永远等不到事件,
326
408
  // 日志广播持续往死连接的发送缓冲堆数据。详见 ws-heartbeat.ts。
327
409
  const heartbeat = createWsHeartbeat({
328
- onStale: ws => logger.debug(`WebUI 客户端心跳超时,回收半开连接 (bufferedAmount=${ws.bufferedAmount})`),
329
- onError: (_ws, err) => logger.warn(`WebUI 客户端连接错误: ${err.message}`),
410
+ onStale: ws => logger.use.debug(`WebUI 客户端心跳超时,回收半开连接 (bufferedAmount=${ws.bufferedAmount})`),
411
+ onError: (_ws, err) => logger.use.warn(`WebUI 客户端连接错误: ${err.message}`),
330
412
  });
331
- const streamBuffers = new Map();
332
- // 延迟清理 streamBuffers:捕获要删的 buf 引用,10s 后仅当 (a) 该 session 仍是
333
- // 同一个 buf 且 (b) 未重新进入生成态 时才删——防止 10s 内开始的新一轮生成被旧
334
- // 定时器误删(审计 HIGH #8 竞态)。
335
- const scheduleBufferCleanup = (sid, captured) => {
336
- setTimeout(() => {
337
- if (streamBuffers.get(sid) === captured && !captured.generating)
338
- streamBuffers.delete(sid);
339
- }, 10_000);
340
- };
341
- // Token 用量缓存:记录每个 session 最近一次的 token 用量,用于刷新/切换会话后立即展示
342
- const tokenUsageCache = new Map();
343
413
  // 前端静态文件托管:前端包不是插件(无 apply,纯静态资源),托管权在本插件——
344
414
  // app:ready 时由 client-discovery 按 `aalis.client` 标记发现候选,本插件把每个候选注册成
345
415
  // 一条 webui-client 服务 provider,再由服务解析(偏好 > 优先级 > 注册顺序)定出活跃前端。
@@ -348,11 +418,11 @@ async function startWebuiServer(caps) {
348
418
  function mountStaticDir(dir) {
349
419
  if (existsSync(dir)) {
350
420
  staticMiddleware = express.static(dir);
351
- logger.info(`前端静态目录: ${dir}`);
421
+ logger.use.info(`前端静态目录: ${dir}`);
352
422
  }
353
423
  else {
354
424
  staticMiddleware = null;
355
- logger.warn(`前端目录不存在: ${dir}`);
425
+ logger.use.warn(`前端目录不存在: ${dir}`);
356
426
  }
357
427
  }
358
428
  // 已发现的前端候选(app:ready 时填充,逐个注册为 webui-client 服务 provider)。
@@ -367,11 +437,12 @@ async function startWebuiServer(caps) {
367
437
  const clientProviderDisposers = new Map();
368
438
  /** 当前活跃前端目录 = 解析后的 webui-client 服务(servicePreferences 偏好 > 优先级 > 注册顺序)。 */
369
439
  function currentClientDir() {
370
- return services.get(webuiClient)?.getClientDir?.();
440
+ // 动态查而不写进 uses:条目多由本插件代前端包登记,声明它会让本插件的按插件偏好也改到选择,静态目录的重挂只跟全局偏好
441
+ return services.use.get(webuiClient)?.getClientDir?.();
371
442
  }
372
443
  /**
373
- * 切换 webui-client 服务偏好后调用:重挂当前活跃前端 + 广播 'reload'——所有客户端进入
374
- * 「正在切换前端,即将刷新」遮罩后自动 reload(复用现有刷新流程,平滑换前端、不重启进程)。
444
+ * 活跃前端可能变了时调用(改或清除 webui-client 的服务偏好、摘掉已不在的前端包):重挂当前活跃前端,
445
+ * 经 /admin 推 client.reload 让页面整页重载,换前端不重启进程。
375
446
  */
376
447
  function remountActiveClient() {
377
448
  const dir = currentClientDir();
@@ -379,11 +450,7 @@ async function startWebuiServer(caps) {
379
450
  clientDist = dir;
380
451
  mountStaticDir(dir);
381
452
  }
382
- const payload = { type: 'reload' };
383
- const json = JSON.stringify(payload);
384
- for (const ws of allClients)
385
- if (ws.readyState === WebSocket.OPEN)
386
- ws.send(json);
453
+ admin.send({ type: 'client.reload' });
387
454
  }
388
455
  // 动态静态文件中间件(支持运行时切换前端)
389
456
  expressApp.use((req, res, next) => {
@@ -395,35 +462,6 @@ async function startWebuiServer(caps) {
395
462
  }
396
463
  });
397
464
  // ---------- REST API ----------
398
- // 获取系统状态
399
- expressApp.get('/api/status', gate(), (_req, res) => {
400
- const personaSvc = caps.persona.current;
401
- // 判断上传能力。media / file-reader 在本插件里只做在场探测,为一个布尔值反向依赖
402
- // 它们的包不值当(file-reader 还是插件包),故按名动态查——动态查到的不算声明依赖。
403
- const hasMedia = services.get('media') !== undefined;
404
- const llmHasVision = listLLMModels(caps.llm).some(e => e.instance.capabilities.includes('vision'));
405
- const hasFileReader = services.get('file-reader') !== undefined;
406
- const appName = caps.hostConfig.current?.get('name') ?? 'Aalis';
407
- res.json({
408
- /** 对话对象的显示名:装有人设时是人设名,否则同 appName */
409
- name: personaSvc?.getPersonaName() ?? appName,
410
- /** 全局配置里的应用名称,每次实时读配置文档 */
411
- appName,
412
- /** 上传能力:客户端据此决定显示哪些上传按钮 */
413
- uploadCapabilities: {
414
- /** 是否支持图片上传(media 可用 或 LLM 声明了 vision) */
415
- image: hasMedia || llmHasVision,
416
- /** 是否支持文件上传(file-reader 可用) */
417
- file: hasFileReader,
418
- },
419
- tools: caps.tools.current?.getAll().map(t => t.name) ?? [],
420
- commands: caps.commands.current?.getAll().map(c => ({
421
- name: c.name,
422
- description: descSummary(c.description),
423
- visibility: c.visibility,
424
- })),
425
- });
426
- });
427
465
  // ---------- 插件管理 + 全局配置 ----------
428
466
  // fs 扫描 env:discoverClients(app:ready 时)与市场「已装」兜底共用一份,避免两处重复。
429
467
  const fsScanEnv = {
@@ -446,8 +484,10 @@ async function startWebuiServer(caps) {
446
484
  },
447
485
  join: (...parts) => resolve(...parts),
448
486
  };
449
- // 页面动作登记表:插件经 webui-server 的绑定接口登记,路由层按「插件 id + 方法名」查
450
- const registeredActions = new Map();
487
+ // 市场「已装」判定、依赖图、插件列表的版本与前端候选发现共用这一份扫描目录(pnpm 工作区下 require.resolve
488
+ // 从仓库根解析不到工作区包,只能扫盘)。每请求懒扫,量小。host-paths 由宿主在根上提供,不会中途换。
489
+ const appDir = caps.paths.present ? caps.paths.use.app : undefined;
490
+ const scanDirs = localScanDirs(appDir);
451
491
  registerPluginRoutes(expressApp, {
452
492
  app: caps.app,
453
493
  plugins: caps.plugins,
@@ -455,48 +495,87 @@ async function startWebuiServer(caps) {
455
495
  hostConfig: caps.hostConfig,
456
496
  tools: caps.tools,
457
497
  commands: caps.commands,
458
- webui: () => services.get(webuiServer),
459
- logger,
460
- }, auth.identify, gate, (plugin, method) => registeredActions.get(plugin)?.get(method));
461
- // 市场「已装」判定、依赖图、前端候选发现共用这一份扫描目录(pnpm 工作区下 require.resolve
462
- // 从仓库根解析不到工作区包,只能扫盘)。每请求懒扫,量小。
463
- const localScanDirs = LOCAL_SCAN_DIRS;
498
+ services: services.use,
499
+ self: lifecycle.use.id,
500
+ home: caps.paths.present ? caps.paths.use.home : undefined,
501
+ versions: () => {
502
+ if (!appDir)
503
+ return () => undefined;
504
+ const lookup = localPackageLookup(appDir, collectLocalPackageDeps(scanDirs, fsScanEnv));
505
+ return name => lookup(name)?.version;
506
+ },
507
+ logger: logger.use,
508
+ }, gate);
509
+ // 页面:插件经 webui-server 的绑定接口登记,页面的数据源与动作、插件前端的文件、会话面板与布局记录的路由按这张登记表分派
510
+ const pageFiles = createPageFiles(() => (caps.storage.present ? storage.listRoots() : []));
511
+ const pages = createPageRegistry({
512
+ logger: logger.use,
513
+ send: event => admin.send(event),
514
+ sessions: caps.sessions,
515
+ signFile: pageFiles.sign,
516
+ });
517
+ registerPageRoutes(expressApp, {
518
+ registry: pages,
519
+ layout: createLayoutStore(storage.own, logger.use),
520
+ storageReady: () => caps.storage.present,
521
+ send: event => admin.send(event),
522
+ logger: logger.use,
523
+ }, auth.identify, gate);
464
524
  registerMarketplaceRoutes(expressApp, {
465
- logger,
525
+ logger: logger.use,
466
526
  plugins: caps.plugins,
467
- services,
527
+ services: services.use,
468
528
  packageManager: caps.packageManager,
469
- }, gate, uiConfig.marketplaceRegistry, () => collectLocalPackageDeps(localScanDirs, fsScanEnv), () => discoverAndProvideClients());
470
- // 历史日志从 data/latest.log 读(lazy load)。
471
- // 单进程内 LogHub 不再缓存 buffer——历史以文件为单一数据源。
472
- // 尾部 N 条(首屏 / 显式刷新)
473
- expressApp.get('/api/logs/tail', gate(), async (req, res) => {
474
- const limit = Math.min(Math.max(Number(req.query.limit) || 200, 1), 5000);
475
- try {
476
- res.json(await readLogFileTail(storage, limit));
529
+ appDir,
530
+ }, gate, uiConfig.marketplaceRegistry, () => collectLocalPackageDeps(scanDirs, fsScanEnv), () => discoverAndProvideClients());
531
+ // 「需要处理」:推出未结的审批、插件故障与提示,界面的首页摘要、插件详情与标签页标题都读这一个列表
532
+ const attention = createAttention({
533
+ approvals: () => (caps.approvals.present ? caps.approvals.use.list() : []),
534
+ sessions: caps.sessions,
535
+ plugins: () => {
536
+ const pm = caps.plugins.present ? caps.plugins.use : undefined;
537
+ return pm
538
+ ? blockedStatus({
539
+ plugins: pm,
540
+ services: services.use,
541
+ doc: caps.hostConfig.present ? caps.hostConfig.use : undefined,
542
+ })
543
+ : [];
544
+ },
545
+ notices: pages.notices,
546
+ send: event => admin.send(event),
547
+ });
548
+ route(expressApp, 'GET /api/attention', gate(), (_req, res) => {
549
+ res.json({ items: attention.items() });
550
+ });
551
+ /** 按查询参数里的筛选读日志的历史回给前端,给了 before 时往前翻;筛选写法不对回 400 */
552
+ const sendLogs = async (query, res, before) => {
553
+ const filter = parseLogFilter(query);
554
+ if (!filter) {
555
+ res.status(400).json({ error: '日志筛选写法不对:level 只收 debug、info、warn、error,每一项最多给一次' });
556
+ return;
477
557
  }
478
- catch (err) {
479
- res.status(500).json({ error: err.message });
558
+ if (!caps.logs.present) {
559
+ res.status(503).json({ error: '宿主未提供日志服务(host-logs),读不到日志' });
560
+ return;
480
561
  }
481
- });
482
- // 历史分页:返回 seq < before 的最近 limit 条(向上滚动加载更早)
483
- expressApp.get('/api/logs/range', gate(), async (req, res) => {
562
+ const limit = Math.min(Math.max(Number(query.limit) || 200, 1), 5000);
563
+ res.json(await caps.logs.use.recent(limit, { before, match: matchLogs(filter) }));
564
+ };
565
+ // 日志的历史:最后 limit 条
566
+ route(expressApp, 'GET /api/logs/tail', gate(), (req, res) => sendLogs(req.query, res));
567
+ // 往前翻:seq 比 before 小的最后 limit 条
568
+ route(expressApp, 'GET /api/logs/range', gate(), (req, res) => {
484
569
  const before = Number(req.query.before);
485
- const limit = Math.min(Math.max(Number(req.query.limit) || 200, 1), 5000);
486
570
  if (!Number.isFinite(before)) {
487
- res.status(400).json({ error: 'before query param required' });
571
+ res.status(400).json({ error: '往前翻日志要给数字参数 before(取 seq 比它小的那几条)' });
488
572
  return;
489
573
  }
490
- try {
491
- res.json(await readLogFileBefore(storage, before, limit));
492
- }
493
- catch (err) {
494
- res.status(500).json({ error: err.message });
495
- }
574
+ return sendLogs(req.query, res, before);
496
575
  });
497
576
  // 获取服务列表(含提供者信息)
498
- expressApp.get('/api/services', gate(), async (_req, res) => {
499
- const pluginMgr = caps.plugins.current;
577
+ route(expressApp, 'GET /api/services', gate(), async (_req, res) => {
578
+ const pluginMgr = caps.plugins.present ? caps.plugins.use : undefined;
500
579
  const pluginStatus = pluginMgr ? pluginMgr.getStatus() : [];
501
580
  const displayNameMap = new Map();
502
581
  for (const p of pluginStatus) {
@@ -508,11 +587,11 @@ async function startWebuiServer(caps) {
508
587
  // 根激活归宿主:core 的内置服务与宿主登记的 host-config / plugin-source 都在这里(与市场依赖图同一标签)。
509
588
  // 别把内部 id 'root' 裸露给用户。
510
589
  displayNameMap.set('root', '宿主');
511
- const serviceNames = services.names();
590
+ const serviceNames = services.use.names();
512
591
  const detail = {};
513
592
  for (const svcName of serviceNames) {
514
593
  // 枚举已按「偏好 > 优先级 > 注册顺序」排序,附带 priority 字段;只取登记元数据,不取实例
515
- const entries = services.inspect(svcName);
594
+ const entries = services.use.inspect(svcName);
516
595
  detail[svcName] = {
517
596
  providers: entries.map(e => ({
518
597
  contextId: e.contextId,
@@ -521,210 +600,107 @@ async function startWebuiServer(caps) {
521
600
  priority: e.priority,
522
601
  exclusive: e.exclusive,
523
602
  })),
524
- preferred: services.preferred(svcName) ?? null,
603
+ preferred: services.use.preferred(svcName) ?? null,
525
604
  };
526
605
  }
527
606
  res.json({ services: detail });
528
607
  });
529
608
  /**
530
- * 设置服务偏好。body: { contextId: string }
531
- * 偏好语义:`preferred > priority > 注册顺序`。持久化到 aalis.config.yaml 的 servicePreferences。
609
+ * 改服务偏好,contextId 为 null 即清除:scope 不给 `for` 是全局偏好,给了是只对那个插件实例的本插件偏好。
610
+ * 挑选规则:本插件偏好 > 全局偏好 > priority > 注册顺序。偏好要跨重启保留就得写进文档(servicePreferences /
611
+ * pluginServicePreferences),宿主没提供文档时不动运行态
532
612
  */
533
- expressApp.post('/api/services/:name/prefer', gate(), async (req, res) => {
534
- const svcName = String(req.params.name);
535
- const contextId = String(req.body?.contextId ?? '').trim();
536
- if (!contextId) {
537
- res.status(400).json({ ok: false, error: 'contextId required' });
613
+ const changePreference = async (svcName, contextId, scope, res) => {
614
+ if (contextId !== null && !services.use.inspect(svcName).some(e => e.contextId === contextId)) {
615
+ res.status(404).json({ error: `服务 ${svcName} 没有 id 为 ${contextId} 的提供者` });
538
616
  return;
539
617
  }
540
- // 校验 entry 存在
541
- const entries = services.inspect(svcName);
542
- if (!entries.some(e => e.contextId === contextId)) {
543
- res.status(404).json({ ok: false, error: `service "${svcName}" has no provider with contextId "${contextId}"` });
618
+ if (!caps.hostConfig.present) {
619
+ res.status(503).json({ error: '宿主未提供配置文档(host-config),无法保存偏好' });
544
620
  return;
545
621
  }
546
- // 偏好要跨重启保留就得写进文档:宿主没提供文档时不动运行态
547
- const host = caps.hostConfig.current;
548
- if (!host) {
549
- res.status(503).json({ ok: false, error: '宿主未提供配置文档(host-config),无法保存偏好' });
550
- return;
622
+ const host = caps.hostConfig.use;
623
+ if (contextId === null) {
624
+ services.use.unprefer(svcName, scope);
625
+ host.removeServicePreference(svcName, scope);
551
626
  }
552
- if (!services.prefer(svcName, contextId)) {
553
- res.status(409).json({ ok: false, error: `service "${svcName}" rejected provider preference "${contextId}"` });
554
- return;
627
+ else {
628
+ services.use.prefer(svcName, contextId, scope);
629
+ host.setServicePreference(svcName, contextId, scope);
555
630
  }
556
- host.setServicePreference(svcName, contextId);
557
- // 切换前端:webui-client 是「前端」服务,偏好变更需重挂静态目录 + 通知客户端刷新。
631
+ // 切换前端:webui-client 是「前端」服务,全局偏好变更需重挂静态目录 + 通知客户端刷新。
558
632
  // 重挂与偏好同属内存态,必须在等落盘之前一起生效:save 拒绝时才不会留下「解析选 B、静态挂 A」。
559
- if (svcName === 'webui-client')
633
+ if (svcName === 'webui-client' && scope.for === undefined)
560
634
  remountActiveClient();
561
- if (!(await saveAfterApply(host, res, 'restart')))
635
+ if (!(await saveAfterApply(host, res, 'reload')))
562
636
  return;
563
637
  res.json({ ok: true });
638
+ };
639
+ const preferredIn = (body) => String(body?.contextId ?? '').trim();
640
+ route(expressApp, 'POST /api/services/:name/prefer', gate(), async (req, res) => {
641
+ const contextId = preferredIn(req.body);
642
+ if (!contextId) {
643
+ res.status(400).json({ error: '请求体要给 contextId:偏好的那个提供者的 id' });
644
+ return;
645
+ }
646
+ await changePreference(String(req.params.name), contextId, {}, res);
564
647
  });
565
- /** 清除服务偏好 */
566
- expressApp.delete('/api/services/:name/prefer', gate(), async (req, res) => {
567
- const svcName = String(req.params.name);
568
- const host = caps.hostConfig.current;
569
- if (!host) {
570
- res.status(503).json({ ok: false, error: '宿主未提供配置文档(host-config),无法保存偏好' });
648
+ route(expressApp, 'DELETE /api/services/:name/prefer', gate(), (req, res) => changePreference(String(req.params.name), null, {}, res));
649
+ route(expressApp, 'POST /api/plugins/:instanceId/services/:name/prefer', gate(), async (req, res) => {
650
+ const contextId = preferredIn(req.body);
651
+ if (!contextId) {
652
+ res.status(400).json({ error: '请求体要给 contextId:偏好的那个提供者的 id' });
571
653
  return;
572
654
  }
573
- services.unprefer(svcName);
574
- host.removeServicePreference(svcName);
575
- if (svcName === 'webui-client')
576
- remountActiveClient();
577
- if (!(await saveAfterApply(host, res, 'restart')))
655
+ const instanceId = String(req.params.instanceId);
656
+ if (!caps.plugins.present || !caps.plugins.use.getPlugin(instanceId)) {
657
+ res.status(404).json({ error: `插件 ${instanceId} 不存在` });
578
658
  return;
579
- res.json({ ok: true });
659
+ }
660
+ await changePreference(String(req.params.name), contextId, { for: instanceId }, res);
661
+ });
662
+ route(expressApp, 'DELETE /api/plugins/:instanceId/services/:name/prefer', gate(), (req, res) => changePreference(String(req.params.name), null, { for: String(req.params.instanceId) }, res));
663
+ // 插件经贡献点提供的主题:与导入的主题同一套校验,不合格式的不列出、每份告警一次。插件停用时贡献随之撤回,
664
+ // 界面收到 plugins.changed 后重取。贡献点服务不在时没有插件主题
665
+ const warnedThemes = new WeakSet();
666
+ route(expressApp, 'GET /api/themes', gate(), (_req, res) => {
667
+ const themes = [];
668
+ for (const { key, spec } of caps.contributions.present ? caps.contributions.use.collect('webui:theme') : []) {
669
+ const parsed = parseTheme(spec.theme);
670
+ if (parsed.ok)
671
+ themes.push({ id: key, instance: key.slice(0, key.lastIndexOf('/')), theme: parsed.theme });
672
+ else if (!warnedThemes.has(spec)) {
673
+ warnedThemes.add(spec);
674
+ logger.use.warn(`插件主题 ${key} 不合主题文件的格式,没有列出:${parsed.errors.join(';')}`);
675
+ }
676
+ }
677
+ res.json({ themes });
580
678
  });
581
679
  // 前端切换已统一到「服务偏好」:前端是 webui-client 服务的多 provider,切换走
582
680
  // POST /api/services/webui-client/prefer(见上),webui-server 在该偏好变更时重挂 + 广播刷新。
583
681
  // 故不再有独立的 /api/clients* 路由。
584
682
  // 获取所有平台适配器及其连接状态
585
- expressApp.get('/api/platforms', gate(), (_req, res) => {
683
+ route(expressApp, 'GET /api/platforms', gate(), (_req, res) => {
586
684
  res.json({ platforms: aggregatePlatformDetails(caps.platform) });
587
685
  });
588
- // 获取已注册的工具分组(含元数据 + 各组工具数量 + 贡献插件列表)
589
- expressApp.get('/api/tool-groups', gate(), (_req, res) => {
590
- const toolService = caps.tools.current;
591
- const groups = toolService?.getGroups() ?? [];
592
- const allTools = toolService?.getAll() ?? [];
593
- const knownNames = new Set(groups.map(g => g.name));
594
- const result = groups.map(g => {
595
- const toolsInGroup = allTools.filter(t => t.groups?.includes(g.name));
596
- // 贡献插件 = 该分组下所有工具的 pluginName 去重(可能跨多个插件,
597
- // 例如 'session-history' 同时被 plugin-tool-session 与 plugin-memory-history 贡献)
598
- const contributingPlugins = [...new Set(toolsInGroup.map(t => t.pluginName))].sort();
599
- return { ...g, description: descSummary(g.description), toolCount: toolsInGroup.length, contributingPlugins };
600
- });
601
- // 兜底:未声明任何 group 或 group 不在已注册集合中的工具,聚成 "other" 组
602
- const orphans = allTools.filter(t => {
603
- const gs = t.groups ?? [];
604
- return gs.length === 0 || gs.every(n => !knownNames.has(n));
605
- });
606
- if (orphans.length > 0) {
607
- result.push({
608
- name: 'other',
609
- label: '其他',
610
- description: '未声明分组的工具',
611
- pluginName: '(system)',
612
- toolCount: orphans.length,
613
- contributingPlugins: [...new Set(orphans.map(t => t.pluginName))].sort(),
614
- });
615
- }
616
- res.json({ groups: result });
617
- });
618
- // 获取服务分组(manifest 驱动:每个插件自己声明 subsystem,本路由仅聚合)
619
- expressApp.get('/api/service-groups', gate(), (_req, res) => {
620
- const pluginMgr = caps.plugins.current;
621
- const pluginStatus = pluginMgr ? pluginMgr.getStatus() : [];
622
- // 按插件 subsystem 归组(未声明 → 'external')。subsystem 是插件定义上的展示标签,
623
- // core 状态契约不含——从定义直接读。
624
- const groupsMap = new Map();
625
- for (const p of pluginStatus) {
626
- const sub = pluginMgr?.getPlugin(p.instanceId)?.definition?.subsystem ?? 'external';
627
- if (!groupsMap.has(sub))
628
- groupsMap.set(sub, []);
629
- groupsMap.get(sub).push({ name: p.name, provides: p.provides ?? [] });
630
- }
631
- // 排序:DEFAULT_SUBSYSTEM_METADATA 已知 id 优先按 order 排,未知 id 以 id 作为 label,order=9999
632
- const meta = new Map(DEFAULT_SUBSYSTEM_METADATA.map(e => [e.id, e]));
633
- const sorted = [...groupsMap.keys()]
634
- .map(id => {
635
- const m = meta.get(id);
636
- return m ? { id, label: m.label, order: m.order } : { id, label: id, order: 9999 };
637
- })
638
- .sort((a, b) => a.order - b.order || a.id.localeCompare(b.id));
639
- // First-claim-wins:服务名只归入最先(order 最小)声明它的组,避免同一服务出现在多个分组
640
- const servicesClaimed = new Set();
641
- const groups = sorted.map(({ id, label }) => {
642
- const plugins = groupsMap.get(id);
643
- const owned = [...new Set(plugins.flatMap(p => p.provides))].filter(s => {
644
- if (servicesClaimed.has(s))
645
- return false;
646
- servicesClaimed.add(s);
647
- return true;
648
- });
649
- return { id, label, plugins, services: owned };
650
- });
651
- // 系统内建服务(app、plugins 等由 core 直接 provide,不属于任何插件)
652
- const systemServices = services.names().filter(n => !servicesClaimed.has(n));
653
- if (systemServices.length > 0) {
654
- const sysLabel = meta.get('system')?.label ?? '系统';
655
- groups.unshift({ id: 'system', label: sysLabel, plugins: [], services: systemServices });
656
- }
657
- res.json({ groups });
658
- });
659
- // 获取所有 LLM 模型(枚举所有注册的 per-model entry)
660
- expressApp.get('/api/llm-models', gate(), async (_req, res) => {
661
- try {
662
- const entries = caps.llm.all();
663
- const models = entries.map(e => ({
664
- id: e.instance.id,
665
- capabilities: [...e.instance.capabilities],
666
- provider: e.instance.providerId,
667
- contextId: e.contextId,
668
- }));
669
- res.json({ models });
670
- }
671
- catch {
672
- res.json({ models: [] });
673
- }
674
- });
675
- // LLM providers + per-provider models(供 schema type='llm-ref' 联动 select 使用)
676
- expressApp.get('/api/llm-providers', gate(), async (_req, res) => {
677
- try {
678
- const entries = caps.llm.all();
679
- const byProvider = new Map();
680
- // entry.label 形如 "Ollama (localhost:11434) / qwen3.6:35b-mlx";
681
- // 取第一个 " / " 之前的部分作为 providerLabel(避免把 model id 也带进去)。
682
- const extractProviderLabel = (raw) => {
683
- if (!raw)
684
- return undefined;
685
- const slash = raw.indexOf(' / ');
686
- return slash > 0 ? raw.slice(0, slash) : raw;
687
- };
688
- for (const e of entries) {
689
- const providerId = e.instance.providerId;
690
- let agg = byProvider.get(providerId);
691
- if (!agg) {
692
- agg = { contextId: providerId, label: extractProviderLabel(e.label), models: [] };
693
- byProvider.set(providerId, agg);
694
- }
695
- else if (!agg.label) {
696
- agg.label = extractProviderLabel(e.label);
697
- }
698
- agg.models.push({
699
- id: e.instance.id,
700
- capabilities: [...e.instance.capabilities],
701
- contextLength: e.instance.contextLength,
702
- });
703
- }
704
- res.json({ providers: [...byProvider.values()] });
705
- }
706
- catch {
707
- res.json({ providers: [] });
708
- }
709
- });
710
- // 触发指定 provider 重新探测远端模型列表(用于 webui 上的"刷新模型"按钮)
711
- // 仅对在 LLMModel 上实现了 refresh() 的 provider 生效(远端动态发现型,如 Ollama / OpenAI)。
686
+ // 触发指定 provider 重新探测远端模型列表(插件配置里模型字段旁的「刷新」按钮)
687
+ // 仅对在 LLMModel 上实现了 refresh() 的 provider 生效(远端动态发现型)。
712
688
  // 同 provider 下所有 model entries 共享同一份 refresh 闭包,调任一个 entry 即可。
713
- // 按 providerId 找条目,与 /api/llm-providers 的聚合同一口径:条目的 contextId 是 `<provider>/<model>`,不等于 provider
714
- expressApp.post('/api/llm-providers/:contextId/refresh', gate(), async (req, res) => {
689
+ // 按 providerId 找条目,与 /api/models/llm 给的 contextId 同一口径:条目自己的 contextId 是 `<provider>/<model>`,不等于 provider
690
+ route(expressApp, 'POST /api/llm-providers/:contextId/refresh', gate(), async (req, res) => {
715
691
  const contextId = req.params.contextId;
716
692
  try {
717
- const llmEntries = caps.llm.all().filter(e => e.instance.providerId === contextId);
718
- const target = llmEntries.find(e => typeof e.instance.refresh === 'function');
693
+ const llmEntries = caps.llm.list().filter(e => e.use.providerId === contextId);
694
+ const target = llmEntries.find(e => typeof e.use.refresh === 'function');
719
695
  if (!target) {
720
696
  res.status(404).json({
721
697
  error: llmEntries.length > 0
722
- ? `提供者 ${contextId} 不支持运行时刷新模型列表(例如关闭了模型发现 discoverModels)`
698
+ ? `提供者 ${contextId} 不支持运行时刷新模型列表(例如关闭了模型发现)`
723
699
  : `提供者 ${contextId} 当前没有已注册的模型,无法刷新`,
724
700
  });
725
701
  return;
726
702
  }
727
- const result = await target.instance.refresh();
703
+ const result = await target.use.refresh();
728
704
  res.json({ ok: true, ...result });
729
705
  }
730
706
  catch (err) {
@@ -732,21 +708,20 @@ async function startWebuiServer(caps) {
732
708
  }
733
709
  });
734
710
  // 获取某个服务的可用模型/选项列表
735
- expressApp.get('/api/models/:service', gate(), async (req, res) => {
711
+ route(expressApp, 'GET /api/models/:service', gate(), async (req, res) => {
736
712
  const serviceName = req.params.service;
737
713
  // 特殊处理 platform:通过 helper 获取已注册的平台名称
738
714
  if (serviceName === 'platform') {
739
715
  res.json({ models: getPlatformNames(caps.platform) });
740
716
  return;
741
717
  }
742
- // 特殊处理 gateway-scopes:基于已注册 adapter.sessionTypes 真实声明生成
743
- // platform×sessionType 的笛卡尔积。无声明的 adapter 视为单会话(不展开 sessionType)。
718
+ // 特殊处理 gateway-scopes:已注册平台与它们的类型名(sessionTypesOf:没声明类型的平台只有一个类型 private)
719
+ // 的笛卡尔积
744
720
  if (serviceName === 'gateway-scopes') {
745
- const adapters = getPlatformAdapters(caps.platform);
746
721
  const platformTypes = new Map();
747
722
  const allTypes = new Set();
748
- for (const a of adapters) {
749
- const types = a.sessionTypes ?? [];
723
+ for (const a of getPlatformAdapters(caps.platform)) {
724
+ const types = sessionTypesOf(a).map(t => t.name);
750
725
  platformTypes.set(a.platform, types);
751
726
  for (const t of types)
752
727
  allTypes.add(t);
@@ -757,23 +732,42 @@ async function startWebuiServer(caps) {
757
732
  scopes.push({ value: `*:${t}`, label: `*:${t} (所有平台的 ${t} 会话)` });
758
733
  }
759
734
  for (const [p, types] of platformTypes) {
760
- if (types.length === 0) {
761
- // 单会话平台 (cli/webui):只列 platform 通配
762
- scopes.push({ value: `${p}:*`, label: `${p}:* (${p} 单会话)` });
763
- }
764
- else {
765
- scopes.push({ value: `${p}:*`, label: `${p}:* (${p} 全部类型)` });
766
- for (const t of types)
767
- scopes.push({ value: `${p}:${t}`, label: `${p}:${t}` });
768
- }
735
+ scopes.push({ value: `${p}:*`, label: `${p}:* (${p} 全部类型)` });
736
+ for (const t of types)
737
+ scopes.push({ value: `${p}:${t}`, label: `${p}:${t}` });
769
738
  }
770
739
  res.json({ models: scopes.map(s => s.value), details: scopes });
771
740
  return;
772
741
  }
742
+ // 深度思考的档位:可选项依赖「模型」这一项,dependency 带它的生效值;没带时按服务偏好解析,与对话时同一种解析。
743
+ // 默认档标 default(没设时界面据它写按哪一档跑),模型不能调时没有可选项。labels 是各模型声明过的档位的名字
744
+ // (同一个 id 取先登记的):换了模型以后存着的档位不在可选项里,界面按它写出名字
745
+ if (serviceName === 'llm-thinking') {
746
+ const ref = modelRefOf(req.query.dependency);
747
+ if (ref === null) {
748
+ res.status(400).json({ error: 'dependency 要是模型引用 { provider, model } 的 JSON' });
749
+ return;
750
+ }
751
+ const thinking = resolveLLMModel(caps.llm, ref, ['chat'])?.use.thinking;
752
+ const levels = thinking?.levels ?? [];
753
+ const labels = {};
754
+ for (const entry of caps.llm.list()) {
755
+ for (const level of entry.use.thinking?.levels ?? [])
756
+ labels[level.id] ??= level.label;
757
+ }
758
+ res.json({
759
+ models: levels.map(level => level.id),
760
+ details: levels.map(level => level.id === thinking?.default
761
+ ? { value: level.id, label: level.label, default: true }
762
+ : { value: level.id, label: level.label }),
763
+ labels,
764
+ });
765
+ return;
766
+ }
773
767
  // 特殊处理 toolGroups:优先从工具分组注册表获取,回退到扫描工具
774
768
  if (serviceName === 'toolGroups') {
775
- const groups = caps.tools.current?.getGroups() ?? [];
776
- // '*' = 全部分组(plugin-tools 的分组过滤认它),与会话范围选项的「* (全部平台)」同一写法
769
+ const groups = caps.tools.present ? caps.tools.use.getGroups() : [];
770
+ // '*' = 全部分组(api-tools 的分组约定),与会话范围选项的「* (全部平台)」同一写法
777
771
  if (groups.length > 0) {
778
772
  res.json({
779
773
  models: ['*', ...groups.map(g => g.name).sort()],
@@ -790,7 +784,7 @@ async function startWebuiServer(caps) {
790
784
  }
791
785
  else {
792
786
  // 回退:从已注册工具中提取分组名称
793
- const allTools = caps.tools.current?.getAll() ?? [];
787
+ const allTools = caps.tools.present ? caps.tools.use.getAll() : [];
794
788
  const groupSet = new Set();
795
789
  for (const t of allTools) {
796
790
  t.groups?.forEach((g) => {
@@ -801,588 +795,191 @@ async function startWebuiServer(caps) {
801
795
  }
802
796
  return;
803
797
  }
804
- // LLM 走 per-model entry 枚举(每个 entry 已对应一个具体 model,不再依赖 provider.listModels)
798
+ // LLM 走 per-model entry 枚举(每个 entry 已对应一个具体 model,不再依赖 provider.listModels)。
799
+ // contextId 给提供者的实例 id(条目自己的是 `<实例 id>/<模型 id>`):llm-ref 的取值 { provider, model } 认的是它
805
800
  if (serviceName === 'llm') {
806
801
  try {
807
- const entries = caps.llm.all();
808
- const aggregated = entries.map(e => ({
809
- value: `${e.contextId}::${e.instance.id}`,
810
- model: e.instance.id,
811
- provider: e.label ?? e.contextId,
812
- contextId: e.contextId,
813
- capabilities: [...e.instance.capabilities],
802
+ const entries = caps.llm.list();
803
+ const providers = entries.map(e => ({
804
+ model: e.use.id,
805
+ provider: e.label ?? e.id,
806
+ contextId: e.use.providerId,
807
+ capabilities: [...e.use.capabilities],
814
808
  }));
815
- res.json({ models: aggregated.map(a => a.value), providers: aggregated });
809
+ res.json({ providers });
816
810
  }
817
811
  catch {
818
- res.json({ models: [] });
812
+ res.json({ providers: [] });
819
813
  }
820
814
  return;
821
815
  }
822
- const service = services.get(serviceName);
823
- if (!service || typeof service.listModels !== 'function') {
824
- res.json({ models: [] });
825
- return;
826
- }
827
- try {
828
- // 聚合所有提供者的模型列表(embedding 等服务仍走 listModels())。
829
- const allProviders = services.all(serviceName);
830
- const aggregated = [];
831
- const flatValues = [];
832
- for (const provider of allProviders) {
833
- if (typeof provider.instance.listModels !== 'function')
834
- continue;
835
- try {
836
- const models = await provider.instance.listModels();
837
- for (const m of models) {
838
- const modelId = String(m);
839
- aggregated.push({
840
- value: modelId,
841
- model: modelId,
842
- provider: provider.label ?? provider.contextId,
843
- contextId: provider.contextId,
844
- capabilities: [],
845
- });
846
- flatValues.push(modelId);
847
- }
848
- }
849
- catch {
850
- // 单个提供者获取模型失败不影响整体
851
- }
816
+ // 合起各提供者的取值(人设卡等服务走 listModels())
817
+ const allProviders = services.use.all(serviceName);
818
+ const models = [];
819
+ for (const provider of allProviders) {
820
+ if (typeof provider.instance.listModels !== 'function')
821
+ continue;
822
+ try {
823
+ models.push(...(await provider.instance.listModels()).map(String));
852
824
  }
853
- res.json({ models: flatValues, providers: aggregated });
854
- }
855
- catch {
856
- res.json({ models: [] });
857
- }
858
- });
859
- // ---------- 受限操作交互式确认(复用 session-confirm 协调器;WebUI 传输=WS type:'confirm',流式友好) ----------
860
- // 协调器逻辑(待确认/超时/解析/文案)由 session-confirm 服务统一实现;WebUI 只注入自己的 WS 投递,
861
- // 并保留 type:'confirm'(前端「确认模式」信号,抑制富客户端的「打字即打断」)。回复在 WS-onmessage 调 feed(下方)。
862
- let confirmChannel;
863
- caps.sessionConfirm.follow(confirmSvc => {
864
- const channel = confirmSvc.createChannel((request, text) => {
865
- const payload = { type: 'confirm', content: text, sessionId: request.sessionId };
866
- const json = JSON.stringify(payload);
867
- const sockets = sessions.get(request.sessionId);
868
- const targets = sockets && sockets.size > 0 ? sockets : allClients;
869
- for (const ws of targets) {
870
- if (ws.readyState === WebSocket.OPEN)
871
- ws.send(json);
825
+ catch (err) {
826
+ // 单个提供者获取失败不影响整体,它的取值从下拉里消失:记下是谁、为什么
827
+ logger.use.warn(`取 ${serviceName} 的可选值时 ${provider.contextId} 失败,它的取值不在下拉里:`, err);
872
828
  }
873
- });
874
- // 挂到 authority 上要跟着它的胜者走(bounce 后重挂),所以嵌套一层跟随;
875
- // 外层 cleanup 即内层订阅的退订:session-confirm 换胜者或本插件拆卸时一并注销
876
- confirmChannel = channel;
877
- const offAuthority = caps.authority.follow(authoritySvc => authoritySvc.setConfirmHandler('webui', channel.handler));
878
- // cleanup:摘 handler、dispose 旧通道(在飞确认按取消结算,不留到 60s 超时)、清引用
879
- return () => {
880
- offAuthority();
881
- channel.dispose();
882
- if (confirmChannel === channel)
883
- confirmChannel = undefined;
884
- };
829
+ }
830
+ res.json({ models });
885
831
  });
886
- // ---------- 文件管理 API ----------
887
- registerFileRoutes(expressApp, { storage, fileRoot: uiConfig.fileRoot }, gate);
888
- registerUploadedFilesRoutes(expressApp, {
889
- storage,
890
- logger,
891
- fileIndex: () => services.get('file-reader'),
892
- }, gate);
893
- registerProxyRoutes(expressApp, logger, gate);
832
+ registerProxyRoutes(expressApp, logger.use, gate);
833
+ registerMediaRoutes(expressApp, storage, logger.use, gate);
834
+ registerFileRoutes(expressApp, storage, { sessions: caps.sessionProtocol, pages: pageFiles }, logger.use, gate);
894
835
  // ---------- WebSocket ----------
895
836
  wss.on('connection', (ws, req) => {
896
837
  // 连接建立时解析一次调用者身份(cookie 在升级请求里;verifyClient 已保证已认证)
897
838
  const wsIdentity = auth.identify(req) ?? { platform: 'webui', userId: 'console' };
898
- logger.debug(`WebUI 客户端已连接: ${wsIdentity.platform}:${wsIdentity.userId}`);
899
- allClients.add(ws);
839
+ logger.use.debug(`WebUI 客户端已连接: ${wsIdentity.platform}:${wsIdentity.userId}`);
840
+ // 每条连接都过心跳:回收半开连接,track 同时挂上 'error' 兜底监听(见 ws-heartbeat.ts)
900
841
  heartbeat.track(ws);
901
- ws.on('message', async (data) => {
902
- // 非 JSON 帧与形状不对的帧同属协议违规:都只记一行告警,外层 catch 留给真正的处理失败
903
- let raw;
904
- try {
905
- raw = JSON.parse(data.toString());
906
- }
907
- catch {
908
- logger.warn('WebUI 收到协议违规消息: 非 JSON 帧');
909
- return;
910
- }
911
- try {
912
- const parseResult = WSIncomingSchema.safeParse(raw);
913
- if (!parseResult.success) {
914
- logger.warn(`WebUI 收到协议违规消息: ${parseResult.error.issues.map(i => `${i.path.join('.')}: ${i.message}`).join('; ')}`);
915
- return;
916
- }
917
- const msg = parseResult.data;
918
- if (msg.type === 'subscribe_logs') {
919
- logSubscribers.add(ws);
920
- return;
921
- }
922
- // 客户端连接时主动注册会话,确保 scheduler 等异步消息能推送
923
- if (msg.type === 'subscribe_session') {
924
- const sid = msg.sessionId || 'webui-default';
925
- if (!sessions.has(sid)) {
926
- sessions.set(sid, new Set());
927
- }
928
- sessions.get(sid).add(ws);
929
- // 如果该会话正在生成中,发送已累积的内容供客户端恢复
930
- const buf = streamBuffers.get(sid);
931
- if (buf &&
932
- (buf.content || buf.reasoningContent || buf.segments.length > 0 || buf.toolCallsProgress.size > 0)) {
933
- const resume = {
934
- type: 'stream_resume',
935
- sessionId: sid,
936
- content: buf.content,
937
- reasoningContent: buf.reasoningContent,
938
- segments: buf.segments.length > 0 ? buf.segments : undefined,
939
- toolCallsProgress: buf.toolCallsProgress.size > 0
940
- ? [...buf.toolCallsProgress.entries()]
941
- .sort((a, b) => a[0] - b[0])
942
- .map(([index, v]) => ({
943
- index,
944
- name: v.name,
945
- charsAccumulated: v.charsAccumulated,
946
- startedAt: v.startedAt,
947
- }))
948
- : undefined,
949
- done: !buf.generating,
950
- };
951
- ws.send(JSON.stringify(resume));
952
- }
953
- // 发送缓存的 token 用量,刷新/切换会话后立即展示
954
- const cachedUsage = tokenUsageCache.get(sid);
955
- if (cachedUsage) {
956
- ws.send(JSON.stringify(cachedUsage));
957
- }
958
- else {
959
- // 无缓存(服务重启后),请求 agent 重新计算
960
- events
961
- .emit('token:request', {
962
- sessionId: sid,
963
- platform: resolveSessionPlatform(sid, new Set(getPlatformNames(caps.platform))),
964
- })
965
- .catch(() => { });
966
- }
967
- return;
968
- }
969
- if (msg.type === 'unsubscribe_session') {
970
- const sid = msg.sessionId || 'webui-default';
971
- sessions.get(sid)?.delete(ws);
972
- return;
973
- }
974
- if (msg.type === 'abort') {
975
- const sessionId = msg.sessionId || 'webui-default';
976
- const agentSvc = caps.agent.current;
977
- if (agentSvc?.abort)
978
- agentSvc.abort(sessionId);
979
- return;
980
- }
981
- if (msg.type === 'compress') {
982
- const sessionId = msg.sessionId || 'webui-default';
983
- logger.info(`收到手动压缩请求: session=${sessionId}`);
984
- // 触发压缩事件(memory-summary 监听此事件,并发出 session:compressing 通知)
985
- events
986
- .emit('session:compress', { sessionId, reason: 'manual' })
987
- .then(() => {
988
- // 压缩完成后重新计算 token 用量并推送给客户端
989
- events
990
- .emit('token:request', {
991
- sessionId,
992
- platform: resolveSessionPlatform(sessionId, new Set(getPlatformNames(caps.platform))),
993
- })
994
- .catch(() => { });
995
- })
996
- .catch(() => { });
997
- return;
998
- }
999
- if (msg.type !== 'message' || !msg.content)
1000
- return;
1001
- const sessionId = msg.sessionId || 'webui-default';
1002
- const trimmed = msg.content.trim();
1003
- if (!sessions.has(sessionId)) {
1004
- sessions.set(sessionId, new Set());
1005
- }
1006
- sessions.get(sessionId).add(ws);
1007
- // 检查待确认的受限操作(拦截输入作为确认/取消;协调器逻辑由 session-confirm 服务统一处理)
1008
- // 传应答者身份:仅确认的触发者本人能应答(防第三方抢答;webui 同账户天然同人)。
1009
- if (confirmChannel?.feed(sessionId, trimmed, wsIdentity.userId)) {
1010
- // 不继续处理此消息,交给原始命令/工具执行流返回结果
1011
- return;
1012
- }
1013
- // 指令解析已统一到全局 inbound:command 相位(plugin-commands 注册)。
1014
- // 适配器不再内联解析命令——未注册的命令会被该相位放行为普通消息进入
1015
- // agent,命中的命令由该相位执行并经 outbound:message 回送,与 onebot
1016
- // 平台行为完全一致。客户端只通过 attachments 发送多模态内容。
1017
- await events.emit('inbound:message', {
1018
- content: trimmed,
1019
- sessionId,
1020
- platform: wsIdentity.platform,
1021
- userId: wsIdentity.userId,
1022
- nickname: undefined,
1023
- ...(msg.attachments && msg.attachments.length > 0 ? { attachments: msg.attachments } : {}),
1024
- });
1025
- }
1026
- catch (err) {
1027
- logger.warn('WebUI 消息处理失败:', err);
1028
- }
1029
- });
1030
- ws.on('close', () => {
1031
- heartbeat.untrack(ws);
1032
- allClients.delete(ws);
1033
- logSubscribers.delete(ws);
1034
- for (const [sid, sockets] of sessions) {
1035
- sockets.delete(ws);
1036
- if (sockets.size === 0)
1037
- sessions.delete(sid);
1038
- }
1039
- });
1040
- });
1041
- // 实时推送日志给订阅者
1042
- const removeLogListener = LogHub.default.onEntry((entry) => {
1043
- const payload = { type: 'log', log: entry };
1044
- const json = JSON.stringify(payload);
1045
- for (const ws of logSubscribers) {
1046
- if (ws.readyState === WebSocket.OPEN) {
1047
- ws.send(json);
1048
- }
1049
- }
1050
- });
1051
- // 插件状态级联变更后,广播通知所有前端刷新
1052
- events.on('plugins:changed', () => {
1053
- const payload = { type: 'state_changed' };
1054
- const json = JSON.stringify(payload);
1055
- for (const ws of allClients) {
1056
- if (ws.readyState === WebSocket.OPEN) {
1057
- ws.send(json);
1058
- }
1059
- }
1060
- });
1061
- // 重启通知:广播给所有客户端。WS 报文的 type 是前端协议,与 core 事件名无关,保持 'restarting'。
1062
- events.on('app:restarting', () => {
1063
- const payload = { type: 'restarting' };
1064
- const json = JSON.stringify(payload);
1065
- for (const ws of allClients) {
1066
- if (ws.readyState === WebSocket.OPEN) {
1067
- ws.send(json);
1068
- }
1069
- }
1070
- });
1071
- // 会话列表变更:广播给所有客户端,让前端即时刷新
1072
- const broadcastSessionsChanged = () => {
1073
- const payload = { type: 'sessions_changed' };
1074
- const json = JSON.stringify(payload);
1075
- for (const ws of allClients) {
1076
- if (ws.readyState === WebSocket.OPEN) {
1077
- ws.send(json);
1078
- }
1079
- }
1080
- };
1081
- // Todo 列表变更:推送给订阅了该会话的客户端
1082
- events.on('todo:updated', (sessionId, items) => {
1083
- const sockets = sessions.get(sessionId);
1084
- if (!sockets)
842
+ ws.on('close', () => heartbeat.untrack(ws));
843
+ if (wsPath(req) === ADMIN_PATH) {
844
+ admin.accept(ws);
1085
845
  return;
1086
- const payload = { type: 'todo_updated', sessionId, todoItems: items };
1087
- const json = JSON.stringify(payload);
1088
- for (const ws of sockets) {
1089
- if (ws.readyState === WebSocket.OPEN) {
1090
- ws.send(json);
1091
- }
1092
846
  }
847
+ sessionSockets.add(ws);
848
+ ws.on('close', () => sessionSockets.delete(ws));
849
+ acceptSessionSocket(ws, { identity: wsIdentity, surface: approvalSurface }, caps.sessionProtocol.present ? caps.sessionProtocol.use : undefined);
1093
850
  });
1094
- events.on('session:created', broadcastSessionsChanged);
1095
- events.on('session:updated', broadcastSessionsChanged);
1096
- events.on('session:deleted', broadcastSessionsChanged);
1097
- events.on('session:completed', broadcastSessionsChanged);
1098
- // 动态页面刷新通知:插件可通过广播 'page_refresh' 让前端无感刷新对应页面数据
1099
- // 当前已知发射方:plugin-doctor('doctor:updated')。新增同类需求时按相同模式订阅即可。
1100
- const broadcastPageRefresh = (pluginName) => {
1101
- const payload = { type: 'page_refresh', pluginName };
1102
- const json = JSON.stringify(payload);
1103
- for (const ws of allClients) {
1104
- if (ws.readyState === WebSocket.OPEN)
1105
- ws.send(json);
1106
- }
1107
- };
1108
- events.on('doctor:updated', () => broadcastPageRefresh('@aalis/plugin-doctor'));
1109
- // 会话历史变更(如 checkpoint 回滚整轮对话):推送给订阅该会话的客户端,让前端重新拉取历史
1110
- events.on('history:changed', change => {
1111
- const sessionId = change?.sessionId;
1112
- if (!sessionId)
1113
- return;
1114
- const sockets = sessions.get(sessionId);
1115
- if (!sockets)
1116
- return;
1117
- const payload = { type: 'history_changed', sessionId };
1118
- const json = JSON.stringify(payload);
1119
- for (const ws of sockets) {
1120
- if (ws.readyState === WebSocket.OPEN) {
1121
- ws.send(json);
1122
- }
1123
- }
851
+ // 管理面推送(/admin):日志、插件状态、「需要处理」、重启。页面登记表与提示的变化、invalidate 在页面登记表(pages.ts)里推,
852
+ // 布局记录的变化在它的路由里推,换前端在 remountActiveClient 里推
853
+ caps.logs.use.subscribe(entry => admin.send({ type: 'log', entry }));
854
+ events.use.on('plugins:changed', () => {
855
+ admin.send({ type: 'plugins.changed' });
856
+ attention.pluginsChanged();
1124
857
  });
1125
- // 压缩状态通知:memory-summary 发出 session:compressing 事件,广播给订阅该会话的客户端
1126
- events.on('session:compressing', info => {
1127
- const sockets = sessions.get(info.sessionId);
1128
- if (!sockets)
858
+ events.use.on('approval:created', () => attention.changed());
859
+ events.use.on('approval:settled', () => attention.changed());
860
+ events.use.on('app:restarting', () => admin.send({ type: 'app.restarting' }));
861
+ // 发往 WebUI 自己会话的出站消息:会话历史就是 WebUI 的收件箱,页面只是查看器,打开着的页面由会话服务推送。
862
+ // 回执表示这条消息已经进了会话历史,不看有没有页面在线。owner 从 WebUI 驱动的 IM 房间(根会话记下了出生平台)
863
+ // 由那个平台的适配器认领
864
+ events.use.on('outbound:message', (msg) => {
865
+ if (msg.delivery || msg.platform !== 'webui')
1129
866
  return;
1130
- const payload = { type: 'compressing', sessionId: info.sessionId, content: info.status };
1131
- const json = JSON.stringify(payload);
1132
- for (const ws of sockets) {
1133
- if (ws.readyState === WebSocket.OPEN) {
1134
- ws.send(json);
1135
- }
1136
- }
1137
- });
1138
- // 会话切换不再跨客户端广播:每个 webui 客户端独立持有自己的活跃会话(localStorage
1139
- // 持久化 + 每条消息自带 sessionId),互不干扰。全局 activeSessionId 退化为 CLI 概念,
1140
- // 不再驱动 webui。这样多人同时使用时,他人新建/切换会话不会把你的窗口切走。
1141
- // 监听 AI 回复
1142
- events.on('outbound:message', (msg) => {
1143
- // 生成完成,延迟清理缓冲区(给客户端重连拉取历史留出时间窗口)
1144
- const buf = streamBuffers.get(msg.sessionId);
1145
- if (buf) {
1146
- buf.generating = false;
1147
- buf.content = msg.content ?? buf.content;
1148
- buf.reasoningContent = msg.reasoningContent ?? buf.reasoningContent;
1149
- scheduleBufferCleanup(msg.sessionId, buf);
1150
- }
1151
- const sockets = sessions.get(msg.sessionId);
1152
- if (!sockets)
867
+ const sm = caps.sessions.present ? caps.sessions.use : undefined;
868
+ const root = sm && rootSessionOf(sm.getSession(msg.sessionId), id => sm.getSession(id));
869
+ if (root?.originPlatform !== undefined)
1153
870
  return;
1154
- // 仅信任 msg 本身携带的 segments。不要回退到 buf?.segments:
1155
- // 工具循环中 agent tool(如 send_attachment / commands 回复)会直接 emit 'outbound:message',
1156
- // 但 streamBuffers[sid].segments 累积的是当前回合的完整时间线(含本工具之前的 reasoning/tool_call)。
1157
- // 若回退使用 buf,会把整条时间线"借尸还魂"挂到工具消息上 → 前端 REPLACE 当前 assistant 后,
1158
- // 真正的 agent final outbound:message 再到达时被识别为新消息 APPEND,导致整条时间线渲染两遍。
1159
- // agent 的 final emit 已显式带 segments=turnSegments,不需要这个隐式 fallback。
1160
- const segments = msg.segments;
1161
- const payload = {
1162
- type: 'message',
1163
- content: msg.content,
1164
- sessionId: msg.sessionId,
1165
- reasoningContent: msg.reasoningContent,
1166
- segments: segments && segments.length > 0 ? segments : undefined,
1167
- attachments: msg.attachments?.length
1168
- ? msg.attachments.map(a => ({
1169
- kind: a.kind,
1170
- data: a.data,
1171
- mimeType: a.mimeType,
1172
- name: a.name,
1173
- }))
1174
- : undefined,
1175
- modelInfo: msg.modelInfo,
1176
- };
1177
- const json = JSON.stringify(payload);
1178
- for (const ws of sockets) {
1179
- if (ws.readyState === WebSocket.OPEN) {
1180
- ws.send(json);
1181
- }
1182
- }
1183
- });
1184
- // 监听流式增量推送
1185
- events.on('outbound:stream', (chunk) => {
1186
- // 累积到缓冲区
1187
- if (!chunk.done) {
1188
- let buf = streamBuffers.get(chunk.sessionId);
1189
- if (!buf) {
1190
- buf = { content: '', reasoningContent: '', segments: [], generating: true, toolCallsProgress: new Map() };
1191
- streamBuffers.set(chunk.sessionId, buf);
1192
- }
1193
- if (chunk.contentDelta) {
1194
- // 进入文本生成阶段:清空所有 tool 进度
1195
- buf.toolCallsProgress.clear();
1196
- buf.content += chunk.contentDelta;
1197
- // 追加到 segments:合并连续 text
1198
- const last = buf.segments[buf.segments.length - 1];
1199
- if (last && last.type === 'text') {
1200
- last.content += chunk.contentDelta;
1201
- }
1202
- else {
1203
- buf.segments.push({ type: 'text', content: chunk.contentDelta });
1204
- }
1205
- }
1206
- if (chunk.reasoningDelta) {
1207
- buf.toolCallsProgress.clear();
1208
- buf.reasoningContent += chunk.reasoningDelta;
1209
- // 同样追加到统一时间线:合并连续 reasoning_text
1210
- const last = buf.segments[buf.segments.length - 1];
1211
- if (last && last.type === 'reasoning_text') {
1212
- last.content += chunk.reasoningDelta;
1213
- }
1214
- else {
1215
- buf.segments.push({ type: 'reasoning_text', content: chunk.reasoningDelta });
1216
- }
1217
- }
1218
- if (chunk.toolCallProgress) {
1219
- const { index, name, charsAccumulated } = chunk.toolCallProgress;
1220
- const prev = buf.toolCallsProgress.get(index);
1221
- buf.toolCallsProgress.set(index, {
1222
- name,
1223
- charsAccumulated,
1224
- startedAt: prev?.startedAt ?? Date.now(),
1225
- });
1226
- }
1227
- }
1228
- else {
1229
- // done:回合终态收口。无论本回合是正常完成、空回复、还是被用户中止,
1230
- // agent 都会发一次 outbound:stream{done:true}(见 plugin-agent 的成功/中止/异常/未处理四条路径)。
1231
- // 这里必须把 generating 翻 false 并安排清理,否则刷新后 subscribe_session 仍读到
1232
- // generating=true 的残留 buffer → 回 stream_resume{done:false} → 前端永远显示"可停止"。
1233
- // (正常路径的 outbound:message 也会做同样的事;此处补全保证中止/静默路径对称。)
1234
- const buf = streamBuffers.get(chunk.sessionId);
1235
- if (buf) {
1236
- buf.toolCallsProgress.clear();
1237
- buf.generating = false;
1238
- scheduleBufferCleanup(chunk.sessionId, buf);
1239
- }
1240
- }
1241
- const sockets = sessions.get(chunk.sessionId);
1242
- if (!sockets)
1243
- return;
1244
- const payload = {
1245
- type: 'stream',
1246
- sessionId: chunk.sessionId,
1247
- contentDelta: chunk.contentDelta,
1248
- reasoningDelta: chunk.reasoningDelta,
1249
- toolCallProgress: chunk.toolCallProgress,
1250
- done: chunk.done,
1251
- toolLimitReached: chunk.toolLimitReached,
1252
- };
1253
- const json = JSON.stringify(payload);
1254
- for (const ws of sockets) {
1255
- if (ws.readyState === WebSocket.OPEN) {
1256
- ws.send(json);
1257
- }
1258
- }
1259
- });
1260
- // 监听工具调用事件
1261
- events.on('tool:execute', (info) => {
1262
- // 缓存工具调用到 segments
1263
- let buf = streamBuffers.get(info.sessionId);
1264
- if (!buf) {
1265
- buf = { content: '', reasoningContent: '', segments: [], generating: true, toolCallsProgress: new Map() };
1266
- streamBuffers.set(info.sessionId, buf);
1267
- }
1268
- if (info.phase === 'start') {
1269
- // 工具进入实际执行阶段:清掉生成中进度(占位卡 → ToolCallBlock 切换)
1270
- buf.toolCallsProgress.clear();
1271
- buf.segments.push({ type: 'tool_call', name: info.toolName, args: info.args ?? {}, startTime: Date.now() });
1272
- }
1273
- else if (info.phase === 'end') {
1274
- // 找到最后一个同名且无结果的 tool_call segment,填充结果
1275
- for (let i = buf.segments.length - 1; i >= 0; i--) {
1276
- const seg = buf.segments[i];
1277
- if (seg.type === 'tool_call' && seg.name === info.toolName && seg.result == null) {
1278
- seg.result = info.result;
1279
- seg.endTime = Date.now();
1280
- break;
871
+ // 同步挂上 Promise,始终 resolve,即使其他 outbound 监听器随后耗时,也不会产生未处理的 rejection。
872
+ msg.delivery = (async () => {
873
+ try {
874
+ // 保存快照:下游处理不应受生产者或其他适配器后续改写影响。
875
+ const snapshot = { ...msg, attachments: msg.attachments?.map(att => ({ ...att })) };
876
+ if (!caps.archive.present)
877
+ return { ok: false, error: '消息存档服务不在场,这条消息没有存进 WebUI 会话的历史' };
878
+ const archive = caps.archive.use;
879
+ for (const record of inboxRecords(snapshot))
880
+ await archive.saveMessage(msg.sessionId, record);
881
+ if (snapshot.attachments?.length) {
882
+ // 附件已由上面记进会话历史(见 inboxRecords),标上 skipArchive,免得全局出站归档再记一份
883
+ const attachments = snapshot.attachments.map(att => ({ ...att, skipArchive: true }));
884
+ void events.use.emit('outbound:delivered', { ...snapshot, attachments, delivery: undefined });
1281
885
  }
886
+ return { ok: true };
1282
887
  }
1283
- }
1284
- const sockets = sessions.get(info.sessionId);
1285
- if (!sockets)
1286
- return;
1287
- const payload = {
1288
- type: 'tool_call',
1289
- sessionId: info.sessionId,
1290
- toolName: info.toolName,
1291
- toolArgs: info.args,
1292
- toolPhase: info.phase,
1293
- toolResult: info.result,
1294
- };
1295
- const json = JSON.stringify(payload);
1296
- for (const ws of sockets) {
1297
- if (ws.readyState === WebSocket.OPEN) {
1298
- ws.send(json);
1299
- }
1300
- }
1301
- });
1302
- // 监听 token 使用量统计事件
1303
- events.on('token:usage', usage => {
1304
- const sockets = sessions.get(usage.sessionId);
1305
- if (!sockets)
1306
- return;
1307
- const payload = {
1308
- type: 'token_usage',
1309
- sessionId: usage.sessionId,
1310
- tokenUsage: {
1311
- contextWindow: usage.contextWindow,
1312
- maxTokens: usage.maxTokens,
1313
- tokenBudget: usage.tokenBudget,
1314
- used: usage.used,
1315
- usageRatio: usage.usageRatio,
1316
- breakdown: usage.breakdown,
1317
- },
1318
- };
1319
- // 缓存最新的 token 用量
1320
- tokenUsageCache.set(usage.sessionId, payload);
1321
- const json = JSON.stringify(payload);
1322
- for (const ws of sockets) {
1323
- if (ws.readyState === WebSocket.OPEN) {
1324
- ws.send(json);
888
+ catch (error) {
889
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
1325
890
  }
1326
- }
891
+ })();
1327
892
  });
1328
893
  // 前端切换「逃生页」:由 webui-server 直出、独立于任何可被切换的前端,故任意前端(哪怕
1329
894
  // 极简第三方前端无切换 UI)卡住时都能在此切回。受全局 auth 中间件登录保护;切换走既有 owner
1330
895
  // 闸的 POST /api/services/webui-client/prefer,本页零新增后端逻辑(见 client-switch-page.ts)。
1331
896
  // 注册在 SPA 兜底之前,故 `/__clients` 被本路由先接走、不落到前端 index.html。
1332
- expressApp.get('/__clients', (_req, res) => {
897
+ route(expressApp, 'GET /__clients', (_req, res) => {
1333
898
  res.type('html').send(renderClientSwitchPage());
1334
899
  });
1335
- // API 未命中:回 404 JSON,不落到 SPA 兜底(否则客户端拿到 HTML 去解析 JSON)。
1336
- expressApp.all('/api/{*path}', (req, res) => {
900
+ route(expressApp, 'GET /__clients.js', (_req, res) => {
901
+ res.type('application/javascript').send(CLIENT_SWITCH_SCRIPT);
902
+ });
903
+ // API 未命中:回 404 JSON,不落到 SPA 兜底(否则客户端拿到 HTML 去解析 JSON)。不是路由:任何方法都回它
904
+ expressApp.use((req, res, next) => {
905
+ if (!req.path.startsWith('/api/'))
906
+ return next();
1337
907
  res.status(404).json({ error: `未知 API: ${req.method} ${req.path}` });
1338
908
  });
1339
- // SPA fallback: 所有非 API 路径返回 index.html
1340
- expressApp.get('{*path}', (_req, res) => {
1341
- const indexPath = resolve(clientDist, 'index.html');
1342
- if (existsSync(indexPath)) {
909
+ // SPA fallback: 所有非 API 路径返回 index.html;/assets/ 下缺失的文件回 404(旧页面引用的资源换了构建就不在了,
910
+ // 回 index.html 只会让浏览器把 HTML 当脚本执行)
911
+ route(expressApp, 'GET /{*path}', (req, res) => {
912
+ if (req.path.startsWith('/assets/')) {
913
+ res.status(404).end();
914
+ return;
915
+ }
916
+ // 还没有选中的前端时 clientDist 为空:不能拼出相对 cwd 的 index.html
917
+ const indexPath = clientDist ? join(clientDist, 'index.html') : undefined;
918
+ if (indexPath && existsSync(indexPath)) {
1343
919
  res.sendFile(indexPath);
1344
920
  }
1345
921
  else {
1346
- res
1347
- .status(404)
1348
- .json({ error: '前端未就绪:请安装一个带 aalis.client 标记的前端包(如 @aalis/plugin-webui-client)' });
922
+ res.status(404).json({ error: '前端未就绪:请安装一个带 aalis.client 标记的前端包' });
1349
923
  }
1350
924
  });
925
+ // 路由与中间件抛出的错误(请求体不是 JSON 或太大、路径参数解不开、处理函数出错):/api/ 下回 ErrorResponse,别处回纯文本,
926
+ // 都不带堆栈,原因只进日志。挂在全部路由之后(插件前端的挂载另有自己的,紧跟在挂载之后)
927
+ expressApp.use((err, req, res, next) => {
928
+ if (res.headersSent)
929
+ return next(err);
930
+ const { status, expose, message } = err;
931
+ const code = typeof status === 'number' && status >= 400 && status < 600 ? status : 500;
932
+ if (code >= 500)
933
+ logger.use.error(`${req.method} ${req.path} 处理出错:`, err);
934
+ else
935
+ logger.use.debug(`${req.method} ${req.path} 请求被拒 (${code}): ${String(message)}`);
936
+ const reason = code >= 500
937
+ ? '处理这个请求时出错,原因见日志'
938
+ : expose === true && typeof message === 'string'
939
+ ? message
940
+ : '请求的写法不对';
941
+ if (req.path.startsWith('/api/'))
942
+ res.status(code).json({ error: reason });
943
+ else
944
+ res.status(code).type('text').send(reason);
945
+ });
1351
946
  /**
1352
947
  * 全动态发现前端:按 `aalis.client:true` 标记扫描,**不硬编码任何前端包名**
1353
948
  * (忒修斯之船——任意第三方前端带标记+dist 即被发现)。
1354
949
  * 覆盖三种拓扑:monorepo(扫 packages 同级目录)/ 独立项目(扫 node_modules/@aalis + 根 deps)。
1355
950
  *
1356
- * **幂等**,可重复调用:装完一个 `aalis-interface` 包后要立刻重跑,否则它不会出现在服务页
951
+ * **幂等**,可重复调用:装完一个 `aalis-interface` 包后要立刻重跑,否则它不会出现在服务偏好
1357
952
  * 的下拉里、必须重启才看得见。幂等是硬要求而非优化——重复 `fork().provide()` 会在服务
1358
953
  * 容器里堆同名重复项。已登记过的 id 直接跳过。
1359
954
  *
1360
- * 只登记新候选,**不切换活跃前端**:换前端是用户在服务页的显式选择,装了个新的不该把
955
+ * 只登记新候选,**不切换活跃前端**:换前端是用户在服务偏好里的显式选择,装了个新的不该把
1361
956
  * 他正在用的顶掉。
1362
957
  */
1363
958
  function discoverAndProvideClients() {
1364
- const projectRequire = createRequire(pathToFileURL(resolve(process.cwd(), 'package.json')));
959
+ // 宿主不提供安装集目录时没有依赖表可读,只按扫描目录发现
960
+ const projectRequire = appDir ? createRequire(pathToFileURL(join(appDir, 'package.json'))) : undefined;
1365
961
  const env = {
1366
962
  ...fsScanEnv,
1367
963
  dirname,
1368
964
  resolvePkgJson: id => {
1369
965
  try {
1370
- return projectRequire.resolve(`${id}/package.json`);
966
+ return projectRequire?.resolve(`${id}/package.json`);
1371
967
  }
1372
968
  catch {
1373
969
  return undefined;
1374
970
  }
1375
971
  },
1376
972
  };
1377
- const scanDirs = LOCAL_SCAN_DIRS;
1378
973
  let depIds = [];
1379
- try {
1380
- // 每次重读:装完新包后根 dependencies 已变,缓存会让新前端发现不到。
1381
- const rootPkg = JSON.parse(readFileSync(resolve(process.cwd(), 'package.json'), 'utf-8'));
1382
- depIds = Object.keys({ ...rootPkg.dependencies, ...rootPkg.optionalDependencies });
1383
- }
1384
- catch {
1385
- /* 无项目根 package.json(如 monorepo 直跑),跳过 deps 扫描 */
974
+ if (appDir) {
975
+ try {
976
+ // 每次重读:装完新包后根 dependencies 已变,缓存会让新前端发现不到。
977
+ const rootPkg = JSON.parse(readFileSync(join(appDir, 'package.json'), 'utf-8'));
978
+ depIds = Object.keys({ ...rootPkg.dependencies, ...rootPkg.optionalDependencies });
979
+ }
980
+ catch {
981
+ /* 安装集没有 package.json,跳过 deps 扫描 */
982
+ }
1386
983
  }
1387
984
  const scanned = discoverClients(scanDirs, depIds, env);
1388
985
  // 对账:本轮扫不到的候选要摘掉,不能只增不删。卸载现在会真删 node_modules,
@@ -1397,7 +994,7 @@ async function startWebuiServer(caps) {
1397
994
  clientProviderDisposers.delete(stale.id);
1398
995
  clientCandidates.splice(i, 1);
1399
996
  removed++;
1400
- logger.info(`前端已消失,摘除候选: ${stale.label} (${stale.dir})`);
997
+ logger.use.info(`前端已消失,摘除候选: ${stale.label} (${stale.dir})`);
1401
998
  }
1402
999
  // 摘掉 provider 只改了「服务解析结果」,**真正服务 HTTP 的是 clientDist / staticMiddleware**,
1403
1000
  // 它们在 app:ready 时绑定、之后只由 remountActiveClient 更新。不重挂的话,被摘掉的正好是当前
@@ -1407,32 +1004,41 @@ async function startWebuiServer(caps) {
1407
1004
  remountActiveClient();
1408
1005
  const fresh = pickFreshClients(clientCandidates.map(c => c.id), scanned);
1409
1006
  clientCandidates.push(...fresh);
1410
- // 把每个新发现的前端注册为 webui-client 服务的一个 provider(带 label,供「服务」页下拉切换)。
1007
+ // 把每个新发现的前端注册为 webui-client 服务的一个 provider(带 label,供服务偏好里切换)。
1411
1008
  // 外部插件在 apply 里主动提供 webui-client 的也已在服务池中(注册更早 → 默认胜出,仍可被偏好切换)。
1412
1009
  //
1413
- // 代前端包登记:条目身份是前端**包名本身**——它是服务页展示的 contextId,也是
1010
+ // 代前端包登记:条目身份是前端**包名本身**——它是服务偏好里展示的 contextId,也是
1414
1011
  // servicePreferences['webui-client'] 里持久化的键,还是装卸闸按包名比对的对象。
1415
1012
  for (const candidate of fresh) {
1416
- clientProviderDisposers.set(candidate.id, provide(webuiClient, { getClientDir: () => candidate.dir }, { label: candidate.label, onBehalfOf: candidate.id }));
1417
- logger.info(`发现前端: ${candidate.label} (${candidate.dir})`);
1013
+ clientProviderDisposers.set(candidate.id, provide.use(webuiClient, { getClientDir: () => candidate.dir }, { label: candidate.label, entryId: candidate.id }));
1014
+ logger.use.info(`发现前端: ${candidate.label} (${candidate.dir})`);
1418
1015
  }
1419
1016
  }
1420
1017
  // 启动服务器
1421
- events.on('app:ready', async () => {
1018
+ events.use.on('app:ready', async () => {
1422
1019
  // 冷启动时 storage 的激活在 app:ready 之前收敛;等晚到的读回落定,listen / autoOpen /
1423
1020
  // access.txt 用的就是持久化的 token
1424
1021
  if (tokenLoading)
1425
1022
  await tokenLoading;
1023
+ // 等插件状态机静置再登记扫描到的前端:bounce 后 sticky 的 app:ready 在本实例激活途中补发,立刻登记会落在
1024
+ // provides 核对之前(dev 多报「未在 provides 中声明」),等过之后与冷启动一样晚于全部激活,主动覆盖仍先登记。
1025
+ // 代价:重算一直不收敛时,前端要等它收敛后才登记与挂上
1026
+ if (caps.plugins.present)
1027
+ await caps.plugins.use.idle();
1028
+ // 等待期间本实例可能已开始关闭(bounce、disable、unload)。醒来照常登记、listen 的话,它成了占着端口的旧实例:
1029
+ // 接手的新实例 listen 报 EADDRINUSE,禁用之后端口照样应答
1030
+ if (lifecycle.use.signal.aborted)
1031
+ return;
1426
1032
  discoverAndProvideClients();
1427
1033
  // 活跃前端 = 解析后的 webui-client 服务(servicePreferences 偏好 > 优先级 > 注册顺序);零前端则 404。
1428
1034
  const activeDir = currentClientDir();
1429
1035
  if (activeDir) {
1430
1036
  clientDist = activeDir;
1431
1037
  mountStaticDir(activeDir);
1432
- logger.info(`活跃前端: ${activeDir}`);
1038
+ logger.use.info(`活跃前端: ${activeDir}`);
1433
1039
  }
1434
1040
  else {
1435
- logger.warn('未发现任何前端(webui-client provider);前端路由将 404,请安装一个 aalis.client 包');
1041
+ logger.use.warn('未发现任何前端(webui-client provider);前端路由将 404,请安装一个 aalis.client 包');
1436
1042
  }
1437
1043
  // 监听失败(典型 EADDRINUSE:3000 被别的 dev server 占了)是 bind 之后**异步 emit** 的,
1438
1044
  // 不在任何调用栈内,外层 try/catch 接不住 —— 无人处理即 uncaughtException →
@@ -1444,7 +1050,7 @@ async function startWebuiServer(caps) {
1444
1050
  // wss)并排在最前;EventEmitter 按注册顺序调用,它 emit 到无监听器的 wss 上会同步抛出、
1445
1051
  // 中断 emit 循环,后注册在 server 上的 handler 因此永远轮不到。
1446
1052
  wss.on('error', (err) => {
1447
- logger.error(`WebUI 监听 ${uiConfig.host}:${uiConfig.port} 失败: ${err.message};WebUI 不可用,其余功能照常运行`);
1053
+ logger.use.error(`WebUI 监听 ${uiConfig.host}:${uiConfig.port} 失败: ${err.message};WebUI 不可用,其余功能照常运行`);
1448
1054
  });
1449
1055
  server.listen(uiConfig.port, uiConfig.host, () => {
1450
1056
  // 通配绑定地址(0.0.0.0 / ::)是"听在哪",不是"从哪访问"——直接拼进 URL 会让
@@ -1454,10 +1060,10 @@ async function startWebuiServer(caps) {
1454
1060
  const url = `http://${displayHost}:${uiConfig.port}/`;
1455
1061
  listeningUrl = url;
1456
1062
  const accessUrl = `${url}?token=${authToken}`;
1457
- logger.info(`WebUI 已启动: ${url}`);
1063
+ logger.use.info(`WebUI 已启动: ${url}`);
1458
1064
  // 多前端时提示恢复页 URL:万一切到无切换 UI 的前端被卡住,可在此切回(详见 client-switch-page.ts)。
1459
1065
  if (clientCandidates.length > 1) {
1460
- logger.info(`检测到多个前端;若卡在某前端无切换入口,可访问恢复页切回: ${url}__clients`);
1066
+ logger.use.info(`检测到多个前端;若卡在某前端无切换入口,可访问恢复页切回: ${url}__clients`);
1461
1067
  }
1462
1068
  const tokenHint = uiConfig.tokenMode === 'ephemeral'
1463
1069
  ? 'token 仅本次启动有效,重启轮换'
@@ -1465,15 +1071,16 @@ async function startWebuiServer(caps) {
1465
1071
  ? 'token 来自配置 fixedToken,固定不变'
1466
1072
  : 'token 已持久化到 storage data:/webui/token,重启沿用';
1467
1073
  // 不把 token 打进日志(会落 latest.log / 被 /api/logs/* 回放 / 贴日志求助时外泄)。
1468
- // 仅打不带 token 的 URL,完整一键登录链接见 access.txt(该文件应 0o600,见 token 落盘)。
1469
- logger.info(`首次访问 URL(${tokenHint}): ${url} —— 完整一键登录链接见 ${accessFileUri}`);
1074
+ // 仅打不带 token 的 URL,完整一键登录链接见 access.txt(按凭据写,只给本机用户读)。
1075
+ logger.use.info(`首次访问 URL(${tokenHint}): ${url} —— 完整一键登录链接见 ${accessFileUri}`);
1470
1076
  void (async () => {
1471
1077
  // 先把文件写出来,再解析它的绝对路径:resolveLocalPath 走 realpath,文件尚未落盘会抛
1472
1078
  // ENOENT 被吞掉,于是「绝对路径」退化成原样重复一遍 URI——偏偏首启(新用户唯一需要
1473
1079
  // 这行的时刻)必然命中,二次启动因文件已在反而正常。两者原先都是 void,谁先到看调度。
1474
1080
  if (!(await writeAccessFile(url, authToken))) {
1475
1081
  // 写失败上面已 warn 过;再打一条「已写入」只会让人去找一个不存在的文件
1476
- logger.warn(`访问凭据未能写入 ${accessFileUri};请用 WebUI 登录页手工粘贴 token`);
1082
+ logger.use.warn(`访问凭据未能写入 ${accessFileUri}:修好家目录 state/webui/ 的写权限后重启;` +
1083
+ `或把 tokenMode 设为 fixed、在 fixedToken 里填一个至少 ${FIXED_TOKEN_MIN_LENGTH} 个字符的 token,用它在登录页登录`);
1477
1084
  return;
1478
1085
  }
1479
1086
  let absHint = accessFileUri;
@@ -1483,24 +1090,28 @@ async function startWebuiServer(caps) {
1483
1090
  }
1484
1091
  }
1485
1092
  catch (err) {
1486
- logger.debug(`解析访问凭据文件绝对路径失败: ${err.message}`);
1093
+ logger.use.debug(`解析访问凭据文件绝对路径失败: ${err.message}`);
1487
1094
  }
1488
- logger.info(`访问凭据已写入: ${accessFileUri}(绝对路径: ${absHint})`);
1095
+ logger.use.info(`访问凭据已写入: ${accessFileUri}(绝对路径: ${absHint})`);
1489
1096
  })();
1490
- if (uiConfig.autoOpen && tokenFresh)
1491
- openBrowser(accessUrl, createProcessGateway(caps.process));
1097
+ // 记的是宿主实例本身(业务句柄每次激活各一个,认不出同一个宿主)
1098
+ const host = services.use.get(appService);
1099
+ if (uiConfig.autoOpen && (tokenFresh || !host || !browserOpenedFor.has(host))) {
1100
+ if (host)
1101
+ browserOpenedFor.add(host);
1102
+ openBrowser(accessUrl, caps.process.use);
1103
+ }
1492
1104
  });
1493
1105
  });
1494
- lifecycle.onDispose(() => {
1495
- confirmChannel?.dispose(); // 清 WebUI 确认通道里挂起的待确认(安全拒),避免 Promise 永挂
1106
+ lifecycle.use.onDispose(() => {
1496
1107
  heartbeat.dispose();
1497
- removeLogListener();
1108
+ pages.dispose();
1498
1109
  // 先把已建立的连接收口再关服务端:ws 的 close() 在 {server} 模式下只摘监听器、对
1499
- // this.clients 一个都不动,server.close() 也只停止 accept。不主动关的话,旧 socket 上
1500
- // 注册的 message 闭包在插件卸载后仍然活着,照常发 'inbound:message'——而事件发射
1501
- // 不设关闭守卫(订阅与发布两侧不对称)。
1502
- // 发 1001 触发前端既有的 onclose 重连路径,bounce 之后自动接回新实例。
1503
- for (const ws of allClients) {
1110
+ // this.clients 一个都不动,server.close() 也只停止 accept。不主动关的话,旧 socket 在插件卸载后
1111
+ // 仍然活着,照常把帧转给会话服务,发出的消息照样进入站事件。
1112
+ // 发 1001 触发前端既有的 onclose 重连路径,bounce 之后自动接回新实例。各端点的连接都在 wss.clients 里,
1113
+ // /session 的连接关闭时撤掉会话服务那头的连接。
1114
+ for (const ws of wss.clients) {
1504
1115
  try {
1505
1116
  ws.close(1001, 'webui 正在卸载');
1506
1117
  }
@@ -1508,95 +1119,30 @@ async function startWebuiServer(caps) {
1508
1119
  /* 单条连接关不掉不应阻塞拆卸 */
1509
1120
  }
1510
1121
  }
1511
- allClients.clear();
1512
1122
  wss.close();
1513
1123
  server.close();
1514
1124
  });
1515
- // 构造 PlatformAdapter 实例
1516
- const adapter = {
1125
+ // 平台名 webui 标明说话的是从 WebUI 来的 owner。登记成平台,平台档、会话范围的候选与用户关系认的平台名里才有它;
1126
+ // 连接状态数的是打开着的 /session 连接。WebUI 的会话不经适配器收发:页面走会话协议,出站消息由上面的收件箱认领
1127
+ provide.use(platform, {
1517
1128
  adapterName: 'WebUI',
1518
1129
  platform: 'webui',
1519
- sessionTypes: [], // WebUI 单会话,不区分 sessionType
1520
1130
  getConnections() {
1521
- // 统计所有活跃 WebSocket 客户端
1522
- const activeCount = [...allClients].filter(ws => ws.readyState === WebSocket.OPEN).length;
1523
- if (activeCount === 0)
1524
- return [];
1525
- return [
1526
- {
1527
- id: 'webui',
1528
- platform: 'webui',
1529
- status: 'online',
1530
- detail: { clients: activeCount },
1531
- },
1532
- ];
1131
+ const clients = sessionSockets.size;
1132
+ return clients === 0 ? [] : [{ id: 'webui', platform: 'webui', status: 'online', detail: { clients } }];
1533
1133
  },
1534
- async sendMessage(sessionId, content) {
1535
- const sockets = sessions.get(sessionId);
1536
- if (!sockets)
1537
- return;
1538
- const payload = {
1539
- type: 'message',
1540
- content,
1541
- sessionId,
1542
- };
1543
- const json = JSON.stringify(payload);
1544
- for (const ws of sockets) {
1545
- if (ws.readyState === WebSocket.OPEN) {
1546
- ws.send(json);
1547
- }
1548
- }
1134
+ async sendMessage() {
1135
+ throw new Error('WebUI 的会话不经平台适配器发消息:页面经会话协议收发,发往 WebUI 会话的出站消息写进会话历史');
1549
1136
  },
1550
- };
1551
- provide(platform, adapter);
1137
+ });
1552
1138
  // === 注册 WebUI 服务 ===
1553
- const registeredPages = new Map();
1554
- // 内建页面归属为 webui-server 本身
1555
- for (const page of webuiPages) {
1556
- const list = registeredPages.get(name) ?? [];
1557
- list.push({ ...page, pluginName: name });
1558
- registeredPages.set(name, list);
1559
- }
1560
1139
  const webuiService = {
1561
1140
  getPort: () => uiConfig.port,
1562
1141
  getHost: () => uiConfig.host,
1563
- registerPage(page, contextId) {
1564
- const list = registeredPages.get(contextId) ?? [];
1565
- const entry = { ...page, pluginName: contextId };
1566
- list.push(entry);
1567
- registeredPages.set(contextId, list);
1568
- // 退订按条目引用比对:同 key 重注册后,旧退订闭包不得摘掉新登记
1569
- return () => {
1570
- const cur = registeredPages.get(contextId);
1571
- if (!cur)
1572
- return;
1573
- const idx = cur.indexOf(entry);
1574
- if (idx >= 0)
1575
- cur.splice(idx, 1);
1576
- if (cur.length === 0)
1577
- registeredPages.delete(contextId);
1578
- };
1579
- },
1580
- getPages() {
1581
- const out = [];
1582
- for (const list of registeredPages.values())
1583
- out.push(...list);
1584
- return out;
1585
- },
1586
- registerAction(method, handler, contextId) {
1587
- const actions = registeredActions.get(contextId) ?? new Map();
1588
- actions.set(method, handler);
1589
- registeredActions.set(contextId, actions);
1590
- // 退订按这一次登记比对:同名替换后旧退订闭包不得摘掉新登记
1591
- return () => {
1592
- if (actions.get(method) !== handler)
1593
- return;
1594
- actions.delete(method);
1595
- if (actions.size === 0 && registeredActions.get(contextId) === actions)
1596
- registeredActions.delete(contextId);
1597
- };
1598
- },
1142
+ addPage: pages.addPage,
1143
+ invalidate: pages.invalidate,
1144
+ addNotice: pages.addNotice,
1599
1145
  };
1600
- provide(webuiServer, webuiService);
1146
+ provide.use(webuiServer, webuiService);
1601
1147
  }
1602
1148
  //# sourceMappingURL=index.js.map