Files
ttstd-infinite-canvas/web/src/lib/canvas/canvas-batch-connection.ts
WindC0X 0e251c3c8f feat(canvas): 画布媒体工作流 - 支持批量连线、失败重试与模型能力增强
合并 PR #230。保留当前主线样式与组件结构,吸收批量连线、失败批次重试、媒体模型能力和相关后端增强,并修复集成检查发现的分辨率选择器启用条件与测试稳定性问题。
2026-08-15 15:48:16 +08:00

140 lines
5.5 KiB
TypeScript

import { nanoid } from "nanoid";
import { canvasConnectionError } from "@/lib/canvas/canvas-connection-policy";
import { normalizeConnection } from "@/lib/canvas/canvas-project-domain";
import type { AiConfig } from "@/stores/use-config-store";
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData, type ConnectionHandle, type Position } from "@/types/canvas";
export type CanvasBatchConnectionPreview = {
sourceNodeIds: string[];
targetNodeId: string | null;
targetHandleId?: string;
targetAnchorRatio?: number;
mouseWorld: Position;
status: "idle" | "valid" | "partial" | "invalid";
};
export type BatchConnectionPlanOptions = {
sourceNodeIds: string[];
targetNodeId: string;
targetHandleId?: string;
targetAnchorRatio?: number;
nodes: CanvasNodeData[];
connections: CanvasConnection[];
config: AiConfig;
// 创建聚合节点时保留完整图结构;生成提交阶段再按模型能力截取参考素材。
allowCapacityOverflow?: boolean;
};
export type BatchConnectionSkip = {
nodeId: string;
reason: string;
};
export type BatchConnectionPlan = {
connected: string[];
duplicates: string[];
skipped: BatchConnectionSkip[];
connections: CanvasConnection[];
};
export type BatchConnectionCreateRequest = {
batchSourceNodeIds: string[];
connection: ConnectionHandle;
position: Position;
};
export function buildBatchConnectionCreateRequest(sourceNodeIds: string[], nodes: CanvasNodeData[], position: Position): BatchConnectionCreateRequest | null {
const eligibleSourceNodeIds = Array.from(new Set(sourceNodeIds)).filter((nodeId) => {
const node = nodes.find((item) => item.id === nodeId);
return Boolean(node && !batchSourceRestriction(node));
});
const firstSourceNodeId = eligibleSourceNodeIds[0];
if (!firstSourceNodeId) return null;
return {
batchSourceNodeIds: eligibleSourceNodeIds,
connection: { nodeId: firstSourceNodeId, handleType: "source" },
position,
};
}
export function hasBatchConnectionCandidate(sourceNodeIds: string[], targetNodeId: string, nodes: CanvasNodeData[]) {
return sourceNodeIds.some((sourceNodeId) => {
const source = nodes.find((node) => node.id === sourceNodeId);
return Boolean(source && !batchSourceRestriction(source) && normalizeConnection(source.id, targetNodeId, nodes, "source"));
});
}
export function planBatchConnections({ sourceNodeIds, targetNodeId, targetHandleId, targetAnchorRatio, nodes, connections, config, allowCapacityOverflow = false }: BatchConnectionPlanOptions): BatchConnectionPlan {
const connected: string[] = [];
const duplicates: string[] = [];
const skipped: BatchConnectionSkip[] = [];
const nextConnections: CanvasConnection[] = [];
const workingConnections = [...connections];
const seenSourceIds = new Set<string>();
sourceNodeIds.forEach((sourceNodeId) => {
if (seenSourceIds.has(sourceNodeId)) return;
seenSourceIds.add(sourceNodeId);
const source = nodes.find((node) => node.id === sourceNodeId);
if (!source) {
skipped.push({ nodeId: sourceNodeId, reason: "找不到连接源节点" });
return;
}
const sourceRestriction = batchSourceRestriction(source);
if (sourceRestriction) {
skipped.push({ nodeId: source.id, reason: sourceRestriction });
return;
}
if (source.id === targetNodeId) {
skipped.push({ nodeId: source.id, reason: "目标节点不能连接到自身" });
return;
}
const normalized = normalizeConnection(source.id, targetNodeId, nodes, "source");
if (!normalized) {
skipped.push({ nodeId: source.id, reason: "节点之间不能连接" });
return;
}
const fromHandleId = normalized.fromNodeId === source.id ? undefined : targetHandleId;
const toHandleId = normalized.toNodeId === targetNodeId ? targetHandleId : undefined;
const duplicate = workingConnections.some((connection) => connection.fromNodeId === normalized.fromNodeId
&& connection.toNodeId === normalized.toNodeId
&& connection.fromHandleId === fromHandleId
&& connection.toHandleId === toHandleId);
if (duplicate) {
duplicates.push(source.id);
return;
}
const policyError = canvasConnectionError(config, nodes, workingConnections, normalized, { ignoreCapacity: allowCapacityOverflow });
if (policyError) {
skipped.push({ nodeId: source.id, reason: policyError });
return;
}
const connection: CanvasConnection = {
id: nanoid(),
...normalized,
fromHandleId,
toHandleId,
fromAnchorRatio: normalized.fromNodeId === source.id ? 0.5 : targetAnchorRatio,
toAnchorRatio: normalized.toNodeId === targetNodeId ? targetAnchorRatio : 0.5,
};
workingConnections.push(connection);
nextConnections.push(connection);
connected.push(source.id);
});
return { connected, duplicates, skipped, connections: nextConnections };
}
export function batchSourceRestriction(node: CanvasNodeData) {
if (node.type === CanvasNodeType.Frame) return "背板不能作为连接源";
if (node.type === CanvasNodeType.Script) return "分镜脚本需要按行连接";
if (node.type === CanvasNodeType.Config) return "生成配置不能作为聚合连接源";
return "";
}