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