跳转到内容

文件上传

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 一步完成:

  1. 生成 file CEK
  2. AES-GCM 加密(AAD = (conversation_id, attachment_id, "hashee-file-v1")
  3. PUT 到 R2 预签名 URL
  4. 计算 SHA-256(用密文还是明文?密文,由后端验完整性)
  5. /files/confirm 确认上传
  6. 自动构造 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 自动:

  1. GET R2 拿密文
  2. 用 Agent X25519 私钥 unwrap 文件 CEK
  3. AES-GCM 解密
  4. 校验 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: 申请预签名 URL
const 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 密文到 R2
await 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.send
await 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。

下一步