show canvas reference upload progress

This commit is contained in:
134 2026-07-08 20:04:46 +08:00
parent 66cdf6d3d1
commit 8acf1f60ec

View File

@ -58,7 +58,10 @@ import { toast } from "sonner";
import { useCanvasStore } from "@/store/canvas";
import { cn } from "@/lib/utils";
import { fetcher, fetchWithErrorHandlers, generateUUID } from "@/lib/utils";
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
import {
fetchUploadWithRetry,
type UploadProgress,
} from "@/lib/client/upload-with-retry";
import {
downloadCanvasImageAsPng,
type CanvasImageDownloadProgress,
@ -1186,8 +1189,78 @@ type CanvasComposerUploadProgress = {
current: number;
total: number;
fileName?: string;
filePercent?: number;
percent?: number;
phase?: UploadProgress["phase"];
target?: "composer-reference" | "canvas" | "slot";
};
type CanvasUploadProgressOptions = {
title: string;
fileName?: string;
fileIndex?: number;
fileTotal?: number;
target?: CanvasComposerUploadProgress["target"];
};
function getCanvasUploadProgressPhaseLabel(phase?: UploadProgress["phase"]) {
switch (phase) {
case "preparing":
return "正在准备上传";
case "direct":
return "正在上传到云储存";
case "multipart":
return "正在分片上传到云储存";
case "server":
return "正在通过服务器上传";
case "finalizing":
return "正在确认图片地址";
case "complete":
return "上传完成";
default:
return "正在上传图片";
}
}
function buildCanvasComposerUploadProgress(
options: CanvasUploadProgressOptions,
progress?: UploadProgress
): CanvasComposerUploadProgress {
const total = Math.max(1, Math.round(options.fileTotal || 1));
const fileIndex = Math.min(
total - 1,
Math.max(0, Math.round(options.fileIndex || 0))
);
const current = fileIndex + 1;
const filePercent = Math.max(
1,
Math.min(100, Math.round(progress?.percent || 1))
);
const percent =
total <= 1
? filePercent
: Math.max(
1,
Math.min(100, Math.round(((fileIndex + filePercent / 100) / total) * 100))
);
const phaseLabel = getCanvasUploadProgressPhaseLabel(progress?.phase);
return {
title: options.title,
message:
total > 1
? `${phaseLabel},第 ${current}/${total} 张,当前 ${filePercent}%`
: `${phaseLabel},当前 ${filePercent}%`,
current,
total,
fileName: options.fileName,
filePercent,
percent,
phase: progress?.phase,
target: options.target,
};
}
const OPENCLAW_ACTIONS_CODE_BLOCK_REGEX =
/```openclaw-actions\s*([\s\S]*?)```/gi;
@ -1632,13 +1705,17 @@ function CanvasComposerUploadProgressDialog({
if (!progress) return null;
const percent =
progress.total > 1
typeof progress.percent === "number"
? Math.max(1, Math.min(100, Math.round(progress.percent)))
: progress.total > 1
? Math.max(
8,
Math.min(100, Math.round((progress.current / progress.total) * 100))
)
: 100;
const isSingleFile = progress.total <= 1;
const isIndeterminate =
typeof progress.percent !== "number" && progress.total <= 1;
const phaseLabel = getCanvasUploadProgressPhaseLabel(progress.phase);
return createPortal(
<div className="fixed inset-0 z-[1000] flex items-center justify-center bg-black/35 px-4 backdrop-blur-[2px]">
@ -1663,12 +1740,19 @@ function CanvasComposerUploadProgressDialog({
<div
className={cn(
"h-full rounded-full bg-[#e0ff3a] transition-all duration-300",
isSingleFile && "animate-pulse"
isIndeterminate && "animate-pulse"
)}
style={{ width: `${percent}%` }}
/>
</div>
<div className="mt-2 flex items-center justify-between gap-3 text-[11px] leading-4 text-zinc-500">
<span className="min-w-0 truncate">
{progress.total > 1 ? `${progress.current}/${progress.total}` : phaseLabel}
</span>
<span className="shrink-0 tabular-nums">{percent}%</span>
</div>
<div className="mt-3 text-[12px] leading-5 text-zinc-400">
{progress.message}
</div>
@ -2287,6 +2371,35 @@ export function CanvasChatPanel({
useState<CanvasImageDownloadProgress | null>(null);
const [composerUploadProgress, setComposerUploadProgress] =
useState<CanvasComposerUploadProgress | null>(null);
const composerUploadClearTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const clearComposerUploadProgress = useCallback((delayMs = 0) => {
if (composerUploadClearTimeoutRef.current) {
clearTimeout(composerUploadClearTimeoutRef.current);
composerUploadClearTimeoutRef.current = null;
}
if (delayMs <= 0) {
setComposerUploadProgress(null);
return;
}
composerUploadClearTimeoutRef.current = setTimeout(() => {
composerUploadClearTimeoutRef.current = null;
setComposerUploadProgress(null);
}, delayMs);
}, []);
const updateComposerUploadProgress = useCallback(
(options: CanvasUploadProgressOptions, progress?: UploadProgress) => {
if (composerUploadClearTimeoutRef.current) {
clearTimeout(composerUploadClearTimeoutRef.current);
composerUploadClearTimeoutRef.current = null;
}
setComposerUploadProgress(
buildCanvasComposerUploadProgress(options, progress)
);
},
[]
);
const [downloadingOriginalUrl, setDownloadingOriginalUrl] = useState("");
const [addingClawdImageUrl, setAddingClawdImageUrl] = useState("");
const downloadAbortControllerRef = useRef<AbortController | null>(null);
@ -2300,6 +2413,14 @@ export function CanvasChatPanel({
format: "webp",
});
}, [fullscreenPreview]);
useEffect(() => {
return () => {
if (composerUploadClearTimeoutRef.current) {
clearTimeout(composerUploadClearTimeoutRef.current);
composerUploadClearTimeoutRef.current = null;
}
};
}, []);
// 🆕 图片参数状态
const [imageParams, setImageParams] = useState<ImageGenerationParams>({
@ -4852,12 +4973,12 @@ export function CanvasChatPanel({
setAttachments([...attachments, ...toAdd]);
return toAdd.length;
}, [
agentUploadSlots.length,
agentUploadSlots,
attachments,
hasAgentUploadSlots,
insertImageTagInEditor,
isVideoModel,
videoFrameSlots.length,
videoFrameSlots,
]);
// 监听 pendingChatAttachments:视频模式时填充首尾帧槽位,非视频模式插入对话标签
@ -8343,12 +8464,22 @@ export function CanvasChatPanel({
}
setVideoSlotUploading(slotIndex);
const progressOptions: CanvasUploadProgressOptions = {
title: `正在上传${videoFrameSlots[slotIndex] || "参考图"}`,
fileName: file.name,
target: "slot",
};
try {
const formData = new FormData();
formData.append("file", file);
formData.append("purpose", "canvas-upload");
const response = await fetchUploadWithRetry(formData);
updateComposerUploadProgress(progressOptions);
const response = await fetchUploadWithRetry(formData, {
onProgress: (progress) => {
updateComposerUploadProgress(progressOptions, progress);
},
});
if (!response.ok) {
throw new Error("上传失败");
@ -8388,11 +8519,12 @@ export function CanvasChatPanel({
} catch (_error) {
toast.error("图片上传失败");
} finally {
clearComposerUploadProgress(450);
setVideoSlotUploading(null);
event.currentTarget.value = "";
}
},
[addNode, nodes.length, updateAttachmentAtVideoSlot]
[addNode, clearComposerUploadProgress, nodes.length, updateAttachmentAtVideoSlot, updateComposerUploadProgress, videoFrameSlots]
);
const handleAgentSlotFileChange = useCallback(
@ -8408,12 +8540,23 @@ export function CanvasChatPanel({
}
setAgentSlotUploading(slotIndex);
const slotLabel = agentUploadSlots[slotIndex]?.label || `第${slotIndex + 1}张`;
const progressOptions: CanvasUploadProgressOptions = {
title: `正在上传${slotLabel}`,
fileName: file.name,
target: "slot",
};
try {
const formData = new FormData();
formData.append("file", file);
formData.append("purpose", "canvas-upload");
const response = await fetchUploadWithRetry(formData);
updateComposerUploadProgress(progressOptions);
const response = await fetchUploadWithRetry(formData, {
onProgress: (progress) => {
updateComposerUploadProgress(progressOptions, progress);
},
});
if (!response.ok) {
throw new Error("上传失败");
@ -8450,16 +8593,16 @@ export function CanvasChatPanel({
connections: [],
});
const slotLabel = agentUploadSlots[slotIndex]?.label || `第${slotIndex + 1}张`;
toast.success(`${slotLabel}上传成功`);
} catch (_error) {
toast.error("图片上传失败");
} finally {
clearComposerUploadProgress(450);
setAgentSlotUploading(null);
event.currentTarget.value = "";
}
},
[addNode, agentUploadSlots, nodes.length, updateAttachmentAtAgentSlot]
[addNode, agentUploadSlots, clearComposerUploadProgress, nodes.length, updateAttachmentAtAgentSlot, updateComposerUploadProgress]
);
const updateDetailSlotAttachment = useCallback(
@ -8559,12 +8702,22 @@ export function CanvasChatPanel({
}
setDetailSlotUploading(slotIndex);
const progressOptions: CanvasUploadProgressOptions = {
title: `正在上传${DETAIL_SLOT_CONFIGS[slotIndex]?.label || "图片"}`,
fileName: file.name,
target: "slot",
};
try {
const formData = new FormData();
formData.append("file", file);
formData.append("purpose", "canvas-upload");
const response = await fetchUploadWithRetry(formData);
updateComposerUploadProgress(progressOptions);
const response = await fetchUploadWithRetry(formData, {
onProgress: (progress) => {
updateComposerUploadProgress(progressOptions, progress);
},
});
if (!response.ok) {
throw new Error("上传失败");
@ -8585,11 +8738,12 @@ export function CanvasChatPanel({
} catch (_error) {
toast.error("图片上传失败");
} finally {
clearComposerUploadProgress(450);
setDetailSlotUploading(null);
event.currentTarget.value = "";
}
},
[updateDetailSlotAttachment]
[clearComposerUploadProgress, updateComposerUploadProgress, updateDetailSlotAttachment]
);
const updateLocalDetailArchive = useCallback(
@ -11313,7 +11467,8 @@ export function CanvasChatPanel({
const uploadImageFileAndAddToCanvas = useCallback(async (
file: File,
nodeIndex: number
nodeIndex: number,
progressOptions?: CanvasUploadProgressOptions
): Promise<{
imageUrl: string;
name: string;
@ -11340,7 +11495,16 @@ export function CanvasChatPanel({
formData.append('file', file);
formData.append('purpose', 'canvas-upload');
const response = await fetchUploadWithRetry(formData);
if (progressOptions) {
updateComposerUploadProgress(progressOptions);
}
const response = await fetchUploadWithRetry(formData, progressOptions
? {
onProgress: (progress) => {
updateComposerUploadProgress(progressOptions, progress);
},
}
: undefined);
if (!response.ok) {
throw new Error('上传失败');
@ -11385,10 +11549,11 @@ export function CanvasChatPanel({
toast.error('图片上传失败');
return null;
}
}, [addNode]);
}, [addNode, updateComposerUploadProgress]);
const uploadImageFileForComposerAttachment = useCallback(async (
file: File
file: File,
progressOptions?: CanvasUploadProgressOptions
): Promise<{
imageUrl: string;
name: string;
@ -11416,7 +11581,16 @@ export function CanvasChatPanel({
formData.append("file", file);
formData.append("purpose", "canvas-upload");
const response = await fetchUploadWithRetry(formData);
if (progressOptions) {
updateComposerUploadProgress(progressOptions);
}
const response = await fetchUploadWithRetry(formData, progressOptions
? {
onProgress: (progress) => {
updateComposerUploadProgress(progressOptions, progress);
},
}
: undefined);
if (!response.ok) {
throw new Error("上传失败");
}
@ -11441,7 +11615,7 @@ export function CanvasChatPanel({
toast.error("图片上传失败");
return null;
}
}, []);
}, [updateComposerUploadProgress]);
const handlePastedImages = useCallback(async (files: File[]) => {
if (files.length === 0) return;
@ -11454,18 +11628,21 @@ export function CanvasChatPanel({
let handledCount = 0;
try {
for (const [index, file] of files.entries()) {
setComposerUploadProgress({
const progressOptions: CanvasUploadProgressOptions = {
title:
files.length === 1
? "正在上传图片"
: `正在上传 ${files.length} 张图片`,
message: `正在上传第 ${index + 1}/${files.length} 张,图片较大时可能需要一点时间。`,
current: index + 1,
total: files.length,
fileName: file.name,
});
fileIndex: index,
fileTotal: files.length,
target: "canvas",
};
const uploaded = await uploadImageFileForComposerAttachment(file);
const uploaded = await uploadImageFileForComposerAttachment(
file,
progressOptions
);
if (!uploaded) continue;
handledCount += 1;
@ -11490,7 +11667,7 @@ export function CanvasChatPanel({
});
}
} finally {
setComposerUploadProgress(null);
clearComposerUploadProgress(450);
}
if (handledCount > 0) {
@ -11508,11 +11685,24 @@ export function CanvasChatPanel({
const currentSlots = Array.from({ length: slotCount }, (_, index) => attachments[index] ?? null);
let handledCount = 0;
for (const file of files) {
try {
for (const [index, file] of files.entries()) {
const targetSlotIndex = currentSlots.findIndex((slot) => !slot);
if (targetSlotIndex < 0) break;
const uploaded = await uploadImageFileAndAddToCanvas(file, baseNodeCount + uploadIndex);
const slotLabel =
agentUploadSlots[targetSlotIndex]?.label || `第${targetSlotIndex + 1}张`;
const uploaded = await uploadImageFileAndAddToCanvas(
file,
baseNodeCount + uploadIndex,
{
title: `正在上传${slotLabel}`,
fileName: file.name,
fileIndex: index,
fileTotal: files.length,
target: "slot",
}
);
if (!uploaded) continue;
const attachment: PickedObjectAttachment = {
@ -11531,6 +11721,9 @@ export function CanvasChatPanel({
firstImageUrl = uploaded.imageUrl;
}
}
} finally {
clearComposerUploadProgress(450);
}
if (handledCount > 0) {
toast.success(
@ -11551,11 +11744,22 @@ export function CanvasChatPanel({
const currentSlots = Array.from({ length: slotCount }, (_, index) => attachments[index] ?? null);
let handledCount = 0;
for (const file of files) {
try {
for (const [index, file] of files.entries()) {
const targetSlotIndex = currentSlots.findIndex((slot) => !slot);
if (targetSlotIndex < 0) break;
const uploaded = await uploadImageFileAndAddToCanvas(file, baseNodeCount + uploadIndex);
const uploaded = await uploadImageFileAndAddToCanvas(
file,
baseNodeCount + uploadIndex,
{
title: `正在上传${videoFrameSlots[targetSlotIndex] || "参考图"}`,
fileName: file.name,
fileIndex: index,
fileTotal: files.length,
target: "slot",
}
);
if (!uploaded) continue;
const attachment: PickedObjectAttachment = {
@ -11573,6 +11777,9 @@ export function CanvasChatPanel({
firstImageUrl = uploaded.imageUrl;
}
}
} finally {
clearComposerUploadProgress(450);
}
if (handledCount > 0) {
toast.success(handledCount === 1 ? "已粘贴图片到视频槽位" : `已粘贴 ${handledCount} 张图片到视频槽位`);
@ -11585,8 +11792,22 @@ export function CanvasChatPanel({
}
let handledCount = 0;
for (const file of files) {
const uploaded = await uploadImageFileAndAddToCanvas(file, baseNodeCount + uploadIndex);
try {
for (const [index, file] of files.entries()) {
const uploaded = await uploadImageFileAndAddToCanvas(
file,
baseNodeCount + uploadIndex,
{
title:
files.length === 1
? "正在上传参考图"
: `正在上传 ${files.length} 张参考图`,
fileName: file.name,
fileIndex: index,
fileTotal: files.length,
target: "composer-reference",
}
);
if (!uploaded) continue;
handledCount += 1;
@ -11615,6 +11836,9 @@ export function CanvasChatPanel({
];
});
}
} finally {
clearComposerUploadProgress(450);
}
if (handledCount > 0) {
toast.success(handledCount === 1 ? "图片已添加到画布" : `已粘贴并添加 ${handledCount} 张图片`);
@ -11622,8 +11846,9 @@ export function CanvasChatPanel({
void maybeAutoSetCanvasTitle(firstImageUrl);
}, [
agentUploadSlots.length,
agentUploadSlots,
attachments,
clearComposerUploadProgress,
hasAgentUploadSlots,
insertImageTagInEditor,
isClawdMode,
@ -11634,7 +11859,7 @@ export function CanvasChatPanel({
updateAttachmentAtVideoSlot,
uploadImageFileForComposerAttachment,
uploadImageFileAndAddToCanvas,
videoFrameSlots.length,
videoFrameSlots,
]);
const handleComposerPaste = useCallback((event: ReactClipboardEvent<HTMLDivElement>) => {
@ -11716,18 +11941,21 @@ export function CanvasChatPanel({
if (imageFiles.length > 0) {
try {
for (const [index, file] of imageFiles.entries()) {
setComposerUploadProgress({
const progressOptions: CanvasUploadProgressOptions = {
title:
imageFiles.length === 1
? "正在上传图片"
: `正在上传 ${imageFiles.length} 张图片`,
message: `正在上传第 ${index + 1}/${imageFiles.length} 张,图片较大时可能需要一点时间。`,
current: index + 1,
total: imageFiles.length,
fileName: file.name,
});
fileIndex: index,
fileTotal: imageFiles.length,
target: "canvas",
};
const uploaded = await uploadImageFileForComposerAttachment(file);
const uploaded = await uploadImageFileForComposerAttachment(
file,
progressOptions
);
if (!uploaded) continue;
handledImageCount += 1;
@ -11752,7 +11980,7 @@ export function CanvasChatPanel({
});
}
} finally {
setComposerUploadProgress(null);
clearComposerUploadProgress(450);
}
}
@ -11777,11 +12005,24 @@ export function CanvasChatPanel({
const currentSlots = Array.from({ length: slotCount }, (_, index) => attachments[index] ?? null);
let handledCount = 0;
for (const file of selectedFiles) {
try {
for (const [index, file] of selectedFiles.entries()) {
const targetSlotIndex = currentSlots.findIndex((slot) => !slot);
if (targetSlotIndex < 0) break;
const uploaded = await uploadImageFileAndAddToCanvas(file, baseNodeCount + uploadIndex);
const slotLabel =
agentUploadSlots[targetSlotIndex]?.label || `第${targetSlotIndex + 1}张`;
const uploaded = await uploadImageFileAndAddToCanvas(
file,
baseNodeCount + uploadIndex,
{
title: `正在上传${slotLabel}`,
fileName: file.name,
fileIndex: index,
fileTotal: selectedFiles.length,
target: "slot",
}
);
if (!uploaded) continue;
updateAttachmentAtAgentSlot(targetSlotIndex, {
@ -11807,6 +12048,9 @@ export function CanvasChatPanel({
firstImageUrl = uploaded.imageUrl;
}
}
} finally {
clearComposerUploadProgress(450);
}
if (handledCount > 0) {
toast.success(
@ -11823,8 +12067,9 @@ export function CanvasChatPanel({
e.target.value = '';
return;
}
for (const file of selectedFiles) {
try {
for (const [index, file] of selectedFiles.entries()) {
if (!file.type.startsWith('image/')) {
toast.error('请选择图片文件');
continue;
@ -11840,8 +12085,23 @@ export function CanvasChatPanel({
const formData = new FormData();
formData.append('file', file);
formData.append('purpose', 'canvas-upload');
const response = await fetchUploadWithRetry(formData);
const progressOptions: CanvasUploadProgressOptions = {
title:
selectedFiles.length === 1
? "正在上传参考图"
: `正在上传 ${selectedFiles.length} 张参考图`,
fileName: file.name,
fileIndex: index,
fileTotal: selectedFiles.length,
target: "composer-reference",
};
updateComposerUploadProgress(progressOptions);
const response = await fetchUploadWithRetry(formData, {
onProgress: (progress) => {
updateComposerUploadProgress(progressOptions, progress);
},
});
if (!response.ok) {
throw new Error('上传失败');
@ -11908,6 +12168,9 @@ export function CanvasChatPanel({
toast.error('图片上传失败');
}
}
} finally {
clearComposerUploadProgress(450);
}
console.log('[CanvasChat] 检查是否需要自动命名:', {
firstImageUrl: !!firstImageUrl,
@ -12048,6 +12311,10 @@ export function CanvasChatPanel({
const isComposerUploading = Boolean(composerUploadProgress);
const isSubmitting =
isActiveChatPending || isDetailGenerating || isClawdSubmitting || isComposerUploading;
const composerReferenceUploadProgress =
composerUploadProgress?.target === "composer-reference"
? composerUploadProgress
: null;
const hasInputOrAttachments = Boolean(
input.trim() || attachments.length > 0 || (!isDetailGenerationMode && selectedSkillTag)
);
@ -15124,7 +15391,7 @@ export function CanvasChatPanel({
{!isWorkspacePresentation && !isDetailGenerationMode && !isVideoWorkspace && activeComposerMode === "image" ? (
<button
type="button"
className="mx-4 mt-3 flex h-16 w-16 shrink-0 flex-col items-center justify-center gap-1.5 overflow-hidden rounded-[18px] bg-[#f3f3f4] text-[#a8adb5] transition-colors hover:bg-[#ececee] hover:text-[#7d848e] dark:bg-white/8 dark:text-zinc-400 dark:hover:bg-white/12"
className="relative mx-4 mt-3 flex h-16 w-16 shrink-0 flex-col items-center justify-center gap-1.5 overflow-hidden rounded-[18px] bg-[#f3f3f4] text-[#a8adb5] transition-colors hover:bg-[#ececee] hover:text-[#7d848e] dark:bg-white/8 dark:text-zinc-400 dark:hover:bg-white/12"
onClick={() => fileInputRef.current?.click()}
disabled={isSubmitting}
title="添加参考图"
@ -15148,6 +15415,36 @@ export function CanvasChatPanel({
<span className="text-[13px] font-medium leading-none">参考图</span>
</>
)}
{composerReferenceUploadProgress ? (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-1 bg-black/65 px-1.5 text-white backdrop-blur-[1px]">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-[10px] font-medium leading-none">上传中</span>
<span className="text-[11px] font-semibold leading-none tabular-nums">
{Math.max(
1,
Math.min(
100,
Math.round(composerReferenceUploadProgress.percent || 1)
)
)}
%
</span>
<span className="absolute inset-x-2 bottom-2 h-1 overflow-hidden rounded-full bg-white/20">
<span
className="block h-full rounded-full bg-[#e0ff3a] transition-all duration-300"
style={{
width: `${Math.max(
1,
Math.min(
100,
Math.round(composerReferenceUploadProgress.percent || 1)
)
)}%`,
}}
/>
</span>
</div>
) : null}
</button>
) : null}
{isClawdMode && clawdTableFiles.length > 0 ? (