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