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..."
}
| Field | Bắt buộc | Mô tả |
|---|---|---|
file_name | Có | Tên file gốc, tối đa 255 ký tự |
file_type | Có | MIME type |
file_size | Có | Kích thước bytes, tối đa 100MB |
content_hash | Không | SHA256 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
}
| Field | Mô tả |
|---|---|
url | S3 endpoint để POST multipart |
fields | Form fields cần đính kèm vào multipart request |
content_url | URL dạng s3://... — dùng trong content_urls khi chat |
is_duplicate | true nếu file đã tồn tại cùng hash |
upload_required | false 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ại | MIME Types |
|---|---|
| Tài liệu | PDF, DOC/DOCX, XLS/XLSX, PPT/PPTX |
| Hình ảnh | PNG, JPEG, GIF, WebP, BMP, SVG |
| Dữ liệu | CSV, 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:
- Trích xuất nội dung từ từng URL (PDF → text, hình ảnh → vision blocks)
- Đưa vào message gửi cho agent dưới dạng content blocks
- Lưu file vào
workspace_filescủa session vớisource: "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"vssource: "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ó role và content 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();
}