fix: prevent canvas chat image attachment crashes
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-04-30 12:40:25 +08:00
parent b630267f33
commit fec1151f70
2 changed files with 185 additions and 36 deletions

View File

@ -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<HTMLElement>('[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 = `
<img src="${url}" alt="" class="h-5 w-5 object-cover rounded-full border border-border/50" />
<span class="text-xs text-foreground/80">${shortName}</span>
`;
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) => {

View File

@ -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);
}