show image upload progress

This commit is contained in:
134 2026-07-08 19:26:45 +08:00
parent 607491fc9e
commit 91ce0ff5f8
3 changed files with 454 additions and 78 deletions

View File

@ -69,6 +69,40 @@ type PointsSummaryResponse = {
const fetcher = (url: string) => fetch(url).then((res) => res.json());
type UploadQueueStatus = "preparing" | "uploading" | "finalizing";
type UploadQueueItem = {
id: string;
name: string;
progress: number;
status: UploadQueueStatus;
};
function createUploadQueueId(file: File) {
return [
file.name || "image",
file.size,
file.lastModified,
Date.now(),
Math.random().toString(36).slice(2, 8),
].join("-");
}
function createUploadQueueItem(file: File, fallbackName?: string): UploadQueueItem {
return {
id: createUploadQueueId(file),
name: file.name || fallbackName || "图片",
progress: 0,
status: "preparing",
};
}
function getUploadQueueStatus(phase: UploadProgressPhase): UploadQueueStatus {
if (phase === "preparing") return "preparing";
if (phase === "finalizing" || phase === "complete") return "finalizing";
return "uploading";
}
function isVideoProviderType(model?: Partial<DynamicModel> | null) {
return isVideoModelDescriptor({
providerType: model?.providerType,
@ -117,7 +151,10 @@ import { toast } from "sonner";
import useSWR from "swr";
import { useLocalStorage, useWindowSize } from "usehooks-ts";
import { saveChatModelAsCookie } from "@/app/(chat)/actions";
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
import {
fetchUploadWithRetry,
type UploadProgressPhase,
} from "@/lib/client/upload-with-retry";
import { SelectItem } from "@/components/ui/select";
import {
AlertDialog,
@ -446,10 +483,41 @@ function PureMultimodalInput({
const fileInputRef = useRef<HTMLInputElement>(null);
const videoSlotInputRefs = useRef<Array<HTMLInputElement | null>>([]);
const [uploadQueue, setUploadQueue] = useState<string[]>([]);
const [uploadQueue, setUploadQueue] = useState<UploadQueueItem[]>([]);
const [isDragging, setIsDragging] = useState(false);
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
const [videoSlotUploading, setVideoSlotUploading] = useState<number | null>(null);
const [videoSlotUploadItemIds, setVideoSlotUploadItemIds] = useState<Record<number, string>>({});
const updateUploadQueueItem = useCallback(
(
id: string | undefined,
patch: Partial<Pick<UploadQueueItem, "progress" | "status">>
) => {
if (!id) return;
setUploadQueue((current) =>
current.map((item) =>
item.id === id
? {
...item,
...patch,
progress:
typeof patch.progress === "number"
? Math.max(0, Math.min(100, Math.round(patch.progress)))
: item.progress,
}
: item
)
);
},
[]
);
const removeUploadQueueItems = useCallback((ids: string[]) => {
if (ids.length === 0) return;
const idSet = new Set(ids);
setUploadQueue((current) => current.filter((item) => !idSet.has(item.id)));
}, []);
const ensureImagePointsBeforeSend = useCallback(async () => {
if (!isImageModel || isVideoModel) return true;
@ -678,18 +746,40 @@ function PureMultimodalInput({
});
}, []);
const uploadFile = useCallback(async (file: File): Promise<Attachment | null> => {
const uploadFile = useCallback(async (
file: File,
uploadQueueItemId?: string
): Promise<Attachment | null> => {
try {
// 先压缩图片
console.log(`[上传] 开始处理文件: ${file.name}, 大小: ${(file.size / 1024 / 1024).toFixed(2)}MB`);
updateUploadQueueItem(uploadQueueItemId, {
progress: 2,
status: "preparing",
});
const processedFile = await compressImage(file);
updateUploadQueueItem(uploadQueueItemId, {
progress: 6,
status: "uploading",
});
const formData = new FormData();
formData.append("file", processedFile);
const response = await fetchUploadWithRetry(formData);
const response = await fetchUploadWithRetry(formData, {
onProgress: (progress) => {
updateUploadQueueItem(uploadQueueItemId, {
progress: progress.percent,
status: getUploadQueueStatus(progress.phase),
});
},
});
if (response.ok) {
updateUploadQueueItem(uploadQueueItemId, {
progress: 100,
status: "finalizing",
});
const data = await response.json();
const { url, pathname, contentType } = data;
@ -712,7 +802,7 @@ function PureMultimodalInput({
toast.error(_error instanceof Error ? _error.message : "文件上传失败,请稍后重试");
return null;
}
}, [compressImage]);
}, [compressImage, updateUploadQueueItem]);
const contextProps = useMemo(
() => ({
@ -759,20 +849,30 @@ function PureMultimodalInput({
event.currentTarget.value = "";
return;
}
const queueItem = createUploadQueueItem(file);
setVideoSlotUploading(slotIndex);
setUploadQueue((prev) => [...prev, file.name]);
setVideoSlotUploadItemIds((current) => ({
...current,
[slotIndex]: queueItem.id,
}));
setUploadQueue((prev) => [...prev, queueItem]);
try {
const uploaded = await uploadFile(file);
const uploaded = await uploadFile(file, queueItem.id);
if (uploaded) {
updateAttachmentAtSlot(slotIndex, uploaded as Attachment);
}
} finally {
setVideoSlotUploading(null);
setUploadQueue((prev) => prev.filter((name) => name !== file.name));
setVideoSlotUploadItemIds((current) => {
const next = { ...current };
delete next[slotIndex];
return next;
});
removeUploadQueueItems([queueItem.id]);
event.currentTarget.value = "";
}
},
[updateAttachmentAtSlot, uploadFile]
[removeUploadQueueItems, updateAttachmentAtSlot, uploadFile]
);
// 🆕 视频模式下的图片数量限制
@ -879,11 +979,14 @@ function PureMultimodalInput({
return;
}
const filesToUpload = files.slice(0, allowedCount);
const queueItems = filesToUpload.map((file) => createUploadQueueItem(file));
setUploadQueue(filesToUpload.map((file) => file.name));
setUploadQueue((prev) => [...prev, ...queueItems]);
try {
const uploadPromises = filesToUpload.map((file) => uploadFile(file));
const uploadPromises = filesToUpload.map((file, index) =>
uploadFile(file, queueItems[index]?.id)
);
const uploadedAttachments = await Promise.all(uploadPromises);
const successfullyUploadedAttachments = uploadedAttachments.filter(
(attachment): attachment is Attachment => attachment !== null
@ -896,13 +999,13 @@ function PureMultimodalInput({
} catch (error) {
console.error("Error uploading files!", error);
} finally {
setUploadQueue([]);
removeUploadQueueItems(queueItems.map((item) => item.id));
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
}
},
[enforceVideoImageLimit, setAttachments, uploadFile]
[enforceVideoImageLimit, removeUploadQueueItems, setAttachments, uploadFile]
);
const handlePaste = useCallback(
@ -923,19 +1026,24 @@ function PureMultimodalInput({
// Prevent default paste behavior for images
event.preventDefault();
setUploadQueue((prev) => [...prev, "已粘贴图片"]);
let queueItems: UploadQueueItem[] = [];
try {
const allowedCount = enforceVideoImageLimit(imageItems.length);
if (allowedCount <= 0) {
setUploadQueue([]);
return;
}
const uploadPromises = imageItems
const filesToUpload = imageItems
.map((item) => item.getAsFile())
.filter((file): file is File => file !== null)
.slice(0, allowedCount)
.map((file) => uploadFile(file));
.slice(0, allowedCount);
queueItems = filesToUpload.map((file, index) =>
createUploadQueueItem(file, `已粘贴图片 ${index + 1}`)
);
setUploadQueue((prev) => [...prev, ...queueItems]);
const uploadPromises = filesToUpload.map((file, index) =>
uploadFile(file, queueItems[index]?.id)
);
const uploadedAttachments = await Promise.all(uploadPromises);
const successfullyUploadedAttachments = uploadedAttachments.filter(
@ -953,10 +1061,10 @@ function PureMultimodalInput({
console.error("Error uploading pasted images:", error);
toast.error("粘贴的图片上传失败");
} finally {
setUploadQueue([]);
removeUploadQueueItems(queueItems.map((item) => item.id));
}
},
[enforceVideoImageLimit, setAttachments, uploadFile]
[enforceVideoImageLimit, removeUploadQueueItems, setAttachments, uploadFile]
);
// Add paste event listener to textarea
@ -1022,20 +1130,20 @@ function PureMultimodalInput({
const allowedCount = enforceVideoImageLimit(imageFiles.length);
if (allowedCount <= 0) {
setUploadQueue([]);
return;
}
const filesToUpload = imageFiles.slice(0, allowedCount);
const queueItems = filesToUpload.map((file) => createUploadQueueItem(file));
console.log("[拖拽] 开始处理文件:", imageFiles.length, "个");
// 添加到上传队列
setUploadQueue((prev) => [...prev, ...filesToUpload.map((f) => f.name)]);
setUploadQueue((prev) => [...prev, ...queueItems]);
try {
const uploadedAttachments = await Promise.all(
filesToUpload.map((file) => uploadFile(file))
filesToUpload.map((file, index) => uploadFile(file, queueItems[index]?.id))
);
const validAttachments = uploadedAttachments.filter(
@ -1050,10 +1158,10 @@ function PureMultimodalInput({
console.error("拖拽上传失败:", error);
toast.error("拖拽的图片上传失败");
} finally {
setUploadQueue([]);
removeUploadQueueItems(queueItems.map((item) => item.id));
}
},
[uploadFile]
[enforceVideoImageLimit, removeUploadQueueItems, setAttachments, uploadFile]
);
// 🆕 拖拽排序处理函数
@ -1161,6 +1269,10 @@ function PureMultimodalInput({
const slotAttachment = attachments[index];
const disabled = index > 0 && !attachments[index - 1];
const isUploading = videoSlotUploading === index;
const uploadItem = isUploading
? uploadQueue.find((item) => item.id === videoSlotUploadItemIds[index])
: null;
const uploadProgress = uploadItem?.progress ?? 0;
return (
<button
key={`video-slot-${label}-${index}`}
@ -1196,10 +1308,22 @@ function PureMultimodalInput({
</div>
</>
) : (
<div className="flex flex-col items-center gap-1">
<span className="text-xl leading-none">+</span>
<span className="text-sm">{isUploading ? "上传中" : label}</span>
</div>
<>
<div className="flex flex-col items-center gap-1">
<span className="text-xl leading-none">+</span>
<span className="text-sm">
{isUploading ? `${uploadProgress}%` : label}
</span>
</div>
{isUploading && (
<div className="absolute inset-x-2 bottom-2 h-1 overflow-hidden rounded-full bg-black/15">
<div
className="h-full rounded-full bg-primary transition-[width] duration-200"
style={{ width: `${uploadProgress}%` }}
/>
</div>
)}
</>
)}
</button>
);
@ -1284,15 +1408,17 @@ function PureMultimodalInput({
/>
))}
{uploadQueue.map((filename) => (
{uploadQueue.map((item) => (
<PreviewAttachment
attachment={{
url: "",
name: filename,
name: item.name,
contentType: "",
}}
isUploading={true}
key={filename}
key={item.id}
uploadProgress={item.progress}
uploadStatus={item.status}
/>
))}
</div>

View File

@ -22,9 +22,13 @@ export const PreviewAttachment = ({
onAnnotate,
onDrawingComplete,
imageIndex,
uploadProgress,
uploadStatus,
}: {
attachment: Attachment;
isUploading?: boolean;
uploadProgress?: number;
uploadStatus?: "preparing" | "uploading" | "finalizing";
onRemove?: () => void;
onDragStart?: (e: React.DragEvent) => void;
onDragEnd?: (e: React.DragEvent) => void;
@ -40,6 +44,16 @@ export const PreviewAttachment = ({
imageIndex?: number;
}) => {
const { name, url, contentType } = attachment;
const normalizedUploadProgress =
typeof uploadProgress === "number" && Number.isFinite(uploadProgress)
? Math.max(0, Math.min(100, Math.round(uploadProgress)))
: null;
const uploadStatusLabel =
uploadStatus === "preparing"
? "处理中"
: uploadStatus === "finalizing"
? "保存中"
: "上传中";
const [isOpen, setIsOpen] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const [isAnnotationMode, setIsAnnotationMode] = useState(false);
@ -164,7 +178,7 @@ export const PreviewAttachment = ({
onDragOver={onDragOver}
onDrop={onDrop}
>
{contentType?.startsWith("image") ? (
{!isUploading && contentType?.startsWith("image") ? (
<Image
alt={name ?? "图片附件"}
className="size-full object-cover"
@ -180,10 +194,21 @@ export const PreviewAttachment = ({
{isUploading && (
<div
className="absolute inset-0 flex items-center justify-center bg-black/50"
className="absolute inset-0 flex flex-col items-center justify-center gap-1.5 bg-black/60 px-2 text-white"
data-testid="input-attachment-loader"
>
<Loader size={16} />
<span className="text-[10px] font-medium leading-none">
{normalizedUploadProgress === null
? uploadStatusLabel
: `${normalizedUploadProgress}%`}
</span>
<div className="h-1 w-full overflow-hidden rounded-full bg-white/25">
<div
className="h-full rounded-full bg-white transition-[width] duration-200"
style={{ width: `${normalizedUploadProgress ?? 18}%` }}
/>
</div>
</div>
)}

View File

@ -36,6 +36,23 @@ let activeUploadCount = 0;
const uploadQueue: Array<() => void> = [];
let directUploadSuppressedUntil = 0;
export type UploadProgressPhase =
| "preparing"
| "direct"
| "multipart"
| "server"
| "finalizing"
| "complete";
export type UploadProgress = {
loaded: number;
total: number;
percent: number;
phase: UploadProgressPhase;
};
type UploadProgressHandler = (progress: UploadProgress) => void;
type UploadFormFile = {
blob: Blob;
filename: string;
@ -146,6 +163,38 @@ function isAbortError(error: unknown, signal?: AbortSignal) {
);
}
function clampPercent(value: number, max = 99) {
if (!Number.isFinite(value)) return 0;
return Math.max(0, Math.min(max, Math.round(value)));
}
function emitUploadProgress(
onProgress: UploadProgressHandler | undefined,
loaded: number,
total: number,
phase: UploadProgressPhase,
options?: { percent?: number; maxPercent?: number }
) {
if (!onProgress) return;
const safeLoaded = Math.max(0, Math.round(loaded || 0));
const safeTotal = Math.max(0, Math.round(total || 0));
const computedPercent =
typeof options?.percent === "number"
? options.percent
: safeTotal > 0
? (safeLoaded / safeTotal) * 100
: 0;
const maxPercent = options?.maxPercent ?? (phase === "complete" ? 100 : 99);
onProgress({
loaded: safeLoaded,
total: safeTotal,
percent: clampPercent(computedPercent, maxPercent),
phase,
});
}
function cloneFormData(formData: FormData) {
const copy = new FormData();
@ -283,18 +332,122 @@ async function postDirectUploadAction<T>(
return (await response.json()) as T;
}
function createResponseFromXhr(xhr: XMLHttpRequest) {
const headers = new Headers();
const rawHeaders = xhr.getAllResponseHeaders();
for (const line of rawHeaders.trim().split(/[\r\n]+/)) {
if (!line) continue;
const separatorIndex = line.indexOf(":");
if (separatorIndex <= 0) continue;
headers.append(
line.slice(0, separatorIndex).trim(),
line.slice(separatorIndex + 1).trim()
);
}
return new Response(xhr.responseText || null, {
status: xhr.status,
statusText: xhr.statusText,
headers,
});
}
function normalizeXhrHeaders(headers?: HeadersInit) {
if (!headers) return [] as Array<[string, string]>;
if (headers instanceof Headers) {
return Array.from(headers.entries());
}
if (Array.isArray(headers)) {
return headers.map(([key, value]) => [key, String(value)] as [string, string]);
}
return Object.entries(headers).map(
([key, value]) => [key, String(value)] as [string, string]
);
}
function xhrUpload(
url: string,
init: {
method: string;
headers?: HeadersInit;
body: XMLHttpRequestBodyInit;
},
options?: {
signal?: AbortSignal;
onProgress?: (loaded: number, total: number) => void;
}
) {
return new Promise<Response>((resolve, reject) => {
if (options?.signal?.aborted) {
reject(createAbortError());
return;
}
const xhr = new XMLHttpRequest();
let settled = false;
const settle = (callback: () => void) => {
if (settled) return;
settled = true;
options?.signal?.removeEventListener("abort", onAbort);
callback();
};
const onAbort = () => {
xhr.abort();
settle(() => reject(createAbortError()));
};
xhr.open(init.method, url, true);
for (const [key, value] of normalizeXhrHeaders(init.headers)) {
xhr.setRequestHeader(key, value);
}
xhr.upload.onprogress = (event) => {
const total = event.lengthComputable ? event.total : 0;
options?.onProgress?.(event.loaded, total);
};
xhr.onload = () => {
settle(() => resolve(createResponseFromXhr(xhr)));
};
xhr.onerror = () => {
settle(() => reject(new TypeError("Network request failed")));
};
xhr.ontimeout = () => {
settle(() => reject(new Error("Upload request timed out")));
};
xhr.onabort = () => {
settle(() => reject(createAbortError()));
};
options?.signal?.addEventListener("abort", onAbort, { once: true });
xhr.send(init.body);
});
}
async function fetchDirectPartWithRetry(
url: string,
init: RequestInit,
init: {
method: string;
headers?: HeadersInit;
body: XMLHttpRequestBodyInit;
},
maxRetries: number,
signal?: AbortSignal
signal?: AbortSignal,
onProgress?: (loaded: number, total: number) => void
) {
for (let attempt = 0; ; attempt += 1) {
try {
const response = await fetch(url, {
...init,
signal,
});
const response = await xhrUpload(
url,
init,
{
onProgress,
signal,
}
);
if (response.ok || !isRetryableUploadStatus(response.status) || attempt >= maxRetries) {
return response;
@ -311,15 +464,79 @@ async function fetchDirectPartWithRetry(
}
}
async function fetchServerUploadWithRetry(
formData: FormData,
maxRetries: number,
signal?: AbortSignal,
onProgress?: UploadProgressHandler
) {
for (let attempt = 0; ; attempt++) {
try {
const response = await xhrUpload(
"/api/files/upload",
{
method: "POST",
body: cloneFormData(formData),
},
{
signal,
onProgress: (loaded, total) => {
emitUploadProgress(onProgress, loaded, total, "server");
},
}
);
if (response.ok || !isRetryableUploadStatus(response.status) || attempt >= maxRetries) {
return response;
}
let retryAfterMs = parseRetryAfterMs(response.headers.get("Retry-After"));
if (!retryAfterMs) {
try {
const payload = await response.clone().json();
const retryAfterSec = Number(payload?.retryAfter || 0);
if (Number.isFinite(retryAfterSec) && retryAfterSec > 0) {
retryAfterMs = Math.round(retryAfterSec * 1000);
}
} catch {}
}
await sleep(computeBackoffMs(attempt, retryAfterMs), signal);
} catch (error) {
if (isAbortError(error, signal) || attempt >= maxRetries) {
throw error;
}
await sleep(computeBackoffMs(attempt, null), signal);
}
}
}
async function uploadMultipartParts(params: {
file: UploadFormFile;
upload: Extract<DirectUploadInitiateResponse, { mode: "multipart" }>;
maxRetries: number;
signal?: AbortSignal;
onProgress?: UploadProgressHandler;
}) {
const uploadedParts: Array<{ ETag: string; PartNumber: number }> = [];
const partLoadedBytes = new Map<number, number>();
let nextIndex = 0;
const reportPartProgress = (partNumber: number, loaded: number, total: number) => {
partLoadedBytes.set(partNumber, Math.min(loaded, total || loaded));
const uploadedBytes = Array.from(partLoadedBytes.values()).reduce(
(sum, value) => sum + value,
0
);
emitUploadProgress(
params.onProgress,
uploadedBytes,
params.file.blob.size,
"multipart"
);
};
async function worker() {
for (;;) {
const part = params.upload.parts[nextIndex];
@ -336,13 +553,18 @@ async function uploadMultipartParts(params: {
body,
},
params.maxRetries,
params.signal
params.signal,
(loaded, total) => {
reportPartProgress(part.partNumber, loaded, total || body.size);
}
);
if (!response.ok) {
throw new Error(`Direct multipart upload failed: ${response.status}`);
}
reportPartProgress(part.partNumber, body.size, body.size);
const etag = response.headers.get("ETag") || response.headers.get("etag");
if (!etag) {
throw new Error("Storage did not expose ETag; please allow ETag in bucket CORS");
@ -368,7 +590,8 @@ async function uploadMultipartParts(params: {
async function fetchDirectUpload(
formData: FormData,
maxRetries: number,
signal?: AbortSignal
signal?: AbortSignal,
onProgress?: UploadProgressHandler
): Promise<Response | null> {
const file = getUploadFormFile(formData);
if (!file || !shouldTryDirectUpload(file)) {
@ -380,6 +603,9 @@ async function fetchDirectUpload(
| null = null;
try {
emitUploadProgress(onProgress, 0, file.blob.size, "preparing", {
percent: 1,
});
const upload = await postDirectUploadAction<DirectUploadInitiateResponse>(
{
action: "initiate",
@ -403,13 +629,19 @@ async function fetchDirectUpload(
body: file.blob,
},
maxRetries,
signal
signal,
(loaded, total) => {
emitUploadProgress(onProgress, loaded, total || file.blob.size, "direct");
}
);
if (!response.ok) {
throw new Error(`Direct upload failed: ${response.status}`);
}
emitUploadProgress(onProgress, file.blob.size, file.blob.size, "finalizing", {
percent: 99,
});
const payload = await postDirectUploadAction<Record<string, unknown>>(
{
action: "complete",
@ -423,6 +655,9 @@ async function fetchDirectUpload(
signal
);
emitUploadProgress(onProgress, file.blob.size, file.blob.size, "complete", {
percent: 100,
});
return Response.json(payload);
}
@ -432,6 +667,10 @@ async function fetchDirectUpload(
upload,
maxRetries,
signal,
onProgress,
});
emitUploadProgress(onProgress, file.blob.size, file.blob.size, "finalizing", {
percent: 99,
});
const payload = await postDirectUploadAction<Record<string, unknown>>(
{
@ -448,6 +687,9 @@ async function fetchDirectUpload(
signal
);
emitUploadProgress(onProgress, file.blob.size, file.blob.size, "complete", {
percent: 100,
});
return Response.json(payload);
} catch (error) {
if (multipartUpload && !isAbortError(error, signal)) {
@ -489,6 +731,7 @@ export async function fetchUploadWithRetry(
options?: {
maxRetries?: number;
signal?: AbortSignal;
onProgress?: UploadProgressHandler;
}
) {
const maxRetries = options?.maxRetries ?? DEFAULT_MAX_RETRIES;
@ -497,45 +740,27 @@ export async function fetchUploadWithRetry(
const directResponse = await fetchDirectUpload(
formData,
maxRetries,
options?.signal
options?.signal,
options?.onProgress
);
if (directResponse) {
return directResponse;
}
for (let attempt = 0; ; attempt++) {
try {
const response = await fetch("/api/files/upload", {
method: "POST",
body: cloneFormData(formData),
signal: options?.signal,
});
if (!isRetryableUploadStatus(response.status) || attempt >= maxRetries) {
return response;
}
let retryAfterMs = parseRetryAfterMs(response.headers.get("Retry-After"));
if (!retryAfterMs) {
try {
const payload = await response.clone().json();
const retryAfterSec = Number(payload?.retryAfter || 0);
if (Number.isFinite(retryAfterSec) && retryAfterSec > 0) {
retryAfterMs = Math.round(retryAfterSec * 1000);
}
} catch {}
}
const delayMs = computeBackoffMs(attempt, retryAfterMs);
await sleep(delayMs, options?.signal);
} catch (error) {
if (isAbortError(error, options?.signal) || attempt >= maxRetries) {
throw error;
}
const delayMs = computeBackoffMs(attempt, null);
await sleep(delayMs, options?.signal);
}
const response = await fetchServerUploadWithRetry(
formData,
maxRetries,
options?.signal,
options?.onProgress
);
if (response.ok) {
options?.onProgress?.({
loaded: 1,
total: 1,
percent: 100,
phase: "complete",
});
}
return response;
});
}