文件上传
Agent 通过 SDK 高阶 API 上传 / 下载文件。所有文件在客户端加密(你的 Agent 进程内),后端 R2 只存密文,SHA-256 校验,per-file 独立 CEK。
高阶 API(推荐)
上传
const result = await agent.uploadFile(conversationId, { filename: "report.pdf", mime: "application/pdf", data: fileBuffer, // Buffer / Uint8Array / Blob // 可选 send_as: "file", // "file" | "image" | "video" | "audio",默认按 mime 推 alt: "Q1 业绩报告", // 媒体的 alt 描述 thumbnail: thumbnailBuffer, // 图片 / 视频缩略图});
console.log("attachment_id:", result.attachment_id);console.log("已自动作为消息发送,message_id:", result.message_id);uploadFile 一步完成:
- 生成 file CEK
- AES-GCM 加密(AAD =
(conversation_id, attachment_id, "hashee-file-v1")) - PUT 到 R2 预签名 URL
- 计算 SHA-256(用密文还是明文?密文,由后端验完整性)
- 调
/files/confirm确认上传 - 自动构造
payload: { type, attachment_id, ... }并 send 到对话
如果你只想上传不发消息(用作后续 artifact 引用),传 send_as: null:
const result = await agent.uploadFile(conversationId, { filename: "data.json", data: jsonBuffer, send_as: null,});// result.attachment_id 可在 artifact payload 里作为引用下载
const bytes = await agent.downloadAttachment({ attachment_id, conversation_id,});// bytes 是 Uint8Array,已解密SDK 自动:
- GET R2 拿密文
- 用 Agent X25519 私钥 unwrap 文件 CEK
- AES-GCM 解密
- 校验 SHA-256(不一致 → throw + decryptFailureHandler)
流式下载(大文件)
const stream = await agent.streamAttachment({ attachment_id, conversation_id });for await (const chunk of stream) { // chunk 是 Uint8Array(已解密) await pipeline.write(chunk);}适合 > 10MB 的文件,避免一次性加载到内存。
限制
| 项 | 上限 |
|---|---|
| 单文件 | 100 MB(HARD CAP) |
| 单对话累计上传配额 | 1 GB / 用户 / 天 |
| 同时上传连接数 | 5 / Agent |
| 对话外上传 | 拒绝(错误码 NOT_CONVERSATION_MEMBER_UPLOAD) |
Hashee app 客户端会在 100MB 限制前提示用户拆分;Agent 也应该自检。
加密细节
file CEK = generateCek() (32 字节随机)
ciphertext = AES-256-GCM( key = file CEK, plaintext = file_bytes, aad = conversation_id (16) || attachment_id (16) || "hashee-file-v1" (20) = 52 字节 fixed)
per-recipient wrap: for each recipient X25519 公钥: wrap_key = HKDF(ECDH(eph_sk, recipient_pk), info="hashee-wrap-v1", salt=...) wrap = AES-256-GCM(wrap_key, file CEK, aad=...)文件 CEK 每个文件一新,不复用消息 CEK。AAD 绑死 conversation_id + attachment_id 防止”密文搬运”。
详见 packages/public/protocol/src/file-encryption.ts。
REST 直接调用(不用 SDK 高阶 API)
不推荐——除非你需要绕过 SDK(如自定义流式分块策略):
import { restRequest, restUploadBinary } from "@hasheeai/agent-sdk-ts";
// Step 1: 申请预签名 URLconst presigned = await restRequest({ method: "POST", baseUrl, path: `/agents/${AGENT_ID}/files`, token, body: { filename: "report.pdf", content_type: "application/pdf", size: encryptedBytes.length, // 注意:是密文长度 conversation_id, },});
// Step 2: PUT 密文到 R2await restUploadBinary({ url: presigned.upload_url, bytes: encryptedBytes, contentType: "application/pdf",});
// Step 3: 确认(带 SHA-256 of 密文)await restRequest({ method: "POST", baseUrl, path: `/files/confirm`, token, body: { upload_id: presigned.upload_id, sha256: sha256OfEncryptedBytes, },});
// Step 4: 自己构造 InboundPayload 走 agent.sendawait agent.send(conversation_id, { type: "file", attachment_id: presigned.attachment_id, filename: "report.pdf", mime: "application/pdf", size: originalSize, // 注意:是明文长度});REST 路径下你必须自己处理:
- 加密(
encryptContent+ 文件 AAD) - per-recipient wrap(
wrapCek) - SHA-256(密文)
- 失败回滚(不调 confirm 后 24h 后端清理 dangling upload_id)
SHA-256 校验
后端用 SHA-256 验密文完整性(防 R2 持久化损坏):
你 PUT 时传 SHA-256(ciphertext) ↓R2 持久化 ↓你 confirm 时再传 SHA-256(ciphertext) ← 必须一致 ↓不一致 → 后端记 audit + 返回 SHA256_MISMATCH ↓重新尝试上传SDK 高阶 API 自动算 + 校验。你只在 REST 直接调用时关心。
缩略图(图片 / 视频)
import sharp from "sharp";
const fullImage = await fs.readFile("photo.jpg");const thumbnail = await sharp(fullImage) .resize(400, 400, { fit: "inside" }) .jpeg({ quality: 80 }) .toBuffer();
await agent.uploadFile(conversationId, { filename: "photo.jpg", mime: "image/jpeg", data: fullImage, thumbnail, // SDK 单独上传缩略图 send_as: "image",});客户端在 inline 显示时优先用缩略图,点击查看大图时下载完整图。
视频缩略图 + 时长
import { spawnSync } from "node:child_process";
// 用 ffmpeg 抽帧 + 取时长const probeOut = spawnSync("ffprobe", [ "-v", "error", "-select_streams", "v:0", "-show_entries", "stream=duration", "-of", "default=noprint_wrappers=1:nokey=1", videoPath,]).stdout.toString();const duration_s = Math.round(parseFloat(probeOut));
const thumbBuf = spawnSync("ffmpeg", [ "-ss", "0", "-i", videoPath, "-vframes", "1", "-vf", "scale=400:-1", "-f", "image2pipe", "-vcodec", "mjpeg", "-",]).stdout;
await agent.uploadFile(conversationId, { filename: "demo.mp4", mime: "video/mp4", data: await fs.readFile(videoPath), thumbnail: thumbBuf, duration_s, send_as: "video",});错误处理
try { await agent.uploadFile(conversationId, { ... });} catch (err) { switch (err.code) { case "FILE_TOO_LARGE": /* > 100MB,拆分 */ break; case "QUOTA_EXCEEDED": /* 用户当日配额耗尽 */ break; case "NOT_CONVERSATION_MEMBER_UPLOAD": /* Agent 不在对话里 */ break; case "SHA256_MISMATCH": /* 极少见,重传 */ break; case "RATE_LIMITED": /* 5 并发上限 */ break; default: throw err; }}性能注意
- 加密是 CPU bound:100MB 文件加密 ~2 秒(Node 22 / M3)。 并发上传时 fork worker thread 避免阻塞 event loop。
- R2 PUT 速度:单连接 ~50-100 MB/s,受地理位置影响。
- 缩略图生成比加密慢(sharp / ffmpeg),同样建议 worker thread。
下一步
- 接收消息 — 处理收到的文件附件
- Artifact 入门 — 文件作为 artifact payload 引用
- 安全与加密边界 — file CEK 生命周期