Phiên bản đầu của tính năng "hỏi về hoá đơn này" bên mình nhận mọi file và POST thẳng lên một API route. Chưa tới một tuần đã có ba loại bug report: một ảnh iPhone 14 MB bị timeout trên 4G, một ảnh chụp màn hình paste từ clipboard mà không có gì xảy ra, và một ticket từ người dùng phát hiện toạ độ GPS nhà mình nằm trong bức ảnh mà hệ thống đã lưu lại. Model chẳng có lỗi gì. Bài này là những gì giờ mình luôn làm cho bất kỳ tính năng nào gửi ảnh, tài liệu hay audio cho LLM, không phụ thuộc provider.
Ba đường vào: picker, kéo thả và paste#
Người dùng đưa file vào theo ba cách; thiếu một cách là tính năng có cảm giác "hỏng".
- File input. Luôn giữ một
input type="file"thật trong DOM, kể cả khi bạn style một nút đè lên. Nó cho bạn bàn phím, picker gốc trên mobile và gợi ýaccept— chỉ là gợi ý, không phải validate. - Kéo thả. Lắng nghe
dragover(phải gọipreventDefault, không thìdropkhông bao giờ bắn) vàdrop, rồi đọcevent.dataTransfer.files. - Paste. Với ảnh chụp màn hình, đây là đường chính: người ta bấm phím tắt chụp màn hình rồi paste vào ô chat. Event
pastecó sẵnclipboardData.files. Không cần async Clipboard API hay xin quyền.
Cả ba nên đổ về một hàm nhận File[], để validate và state nằm ở đúng một chỗ:
import { useCallback, type ClipboardEvent, type DragEvent } from "react";
export function useFileIntake(addFiles: (files: File[]) => void) {
const onPaste = useCallback(
(event: ClipboardEvent<HTMLElement>) => {
const files = Array.from(event.clipboardData.files);
if (files.length === 0) return;
event.preventDefault();
addFiles(files);
},
[addFiles],
);
const onDragOver = useCallback((event: DragEvent<HTMLElement>) => {
if (event.dataTransfer.types.includes("Files")) event.preventDefault();
}, []);
const onDrop = useCallback(
(event: DragEvent<HTMLElement>) => {
event.preventDefault();
addFiles(Array.from(event.dataTransfer.files));
},
[addFiles],
);
return { onPaste, onDragOver, onDrop };
}
Để ý onPaste chỉ gọi preventDefault khi thật sự có file. Paste chữ bình thường vào textarea vẫn phải chạy như cũ.
Mô hình state cho attachment composer#
Mỗi attachment đi qua một vòng đời nhỏ: đọc file, có thể biến đổi, upload, và chỉ sau đó message mới được gửi. Giống bài state machine cho UI, mình mô hình hoá mỗi attachment bằng một discriminated union thay vì một mớ cờ boolean:
export type AttachmentKind = "image" | "pdf" | "audio";
export type Attachment =
| { id: string; status: "processing"; name: string; kind: AttachmentKind }
| { id: string; status: "rejected"; name: string; reason: string }
| { id: string; status: "uploading"; name: string; kind: AttachmentKind; previewUrl: string | null; progress: number }
| { id: string; status: "ready"; name: string; kind: AttachmentKind; previewUrl: string | null; fileRef: string }
| { id: string; status: "failed"; name: string; kind: AttachmentKind; previewUrl: string | null; message: string };
export type ComposerEvent =
| { type: "ADDED"; id: string; name: string; kind: AttachmentKind }
| { type: "REJECTED"; id: string; name: string; reason: string }
| { type: "UPLOAD_STARTED"; id: string; previewUrl: string | null }
| { type: "PROGRESS"; id: string; progress: number }
| { type: "UPLOADED"; id: string; fileRef: string }
| { type: "UPLOAD_FAILED"; id: string; message: string }
| { type: "REMOVED"; id: string };
export function composerReducer(attachments: Attachment[], event: ComposerEvent): Attachment[] {
switch (event.type) {
case "ADDED":
return [...attachments, { id: event.id, status: "processing", name: event.name, kind: event.kind }];
case "REJECTED":
return [...attachments, { id: event.id, status: "rejected", name: event.name, reason: event.reason }];
case "REMOVED":
return attachments.filter((attachment) => attachment.id !== event.id);
default:
return attachments.map((attachment) =>
attachment.id === event.id ? transitionAttachment(attachment, event) : attachment,
);
}
}
function transitionAttachment(attachment: Attachment, event: ComposerEvent): Attachment {
if (attachment.status === "processing" && event.type === "UPLOAD_STARTED") {
return { ...attachment, status: "uploading", previewUrl: event.previewUrl, progress: 0 };
}
if (attachment.status !== "uploading") return attachment;
switch (event.type) {
case "PROGRESS":
return { ...attachment, progress: event.progress };
case "UPLOADED":
return { id: attachment.id, status: "ready", name: attachment.name, kind: attachment.kind, previewUrl: attachment.previewUrl, fileRef: event.fileRef };
case "UPLOAD_FAILED":
return { id: attachment.id, status: "failed", name: attachment.name, kind: attachment.kind, previewUrl: attachment.previewUrl, message: event.message };
default:
return attachment;
}
}
export function canSend(attachments: Attachment[]): boolean {
return attachments.every((attachment) => attachment.status === "ready" || attachment.status === "rejected");
}
Có hai quyết định được ghi thẳng vào type. Thứ nhất, message chỉ tham chiếu fileRef, một ID do server cấp, không bao giờ mang byte thô, nên retry một message không phải upload lại. Thứ hai, một PROGRESS hay UPLOADED đến muộn cho attachment mà người dùng đã xoá đơn giản là không tìm thấy gì để cập nhật. File bị từ chối vẫn hiện kèm lý do, vì file tự dưng biến mất là phản hồi tệ nhất.
Validate phía client: nhìn byte, đừng tin tên file#
File.type được suy ra từ đuôi file (hoặc từ hệ điều hành), không phải từ nội dung. Một file .jpg thực chất là HEIC, hay một PDF bị đổi tên thành .png, gặp rất thường ngoài đời. Mình đọc vài byte đầu và so magic number:
type SniffedType = "image/jpeg" | "image/png" | "image/webp" | "image/gif" | "image/heic" | "application/pdf";
function startsWith(bytes: Uint8Array, signature: number[], offset = 0): boolean {
return signature.every((byte, index) => bytes[offset + index] === byte);
}
function ascii(bytes: Uint8Array, start: number, end: number): string {
return String.fromCharCode(...bytes.subarray(start, end));
}
export async function sniffType(file: Blob): Promise<SniffedType | null> {
const bytes = new Uint8Array(await file.slice(0, 16).arrayBuffer());
if (startsWith(bytes, [0xff, 0xd8, 0xff])) return "image/jpeg";
if (startsWith(bytes, [0x89, 0x50, 0x4e, 0x47])) return "image/png";
if (ascii(bytes, 0, 4) === "RIFF" && ascii(bytes, 8, 12) === "WEBP") return "image/webp";
if (ascii(bytes, 0, 4) === "GIF8") return "image/gif";
if (ascii(bytes, 0, 5) === "%PDF-") return "application/pdf";
if (ascii(bytes, 4, 8) === "ftyp" && ["heic", "heix", "mif1", "msf1"].includes(ascii(bytes, 8, 12))) return "image/heic";
return null;
}
Giới hạn kích thước có hai lớp: một mức trần cứng cho file gốc (mình hay dùng khoảng 25 MB, chủ yếu để bảo vệ bộ nhớ của tab) và giới hạn của provider cho thứ thật sự được gửi đi, thường thấp hơn và khác nhau theo provider lẫn loại file. Gom chúng vào một object config.
Cẩn thận
Validate phía client là UX, không phải bảo mật. Ai cũng có thể gọi endpoint upload của bạn bằng curl. Mọi thứ trong phần này đều phải được lặp lại ở server, trên chính các byte đã lưu.
Thu nhỏ ảnh ngay trên trình duyệt#
Model vision đằng nào cũng resize ảnh lớn ở phía trong, và nhiều provider tính tiền ảnh theo diện tích pixel. Gửi một ảnh 4032×3024 tốn thời gian upload trên mạng di động mà chẳng được thêm gì. Mình thu nhỏ về cạnh dài khoảng 1500–2000 px trước khi upload (đọc chữ nhỏ trên hoá đơn thì cần nhiều hơn).
Encode lại cũng giải quyết luôn vấn đề quyền riêng tư ở phần mở bài. Vẽ lên canvas rồi xuất ra một JPEG hay WebP mới chỉ ghi lại pixel, nên EXIF, kể cả toạ độ GPS, không còn sót lại. Đây là cách rẻ và chắc chắn nhất để xoá metadata: đừng parse EXIF, chỉ cần đừng copy nó.
type DownscaleOptions = { maxEdge: number; mimeType: "image/jpeg" | "image/webp"; quality: number };
export async function downscaleImage(file: Blob, options: DownscaleOptions): Promise<Blob> {
const bitmap = await createImageBitmap(file, { imageOrientation: "from-image" });
try {
const scale = Math.min(1, options.maxEdge / Math.max(bitmap.width, bitmap.height));
const width = Math.round(bitmap.width * scale);
const height = Math.round(bitmap.height * scale);
if (typeof OffscreenCanvas !== "undefined") {
const canvas = new OffscreenCanvas(width, height);
const context = canvas.getContext("2d");
if (!context) throw new Error("2D context unavailable");
context.imageSmoothingQuality = "high";
context.drawImage(bitmap, 0, 0, width, height);
return await canvas.convertToBlob({ type: options.mimeType, quality: options.quality });
}
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const context = canvas.getContext("2d");
if (!context) throw new Error("2D context unavailable");
context.imageSmoothingQuality = "high";
context.drawImage(bitmap, 0, 0, width, height);
return await new Promise<Blob>((resolve, reject) =>
canvas.toBlob(
(blob) => (blob ? resolve(blob) : reject(new Error("Encoding failed"))),
options.mimeType,
options.quality,
),
);
} finally {
bitmap.close();
}
}
Vài chi tiết từng làm mình mất thời gian:
- Hướng ảnh.
imageOrientation: "from-image"hiện là mặc định, nên khi decode, trình duyệt đã xoay ảnh theo EXIF. Mình vẫn truyền tường minh, vì khi đã bỏ EXIF thì pixel phải đứng thẳng sẵn. - Loại output không hỗ trợ sẽ âm thầm fallback. Nếu trình duyệt không encode được loại bạn yêu cầu,
toBlobvàconvertToBlobtrả về PNG, có khi còn nặng hơn file gốc. Kiểm trablob.typesau khi encode và tự fallback về JPEG. JPEG quality 0.85 là mặc định an toàn của mình. - Nền trong suốt. JPEG không có kênh alpha; ảnh chụp màn hình PNG có vùng trong suốt sẽ thành nền đen. Giữ ảnh nguồn PNG và WebP ở dạng WebP.
- Main thread. Decode ảnh 48 MP có thể chặn main thread. Vì
createImageBitmapvàOffscreenCanvasđều chạy được trong worker, nhánhOffscreenCanvascủa hàm trên có thể chuyển vào worker gần như nguyên vẹn; xem bài Web Workers.
HEIC từ iPhone#
iPhone mặc định chụp HEIC. Tính đến cuối 2026, Safari (từ bản 17) là trình duyệt lớn duy nhất decode được HEIC trong nội dung web; Chrome và Firefox thì không, nên createImageBitmap sẽ reject trên hai trình duyệt này. Theo kinh nghiệm của mình, iOS Safari thường đưa cho trang một file JPEG khi accept của file input không ghi rõ HEIC, nhưng người dùng desktop AirDrop ảnh sang Mac rồi kéo vào Chrome thì sẽ đưa bạn HEIC nguyên bản. Trình duyệt decode được thì thu nhỏ như thường; không được thì convert ở server (công cụ dựa trên libheif) hoặc tải một decoder WASM khi cần. Và đừng hiện preview vỡ: sniff đã cho biết đó là HEIC, nên hãy render một chip tên file thay vì thẻ img.
PDF: gửi nguyên file hay trích text?#
Nhiều LLM API giờ nhận PDF trực tiếp và xử lý cả phần text lẫn hình ảnh từng trang. Bản scan, bảng, biểu đồ được xử lý tốt hơn, đổi lại tốn token hơn. Cách còn lại là trích text ngay trên trình duyệt bằng pdf.js rồi gửi text thuần.
| Gửi nguyên PDF | Trích text trên trình duyệt | |
|---|---|---|
| Bản scan, biểu đồ, bảng | Model tự xử lý | Mất; bản scan cho ra text rỗng |
| Chi phí | Cao hơn; thường tính cả text lẫn ảnh trang | Chỉ token text |
| Bundle | Không thêm gì | pdf.js nặng; phải lazy-load |
| Giới hạn số trang và dung lượng | Tuỳ provider | Bạn tự kiểm soát |
| Quyền riêng tư | Cả file rời khỏi máy | Có thể cho xem và cắt bớt text trước khi gửi |
Mặc định của mình là upload PDF và để server quyết định, vì server biết giới hạn hiện tại của provider. Trích text phía client đáng làm khi người dùng phải duyệt chính xác thứ được gửi đi. Dù chọn cách nào, hãy kiểm tra số trang sớm và báo trước cho người dùng thay vì để họ chờ một lần upload chắc chắn bị từ chối.
Ghi âm bằng MediaRecorder#
Với voice input, getUserMedia cộng MediaRecorder là đủ, nhưng không có container nào mọi trình duyệt đều ghi được. Chromium ghi WebM với Opus; Safari trước đây chỉ ghi MP4/AAC và mới thêm khả năng ghi WebM tương đối gần đây; Firefox ghi WebM/Opus hoặc Ogg. Đừng hardcode loại file: hãy hỏi trình duyệt.
const preferredAudioTypes = ["audio/webm;codecs=opus", "audio/mp4", "audio/ogg;codecs=opus"];
export function pickAudioMimeType(): string | undefined {
return preferredAudioTypes.find((type) => MediaRecorder.isTypeSupported(type));
}
export async function recordAudio(signal: AbortSignal): Promise<Blob> {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const mimeType = pickAudioMimeType();
const recorder = new MediaRecorder(stream, mimeType ? { mimeType } : undefined);
const chunks: Blob[] = [];
return new Promise<Blob>((resolve, reject) => {
recorder.addEventListener("dataavailable", (event) => {
if (event.data.size > 0) chunks.push(event.data);
});
recorder.addEventListener("stop", () => {
stream.getTracks().forEach((track) => track.stop());
resolve(new Blob(chunks, { type: recorder.mimeType }));
});
recorder.addEventListener("error", () => reject(new Error("Recording failed")));
signal.addEventListener("abort", () => recorder.stop(), { once: true });
recorder.start(1000);
});
}
Gửi kèm recorder.mimeType khi upload, không phải loại bạn đã yêu cầu, vì trình duyệt có thể ghi cụ thể hơn. Nếu bước speech-to-text chỉ nhận một định dạng, hãy transcode ở server. Luôn stop các track, không thì đèn báo ghi âm vẫn sáng và người dùng có lý do để nghĩ bạn vẫn đang nghe lén. Và giới hạn thời lượng trên UI.
Upload: presigned URL, progress và huỷ#
Có hai kiểu. Đi qua server của bạn: trình duyệt POST lên route, route đẩy tiếp sang storage. Đơn giản, nhưng body lớn đụng giới hạn request của serverless. Thẳng lên storage: trình duyệt xin API của bạn một presigned URL sống ngắn, gắn với đúng một object key, upload thẳng lên bucket, rồi báo lại API là "xong". Nếu được, hãy ưu tiên presigned POST policy (hoặc cơ chế tương đương của storage bạn dùng), vì nó cho storage tự áp giới hạn dung lượng và content type; một presigned PUT thông thường nói chung không tự giới hạn được kích thước.
fetch trên trình duyệt đến giờ vẫn không có event progress cho upload, nên để có thanh tiến trình mình dùng XMLHttpRequest, bọc lại để nhận AbortSignal:
export function uploadWithProgress(
url: string,
body: Blob,
contentType: string,
onProgress: (fraction: number) => void,
signal: AbortSignal,
): Promise<void> {
return new Promise((resolve, reject) => {
const request = new XMLHttpRequest();
request.open("PUT", url);
request.setRequestHeader("Content-Type", contentType);
request.upload.addEventListener("progress", (event) => {
if (event.lengthComputable) onProgress(event.loaded / event.total);
});
request.addEventListener("load", () =>
request.status >= 200 && request.status < 300 ? resolve() : reject(new Error(`Upload failed: ${request.status}`)),
);
request.addEventListener("error", () => reject(new Error("Network error")));
request.addEventListener("abort", () => reject(new DOMException("Upload aborted", "AbortError")));
signal.addEventListener("abort", () => request.abort(), { once: true });
request.send(body);
});
}
Bắt đầu upload ngay khi file được thêm vào, đừng đợi người dùng bấm Gửi. Xoá một attachment sẽ abort controller của nó, unmount composer thì abort tất cả. Đặt lifecycle rule cho prefix upload để tự xoá object chưa từng được xác nhận.
Preview, object URL và accessibility#
URL.createObjectURL cho bạn preview tức thì mà không phải đọc cả file vào bộ nhớ dưới dạng data URL, nhưng mỗi URL giữ blob của nó cho tới khi bạn gọi URL.revokeObjectURL hoặc document bị unload. Mình tạo URL khi attachment vào uploading và revoke khi REMOVED, cùng với mọi attachment còn lại khi composer unmount. Hãy preview blob đã thu nhỏ: đó là thứ model thật sự thấy.
Về accessibility:
- Vùng kéo thả là phần bổ sung, không bao giờ là đường duy nhất. Nút hiển thị trên màn hình kích hoạt file input thật và dùng được bằng bàn phím.
- Mỗi chip attachment có tên dạng text, một nút xoá với accessible name như "Xoá invoice.pdf", và progress hiển thị bằng chữ ("Đang tải lên, 40%") chứ không chỉ là một thanh.
- Thông báo file bị từ chối qua một live region kiểu polite: "Không đọc được photo.heic. Hãy thử JPEG hoặc PNG."
- Nút ghi âm phản ánh trạng thái bằng
aria-pressedvà một đồng hồ đếm hiện rõ, và composer phải cho thấy rõ lúc nào micro đang bật.
Những gì server vẫn phải kiểm tra#
Trình duyệt làm phần việc nặng và hướng tới người dùng; server mới là nơi ra quyết định. Trước khi một fileRef tới được model, server phải xác minh object đã upload thật sự tồn tại và thuộc về đúng người dùng, đúng cuộc hội thoại, sniff lại byte đã lưu và kiểm tra dung lượng, áp giới hạn số trang và thời lượng, và xoá metadata thêm lần nữa phòng khi client bị bỏ qua. Server cũng phải coi nội dung file là input không tin cậy với model: chữ trong ảnh hay PDF có thể mang theo chỉ thị y như một trang web, đúng kiểu indirect prompt injection trong bài bảo mật ứng dụng LLM. Cuối cùng, đặt lịch xoá file và nói rõ điều đó trên UI.
Tóm tắt#
- Hỗ trợ file picker, kéo thả và paste, cả ba đổ về một hàm
addFiles. - Mô hình hoá mỗi attachment bằng discriminated union; message gửi tham chiếu file do server cấp, không gửi byte.
- Sniff magic bytes thay vì tin
File.type, và gom giới hạn dung lượng, số trang vào một config. - Thu nhỏ ảnh trước khi upload; encode lại qua canvas xoá EXIF và GPS mà không tốn thêm gì.
- Kiểm tra
blob.typesau khi encode, xử lý nền trong suốt, và chuyển việc decode nặng vào worker. - Phát hiện HEIC từ đầu; decode ở trình duyệt nào làm được, convert ở chỗ khác, không bao giờ hiện preview vỡ.
- Chọn loại file cho MediaRecorder bằng
isTypeSupported, gửi kèmrecorder.mimeTypethật, và luôn stop track. - Upload thẳng lên storage bằng presigned URL, dùng XHR để có progress, abort khi xoá, cho object mồ côi tự hết hạn.
- Revoke object URL, luôn có đường dùng bàn phím, và thông báo progress lẫn lỗi bằng chữ.
- Lặp lại mọi kiểm tra ở server và coi nội dung file là input không tin cậy với model.