Fix canvas node models and connections

This commit is contained in:
Codex
2026-05-11 21:47:06 +08:00
parent 693fc7cae1
commit 65b3fe1100
2 changed files with 127 additions and 80 deletions

View File

@@ -850,7 +850,7 @@ function WorkflowTemplateIcon({ icon, className }: { icon: WorkflowTemplateMeta[
export function InfiniteCanvasWorkspace() {
const { user, accessToken } = useAuth();
const { imageKeys } = useCustomApiKeys();
const { imageKeys, textKeys } = useCustomApiKeys();
const managedSystemApis = useManagedSystemApis();
const fileInputRef = useRef<HTMLInputElement>(null);
const importInputRef = useRef<HTMLInputElement>(null);
@@ -919,7 +919,8 @@ export function InfiniteCanvasWorkspace() {
], [state.assets.length, state.connections.length, state.groups.length, state.nodes.length]);
const systemImageApis = managedSystemApis.filter(api => api.type === 'image' && api.isActive);
const modelOptions = useMemo(() => [
const systemTextApis = managedSystemApis.filter(api => api.type === 'text' && api.isActive);
const imageModelOptions = useMemo(() => [
...systemImageApis.map(api => ({
id: buildSystemModelId(api.id),
label: `${api.name} (系统)`,
@@ -933,6 +934,20 @@ export function InfiniteCanvasWorkspace() {
source: 'custom' as const,
})),
], [systemImageApis, imageKeys]);
const textModelOptions = useMemo(() => [
...systemTextApis.map(api => ({
id: buildSystemModelId(api.id),
label: `${api.name} (系统)`,
modelName: api.modelName,
source: 'system' as const,
})),
...textKeys.map(key => ({
id: buildCustomModelId(key.id),
label: `${key.modelName || key.provider} (自定义)`,
modelName: key.modelName,
source: 'custom' as const,
})),
], [systemTextApis, textKeys]);
useEffect(() => {
setMounted(true);
@@ -1644,29 +1659,47 @@ export function InfiniteCanvasWorkspace() {
toast.info('不能连接到同一个模块');
return;
}
let added = false;
let referenceApplied = false;
mutateState(current => {
const source = current.nodes.find(node => node.id === sourceNodeId);
const target = current.nodes.find(node => node.id === targetNodeId);
if (!source || !target) return current;
const exists = current.connections.some(
connection => connection.sourceNodeId === sourceNodeId && connection.targetNodeId === targetNodeId,
);
if (exists) return current;
if (exists) {
referenceApplied = !!getNodeImageUrl(source) && target.type === 'img2img' && target.referenceImage !== getNodeImageUrl(source);
return referenceApplied
? {
...normalizeCanvasProjectState(current),
nodes: current.nodes.map(node => node.id === targetNodeId ? { ...node, referenceImage: getNodeImageUrl(source), updatedAt: nowIso() } : node),
}
: current;
}
const sourceImage = getNodeImageUrl(source);
added = true;
referenceApplied = !!sourceImage && target.type === 'img2img';
const connection: CanvasConnection = {
id: createId('connection'),
sourceNodeId,
targetNodeId,
createdAt: nowIso(),
};
return { ...normalizeCanvasProjectState(current), connections: [...current.connections, connection] };
return {
...normalizeCanvasProjectState(current),
nodes: referenceApplied
? current.nodes.map(node => node.id === targetNodeId ? { ...node, referenceImage: sourceImage, updatedAt: nowIso() } : node)
: current.nodes,
connections: [...current.connections, connection],
};
});
const source = state.nodes.find(node => node.id === sourceNodeId);
const target = state.nodes.find(node => node.id === targetNodeId);
const sourceImage = getNodeImageUrl(source);
if (sourceImage && target?.type === 'img2img') {
updateNode(target.id, { referenceImage: sourceImage });
if (!options?.silent) toast.success('已连接,并将图片设为图生图参考');
} else if (!options?.silent) {
toast.success('已连接模块');
if (!options?.silent) {
if (added && referenceApplied) toast.success('已连接,并将图片设为图生图参考');
else if (added) toast.success('已连接模块');
else toast.info('这两个模块已经连接');
}
}, [mutateState, state.nodes, updateNode]);
}, [mutateState]);
const createVariationNodeFromCanvas = useCallback((sourceNodeId: string) => {
const source = state.nodes.find(node => node.id === sourceNodeId);
@@ -1961,7 +1994,7 @@ export function InfiniteCanvasWorkspace() {
const prompt = incomingPromptText
? [incomingPromptText, basePrompt].filter(Boolean).join('\n\n')
: basePrompt;
const selectedModel = node.params?.model || modelOptions[0]?.id || '';
const selectedModel = node.params?.model || imageModelOptions[0]?.id || '';
const shouldUseReferenceImage = node.type === 'img2img' || !!referenceImage;
const rawAspectRatio = node.params?.aspectRatio;
const aspectRatio = rawAspectRatio && (shouldUseReferenceImage || rawAspectRatio !== 'original')
@@ -2010,7 +2043,7 @@ export function InfiniteCanvasWorkspace() {
}
return payload;
}, [imageKeys, modelOptions, state, systemImageApis]);
}, [imageKeys, imageModelOptions, state, systemImageApis]);
const generateForNode = useCallback(async (node: CanvasNode, options?: { canvasState?: CanvasProjectState; selectOutput?: boolean }) => {
const canvasState = options?.canvasState || state;
@@ -2473,7 +2506,8 @@ export function InfiniteCanvasWorkspace() {
return () => window.removeEventListener('keydown', handleKeyDown);
}, [copySelectedNodes, cutSelectedNodes, duplicateSelectedNode, pasteClipboardNodes, redoCanvas, removeSelectedNode, saveProject, state.nodes, undoCanvas]);
const activeModelValue = selectedNode?.params?.model || modelOptions[0]?.id || '';
const selectedNodeModelOptions = selectedNode?.type === 'text' ? textModelOptions : imageModelOptions;
const activeModelValue = selectedNode?.params?.model || selectedNodeModelOptions[0]?.id || '';
const selectedNodeCanGenerate = isRunnableGenerationNode(selectedNode);
const selectedNodeHasIncomingImage = !!selectedNode && state.connections
.filter(connection => connection.targetNodeId === selectedNode.id)
@@ -2926,7 +2960,8 @@ export function InfiniteCanvasWorkspace() {
<ReactFlowCanvas
nodes={state.nodes}
connections={state.connections}
modelOptions={modelOptions.map(option => ({ id: option.id, label: option.label }))}
imageModelOptions={imageModelOptions.map(option => ({ id: option.id, label: option.label }))}
textModelOptions={textModelOptions.map(option => ({ id: option.id, label: option.label }))}
viewport={state.viewport}
selectedNodeIds={selectedNodeIds}
connectingFromId={connectingFromId}
@@ -3236,6 +3271,18 @@ export function InfiniteCanvasWorkspace() {
</div>
) : null}
{selectedNode.type === 'text' ? (
<div>
<Label></Label>
<Select value={activeModelValue} onValueChange={(value) => updateNode(selectedNode.id, { params: { ...selectedNode.params, model: value } })}>
<SelectTrigger><SelectValue placeholder="选择模型" /></SelectTrigger>
<SelectContent>
{textModelOptions.map(option => <SelectItem key={option.id} value={option.id}>{option.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
) : null}
{selectedNodeCanGenerate ? (
<>
<div>
@@ -3243,7 +3290,7 @@ export function InfiniteCanvasWorkspace() {
<Select value={activeModelValue} onValueChange={(value) => updateNode(selectedNode.id, { params: { ...selectedNode.params, model: value } })}>
<SelectTrigger><SelectValue placeholder="选择模型" /></SelectTrigger>
<SelectContent>
{modelOptions.map(option => <SelectItem key={option.id} value={option.id}>{option.label}</SelectItem>)}
{selectedNodeModelOptions.map(option => <SelectItem key={option.id} value={option.id}>{option.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
@@ -3295,12 +3342,12 @@ export function InfiniteCanvasWorkspace() {
</div>
) : null}
<Button className="w-full gap-2" onClick={() => void generateForNode(selectedNode)} disabled={selectedNode.status === 'generating' || modelOptions.length === 0}>
<Button className="w-full gap-2" onClick={() => void generateForNode(selectedNode)} disabled={selectedNode.status === 'generating' || imageModelOptions.length === 0}>
{selectedNode.status === 'generating' ? <Loader2 className="h-4 w-4 animate-spin" /> : <Sparkles className="h-4 w-4" />}
</Button>
{state.connections.some(connection => connection.sourceNodeId === selectedNode.id) ? (
<Button variant="outline" className="w-full gap-2" onClick={() => void runDownstream(selectedNode)} disabled={selectedNode.status === 'generating' || modelOptions.length === 0}>
<Button variant="outline" className="w-full gap-2" onClick={() => void runDownstream(selectedNode)} disabled={selectedNode.status === 'generating' || imageModelOptions.length === 0}>
<Wand2 className="h-4 w-4" />
</Button>
) : null}

View File

@@ -37,7 +37,8 @@ type CanvasFlowNodeData = {
connecting: boolean;
connections: CanvasConnection[];
allNodes: CanvasNode[];
modelOptions: CanvasModelOption[];
imageModelOptions: CanvasModelOption[];
textModelOptions: CanvasModelOption[];
layerCanvasSize: number;
layerColors: Record<CanvasLayer['type'], string>;
onSelect: (id: string, additive: boolean) => void;
@@ -56,7 +57,8 @@ type CanvasFlowNode = FlowNode<CanvasFlowNodeData, 'canvasNode'>;
type ReactFlowCanvasProps = {
nodes: CanvasNode[];
connections: CanvasConnection[];
modelOptions: CanvasModelOption[];
imageModelOptions: CanvasModelOption[];
textModelOptions: CanvasModelOption[];
viewport: CanvasViewport;
selectedNodeIds: string[];
connectingFromId: string | null;
@@ -190,12 +192,12 @@ function NodeCountControl({
function NodeInlineParams({
node,
hasReference,
modelOptions,
imageModelOptions,
onUpdateNode,
}: {
node: CanvasNode;
hasReference: boolean;
modelOptions: CanvasModelOption[];
imageModelOptions: CanvasModelOption[];
onUpdateNode: (id: string, patch: Partial<CanvasNode>) => void;
}) {
const aspectOptions = node.type === 'text2img' || (node.type === 'image' && !hasReference)
@@ -207,18 +209,18 @@ function NodeInlineParams({
const resolution = node.params?.resolution && RESOLUTION_OPTIONS.includes(node.params.resolution)
? node.params.resolution
: '2K';
const modelValue = node.params?.model || modelOptions[0]?.id || '';
const modelValue = node.params?.model || imageModelOptions[0]?.id || '';
return (
<div className="nodrag mt-2 grid grid-cols-[minmax(0,1.2fr)_72px_72px] gap-2">
<select
className="h-7 min-w-0 rounded-md border bg-muted/30 px-2 text-[11px] text-foreground outline-none transition-colors focus:border-primary focus:bg-background disabled:cursor-not-allowed disabled:opacity-60"
value={modelValue}
title="生成模型"
disabled={modelOptions.length === 0}
disabled={imageModelOptions.length === 0}
onPointerDown={(event) => event.stopPropagation()}
onChange={(event) => onUpdateNode(node.id, { params: { ...node.params, model: event.target.value } })}
>
{modelOptions.length > 0 ? modelOptions.map(option => (
{imageModelOptions.length > 0 ? imageModelOptions.map(option => (
<option key={option.id} value={option.id}>{option.label}</option>
)) : <option value=""></option>}
</select>
@@ -249,11 +251,10 @@ function NodeInlineParams({
}
function CanvasNodeCard({ data }: NodeProps<CanvasFlowNode>) {
const { node, selected, connecting, connections, allNodes, modelOptions, layerCanvasSize, layerColors, onSelect, onStartConnect, onUpdateNode, onRunNode, onRunDownstreamNode, onCreateVariationNode, onPreviewNode, onDownloadNode, onRemoveConnection } = data;
const { node, selected, connecting, connections, allNodes, imageModelOptions, textModelOptions, layerCanvasSize, layerColors, onSelect, onStartConnect, onUpdateNode, onRunNode, onRunDownstreamNode, onCreateVariationNode, onPreviewNode, onDownloadNode } = data;
const incomingNodes = allNodes.filter(item => connections.some(connection => connection.targetNodeId === node.id && connection.sourceNodeId === item.id));
const incomingImage = incomingNodes.find(item => !!getNodeImageUrl(item));
const incomingText = incomingNodes.map(getNodeTextValue).filter(Boolean).join('\n');
const nodeConnections = connections.filter(connection => connection.sourceNodeId === node.id || connection.targetNodeId === node.id);
const hasOutgoing = connections.some(connection => connection.sourceNodeId === node.id);
const meta = NODE_TYPE_META[node.type];
const [editingText, setEditingText] = useState(false);
@@ -362,35 +363,49 @@ function CanvasNodeCard({ data }: NodeProps<CanvasFlowNode>) {
<div className="h-[calc(100%-2.875rem-0.375rem)] p-3">
{node.type === 'text' ? (
editingText ? (
<textarea
className="nodrag nowheel h-full w-full resize-none rounded-md border border-primary/40 bg-background p-3 text-sm leading-6 outline-none ring-2 ring-primary/20"
value={node.text || ''}
autoFocus
<div className="flex h-full flex-col gap-2">
{editingText ? (
<textarea
className="nodrag nowheel min-h-0 flex-1 resize-none rounded-md border border-primary/40 bg-background p-3 text-sm leading-6 outline-none ring-2 ring-primary/20"
value={node.text || ''}
autoFocus
onPointerDown={(event) => event.stopPropagation()}
onDoubleClick={(event) => event.stopPropagation()}
onChange={(event) => onUpdateNode(node.id, { text: event.target.value })}
onBlur={() => setEditingText(false)}
onKeyDown={(event) => {
if (event.key === 'Escape') {
event.preventDefault();
setEditingText(false);
}
}}
/>
) : (
<button
type="button"
className="nodrag min-h-0 flex-1 whitespace-pre-wrap rounded-md bg-muted/30 p-3 text-left text-sm leading-6 text-foreground transition-colors hover:bg-muted/50"
onPointerDown={(event) => event.stopPropagation()}
onDoubleClick={(event) => {
event.stopPropagation();
setEditingText(true);
}}
>
{node.text || '双击输入文本'}
</button>
)}
<select
className="nodrag h-7 shrink-0 rounded-md border bg-muted/30 px-2 text-[11px] text-foreground outline-none transition-colors focus:border-primary focus:bg-background disabled:cursor-not-allowed disabled:opacity-60"
value={node.params?.model || textModelOptions[0]?.id || ''}
title="多模态模型"
disabled={textModelOptions.length === 0}
onPointerDown={(event) => event.stopPropagation()}
onDoubleClick={(event) => event.stopPropagation()}
onChange={(event) => onUpdateNode(node.id, { text: event.target.value })}
onBlur={() => setEditingText(false)}
onKeyDown={(event) => {
if (event.key === 'Escape') {
event.preventDefault();
setEditingText(false);
}
}}
/>
) : (
<button
type="button"
className="nodrag h-full w-full whitespace-pre-wrap rounded-md bg-muted/30 p-3 text-left text-sm leading-6 text-foreground transition-colors hover:bg-muted/50"
onPointerDown={(event) => event.stopPropagation()}
onDoubleClick={(event) => {
event.stopPropagation();
setEditingText(true);
}}
onChange={(event) => onUpdateNode(node.id, { params: { ...node.params, model: event.target.value } })}
>
{node.text || '双击输入文本'}
</button>
)
{textModelOptions.length > 0 ? textModelOptions.map(option => (
<option key={option.id} value={option.id}>{option.label}</option>
)) : <option value=""></option>}
</select>
</div>
) : null}
{node.type === 'image' ? (
@@ -462,7 +477,7 @@ function CanvasNodeCard({ data }: NodeProps<CanvasFlowNode>) {
</button>
</div>
<NodeInlineParams node={node} hasReference={hasReferenceImage} modelOptions={modelOptions} onUpdateNode={onUpdateNode} />
<NodeInlineParams node={node} hasReference={hasReferenceImage} imageModelOptions={imageModelOptions} onUpdateNode={onUpdateNode} />
<div className="mt-2">
{editingNegativePrompt ? (
<textarea
@@ -525,7 +540,7 @@ function CanvasNodeCard({ data }: NodeProps<CanvasFlowNode>) {
</button>
</div>
<NodeInlineParams node={node} hasReference={hasReferenceImage} modelOptions={modelOptions} onUpdateNode={onUpdateNode} />
<NodeInlineParams node={node} hasReference={hasReferenceImage} imageModelOptions={imageModelOptions} onUpdateNode={onUpdateNode} />
<div className="mt-2">
{editingNegativePrompt ? (
<textarea
@@ -651,24 +666,6 @@ function CanvasNodeCard({ data }: NodeProps<CanvasFlowNode>) {
) : null}
</div>
</div>
{selected && nodeConnections.length > 0 ? (
<div className="nodrag absolute left-3 top-full z-30 mt-2 w-64 rounded-lg border bg-background/95 p-2 text-xs shadow-lg backdrop-blur">
<div className="mb-1 font-medium text-muted-foreground">线</div>
<div className="space-y-1">
{nodeConnections.slice(0, 4).map(connection => {
const source = allNodes.find(item => item.id === connection.sourceNodeId);
const target = allNodes.find(item => item.id === connection.targetNodeId);
return (
<div key={connection.id} className="flex items-center justify-between gap-2 rounded-md border px-2 py-1">
<span className="min-w-0 truncate">{source?.title || '未知模块'} {target?.title || '未知模块'}</span>
<button className="text-destructive hover:underline" onClick={() => onRemoveConnection(connection.id)}></button>
</div>
);
})}
</div>
</div>
) : null}
</div>
);
}
@@ -700,7 +697,8 @@ function sameFlowNodes(a: CanvasFlowNode[], b: CanvasFlowNode[]) {
&& node.data.connecting === next.data.connecting
&& node.data.connections === next.data.connections
&& node.data.allNodes === next.data.allNodes
&& node.data.modelOptions === next.data.modelOptions
&& node.data.imageModelOptions === next.data.imageModelOptions
&& node.data.textModelOptions === next.data.textModelOptions
&& node.data.onRunNode === next.data.onRunNode
&& node.data.onRunDownstreamNode === next.data.onRunDownstreamNode
&& node.data.onCreateVariationNode === next.data.onCreateVariationNode
@@ -720,7 +718,8 @@ function clampViewport(viewport: FlowViewport, minZoom: number, maxZoom: number)
function FlowCanvasInner({
nodes,
connections,
modelOptions,
imageModelOptions,
textModelOptions,
viewport,
selectedNodeIds,
connectingFromId,
@@ -773,7 +772,8 @@ function FlowCanvasInner({
connecting: connectingFromId === node.id,
connections,
allNodes: nodes,
modelOptions,
imageModelOptions,
textModelOptions,
layerCanvasSize,
layerColors,
onSelect: onSelectNode,
@@ -790,7 +790,7 @@ function FlowCanvasInner({
width: node.width,
height: node.height,
},
})), [connectingFromId, connections, editable, layerCanvasSize, layerColors, modelOptions, nodes, onCreateVariationNode, onDownloadNode, onPreviewNode, onRemoveConnection, onRunDownstreamNode, onRunNode, onSelectNode, onStartConnect, onUpdateNode, selectedNodeIds]);
})), [connectingFromId, connections, editable, imageModelOptions, layerCanvasSize, layerColors, nodes, onCreateVariationNode, onDownloadNode, onPreviewNode, onRemoveConnection, onRunDownstreamNode, onRunNode, onSelectNode, onStartConnect, onUpdateNode, selectedNodeIds, textModelOptions]);
const [flowNodes, setFlowNodes] = useState<CanvasFlowNode[]>(incomingNodes);