show image upload progress
This commit is contained in:
parent
607491fc9e
commit
91ce0ff5f8
@ -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>
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user