feat(canvas): 画布模型与连线能力 - 合并同名模型并按输入动态限制节点关联

Closes #220
This commit is contained in:
ddCat committed 2026-08-14 12:41:55 +08:00
1 parent cfdf08d05f
commit e7af466a16
16 files changed
+509 -80

No files matched your search

+3 -1
View File
@@ -705,9 +705,10 @@ function InfiniteCanvasPage() {
onNodesDeleted: handleNodesDeleted,
});
const { cancelPendingConnectionCreate, closeConnectionCreateMenu, connectionTargetAnchorRatio, connectionTargetNodeId, connectingParams, createConnectedNode, handleConnectStart, mouseWorld, pendingConnectionCreate, setConnecting } =
const { cancelPendingConnectionCreate, closeConnectionCreateMenu, connectionTargetAnchorRatio, connectionTargetNodeId, connectingParams, createConnectedNode, getConnectionCreateDisabledReason, handleConnectStart, mouseWorld, pendingConnectionCreate, setConnecting } =
useCanvasConnectionController({
projectId,
config: effectiveConfig,
defaultDrawingEngine,
nodesRef,
connectionsRef,
@@ -1786,6 +1787,7 @@ function InfiniteCanvasPage() {
viewportSize={size}
containerRef={containerRef}
canCreateDrawing={canCreateDrawingFromConnection}
getDisabledReason={(type) => getConnectionCreateDisabledReason(type, pendingConnectionCreate)}
onCreate={(type) => void createConnectedNode(type, pendingConnectionCreate)}
onClose={cancelPendingConnectionCreate}
/>
@@ -4,15 +4,18 @@ import { nanoid } from "nanoid";
import type { PendingConnectionCreate } from "@/components/canvas/canvas-workspace-overlays";
import { getNodeSpec } from "@/constant/canvas";
import { canvasConnectionError } from "@/lib/canvas/canvas-connection-policy";
import { attachNodeToStoryboardRow, createCanvasNode, getConnectionTargetAnchor, isHiddenBatchChild, normalizeConnection, storyboardHandleAtY, storyboardPromptTemplateMetadata, storyboardRowFromHandle } from "@/lib/canvas/canvas-project-domain";
import { createCanvasDrawingFromImage } from "@/lib/canvas/canvas-drawing-storage";
import { isDrawingEngineAvailable, type CanvasDrawingEngine } from "@/lib/canvas/canvas-drawing-engine";
import { isFrameNode, isNodeHiddenByCollapsedFrame } from "@/lib/canvas/canvas-frame";
import type { AiConfig } from "@/stores/use-config-store";
import { useUserStore } from "@/stores/use-user-store";
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData, type ConnectionHandle, type ContextMenuState, type Position, type ViewportTransform } from "@/types/canvas";
type UseCanvasConnectionControllerOptions = {
projectId: string;
config: AiConfig;
defaultDrawingEngine: CanvasDrawingEngine;
nodesRef: { current: CanvasNodeData[] };
connectionsRef: { current: CanvasConnection[] };
@@ -41,6 +44,7 @@ const NODE_STATUS_IDLE = "idle" as const;
export function useCanvasConnectionController({
projectId,
config,
defaultDrawingEngine,
nodesRef,
connectionsRef,
@@ -105,6 +109,11 @@ export function useCanvasConnectionController({
const toHandleId = toNodeId === current.nodeId ? current.handleId : targetHandleId;
const fromAnchorRatio = fromNodeId === current.nodeId ? current.anchorRatio : targetAnchorRatio;
const toAnchorRatio = toNodeId === current.nodeId ? current.anchorRatio : targetAnchorRatio;
const policyError = canvasConnectionError(config, nodesRef.current, connectionsRef.current, { fromNodeId, toNodeId });
if (policyError) {
message.warning(policyError);
return;
}
const exists = connectionsRef.current.find((item) => item.fromNodeId === fromNodeId && item.toNodeId === toNodeId && item.fromHandleId === fromHandleId && item.toHandleId === toHandleId);
if (exists) {
setConnections((currentConnections) => currentConnections.map((item) => item.id === exists.id ? { ...item, fromAnchorRatio, toAnchorRatio } : item));
@@ -113,7 +122,7 @@ export function useCanvasConnectionController({
setNodes((currentNodes) => attachNodeToStoryboardRow(currentNodes, { fromNodeId, toNodeId, fromHandleId, toHandleId }));
}
setContextMenu(null);
}, [connectionsRef, message, nodesRef, setConnections, setContextMenu, setNodes]);
}, [config, connectionsRef, message, nodesRef, setConnections, setContextMenu, setNodes]);
const createConnectedNode = useCallback(async (type: CanvasNodeType.Image | CanvasNodeType.Text | CanvasNodeType.Script | CanvasNodeType.Video | CanvasNodeType.Audio | CanvasNodeType.Drawing, pending: PendingConnectionCreate) => {
if (type === CanvasNodeType.Drawing && !isDrawingEngineAvailable(defaultDrawingEngine, tldrawLicenseKey)) {
@@ -149,6 +158,11 @@ export function useCanvasConnectionController({
message.warning("配置节点之间不能连接");
return;
}
const policyError = canvasConnectionError(config, [...nodesRef.current, newNode], connectionsRef.current, connection);
if (policyError) {
message.warning(policyError);
return;
}
if (type === CanvasNodeType.Drawing) {
const drawingSourceNode = nodesRef.current.find((node) => node.id === pending.connection.nodeId);
const sourceUrl = drawingSourceNode?.type === CanvasNodeType.Image ? drawingSourceNode.metadata?.content : "";
@@ -192,7 +206,16 @@ export function useCanvasConnectionController({
else if (type !== CanvasNodeType.Text && type !== CanvasNodeType.Script && type !== CanvasNodeType.Audio) setDialogNodeId(newNode.id);
closeConnectionCreateMenu();
setConnecting(null);
}, [closeConnectionCreateMenu, defaultDrawingEngine, message, nodesRef, projectId, setConnecting, setConnections, setDialogNodeId, setDrawingNodeId, setNodes, setSelectedConnectionId, setSelectedNodeIds, tldrawLicenseKey]);
}, [closeConnectionCreateMenu, config, connectionsRef, defaultDrawingEngine, message, nodesRef, projectId, setConnecting, setConnections, setDialogNodeId, setDrawingNodeId, setNodes, setSelectedConnectionId, setSelectedNodeIds, tldrawLicenseKey]);
const getConnectionCreateDisabledReason = useCallback((type: CanvasNodeType.Image | CanvasNodeType.Text | CanvasNodeType.Script | CanvasNodeType.Video | CanvasNodeType.Audio | CanvasNodeType.Drawing, pending: PendingConnectionCreate) => {
const spec = getNodeSpec(type);
const pendingNode: CanvasNodeData = { id: "__pending-connection-node__", type, title: "", position: pending.position, width: spec.width, height: spec.height };
const pendingNodes = [...nodesRef.current, pendingNode];
const connection = normalizeConnection(pending.connection.nodeId, pendingNode.id, pendingNodes, pending.connection.handleType);
if (!connection) return "当前节点类型不能这样连接";
return canvasConnectionError(config, pendingNodes, connectionsRef.current, connection);
}, [config, connectionsRef, nodesRef]);
const getConnectionDropTarget = useCallback((clientX: number, clientY: number, current: ConnectionHandle): ConnectionDropTarget => {
const world = screenToCanvas(clientX, clientY);
@@ -221,7 +244,8 @@ export function useCanvasConnectionController({
const hitsExpanded = world.x >= node.position.x - padding && world.x <= node.position.x + node.width + padding && world.y >= node.position.y - padding && world.y <= node.position.y + node.height + padding;
if (!hitsHandle && !hitsInside && !hitsExpanded) return;
isNearNode = true;
if (node.id === current.nodeId || !normalizeConnection(current.nodeId, node.id, nodesRef.current, current.handleType)) return;
const normalized = node.id === current.nodeId ? null : normalizeConnection(current.nodeId, node.id, nodesRef.current, current.handleType);
if (!normalized || canvasConnectionError(config, nodesRef.current, connectionsRef.current, normalized)) return;
const priority = hitsInside ? 0 : hitsHandle ? 1 : 2;
if (priority < bestPriority) {
bestNodeId = node.id;
@@ -231,7 +255,7 @@ export function useCanvasConnectionController({
}
});
return { nodeId: bestNodeId, handleId: bestHandleId, anchorRatio: bestAnchorRatio, isNearNode };
}, [nodesRef, screenToCanvas, scriptScrollTopById, viewportRef]);
}, [config, connectionsRef, nodesRef, screenToCanvas, scriptScrollTopById, viewportRef]);
const finishConnection = useCallback((clientX: number, clientY: number) => {
if (pendingConnectionCreateRef.current) return;
@@ -313,6 +337,7 @@ export function useCanvasConnectionController({
connectionTargetAnchorRatio,
connectingParams,
createConnectedNode,
getConnectionCreateDisabledReason,
handleConnectStart,
mouseWorld,
pendingConnectionCreate,
@@ -3,12 +3,13 @@ import { App } from "antd";
import { buildNodeGenerationContext, hydrateNodeGenerationContext } from "@/components/canvas/canvas-node-generation";
import type { CanvasNodeGenerationMode } from "@/components/canvas/canvas-node-prompt-panel";
import { buildGenerationConfig, isGenerationCanceled, supportsVideoReferenceAudio } from "@/lib/canvas/canvas-project-generation";
import { buildGenerationConfig, isGenerationCanceled } from "@/lib/canvas/canvas-project-generation";
import { isGenerationTaskCapacityError } from "@/lib/canvas/canvas-generation-batch";
import { buildPortraitTexturePrompt } from "@/lib/canvas/canvas-portrait-texture";
import { resolveCanvasStyleExecution } from "@/lib/canvas/canvas-style-execution";
import { expandSkillMentions } from "@/lib/canvas/canvas-skill-mentions";
import { generationErrorMessage, generationFailureMetadata } from "@/lib/generation-error";
import { modelCompatibilityError, modelGroupReferenceLimits, type ModelRequirements } from "@/lib/model-selection";
import { navigateToSettings } from "@/lib/settings-navigation";
import type { Skill } from "@/services/api/skills";
import type { GenerationTask } from "@/services/api/task-center";
@@ -127,14 +128,25 @@ export function useCanvasGenerationExecutor({
// 视频文本只保留输入框内容;连接的媒体仍作为结构化参考传递。
const promptOnly = mode === "video";
try {
const baseContext = buildNodeGenerationContext(nodeId, nodesRef.current, connectionsRef.current, editingTextNode ? `请根据要求修改以下文本。\n\n原文:\n${sourceTextContent}\n\n修改要求:\n${prompt}` : generationPrompt, promptOnly);
const requirements = generationModelRequirements(mode, baseContext, sourceNode, generationConfig.videoSeconds, true);
generationConfig = buildGenerationConfig(effectiveConfig, sourceNode, mode, requirements);
const compatibilityError = modelCompatibilityError(generationConfig, generationConfig.model, requirements);
if (compatibilityError) throw new Error(`当前逻辑模型没有可用的细分模型:${compatibilityError}`);
const referenceLimits = modelGroupReferenceLimits(effectiveConfig, generationConfig.model, mode, requirements);
rawGenerationContext = await hydrateNodeGenerationContext(
buildNodeGenerationContext(nodeId, nodesRef.current, connectionsRef.current, editingTextNode ? `请根据要求修改以下文本。\n\n原文:\n${sourceTextContent}\n\n修改要求:\n${prompt}` : generationPrompt, promptOnly),
baseContext,
projectId,
domainProjectId,
mode,
mode === "video" && supportsVideoReferenceAudio(generationConfig),
mode === "video" && Boolean(referenceLimits?.maxAudios),
!promptOnly,
referenceLimits,
);
const hydratedRequirements = generationModelRequirements(mode, rawGenerationContext, sourceNode, generationConfig.videoSeconds);
generationConfig = buildGenerationConfig(effectiveConfig, sourceNode, mode, hydratedRequirements);
const hydratedCompatibilityError = modelCompatibilityError(generationConfig, generationConfig.model, hydratedRequirements);
if (hydratedCompatibilityError) throw new Error(`当前逻辑模型没有可用的细分模型:${hydratedCompatibilityError}`);
} catch (error) {
const errorDetails = generationErrorMessage(error);
if (isPreparingEmptyImage) {
@@ -258,3 +270,18 @@ export function useCanvasGenerationExecutor({
[addedSkills, bindGenerationTask, domainProjectId, effectiveConfig, finishGenerationRequest, isAiConfigReady, message, nodesRef, connectionsRef, projectId, setConnections, setDialogNodeId, setNodes, setRunningNodeId, setSelectedConnectionId, setSelectedNodeIds, startGenerationRequest],
);
}
function generationModelRequirements(mode: CanvasNodeGenerationMode, input: Pick<Awaited<ReturnType<typeof hydrateNodeGenerationContext>>, "textCount" | "imageCount" | "videoCount" | "audioCount" | "characterReferences">, sourceNode: CanvasNodeData | undefined, videoSeconds: string, includeCharacterMinimum = false): ModelRequirements {
return {
capability: mode,
input: {
textCount: input.textCount,
imageCount: input.imageCount,
videoCount: input.videoCount,
audioCount: input.audioCount,
characterCount: includeCharacterMinimum ? input.characterReferences.length : 0,
},
videoOperation: sourceNode?.metadata?.videoEditOperation,
videoSeconds,
};
}