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 ? (