diff --git a/web/src/pages/canvas/canvas-image-generation-executor.ts b/web/src/pages/canvas/canvas-image-generation-executor.ts index 7d34f9410..773ec1b5e 100644 --- a/web/src/pages/canvas/canvas-image-generation-executor.ts +++ b/web/src/pages/canvas/canvas-image-generation-executor.ts @@ -54,6 +54,7 @@ export async function executeImageGeneration({ const count = getGenerationCount(generationConfig.count); const isConfigNode = sourceNode?.type === CanvasNodeType.Config; const isImageNode = sourceNode?.type === CanvasNodeType.Image; + const isExistingImageNode = isImageNode && Boolean(sourceNode?.metadata?.content); const reuseSourceNode = canGenerateImageInPlace(sourceNode); const retired = reuseSourceNode && sourceNode ? retireImageBatchChildren(sourceNode, canvasNodes, canvasConnections) : { nodes: canvasNodes, connections: canvasConnections, removedIds: [] as string[] }; const workingNodes = retired.nodes; @@ -133,9 +134,10 @@ export async function executeImageGeneration({ failedPromptFingerprint: undefined, }, })); + const connectsSelf = !isExistingImageNode || referenceImages.some((img) => img.id === nodeId); const batchConnections = [ ...(reuseSourceNode ? [] : imageGenerationReferenceConnections(nodeId, rootId, workingNodes, workingConnections, nanoid)), - ...(reuseSourceNode ? [] : [{ id: nanoid(), fromNodeId: nodeId, toNodeId: rootId }]), + ...(reuseSourceNode || !connectsSelf ? [] : [{ id: nanoid(), fromNodeId: nodeId, toNodeId: rootId }]), ...childIds.map((childId) => ({ id: nanoid(), fromNodeId: rootId, toNodeId: childId })), ]; diff --git a/web/src/pages/canvas/canvas-media-generation-executors.ts b/web/src/pages/canvas/canvas-media-generation-executors.ts index e64493b5e..fa03dffee 100644 --- a/web/src/pages/canvas/canvas-media-generation-executors.ts +++ b/web/src/pages/canvas/canvas-media-generation-executors.ts @@ -151,6 +151,7 @@ export async function executeAudioGeneration({ }: CanvasGenerationExecution) { const spec = NODE_DEFAULT_SIZE[CanvasNodeType.Audio]; const isEmptyAudioNode = sourceNode?.type === CanvasNodeType.Audio && !sourceNode.metadata?.content; + const isExistingAudioNode = sourceNode?.type === CanvasNodeType.Audio && Boolean(sourceNode.metadata?.content); const audioId = isEmptyAudioNode ? nodeId : nanoid(); const parent = sourceNode?.position || { x: 0, y: 0 }; const audioNode: CanvasNodeData = { @@ -166,7 +167,7 @@ export async function executeAudioGeneration({ setNodes((current) => isEmptyAudioNode ? current.map((node) => (node.id === nodeId ? { ...node, ...audioNode } : node)) : [...current.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_SUCCESS } } : node)), audioNode], ); - if (!isEmptyAudioNode) setConnections((current) => [...current, { id: nanoid(), fromNodeId: nodeId, toNodeId: audioId }]); + if (!isEmptyAudioNode && !isExistingAudioNode) setConnections((current) => [...current, { id: nanoid(), fromNodeId: nodeId, toNodeId: audioId }]); startGenerationRequest(audioId, nodeId, nodeId, controller); try { diff --git a/web/test/canvas-image-regeneration.test.ts b/web/test/canvas-image-regeneration.test.ts new file mode 100644 index 000000000..30704a19f --- /dev/null +++ b/web/test/canvas-image-regeneration.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, test } from "bun:test"; +import { CanvasNodeType, type CanvasConnection, type CanvasNodeData } from "../src/types/canvas"; +import { imageGenerationReferenceConnections } from "../src/lib/canvas/canvas-resource-references"; + +describe("image generation connections", () => { + test("重新生成已有图片节点时,只有在用户显式引用源图时才连接源图,避免误将旧结果当作参考图", () => { + const sourceImageNode: CanvasNodeData = { + id: "img-1", + type: CanvasNodeType.Image, + title: "第一次生成结果", + position: { x: 0, y: 0 }, + width: 300, + height: 300, + metadata: { + content: "data:image/png;base64,mock", + storageKey: "user:img-1:file", + }, + }; + + const rootId = "img-2"; + const workingNodes = [sourceImageNode]; + const workingConnections: CanvasConnection[] = []; + + // Case 1: 用户修改提示词重新生成(未显式 @ 源图) + const referenceImagesNone: any[] = []; + const isExistingImageNode = sourceImageNode.type === CanvasNodeType.Image && Boolean(sourceImageNode.metadata?.content); + const reuseSourceNode = false; + + const connectsSelfNoMention = !isExistingImageNode || referenceImagesNone.some((img) => img.id === sourceImageNode.id); + expect(connectsSelfNoMention).toBe(false); + + const batchConnectionsNoMention = [ + ...(reuseSourceNode ? [] : imageGenerationReferenceConnections(sourceImageNode.id, rootId, workingNodes, workingConnections, () => "c1")), + ...(reuseSourceNode || !connectsSelfNoMention ? [] : [{ id: "c-self", fromNodeId: sourceImageNode.id, toNodeId: rootId }]), + ]; + + // 验证:不应存在从 img-1 到 img-2 的连接 + expect(batchConnectionsNoMention.some((c) => c.fromNodeId === sourceImageNode.id && c.toNodeId === rootId)).toBe(false); + expect(batchConnectionsNoMention.length).toBe(0); + + // Case 2: 用户在提示词中显式 @图片1 进行图生图 + const referenceImagesMention = [{ id: sourceImageNode.id, dataUrl: "mock" }]; + const connectsSelfWithMention = !isExistingImageNode || referenceImagesMention.some((img) => img.id === sourceImageNode.id); + expect(connectsSelfWithMention).toBe(true); + + const batchConnectionsWithMention = [ + ...(reuseSourceNode ? [] : imageGenerationReferenceConnections(sourceImageNode.id, rootId, workingNodes, workingConnections, () => "c1")), + ...(reuseSourceNode || !connectsSelfWithMention ? [] : [{ id: "c-self", fromNodeId: sourceImageNode.id, toNodeId: rootId }]), + ]; + + // 验证:显式引用时正确建立从 img-1 到 img-2 的连线 + expect(batchConnectionsWithMention.some((c) => c.fromNodeId === sourceImageNode.id && c.toNodeId === rootId)).toBe(true); + }); + + test("若源图本身带有上游参考素材,新图片节点正确继承上游素材连线而不连接源图自身", () => { + const upstreamRefNode: CanvasNodeData = { + id: "ref-source", + type: CanvasNodeType.Image, + title: "原始参考图", + position: { x: 0, y: 0 }, + width: 300, + height: 300, + metadata: { + content: "data:image/png;base64,ref", + storageKey: "user:ref-source:file", + }, + }; + + const intermediateImageNode: CanvasNodeData = { + id: "img-intermediate", + type: CanvasNodeType.Image, + title: "中间生成图", + position: { x: 400, y: 0 }, + width: 300, + height: 300, + metadata: { + content: "data:image/png;base64,mid", + storageKey: "user:img-intermediate:file", + }, + }; + + const existingConnection: CanvasConnection = { + id: "c-upstream", + fromNodeId: upstreamRefNode.id, + toNodeId: intermediateImageNode.id, + }; + + const rootId = "img-new"; + const workingNodes = [upstreamRefNode, intermediateImageNode]; + const workingConnections = [existingConnection]; + + const isExistingImageNode = true; + const reuseSourceNode = false; + const referenceImagesNone: any[] = []; + const connectsSelf = !isExistingImageNode || referenceImagesNone.some((img) => img.id === intermediateImageNode.id); + + const batchConnections = [ + ...(reuseSourceNode ? [] : imageGenerationReferenceConnections(intermediateImageNode.id, rootId, workingNodes, workingConnections, () => "c-inherited")), + ...(reuseSourceNode || !connectsSelf ? [] : [{ id: "c-self", fromNodeId: intermediateImageNode.id, toNodeId: rootId }]), + ]; + + // 验证:新节点继承了 upstreamRefNode 的连线,但绝不连接 intermediateImageNode 自身 + expect(batchConnections).toHaveLength(1); + expect(batchConnections[0].fromNodeId).toBe(upstreamRefNode.id); + expect(batchConnections[0].toNodeId).toBe(rootId); + }); +});