Browse Source

feat: add array batch mode for sequential generation from all items

When batch mode is enabled on an array node, all parsed items are sent
through a single connection to a downstream generate node, which loops
through them sequentially. This eliminates the need to create separate
downstream nodes for each array item.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
handoff-20260429-1057
shrimbly 3 months ago
parent
commit
cd6dada4d6
  1. 4
      src/components/__tests__/SplitGridNode.test.tsx
  2. 57
      src/components/nodes/ArrayNode.tsx
  3. 18
      src/store/utils/connectedInputs.ts
  4. 1
      src/store/utils/nodeDefaults.ts
  5. 83
      src/store/workflowStore.ts
  6. 1
      src/types/nodes.ts

4
src/components/__tests__/SplitGridNode.test.tsx

@ -52,7 +52,7 @@ describe("SplitGridNode", () => {
groups: {},
nodes: [],
edges: [],
getConnectedInputs: vi.fn(() => ({ images: [], videos: [], audio: [], model3d: null, text: null, dynamicInputs: {}, easeCurve: null })),
getConnectedInputs: vi.fn(() => ({ images: [], videos: [], audio: [], model3d: null, text: null, textItems: [], dynamicInputs: {}, easeCurve: null })),
getNodesWithComments: vi.fn(() => []),
markCommentViewed: vi.fn(),
setNavigationTarget: vi.fn(),
@ -276,7 +276,7 @@ describe("SplitGridNode", () => {
groups: {},
nodes: [],
edges: [],
getConnectedInputs: vi.fn(() => ({ images: [], videos: [], audio: [], model3d: null, text: null, dynamicInputs: {}, easeCurve: null })),
getConnectedInputs: vi.fn(() => ({ images: [], videos: [], audio: [], model3d: null, text: null, textItems: [], dynamicInputs: {}, easeCurve: null })),
getNodesWithComments: vi.fn(() => []),
markCommentViewed: vi.fn(),
setNavigationTarget: vi.fn(),

57
src/components/nodes/ArrayNode.tsx

@ -226,21 +226,40 @@ export function ArrayNode({ id, data, selected }: NodeProps<ArrayNodeType>) {
{/* Single text output point (each outgoing edge receives a separate item) */}
<Handle type="source" position={Position.Right} id="text" data-handletype="text" style={{ top: 48 }} />
{/* Auto-route button - floating absolute positioned */}
<button
type="button"
onClick={handleAutoRouteToPrompts}
disabled={previewItems.length === 0}
className="nodrag nopan absolute top-2 right-2 z-10 shrink-0 p-1 bg-[#1a1a1a] rounded-md text-neutral-400 hover:text-neutral-100 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
title="Auto-route to Prompts"
>
<svg className="w-3.5 h-3.5 rotate-90" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<path d="M16 3h5v5" />
<path d="M8 3H3v5" />
<path d="M12 22v-8.3a4 4 0 0 0-1.172-2.872L3 3" />
<path d="m15 9 6-6" />
</svg>
</button>
{/* Header buttons - floating absolute positioned */}
<div className="absolute top-2 right-2 z-10 flex items-center gap-1">
{/* Batch mode toggle */}
<button
type="button"
onClick={() => updateNodeData(id, { batchMode: !nodeData.batchMode })}
className={`nodrag nopan shrink-0 px-1.5 py-0.5 rounded-md text-[10px] font-medium transition-colors ${
nodeData.batchMode
? "bg-blue-600/80 text-blue-100"
: "bg-[#1a1a1a] text-neutral-500 hover:text-neutral-300"
}`}
title={nodeData.batchMode ? "Batch mode: all items sent to one downstream node" : "Enable batch mode"}
>
Batch
</button>
{/* Auto-route button (hidden in batch mode) */}
{!nodeData.batchMode && (
<button
type="button"
onClick={handleAutoRouteToPrompts}
disabled={previewItems.length === 0}
className="nodrag nopan shrink-0 p-1 bg-[#1a1a1a] rounded-md text-neutral-400 hover:text-neutral-100 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
title="Auto-route to Prompts"
>
<svg className="w-3.5 h-3.5 rotate-90" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<path d="M16 3h5v5" />
<path d="M8 3H3v5" />
<path d="M12 22v-8.3a4 4 0 0 0-1.172-2.872L3 3" />
<path d="m15 9 6-6" />
</svg>
</button>
)}
</div>
<div className="flex flex-col gap-2 pt-3 flex-1 min-h-0">
<div className="flex items-center gap-2 max-w-[75%]">
@ -352,9 +371,11 @@ export function ArrayNode({ id, data, selected }: NodeProps<ArrayNodeType>) {
)}
</div>
<div className="text-[10px] text-neutral-500">
{nodeData.selectedOutputIndex !== null
? `Next wire uses item ${nodeData.selectedOutputIndex + 1}`
: "No selection: wires advance in order from item 1"}
{nodeData.batchMode
? "Batch: all items sent to downstream node"
: nodeData.selectedOutputIndex !== null
? `Next wire uses item ${nodeData.selectedOutputIndex + 1}`
: "No selection: wires advance in order from item 1"}
</div>
</div>
</BaseNode>

18
src/store/utils/connectedInputs.ts

@ -39,6 +39,7 @@ export interface ConnectedInputs {
audio: string[];
model3d: string | null;
text: string | null;
textItems: string[]; // All items from array batch mode (empty when not in batch)
dynamicInputs: Record<string, string | string[]>;
easeCurve: { bezierHandles: [number, number, number, number]; easingPreset: string | null; outputDuration: number } | null;
}
@ -169,13 +170,14 @@ export function getConnectedInputsPure(
dimmedNodeIds?: Set<string>
): ConnectedInputs {
const _visited = visited || new Set<string>();
if (_visited.has(nodeId)) return { images: [], videos: [], audio: [], model3d: null, text: null, dynamicInputs: {}, easeCurve: null };
if (_visited.has(nodeId)) return { images: [], videos: [], audio: [], model3d: null, text: null, textItems: [], dynamicInputs: {}, easeCurve: null };
_visited.add(nodeId);
const images: string[] = [];
const videos: string[] = [];
const audio: string[] = [];
let model3d: string | null = null;
let text: string | null = null;
const textItems: string[] = [];
const dynamicInputs: Record<string, string | string[]> = {};
let easeCurve: ConnectedInputs["easeCurve"] = null;
@ -226,6 +228,18 @@ export function getConnectedInputsPure(
// Skip dimmed source nodes — their data should not flow downstream
if (dimmedNodeIds && dimmedNodeIds.has(sourceNode.id)) return;
// Array batch mode — send all items as textItems instead of a single item
if (sourceNode.type === "array" && (edge.data as Record<string, unknown> | undefined)?.arrayBatchAll === true) {
const arrayData = sourceNode.data as ArrayNodeData;
const items = arrayData.outputItems;
if (items.length > 0) {
textItems.push(...items);
// Set text to first item for backward compatibility
if (text === null) text = items[0];
}
return; // Skip normal getSourceOutput processing
}
// Router passthrough — traverse upstream to find actual data source
if (sourceNode.type === "router") {
const routerInputs = passthroughCache.get(sourceNode.id)
@ -369,7 +383,7 @@ export function getConnectedInputsPure(
}
}
return { images, videos, audio, model3d, text, dynamicInputs, easeCurve };
return { images, videos, audio, model3d, text, textItems, dynamicInputs, easeCurve };
}
/**

1
src/store/utils/nodeDefaults.ts

@ -125,6 +125,7 @@ export const createDefaultNodeData = (type: NodeType): WorkflowNodeData => {
regexPattern: "",
trimItems: true,
removeEmpty: true,
batchMode: false,
selectedOutputIndex: null,
outputItems: [],
outputText: "[]",

83
src/store/workflowStore.ts

@ -60,7 +60,7 @@ import {
chunk,
clearNodeImageRefs,
} from "./utils/executionUtils";
import { getConnectedInputsPure, validateWorkflowPure } from "./utils/connectedInputs";
import { getConnectedInputsPure, validateWorkflowPure, type ConnectedInputs } from "./utils/connectedInputs";
import { evaluateRule } from "./utils/ruleEvaluation";
import { computeDimmedNodes } from "./utils/dimmingUtils";
import {
@ -96,7 +96,7 @@ export { CONCURRENCY_SETTINGS_KEY } from "./utils/executionUtils";
async function evaluateAndExecuteConditionalSwitch(
node: WorkflowNode,
executionCtx: NodeExecutionContext,
getConnectedInputs: (nodeId: string) => { text: string | null; images: string[]; videos: string[]; audio: string[]; model3d: string | null; dynamicInputs: Record<string, string | string[]>; easeCurve: { bezierHandles: [number, number, number, number]; easingPreset: string | null; outputDuration: number } | null },
getConnectedInputs: (nodeId: string) => ConnectedInputs,
updateNodeData: (nodeId: string, data: Partial<WorkflowNodeData>) => void,
): Promise<void> {
const condInputs = getConnectedInputs(node.id);
@ -144,6 +144,13 @@ function buildConnectionEdgeData(
// Array node uses a single output handle; assign each edge a stable item index.
if (sourceNode?.type === "array" && (connection.sourceHandle || "text") === "text") {
const sourceData = sourceNode.data as Record<string, unknown>;
// Batch mode: all items sent through a single connection
if (sourceData.batchMode === true) {
baseData.arrayBatchAll = true;
return baseData;
}
const selectedIndex = sourceData.selectedOutputIndex;
const outputItems = Array.isArray(sourceData.outputItems) ? sourceData.outputItems : [];
const outputCount = outputItems.length;
@ -276,7 +283,7 @@ interface WorkflowStore {
// Helpers
getNodeById: (id: string) => WorkflowNode | undefined;
getConnectedInputs: (nodeId: string) => { images: string[]; videos: string[]; audio: string[]; model3d: string | null; text: string | null; dynamicInputs: Record<string, string | string[]>; easeCurve: { bezierHandles: [number, number, number, number]; easingPreset: string | null; outputDuration: number } | null };
getConnectedInputs: (nodeId: string) => ConnectedInputs;
validateWorkflow: () => { valid: boolean; errors: string[] };
// Global Image History
@ -1314,6 +1321,76 @@ const workflowStoreImpl: StateCreator<WorkflowStore> = (set, get) => ({
const executionCtx = get()._buildExecutionContext(node, signal);
// Batch mode: for generate-type nodes, detect textItems and loop through them
const batchNodeTypes = new Set(["nanoBanana", "generateVideo", "generateAudio", "llmGenerate"]);
if (node.type && batchNodeTypes.has(node.type)) {
const connectedInputs = get().getConnectedInputs(node.id);
if (connectedInputs.textItems.length > 0) {
const items = connectedInputs.textItems;
const totalItems = items.length;
for (let i = 0; i < totalItems; i++) {
// Check abort signal before each iteration
if (signal.aborted) {
throw new DOMException('Aborted', 'AbortError');
}
// Update status with batch progress
get().updateNodeData(node.id, {
status: "loading",
error: null,
} as Partial<WorkflowNodeData>);
logger.info('node.execution', `Batch ${i + 1} of ${totalItems}`, {
nodeId: node.id,
nodeType: node.type,
batchIndex: i,
batchTotal: totalItems,
});
// Create a wrapped context where getConnectedInputs returns
// the current item as `text` and clears `textItems`
const batchCtx: NodeExecutionContext = {
...executionCtx,
getConnectedInputs: (nodeId: string) => {
const inputs = get().getConnectedInputs(nodeId);
return {
...inputs,
text: items[i],
textItems: [], // Clear so executors don't see batch
};
},
};
// Execute the appropriate executor
switch (node.type) {
case "nanoBanana":
await executeNanoBanana(batchCtx);
break;
case "generateVideo":
await executeGenerateVideo(batchCtx);
break;
case "generateAudio":
await executeGenerateAudio(batchCtx);
break;
case "llmGenerate":
await executeLlmGenerate(batchCtx);
break;
}
// After each iteration (except last), reset status to loading for next iteration
if (i < totalItems - 1) {
get().updateNodeData(node.id, {
status: "loading",
} as Partial<WorkflowNodeData>);
}
}
// Batch complete — skip the normal switch below
return;
}
}
switch (node.type) {
case "imageInput":
// Data source node - no execution needed

1
src/types/nodes.ts

@ -110,6 +110,7 @@ export interface ArrayNodeData extends BaseNodeData {
regexPattern: string;
trimItems: boolean;
removeEmpty: boolean;
batchMode: boolean; // When true, all items are sent as a batch to downstream generate nodes
selectedOutputIndex: number | null;
outputItems: string[];
outputText: string | null; // JSON array string for the primary text output

Loading…
Cancel
Save