From 66cdf6d3d1a9cedfae418fda55c9ea3c6abf7b4e Mon Sep 17 00:00:00 2001 From: 134 Date: Wed, 8 Jul 2026 19:39:10 +0800 Subject: [PATCH] fix safari direct upload feedback --- components/multimodal-input.tsx | 82 +++++++++++++++++++++++++++++---- lib/client/upload-with-retry.ts | 18 ++++++-- 2 files changed, 89 insertions(+), 11 deletions(-) diff --git a/components/multimodal-input.tsx b/components/multimodal-input.tsx index 7393d6c5d..2f996561d 100644 --- a/components/multimodal-input.tsx +++ b/components/multimodal-input.tsx @@ -78,6 +78,8 @@ type UploadQueueItem = { status: UploadQueueStatus; }; +const UPLOAD_PROGRESS_HOLD_MS = 700; + function createUploadQueueId(file: File) { return [ file.name || "image", @@ -103,6 +105,12 @@ function getUploadQueueStatus(phase: UploadProgressPhase): UploadQueueStatus { return "uploading"; } +function getUploadQueueStatusText(status: UploadQueueStatus) { + if (status === "preparing") return "正在处理图片"; + if (status === "finalizing") return "正在保存图片"; + return "正在上传图片"; +} + function isVideoProviderType(model?: Partial | null) { return isVideoModelDescriptor({ providerType: model?.providerType, @@ -519,6 +527,36 @@ function PureMultimodalInput({ setUploadQueue((current) => current.filter((item) => !idSet.has(item.id))); }, []); + const holdAndRemoveUploadQueueItems = useCallback( + async (ids: string[]) => { + if (ids.length === 0) return; + await new Promise((resolve) => { + globalThis.setTimeout(resolve, UPLOAD_PROGRESS_HOLD_MS); + }); + removeUploadQueueItems(ids); + }, + [removeUploadQueueItems] + ); + + const uploadProgressSummary = useMemo(() => { + if (uploadQueue.length === 0) return null; + + const totalProgress = Math.round( + uploadQueue.reduce((sum, item) => sum + item.progress, 0) / + uploadQueue.length + ); + const activeItem = + uploadQueue.find((item) => item.progress < 100) || + uploadQueue[uploadQueue.length - 1]; + + return { + count: uploadQueue.length, + fileName: activeItem?.name || "图片", + percent: Math.max(0, Math.min(100, totalProgress)), + statusText: getUploadQueueStatusText(activeItem?.status || "uploading"), + }; + }, [uploadQueue]); + const ensureImagePointsBeforeSend = useCallback(async () => { if (!isImageModel || isVideoModel) return true; if (membershipSummary?.isHighVip) return true; @@ -868,11 +906,11 @@ function PureMultimodalInput({ delete next[slotIndex]; return next; }); - removeUploadQueueItems([queueItem.id]); + await holdAndRemoveUploadQueueItems([queueItem.id]); event.currentTarget.value = ""; } }, - [removeUploadQueueItems, updateAttachmentAtSlot, uploadFile] + [holdAndRemoveUploadQueueItems, updateAttachmentAtSlot, uploadFile] ); // 🆕 视频模式下的图片数量限制 @@ -999,13 +1037,13 @@ function PureMultimodalInput({ } catch (error) { console.error("Error uploading files!", error); } finally { - removeUploadQueueItems(queueItems.map((item) => item.id)); + await holdAndRemoveUploadQueueItems(queueItems.map((item) => item.id)); if (fileInputRef.current) { fileInputRef.current.value = ""; } } }, - [enforceVideoImageLimit, removeUploadQueueItems, setAttachments, uploadFile] + [enforceVideoImageLimit, holdAndRemoveUploadQueueItems, setAttachments, uploadFile] ); const handlePaste = useCallback( @@ -1061,10 +1099,10 @@ function PureMultimodalInput({ console.error("Error uploading pasted images:", error); toast.error("粘贴的图片上传失败"); } finally { - removeUploadQueueItems(queueItems.map((item) => item.id)); + await holdAndRemoveUploadQueueItems(queueItems.map((item) => item.id)); } }, - [enforceVideoImageLimit, removeUploadQueueItems, setAttachments, uploadFile] + [enforceVideoImageLimit, holdAndRemoveUploadQueueItems, setAttachments, uploadFile] ); // Add paste event listener to textarea @@ -1158,10 +1196,10 @@ function PureMultimodalInput({ console.error("拖拽上传失败:", error); toast.error("拖拽的图片上传失败"); } finally { - removeUploadQueueItems(queueItems.map((item) => item.id)); + await holdAndRemoveUploadQueueItems(queueItems.map((item) => item.id)); } }, - [enforceVideoImageLimit, removeUploadQueueItems, setAttachments, uploadFile] + [enforceVideoImageLimit, holdAndRemoveUploadQueueItems, setAttachments, uploadFile] ); // 🆕 拖拽排序处理函数 @@ -1263,6 +1301,34 @@ function PureMultimodalInput({ } }} > + {uploadProgressSummary && ( +
+
+ + {uploadProgressSummary.count > 1 + ? `正在上传 ${uploadProgressSummary.count} 张图片` + : uploadProgressSummary.statusText} + + + {uploadProgressSummary.percent}% + +
+
+
+
+
+ {uploadProgressSummary.fileName} +
+
+ )} + {videoFrameSlots.length > 0 && (
{videoFrameSlots.map((label, index) => { diff --git a/lib/client/upload-with-retry.ts b/lib/client/upload-with-retry.ts index f221ba214..a6339a316 100644 --- a/lib/client/upload-with-retry.ts +++ b/lib/client/upload-with-retry.ts @@ -353,6 +353,18 @@ function createResponseFromXhr(xhr: XMLHttpRequest) { }); } +function createJsonResponse(payload: unknown, init?: ResponseInit) { + const headers = new Headers(init?.headers); + if (!headers.has("Content-Type")) { + headers.set("Content-Type", "application/json"); + } + + return new Response(JSON.stringify(payload), { + ...init, + headers, + }); +} + function normalizeXhrHeaders(headers?: HeadersInit) { if (!headers) return [] as Array<[string, string]>; if (headers instanceof Headers) { @@ -658,7 +670,7 @@ async function fetchDirectUpload( emitUploadProgress(onProgress, file.blob.size, file.blob.size, "complete", { percent: 100, }); - return Response.json(payload); + return createJsonResponse(payload); } multipartUpload = upload; @@ -690,7 +702,7 @@ async function fetchDirectUpload( emitUploadProgress(onProgress, file.blob.size, file.blob.size, "complete", { percent: 100, }); - return Response.json(payload); + return createJsonResponse(payload); } catch (error) { if (multipartUpload && !isAbortError(error, signal)) { postDirectUploadAction( @@ -706,7 +718,7 @@ async function fetchDirectUpload( if (!isAbortError(error, signal)) { if (file.blob.size > SERVER_UPLOAD_MAX_BYTES) { - return Response.json( + return createJsonResponse( { error: "图片超过 50MB,必须使用浏览器直传;当前直传失败,请刷新后重试,或联系管理员检查存储桶 CORS/ETag 配置。",