diff --git a/components/multimodal-input.tsx b/components/multimodal-input.tsx index 145daf751..7393d6c5d 100644 --- a/components/multimodal-input.tsx +++ b/components/multimodal-input.tsx @@ -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 | 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(null); const videoSlotInputRefs = useRef>([]); - const [uploadQueue, setUploadQueue] = useState([]); + const [uploadQueue, setUploadQueue] = useState([]); const [isDragging, setIsDragging] = useState(false); const [draggedIndex, setDraggedIndex] = useState(null); const [videoSlotUploading, setVideoSlotUploading] = useState(null); + const [videoSlotUploadItemIds, setVideoSlotUploadItemIds] = useState>({}); + + const updateUploadQueueItem = useCallback( + ( + id: string | undefined, + patch: Partial> + ) => { + 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 => { + const uploadFile = useCallback(async ( + file: File, + uploadQueueItemId?: string + ): Promise => { 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 ( ); @@ -1284,15 +1408,17 @@ function PureMultimodalInput({ /> ))} - {uploadQueue.map((filename) => ( + {uploadQueue.map((item) => ( ))} diff --git a/components/preview-attachment.tsx b/components/preview-attachment.tsx index 482748ef6..6debbc9ec 100644 --- a/components/preview-attachment.tsx +++ b/components/preview-attachment.tsx @@ -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") ? ( {name + + {normalizedUploadProgress === null + ? uploadStatusLabel + : `${normalizedUploadProgress}%`} + +
+
+
)} diff --git a/lib/client/upload-with-retry.ts b/lib/client/upload-with-retry.ts index d64b6ac2c..f221ba214 100644 --- a/lib/client/upload-with-retry.ts +++ b/lib/client/upload-with-retry.ts @@ -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( 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((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; maxRetries: number; signal?: AbortSignal; + onProgress?: UploadProgressHandler; }) { const uploadedParts: Array<{ ETag: string; PartNumber: number }> = []; + const partLoadedBytes = new Map(); 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 { 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( { 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>( { 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>( { @@ -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; }); }