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