@ai-zen/agents-host 0.0.0-stage → 0.1.0-alpha.1
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/CHANGELOG.md +39 -0
- package/LICENSE +21 -0
- package/README.md +759 -2
- package/dist/app.d.ts +384 -0
- package/dist/app.js +1148 -0
- package/dist/cli.d.ts +22 -0
- package/dist/cli.js +354 -0
- package/dist/config.d.ts +97 -0
- package/dist/config.js +171 -0
- package/dist/handlers/agentHandlers.d.ts +18 -0
- package/dist/handlers/agentHandlers.js +20 -0
- package/dist/handlers/assetHandlers.d.ts +29 -0
- package/dist/handlers/assetHandlers.js +35 -0
- package/dist/handlers/capabilities.d.ts +26 -0
- package/dist/handlers/capabilities.js +138 -0
- package/dist/handlers/completionHandlers.d.ts +18 -0
- package/dist/handlers/completionHandlers.js +13 -0
- package/dist/handlers/configHandlers.d.ts +31 -0
- package/dist/handlers/configHandlers.js +59 -0
- package/dist/handlers/conversationHandlers.d.ts +118 -0
- package/dist/handlers/conversationHandlers.js +314 -0
- package/dist/handlers/handoffHandlers.d.ts +17 -0
- package/dist/handlers/handoffHandlers.js +14 -0
- package/dist/handlers/hostHandlers.d.ts +39 -0
- package/dist/handlers/hostHandlers.js +56 -0
- package/dist/handlers/index.d.ts +128 -0
- package/dist/handlers/index.js +92 -0
- package/dist/handlers/messageHandlers.d.ts +11 -0
- package/dist/handlers/messageHandlers.js +10 -0
- package/dist/handlers/modelHandlers.d.ts +17 -0
- package/dist/handlers/modelHandlers.js +19 -0
- package/dist/handlers/noticeHandlers.d.ts +24 -0
- package/dist/handlers/noticeHandlers.js +24 -0
- package/dist/handlers/questionHandlers.d.ts +16 -0
- package/dist/handlers/questionHandlers.js +16 -0
- package/dist/handlers/searchHandlers.d.ts +12 -0
- package/dist/handlers/searchHandlers.js +12 -0
- package/dist/handlers/workspaceHandlers.d.ts +29 -0
- package/dist/handlers/workspaceHandlers.js +47 -0
- package/dist/i18n/en.d.ts +243 -0
- package/dist/i18n/en.js +270 -0
- package/dist/i18n/index.d.ts +43 -0
- package/dist/i18n/index.js +72 -0
- package/dist/i18n/zh.d.ts +240 -0
- package/dist/i18n/zh.js +257 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +15 -0
- package/dist/net/UploadReceiver.d.ts +186 -0
- package/dist/net/UploadReceiver.js +494 -0
- package/dist/net/WsHub.d.ts +231 -0
- package/dist/net/WsHub.js +689 -0
- package/dist/net/auth.d.ts +73 -0
- package/dist/net/auth.js +74 -0
- package/dist/net/discovery.d.ts +72 -0
- package/dist/net/discovery.js +171 -0
- package/dist/net/index.d.ts +14 -0
- package/dist/net/index.js +10 -0
- package/dist/runtime/ChatRunner.d.ts +374 -0
- package/dist/runtime/ChatRunner.js +1204 -0
- package/dist/runtime/ConversationDrafts.d.ts +43 -0
- package/dist/runtime/ConversationDrafts.js +47 -0
- package/dist/runtime/RunRegistry.d.ts +79 -0
- package/dist/runtime/RunRegistry.js +108 -0
- package/dist/runtime/ScopeRegistry.d.ts +153 -0
- package/dist/runtime/ScopeRegistry.js +259 -0
- package/dist/runtime/TransientRunner.d.ts +90 -0
- package/dist/runtime/TransientRunner.js +179 -0
- package/dist/runtime/index.d.ts +14 -0
- package/dist/runtime/index.js +10 -0
- package/dist/services/AgentService.d.ts +150 -0
- package/dist/services/AgentService.js +718 -0
- package/dist/services/AssetService.d.ts +338 -0
- package/dist/services/AssetService.js +739 -0
- package/dist/services/AutoNaming.d.ts +92 -0
- package/dist/services/AutoNaming.js +235 -0
- package/dist/services/CompletionService.d.ts +122 -0
- package/dist/services/CompletionService.js +442 -0
- package/dist/services/ConfigService.d.ts +171 -0
- package/dist/services/ConfigService.js +695 -0
- package/dist/services/ConversationService.d.ts +274 -0
- package/dist/services/ConversationService.js +902 -0
- package/dist/services/CredentialService.d.ts +33 -0
- package/dist/services/CredentialService.js +86 -0
- package/dist/services/DiscoveryService.d.ts +182 -0
- package/dist/services/DiscoveryService.js +998 -0
- package/dist/services/HandoffService.d.ts +189 -0
- package/dist/services/HandoffService.js +414 -0
- package/dist/services/Logger.d.ts +130 -0
- package/dist/services/Logger.js +256 -0
- package/dist/services/MessageService.d.ts +88 -0
- package/dist/services/MessageService.js +280 -0
- package/dist/services/ModelService.d.ts +89 -0
- package/dist/services/ModelService.js +489 -0
- package/dist/services/NoticeService.d.ts +93 -0
- package/dist/services/NoticeService.js +217 -0
- package/dist/services/QuestionService.d.ts +184 -0
- package/dist/services/QuestionService.js +457 -0
- package/dist/services/SearchService.d.ts +54 -0
- package/dist/services/SearchService.js +117 -0
- package/dist/services/WorkspaceService.d.ts +96 -0
- package/dist/services/WorkspaceService.js +213 -0
- package/dist/services/imageSize.d.ts +41 -0
- package/dist/services/imageSize.js +146 -0
- package/dist/services/index.d.ts +20 -0
- package/dist/services/index.js +15 -0
- package/dist/services/secretMask.d.ts +24 -0
- package/dist/services/secretMask.js +34 -0
- package/dist/storage/AssetRepository.d.ts +87 -0
- package/dist/storage/AssetRepository.js +180 -0
- package/dist/storage/ConversationRepository.d.ts +55 -0
- package/dist/storage/ConversationRepository.js +128 -0
- package/dist/storage/CredentialRepository.d.ts +28 -0
- package/dist/storage/CredentialRepository.js +47 -0
- package/dist/storage/Database.d.ts +95 -0
- package/dist/storage/Database.js +180 -0
- package/dist/storage/MessageRepository.d.ts +104 -0
- package/dist/storage/MessageRepository.js +323 -0
- package/dist/storage/MetaRepository.d.ts +21 -0
- package/dist/storage/MetaRepository.js +33 -0
- package/dist/storage/NoticeRepository.d.ts +31 -0
- package/dist/storage/NoticeRepository.js +89 -0
- package/dist/storage/QuestionRepository.d.ts +34 -0
- package/dist/storage/QuestionRepository.js +96 -0
- package/dist/storage/WorkspaceRepository.d.ts +25 -0
- package/dist/storage/WorkspaceRepository.js +44 -0
- package/dist/storage/dbWorker.d.ts +10 -0
- package/dist/storage/dbWorker.js +132 -0
- package/dist/storage/index.d.ts +20 -0
- package/dist/storage/index.js +16 -0
- package/dist/storage/migrations.d.ts +26 -0
- package/dist/storage/migrations.js +207 -0
- package/dist/storage/types.d.ts +298 -0
- package/dist/storage/types.js +14 -0
- package/dist/wire/errors.d.ts +105 -0
- package/dist/wire/errors.js +143 -0
- package/dist/wire/events.d.ts +102 -0
- package/dist/wire/events.js +13 -0
- package/dist/wire/frames.d.ts +106 -0
- package/dist/wire/frames.js +156 -0
- package/dist/wire/index.d.ts +31 -0
- package/dist/wire/index.js +31 -0
- package/dist/wire/methods.d.ts +1896 -0
- package/dist/wire/methods.js +99 -0
- package/dist/wire/states.d.ts +487 -0
- package/dist/wire/states.js +61 -0
- package/package.json +63 -3
|
@@ -0,0 +1,689 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `WsHub` —— 一条 WebSocket 上的接线层(architecture A2 / §6)。
|
|
3
|
+
*
|
|
4
|
+
* 它只做四件事,一件也不多做:
|
|
5
|
+
*
|
|
6
|
+
* 1. **握手**:首帧必须是 `hello`(`{ token, language?, clientInfo }`)。没通过之前除了 `hello`
|
|
7
|
+
* 什么都不接(一律 `unauthorized`),凭证不放在 URL 查询串里。`language` 挂在这条连接上、
|
|
8
|
+
* 断开即忘(PRD D8)。
|
|
9
|
+
* 2. **分发**:`MethodMap` 里的方法交给注入的 handler 表;没注册的方法回 `unsupported`
|
|
10
|
+
* (接线层自己代管的只有 `host.hello` / `host.setLanguage` 与上传三段)。
|
|
11
|
+
* 业务逻辑一律不在本文件里。
|
|
12
|
+
* 3. **推送**:JSON-RPC 通知(无 id),`params` 就是事件载荷。不重放、不需要 ack(PRD L5:
|
|
13
|
+
* 断线重连由客户端自己拉)。受理即返回的方法(`conversation.send` 这类)会产生「事件早于回包」
|
|
14
|
+
* 的顺序问题,所以按 `conversationId` 缓冲,回包写完再按序放行(裁定 R47)。
|
|
15
|
+
* 4. **上传帧**:二进制帧交给 `UploadReceiver` 落盘;连接断开时把它名下未完成的上传清掉。
|
|
16
|
+
*
|
|
17
|
+
* 错误一律走 JSON-RPC `error`:`data.code` 是稳定业务码、`data.message` 是按**这条连接的语言**
|
|
18
|
+
* 渲染好的句子、`data.detail` 是结构化事实。非 `HostError` 的抛出收敛成 `internal` ——
|
|
19
|
+
* 细节只进日志,栈与内部路径不出网。
|
|
20
|
+
*/
|
|
21
|
+
import { randomUUID } from "node:crypto";
|
|
22
|
+
import { WebSocket, WebSocketServer } from "ws";
|
|
23
|
+
import { normalizeLanguage } from "../i18n/index.js";
|
|
24
|
+
import { MAX_ASSETS_PER_MESSAGE, MAX_ASSET_BYTES, PROTOCOL_VERSION, HostError, errResponse, isMethodName, notification, okResponse, parseFrame, } from "../wire/index.js";
|
|
25
|
+
import { decodeChunkFrame } from "./UploadReceiver.js";
|
|
26
|
+
/** 安静日志(缺省值,不导出:不让它变成别处共享的全局) */
|
|
27
|
+
const QUIET_LOGGER = {
|
|
28
|
+
warn: () => undefined,
|
|
29
|
+
error: () => undefined,
|
|
30
|
+
};
|
|
31
|
+
/** `hello` 之前唯一允许的方法 */
|
|
32
|
+
const HELLO_METHOD = "host.hello";
|
|
33
|
+
export class WsHub {
|
|
34
|
+
options;
|
|
35
|
+
logger;
|
|
36
|
+
clock;
|
|
37
|
+
startedAt;
|
|
38
|
+
/** 缺省语言来源(可能是函数:配置里的那一项可改,每次握手现算) */
|
|
39
|
+
defaultLanguageSource;
|
|
40
|
+
/** connectionId → 连接状态(关闭即移除;推送只发给还在这里面的连接) */
|
|
41
|
+
connections = new Map();
|
|
42
|
+
server = null;
|
|
43
|
+
closing = null;
|
|
44
|
+
constructor(options) {
|
|
45
|
+
this.options = options;
|
|
46
|
+
this.logger = options.logger ?? QUIET_LOGGER;
|
|
47
|
+
this.clock = options.clock ?? Date.now;
|
|
48
|
+
this.startedAt = options.startedAt ?? new Date(this.clock()).toISOString();
|
|
49
|
+
this.defaultLanguageSource = options.defaultLanguage ?? "en";
|
|
50
|
+
}
|
|
51
|
+
// -------------------------------------------------------------------------
|
|
52
|
+
// 启停
|
|
53
|
+
// -------------------------------------------------------------------------
|
|
54
|
+
/**
|
|
55
|
+
* 开始监听。
|
|
56
|
+
*
|
|
57
|
+
* 返回**实际**监听地址:端口给 `0` 时由系统分配,回调里拿到的才是真端口 ——
|
|
58
|
+
* 调用方要把它写进发现文件(客户端读的是那里,不是启动参数)。
|
|
59
|
+
*/
|
|
60
|
+
async listen() {
|
|
61
|
+
if (this.server)
|
|
62
|
+
throw new Error("WsHub 已经在监听");
|
|
63
|
+
const server = new WebSocketServer({ host: this.options.host, port: this.options.port });
|
|
64
|
+
server.on("connection", (socket) => this.accept(socket));
|
|
65
|
+
await new Promise((resolve, reject) => {
|
|
66
|
+
const onError = (error) => {
|
|
67
|
+
server.off("listening", onListening);
|
|
68
|
+
reject(error);
|
|
69
|
+
};
|
|
70
|
+
const onListening = () => {
|
|
71
|
+
server.off("error", onError);
|
|
72
|
+
resolve();
|
|
73
|
+
};
|
|
74
|
+
server.once("error", onError);
|
|
75
|
+
server.once("listening", onListening);
|
|
76
|
+
});
|
|
77
|
+
// 监听成功之后的错误(某个连接上的异常等)记日志,不让它变成未捕获异常
|
|
78
|
+
server.on("error", (error) => this.logger.error("WebSocket 服务器出错", { error }));
|
|
79
|
+
this.server = server;
|
|
80
|
+
const address = server.address();
|
|
81
|
+
if (typeof address === "string" || address === null) {
|
|
82
|
+
return { host: this.options.host, port: this.options.port };
|
|
83
|
+
}
|
|
84
|
+
const info = address;
|
|
85
|
+
return { host: info.address, port: info.port };
|
|
86
|
+
}
|
|
87
|
+
/** 实际监听地址;没在监听时为 `null` */
|
|
88
|
+
address() {
|
|
89
|
+
const address = this.server?.address();
|
|
90
|
+
if (!address || typeof address === "string")
|
|
91
|
+
return null;
|
|
92
|
+
return { host: address.address, port: address.port };
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* 收尾:先给每条连接一个正常的关闭帧,再关服务器;有连接迟迟不断就强制断开。
|
|
96
|
+
*
|
|
97
|
+
* 收尾之后不再接受新连接,也不再持有任何连接状态与上传状态。
|
|
98
|
+
*/
|
|
99
|
+
close() {
|
|
100
|
+
this.closing ??= this.doClose();
|
|
101
|
+
return this.closing;
|
|
102
|
+
}
|
|
103
|
+
async doClose() {
|
|
104
|
+
const server = this.server;
|
|
105
|
+
this.server = null;
|
|
106
|
+
const states = [...this.connections.values()];
|
|
107
|
+
for (const state of states) {
|
|
108
|
+
try {
|
|
109
|
+
state.socket.close(1001, "host shutting down");
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
// 已经断了:下面统一按「连接没了」处理
|
|
113
|
+
}
|
|
114
|
+
await this.options.uploads?.abortConnection(state.id).catch((error) => {
|
|
115
|
+
this.logger.warn("清理上传失败", { error });
|
|
116
|
+
});
|
|
117
|
+
state.holds.clear();
|
|
118
|
+
this.connections.delete(state.id);
|
|
119
|
+
}
|
|
120
|
+
this.notifyConnectionsChanged();
|
|
121
|
+
if (!server)
|
|
122
|
+
return;
|
|
123
|
+
await new Promise((resolve) => {
|
|
124
|
+
const timer = setTimeout(() => {
|
|
125
|
+
// 兜底:还在半开状态的连接直接断掉,避免关宿主时挂在这里
|
|
126
|
+
for (const client of server.clients)
|
|
127
|
+
client.terminate();
|
|
128
|
+
}, 200);
|
|
129
|
+
server.close(() => {
|
|
130
|
+
clearTimeout(timer);
|
|
131
|
+
resolve();
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
// -------------------------------------------------------------------------
|
|
136
|
+
// 推送(宿主 → 客户端)
|
|
137
|
+
// -------------------------------------------------------------------------
|
|
138
|
+
/**
|
|
139
|
+
* 推一个事件给所有已握手的连接。
|
|
140
|
+
*
|
|
141
|
+
* 形状与 `ChatRunner` 的 `ChatEventEmitter` 一致,所以单根可以把它直接当 `emit` 注入。
|
|
142
|
+
* 事件里带 `conversationId` 的(全部事件都带)会受该对话的缓冲控制:有请求还没回包时先排队。
|
|
143
|
+
*/
|
|
144
|
+
push(event, payload) {
|
|
145
|
+
const key = conversationKeyOf(event, payload);
|
|
146
|
+
for (const state of this.connections.values()) {
|
|
147
|
+
this.deliver(state, key, () => this.frameFor(state, event, payload));
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
/** 推一个事件给指定连接;连接不在(已断开)时返回 `false` */
|
|
151
|
+
pushTo(connectionId, event, payload) {
|
|
152
|
+
const state = this.connections.get(connectionId);
|
|
153
|
+
if (!state)
|
|
154
|
+
return false;
|
|
155
|
+
this.deliver(state, conversationKeyOf(event, payload), () => this.frameFor(state, event, payload));
|
|
156
|
+
return true;
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* 把事件载荷变成这条连接要收的通知帧。
|
|
160
|
+
*
|
|
161
|
+
* 注入了 `localizeEvent` 时先过一道:事件里的 `LocalizedText` 是在产生现场渲染的(那时不知道
|
|
162
|
+
* 收件人是谁),而语言挂在连接上(D8),所以真正的「按这条连接的语言说这句话」发生在这里。
|
|
163
|
+
* 没注入时原样搬运(测试与不关心多语言的场合)。
|
|
164
|
+
*/
|
|
165
|
+
frameFor(state, event, payload) {
|
|
166
|
+
const localize = this.options.localizeEvent;
|
|
167
|
+
const body = localize ? localize(event, payload, state.language) : payload;
|
|
168
|
+
return notification(event, body);
|
|
169
|
+
}
|
|
170
|
+
/** 当前在线的连接(只给事实,不给 socket) */
|
|
171
|
+
connectionInfos() {
|
|
172
|
+
return [...this.connections.values()].map((state) => toConnectionInfo(state));
|
|
173
|
+
}
|
|
174
|
+
/** 报一次连接数变化;回调自己抛错不影响连接本身 */
|
|
175
|
+
notifyConnectionsChanged() {
|
|
176
|
+
const listener = this.options.onConnectionsChanged;
|
|
177
|
+
if (!listener)
|
|
178
|
+
return;
|
|
179
|
+
try {
|
|
180
|
+
listener(this.connections.size);
|
|
181
|
+
}
|
|
182
|
+
catch (error) {
|
|
183
|
+
this.logger.warn("连接数变化回调抛错", { error: describeError(error) });
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
// -------------------------------------------------------------------------
|
|
187
|
+
// 连接
|
|
188
|
+
// -------------------------------------------------------------------------
|
|
189
|
+
accept(socket) {
|
|
190
|
+
const state = {
|
|
191
|
+
id: randomUUID(),
|
|
192
|
+
socket,
|
|
193
|
+
// 握完手就换成协商出的那一份;这里是它之前的样子(同步可得的那个值)
|
|
194
|
+
language: this.initialLanguage(),
|
|
195
|
+
clientInfo: null,
|
|
196
|
+
credentialId: null,
|
|
197
|
+
authorized: false,
|
|
198
|
+
holds: new Map(),
|
|
199
|
+
};
|
|
200
|
+
this.connections.set(state.id, state);
|
|
201
|
+
this.notifyConnectionsChanged();
|
|
202
|
+
socket.on("message", (data, isBinary) => {
|
|
203
|
+
void this.onFrame(state, data, isBinary).catch((error) => {
|
|
204
|
+
// 兜底:任何没被分支捕获的抛出都只能进日志(连接已经没法给出更具体的回答)
|
|
205
|
+
this.logger.error("处理帧时出现未捕获的错误", { error: describeError(error) });
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
socket.on("close", () => this.drop(state));
|
|
209
|
+
socket.on("error", (error) => {
|
|
210
|
+
this.logger.warn("连接出错", { connectionId: state.id, error });
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
/** 连接断开:把它的上传与缓冲一起扔掉(订阅就是「它在不在这个集合里」) */
|
|
214
|
+
drop(state) {
|
|
215
|
+
if (!this.connections.delete(state.id))
|
|
216
|
+
return;
|
|
217
|
+
state.holds.clear();
|
|
218
|
+
this.notifyConnectionsChanged();
|
|
219
|
+
void this.options.uploads?.abortConnection(state.id).catch((error) => {
|
|
220
|
+
this.logger.warn("断线清理上传失败", { connectionId: state.id, error });
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
// -------------------------------------------------------------------------
|
|
224
|
+
// 入站帧
|
|
225
|
+
// -------------------------------------------------------------------------
|
|
226
|
+
async onFrame(state, data, isBinary) {
|
|
227
|
+
if (isBinary) {
|
|
228
|
+
await this.onBinary(state, toBytes(data));
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
let frame;
|
|
232
|
+
try {
|
|
233
|
+
frame = parseFrame(toText(data));
|
|
234
|
+
}
|
|
235
|
+
catch (error) {
|
|
236
|
+
this.send(state, this.errorFrame(extractId(toText(data)), error, state.language));
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
if (frame.kind === "request") {
|
|
240
|
+
await this.onRequest(state, frame.id, frame.method, frame.params);
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
if (frame.kind === "notification") {
|
|
244
|
+
await this.onNotification(state, frame.method, frame.params);
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
// 客户端发来的响应:我们的协议里没有「宿主向客户端请求」这回事
|
|
248
|
+
this.send(state, this.errorFrame(null, new HostError("invalid_params", {
|
|
249
|
+
detail: { reason: "unexpected_response", got: frame.kind },
|
|
250
|
+
}), state.language));
|
|
251
|
+
}
|
|
252
|
+
/** 二进制帧:只可能是上传分片 */
|
|
253
|
+
async onBinary(state, bytes) {
|
|
254
|
+
const uploads = this.options.uploads;
|
|
255
|
+
if (!uploads) {
|
|
256
|
+
this.send(state, this.errorFrame(null, new HostError("unsupported", {
|
|
257
|
+
params: { what: "binary uploads" },
|
|
258
|
+
detail: { reason: "uploads_not_enabled" },
|
|
259
|
+
}), state.language));
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
if (!state.authorized) {
|
|
263
|
+
this.send(state, this.errorFrame(null, new HostError("unauthorized", { detail: { reason: "hello_required" } }), state.language));
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
try {
|
|
267
|
+
await uploads.receiveBinary(state.id, bytes);
|
|
268
|
+
}
|
|
269
|
+
catch (error) {
|
|
270
|
+
this.send(state, this.chunkErrorFrame(error, bytes, state.language));
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* 分片帧的错误帧。
|
|
275
|
+
*
|
|
276
|
+
* 二进制帧没有 `id`,所以「哪一趟上传被拒了」只能靠 `detail.uploadId` 认出来:这里尽力从帧头
|
|
277
|
+
* 取一次 `uploadId` 补进 `detail`(原有的结构化事实不动,已经有 `uploadId` 就不覆盖)。
|
|
278
|
+
* 业务错误原样出网;非业务错误按 `internal` 出网、细节只进日志。帧头坏到解不出 `uploadId` 时
|
|
279
|
+
* 与原来一样,什么也不补。
|
|
280
|
+
*/
|
|
281
|
+
chunkErrorFrame(error, frame, language) {
|
|
282
|
+
const uploadId = uploadIdOf(frame);
|
|
283
|
+
if (uploadId === null)
|
|
284
|
+
return this.errorFrame(null, error, language);
|
|
285
|
+
if (HostError.is(error)) {
|
|
286
|
+
return errResponse(null, withUploadId(error, uploadId), language);
|
|
287
|
+
}
|
|
288
|
+
this.logger.error("处理上传分片时出现非业务错误", { uploadId, error: describeError(error) });
|
|
289
|
+
return errResponse(null, new HostError("internal", { detail: { reason: "chunk_failed", uploadId } }), language);
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* 请求帧:先按对话挂起推送,再分发,最后写回包,回包写完按序放行该对话的推送(R47)。
|
|
293
|
+
*
|
|
294
|
+
* 「先挂起再分发」是关键:事件可能在分发的第一行就产生(受理即返回的方法正是这样),
|
|
295
|
+
* 挂起晚一步就会出现「客户端还没拿到 id 就收到事件」。
|
|
296
|
+
*/
|
|
297
|
+
async onRequest(state, id, method, params) {
|
|
298
|
+
const holdKey = conversationKeyOfParams(params);
|
|
299
|
+
if (holdKey !== null)
|
|
300
|
+
this.hold(state, holdKey);
|
|
301
|
+
let response;
|
|
302
|
+
try {
|
|
303
|
+
const result = await this.invoke(state, method, params);
|
|
304
|
+
response = okResponse(id, result);
|
|
305
|
+
}
|
|
306
|
+
catch (error) {
|
|
307
|
+
response = this.errorFrame(id, error, state.language);
|
|
308
|
+
}
|
|
309
|
+
this.send(state, response);
|
|
310
|
+
// 回包已经交给 socket(ws 按调用顺序写出),这时候放行不会插到回包前面
|
|
311
|
+
if (holdKey !== null)
|
|
312
|
+
this.release(state, holdKey);
|
|
313
|
+
}
|
|
314
|
+
/** 通知帧(客户端发来的、不需要回包):照常执行,出错时用 `id: null` 报一次 */
|
|
315
|
+
async onNotification(state, method, params) {
|
|
316
|
+
const holdKey = conversationKeyOfParams(params);
|
|
317
|
+
if (holdKey !== null)
|
|
318
|
+
this.hold(state, holdKey);
|
|
319
|
+
try {
|
|
320
|
+
await this.invoke(state, method, params);
|
|
321
|
+
}
|
|
322
|
+
catch (error) {
|
|
323
|
+
this.send(state, this.errorFrame(null, error, state.language));
|
|
324
|
+
}
|
|
325
|
+
if (holdKey !== null)
|
|
326
|
+
this.release(state, holdKey);
|
|
327
|
+
}
|
|
328
|
+
// -------------------------------------------------------------------------
|
|
329
|
+
// 分发
|
|
330
|
+
// -------------------------------------------------------------------------
|
|
331
|
+
/**
|
|
332
|
+
* 方法名 → 结果。
|
|
333
|
+
*
|
|
334
|
+
* 顺序是有意的:`hello` 永远放行(握手本身不需要先握手);其余方法在未握手时一律
|
|
335
|
+
* `unauthorized`(连方法名都不判断,免得「未授权的连接能探出宿主有哪些方法」)。
|
|
336
|
+
*/
|
|
337
|
+
async invoke(state, method, params) {
|
|
338
|
+
if (method === HELLO_METHOD)
|
|
339
|
+
return await this.handleHello(state, params);
|
|
340
|
+
if (!state.authorized) {
|
|
341
|
+
throw new HostError("unauthorized", { detail: { reason: "hello_required" } });
|
|
342
|
+
}
|
|
343
|
+
if (!isMethodName(method)) {
|
|
344
|
+
throw new HostError("unsupported", {
|
|
345
|
+
params: { what: method },
|
|
346
|
+
detail: { method, reason: "unknown_method" },
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
// 语言是连接状态,接线层自己管(业务层不该知道「这条连接说哪种语言」之外的任何事)
|
|
350
|
+
if (method === "host.setLanguage")
|
|
351
|
+
return this.handleSetLanguage(state, params);
|
|
352
|
+
if (this.options.uploads) {
|
|
353
|
+
if (method === "asset.begin") {
|
|
354
|
+
// 语言是连接状态:草稿对话落库时要靠它渲染那条隐藏的 cwd 消息(D8)
|
|
355
|
+
return await this.options.uploads.begin(state.id, params, state.language);
|
|
356
|
+
}
|
|
357
|
+
if (method === "asset.end") {
|
|
358
|
+
return await this.options.uploads.end(state.id, params);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
const handler = this.options.handlers[method];
|
|
362
|
+
if (typeof handler !== "function") {
|
|
363
|
+
throw new HostError("unsupported", {
|
|
364
|
+
params: { what: method },
|
|
365
|
+
detail: { method, reason: "handler_not_registered" },
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
const call = handler;
|
|
369
|
+
return await call({ method, params, connection: toConnectionInfo(state) });
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* 握手。
|
|
373
|
+
*
|
|
374
|
+
* `language` 认不出来时回落英文(PRD D8);不传就用配置的默认语言。
|
|
375
|
+
* 失败不改变这条连接已经有的授权状态(重复握手是客户端自己的选择,失败不该把现有连接打回未授权)。
|
|
376
|
+
*/
|
|
377
|
+
async handleHello(state, params) {
|
|
378
|
+
const hello = asHelloParams(params);
|
|
379
|
+
const verified = await this.options.verifyToken.verify(hello.token);
|
|
380
|
+
if (verified === null) {
|
|
381
|
+
throw new HostError("unauthorized", { detail: { reason: "token_rejected" } });
|
|
382
|
+
}
|
|
383
|
+
state.authorized = true;
|
|
384
|
+
state.credentialId = verified.id;
|
|
385
|
+
state.clientInfo = hello.clientInfo;
|
|
386
|
+
state.language =
|
|
387
|
+
typeof hello.language === "string" && hello.language.trim() !== ""
|
|
388
|
+
? normalizeLanguage(hello.language)
|
|
389
|
+
: await this.resolveDefaultLanguage();
|
|
390
|
+
return {
|
|
391
|
+
language: state.language,
|
|
392
|
+
hostVersion: this.options.hostVersion,
|
|
393
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
394
|
+
startedAt: this.startedAt,
|
|
395
|
+
// 握手只给能力 id 列表(R57):清单本身按这条连接的语言渲染,但 id 与顺序固定
|
|
396
|
+
capabilities: (this.options.capabilities?.(state.language) ?? []).map((capability) => capability.id),
|
|
397
|
+
limits: this.transferLimits(),
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
/**
|
|
401
|
+
* 连接没声明语言时用哪一种(PRD D8 / 裁定 R58)。
|
|
402
|
+
*
|
|
403
|
+
* 来源可以是函数(配置里的缺省语言可改),所以每次握手现算一次;算不出来就用英文 ——
|
|
404
|
+
* 语言这件事不该让握手失败。
|
|
405
|
+
*/
|
|
406
|
+
async resolveDefaultLanguage() {
|
|
407
|
+
const source = this.defaultLanguageSource;
|
|
408
|
+
if (typeof source === "string")
|
|
409
|
+
return source;
|
|
410
|
+
try {
|
|
411
|
+
return normalizeLanguage(await source());
|
|
412
|
+
}
|
|
413
|
+
catch (error) {
|
|
414
|
+
this.logger.warn("读缺省语言失败,回落到英文", { error: describeError(error) });
|
|
415
|
+
return "en";
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
/** 握手之前的语言:来源不是函数时就是它,是函数时先用英文(拿不到同步值) */
|
|
419
|
+
initialLanguage() {
|
|
420
|
+
const source = this.defaultLanguageSource;
|
|
421
|
+
return typeof source === "string" ? source : "en";
|
|
422
|
+
}
|
|
423
|
+
/** `host.setLanguage`:改这条连接的语言,断开即忘(PRD D8 / F-E4) */
|
|
424
|
+
handleSetLanguage(state, params) {
|
|
425
|
+
const record = asRecord(params, "params");
|
|
426
|
+
const language = record.language;
|
|
427
|
+
if (typeof language !== "string" || language.trim() === "") {
|
|
428
|
+
throw new HostError("invalid_params", {
|
|
429
|
+
params: { field: "language" },
|
|
430
|
+
detail: { field: "language", reason: "missing_language" },
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
state.language = normalizeLanguage(language);
|
|
434
|
+
return { language: state.language };
|
|
435
|
+
}
|
|
436
|
+
transferLimits() {
|
|
437
|
+
return {
|
|
438
|
+
maxAssetBytes: this.options.uploads?.maxAssetBytes ?? MAX_ASSET_BYTES,
|
|
439
|
+
maxAssetsPerMessage: MAX_ASSETS_PER_MESSAGE,
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
// -------------------------------------------------------------------------
|
|
443
|
+
// 推送缓冲(R47)
|
|
444
|
+
// -------------------------------------------------------------------------
|
|
445
|
+
hold(state, key) {
|
|
446
|
+
const existing = state.holds.get(key);
|
|
447
|
+
if (existing) {
|
|
448
|
+
existing.depth += 1;
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
state.holds.set(key, { depth: 1, queue: [] });
|
|
452
|
+
}
|
|
453
|
+
/**
|
|
454
|
+
* 放行一段对话的推送。
|
|
455
|
+
*
|
|
456
|
+
* 只有最后一层请求回包之后才真的放(同一对话同时有两个请求在飞时,先回包的那一个不该把
|
|
457
|
+
* 后一个还没回包时产生的事件抢在前面发出去)。
|
|
458
|
+
*/
|
|
459
|
+
release(state, key) {
|
|
460
|
+
const hold = state.holds.get(key);
|
|
461
|
+
if (!hold)
|
|
462
|
+
return;
|
|
463
|
+
hold.depth -= 1;
|
|
464
|
+
if (hold.depth > 0)
|
|
465
|
+
return;
|
|
466
|
+
state.holds.delete(key);
|
|
467
|
+
for (const makeFrame of hold.queue)
|
|
468
|
+
this.send(state, makeFrame());
|
|
469
|
+
}
|
|
470
|
+
/** 发一帧:按对话的缓冲、连接状态一起看,该排队就排队 */
|
|
471
|
+
deliver(state, conversationId, makeFrame) {
|
|
472
|
+
if (!state.authorized)
|
|
473
|
+
return;
|
|
474
|
+
if (conversationId !== null) {
|
|
475
|
+
const hold = state.holds.get(conversationId);
|
|
476
|
+
if (hold) {
|
|
477
|
+
// 排队的是一段「怎么造这一帧」的意图,不是某个已经渲染好的帧:放行时用那时的语言重算
|
|
478
|
+
hold.queue.push(makeFrame);
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
this.send(state, makeFrame());
|
|
483
|
+
}
|
|
484
|
+
send(state, frame) {
|
|
485
|
+
if (state.socket.readyState !== WebSocket.OPEN)
|
|
486
|
+
return;
|
|
487
|
+
try {
|
|
488
|
+
state.socket.send(JSON.stringify(frame));
|
|
489
|
+
}
|
|
490
|
+
catch (error) {
|
|
491
|
+
this.logger.warn("发送帧失败", { connectionId: state.id, error });
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
/**
|
|
495
|
+
* 抛出的东西 → 错误帧。
|
|
496
|
+
*
|
|
497
|
+
* `HostError` 原样出网(业务码 + 按连接语言渲染的句子 + detail);其余一律收敛成 `internal`,
|
|
498
|
+
* 细节(含栈)只进日志 —— 客户端看到的是「宿主出错了,日志里有细节」。
|
|
499
|
+
*/
|
|
500
|
+
errorFrame(id, error, language) {
|
|
501
|
+
if (HostError.is(error))
|
|
502
|
+
return errResponse(id, error, language);
|
|
503
|
+
this.logger.error("处理请求时抛出非业务错误", { error: describeError(error) });
|
|
504
|
+
return errResponse(id, new HostError("internal"), language);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
// ---------------------------------------------------------------------------
|
|
508
|
+
// 纯函数
|
|
509
|
+
// ---------------------------------------------------------------------------
|
|
510
|
+
function toConnectionInfo(state) {
|
|
511
|
+
return {
|
|
512
|
+
id: state.id,
|
|
513
|
+
language: state.language,
|
|
514
|
+
clientInfo: state.clientInfo,
|
|
515
|
+
credentialId: state.credentialId,
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
function describeError(error) {
|
|
519
|
+
if (error instanceof Error)
|
|
520
|
+
return { message: error.message, stack: error.stack };
|
|
521
|
+
return { message: String(error) };
|
|
522
|
+
}
|
|
523
|
+
/**
|
|
524
|
+
* 从一帧分片里尽力取出 `uploadId`(解不出就是 `null`:帧已经坏到没有头可读)。
|
|
525
|
+
*/
|
|
526
|
+
function uploadIdOf(frame) {
|
|
527
|
+
try {
|
|
528
|
+
return decodeChunkFrame(frame).uploadId;
|
|
529
|
+
}
|
|
530
|
+
catch {
|
|
531
|
+
return null;
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
/**
|
|
535
|
+
* 把 `uploadId` 补进错误的 `detail`(原有的事实保留,已有的 `uploadId` 不覆盖)。
|
|
536
|
+
*
|
|
537
|
+
* `detail` 不是对象时把它挪到 `detail.detail` 下而不是丢掉:错误帧里带出去的结构化事实
|
|
538
|
+
* 都走 `detail`,一个非对象的值不能成为「既然不好合并就扔掉」的理由。
|
|
539
|
+
*/
|
|
540
|
+
function withUploadId(error, uploadId) {
|
|
541
|
+
const detail = error.detail;
|
|
542
|
+
if (detail === undefined)
|
|
543
|
+
return error.withDetail({ uploadId });
|
|
544
|
+
if (typeof detail === "object" && detail !== null && !Array.isArray(detail)) {
|
|
545
|
+
const record = detail;
|
|
546
|
+
if (record.uploadId !== undefined)
|
|
547
|
+
return error;
|
|
548
|
+
return error.withDetail({ ...record, uploadId });
|
|
549
|
+
}
|
|
550
|
+
return error.withDetail({ detail, uploadId });
|
|
551
|
+
}
|
|
552
|
+
/** 入站字节:`ws` 可能给 Buffer、ArrayBuffer 或 Buffer 数组(分片) */
|
|
553
|
+
function toBytes(data) {
|
|
554
|
+
if (Array.isArray(data))
|
|
555
|
+
return new Uint8Array(Buffer.concat(data));
|
|
556
|
+
if (data instanceof ArrayBuffer)
|
|
557
|
+
return new Uint8Array(data);
|
|
558
|
+
return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
|
559
|
+
}
|
|
560
|
+
/** 入站文本:同样要能吃分片 */
|
|
561
|
+
function toText(data) {
|
|
562
|
+
if (Array.isArray(data))
|
|
563
|
+
return Buffer.concat(data).toString("utf8");
|
|
564
|
+
if (data instanceof ArrayBuffer)
|
|
565
|
+
return Buffer.from(data).toString("utf8");
|
|
566
|
+
return data.toString("utf8");
|
|
567
|
+
}
|
|
568
|
+
/**
|
|
569
|
+
* 从一段坏帧里尽量捞出 `id`,好让错误响应能对上是哪个请求。
|
|
570
|
+
*
|
|
571
|
+
* 两步:能整段解析就取 `id`;解析不了(半截 JSON、多了个逗号)再用一次文本扫描 ——
|
|
572
|
+
* 这是尽力而为:扫不到就回 `null`,错误帧允许 `id` 为 `null`(标准里就是「连 id 都没解析出来」)。
|
|
573
|
+
* 扫描可能误命中嵌套对象里的 `id`,所以它只作为整段解析失败后的退路。
|
|
574
|
+
*/
|
|
575
|
+
function extractId(text) {
|
|
576
|
+
try {
|
|
577
|
+
const value = JSON.parse(text);
|
|
578
|
+
if (typeof value === "object" && value !== null) {
|
|
579
|
+
return asId(value.id);
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
catch {
|
|
583
|
+
// 坏到解析不了:往下走文本扫描
|
|
584
|
+
}
|
|
585
|
+
const matched = /"id"\s*:\s*("(?:[^"\\]|\\.)*"|-?\d+(?:\.\d+)?)/.exec(text);
|
|
586
|
+
const raw = matched?.[1];
|
|
587
|
+
if (raw === undefined)
|
|
588
|
+
return null;
|
|
589
|
+
if (raw.startsWith("\"")) {
|
|
590
|
+
try {
|
|
591
|
+
return asId(JSON.parse(raw));
|
|
592
|
+
}
|
|
593
|
+
catch {
|
|
594
|
+
return null;
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
return asId(Number(raw));
|
|
598
|
+
}
|
|
599
|
+
function asId(value) {
|
|
600
|
+
if (typeof value === "string" && value !== "")
|
|
601
|
+
return value;
|
|
602
|
+
if (typeof value === "number" && Number.isFinite(value))
|
|
603
|
+
return value;
|
|
604
|
+
return null;
|
|
605
|
+
}
|
|
606
|
+
/** 请求参数里的 `conversationId`:有就按它缓冲该对话的推送(R47) */
|
|
607
|
+
function conversationKeyOfParams(params) {
|
|
608
|
+
if (typeof params !== "object" || params === null || Array.isArray(params))
|
|
609
|
+
return null;
|
|
610
|
+
const value = params.conversationId;
|
|
611
|
+
return typeof value === "string" && value.trim() !== "" ? value.trim() : null;
|
|
612
|
+
}
|
|
613
|
+
/**
|
|
614
|
+
* 事件载荷里的对话 id。
|
|
615
|
+
*
|
|
616
|
+
* 六种事件都挂着某段对话(通知与问题的 `conversationId` 可能是 `null`:那是全局的,
|
|
617
|
+
* 不受任何对话的缓冲影响)。这一处让 `push` 的调用方不必自己传 key。
|
|
618
|
+
*/
|
|
619
|
+
function conversationKeyOf(event, payload) {
|
|
620
|
+
switch (event) {
|
|
621
|
+
case "run.state":
|
|
622
|
+
case "message.delta":
|
|
623
|
+
case "conversation.changed":
|
|
624
|
+
case "usage":
|
|
625
|
+
return nonEmpty(payload.conversationId);
|
|
626
|
+
case "notice":
|
|
627
|
+
return nonEmpty(payload.notice.conversationId);
|
|
628
|
+
case "question":
|
|
629
|
+
return nonEmpty(payload.question.conversationId);
|
|
630
|
+
default:
|
|
631
|
+
return null;
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
function nonEmpty(value) {
|
|
635
|
+
return typeof value === "string" && value.trim() !== "" ? value.trim() : null;
|
|
636
|
+
}
|
|
637
|
+
function asRecord(value, field) {
|
|
638
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
639
|
+
throw new HostError("invalid_params", {
|
|
640
|
+
params: { field },
|
|
641
|
+
detail: { field, reason: "not_an_object" },
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
return value;
|
|
645
|
+
}
|
|
646
|
+
/**
|
|
647
|
+
* `hello` 入参的校验。
|
|
648
|
+
*
|
|
649
|
+
* 只校验「少了就完全没法握手」的三样:`token`、`clientInfo`(客户端自报身份,用于连接列表与日志)、
|
|
650
|
+
* 以及可选的 `language`(不是字符串就当没给)。
|
|
651
|
+
*/
|
|
652
|
+
function asHelloParams(value) {
|
|
653
|
+
const record = asRecord(value, "params");
|
|
654
|
+
const token = record.token;
|
|
655
|
+
if (typeof token !== "string" || token.trim() === "") {
|
|
656
|
+
throw new HostError("invalid_params", {
|
|
657
|
+
params: { field: "token" },
|
|
658
|
+
detail: { field: "token", reason: "missing_token" },
|
|
659
|
+
});
|
|
660
|
+
}
|
|
661
|
+
return {
|
|
662
|
+
token: token.trim(),
|
|
663
|
+
language: typeof record.language === "string" ? record.language : undefined,
|
|
664
|
+
clientInfo: asClientInfo(record.clientInfo),
|
|
665
|
+
};
|
|
666
|
+
}
|
|
667
|
+
function asClientInfo(value) {
|
|
668
|
+
const record = asRecord(value, "clientInfo");
|
|
669
|
+
const name = record.name;
|
|
670
|
+
const version = record.version;
|
|
671
|
+
if (typeof name !== "string" || name.trim() === "") {
|
|
672
|
+
throw new HostError("invalid_params", {
|
|
673
|
+
params: { field: "clientInfo.name" },
|
|
674
|
+
detail: { field: "clientInfo.name", reason: "missing_name" },
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
if (typeof version !== "string" || version.trim() === "") {
|
|
678
|
+
throw new HostError("invalid_params", {
|
|
679
|
+
params: { field: "clientInfo.version" },
|
|
680
|
+
detail: { field: "clientInfo.version", reason: "missing_version" },
|
|
681
|
+
});
|
|
682
|
+
}
|
|
683
|
+
const platform = record.platform;
|
|
684
|
+
return {
|
|
685
|
+
name: name.trim(),
|
|
686
|
+
version: version.trim(),
|
|
687
|
+
platform: typeof platform === "string" && platform.trim() !== "" ? platform.trim() : undefined,
|
|
688
|
+
};
|
|
689
|
+
}
|