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