@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.
Files changed (146) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/LICENSE +21 -0
  3. package/README.md +759 -2
  4. package/dist/app.d.ts +384 -0
  5. package/dist/app.js +1148 -0
  6. package/dist/cli.d.ts +21 -0
  7. package/dist/cli.js +353 -0
  8. package/dist/config.d.ts +97 -0
  9. package/dist/config.js +171 -0
  10. package/dist/handlers/agentHandlers.d.ts +18 -0
  11. package/dist/handlers/agentHandlers.js +20 -0
  12. package/dist/handlers/assetHandlers.d.ts +29 -0
  13. package/dist/handlers/assetHandlers.js +35 -0
  14. package/dist/handlers/capabilities.d.ts +26 -0
  15. package/dist/handlers/capabilities.js +138 -0
  16. package/dist/handlers/completionHandlers.d.ts +18 -0
  17. package/dist/handlers/completionHandlers.js +13 -0
  18. package/dist/handlers/configHandlers.d.ts +31 -0
  19. package/dist/handlers/configHandlers.js +59 -0
  20. package/dist/handlers/conversationHandlers.d.ts +118 -0
  21. package/dist/handlers/conversationHandlers.js +314 -0
  22. package/dist/handlers/handoffHandlers.d.ts +17 -0
  23. package/dist/handlers/handoffHandlers.js +14 -0
  24. package/dist/handlers/hostHandlers.d.ts +39 -0
  25. package/dist/handlers/hostHandlers.js +56 -0
  26. package/dist/handlers/index.d.ts +128 -0
  27. package/dist/handlers/index.js +92 -0
  28. package/dist/handlers/messageHandlers.d.ts +11 -0
  29. package/dist/handlers/messageHandlers.js +10 -0
  30. package/dist/handlers/modelHandlers.d.ts +17 -0
  31. package/dist/handlers/modelHandlers.js +19 -0
  32. package/dist/handlers/noticeHandlers.d.ts +24 -0
  33. package/dist/handlers/noticeHandlers.js +24 -0
  34. package/dist/handlers/questionHandlers.d.ts +16 -0
  35. package/dist/handlers/questionHandlers.js +16 -0
  36. package/dist/handlers/searchHandlers.d.ts +12 -0
  37. package/dist/handlers/searchHandlers.js +12 -0
  38. package/dist/handlers/workspaceHandlers.d.ts +29 -0
  39. package/dist/handlers/workspaceHandlers.js +47 -0
  40. package/dist/i18n/en.d.ts +243 -0
  41. package/dist/i18n/en.js +270 -0
  42. package/dist/i18n/index.d.ts +43 -0
  43. package/dist/i18n/index.js +72 -0
  44. package/dist/i18n/zh.d.ts +240 -0
  45. package/dist/i18n/zh.js +257 -0
  46. package/dist/index.d.ts +17 -0
  47. package/dist/index.js +15 -0
  48. package/dist/net/UploadReceiver.d.ts +186 -0
  49. package/dist/net/UploadReceiver.js +494 -0
  50. package/dist/net/WsHub.d.ts +231 -0
  51. package/dist/net/WsHub.js +689 -0
  52. package/dist/net/auth.d.ts +73 -0
  53. package/dist/net/auth.js +74 -0
  54. package/dist/net/discovery.d.ts +72 -0
  55. package/dist/net/discovery.js +171 -0
  56. package/dist/net/index.d.ts +14 -0
  57. package/dist/net/index.js +10 -0
  58. package/dist/runtime/ChatRunner.d.ts +374 -0
  59. package/dist/runtime/ChatRunner.js +1204 -0
  60. package/dist/runtime/ConversationDrafts.d.ts +43 -0
  61. package/dist/runtime/ConversationDrafts.js +47 -0
  62. package/dist/runtime/RunRegistry.d.ts +79 -0
  63. package/dist/runtime/RunRegistry.js +108 -0
  64. package/dist/runtime/ScopeRegistry.d.ts +153 -0
  65. package/dist/runtime/ScopeRegistry.js +259 -0
  66. package/dist/runtime/TransientRunner.d.ts +90 -0
  67. package/dist/runtime/TransientRunner.js +179 -0
  68. package/dist/runtime/index.d.ts +14 -0
  69. package/dist/runtime/index.js +10 -0
  70. package/dist/services/AgentService.d.ts +150 -0
  71. package/dist/services/AgentService.js +718 -0
  72. package/dist/services/AssetService.d.ts +338 -0
  73. package/dist/services/AssetService.js +739 -0
  74. package/dist/services/AutoNaming.d.ts +92 -0
  75. package/dist/services/AutoNaming.js +235 -0
  76. package/dist/services/CompletionService.d.ts +122 -0
  77. package/dist/services/CompletionService.js +442 -0
  78. package/dist/services/ConfigService.d.ts +171 -0
  79. package/dist/services/ConfigService.js +695 -0
  80. package/dist/services/ConversationService.d.ts +274 -0
  81. package/dist/services/ConversationService.js +902 -0
  82. package/dist/services/CredentialService.d.ts +33 -0
  83. package/dist/services/CredentialService.js +86 -0
  84. package/dist/services/DiscoveryService.d.ts +182 -0
  85. package/dist/services/DiscoveryService.js +998 -0
  86. package/dist/services/HandoffService.d.ts +189 -0
  87. package/dist/services/HandoffService.js +414 -0
  88. package/dist/services/Logger.d.ts +130 -0
  89. package/dist/services/Logger.js +256 -0
  90. package/dist/services/MessageService.d.ts +88 -0
  91. package/dist/services/MessageService.js +280 -0
  92. package/dist/services/ModelService.d.ts +89 -0
  93. package/dist/services/ModelService.js +489 -0
  94. package/dist/services/NoticeService.d.ts +93 -0
  95. package/dist/services/NoticeService.js +217 -0
  96. package/dist/services/QuestionService.d.ts +184 -0
  97. package/dist/services/QuestionService.js +457 -0
  98. package/dist/services/SearchService.d.ts +54 -0
  99. package/dist/services/SearchService.js +117 -0
  100. package/dist/services/WorkspaceService.d.ts +96 -0
  101. package/dist/services/WorkspaceService.js +213 -0
  102. package/dist/services/imageSize.d.ts +41 -0
  103. package/dist/services/imageSize.js +146 -0
  104. package/dist/services/index.d.ts +20 -0
  105. package/dist/services/index.js +15 -0
  106. package/dist/services/secretMask.d.ts +24 -0
  107. package/dist/services/secretMask.js +34 -0
  108. package/dist/storage/AssetRepository.d.ts +87 -0
  109. package/dist/storage/AssetRepository.js +180 -0
  110. package/dist/storage/ConversationRepository.d.ts +55 -0
  111. package/dist/storage/ConversationRepository.js +128 -0
  112. package/dist/storage/CredentialRepository.d.ts +28 -0
  113. package/dist/storage/CredentialRepository.js +47 -0
  114. package/dist/storage/Database.d.ts +95 -0
  115. package/dist/storage/Database.js +180 -0
  116. package/dist/storage/MessageRepository.d.ts +104 -0
  117. package/dist/storage/MessageRepository.js +323 -0
  118. package/dist/storage/MetaRepository.d.ts +21 -0
  119. package/dist/storage/MetaRepository.js +33 -0
  120. package/dist/storage/NoticeRepository.d.ts +31 -0
  121. package/dist/storage/NoticeRepository.js +89 -0
  122. package/dist/storage/QuestionRepository.d.ts +34 -0
  123. package/dist/storage/QuestionRepository.js +96 -0
  124. package/dist/storage/WorkspaceRepository.d.ts +25 -0
  125. package/dist/storage/WorkspaceRepository.js +44 -0
  126. package/dist/storage/dbWorker.d.ts +10 -0
  127. package/dist/storage/dbWorker.js +132 -0
  128. package/dist/storage/index.d.ts +20 -0
  129. package/dist/storage/index.js +16 -0
  130. package/dist/storage/migrations.d.ts +26 -0
  131. package/dist/storage/migrations.js +207 -0
  132. package/dist/storage/types.d.ts +298 -0
  133. package/dist/storage/types.js +14 -0
  134. package/dist/wire/errors.d.ts +105 -0
  135. package/dist/wire/errors.js +143 -0
  136. package/dist/wire/events.d.ts +102 -0
  137. package/dist/wire/events.js +13 -0
  138. package/dist/wire/frames.d.ts +106 -0
  139. package/dist/wire/frames.js +156 -0
  140. package/dist/wire/index.d.ts +31 -0
  141. package/dist/wire/index.js +31 -0
  142. package/dist/wire/methods.d.ts +1896 -0
  143. package/dist/wire/methods.js +99 -0
  144. package/dist/wire/states.d.ts +487 -0
  145. package/dist/wire/states.js +61 -0
  146. package/package.json +63 -3
@@ -0,0 +1,739 @@
1
+ /**
2
+ * 素材服务:上传产物的落定与登记、取用时变成内容段(F-H7 / R44)、按引用清理(R33)、
3
+ * 以及「落库只有 `__asset_id`」这条口径的两端。
4
+ *
5
+ * 它围着 `assets` 这一份真相做五件事:
6
+ *
7
+ * 1. **落定**({@link AssetService.finalizeUpload}):上传接收器(`net/UploadReceiver`)把字节收进
8
+ * 临时文件之后,本服务把它改名为正式文件、在 `assets` 表登记一行、回一个 {@link AssetRef}。
9
+ * 库里已经有同摘要的那一行时不重复落文件(全局去重),回既有那一行。
10
+ * 2. **取用**({@link AssetService.prepareAttachments}):`MessageInput.assetIds` → core 的内容段。
11
+ * 走文件引用、内联 base64 还是退化成文字,由**实发模型与它所用端点**决定,判定逻辑用 SDK 的
12
+ * `fileToContentSection`(同一份口径,不在这里另写一遍)。没送出去的一律如实记账,不假装成功。
13
+ * 同时给出「落库用」的那一份内容:素材在那里写成段上的 `__asset_id`(本地扩展字段)。
14
+ * 3. **重放**({@link AssetService.resolveHistory}):历史里的 `__asset_id` 在每轮发送前还原成
15
+ * 当前模型能吃的内容段 —— 换了模型、重启之后再跑,同一条历史照样发得出去。发出去的那一份
16
+ * 经 core 的 `formatContent` 按字段白名单重建,`__` 开头的键不会出网。
17
+ * 4. **厂商 file_id 的缓存**:`assets` 是真相;厂商的 `file_id` 是端点维度资源,落在
18
+ * `asset_vendor_files`({@link AssetService.vendorFiles})。三条:
19
+ * - 这个端点上有缓存行 → 直接用那个 `vendorFileId`;
20
+ * - 这个端点上没有、别的端点上有(换厂商 / 换端点)→ 内联 base64,不往新端点重传;
21
+ * - 哪个端点上都没有 → 上传一次并写回。
22
+ * 发送失败就报失败:不自动重试、不判失效、不清缓存。
23
+ * 5. **清理**({@link AssetService.sweepUnreferenced}):引用归零的素材才删行、删文件。关系行
24
+ * (`conversation_assets`)由外键级联删,所以这里判的是「删完之后还剩几张引用」。
25
+ * 这一层是碰 `assets/` 下**正式文件**的地方(临时文件的生与死归上传接收器)。
26
+ *
27
+ * 它被 `ChatRunner` 当作 `prepareAttachments` / `prepareHistory` / `linkMessageAssets` 注入
28
+ * (PRD F-H7 / 裁定 R44)。
29
+ */
30
+ import { randomUUID } from "node:crypto";
31
+ import { promises as fs } from "node:fs";
32
+ import { basename, isAbsolute, join, relative } from "node:path";
33
+ import { formatContent, formatContentSection } from "@ai-zen/agents-core";
34
+ import { fileToContentSection, mimeOf } from "@ai-zen/agents-sdk";
35
+ import { DEFAULT_LANGUAGE, render } from "../i18n/index.js";
36
+ import { HostError } from "../wire/errors.js";
37
+ import { MAX_ASSETS_PER_MESSAGE, localAssetIdOf, } from "../wire/index.js";
38
+ import { readImageSizeFromFile } from "./imageSize.js";
39
+ /**
40
+ * MIME → 落盘扩展名。
41
+ *
42
+ * 扩展名不是装饰:`fileToContentSection` 会按扩展名推 MIME(它要据此决定走视觉通道还是 `file` 块),
43
+ * 所以常见类型都按同一张表落地;表外的类型落 `.bin`,取用时改走「给字节 + 显式 mime」那条来源。
44
+ */
45
+ const EXTENSION_BY_MIME = {
46
+ "image/png": ".png",
47
+ "image/jpeg": ".jpg",
48
+ "image/gif": ".gif",
49
+ "image/webp": ".webp",
50
+ "application/pdf": ".pdf",
51
+ "text/plain": ".txt",
52
+ "text/markdown": ".md",
53
+ "application/json": ".json",
54
+ };
55
+ export class AssetService {
56
+ options;
57
+ assets;
58
+ vendorFiles;
59
+ now;
60
+ newId;
61
+ constructor(options) {
62
+ this.options = options;
63
+ this.assets = options.assets;
64
+ this.vendorFiles = options.vendorFiles;
65
+ this.now = options.now ?? Date.now;
66
+ this.newId = options.newId ?? randomUUID;
67
+ }
68
+ // -------------------------------------------------------------------------
69
+ // 落定与登记
70
+ // -------------------------------------------------------------------------
71
+ /**
72
+ * 把收完的临时文件变成正式素材(`UploadReceiver` 的落定回调)。
73
+ *
74
+ * 顺序是「先改名、再登记行、失败就把文件删回去」:库里出现一行指向不存在的文件,
75
+ * 比文件多留一份更难收拾(取用时会变成「有引用但读不到」)。
76
+ *
77
+ * 登记走 upsert,键是 `sha256`(全库唯一):库里已经有同一份字节时(另一段对话传过、或
78
+ * 两个上传撞在一起),唯一约束会挡住插入,这时把刚写出去的文件删掉、回库里既有那一行 ——
79
+ * 全局去重的目的(整台机器只占一份磁盘)因此对两条路都成立。
80
+ *
81
+ * 「这段素材归哪段对话」不在这张表上:归属在消息落库那一刻写进 `conversation_assets`
82
+ * ({@link AssetService.linkMessageAssets}),所以这个入参里的 `conversationId` 不参与登记。
83
+ *
84
+ * 文件落到正式位置之后读一次文件头,把像素尺寸一并登记({@link readImageSizeFromFile}):
85
+ * 客户端拿 `width` / `height` 先排占位框,图显示出来时界面不会跳。量不到就是 `null`。
86
+ */
87
+ async finalizeUpload(request) {
88
+ const assetId = this.newId();
89
+ const fileName = `${assetId}${extensionFor(request.mime)}`;
90
+ const finalPath = join(this.options.paths.assetsDir, fileName);
91
+ await fs.mkdir(this.options.paths.assetsDir, { recursive: true });
92
+ await fs.rename(request.tempPath, finalPath);
93
+ const size = await readImageSizeFromFile(finalPath);
94
+ const relativePath = toRelativePath(this.options.paths.dataRoot, finalPath);
95
+ try {
96
+ const { row, created } = await this.assets.upsert({
97
+ id: assetId,
98
+ name: request.name,
99
+ path: relativePath,
100
+ mime: request.mime,
101
+ bytes: request.bytes,
102
+ sha256: request.sha256,
103
+ // 尺寸来自文件头解码;量不到(非 PNG / JPEG、截断、读不出来)时为 `null`
104
+ width: size?.width ?? null,
105
+ height: size?.height ?? null,
106
+ createdAt: this.now(),
107
+ });
108
+ // 没插进去:库里已经有一份同摘要的字节,刚改名的那份是多余的
109
+ if (!created)
110
+ await fs.rm(finalPath, { force: true }).catch(() => undefined);
111
+ return toAssetRef(row);
112
+ }
113
+ catch (error) {
114
+ await fs.rm(finalPath, { force: true }).catch(() => undefined);
115
+ throw this.registrationError(error);
116
+ }
117
+ }
118
+ /**
119
+ * 「库里已经有这张图了」时回既有那一行(`UploadReceiver.begin` 的去重判定)。
120
+ *
121
+ * 两个条件同时成立才算命中:同一个 `sha256`、同一个字节数。第二个条件看着多余
122
+ * (摘要相同就是同一份内容),但它是「客户端报的总长与宿主那一行对得上」的交叉校验:对不上
123
+ * 就当作没命中,让客户端老老实实传一遍。
124
+ *
125
+ * 去重是**全局**的:另一段对话传过的同一张图也算命中,命中之后两个对话各自在
126
+ * `conversation_assets` 里留一行引用,磁盘上仍然只有一份。
127
+ */
128
+ async findExistingAsset(input) {
129
+ const row = await this.assets.findByDigest(input.sha256);
130
+ if (!row || row.bytes !== input.bytes)
131
+ return null;
132
+ return toAssetRef(row);
133
+ }
134
+ /**
135
+ * 登记素材行失败的翻译。
136
+ *
137
+ * `assets` 这一行不挂任何外键(归属在关系表上),所以这里没有「对话不存在」这类可判别失败:
138
+ * 盘坏了、库坏了都是 internal,原因写进 detail 并进日志。
139
+ */
140
+ registrationError(error) {
141
+ if (HostError.is(error))
142
+ return error;
143
+ const reason = error instanceof Error ? error.message : String(error);
144
+ return new HostError("internal", { detail: { reason, context: "asset_register" } });
145
+ }
146
+ /**
147
+ * 消息落库那一刻:扫 `content` 里的 `__asset_id` 记关系行(同一 message + asset 只一行)。
148
+ *
149
+ * 关系行是「谁引用了它」的唯一记录,所以它跟消息行同一步写:之后撤回、删对话、删消息都由
150
+ * 外键级联删关系行,不需要谁去减计数。写不进去(素材 id 认不得、库出错)不让这一轮失败 ——
151
+ * 后果只是这份素材的引用少记一次,{@link AssetService.sweepUnreferenced} 因此会晚一步删。
152
+ */
153
+ async linkMessageAssets(context) {
154
+ let linked = 0;
155
+ for (const assetId of assetIdsOf(context.content)) {
156
+ try {
157
+ linked += await this.assets.linkAsset({
158
+ conversationId: context.conversationId,
159
+ messageId: context.messageId,
160
+ assetId,
161
+ createdAt: this.now(),
162
+ });
163
+ }
164
+ catch (error) {
165
+ this.report(error, "登记消息的素材引用失败");
166
+ }
167
+ }
168
+ return linked;
169
+ }
170
+ /** 一条素材行(取用方要按 id 查 ref 时用) */
171
+ async get(assetId) {
172
+ return await this.assets.get(assetId);
173
+ }
174
+ // -------------------------------------------------------------------------
175
+ // 取用:assetIds → 内容段(PRD F-H7 / R44)
176
+ // -------------------------------------------------------------------------
177
+ /**
178
+ * 用户输入 + 已上传的素材 → 真正发给模型的内容(`ChatRunner` 的 `prepareAttachments`)。
179
+ *
180
+ * 口径(F-H7 逐条):
181
+ * - **文本段在前**:用户打的字始终是第一段,素材段按用户摆放的顺序跟在后面;
182
+ * - **按实发模型选传输方式**:能不能走 Files API 看端点声明、图片能不能进上下文看模型,
183
+ * 判定与转换交给 SDK 的 `fileToContentSection`(与工具链里那段代码同一份口径);
184
+ * - **失败要告警**:每张图各自的结局都记账(`deliveredAs` + `problem`),不合并成一句「成功」;
185
+ * - **全失败退回纯文本**:一张都没送出去时内容就是用户原来那段文字,
186
+ * 既没有凭空多出来的段,也没有假装发出去的占位。
187
+ *
188
+ * 同时给出**落库用**的那一份(`storedContent`):素材在那里是段上的 `__asset_id`。
189
+ * 一份内容、两种形状是故意的 —— 发给模型的带着厂商产物(`file_id` / base64),存下的带的是引用,
190
+ * 否则这条消息绑在「这一次发送所用的端点」上,换模型 / 换端点 / 重启之后就发了。
191
+ */
192
+ async prepareAttachments(context) {
193
+ const content = context.input.content;
194
+ const assetIds = dedupe(context.input.assetIds ?? []);
195
+ if (assetIds.length === 0) {
196
+ // 没有素材也要过一遍白名单:用户输入里可能带着客户端塞的本地字段,它们不该出网
197
+ return { content: formatContent(content), attachments: [] };
198
+ }
199
+ const withinLimit = assetIds.slice(0, MAX_ASSETS_PER_MESSAGE);
200
+ const overflow = assetIds.slice(MAX_ASSETS_PER_MESSAGE);
201
+ const collected = [];
202
+ const attachments = [];
203
+ // 一条消息最多带几张(PRD D5):超出的那些明确记账,不静默丢掉
204
+ for (const assetId of overflow) {
205
+ attachments.push({
206
+ assetId,
207
+ deliveredAs: "dropped",
208
+ section: null,
209
+ problem: this.problem("attachment.tooMany", { limit: MAX_ASSETS_PER_MESSAGE }, { assetId, reason: "over_per_message_limit" }),
210
+ });
211
+ }
212
+ const target = await this.resolveTarget(context.modelId);
213
+ const records = [];
214
+ for (const assetId of withinLimit) {
215
+ const record = await this.assets.get(assetId);
216
+ if (!record) {
217
+ attachments.push({
218
+ assetId,
219
+ deliveredAs: "dropped",
220
+ section: null,
221
+ problem: this.problem("notice.attachmentDropped", { name: assetId }, {
222
+ assetId,
223
+ reason: "asset_not_found",
224
+ }),
225
+ });
226
+ continue;
227
+ }
228
+ records.push(record);
229
+ const outcome = await this.deliver(record, target);
230
+ attachments.push(outcome);
231
+ if (outcome.section !== null)
232
+ collected.push(outcome.section);
233
+ }
234
+ return {
235
+ content: formatContent(withSections(content, collected)),
236
+ attachments,
237
+ // 落库的那一份:成不成功都记「用户挂过这几张」(失败事实在 `attachments` 里,不在库里)
238
+ storedContent: withSections(content, records.map(assetSectionOf)),
239
+ };
240
+ }
241
+ /**
242
+ * 历史里的 `__asset_id` → 当前模型能吃的内容段(`ChatRunner` 的 `prepareHistory`)。
243
+ *
244
+ * 每轮发送前跑一次,因为「能不能吃」跟着模型 / 端点变:换个支持 Files API 的端点就会走文件引用,
245
+ * 换个不支持视觉的模型就退化成一句文字提示。还原不出来的那一段换成一句文字说明而不是把
246
+ * 引用原样交给厂商(那会让整条请求 400)。
247
+ *
248
+ * 无论有没有素材引用,内容都按 core 的字段白名单重建一次(`formatContent`):`__` 开头的键
249
+ * 是本机扩展,出网的那一份里不该有它们。没有引用时不读盘、不上传。
250
+ */
251
+ async resolveHistory(context) {
252
+ const usesAssets = context.messages.some((message) => Array.isArray(message.content) &&
253
+ message.content.some((section) => localAssetIdOf(section) !== null));
254
+ if (!usesAssets) {
255
+ return context.messages.map((message) => Array.isArray(message.content)
256
+ ? { ...message, content: formatContent(message.content) }
257
+ : message);
258
+ }
259
+ const target = await this.resolveTarget(context.modelId);
260
+ const resolved = [];
261
+ for (const message of context.messages) {
262
+ if (!Array.isArray(message.content)) {
263
+ resolved.push(message);
264
+ continue;
265
+ }
266
+ const sections = [];
267
+ for (const section of message.content) {
268
+ const assetId = localAssetIdOf(section);
269
+ if (assetId === null) {
270
+ sections.push(formatContentSection(section));
271
+ continue;
272
+ }
273
+ const record = await this.assets.get(assetId);
274
+ const outcome = record === null || record === undefined
275
+ ? null
276
+ : await this.deliver(record, target, { quiet: true });
277
+ sections.push(outcome?.section ?? unavailableSection(assetId, record));
278
+ }
279
+ // 只换内容、不动消息本体(id / 角色 / 状态都是 core 那一份)
280
+ resolved.push({ ...message, content: sections });
281
+ }
282
+ return resolved;
283
+ }
284
+ /**
285
+ * 按 id 取回素材内容(`asset.get`)。
286
+ *
287
+ * `thumb` 目前**等同原图**:本波不引入原生图像库,两档返回同一份字节与同一个 mime;
288
+ * 客户端要小图时按回包里的 `width` / `height`(未知为 `null`)自己缩。
289
+ */
290
+ async getAsset(input) {
291
+ const row = await this.assets.get(input.assetId);
292
+ if (!row) {
293
+ throw new HostError("not_found", {
294
+ params: { entity: "asset" },
295
+ detail: { assetId: input.assetId },
296
+ });
297
+ }
298
+ let data;
299
+ try {
300
+ data = await fs.readFile(this.resolveAssetPath(row.path));
301
+ }
302
+ catch (error) {
303
+ // 行在、文件不在:这是宿主自己的不一致(不是调用方的错),但客户端要知道“取不到”
304
+ this.report(error, "读取素材文件失败");
305
+ throw new HostError("not_found", {
306
+ params: { entity: "asset" },
307
+ detail: { assetId: input.assetId, reason: "asset_file_missing", path: row.path },
308
+ });
309
+ }
310
+ return {
311
+ asset: toAssetRef(row),
312
+ variant: input.variant ?? "original",
313
+ data: data.toString("base64"),
314
+ };
315
+ }
316
+ /**
317
+ * 一张素材的送达过程:读出字节来源、交给 SDK 转换、把结论译成 wire 上的「怎么走的」。
318
+ *
319
+ * 抛错不往上扔:一张图没送出去不该让整轮消息发不出去(F-H7 的「全失败退回纯文本」前提是
320
+ * 其余内容照发),所以这里收敛成一个 `dropped` 的结局并上报。
321
+ *
322
+ * `quiet` 是历史重放用的:重放是每一轮的内部动作,一张旧图今天没送出去不等于用户刚做错了什么,
323
+ * 不再往 `onProblem` 里塞一条(发送链上的告警留给“用户这一轮挂的图”)。
324
+ */
325
+ async deliver(record, target, options = {}) {
326
+ const label = record.name ?? basename(record.path);
327
+ try {
328
+ const result = await fileToContentSection({
329
+ source: await this.sourceOf(record),
330
+ endpoint: await this.vendorEndpoint(record, target.endpoint),
331
+ model: target.model,
332
+ deps: this.conversionDeps(record, target),
333
+ });
334
+ if (result.transport === "text") {
335
+ // 模型读不了图:给模型一句说明(不是给用户看的文案,所以不走 i18n)
336
+ const problem = this.problem("message.imageAsText", {
337
+ model: target.model?.name ?? target.model?.id ?? "",
338
+ name: label,
339
+ }, { assetId: record.id, reason: "model_without_vision", warning: result.warning });
340
+ if (!options.quiet)
341
+ this.options.onProblem?.(problem, "附件退化成文字");
342
+ return {
343
+ assetId: record.id,
344
+ deliveredAs: "text_fallback",
345
+ section: noVisionSection(label),
346
+ problem,
347
+ };
348
+ }
349
+ return {
350
+ assetId: record.id,
351
+ deliveredAs: deliveryOf(result.transport),
352
+ section: replacementSection(result.section, label),
353
+ problem: null,
354
+ };
355
+ }
356
+ catch (error) {
357
+ const problem = this.problem("notice.attachmentDropped", { name: label }, {
358
+ assetId: record.id,
359
+ reason: error instanceof Error ? error.message : String(error),
360
+ });
361
+ if (!options.quiet)
362
+ this.options.onProblem?.(problem, "附件没能发出去");
363
+ return { assetId: record.id, deliveredAs: "dropped", section: null, problem };
364
+ }
365
+ }
366
+ /**
367
+ * 这次交给 SDK 的端点。
368
+ *
369
+ * 这个端点上已经有这个素材的缓存行(或者哪个端点上都没有)时原样给它:前者命中复用,
370
+ * 后者上传一次。只有「这个端点上没有、别的端点上有」才给 `null` —— 那是换厂商 / 换端点,
371
+ * 不往新端点重传,SDK 于是走内联 base64。
372
+ */
373
+ async vendorEndpoint(record, endpoint) {
374
+ const vendor = this.vendorFiles;
375
+ if (!vendor || !endpoint)
376
+ return endpoint;
377
+ try {
378
+ const presence = await vendor.describeVendorFiles(record.id, endpoint.id);
379
+ return presence.onEndpoint || !presence.elsewhere ? endpoint : null;
380
+ }
381
+ catch (error) {
382
+ // 读不到缓存行就当没命中:交给 SDK 那条既定选路(上传失败会自己降级内联)
383
+ this.report(error, "读厂商文件缓存失败");
384
+ return endpoint;
385
+ }
386
+ }
387
+ /**
388
+ * 交给 SDK 转换器的依赖:只把 `upload` 换成一个「先查缓存、miss 才真上传」的版本。
389
+ *
390
+ * SDK 那边不记任何 file_id,所以「同一张图不重复上传」由持有持久存储的这一层承担。
391
+ * 缓存行以 `(assetId, endpointId)` 为键:命中就拿那个 `vendorFileId`,miss 才发 HTTP 并写回。
392
+ *
393
+ * 每张素材各自拿一个闭包(不共享可变状态),并发发送两张不同的图不会互相写错行。
394
+ */
395
+ conversionDeps(record, target) {
396
+ const vendor = this.vendorFiles;
397
+ const base = this.options.toolEnv;
398
+ if (!vendor)
399
+ return base;
400
+ const realUpload = base?.upload ?? defaultVendorUpload;
401
+ const upload = async (input) => {
402
+ const endpointId = input.endpoint.id;
403
+ try {
404
+ const cached = await vendor.getVendorFile(record.id, endpointId);
405
+ if (cached)
406
+ return cached.vendorFileId;
407
+ }
408
+ catch (error) {
409
+ // 读缓存失败不该让发送失败:当作 miss,重新上传一遍
410
+ this.report(error, "读厂商文件缓存失败");
411
+ }
412
+ const vendorFileId = await realUpload(input);
413
+ try {
414
+ await vendor.upsertVendorFile({
415
+ assetId: record.id,
416
+ endpointId,
417
+ vendorFileId,
418
+ createdAt: this.now(),
419
+ });
420
+ }
421
+ catch (error) {
422
+ // 写回失败:这一轮照常发(id 已经拿到了),下一轮只是多传一次
423
+ this.report(error, "写厂商文件缓存失败");
424
+ }
425
+ return vendorFileId;
426
+ };
427
+ return { ...base, upload };
428
+ }
429
+ /**
430
+ * 素材 → `fileToContentSection` 的来源。
431
+ *
432
+ * 扩展名能推出同一个 MIME 时给路径(SDK 自己 stat + 读,省一次我们的读盘);
433
+ * 推不出来时给字节 + 显式 mime,否则 SDK 会因为「不认识的扩展名」直接拒掉一个我们明明知道类型的文件。
434
+ */
435
+ async sourceOf(record) {
436
+ const absolutePath = this.resolveAssetPath(record.path);
437
+ if (mimeOf(absolutePath) === record.mime)
438
+ return { path: absolutePath };
439
+ const data = await fs.readFile(absolutePath);
440
+ return {
441
+ data: new Uint8Array(data),
442
+ mime: record.mime,
443
+ filename: record.name ?? basename(absolutePath),
444
+ };
445
+ }
446
+ /**
447
+ * 按 id 找模型与端点。
448
+ *
449
+ * 解析失败不让整轮崩掉:拿不到能力信息就按「没有端点能力、也没有模型信息」继续 ——
450
+ * 后果是图片走内联、超过内联上限的那张如实报「发不出去」,而不是整条消息失败。
451
+ */
452
+ async resolveTarget(modelId) {
453
+ const resolver = this.options.resolveModelContext;
454
+ const empty = { model: null, endpoint: null };
455
+ if (!resolver)
456
+ return empty;
457
+ try {
458
+ const resolved = await resolver(modelId);
459
+ return { model: resolved.model ?? null, endpoint: resolved.endpoint ?? null };
460
+ }
461
+ catch (error) {
462
+ this.report(error, "解析素材的实发模型失败");
463
+ return empty;
464
+ }
465
+ }
466
+ // -------------------------------------------------------------------------
467
+ // 按引用清理(R33)
468
+ // -------------------------------------------------------------------------
469
+ /**
470
+ * 一批素材 id → 引用归零的那些:删行、删文件。
471
+ *
472
+ * 调用时机是「消息清理完之后」:撤回、删对话、删项目都已经交给外键把关系行级联删掉了,
473
+ * 这里再问一次「这张素材还剩几行引用」(`COUNT(*)`)——还剩就不动,归零才删。
474
+ * `assets` 那行的删除走 {@link AssetRegistry.deleteIfUnreferenced}(`RESTRICT` 是护栏:
475
+ * 判完之后又有人引用上时,物理删不掉)。
476
+ *
477
+ * 返回真正删掉的文件(数据根相对路径);行在而文件已经不在时不报错(清理要能重复执行)。
478
+ */
479
+ async sweepUnreferenced(assetIds) {
480
+ const removed = [];
481
+ for (const assetId of dedupe(assetIds)) {
482
+ const row = await this.assets.get(assetId);
483
+ if (!row)
484
+ continue;
485
+ try {
486
+ if ((await this.assets.countReferences(assetId)) > 0)
487
+ continue;
488
+ if ((await this.assets.deleteIfUnreferenced(assetId)) === 0)
489
+ continue;
490
+ }
491
+ catch (error) {
492
+ // 删行失败就留着文件:行还在而文件没了会变成「有引用无文件」,比多留一份更糟
493
+ this.report(error, "删除素材行失败");
494
+ continue;
495
+ }
496
+ removed.push(...(await this.removeFiles([row.path])));
497
+ }
498
+ return removed;
499
+ }
500
+ /**
501
+ * 只删文件。
502
+ *
503
+ * 入参是数据根下的相对路径(`assets` 行的 `path` 就是这个形状)。路径必须落在 `assets/` 里:
504
+ * 这是唯一会让本服务删掉库外文件的地方,越界的路径一律不碰。
505
+ */
506
+ async removeFiles(relativePaths) {
507
+ const removed = [];
508
+ for (const relativePath of relativePaths) {
509
+ const absolutePath = this.resolveAssetPath(relativePath);
510
+ if (!this.isInsideAssetsDir(absolutePath)) {
511
+ this.report(new Error(`refusing to remove ${relativePath}`), "素材路径不在 assets 目录下,跳过");
512
+ continue;
513
+ }
514
+ try {
515
+ await fs.rm(absolutePath, { force: true });
516
+ removed.push(normalizeSeparators(relativePath));
517
+ }
518
+ catch (error) {
519
+ this.report(error, "删除素材文件失败");
520
+ }
521
+ }
522
+ return removed;
523
+ }
524
+ // -------------------------------------------------------------------------
525
+ // 路径与文案
526
+ // -------------------------------------------------------------------------
527
+ /** 数据根相对路径 → 绝对路径(已经是绝对路径的原样返回) */
528
+ resolveAssetPath(path) {
529
+ return isAbsolute(path) ? path : join(this.options.paths.dataRoot, path);
530
+ }
531
+ /** 只认 `assets/` 目录里面的路径(含 `..` 绕出去的都不认) */
532
+ isInsideAssetsDir(absolutePath) {
533
+ const fromAssets = relative(this.options.paths.assetsDir, absolutePath);
534
+ return fromAssets !== "" && !fromAssets.startsWith("..") && !isAbsolute(fromAssets);
535
+ }
536
+ /**
537
+ * 一条给人看的事实(`LocalizedText` 的血缘:码 + 文案键 + 参数 + 当前语言下的句子)。
538
+ *
539
+ * 这里的语言用默认语言:素材转换发生在 `ChatRunner` 那一轮里,那一层拿不到「这是哪条连接」。
540
+ * 客户端要按自己连接的语言显示时,用 `messageKey` + `params` 重新渲染即可(D8 的存码不存句子)。
541
+ */
542
+ problem(messageKey, params, detail) {
543
+ const text = {
544
+ code: messageKey,
545
+ messageKey,
546
+ params,
547
+ message: render(DEFAULT_LANGUAGE, messageKey, params),
548
+ };
549
+ return detail === undefined ? text : { ...text, detail };
550
+ }
551
+ report(error, context) {
552
+ const message = error instanceof Error ? error.message : String(error);
553
+ this.options.onProblem?.({
554
+ code: "internal",
555
+ messageKey: "error.internal",
556
+ params: {},
557
+ message: render(DEFAULT_LANGUAGE, "error.internal"),
558
+ detail: { context, reason: message },
559
+ }, context);
560
+ }
561
+ }
562
+ // ---------------------------------------------------------------------------
563
+ // 纯函数
564
+ // ---------------------------------------------------------------------------
565
+ /** 素材行 → wire 上的引用(尺寸未知时为 `null`,客户端按「未知」排占位) */
566
+ export function toAssetRef(row) {
567
+ return {
568
+ assetId: row.id,
569
+ mime: row.mime,
570
+ bytes: row.bytes,
571
+ name: row.name,
572
+ width: row.width,
573
+ height: row.height,
574
+ };
575
+ }
576
+ /**
577
+ * 素材 → 落库用的内容段。
578
+ *
579
+ * 图片是 `image_url`(`url` 留空),其余是 `file`(`filename` 记原始名字,`file_id` / `file_data`
580
+ * 都不出现);两种都只带本地扩展字段 `__asset_id`。真字段留空是故意的:库里存的是「引用哪份
581
+ * 素材」,而不是任何一种厂商产物。发送前由 {@link AssetService.resolveHistory} 按当时的模型
582
+ * 换成它能吃的真段。
583
+ */
584
+ export function assetSectionOf(row) {
585
+ if (row.mime.startsWith("image/")) {
586
+ return { type: "image_url", image_url: { url: "" }, __asset_id: row.id };
587
+ }
588
+ return row.name === null
589
+ ? { type: "file", __asset_id: row.id }
590
+ : { type: "file", filename: row.name, __asset_id: row.id };
591
+ }
592
+ /**
593
+ * 一条内容里的全部素材 id(按出现顺序、去重)。
594
+ *
595
+ * 撤回时的“回填输入框”(R62)与消息落库时的关系行都靠它复原「这条消息挂了哪几张图」:
596
+ * `assets` 行上不再记消息,`conversation_assets` 才是那个事实,而它的来源是 content。
597
+ */
598
+ export function assetIdsOf(content) {
599
+ if (!Array.isArray(content))
600
+ return [];
601
+ const ids = [];
602
+ for (const section of content) {
603
+ const assetId = localAssetIdOf(section);
604
+ if (assetId !== null)
605
+ ids.push(assetId);
606
+ }
607
+ return dedupe(ids);
608
+ }
609
+ /**
610
+ * 模型看不了图时给模型的替代段。
611
+ *
612
+ * 这是**发给模型的文字**,不是给人看的文案(所以不走 i18n):把文件名说清楚,让模型知道自己
613
+ * 漏了什么。不带磁盘路径 —— 那是本机事实,厂商不需要知道。
614
+ */
615
+ function noVisionSection(label) {
616
+ return { type: "text", text: `[Image] ${label}(当前模型看不了图片)` };
617
+ }
618
+ /**
619
+ * 把 SDK 给出的段收敛成本服务的三种形态。
620
+ *
621
+ * - Files API:`{ type: "file", file_id }`
622
+ * - 内联图片:`{ type: "image_url", image_url: { url: "data:…" } }`(`detail` 有就带上)
623
+ * - 其余(非图片内联):`{ type: "file", file_data, filename }`
624
+ *
625
+ * 同一个 `(素材, 端点)` 每次进来的 `source` 都是同一份字节,`upload` 命中持久缓存时拿到的也是
626
+ * 同一个 `file_id`,所以这里不做任何跟「这一次」有关的加工(时间戳、随机名、重新编码)——
627
+ * 连发两轮得到的段逐字节相同。
628
+ */
629
+ function replacementSection(section, label) {
630
+ if (section.type === "file") {
631
+ if (typeof section.file_id === "string") {
632
+ // `file_id` 已经是引用:只有 `file_data` 才带 `filename`(两者不同现,见 core 的类型)
633
+ return { type: "file", file_id: section.file_id };
634
+ }
635
+ if (typeof section.file_data === "string") {
636
+ return { type: "file", file_data: section.file_data, filename: label };
637
+ }
638
+ return section;
639
+ }
640
+ if (section.type === "image_url") {
641
+ const detail = section.image_url.detail;
642
+ return detail === undefined
643
+ ? { type: "image_url", image_url: { url: section.image_url.url } }
644
+ : { type: "image_url", image_url: { url: section.image_url.url, detail } };
645
+ }
646
+ return section;
647
+ }
648
+ /**
649
+ * 还原失败时给模型的替代段。
650
+ *
651
+ * 这也是**发给模型的文字**(不走 i18n):素材已经不在库里时,模型至少要知道这里原本有一个附件。
652
+ */
653
+ function unavailableSection(assetId, record) {
654
+ const label = record?.name ?? assetId;
655
+ return {
656
+ type: "text",
657
+ text: record === undefined
658
+ ? `[Attachment] ${label} is no longer available in this conversation.`
659
+ : `[Attachment] ${label} could not be sent with this request.`,
660
+ };
661
+ }
662
+ /**
663
+ * 缺省上传:OpenAI 兼容的 `POST {baseUrl}/files`(`purpose=user_data`)。
664
+ *
665
+ * 与 SDK 里的同名行为一致,但那个函数没有从包根导出(宿主只被允许调用 SDK 的公开出口),
666
+ * 而注入 `deps.upload` 之后 SDK 就不会再用自己的缺省实现,所以这一份写在这里。
667
+ * 用原生 `fetch` + `FormData`,不引任何东西。
668
+ */
669
+ async function defaultVendorUpload(input) {
670
+ // 拷一份:`Uint8Array<ArrayBufferLike>` 与 `BlobPart` 要求的 `ArrayBuffer` 在 TS 5.7 起不兼容
671
+ const bytes = new Uint8Array(input.data.byteLength);
672
+ bytes.set(input.data);
673
+ const form = new FormData();
674
+ form.append("purpose", "user_data");
675
+ form.append("file", new Blob([bytes], { type: mimeOf(input.filename) ?? "application/octet-stream" }), input.filename);
676
+ const response = await fetch(`${input.endpoint.baseUrl.replace(/\/+$/, "")}/files`, {
677
+ method: "POST",
678
+ headers: { authorization: `Bearer ${input.endpoint.apiKey}` },
679
+ body: form,
680
+ signal: input.signal,
681
+ });
682
+ if (!response.ok) {
683
+ const detail = await response.text().catch(() => "");
684
+ throw new Error(`File upload failed: HTTP ${response.status}${detail ? ` — ${detail.slice(0, 200)}` : ""}`);
685
+ }
686
+ const body = (await response.json());
687
+ if (typeof body.id !== "string")
688
+ throw new Error("File upload failed: response has no `id`");
689
+ return body.id;
690
+ }
691
+ /** MIME 对应的落盘扩展名;表外类型落 `.bin`(取用时改走「字节 + 显式 mime」的来源) */
692
+ function extensionFor(mime) {
693
+ return EXTENSION_BY_MIME[mime.toLowerCase()] ?? ".bin";
694
+ }
695
+ /** 绝对路径 → 数据根下的相对路径(分隔符统一成 `/`,与库里存的口径一致) */
696
+ function toRelativePath(dataRoot, absolutePath) {
697
+ return normalizeSeparators(relative(dataRoot, absolutePath));
698
+ }
699
+ function normalizeSeparators(path) {
700
+ return path.replace(/\\/g, "/");
701
+ }
702
+ /** 去重并去掉空值(同一张图重复 id 只发一次) */
703
+ function dedupe(values) {
704
+ const seen = new Set();
705
+ const result = [];
706
+ for (const value of values) {
707
+ if (typeof value !== "string")
708
+ continue;
709
+ const trimmed = value.trim();
710
+ if (trimmed === "" || seen.has(trimmed))
711
+ continue;
712
+ seen.add(trimmed);
713
+ result.push(trimmed);
714
+ }
715
+ return result;
716
+ }
717
+ /** SDK 给的传输方式 → wire 上的「这个附件怎么走的」 */
718
+ function deliveryOf(transport) {
719
+ if (transport === "files-api" || transport === "url")
720
+ return "file_reference";
721
+ if (transport === "inline")
722
+ return "inline";
723
+ return "text_fallback";
724
+ }
725
+ /**
726
+ * 把素材段接在用户输入后面(F-H7:文本段在前)。
727
+ *
728
+ * 用户输入是字符串时先包成一段 `text`:core 的内容段数组里没有「裸字符串 + 数组」这种混装。
729
+ */
730
+ function withSections(content, sections) {
731
+ if (sections.length === 0)
732
+ return content;
733
+ const head = typeof content === "string"
734
+ ? content === ""
735
+ ? []
736
+ : [{ type: "text", text: content }]
737
+ : content;
738
+ return [...head, ...sections];
739
+ }