fix: dedupe canvas media nodes for streamed assistant messages
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-02-21 05:57:03 +08:00
parent ba10d4e5be
commit cf1f431208

View File

@ -2640,105 +2640,122 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
useEffect(() => {
if (!hasHydratedHistoryRef.current) return;
// 只处理新消息
messages.forEach(async (message, messageIndex) => {
if (message.role === 'assistant' && !processedMessageIds.current.has(message.id)) {
const content = message.parts?.map((p: any) => p.text || '').join('') || '';
let cancelled = false;
const processNewAssistantMessages = async () => {
const existingImageUrlKeys = new Set(
nodes
.filter((n) => n.type === "image" && (n.data as any)?.src)
.map((n) => normalizeMediaUrl((n.data as any).src))
.filter(Boolean)
);
const existingVideoUrlKeys = new Set(
nodes
.filter((n) => n.type === "video" && (n.data as any)?.src)
.map((n) => normalizeMediaUrl((n.data as any).src))
.filter(Boolean)
);
for (let messageIndex = 0; messageIndex < messages.length; messageIndex += 1) {
if (cancelled) return;
const message = messages[messageIndex];
if (message.role !== "assistant" || processedMessageIds.current.has(message.id)) {
continue;
}
const content = message.parts?.map((p: any) => p.text || "").join("") || "";
const imageUrls = extractImageUrls(content);
const videoUrls = extractVideoUrls(content);
if (imageUrls.length === 0 && videoUrls.length === 0) continue;
processedMessageIds.current.add(message.id);
const generationPrompt = findNearestUserPrompt(messages, messageIndex);
if (imageUrls.length > 0 || videoUrls.length > 0) {
processedMessageIds.current.add(message.id);
let addedImageCount = 0;
let addedVideoCount = 0;
let addedImageCount = 0;
let addedVideoCount = 0;
const newImageUrls = imageUrls.filter((url) => {
const key = normalizeMediaUrl(url);
if (!key || existingImageUrlKeys.has(key)) return false;
existingImageUrlKeys.add(key);
return true;
});
if (imageUrls.length > 0) {
// 检查画布中是否已存在相同 URL 的图片
const existingUrls = new Set(
nodes.filter(n => n.type === 'image' && (n.data as any)?.src)
.map(n => (n.data as any).src)
);
// 只添加不存在的图片
const newUrls = imageUrls.filter(url => !existingUrls.has(url));
// 异步获取每张图片的尺寸并添加到画布
for (let index = 0; index < newUrls.length; index++) {
const url = newUrls[index];
// 获取图片原始尺寸
const dimensions = await getImageDimensions(url);
// 计算位置(避免重叠,根据图片宽度动态调整间距)
const existingNodes = nodes.length + index;
const spacing = 20; // 图片间距
const x = 100 + (existingNodes % 3) * (dimensions.width + spacing);
const y = 100 + Math.floor(existingNodes / 3) * (dimensions.height + spacing);
const nodeId = addNode({
type: 'image',
position: { x, y },
size: { width: dimensions.width, height: dimensions.height },
data: {
src: url,
status: 'completed',
title: '图片',
prompt: generationPrompt || '对话生成',
metadata: {
originalWidth: dimensions.originalWidth,
originalHeight: dimensions.originalHeight,
generationPrompt: generationPrompt || undefined,
sourceMessageId: message.id,
},
for (let index = 0; index < newImageUrls.length; index += 1) {
if (cancelled) return;
const url = newImageUrls[index];
const dimensions = await getImageDimensions(url);
const existingNodes = nodes.length + addedImageCount + addedVideoCount;
const spacing = 20;
const x = 100 + (existingNodes % 3) * (dimensions.width + spacing);
const y = 100 + Math.floor(existingNodes / 3) * (dimensions.height + spacing);
const nodeId = addNode({
type: "image",
position: { x, y },
size: { width: dimensions.width, height: dimensions.height },
data: {
src: url,
status: "completed",
title: "图片",
prompt: generationPrompt || "对话生成",
metadata: {
originalWidth: dimensions.originalWidth,
originalHeight: dimensions.originalHeight,
generationPrompt: generationPrompt || undefined,
sourceMessageId: message.id,
},
connections: [],
});
void autoNameGeneratedImageNode(nodeId, url);
addedImageCount += 1;
}
}
},
connections: [],
});
void autoNameGeneratedImageNode(nodeId, url);
addedImageCount += 1;
}
if (videoUrls.length > 0) {
const existingVideoUrls = new Set(
nodes.filter(n => n.type === 'video' && (n.data as any)?.src)
.map(n => (n.data as any).src)
);
const newVideoUrls = videoUrls.filter(url => !existingVideoUrls.has(url));
const newVideoUrls = videoUrls.filter((url) => {
const key = normalizeMediaUrl(url);
if (!key || existingVideoUrlKeys.has(key)) return false;
existingVideoUrlKeys.add(key);
return true;
});
for (let index = 0; index < newVideoUrls.length; index++) {
const url = newVideoUrls[index];
const existingNodes = nodes.length + index;
const x = 120 + (existingNodes % 2) * 420;
const y = 120 + Math.floor(existingNodes / 2) * 260;
addNode({
type: "video",
position: { x, y },
size: { width: 380, height: 220 },
data: {
src: url,
status: "completed",
prompt: generationPrompt || "对话生成",
},
connections: [],
});
addedVideoCount += 1;
}
}
for (let index = 0; index < newVideoUrls.length; index += 1) {
if (cancelled) return;
const url = newVideoUrls[index];
const existingNodes = nodes.length + addedImageCount + addedVideoCount;
const x = 120 + (existingNodes % 2) * 420;
const y = 120 + Math.floor(existingNodes / 2) * 260;
addNode({
type: "video",
position: { x, y },
size: { width: 380, height: 220 },
data: {
src: url,
status: "completed",
prompt: generationPrompt || "对话生成",
},
connections: [],
});
addedVideoCount += 1;
}
if (addedImageCount > 0 || addedVideoCount > 0) {
const notice = [
addedImageCount > 0 ? `${addedImageCount} 张图片` : "",
addedVideoCount > 0 ? `${addedVideoCount} 条视频` : "",
]
.filter(Boolean)
.join("、");
toast.success(`已将 ${notice} 添加到画布`);
}
if (addedImageCount > 0 || addedVideoCount > 0) {
const notice = [
addedImageCount > 0 ? `${addedImageCount} 张图片` : "",
addedVideoCount > 0 ? `${addedVideoCount} 条视频` : "",
]
.filter(Boolean)
.join("、");
toast.success(`已将 ${notice} 添加到画布`);
}
}
});
}, [messages, addNode, nodes]);
};
void processNewAssistantMessages();
return () => {
cancelled = true;
};
}, [messages, addNode, nodes, autoNameGeneratedImageNode]);
// 为历史节点补全可编辑的原始提示词(旧数据里可能只有“对话生成”占位文案)
useEffect(() => {