diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx
index b8bd5e92b..c3b121aef 100644
--- a/components/canvas/CanvasChatPanel.tsx
+++ b/components/canvas/CanvasChatPanel.tsx
@@ -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(
@@ -1663,12 +1740,19 @@ function CanvasComposerUploadProgressDialog({
+
+
+ {progress.total > 1 ? `${progress.current}/${progress.total}` : phaseLabel}
+
+ {percent}%
+
+
{progress.message}
@@ -2287,6 +2371,35 @@ export function CanvasChatPanel({
useState(null);
const [composerUploadProgress, setComposerUploadProgress] =
useState(null);
+ const composerUploadClearTimeoutRef = useRef | 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(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({
@@ -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) => {
@@ -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" ? (
) : null}
{isClawdMode && clawdTableFiles.length > 0 ? (