feat(canvas): Agent引用与局部重绘 - 完善附件预览多图展开并修复取消清理

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
ddCat
2026-09-08 22:59:49 +08:00
parent 897981b893
commit 6c1d17dbf6
26 changed files with 999 additions and 236 deletions

View File

@@ -2,9 +2,11 @@ import { nanoid } from "nanoid";
import { NODE_DEFAULT_SIZE } from "@/constant/canvas";
import { canGenerateImageInPlace, findAvailableGenerationGroupPosition, imageGenerationChildPosition, imageGenerationGroupSize } from "@/lib/canvas/canvas-generation-layout";
import { cancelIncompleteImageBatch, retireImageBatchChildren } from "@/lib/canvas/canvas-image-batch-retry";
import { buildImageGenerationNodeTitle } from "@/lib/canvas/canvas-generation-title";
import { nodeSizeFromRatio } from "@/lib/canvas/canvas-node-size";
import { canvasImageReferenceLimitError, buildImageGenerationMetadata, getGenerationCount, isGenerationCanceled, runCanvasGenerationTaskToConsumer } from "@/lib/canvas/canvas-project-generation";
import { imageGenerationReferenceConnections } from "@/lib/canvas/canvas-resource-references";
import { canvasGenerationPromptMetadata } from "@/lib/canvas/canvas-generation-submission";
import { CONTENT_MODERATION_ERROR_CODE, generationFailureMetadata, type GenerationFailureMetadata } from "@/lib/generation-error";
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
@@ -21,6 +23,7 @@ export async function executeImageGeneration({
nodeId,
sourceNode,
canvasNodes,
canvasConnections,
prompt,
effectivePrompt,
generationConfig,
@@ -52,6 +55,9 @@ export async function executeImageGeneration({
const isConfigNode = sourceNode?.type === CanvasNodeType.Config;
const isImageNode = sourceNode?.type === CanvasNodeType.Image;
const reuseSourceNode = canGenerateImageInPlace(sourceNode);
const retired = reuseSourceNode && sourceNode ? retireImageBatchChildren(sourceNode, canvasNodes, canvasConnections) : { nodes: canvasNodes, connections: canvasConnections, removedIds: [] as string[] };
const workingNodes = retired.nodes;
const workingConnections = retired.connections;
// 已有图片生成新结果并保留旧版本;参考图只来自入边,避免把旧结果误当成自身输入。
const referenceImages = generationContext.referenceImages;
const generationType = referenceImages.length ? ("edit" as const) : ("generation" as const);
@@ -78,7 +84,7 @@ export async function executeImageGeneration({
x: parentPosition.x + parentWidth + 96,
y: parentPosition.y + parentHeight / 2 - rootHeight / 2,
};
const rootPosition = reuseSourceNode ? parentPosition : findAvailableGenerationGroupPosition(canvasNodes, preferredPosition, imageGenerationGroupSize({ width: rootWidth, height: rootHeight }, outputNodeSize, childIds.length));
const rootPosition = reuseSourceNode ? parentPosition : findAvailableGenerationGroupPosition(workingNodes, preferredPosition, imageGenerationGroupSize({ width: rootWidth, height: rootHeight }, outputNodeSize, childIds.length));
const rootNode: CanvasNodeData = {
id: rootId,
@@ -101,7 +107,7 @@ export async function executeImageGeneration({
...generationMetadata,
...styleMetadata,
...skillMetadata,
imageBatchExpanded: count > 1 ? false : undefined,
imageBatchExpanded: count > 1 ? true : undefined,
generationErrorCode: undefined,
resourceReloadAvailable: undefined,
failedPromptFingerprint: undefined,
@@ -127,10 +133,14 @@ export async function executeImageGeneration({
failedPromptFingerprint: undefined,
},
}));
const batchConnections = [...(reuseSourceNode ? [] : [{ id: nanoid(), fromNodeId: nodeId, toNodeId: rootId }]), ...childIds.map((childId) => ({ id: nanoid(), fromNodeId: rootId, toNodeId: childId }))];
const batchConnections = [
...(reuseSourceNode ? [] : imageGenerationReferenceConnections(nodeId, rootId, workingNodes, workingConnections, nanoid)),
...(reuseSourceNode ? [] : [{ id: nanoid(), fromNodeId: nodeId, toNodeId: rootId }]),
...childIds.map((childId) => ({ id: nanoid(), fromNodeId: rootId, toNodeId: childId })),
];
const nextNodes: CanvasNodeData[] = [
...canvasNodes.map((node) => {
...workingNodes.map((node) => {
if (node.id !== nodeId) return node;
if (isConfigNode) return { ...node, metadata: { ...node.metadata, ...canvasGenerationPromptMetadata(prompt, effectivePrompt), status: NODE_STATUS_LOADING, errorDetails: undefined } };
if (reuseSourceNode) return { ...node, position: rootNode.position, width: rootNode.width, height: rootNode.height, title: rootNode.title, metadata: { ...node.metadata, ...rootNode.metadata, errorDetails: undefined } };
@@ -150,7 +160,8 @@ export async function executeImageGeneration({
setNodes(nextNodes);
setConnections((current) => {
const nextConnections = [...current, ...batchConnections];
const removed = new Set(retired.removedIds);
const nextConnections = [...current.filter((connection) => !removed.has(connection.fromNodeId) && !removed.has(connection.toNodeId)), ...batchConnections];
if (projectId) {
useCanvasStore.getState().updateProject(projectId, { nodes: nextNodes, connections: nextConnections });
}
@@ -253,7 +264,14 @@ export async function executeImageGeneration({
);
if (count > 1) finishGenerationRequest(rootId, controller);
if (controller.signal.aborted) {
setNodes((current) => current.map((node) => (node.id === nodeId && isConfigNode && node.metadata?.status === NODE_STATUS_LOADING ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_IDLE, errorDetails: undefined } } : node)));
setNodes((current) => {
const cancelled = cancelIncompleteImageBatch(rootId, childIds, current, []);
if (cancelled.removedIds.length) {
const removed = new Set(cancelled.removedIds);
setConnections((connections) => connections.filter((connection) => !removed.has(connection.fromNodeId) && !removed.has(connection.toNodeId)));
}
return cancelled.nodes.map((node) => (node.id === nodeId && isConfigNode && node.metadata?.status === NODE_STATUS_LOADING ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_IDLE, errorDetails: undefined } } : node));
});
return;
}
if (hasFailure) showError(hasSuccess ? "部分图片生成失败" : "全部图片生成失败");

View File

@@ -4,6 +4,7 @@ import { CanvasNodeMaskEditDialog, type CanvasImageMaskEditPayload } from "@/com
import { CanvasNodeSplitDialog, type CanvasImageSplitParams } from "@/components/canvas/canvas-node-split-dialog";
import { CanvasNodeUpscaleDialog, type CanvasImageUpscaleParams } from "@/components/canvas/canvas-node-upscale-dialog";
import type { CanvasNodeData } from "@/types/canvas";
import type { AiConfig } from "@/stores/use-config-store";
type CanvasProjectMediaDialogsProps = {
cropNode: CanvasNodeData | null;
@@ -21,6 +22,7 @@ type CanvasProjectMediaDialogsProps = {
onMaskEdit: (node: CanvasNodeData, payload: CanvasImageMaskEditPayload) => void;
onSplit: (node: CanvasNodeData, params: CanvasImageSplitParams) => void;
onUpscale: (node: CanvasNodeData, params: CanvasImageUpscaleParams) => void;
config: AiConfig;
};
export function CanvasProjectMediaDialogs({
@@ -39,12 +41,13 @@ export function CanvasProjectMediaDialogs({
onMaskEdit,
onSplit,
onUpscale,
config,
}: CanvasProjectMediaDialogsProps) {
return (
<>
{cropNode?.metadata?.content ? <CanvasNodeCropDialog dataUrl={cropNode.metadata.content} open onClose={onCloseCrop} onConfirm={(crop) => onCrop(cropNode, crop)} /> : null}
{annotationNode?.metadata?.content ? <CanvasNodeAnnotationDialog image={{ url: annotationNode.metadata.content, storageKey: annotationNode.metadata.storageKey }} open onClose={onCloseAnnotation} onConfirm={(dataUrl) => onAnnotate(annotationNode, dataUrl)} /> : null}
{maskEditNode?.metadata?.content ? <CanvasNodeMaskEditDialog dataUrl={maskEditNode.metadata.content} open onClose={onCloseMaskEdit} onConfirm={(payload) => onMaskEdit(maskEditNode, payload)} /> : null}
{maskEditNode?.metadata?.content ? <CanvasNodeMaskEditDialog dataUrl={maskEditNode.metadata.content} config={config} open onClose={onCloseMaskEdit} onConfirm={(payload) => onMaskEdit(maskEditNode, payload)} /> : null}
{splitNode?.metadata?.content ? <CanvasNodeSplitDialog dataUrl={splitNode.metadata.content} open onClose={onCloseSplit} onConfirm={(params) => onSplit(splitNode, params)} /> : null}
{upscaleNode?.metadata?.content ? <CanvasNodeUpscaleDialog dataUrl={upscaleNode.metadata.content} open onClose={onCloseUpscale} onConfirm={(params) => onUpscale(upscaleNode, params)} /> : null}
</>

View File

@@ -2893,6 +2893,7 @@ function InfiniteCanvasPage() {
onMaskEdit={(node, payload) => void maskEditImageNode(node, payload)}
onSplit={(node, params) => void splitImageNode(node, params)}
onUpscale={(node, params) => void upscaleImageNode(node, params)}
config={effectiveConfig}
/>
<CanvasProjectStatusDialogs

View File

@@ -13,6 +13,9 @@ import type { CanvasVideoSegmentParams } from "@/components/canvas/canvas-video-
import { NODE_DEFAULT_SIZE } from "@/constant/canvas";
import { cropDataUrl, splitDataUrl, upscaleDataUrl } from "@/lib/canvas/canvas-image-data";
import { audioMetadata, imageMetadata, videoMetadata } from "@/lib/canvas/canvas-generation-task-sync";
import { findAvailableGenerationGroupPosition, imageGenerationChildPosition, imageGenerationGroupSize } from "@/lib/canvas/canvas-generation-layout";
import { canvasGenerationPromptMetadata } from "@/lib/canvas/canvas-generation-submission";
import { cancelIncompleteImageBatch } from "@/lib/canvas/canvas-image-batch-retry";
import { buildAngleLabel, buildAnglePrompt, createCanvasNode } from "@/lib/canvas/canvas-project-domain";
import { validateVideoSegmentBatch } from "@/lib/canvas/canvas-video-regeneration";
import { resolveCanvasStyleExecution } from "@/lib/canvas/canvas-style-execution";
@@ -67,6 +70,7 @@ type UseCanvasMediaToolsOptions = {
const NODE_STATUS_LOADING = "loading" as const;
const NODE_STATUS_SUCCESS = "success" as const;
const NODE_STATUS_ERROR = "error" as const;
const NODE_STATUS_IDLE = "idle" as const;
const IMAGE_PROMPT_REVERSE_PRESET = `请根据参考图片反推一段适合用于 AI 生图的提示词。
要求:
@@ -571,46 +575,152 @@ export function useCanvasMediaTools({
const maskEditImageNode = useCallback(async (node: CanvasNodeData, payload: CanvasImageMaskEditPayload) => {
if (!node.metadata?.content) return;
const generationConfig = { ...buildGenerationConfig(effectiveConfig, node, "image"), count: "1", size: node.metadata?.size || "auto" };
const baseGenerationConfig = buildGenerationConfig(effectiveConfig, node, "image");
const generationConfig = {
...baseGenerationConfig,
...payload.generationConfig,
model: payload.generationConfig?.model || payload.generationConfig?.imageModel || baseGenerationConfig.model,
imageModel: payload.generationConfig?.imageModel || payload.generationConfig?.model || effectiveConfig.imageModel,
count: String(payload.generationConfig?.count || 1),
size: payload.generationConfig?.size || node.metadata?.size || "auto",
};
if (!isAiConfigReady(generationConfig, generationConfig.model)) {
navigateToSettings({ continueCreation: true });
return;
}
const userPrompt = payload.prompt.trim();
const prompt = `只修改蒙版透明区域,其他区域保持不变。${userPrompt}`;
const childId = nanoid();
const source = nodeReferenceImage(node);
if (!source) return;
const styleExecution = resolveImageEditStyle(node, prompt, generationConfig);
if (!styleExecution) return;
const { prompt: effectivePrompt, metadata: styleMetadata } = styleExecution;
const generationMetadata = buildImageGenerationMetadata("edit", generationConfig, 1, [source]);
const requestedCount = Math.max(1, Number(generationConfig.count) || 1);
const generationMetadata = buildImageGenerationMetadata("edit", generationConfig, requestedCount, [source]);
setMaskEditNodeId(null);
setRunningNodeId(childId);
setNodes((current) => [...current, { id: childId, type: CanvasNodeType.Image, title: userPrompt.slice(0, 32) || "局部编辑结果", position: { x: node.position.x + node.width + 96, y: node.position.y }, width: node.width, height: node.height, metadata: { prompt: effectivePrompt, status: NODE_STATUS_LOADING, ...generationMetadata, ...styleMetadata } }]);
setConnections((current) => [...current, { id: nanoid(), fromNodeId: node.id, toNodeId: childId }]);
setSelectedNodeIds(new Set([childId]));
const rootId = nanoid();
const childIds = requestedCount > 1 ? Array.from({ length: requestedCount }, () => nanoid()) : [];
const targetIds = requestedCount > 1 ? childIds : [rootId];
const imageSize = { width: node.width, height: node.height };
const preferredPosition = { x: node.position.x + node.width + 96, y: node.position.y };
const rootPosition = findAvailableGenerationGroupPosition(nodesRef.current, preferredPosition, imageGenerationGroupSize(imageSize, imageSize, childIds.length));
const rootNode: CanvasNodeData = {
id: rootId,
type: CanvasNodeType.Image,
title: userPrompt.slice(0, 32) || "局部编辑结果",
position: rootPosition,
width: node.width,
height: node.height,
metadata: {
...canvasGenerationPromptMetadata(userPrompt, effectivePrompt),
status: NODE_STATUS_LOADING,
isBatchRoot: requestedCount > 1,
batchChildIds: requestedCount > 1 ? childIds : undefined,
batchFailedCount: requestedCount > 1 ? 0 : undefined,
imageBatchExpanded: requestedCount > 1 ? true : undefined,
...generationMetadata,
...styleMetadata,
},
};
const childNodes: CanvasNodeData[] = childIds.map((id, index) => ({
id,
type: CanvasNodeType.Image,
title: `${userPrompt.slice(0, 28) || "局部编辑结果"} · ${index + 1}`,
position: imageGenerationChildPosition(rootNode.position, rootNode.width, imageSize, index),
width: node.width,
height: node.height,
metadata: {
...canvasGenerationPromptMetadata(userPrompt, effectivePrompt),
status: NODE_STATUS_LOADING,
batchRootId: rootId,
...generationMetadata,
...styleMetadata,
},
}));
setRunningNodeId(rootId);
setNodes((current) => [...current, rootNode, ...childNodes]);
setConnections((current) => [...current, { id: nanoid(), fromNodeId: node.id, toNodeId: rootId }, ...childIds.map((childId) => ({ id: nanoid(), fromNodeId: rootId, toNodeId: childId }))]);
setSelectedNodeIds(new Set([rootId, ...childIds]));
setSelectedConnectionId(null);
setDialogNodeId(childId);
const controller = startGenerationRequest(childId, node.id, childId);
setDialogNodeId(rootId);
const controller = startGenerationRequest(rootId, node.id, rootId);
targetIds.forEach((targetId) => startGenerationRequest(targetId, node.id, rootId, controller));
let hasSuccess = false;
let failureCount = 0;
let representativeError: string | undefined;
try {
const result = await runBackendCanvasGenerationTask({ projectId, nodeId: childId, mode: "image", prompt: effectivePrompt, config: generationConfig, referenceImages: [source], mask: { id: `${node.id}-mask`, name: "mask.png", type: "image/png", dataUrl: payload.maskDataUrl }, signal: controller.signal, metadata: { sourceNodeId: node.id, edit: "mask", ...styleMetadata }, onTaskCreated: (task) => bindGenerationTask(childId, task) });
const image = result.images?.[0];
if (!image?.dataUrl) throw new Error("后端任务没有返回图片");
const uploaded = await uploadImage(image.dataUrl);
const size = fitNodeSize(uploaded.width, uploaded.height, node.width, node.height);
const currentNode = nodesRef.current.find((item) => item.id === childId);
if (!currentNode) throw new Error("局部编辑节点已被删除");
const finalizedNode = { ...currentNode, width: size.width, height: size.height, metadata: { ...currentNode.metadata, ...imageMetadata(uploaded), prompt: effectivePrompt, ...generationMetadata } };
setNodes((current) => current.map((item) => item.id === childId ? finalizedNode : item));
await persistMediaNodes([finalizedNode]);
} catch (error) {
if (isGenerationCanceled(error)) return;
const details = generationErrorMessage(error);
message.error(details);
setNodes((current) => current.map((item) => item.id === childId ? { ...item, metadata: { ...item.metadata, status: NODE_STATUS_ERROR, errorDetails: details } } : item));
await Promise.all(targetIds.map(async (targetId) => {
try {
const result = await runBackendCanvasGenerationTask({
projectId,
nodeId: targetId,
mode: "image",
prompt: effectivePrompt,
config: { ...generationConfig, count: "1" },
referenceImages: [source],
mask: { id: `${node.id}-mask`, name: "mask.png", type: "image/png", dataUrl: payload.maskDataUrl },
signal: controller.signal,
metadata: { sourceNodeId: node.id, edit: "mask", ...styleMetadata },
onTaskCreated: (task) => bindGenerationTask(targetId, task),
});
const image = result.images?.find((item) => item?.dataUrl);
if (!image?.dataUrl) throw new Error("后端任务没有返回图片");
const uploaded = await uploadImage(image.dataUrl);
const size = fitNodeSize(uploaded.width, uploaded.height, node.width, node.height);
const currentNode = nodesRef.current.find((item) => item.id === targetId);
if (!currentNode) throw new Error("局部编辑节点已被删除");
const finalizedNode = { ...currentNode, width: size.width, height: size.height, metadata: { ...currentNode.metadata, ...imageMetadata(uploaded), prompt: effectivePrompt, ...generationMetadata } };
setNodes((current) => current.map((item) => {
if (item.id === targetId) return finalizedNode;
if (item.id !== rootId || requestedCount <= 1 || item.metadata?.primaryImageId) return item;
return { ...item, width: size.width, height: size.height, metadata: { ...item.metadata, ...imageMetadata(uploaded), primaryImageId: targetId, status: NODE_STATUS_SUCCESS } };
}));
await persistMediaNodes([finalizedNode]);
hasSuccess = true;
} catch (error) {
if (isGenerationCanceled(error)) return;
failureCount += 1;
const details = generationErrorMessage(error);
representativeError = details;
setNodes((current) => current.map((item) => (item.id === targetId ? { ...item, metadata: { ...item.metadata, status: NODE_STATUS_ERROR, errorDetails: details } } : item)));
} finally {
finishGenerationRequest(targetId, controller);
}
}));
if (controller.signal.aborted) {
setNodes((current) => {
const cancelled = cancelIncompleteImageBatch(rootId, childIds, current, []);
if (cancelled.removedIds.length) {
const removed = new Set(cancelled.removedIds);
setConnections((connections) => connections.filter((connection) => !removed.has(connection.fromNodeId) && !removed.has(connection.toNodeId)));
}
return cancelled.nodes.map((item) => {
if (item.id !== rootId) return item;
if (item.metadata?.content) return item;
return { ...item, metadata: { ...item.metadata, status: NODE_STATUS_IDLE, errorDetails: undefined } };
});
});
return;
}
if (failureCount > 0) {
message.error(hasSuccess ? "部分局部编辑失败" : representativeError || "局部编辑失败");
}
setNodes((current) => current.map((item) => {
if (item.id !== rootId) return item;
return {
...item,
metadata: {
...item.metadata,
status: hasSuccess ? NODE_STATUS_SUCCESS : NODE_STATUS_ERROR,
batchFailedCount: requestedCount > 1 ? failureCount : undefined,
...(hasSuccess
? { errorDetails: undefined }
: { errorDetails: representativeError || "局部编辑失败" }),
},
};
}));
} finally {
finishGenerationRequest(childId, controller);
if (requestedCount > 1) finishGenerationRequest(rootId, controller);
setRunningNodeId(null);
}
}, [bindGenerationTask, effectiveConfig, finishGenerationRequest, isAiConfigReady, message, nodesRef, persistMediaNodes, projectId, resolveImageEditStyle, setConnections, setDialogNodeId, setNodes, setRunningNodeId, setSelectedConnectionId, setSelectedNodeIds, startGenerationRequest]);