From fec1151f7014cd8ff973ff51f490abea780388e5 Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 30 Apr 2026 12:40:25 +0800 Subject: [PATCH] fix: prevent canvas chat image attachment crashes --- components/canvas/CanvasChatPanel.tsx | 65 +++++++---- components/canvas/nodes/ImageNode.tsx | 156 +++++++++++++++++++++++--- 2 files changed, 185 insertions(+), 36 deletions(-) diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx index 47a1b3325..b0fdffc33 100644 --- a/components/canvas/CanvasChatPanel.tsx +++ b/components/canvas/CanvasChatPanel.tsx @@ -2595,9 +2595,15 @@ export function CanvasChatPanel({ const insertImageTagInEditor = useCallback((url: string, name: string, index: number) => { const editableDiv = textareaRef.current as HTMLDivElement | null; if (!editableDiv || !editableDiv.isContentEditable) return; + if (!isValidChatHttpUrl(url)) { + console.warn('[CanvasChat] 跳过非 HTTP 图片标签:', String(url || '').slice(0, 40)); + return; + } // 检查 DOM 中是否已存在该 URL 的标签(如果存在则不重复添加) - const existing = editableDiv.querySelector(`[data-attachment-url="${url}"]`); + const existing = Array.from( + editableDiv.querySelectorAll('[data-attachment-url]') + ).find((element) => element.getAttribute('data-attachment-url') === url); if (existing) { console.log('[CanvasChat] 标签已存在于 DOM 中,跳过:', url.substring(0, 50)); return; @@ -2609,10 +2615,18 @@ export function CanvasChatPanel({ tagSpan.setAttribute('data-attachment-url', url); tagSpan.className = 'inline-flex items-center gap-1 bg-muted/60 hover:bg-muted dark:bg-white/10 dark:hover:bg-white/15 dark:text-zinc-100 rounded-full pl-1 pr-2 py-0.5 mx-0.5 align-middle cursor-default select-none'; const shortName = name.length > 10 ? name.substring(0, 8) + '...' : name; - tagSpan.innerHTML = ` - - ${shortName} - `; + + const image = document.createElement('img'); + image.src = buildPreviewImageUrl(url, 48); + image.alt = ''; + image.className = 'h-5 w-5 object-cover rounded-full border border-border/50'; + + const label = document.createElement('span'); + label.className = 'text-xs text-foreground/80'; + label.textContent = shortName; + + tagSpan.appendChild(image); + tagSpan.appendChild(label); // 在光标位置插入,或添加到末尾,并在标签后插入一个零宽空格,方便光标落在标签后继续输入 const selection = window.getSelection(); @@ -3562,14 +3576,25 @@ export function CanvasChatPanel({ useEffect(() => { if (pendingChatAttachments.length === 0) return; + const safePendingChatAttachments = pendingChatAttachments + .map((item) => sanitizeChatAttachment(item as PickedObjectAttachment)) + .filter((item): item is PickedObjectAttachment => Boolean(item)); + + if (safePendingChatAttachments.length === 0) { + toast.error("图片还未完成上传,请重新添加到对话"); + clearPendingChatAttachments(); + return; + } + + if (safePendingChatAttachments.length < pendingChatAttachments.length) { + toast.warning("部分图片地址不可用,已跳过以避免页面卡顿"); + } + if (isDetailGenerationMode) { - const normalized = pendingChatAttachments - .filter((item) => item?.url) - .map((item) => ({ - url: item.url, - name: item.name || "素材图", - contentType: item.contentType || "image/jpeg", - })) as PickedObjectAttachment[]; + const normalized = safePendingChatAttachments.map((item) => ({ + ...item, + name: item.name || "素材图", + })); if (normalized.length > 0) { let inserted = 0; @@ -3628,12 +3653,11 @@ export function CanvasChatPanel({ (_, index) => attachments[index] ?? null ); const existingUrls = new Set(currentSlots.filter(Boolean).map((item) => item!.url)); - const toAdd = pendingChatAttachments - .filter((item) => item?.url && !existingUrls.has(item.url)) + const toAdd = safePendingChatAttachments + .filter((item) => !existingUrls.has(item.url)) .map((item) => ({ - url: item.url, + ...item, name: item.name || "参考图", - contentType: item.contentType || "image/jpeg", })) as PickedObjectAttachment[]; let cursor = 0; @@ -3678,12 +3702,11 @@ export function CanvasChatPanel({ (_, index) => attachments[index] ?? null ); const existingUrls = new Set(currentSlots.filter(Boolean).map((item) => item!.url)); - const toAdd = pendingChatAttachments - .filter((item) => item?.url && !existingUrls.has(item.url)) + const toAdd = safePendingChatAttachments + .filter((item) => !existingUrls.has(item.url)) .map((item) => ({ - url: item.url, + ...item, name: item.name || "参考图", - contentType: item.contentType || "image/jpeg", })) as PickedObjectAttachment[]; let cursor = 0; @@ -3719,7 +3742,7 @@ export function CanvasChatPanel({ const currentAttachments = [...attachments]; const existingUrls = new Set(currentAttachments.map((item) => item.url)); - const toAdd = pendingChatAttachments.filter((item) => !existingUrls.has(item.url)); + const toAdd = safePendingChatAttachments.filter((item) => !existingUrls.has(item.url)); if (toAdd.length > 0) { toAdd.forEach((item, index) => { diff --git a/components/canvas/nodes/ImageNode.tsx b/components/canvas/nodes/ImageNode.tsx index 24a498c5d..2b0124041 100644 --- a/components/canvas/nodes/ImageNode.tsx +++ b/components/canvas/nodes/ImageNode.tsx @@ -189,6 +189,8 @@ const normalizeTextEditItems = ( }; const isHttpImageUrl = (url?: string) => !!url && /^https?:\/\//i.test(url); +const CHAT_ATTACHMENT_MAX_UPLOAD_BYTES = 30 * 1024 * 1024; +const CHAT_ATTACHMENT_MAX_DATA_URL_LENGTH = 42 * 1024 * 1024; const CANVAS_3D_POLL_INTERVAL_MS = 2000; const CANVAS_3D_MAX_POLL_ATTEMPTS = 360; const DEFAULT_CANVAS_TRIPO_MODEL = getDefaultThreeDModelAliasId( @@ -204,6 +206,97 @@ const sleep = (ms: number) => const isTerminal3DFailureStatus = (status?: string | null) => ["failed", "banned", "expired", "cancelled"].includes(String(status || "")); +function getChatAttachmentExtension(mimeType: string) { + const normalized = mimeType.toLowerCase(); + if (normalized.includes("png")) return "png"; + if (normalized.includes("webp")) return "webp"; + if (normalized.includes("gif")) return "gif"; + if (normalized.includes("svg")) return "svg"; + return "jpg"; +} + +function normalizeChatAttachmentFileName(name: string, mimeType: string) { + const baseName = + String(name || "canvas-image") + .trim() + .replace(/\.[^.]+$/, "") + .replace(/[^\u4e00-\u9fa5a-zA-Z0-9_-]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 48) || "canvas-image"; + return `${baseName}-${Date.now()}.${getChatAttachmentExtension(mimeType)}`; +} + +async function uploadChatAttachmentBlob(blob: Blob, displayName: string) { + const uploadBlob = blob.type ? blob : new Blob([blob], { type: "image/jpeg" }); + + if (!uploadBlob.type.startsWith("image/")) { + throw new Error("只能添加图片到对话"); + } + + if (!["image/jpeg", "image/jpg", "image/png", "image/webp"].includes(uploadBlob.type)) { + throw new Error("暂只支持 JPG、PNG、WebP 图片添加到对话"); + } + + if (uploadBlob.size > CHAT_ATTACHMENT_MAX_UPLOAD_BYTES) { + throw new Error("图片过大,请先压缩后再添加到对话"); + } + + const formData = new FormData(); + const contentType = uploadBlob.type || "image/jpeg"; + formData.append("file", uploadBlob, normalizeChatAttachmentFileName(displayName, contentType)); + + const response = await fetchUploadWithRetry(formData); + let payload: any = null; + try { + payload = await response.json(); + } catch { + payload = null; + } + + if (!response.ok) { + throw new Error(payload?.error || "图片上传失败,请稍后重试"); + } + + const uploadedUrl = String(payload?.url || "").trim(); + if (!isHttpImageUrl(uploadedUrl)) { + throw new Error("图片上传后未返回可用地址"); + } + + return { + url: uploadedUrl, + contentType, + }; +} + +async function resolveChatAttachmentFromImageSrc(src: string, displayName: string) { + const trimmedSrc = String(src || "").trim(); + if (!trimmedSrc) { + throw new Error("当前图片地址不可用"); + } + + if (isHttpImageUrl(trimmedSrc)) { + return { + url: trimmedSrc, + contentType: "image/jpeg", + }; + } + + if (trimmedSrc.startsWith("data:image/")) { + if (trimmedSrc.length > CHAT_ATTACHMENT_MAX_DATA_URL_LENGTH) { + throw new Error("图片数据过大,请先下载压缩后再添加到对话"); + } + const blob = await fetch(trimmedSrc).then((response) => response.blob()); + return uploadChatAttachmentBlob(blob, displayName); + } + + if (trimmedSrc.startsWith("blob:")) { + const blob = await fetch(trimmedSrc).then((response) => response.blob()); + return uploadChatAttachmentBlob(blob, displayName); + } + + throw new Error("当前图片地址不可直接加入对话,请重新上传图片"); +} + function inferModelFormat(modelUrl?: string | null) { const value = String(modelUrl || "").toLowerCase(); if (!value) return "glb"; @@ -1662,8 +1755,8 @@ export function ImageNode({ updateNode, ]); - const animateAddToChat = useCallback(async () => { - if (!containerRef.current || !data.src || typeof window === "undefined") { + const animateAddToChat = useCallback(async (previewUrl?: string) => { + if (!containerRef.current || typeof window === "undefined") { return false; } @@ -1676,6 +1769,20 @@ export function ImageNode({ ) as HTMLElement | null; if (!target) return false; + const animationImageSrc = isHttpImageUrl(previewUrl) + ? buildImageProxyUrl(previewUrl || "", { + width: 360, + quality: 76, + format: "webp", + }) + : isHttpImageUrl(data.src) + ? buildImageProxyUrl(data.src, { + width: 360, + quality: 76, + format: "webp", + }) + : ""; + const sourceImage = (containerRef.current.querySelector("img") as HTMLImageElement | null) || containerRef.current; @@ -1760,13 +1867,22 @@ export function ImageNode({ flightTag.style.transformOrigin = "center center"; flightTag.style.willChange = "transform, opacity, filter"; - const previewImage = document.createElement("img"); - previewImage.src = data.src; - previewImage.alt = ""; - previewImage.style.width = "100%"; - previewImage.style.height = "100%"; - previewImage.style.objectFit = "cover"; - flightTag.appendChild(previewImage); + if (animationImageSrc) { + const previewImage = document.createElement("img"); + previewImage.src = animationImageSrc; + previewImage.alt = ""; + previewImage.style.width = "100%"; + previewImage.style.height = "100%"; + previewImage.style.objectFit = "cover"; + flightTag.appendChild(previewImage); + } else { + const placeholder = document.createElement("div"); + placeholder.style.width = "100%"; + placeholder.style.height = "100%"; + placeholder.style.background = + "linear-gradient(135deg, rgba(15,23,42,0.06), rgba(59,130,246,0.14))"; + flightTag.appendChild(placeholder); + } document.body.appendChild(flightTag); const compressDx = tagLeft + tagWidth / 2 - sourceCenterX; @@ -1816,14 +1932,17 @@ export function ImageNode({ flightTag.style.backdropFilter = "blur(6px)"; flightTag.innerHTML = ""; - const chipImage = document.createElement("img"); - chipImage.src = data.src; - chipImage.alt = ""; + const chipImage = document.createElement(animationImageSrc ? "img" : "span"); + if (animationImageSrc && chipImage instanceof HTMLImageElement) { + chipImage.src = animationImageSrc; + chipImage.alt = ""; + } chipImage.style.width = "24px"; chipImage.style.height = "24px"; chipImage.style.objectFit = "cover"; chipImage.style.borderRadius = "999px"; chipImage.style.border = "1px solid rgba(148, 163, 184, 0.35)"; + chipImage.style.background = "rgba(59, 130, 246, 0.12)"; const chipLabel = document.createElement("span"); chipLabel.textContent = labelText || "图片"; @@ -1900,13 +2019,20 @@ export function ImageNode({ setIsAddingToChat(true); try { - await animateAddToChat(); + const attachment = await resolveChatAttachmentFromImageSrc( + data.src, + displayTitle + ); + await animateAddToChat(attachment.url); addPendingChatAttachment({ - url: data.src, + url: attachment.url, name: displayTitle, - contentType: "image/jpeg", + contentType: attachment.contentType, }); toast.success("已添加到对话框"); + } catch (error) { + console.error("[CanvasImageNode] add to chat failed:", error); + toast.error(error instanceof Error ? error.message : "添加到对话失败"); } finally { setIsAddingToChat(false); }