Chuyển tới nội dung chính

File Attachments

Tài liệu này mô tả cách upload file, đính kèm vào message, và cách attachment được hiển thị trong realtime stream lẫn history.


Upload Flow

1. User chọn file (click paperclip)
2. Client tính SHA256 hash (deduplication)
3. POST /v2/files/upload-url → nhận presigned S3 URL
4. Client upload thẳng lên S3 (không qua server)
5. Gửi chat với content_urls chứa s3:// URL của file

API: Lấy Presigned Upload URL

POST /v2/files/upload-url
Authorization: Bearer {token}
Content-Type: application/json

Request:

{
"file_name": "bao-cao-tai-chinh.pdf",
"file_type": "application/pdf",
"file_size": 2097152,
"content_hash": "a3f1b2c4d5e6..."
}
FieldBắt buộcMô tả
file_nameTên file gốc, tối đa 255 ký tự
file_typeMIME type
file_sizeKích thước bytes, tối đa 100MB
content_hashKhôngSHA256 hex — nếu trùng server trả is_duplicate: true, skip upload

Response:

{
"url": "https://s3.amazonaws.com/bucket",
"fields": {
"key": "uploads/user123/file456/bao-cao.pdf",
"AWSAccessKeyId": "...",
"policy": "...",
"signature": "..."
},
"content_url": "s3://bucket/uploads/user123/file456/bao-cao.pdf",
"is_duplicate": false,
"upload_required": true
}
FieldMô tả
urlS3 endpoint để POST multipart
fieldsForm fields cần đính kèm vào multipart request
content_urlURL dạng s3://... — dùng trong content_urls khi chat
is_duplicatetrue nếu file đã tồn tại cùng hash
upload_requiredfalse nếu duplicate — dùng content_url trực tiếp, không cần upload

Upload lên S3:

const formData = new FormData();
Object.entries(response.fields).forEach(([key, val]) => formData.append(key, val));
formData.append('file', file);

await fetch(response.url, { method: 'POST', body: formData });

Định Dạng Được Hỗ Trợ

LoạiMIME Types
Tài liệuPDF, DOC/DOCX, XLS/XLSX, PPT/PPTX
Hình ảnhPNG, JPEG, GIF, WebP, BMP, SVG
Dữ liệuCSV, TXT
  • Tối đa 3 file mỗi message
  • Tối đa 100MB mỗi file

Gửi Message Với File

Sau khi upload, truyền content_url vào field content_urls:

WebSocket:

{
"type": "chat",
"message": "Phân tích file này cho tôi",
"session_id": "abc-123",
"content_urls": [
"s3://bucket/uploads/user123/file456/bao-cao.pdf"
]
}

REST (SSE):

{
"message": "Phân tích file này cho tôi",
"session_id": "abc-123",
"content_urls": [
"s3://bucket/uploads/user123/file456/bao-cao.pdf"
]
}

Xử Lý Phía Server

Khi nhận content_urls, server:

  1. Trích xuất nội dung từ từng URL (PDF → text, hình ảnh → vision blocks)
  2. Đưa vào message gửi cho agent dưới dạng content blocks
  3. Lưu file vào workspace_files của session với source: "upload" — file xuất hiện trong WorkingFolderPanel

File upload cũng được track vào workspace_files (cùng bảng với file agent tạo ra), phân biệt bằng source: "upload" vs source: "generated". Xem chi tiết tại Workspace Files.


Hiển Thị Attachment — Realtime

Khi user gửi message kèm file, attachment metadata được lưu ngay trên client và hiển thị dưới message bubble dưới dạng AttachmentChip. Không có SSE event riêng cho attachment trong user message — frontend lưu metadata cùng lúc gửi chat.

Mỗi AttachmentChip hiển thị:

  • Icon file với màu theo loại (PDF đỏ, hình ảnh xanh dương, Excel xanh lá)
  • Tên file và metadata (loại + kích thước)

History (F5 Reload)

Cấu Trúc Message Từ History API

History API trả về mảng messages, mỗi message có rolecontent là array of blocks:

{
"messages": [
{
"role": "user",
"content": [
{
"type": "text",
"text": "Phân tích file này cho tôi"
},
{
"type": "attachment",
"path": "s3://bucket/uploads/user123/file456/bao-cao.pdf",
"filename": "bao-cao.pdf",
"icon_type": "pdf",
"source": "upload",
"url": "uploads/user123/file456/bao-cao.pdf",
"file_size": 2097152,
"content_type": "application/pdf"
}
]
},
{
"role": "assistant",
"content": [
{
"type": "text",
"text": "Đây là kết quả phân tích..."
}
],
"tool_calls": [],
"attachments": []
}
]
}

Parse User Message

parseV2History() đọc msg.content array và tách riêng text và attachments:

msg.content.forEach(block => {
if (block.type === 'text') {
textContent += block.text;
} else if (block.type === 'attachment') {
attachments.push(block);
}
});

Kết quả:

{
id: "session-history-0",
type: "user",
content: "Phân tích file này cho tôi",
attachments: [
{
type: "attachment",
path: "s3://bucket/uploads/.../bao-cao.pdf",
filename: "bao-cao.pdf",
icon_type: "pdf",
source: "upload",
url: "uploads/.../bao-cao.pdf",
file_size: 2097152
}
],
isStreaming: false
}

Frontend render lại AttachmentChip y hệt như realtime — click mở FileViewer.

Parse AI Message Với Attachments Block

Nếu agent đã tạo file và gửi attachments block, message history của assistant sẽ có attachments field:

{
"role": "assistant",
"content": [
{
"type": "text",
"text": "Tôi đã tạo xong báo cáo phân tích:"
}
],
"attachments": [
{
"path": "/report.md",
"filename": "report.md",
"icon_type": "md",
"source": "generated"
}
]
}

parseV2History() thêm attachments block vào cuối danh sách blocks của AI message:

if (msg.attachments && msg.attachments.length > 0) {
blocks.push({
type: 'attachments',
files: msg.attachments,
});
}

Kết quả AI message object:

{
id: "session-history-1",
type: "ai_answer",
isStreaming: false,
blocks: [
{ type: "text", content: "Tôi đã tạo xong báo cáo phân tích:" },
{
type: "attachments",
files: [
{ path: "/report.md", filename: "report.md", icon_type: "md", source: "generated" }
]
}
]
}

Render thành FilesBlock — y hệt như lúc stream realtime.


Click Vào Attachment

Click vào AttachmentChip (user message) hoặc FileCard (AI FilesBlock) → mở FileViewer modal.

Chi tiết FileViewer xem tại File Viewer.


Xóa File

DELETE /v2/files/delete?content_url=s3://bucket/uploads/...
Authorization: Bearer {token}

Ví Dụ: Upload Và Gửi File

async function uploadAndSendFile(file, sessionId, ws) {
const hash = await computeSHA256(file);

const res = await fetch('/v2/files/upload-url', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({
file_name: file.name,
file_type: file.type,
file_size: file.size,
content_hash: hash,
}),
});
const { url, fields, content_url, upload_required } = await res.json();

if (upload_required) {
const form = new FormData();
Object.entries(fields).forEach(([k, v]) => form.append(k, v));
form.append('file', file);
await fetch(url, { method: 'POST', body: form });
}

ws.send(JSON.stringify({
type: 'chat',
message: 'Phân tích file này',
session_id: sessionId,
content_urls: [content_url],
}));
}

async function computeSHA256(file) {
const buffer = await file.arrayBuffer();
const hashBuffer = await crypto.subtle.digest('SHA-256', buffer);
return Array.from(new Uint8Array(hashBuffer))
.map(b => b.toString(16).padStart(2, '0'))
.join('');
}

Ví Dụ: Render Attachment Từ History

function UserMessage({ message }) {
return (
<div>
<p>{message.content}</p>
{message.attachments?.length > 0 && (
<div className="flex flex-wrap gap-2 mt-2">
{message.attachments.map((att, i) => (
<AttachmentChip
key={att.path || i}
attachment={att}
onClick={() => openFileViewer(att)}
/>
))}
</div>
)}
</div>
);
}

function AttachmentChip({ attachment, onClick }) {
return (
<button onClick={onClick} className="flex items-center gap-1.5 px-2 py-1.5 rounded-xl border">
<FileTypeIcon iconType={attachment.icon_type} />
<span>{attachment.filename}</span>
</button>
);
}

Ví Dụ: Flutter (Dart)

Future<String> uploadFile(File file, String token) async {
final bytes = await file.readAsBytes();
final hash = sha256.convert(bytes).toString();

final res = await http.post(
Uri.parse('/v2/files/upload-url'),
headers: {'Authorization': 'Bearer $token', 'Content-Type': 'application/json'},
body: jsonEncode({
'file_name': path.basename(file.path),
'file_type': lookupMimeType(file.path) ?? 'application/octet-stream',
'file_size': bytes.length,
'content_hash': hash,
}),
);
final data = jsonDecode(res.body);

if (data['upload_required'] == true) {
final request = http.MultipartRequest('POST', Uri.parse(data['url']));
(data['fields'] as Map<String, dynamic>).forEach((k, v) {
request.fields[k] = v.toString();
});
request.files.add(await http.MultipartFile.fromPath('file', file.path));
await request.send();
}

return data['content_url'] as String;
}

// Parse attachments từ history message
List<Map<String, dynamic>> parseAttachments(List<dynamic> content) {
return content
.whereType<Map<String, dynamic>>()
.where((block) => block['type'] == 'attachment')
.toList();
}