@ai-zen/agents-host 0.0.0-stage → 0.1.0-alpha.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/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 +21 -0
- package/dist/cli.js +353 -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,494 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 上传接收器:`asset.begin` → 若干二进制帧 → `asset.end`(architecture A11 / PRD D5、F-H8)。
|
|
3
|
+
*
|
|
4
|
+
* 三段式的分工:
|
|
5
|
+
* 1. `asset.begin`(JSON-RPC 请求):声明用途、类型、总字节数。**超限在这里就拒**(PRD D5:
|
|
6
|
+
* 20MB/张),不接收完再说不行 —— 那既浪费带宽,也让客户端在上传中途白等一下。
|
|
7
|
+
* 素材行要按对话挂外键,所以开临时文件之前先让归属的对话落库(草稿对话在库里还没有行,
|
|
8
|
+
* R56:落库发生在「第一次真正用到它」的那一刻,带图的第一条消息就属于这种)。
|
|
9
|
+
* 2. 任意多个二进制帧:每一帧带上「这是哪个上传」的头,字节直接追加进临时文件,不在内存里攒。
|
|
10
|
+
* 3. `asset.end`(JSON-RPC 请求):校验总长度与客户端给的 `sha256`,通过后交给落定回调,
|
|
11
|
+
* 把临时文件变成正式素材(`<dataRoot>/assets/` 下的正式文件 + 库里一行引用)。
|
|
12
|
+
*
|
|
13
|
+
* 二进制帧的形状(`asset.chunk`):
|
|
14
|
+
*
|
|
15
|
+
* ```
|
|
16
|
+
* | 4 字节大端无符号整数 N | N 字节 UTF-8 JSON 头 {"uploadId": "..."} | 若干字节片段 |
|
|
17
|
+
* ```
|
|
18
|
+
*
|
|
19
|
+
* 为什么头里要带 `uploadId` 而不是「一条连接同时只允许一个上传」:同一条连接上可以并发多个上传
|
|
20
|
+
* (一次拖进四张图就是四个),宿主分配的 `uploadId` 是它们之间唯一的区分方式。头做成
|
|
21
|
+
* 「长度前缀 + JSON」而不是固定长度二进制 id,是为了让帧在抓包/日志里仍然可读,也不绑定
|
|
22
|
+
* `uploadId` 的编码方式。
|
|
23
|
+
*
|
|
24
|
+
* 清理的口径(三处,都在本文件里):
|
|
25
|
+
* - 长度或摘要不符:删临时文件,报错;
|
|
26
|
+
* - 连接断开 / 宿主收尾:这条连接名下所有未完成的上传按 `begin` 记下的临时文件删掉;
|
|
27
|
+
* - 写盘出错:删临时文件,报错。
|
|
28
|
+
*/
|
|
29
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
30
|
+
import { promises as fs } from "node:fs";
|
|
31
|
+
import { join } from "node:path";
|
|
32
|
+
import { DEFAULT_LANGUAGE } from "../i18n/index.js";
|
|
33
|
+
import { HostError } from "../wire/errors.js";
|
|
34
|
+
import { MAX_ASSET_BYTES } from "../wire/index.js";
|
|
35
|
+
/** 二进制帧头部的长度字段占多少字节 */
|
|
36
|
+
const HEADER_LENGTH_BYTES = 4;
|
|
37
|
+
/** 头部 JSON 的长度上限(防一个坏帧声称自己有 4GB 的头) */
|
|
38
|
+
const HEADER_LIMIT_BYTES = 4096;
|
|
39
|
+
/** 目前只有「消息里的图片」这一种用途(PRD D5);别的值按参数不对处理 */
|
|
40
|
+
const PURPOSES = ["message_image"];
|
|
41
|
+
/**
|
|
42
|
+
* MIME 的粗校验:`type/subtype`,两边都是 token 字符。
|
|
43
|
+
*
|
|
44
|
+
* 不做白名单:允许上传什么类型是「客户端让用户选什么」的事,宿主只保证这个值能被安全地
|
|
45
|
+
* 用于落盘命名与内容段(真正的类型判断在 `fileToContentSection` 那一层)。
|
|
46
|
+
*/
|
|
47
|
+
const MIME_PATTERN = /^[\w.+-]+\/[\w.+-]+$/;
|
|
48
|
+
export class UploadReceiver {
|
|
49
|
+
assetsDir;
|
|
50
|
+
finalize;
|
|
51
|
+
onError;
|
|
52
|
+
/** 单张上限;`hello` 的 `limits` 也从这里取,两边不各写一个数 */
|
|
53
|
+
maxAssetBytes;
|
|
54
|
+
ensureConversation;
|
|
55
|
+
findExisting;
|
|
56
|
+
/** connectionId → uploadId → 进行中的上传 */
|
|
57
|
+
sessions = new Map();
|
|
58
|
+
constructor(options) {
|
|
59
|
+
this.assetsDir = options.assetsDir;
|
|
60
|
+
this.finalize = options.finalize;
|
|
61
|
+
this.maxAssetBytes = options.maxAssetBytes ?? MAX_ASSET_BYTES;
|
|
62
|
+
this.ensureConversation = options.ensureConversation;
|
|
63
|
+
this.findExisting = options.findExisting;
|
|
64
|
+
this.onError = options.onError;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* `asset.begin`:校验声明、让归属的对话落库、问去重、开临时文件、发一个上传句柄。
|
|
68
|
+
*
|
|
69
|
+
* 声明里只有「总长」能验,字节本身还没到;所以这里的拒绝都是**声明层面**的
|
|
70
|
+
* (超限、类型不合法、缺对话归属、对话不存在)。
|
|
71
|
+
*
|
|
72
|
+
* 去重命中时这条路就到此为止:不建临时文件、不分配 `uploadId`。去重按内容摘要做,不区分
|
|
73
|
+
* 对话,所以命中并不证明这段对话存在 —— 「让对话落库」排在问去重之前,不存在的对话这一趟
|
|
74
|
+
* 一个字节也不会收。
|
|
75
|
+
*
|
|
76
|
+
* 「让对话落库」排在建临时文件之前:对话不存在时这一趟上传一个字节都不该落盘,
|
|
77
|
+
* 也不该占住一个 `uploadId`。`language` 是这条连接的语言,用来渲染那条隐藏的 cwd 消息。
|
|
78
|
+
*/
|
|
79
|
+
async begin(connectionId, params, language = DEFAULT_LANGUAGE) {
|
|
80
|
+
const purpose = requirePurpose(params.purpose);
|
|
81
|
+
const mime = requireMime(params.mime);
|
|
82
|
+
const declaredBytes = requireDeclaredBytes(params.bytes, this.maxAssetBytes);
|
|
83
|
+
const conversationId = requireConversationId(params.conversationId);
|
|
84
|
+
const name = normalizeName(params.name);
|
|
85
|
+
const sha256 = params.sha256 === undefined ? null : normalizeDigest(params.sha256);
|
|
86
|
+
// 草稿对话在库里还没有行(R56),而「这段对话存在」是上传的前提:先落库再收字节
|
|
87
|
+
await this.persistConversation(conversationId, language);
|
|
88
|
+
// 库里已有同一份字节时只回引用:磁盘上只有一份,两段对话各自在关系表里留一行
|
|
89
|
+
const existing = sha256 === null ? null : await this.lookupExisting({ sha256, bytes: declaredBytes });
|
|
90
|
+
if (existing !== null) {
|
|
91
|
+
return {
|
|
92
|
+
deduped: true,
|
|
93
|
+
uploadId: null,
|
|
94
|
+
maxBytes: this.maxAssetBytes,
|
|
95
|
+
receivedBytes: 0,
|
|
96
|
+
asset: existing,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
await fs.mkdir(this.assetsDir, { recursive: true });
|
|
100
|
+
const uploadId = randomUUID();
|
|
101
|
+
// 临时文件名用宿主分配的上传句柄,不用客户端给的名字:文件名不参与路径拼接之外任何判断
|
|
102
|
+
const tempPath = join(this.assetsDir, `.upload-${uploadId}.part`);
|
|
103
|
+
const handle = await fs.open(tempPath, "wx");
|
|
104
|
+
const session = {
|
|
105
|
+
uploadId,
|
|
106
|
+
connectionId,
|
|
107
|
+
purpose,
|
|
108
|
+
mime,
|
|
109
|
+
name,
|
|
110
|
+
conversationId,
|
|
111
|
+
declaredBytes,
|
|
112
|
+
tempPath,
|
|
113
|
+
handle,
|
|
114
|
+
hash: createHash("sha256"),
|
|
115
|
+
receivedBytes: 0,
|
|
116
|
+
chain: Promise.resolve(),
|
|
117
|
+
closed: false,
|
|
118
|
+
};
|
|
119
|
+
this.sessionMap(connectionId).set(uploadId, session);
|
|
120
|
+
return {
|
|
121
|
+
deduped: false,
|
|
122
|
+
uploadId,
|
|
123
|
+
maxBytes: this.maxAssetBytes,
|
|
124
|
+
receivedBytes: 0,
|
|
125
|
+
asset: null,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* 一个二进制帧:解出上传句柄,把片段追加进临时文件。
|
|
130
|
+
*
|
|
131
|
+
* 返回值是这条上传目前已收到的字节数(用得上就返回,测试与调试都不必去翻文件)。
|
|
132
|
+
*/
|
|
133
|
+
async receiveBinary(connectionId, frame) {
|
|
134
|
+
const { uploadId, chunk } = decodeChunkFrame(frame);
|
|
135
|
+
const session = this.sessions.get(connectionId)?.get(uploadId);
|
|
136
|
+
if (!session) {
|
|
137
|
+
throw new HostError("not_found", {
|
|
138
|
+
params: { entity: "upload" },
|
|
139
|
+
detail: { uploadId, reason: "unknown_upload_id" },
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
return await this.append(session, chunk);
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* `asset.end`:等写盘排空、关文件、校验长度与摘要,通过后落定。
|
|
146
|
+
*
|
|
147
|
+
* 校验不过时的顺序是「先删文件、再报错」:报错之后这条上传就没有别的出口了,
|
|
148
|
+
* 留着半个文件只会变成没人认领的垃圾。
|
|
149
|
+
*/
|
|
150
|
+
async end(connectionId, params) {
|
|
151
|
+
const uploadId = requireUploadId(params.uploadId);
|
|
152
|
+
const session = this.sessions.get(connectionId)?.get(uploadId);
|
|
153
|
+
if (!session) {
|
|
154
|
+
throw new HostError("not_found", {
|
|
155
|
+
params: { entity: "upload" },
|
|
156
|
+
detail: { uploadId, reason: "unknown_upload_id" },
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
// 还有分片在写:等它们落完再验,否则长度会少算
|
|
160
|
+
await session.chain;
|
|
161
|
+
await this.closeHandle(session);
|
|
162
|
+
this.detach(session);
|
|
163
|
+
if (session.receivedBytes !== session.declaredBytes) {
|
|
164
|
+
await this.discard(session);
|
|
165
|
+
throw new HostError("invalid_params", {
|
|
166
|
+
detail: {
|
|
167
|
+
reason: "size_mismatch",
|
|
168
|
+
uploadId,
|
|
169
|
+
declaredBytes: session.declaredBytes,
|
|
170
|
+
receivedBytes: session.receivedBytes,
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
const digest = session.hash.digest("hex");
|
|
175
|
+
const expected = normalizeDigest(params.sha256);
|
|
176
|
+
if (digest !== expected) {
|
|
177
|
+
await this.discard(session);
|
|
178
|
+
throw new HostError("invalid_params", {
|
|
179
|
+
detail: { reason: "digest_mismatch", uploadId, expected, actual: digest },
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
try {
|
|
183
|
+
const asset = await this.finalize({
|
|
184
|
+
uploadId,
|
|
185
|
+
tempPath: session.tempPath,
|
|
186
|
+
mime: session.mime,
|
|
187
|
+
bytes: session.receivedBytes,
|
|
188
|
+
sha256: digest,
|
|
189
|
+
name: session.name,
|
|
190
|
+
purpose: session.purpose,
|
|
191
|
+
conversationId: session.conversationId,
|
|
192
|
+
});
|
|
193
|
+
return { asset };
|
|
194
|
+
}
|
|
195
|
+
catch (error) {
|
|
196
|
+
// 落定失败(改名失败、登记失败):临时文件留着没有意义,删掉再往上抛
|
|
197
|
+
await this.discard(session);
|
|
198
|
+
throw error;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* 丢掉一条连接名下所有未完成的上传(连接断开时调)。
|
|
203
|
+
*
|
|
204
|
+
* 这是「中途断开按 `begin` 记的临时文件清理」的落点。清理本身失败只报给 `onError`:
|
|
205
|
+
* 连接已经没了,没有谁在等一个失败结果。
|
|
206
|
+
*/
|
|
207
|
+
async abortConnection(connectionId) {
|
|
208
|
+
const sessions = this.sessions.get(connectionId);
|
|
209
|
+
if (!sessions)
|
|
210
|
+
return;
|
|
211
|
+
this.sessions.delete(connectionId);
|
|
212
|
+
for (const session of sessions.values()) {
|
|
213
|
+
session.closed = true;
|
|
214
|
+
await this.closeHandle(session);
|
|
215
|
+
await this.discard(session);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
/** 宿主收尾:把所有连接的上传都清掉 */
|
|
219
|
+
async dispose() {
|
|
220
|
+
const connectionIds = [...this.sessions.keys()];
|
|
221
|
+
for (const connectionId of connectionIds) {
|
|
222
|
+
await this.abortConnection(connectionId);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
/** 这条连接上还有几个未完成的上传(测试与诊断用) */
|
|
226
|
+
pendingCount(connectionId) {
|
|
227
|
+
if (connectionId !== undefined)
|
|
228
|
+
return this.sessions.get(connectionId)?.size ?? 0;
|
|
229
|
+
let total = 0;
|
|
230
|
+
for (const sessions of this.sessions.values())
|
|
231
|
+
total += sessions.size;
|
|
232
|
+
return total;
|
|
233
|
+
}
|
|
234
|
+
// -------------------------------------------------------------------------
|
|
235
|
+
// 内部
|
|
236
|
+
// -------------------------------------------------------------------------
|
|
237
|
+
/**
|
|
238
|
+
* 让这段对话先成为库里的行(幂等由注入方保证)。
|
|
239
|
+
*
|
|
240
|
+
* 业务错误(`not_found` 之类)原样上抛:客户端要按码说人话,不能让「对话不存在」在出网时
|
|
241
|
+
* 变成一句 internal。其余异常(写库失败这类)收敛成 `internal` 并留一个原因,原始错误同时
|
|
242
|
+
* 交给 `onError` 进日志。
|
|
243
|
+
*/
|
|
244
|
+
async persistConversation(conversationId, language) {
|
|
245
|
+
const ensure = this.ensureConversation;
|
|
246
|
+
if (!ensure)
|
|
247
|
+
return;
|
|
248
|
+
try {
|
|
249
|
+
await ensure(conversationId, language);
|
|
250
|
+
}
|
|
251
|
+
catch (error) {
|
|
252
|
+
if (HostError.is(error))
|
|
253
|
+
throw error;
|
|
254
|
+
this.onError?.(error, "让对话落库失败");
|
|
255
|
+
throw new HostError("internal", {
|
|
256
|
+
detail: { reason: "conversation_persist_failed", conversationId },
|
|
257
|
+
cause: error,
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
sessionMap(connectionId) {
|
|
262
|
+
let sessions = this.sessions.get(connectionId);
|
|
263
|
+
if (!sessions) {
|
|
264
|
+
sessions = new Map();
|
|
265
|
+
this.sessions.set(connectionId, sessions);
|
|
266
|
+
}
|
|
267
|
+
return sessions;
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* 问一句「库里有没有同一份字节」。
|
|
271
|
+
*
|
|
272
|
+
* 查询本身失败时当作「没命中」:去重是省一次上传的优化,不能因为它的存储出错就把整个上传拒掉。
|
|
273
|
+
*/
|
|
274
|
+
async lookupExisting(input) {
|
|
275
|
+
const find = this.findExisting;
|
|
276
|
+
if (!find)
|
|
277
|
+
return null;
|
|
278
|
+
try {
|
|
279
|
+
return await find(input);
|
|
280
|
+
}
|
|
281
|
+
catch (error) {
|
|
282
|
+
this.onError?.(error, "查询重复素材失败");
|
|
283
|
+
return null;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
detach(session) {
|
|
287
|
+
const sessions = this.sessions.get(session.connectionId);
|
|
288
|
+
sessions?.delete(session.uploadId);
|
|
289
|
+
if (sessions && sessions.size === 0)
|
|
290
|
+
this.sessions.delete(session.connectionId);
|
|
291
|
+
}
|
|
292
|
+
/** 追加一片:排在上一片之后写,累积摘要与已收字节数 */
|
|
293
|
+
append(session, chunk) {
|
|
294
|
+
const next = session.chain.then(async () => {
|
|
295
|
+
if (session.closed) {
|
|
296
|
+
throw new HostError("not_found", {
|
|
297
|
+
params: { entity: "upload" },
|
|
298
|
+
detail: { uploadId: session.uploadId, reason: "upload_closed" },
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
if (chunk.byteLength === 0)
|
|
302
|
+
return session.receivedBytes;
|
|
303
|
+
// 先算长度再写:超过声明总长的分片不留痕(临时文件随后整份删掉)
|
|
304
|
+
const nextBytes = session.receivedBytes + chunk.byteLength;
|
|
305
|
+
if (nextBytes > session.declaredBytes) {
|
|
306
|
+
session.closed = true;
|
|
307
|
+
this.detach(session);
|
|
308
|
+
await this.closeHandle(session);
|
|
309
|
+
await this.discard(session);
|
|
310
|
+
throw new HostError("payload_too_large", {
|
|
311
|
+
params: {
|
|
312
|
+
size: formatBytes(nextBytes),
|
|
313
|
+
limit: formatBytes(session.declaredBytes),
|
|
314
|
+
},
|
|
315
|
+
detail: {
|
|
316
|
+
reason: "exceeds_declared_bytes",
|
|
317
|
+
uploadId: session.uploadId,
|
|
318
|
+
declaredBytes: session.declaredBytes,
|
|
319
|
+
receivedBytes: nextBytes,
|
|
320
|
+
},
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
await session.handle.write(chunk);
|
|
324
|
+
session.hash.update(chunk);
|
|
325
|
+
session.receivedBytes = nextBytes;
|
|
326
|
+
return session.receivedBytes;
|
|
327
|
+
});
|
|
328
|
+
// 链条只用来排队:某一片失败不该把后面的分片也判成失败(它们各自会报自己的错)
|
|
329
|
+
session.chain = next.then(() => undefined, () => undefined);
|
|
330
|
+
return next;
|
|
331
|
+
}
|
|
332
|
+
async closeHandle(session) {
|
|
333
|
+
await session.handle.close().catch((error) => {
|
|
334
|
+
this.onError?.(error, "关闭上传临时文件失败");
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
/** 删临时文件(不存在不算错:清理动作要能重复执行) */
|
|
338
|
+
async discard(session) {
|
|
339
|
+
session.closed = true;
|
|
340
|
+
await fs.rm(session.tempPath, { force: true }).catch((error) => {
|
|
341
|
+
this.onError?.(error, "删除上传临时文件失败");
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
// ---------------------------------------------------------------------------
|
|
346
|
+
// 二进制帧
|
|
347
|
+
// ---------------------------------------------------------------------------
|
|
348
|
+
/**
|
|
349
|
+
* 拼一个 `asset.chunk` 二进制帧(客户端按同一份实现编码,测试也用它)。
|
|
350
|
+
*
|
|
351
|
+
* 导出它是为了让「帧长什么样」只有一处定义:客户端与宿主各写一份编码器迟早会错开一个字段。
|
|
352
|
+
*/
|
|
353
|
+
export function encodeChunkFrame(uploadId, chunk) {
|
|
354
|
+
const header = new TextEncoder().encode(JSON.stringify({ uploadId }));
|
|
355
|
+
const frame = new Uint8Array(HEADER_LENGTH_BYTES + header.byteLength + chunk.byteLength);
|
|
356
|
+
new DataView(frame.buffer).setUint32(0, header.byteLength, false);
|
|
357
|
+
frame.set(header, HEADER_LENGTH_BYTES);
|
|
358
|
+
frame.set(chunk, HEADER_LENGTH_BYTES + header.byteLength);
|
|
359
|
+
return frame;
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
362
|
+
* 解一个 `asset.chunk` 二进制帧。
|
|
363
|
+
*
|
|
364
|
+
* 坏帧一律 `invalid_params` 并带 `detail.reason`:帧是二进制的,客户端拼错时唯一能帮上忙的
|
|
365
|
+
* 就是「哪一步不对」(头长度越界、头不是 JSON、头里没有 `uploadId`)。
|
|
366
|
+
*/
|
|
367
|
+
export function decodeChunkFrame(frame) {
|
|
368
|
+
if (frame.byteLength < HEADER_LENGTH_BYTES) {
|
|
369
|
+
throw new HostError("invalid_params", {
|
|
370
|
+
detail: { reason: "frame_too_short", bytes: frame.byteLength },
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
const headerLength = new DataView(frame.buffer, frame.byteOffset, frame.byteLength).getUint32(0, false);
|
|
374
|
+
if (headerLength === 0 || headerLength > HEADER_LIMIT_BYTES) {
|
|
375
|
+
throw new HostError("invalid_params", {
|
|
376
|
+
detail: { reason: "bad_header_length", headerLength },
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
if (HEADER_LENGTH_BYTES + headerLength > frame.byteLength) {
|
|
380
|
+
throw new HostError("invalid_params", {
|
|
381
|
+
detail: {
|
|
382
|
+
reason: "truncated_header",
|
|
383
|
+
headerLength,
|
|
384
|
+
bytes: frame.byteLength - HEADER_LENGTH_BYTES,
|
|
385
|
+
},
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
const headerBytes = frame.subarray(HEADER_LENGTH_BYTES, HEADER_LENGTH_BYTES + headerLength);
|
|
389
|
+
let header;
|
|
390
|
+
try {
|
|
391
|
+
header = JSON.parse(new TextDecoder().decode(headerBytes));
|
|
392
|
+
}
|
|
393
|
+
catch {
|
|
394
|
+
throw new HostError("invalid_params", { detail: { reason: "header_not_json" } });
|
|
395
|
+
}
|
|
396
|
+
if (typeof header !== "object" || header === null || Array.isArray(header)) {
|
|
397
|
+
throw new HostError("invalid_params", { detail: { reason: "header_not_an_object" } });
|
|
398
|
+
}
|
|
399
|
+
const uploadId = header.uploadId;
|
|
400
|
+
if (typeof uploadId !== "string" || uploadId.trim() === "") {
|
|
401
|
+
throw new HostError("invalid_params", { detail: { reason: "header_without_upload_id" } });
|
|
402
|
+
}
|
|
403
|
+
return { uploadId: uploadId.trim(), chunk: frame.subarray(HEADER_LENGTH_BYTES + headerLength) };
|
|
404
|
+
}
|
|
405
|
+
// ---------------------------------------------------------------------------
|
|
406
|
+
// 参数校验与格式化
|
|
407
|
+
// ---------------------------------------------------------------------------
|
|
408
|
+
function requirePurpose(value) {
|
|
409
|
+
if (typeof value === "string" && PURPOSES.includes(value)) {
|
|
410
|
+
return value;
|
|
411
|
+
}
|
|
412
|
+
throw new HostError("invalid_params", {
|
|
413
|
+
params: { field: "purpose" },
|
|
414
|
+
detail: { field: "purpose", got: value ?? null, allowed: PURPOSES },
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
function requireMime(value) {
|
|
418
|
+
if (typeof value === "string" && MIME_PATTERN.test(value.trim())) {
|
|
419
|
+
return value.trim().toLowerCase();
|
|
420
|
+
}
|
|
421
|
+
throw new HostError("invalid_params", {
|
|
422
|
+
params: { field: "mime" },
|
|
423
|
+
detail: { field: "mime", got: value ?? null },
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
function requireDeclaredBytes(value, limit) {
|
|
427
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value <= 0) {
|
|
428
|
+
throw new HostError("invalid_params", {
|
|
429
|
+
params: { field: "bytes" },
|
|
430
|
+
detail: { field: "bytes", got: value ?? null, reason: "not_a_positive_integer" },
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
if (value > limit) {
|
|
434
|
+
throw new HostError("payload_too_large", {
|
|
435
|
+
params: { size: formatBytes(value), limit: formatBytes(limit) },
|
|
436
|
+
detail: { reason: "over_asset_limit", bytes: value, limit },
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
return value;
|
|
440
|
+
}
|
|
441
|
+
/**
|
|
442
|
+
* 上传必须归属一段对话。
|
|
443
|
+
*
|
|
444
|
+
* `asset.begin` 要这段对话在库里存在(草稿先落库),因为素材的引用关系(`conversation_assets`)
|
|
445
|
+
* 与「图片按引用清理」都按对话走;没有归属的上传只能留下一个没人认领的文件。所以这里直接拒
|
|
446
|
+
* (而不是先收完再失败)。
|
|
447
|
+
*/
|
|
448
|
+
function requireConversationId(value) {
|
|
449
|
+
if (typeof value === "string" && value.trim() !== "")
|
|
450
|
+
return value.trim();
|
|
451
|
+
throw new HostError("invalid_params", {
|
|
452
|
+
params: { field: "conversationId" },
|
|
453
|
+
detail: { field: "conversationId", reason: "conversation_required" },
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
function requireUploadId(value) {
|
|
457
|
+
if (typeof value === "string" && value.trim() !== "")
|
|
458
|
+
return value.trim();
|
|
459
|
+
throw new HostError("invalid_params", {
|
|
460
|
+
params: { field: "uploadId" },
|
|
461
|
+
detail: { field: "uploadId", reason: "missing_upload_id" },
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
function normalizeName(value) {
|
|
465
|
+
if (typeof value !== "string")
|
|
466
|
+
return null;
|
|
467
|
+
const name = value.trim();
|
|
468
|
+
return name === "" ? null : name;
|
|
469
|
+
}
|
|
470
|
+
function normalizeDigest(value) {
|
|
471
|
+
if (typeof value !== "string" || !/^[0-9a-fA-F]{64}$/.test(value.trim())) {
|
|
472
|
+
throw new HostError("invalid_params", {
|
|
473
|
+
params: { field: "sha256" },
|
|
474
|
+
detail: { field: "sha256", reason: "not_a_sha256_digest" },
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
return value.trim().toLowerCase();
|
|
478
|
+
}
|
|
479
|
+
/**
|
|
480
|
+
* 字节数的人话形式(错误文案里的 `{size}` / `{limit}`)。
|
|
481
|
+
*
|
|
482
|
+
* 单位用 1024 进制、最多一位小数:`20 MB`、`8.5 MB`、`512 KB` —— 与用户看到文件大小时的口径一致。
|
|
483
|
+
*/
|
|
484
|
+
export function formatBytes(bytes) {
|
|
485
|
+
const units = ["B", "KB", "MB", "GB"];
|
|
486
|
+
let value = Math.max(0, bytes);
|
|
487
|
+
let unit = 0;
|
|
488
|
+
while (value >= 1024 && unit < units.length - 1) {
|
|
489
|
+
value /= 1024;
|
|
490
|
+
unit += 1;
|
|
491
|
+
}
|
|
492
|
+
const rounded = unit === 0 ? Math.round(value) : Math.round(value * 10) / 10;
|
|
493
|
+
return `${rounded} ${units[unit]}`;
|
|
494
|
+
}
|