fix: prevent canvas chat image attachment crashes
This commit is contained in:
parent
b630267f33
commit
fec1151f70
@ -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) => {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user