From fbe43b53a45b61ecfc39388ea36708bc43651f7c Mon Sep 17 00:00:00 2001 From: Leon Date: Thu, 12 Mar 2026 20:23:25 +1100 Subject: [PATCH 01/18] feat: Iterator container node with child node management, expose params, and portal overlays --- electron/workflow/db/edge.repo.ts | 13 +- electron/workflow/db/node.repo.ts | 36 +- electron/workflow/db/schema.ts | 25 + electron/workflow/db/workflow.repo.ts | 7 +- electron/workflow/engine/dag-utils.ts | 89 +- electron/workflow/engine/executor.ts | 49 +- electron/workflow/nodes/control/iterator.ts | 296 +++++++ electron/workflow/nodes/register-all.ts | 2 + src/workflow/WorkflowPage.tsx | 11 + .../components/canvas/NodePalette.tsx | 74 +- .../components/canvas/WorkflowCanvas.tsx | 128 ++- .../canvas/custom-node/CustomNode.tsx | 12 +- .../canvas/custom-node/IteratorExposeBar.tsx | 237 +++++ .../iterator-node/IteratorNodeContainer.tsx | 809 ++++++++++++++++++ .../components/panels/NodeConfigPanel.tsx | 384 ++++++++- src/workflow/hooks/useIteratorAdoption.ts | 208 +++++ src/workflow/lib/cycle-detection.ts | 51 ++ src/workflow/stores/ui.store.ts | 7 + src/workflow/stores/workflow.store.ts | 417 ++++++++- src/workflow/types/index.ts | 1 + src/workflow/types/workflow.ts | 13 + 21 files changed, 2813 insertions(+), 56 deletions(-) create mode 100644 electron/workflow/nodes/control/iterator.ts create mode 100644 src/workflow/components/canvas/custom-node/IteratorExposeBar.tsx create mode 100644 src/workflow/components/canvas/iterator-node/IteratorNodeContainer.tsx create mode 100644 src/workflow/hooks/useIteratorAdoption.ts create mode 100644 src/workflow/lib/cycle-detection.ts diff --git a/electron/workflow/db/edge.repo.ts b/electron/workflow/db/edge.repo.ts index 3363ea5b..fe47f0bd 100644 --- a/electron/workflow/db/edge.repo.ts +++ b/electron/workflow/db/edge.repo.ts @@ -12,11 +12,12 @@ function rowToEdge(row: unknown[]): WorkflowEdge { sourceOutputKey: row[3] as string, targetNodeId: row[4] as string, targetInputKey: row[5] as string, + isInternal: row[6] === 1, }; } const EDGE_COLS = - "id, workflow_id, source_node_id, source_output_key, target_node_id, target_input_key"; + "id, workflow_id, source_node_id, source_output_key, target_node_id, target_input_key, is_internal"; export function getEdgesByWorkflowId(workflowId: string): WorkflowEdge[] { const db = getDatabase(); @@ -43,3 +44,13 @@ export function deleteEdge(edgeId: string): void { db.run("DELETE FROM edges WHERE id = ?", [edgeId]); persistDatabase(); } +export function getInternalEdges(workflowId: string): WorkflowEdge[] { + const db = getDatabase(); + const result = db.exec( + `SELECT ${EDGE_COLS} FROM edges WHERE workflow_id = ? AND is_internal = 1`, + [workflowId], + ); + if (!result.length) return []; + return result[0].values.map(rowToEdge); +} + diff --git a/electron/workflow/db/node.repo.ts b/electron/workflow/db/node.repo.ts index e46d8a9f..5d27567f 100644 --- a/electron/workflow/db/node.repo.ts +++ b/electron/workflow/db/node.repo.ts @@ -4,21 +4,39 @@ import { getDatabase, persistDatabase } from "./connection"; import type { WorkflowNode } from "../../../src/workflow/types/workflow"; -export function getNodesByWorkflowId(workflowId: string): WorkflowNode[] { - const db = getDatabase(); - const result = db.exec( - "SELECT id, workflow_id, node_type, position_x, position_y, params, current_output_id FROM nodes WHERE workflow_id = ?", - [workflowId], - ); - if (!result.length) return []; - return result[0].values.map((row) => ({ +const NODE_COLS = + "id, workflow_id, node_type, position_x, position_y, params, current_output_id, parent_node_id"; + +function rowToNode(row: unknown[]): WorkflowNode { + return { id: row[0] as string, workflowId: row[1] as string, nodeType: row[2] as string, position: { x: row[3] as number, y: row[4] as number }, params: JSON.parse(row[5] as string), currentOutputId: row[6] as string | null, - })); + parentNodeId: (row[7] as string | null) ?? null, + }; +} + +export function getNodesByWorkflowId(workflowId: string): WorkflowNode[] { + const db = getDatabase(); + const result = db.exec( + `SELECT ${NODE_COLS} FROM nodes WHERE workflow_id = ?`, + [workflowId], + ); + if (!result.length) return []; + return result[0].values.map(rowToNode); +} + +export function getChildNodes(parentNodeId: string): WorkflowNode[] { + const db = getDatabase(); + const result = db.exec( + `SELECT ${NODE_COLS} FROM nodes WHERE parent_node_id = ?`, + [parentNodeId], + ); + if (!result.length) return []; + return result[0].values.map(rowToNode); } export function updateNodeParams( diff --git a/electron/workflow/db/schema.ts b/electron/workflow/db/schema.ts index 7e190fa2..70136d2f 100644 --- a/electron/workflow/db/schema.ts +++ b/electron/workflow/db/schema.ts @@ -197,6 +197,31 @@ export function runMigrations(db: SqlJsDatabase): void { db.run("INSERT INTO schema_version (version) VALUES (2)"); }, }, + // Migration 3: Add Iterator Node support + { + version: 3, + apply: (db: SqlJsDatabase) => { + console.log( + "[Schema] Applying migration 3: Add Iterator Node support", + ); + + db.run( + "ALTER TABLE nodes ADD COLUMN parent_node_id TEXT REFERENCES nodes(id) ON DELETE SET NULL", + ); + db.run( + "ALTER TABLE edges ADD COLUMN is_internal INTEGER NOT NULL DEFAULT 0 CHECK (is_internal IN (0, 1))", + ); + + db.run( + "CREATE INDEX IF NOT EXISTS idx_nodes_parent ON nodes(parent_node_id)", + ); + db.run( + "CREATE INDEX IF NOT EXISTS idx_edges_internal ON edges(is_internal)", + ); + + db.run("INSERT INTO schema_version (version) VALUES (3)"); + }, + }, ]; for (const m of migrations) { diff --git a/electron/workflow/db/workflow.repo.ts b/electron/workflow/db/workflow.repo.ts index c52ba7c8..784add2c 100644 --- a/electron/workflow/db/workflow.repo.ts +++ b/electron/workflow/db/workflow.repo.ts @@ -158,7 +158,7 @@ export function updateWorkflow( for (const node of graphDefinition.nodes) { // Insert with NULL first (safe), then restore outputId db.run( - `INSERT INTO nodes (id, workflow_id, node_type, position_x, position_y, params, current_output_id) VALUES (?, ?, ?, ?, ?, ?, NULL)`, + `INSERT INTO nodes (id, workflow_id, node_type, position_x, position_y, params, current_output_id, parent_node_id) VALUES (?, ?, ?, ?, ?, ?, NULL, ?)`, [ node.id, id, @@ -166,12 +166,13 @@ export function updateWorkflow( node.position.x, node.position.y, JSON.stringify(node.params), + node.parentNodeId ?? null, ], ); } for (const edge of graphDefinition.edges) { db.run( - `INSERT INTO edges (id, workflow_id, source_node_id, source_output_key, target_node_id, target_input_key) VALUES (?, ?, ?, ?, ?, ?)`, + `INSERT INTO edges (id, workflow_id, source_node_id, source_output_key, target_node_id, target_input_key, is_internal) VALUES (?, ?, ?, ?, ?, ?, ?)`, [ edge.id, id, @@ -179,6 +180,7 @@ export function updateWorkflow( edge.sourceOutputKey, edge.targetNodeId, edge.targetInputKey, + edge.isInternal ? 1 : 0, ], ); } @@ -269,6 +271,7 @@ export function duplicateWorkflow(sourceId: string): Workflow { id: nodeIdMap.get(n.id)!, workflowId: newWf.id, currentOutputId: null, + parentNodeId: n.parentNodeId ? (nodeIdMap.get(n.parentNodeId) ?? null) : null, }), ); diff --git a/electron/workflow/engine/dag-utils.ts b/electron/workflow/engine/dag-utils.ts index 52957af1..70fc596f 100644 --- a/electron/workflow/engine/dag-utils.ts +++ b/electron/workflow/engine/dag-utils.ts @@ -1,7 +1,7 @@ /** * DAG validation — cycle detection using DFS. */ -interface SimpleEdge { +export interface SimpleEdge { sourceNodeId: string; targetNodeId: string; } @@ -38,3 +38,90 @@ export function wouldCreateCycle( ): boolean { return hasCycle(nodeIds, [...edges, newEdge]); } + +/** + * Check whether adding `newEdge` would create a cycle within a sub-workflow + * defined by `subNodeIds` and `internalEdges`. Only the sub-node scope is + * considered — edges outside the sub-workflow are ignored. + */ +export function wouldCreateCycleInSubWorkflow( + subNodeIds: string[], + internalEdges: SimpleEdge[], + newEdge: SimpleEdge, +): boolean { + return hasCycle(subNodeIds, [...internalEdges, newEdge]); +} + +/** + * Return edges that cross the iterator boundary (one endpoint inside, one + * outside), remapped so the inside endpoint is replaced with `iteratorNodeId`. + * This lets the outer DAG treat the iterator as a single node. + */ +export function getExternalEdges( + allEdges: SimpleEdge[], + iteratorNodeId: string, + childNodeIds: string[], +): SimpleEdge[] { + const childSet = new Set(childNodeIds); + const result: SimpleEdge[] = []; + + for (const edge of allEdges) { + const srcInside = childSet.has(edge.sourceNodeId); + const tgtInside = childSet.has(edge.targetNodeId); + + if (srcInside && !tgtInside) { + // Edge going from inside the iterator to outside — remap source + result.push({ + sourceNodeId: iteratorNodeId, + targetNodeId: edge.targetNodeId, + }); + } else if (!srcInside && tgtInside) { + // Edge going from outside into the iterator — remap target + result.push({ + sourceNodeId: edge.sourceNodeId, + targetNodeId: iteratorNodeId, + }); + } + // Both inside → internal edge, skip + // Both outside → not related to this iterator, skip + } + + return result; +} + +/** + * Build the node list and edge list for outer-DAG validation. Iterator child + * nodes are removed and their boundary-crossing edges are remapped onto the + * iterator node ID. Fully internal edges are dropped. + * + * `iterators` is an array of `{ iteratorNodeId, childNodeIds }` — one entry + * per iterator node in the workflow. + */ +export function buildOuterDAGView( + allNodeIds: string[], + allEdges: SimpleEdge[], + iterators: { iteratorNodeId: string; childNodeIds: string[] }[], +): { nodeIds: string[]; edges: SimpleEdge[] } { + // Collect all child node IDs across every iterator + const allChildIds = new Set(); + for (const it of iterators) { + for (const cid of it.childNodeIds) allChildIds.add(cid); + } + + // Outer node list: exclude child nodes (iterators themselves stay) + const nodeIds = allNodeIds.filter((id) => !allChildIds.has(id)); + + // Start with edges that don't touch any child node at all + const edges: SimpleEdge[] = allEdges.filter( + (e) => + !allChildIds.has(e.sourceNodeId) && !allChildIds.has(e.targetNodeId), + ); + + // Add remapped external edges for each iterator + for (const it of iterators) { + const ext = getExternalEdges(allEdges, it.iteratorNodeId, it.childNodeIds); + edges.push(...ext); + } + + return { nodeIds, edges }; +} diff --git a/electron/workflow/engine/executor.ts b/electron/workflow/engine/executor.ts index 2c917cea..2c0e978b 100644 --- a/electron/workflow/engine/executor.ts +++ b/electron/workflow/engine/executor.ts @@ -63,8 +63,14 @@ export class ExecutionEngine { /** Run all nodes in topological order. */ async runAll(workflowId: string): Promise { - const nodes = getNodesByWorkflowId(workflowId); - const edges = getEdgesByWorkflowId(workflowId); + const allNodes = getNodesByWorkflowId(workflowId); + const allEdges = getEdgesByWorkflowId(workflowId); + + // Exclude child nodes (they are executed internally by their parent Iterator handler) + const nodes = allNodes.filter((n) => !n.parentNodeId); + // Exclude internal edges (edges between sub-nodes inside an Iterator) + const edges = allEdges.filter((e) => !e.isInternal); + const nodeIds = nodes.map((n) => n.id); const simpleEdges = edges.map((e) => ({ sourceNodeId: e.sourceNodeId, @@ -119,15 +125,20 @@ export class ExecutionEngine { /** Run a single node, resolving upstream inputs. Always skips cache (user explicitly re-runs). */ async runNode(workflowId: string, nodeId: string): Promise { - const nodes = getNodesByWorkflowId(workflowId); - const edges = getEdgesByWorkflowId(workflowId); + const allNodes = getNodesByWorkflowId(workflowId); + const allEdges = getEdgesByWorkflowId(workflowId); - if (nodes.length === 0) { + if (allNodes.length === 0) { throw new Error( `No nodes found in workflow ${workflowId}. Please ensure the workflow is saved before running nodes.`, ); } + // Include all nodes in the map (needed for resolveInputs to find upstream sources) + // but filter out internal edges so they don't interfere with outer resolution + const nodes = allNodes; + const edges = allEdges.filter((e) => !e.isInternal); + const nodeMap = new Map(nodes.map((n) => [n.id, n])); const node = nodeMap.get(nodeId); @@ -146,15 +157,21 @@ export class ExecutionEngine { /** Continue from a node — execute it and all downstream nodes. */ async continueFrom(workflowId: string, nodeId: string): Promise { - const nodes = getNodesByWorkflowId(workflowId); - const edges = getEdgesByWorkflowId(workflowId); + const allNodes = getNodesByWorkflowId(workflowId); + const allEdges = getEdgesByWorkflowId(workflowId); + + // Exclude child nodes and internal edges from outer workflow execution + const nodes = allNodes.filter((n) => !n.parentNodeId); + const edges = allEdges.filter((e) => !e.isInternal); + const nodeIds = nodes.map((n) => n.id); const simpleEdges = edges.map((e) => ({ sourceNodeId: e.sourceNodeId, targetNodeId: e.targetNodeId, })); const downstream = downstreamNodes(nodeId, nodeIds, simpleEdges); - const nodeMap = new Map(nodes.map((n) => [n.id, n])); + // Use all nodes in the map so resolveInputs can find upstream sources + const nodeMap = new Map(allNodes.map((n) => [n.id, n])); const levels = topologicalLevels(nodeIds, simpleEdges); let stopped = false; @@ -175,9 +192,10 @@ export class ExecutionEngine { throw new Error(`Circuit breaker tripped for node ${nodeId}`); } - const nodes = getNodesByWorkflowId(workflowId); - const edges = getEdgesByWorkflowId(workflowId); - const nodeMap = new Map(nodes.map((n) => [n.id, n])); + const allNodes = getNodesByWorkflowId(workflowId); + const allEdges = getEdgesByWorkflowId(workflowId); + const edges = allEdges.filter((e) => !e.isInternal); + const nodeMap = new Map(allNodes.map((n) => [n.id, n])); const node = nodeMap.get(nodeId); if (!node) throw new Error(`Node ${nodeId} not found`); @@ -210,8 +228,13 @@ export class ExecutionEngine { /** Mark all downstream nodes as needing re-execution. */ markDownstreamStale(workflowId: string, nodeId: string): string[] { - const nodes = getNodesByWorkflowId(workflowId); - const edges = getEdgesByWorkflowId(workflowId); + const allNodes = getNodesByWorkflowId(workflowId); + const allEdges = getEdgesByWorkflowId(workflowId); + + // Exclude child nodes and internal edges from outer workflow graph + const nodes = allNodes.filter((n) => !n.parentNodeId); + const edges = allEdges.filter((e) => !e.isInternal); + const nodeIds = nodes.map((n) => n.id); const simpleEdges = edges.map((e) => ({ sourceNodeId: e.sourceNodeId, diff --git a/electron/workflow/nodes/control/iterator.ts b/electron/workflow/nodes/control/iterator.ts new file mode 100644 index 00000000..d66a599e --- /dev/null +++ b/electron/workflow/nodes/control/iterator.ts @@ -0,0 +1,296 @@ +/** + * Iterator node — container node that executes an internal sub-workflow + * multiple times, aggregating results across iterations. + */ +import { + BaseNodeHandler, + type NodeExecutionContext, + type NodeExecutionResult, +} from "../base"; +import type { NodeTypeDefinition } from "../../../../src/workflow/types/node-defs"; +import type { ExposedParam } from "../../../../src/workflow/types/workflow"; +import type { NodeRegistry } from "../registry"; +import { getChildNodes } from "../../db/node.repo"; +import { getInternalEdges } from "../../db/edge.repo"; +import { topologicalLevels } from "../../engine/scheduler"; + +export const iteratorDef: NodeTypeDefinition = { + type: "control/iterator", + category: "control", + label: "Iterator", + inputs: [], + outputs: [], + params: [ + { + key: "iterationCount", + label: "Iteration Count", + type: "number", + default: 1, + validation: { min: 1 }, + }, + { + key: "exposedInputs", + label: "Exposed Inputs", + type: "string", + default: "[]", + }, + { + key: "exposedOutputs", + label: "Exposed Outputs", + type: "string", + default: "[]", + }, + ], +}; + +export class IteratorNodeHandler extends BaseNodeHandler { + constructor(private registry: NodeRegistry) { + super(iteratorDef); + } + + async execute(ctx: NodeExecutionContext): Promise { + const start = Date.now(); + + // 1. Parse iteration config from params + const iterationCount = Math.max(1, Number(ctx.params.iterationCount) || 1); + const exposedInputs = this.parseExposedParams(ctx.params.exposedInputs); + const exposedOutputs = this.parseExposedParams(ctx.params.exposedOutputs); + + // 2. Load child nodes and internal edges + const childNodes = getChildNodes(ctx.nodeId); + const internalEdges = getInternalEdges(ctx.workflowId); + + // Filter internal edges to only those between our child nodes + const childNodeIds = childNodes.map((n) => n.id); + const childNodeIdSet = new Set(childNodeIds); + const relevantEdges = internalEdges.filter( + (e) => childNodeIdSet.has(e.sourceNodeId) && childNodeIdSet.has(e.targetNodeId), + ); + + if (childNodes.length === 0) { + return { + status: "success", + outputs: {}, + durationMs: Date.now() - start, + cost: 0, + }; + } + + // 3. Topologically sort child nodes + const simpleEdges = relevantEdges.map((e) => ({ + sourceNodeId: e.sourceNodeId, + targetNodeId: e.targetNodeId, + })); + const levels = topologicalLevels(childNodeIds, simpleEdges); + + // 4. Build lookup maps + const childNodeMap = new Map(childNodes.map((n) => [n.id, n])); + + // Build input routing: map from subNodeId -> paramKey -> external value + const inputRouting = new Map>(); + for (const ep of exposedInputs) { + const externalValue = ctx.inputs[ep.namespacedKey]; + if (externalValue !== undefined) { + if (!inputRouting.has(ep.subNodeId)) { + inputRouting.set(ep.subNodeId, new Map()); + } + inputRouting.get(ep.subNodeId)!.set(ep.paramKey, externalValue); + } + } + + // 5. Execute iterations + const iterationResults: Array> = []; + let totalCost = 0; + + for (let i = 0; i < iterationCount; i++) { + // Track outputs per sub-node for this iteration (for internal edge resolution) + const subNodeOutputs = new Map>(); + + // Execute sub-nodes level by level + let iterationFailed = false; + let failedSubNodeId = ""; + let failedError = ""; + + for (const level of levels) { + if (iterationFailed) break; + + for (const subNodeId of level) { + if (iterationFailed) break; + + const subNode = childNodeMap.get(subNodeId); + if (!subNode) continue; + + const handler = this.registry.getHandler(subNode.nodeType); + if (!handler) { + return { + status: "error", + outputs: {}, + durationMs: Date.now() - start, + cost: totalCost, + error: `No handler found for sub-node type: ${subNode.nodeType} (node: ${subNodeId})`, + }; + } + + // Build params for this sub-node: base params + external inputs + iteration index + const subParams: Record = { ...subNode.params }; + + // Inject external input values + const externalInputs = inputRouting.get(subNodeId); + if (externalInputs) { + for (const [paramKey, value] of externalInputs) { + subParams[paramKey] = value; + } + } + + // Inject iteration index + subParams.__iterationIndex = i; + + // Resolve internal edge inputs from upstream sub-node outputs + const subInputs = this.resolveSubNodeInputs( + subNodeId, + relevantEdges, + subNodeOutputs, + ); + + // Also inject unconnected param defaults (already in subParams from subNode.params) + // External inputs that aren't connected fall back to the sub-node's default value + // which is already present in subNode.params + + const subCtx: NodeExecutionContext = { + nodeId: subNodeId, + nodeType: subNode.nodeType, + params: subParams, + inputs: subInputs, + workflowId: ctx.workflowId, + abortSignal: ctx.abortSignal, + onProgress: (_progress, message) => { + // Forward sub-node progress as part of overall iteration progress + const iterationProgress = (i / iterationCount) * 100; + ctx.onProgress(iterationProgress, message); + }, + }; + + try { + const result = await handler.execute(subCtx); + totalCost += result.cost; + + if (result.status === "error") { + iterationFailed = true; + failedSubNodeId = subNodeId; + failedError = result.error || "Unknown sub-node error"; + break; + } + + // Store sub-node outputs for downstream internal edge resolution + subNodeOutputs.set(subNodeId, result.outputs); + } catch (error) { + return { + status: "error", + outputs: {}, + durationMs: Date.now() - start, + cost: totalCost, + error: `Sub-node ${subNodeId} threw: ${error instanceof Error ? error.message : String(error)}`, + }; + } + } + } + + if (iterationFailed) { + return { + status: "error", + outputs: {}, + durationMs: Date.now() - start, + cost: totalCost, + error: `Sub-node ${failedSubNodeId} failed: ${failedError}`, + }; + } + + // Collect exposed output values for this iteration + const iterOutputs: Record = {}; + for (const ep of exposedOutputs) { + const nodeOutputs = subNodeOutputs.get(ep.subNodeId); + if (nodeOutputs) { + // Key by handle ID format "output-{namespacedKey}" so the executor's + // resolveInputs can find the value via edge.sourceOutputKey + iterOutputs[`output-${ep.namespacedKey}`] = nodeOutputs[ep.paramKey]; + } + } + iterationResults.push(iterOutputs); + + // Report progress + ctx.onProgress(((i + 1) / iterationCount) * 100, `Iteration ${i + 1}/${iterationCount} complete`); + } + + // 6. Aggregate results + const outputs: Record = {}; + if (iterationCount === 1) { + // N=1: return results directly + Object.assign(outputs, iterationResults[0]); + } else { + // N>1: aggregate into arrays per exposed output + for (const ep of exposedOutputs) { + const handleKey = `output-${ep.namespacedKey}`; + outputs[handleKey] = iterationResults.map( + (r) => r[handleKey], + ); + } + } + + return { + status: "success", + outputs, + resultMetadata: { ...outputs }, + durationMs: Date.now() - start, + cost: totalCost, + }; + } + + /** + * Resolve inputs for a sub-node from upstream sub-node outputs via internal edges. + */ + private resolveSubNodeInputs( + subNodeId: string, + internalEdges: { sourceNodeId: string; targetNodeId: string; sourceOutputKey: string; targetInputKey: string }[], + subNodeOutputs: Map>, + ): Record { + const inputs: Record = {}; + const incomingEdges = internalEdges.filter((e) => e.targetNodeId === subNodeId); + + for (const edge of incomingEdges) { + const sourceOutputs = subNodeOutputs.get(edge.sourceNodeId); + if (!sourceOutputs) continue; + + const value = sourceOutputs[edge.sourceOutputKey]; + if (value === undefined) continue; + + // Parse target handle key the same way the main executor does + const targetKey = edge.targetInputKey; + if (targetKey.startsWith("param-")) { + inputs[targetKey.slice(6)] = value; + } else if (targetKey.startsWith("input-")) { + inputs[targetKey.slice(6)] = value; + } else { + inputs[targetKey] = value; + } + } + + return inputs; + } + + /** + * Parse exposed params from JSON string stored in node params. + */ + private parseExposedParams(value: unknown): ExposedParam[] { + if (typeof value === "string") { + try { + return JSON.parse(value) as ExposedParam[]; + } catch { + return []; + } + } + if (Array.isArray(value)) { + return value as ExposedParam[]; + } + return []; + } +} diff --git a/electron/workflow/nodes/register-all.ts b/electron/workflow/nodes/register-all.ts index 228c1974..6e588a89 100644 --- a/electron/workflow/nodes/register-all.ts +++ b/electron/workflow/nodes/register-all.ts @@ -7,6 +7,7 @@ import { previewDisplayDef, PreviewDisplayHandler } from "./output/preview"; import { registerFreeToolNodes } from "./free-tool/register"; import { concatDef, ConcatHandler } from "./processing/concat"; import { selectDef, SelectHandler } from "./processing/select"; +import { iteratorDef, IteratorNodeHandler } from "./control/iterator"; export function registerAllNodes(): void { nodeRegistry.register(mediaUploadDef, new MediaUploadHandler()); @@ -17,6 +18,7 @@ export function registerAllNodes(): void { registerFreeToolNodes(); nodeRegistry.register(concatDef, new ConcatHandler()); nodeRegistry.register(selectDef, new SelectHandler()); + nodeRegistry.register(iteratorDef, new IteratorNodeHandler(nodeRegistry)); console.log( `[Registry] Registered ${nodeRegistry.getAll().length} node types`, ); diff --git a/src/workflow/WorkflowPage.tsx b/src/workflow/WorkflowPage.tsx index af816a87..402f0ef0 100644 --- a/src/workflow/WorkflowPage.tsx +++ b/src/workflow/WorkflowPage.tsx @@ -1057,6 +1057,17 @@ export function WorkflowPage() { prevWasRunning.current = wasRunning; }, [wasRunning, isRunning, nodeStatuses]); + // Listen for workflow:toast events dispatched from the store (e.g. cycle detection) + useEffect(() => { + const handler = (e: Event) => { + const { type, msg } = (e as CustomEvent).detail; + setExecToast({ type, msg }); + setTimeout(() => setExecToast(null), 4000); + }; + window.addEventListener("workflow:toast", handler); + return () => window.removeEventListener("workflow:toast", handler); + }, []); + // Model loading state const [modelSyncStatus, setModelSyncStatus] = useState("idle"); diff --git a/src/workflow/components/canvas/NodePalette.tsx b/src/workflow/components/canvas/NodePalette.tsx index 8033f558..db324008 100644 --- a/src/workflow/components/canvas/NodePalette.tsx +++ b/src/workflow/components/canvas/NodePalette.tsx @@ -64,6 +64,7 @@ export function NodePalette({ definitions }: NodePaletteProps) { const addNode = useWorkflowStore((s) => s.addNode); const width = useUIStore((s) => s.sidebarWidth); const setSidebarWidth = useUIStore((s) => s.setSidebarWidth); + const pendingIteratorParentId = useUIStore((s) => s.pendingIteratorParentId); const [dragging, setDragging] = useState(false); const [query, setQuery] = useState(""); const [collapsed, setCollapsed] = useState>({}); @@ -81,18 +82,50 @@ export function NodePalette({ definitions }: NodePaletteProps) { const handleClick = useCallback( (def: NodeTypeDefinition) => { + // Don't allow creating iterators inside iterators + const pendingParentId = useUIStore.getState().pendingIteratorParentId; + if (pendingParentId && def.type === "control/iterator") return; + const defaultParams: Record = {}; for (const p of def.params) { if (p.default !== undefined) defaultParams[p.key] = p.default; } - const center = useUIStore.getState().getViewportCenter(); - const x = center.x + (Math.random() - 0.5) * 60; - const y = center.y + (Math.random() - 0.5) * 60; + + let x: number, y: number; + + if (pendingParentId) { + // Creating inside an Iterator — compute absolute position in the internal canvas area + const iteratorNode = useWorkflowStore.getState().nodes.find((n) => n.id === pendingParentId); + if (iteratorNode) { + const itW = (iteratorNode.data?.params?.__nodeWidth as number) ?? 600; + const itH = (iteratorNode.data?.params?.__nodeHeight as number) ?? 400; + const childW = (defaultParams.__nodeWidth as number) ?? 300; + // Account for port strips — width depends on whether ports are exposed + const inputDefs = iteratorNode.data?.inputDefinitions ?? []; + const outputDefs = iteratorNode.data?.outputDefinitions ?? []; + const leftStrip = (inputDefs as unknown[]).length > 0 ? 140 : 24; + const rightStrip = (outputDefs as unknown[]).length > 0 ? 140 : 24; + const internalW = itW - leftStrip - rightStrip; + // Center the child in the internal canvas area + x = iteratorNode.position.x + leftStrip + Math.max(10, (internalW - childW) / 2); + y = iteratorNode.position.y + Math.max(60, itH / 2 - 40); + } else { + const center = useUIStore.getState().getViewportCenter(); + x = center.x + (Math.random() - 0.5) * 60; + y = center.y + (Math.random() - 0.5) * 60; + } + } else { + const center = useUIStore.getState().getViewportCenter(); + x = center.x + (Math.random() - 0.5) * 60; + y = center.y + (Math.random() - 0.5) * 60; + } + const localizedLabel = t( `workflow.nodeDefs.${def.type}.label`, def.label, ); - addNode( + + const newNodeId = addNode( def.type, { x, y }, defaultParams, @@ -101,6 +134,14 @@ export function NodePalette({ definitions }: NodePaletteProps) { def.inputs, def.outputs, ); + + // If creating inside an Iterator, adopt immediately + // adoptNode converts absolute position to relative and sets parentNode + extent + if (pendingParentId) { + useWorkflowStore.getState().adoptNode(pendingParentId, newNodeId); + useUIStore.getState().setPendingIteratorParentId(null); + } + recordRecentNodeType(def.type); // Auto-close palette after adding a node toggleNodePalette(); @@ -122,15 +163,20 @@ export function NodePalette({ definitions }: NodePaletteProps) { ); const displayDefs = useMemo(() => { + let defs = definitions; + // When creating inside an Iterator, filter out the iterator type (no nesting) + if (pendingIteratorParentId) { + defs = defs.filter((d) => d.type !== "control/iterator"); + } const q = query.trim(); - if (!q) return definitions; - return fuzzySearch(definitions, q, (def) => [ + if (!q) return defs; + return fuzzySearch(defs, q, (def) => [ def.type, def.label, t(`workflow.nodeDefs.${def.type}.label`, def.label), def.category, ]).map((r) => r.item); - }, [definitions, query, t]); + }, [definitions, query, t, pendingIteratorParentId]); const groupedDefs = useMemo(() => { const groups = new Map(); @@ -166,6 +212,7 @@ export function NodePalette({ definitions }: NodePaletteProps) { ); /* ── render ──────────────────────────────────────────────── */ + return (
+ {/* ── iterator context banner ── */} + {pendingIteratorParentId && ( +
+ + + + + + {t("workflow.addingInsideIterator", "Adding inside Iterator")} + +
+ )} + {/* ── search ── */}
diff --git a/src/workflow/components/canvas/WorkflowCanvas.tsx b/src/workflow/components/canvas/WorkflowCanvas.tsx index d76426b4..097eed68 100644 --- a/src/workflow/components/canvas/WorkflowCanvas.tsx +++ b/src/workflow/components/canvas/WorkflowCanvas.tsx @@ -32,6 +32,8 @@ import { useUIStore } from "../../stores/ui.store"; import { CustomNode } from "./CustomNode"; import { CustomEdge } from "./CustomEdge"; import { AnnotationNode } from "./AnnotationNode"; +import IteratorNodeContainer from "./iterator-node/IteratorNodeContainer"; +import { useIteratorAdoption } from "../../hooks/useIteratorAdoption"; import { ContextMenu, type ContextMenuItem } from "./ContextMenu"; import type { NodeTypeDefinition, @@ -94,7 +96,7 @@ function saveRecentNodeTypes(types: string[]) { } } -const nodeTypes = { custom: CustomNode, annotation: AnnotationNode }; +const nodeTypes = { custom: CustomNode, annotation: AnnotationNode, "control/iterator": IteratorNodeContainer }; const edgeTypes = { custom: CustomEdge }; /** Zoom in/out and fit view controls, positioned on the right of the canvas. */ @@ -390,6 +392,40 @@ export function WorkflowCanvas({ nodeDefs = [] }: WorkflowCanvasProps) { }); }, []); + // ── Iterator adoption: drag-in/out detection, auto-adopt on create, release on delete ── + const { + handleNodesChangeForAdoption, + handleNodeCreated, + handleNodesDeleted, + } = useIteratorAdoption(); + + /** Wrapped onNodesChange that also triggers iterator adoption detection */ + const onNodesChangeWithAdoption = useCallback( + (changes: NodeChange[]) => { + onNodesChange(changes); + handleNodesChangeForAdoption(changes); + }, + [onNodesChange, handleNodesChangeForAdoption], + ); + + /** Wrapped removeNode that releases child from iterator before deletion */ + const removeNodeWithRelease = useCallback( + (nodeId: string) => { + handleNodesDeleted([nodeId]); + removeNode(nodeId); + }, + [removeNode, handleNodesDeleted], + ); + + /** Wrapped removeNodes that releases children from iterators before deletion */ + const removeNodesWithRelease = useCallback( + (nodeIds: string[]) => { + handleNodesDeleted(nodeIds); + removeNodes(nodeIds); + }, + [removeNodes, handleNodesDeleted], + ); + // Keyboard shortcuts useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { @@ -430,10 +466,10 @@ export function WorkflowCanvas({ nodeDefs = [] }: WorkflowCanvasProps) { ) { event.preventDefault(); if (selectedNodeIds.size === 1) { - removeNode([...selectedNodeIds][0]); + removeNodeWithRelease([...selectedNodeIds][0]); selectNode(null); } else { - removeNodes([...selectedNodeIds]); + removeNodesWithRelease([...selectedNodeIds]); selectNode(null); } } @@ -484,7 +520,7 @@ export function WorkflowCanvas({ nodeDefs = [] }: WorkflowCanvasProps) { try { const node = JSON.parse(copiedNode); const center = useUIStore.getState().getViewportCenter(); - addNode( + const pastedNodeId = addNode( node.data.nodeType, { x: center.x + (Math.random() - 0.5) * 60, @@ -496,6 +532,7 @@ export function WorkflowCanvas({ nodeDefs = [] }: WorkflowCanvasProps) { node.data.inputDefinitions ?? [], node.data.outputDefinitions ?? [], ); + handleNodeCreated(pastedNodeId); if (typeof node.data?.nodeType === "string") recordRecentNodeType(node.data.nodeType); } catch (e) { @@ -509,8 +546,8 @@ export function WorkflowCanvas({ nodeDefs = [] }: WorkflowCanvasProps) { }, [ selectedNodeId, selectedNodeIds, - removeNode, - removeNodes, + removeNodeWithRelease, + removeNodesWithRelease, selectNode, selectNodes, nodes, @@ -519,7 +556,8 @@ export function WorkflowCanvas({ nodeDefs = [] }: WorkflowCanvasProps) { redo, saveWorkflow, recordRecentNodeType, - onNodesChange, + handleNodeCreated, + onNodesChangeWithAdoption, ]); // Touch gesture handling: 2 fingers = pan, 3 fingers = select, pinch = zoom (native) @@ -685,6 +723,11 @@ export function WorkflowCanvas({ nodeDefs = [] }: WorkflowCanvasProps) { for (const p of def.params) { if (p.default !== undefined) defaultParams[p.key] = p.default; } + // Iterator nodes need default bounding box dimensions + if (def.type === "control/iterator") { + defaultParams.__nodeWidth = 600; + defaultParams.__nodeHeight = 400; + } const localizedLabel = t( `workflow.nodeDefs.${def.type}.label`, def.label, @@ -722,7 +765,7 @@ export function WorkflowCanvas({ nodeDefs = [] }: WorkflowCanvasProps) { position = projectMenuPosition(contextMenu.x, contextMenu.y); } - addNode( + const newNodeId = addNode( def.type, position, defaultParams, @@ -732,9 +775,20 @@ export function WorkflowCanvas({ nodeDefs = [] }: WorkflowCanvasProps) { def.outputs, ); recordRecentNodeType(def.type); + handleNodeCreated(newNodeId); + + // If pendingIteratorParentId is set (e.g. from child node "+" button), + // adopt the new node into the iterator + const pendingItId = useUIStore.getState().pendingIteratorParentId; + if (pendingItId) { + const { adoptNode } = useWorkflowStore.getState(); + adoptNode(pendingItId, newNodeId); + useUIStore.getState().setPendingIteratorParentId(null); + } + setContextMenu(null); }, - [addNode, contextMenu, nodes, projectMenuPosition, t, recordRecentNodeType], + [addNode, contextMenu, nodes, projectMenuPosition, t, recordRecentNodeType, handleNodeCreated], ); const addNodeDisplayDefs = useMemo(() => { @@ -867,10 +921,20 @@ export function WorkflowCanvas({ nodeDefs = [] }: WorkflowCanvasProps) { }); recordRecentNodeType("ai-task/run"); + handleNodeCreated(newNodeId); + + // If pendingIteratorParentId is set, adopt the new node into the iterator + const pendingItId = useUIStore.getState().pendingIteratorParentId; + if (pendingItId) { + const { adoptNode: adopt } = useWorkflowStore.getState(); + adopt(pendingItId, newNodeId); + useUIStore.getState().setPendingIteratorParentId(null); + } + selectNode(newNodeId); setContextMenu(null); }, - [addNode, contextMenu, nodes, nodeDefs, projectMenuPosition, recordRecentNodeType, selectNode], + [addNode, contextMenu, nodes, nodeDefs, projectMenuPosition, recordRecentNodeType, handleNodeCreated, selectNode], ); const getContextMenuItems = useCallback((): ContextMenuItem[] => { @@ -985,7 +1049,7 @@ export function WorkflowCanvas({ nodeDefs = [] }: WorkflowCanvasProps) { icon: "🗑️", shortcut: "Del", action: () => { - removeNodes([...selectedNodeIds]); + removeNodesWithRelease([...selectedNodeIds]); selectNode(null); }, destructive: true, @@ -995,7 +1059,7 @@ export function WorkflowCanvas({ nodeDefs = [] }: WorkflowCanvasProps) { label: t("workflow.delete", "Delete"), icon: "🗑️", shortcut: "Del", - action: () => removeNode(nodeId), + action: () => removeNodeWithRelease(nodeId), destructive: true, }); } @@ -1058,7 +1122,7 @@ export function WorkflowCanvas({ nodeDefs = [] }: WorkflowCanvasProps) { try { const node = JSON.parse(copiedNode); const position = menuToFlowPosition(); - addNode( + const pastedId = addNode( node.data.nodeType, position, node.data.params, @@ -1067,6 +1131,7 @@ export function WorkflowCanvas({ nodeDefs = [] }: WorkflowCanvasProps) { node.data.inputDefinitions ?? [], node.data.outputDefinitions ?? [], ); + handleNodeCreated(pastedId); if (typeof node.data?.nodeType === "string") recordRecentNodeType(node.data.nodeType); } catch (e) { @@ -1078,8 +1143,8 @@ export function WorkflowCanvas({ nodeDefs = [] }: WorkflowCanvasProps) { return items; }, [ contextMenu, - removeNode, - removeNodes, + removeNodeWithRelease, + removeNodesWithRelease, selectedNodeIds, selectNode, nodes, @@ -1110,6 +1175,22 @@ export function WorkflowCanvas({ nodeDefs = [] }: WorkflowCanvasProps) { x: event.clientX - bounds.left, y: event.clientY - bounds.top, }); + + // Reject drop if it lands inside an iterator boundary (external nodes can't enter) + if (nodeType !== "control/iterator") { + const iteratorNodes = useWorkflowStore.getState().nodes.filter((n) => n.type === "control/iterator"); + for (const it of iteratorNodes) { + const itX = it.position.x; + const itY = it.position.y; + const itW = (it.data?.params?.__nodeWidth as number) ?? 600; + const itH = (it.data?.params?.__nodeHeight as number) ?? 400; + if (position.x >= itX && position.x <= itX + itW && position.y >= itY && position.y <= itY + itH) { + // Don't allow drop inside iterator — user must use the internal Add Node button + return; + } + } + } + const def = nodeDefs.find((d) => d.type === nodeType); const defaultParams: Record = {}; if (def) { @@ -1117,6 +1198,11 @@ export function WorkflowCanvas({ nodeDefs = [] }: WorkflowCanvasProps) { if (p.default !== undefined) defaultParams[p.key] = p.default; } } + // Iterator nodes need default bounding box dimensions + if (nodeType === "control/iterator") { + defaultParams.__nodeWidth = 600; + defaultParams.__nodeHeight = 400; + } const newNodeId = addNode( nodeType, position, @@ -1127,6 +1213,7 @@ export function WorkflowCanvas({ nodeDefs = [] }: WorkflowCanvasProps) { def?.outputs ?? [], ); recordRecentNodeType(nodeType); + handleNodeCreated(newNodeId); selectNode(newNodeId); return; } @@ -1172,6 +1259,7 @@ export function WorkflowCanvas({ nodeDefs = [] }: WorkflowCanvasProps) { uploadDef?.outputs ?? [], ); recordRecentNodeType("input/media-upload"); + handleNodeCreated(newNodeId); selectNode(newNodeId); // Upload the file and update the newly created node's params @@ -1219,7 +1307,7 @@ export function WorkflowCanvas({ nodeDefs = [] }: WorkflowCanvasProps) { }); }); }, - [addNode, nodeDefs, recordRecentNodeType, selectNode, t], + [addNode, nodeDefs, recordRecentNodeType, handleNodeCreated, selectNode, t], ); useEffect(() => { @@ -1241,6 +1329,12 @@ export function WorkflowCanvas({ nodeDefs = [] }: WorkflowCanvasProps) { const { x, y, sourceNodeId, side } = (e as CustomEvent).detail; if (sourceNodeId && side) { sideAddRef.current = { sourceNodeId, side }; + // If the source node is inside an iterator, set pendingIteratorParentId + // so the new node is also created inside the same iterator + const sourceNode = useWorkflowStore.getState().nodes.find((n) => n.id === sourceNodeId); + if (sourceNode?.parentNode) { + useUIStore.getState().setPendingIteratorParentId(sourceNode.parentNode); + } } else { sideAddRef.current = null; } @@ -1492,7 +1586,7 @@ export function WorkflowCanvas({ nodeDefs = [] }: WorkflowCanvasProps) { s.removeNode); const { continueFrom } = useExecutionStore(); + // Detect if this node is inside an Iterator container + const parentIteratorId = useMemo(() => { + const thisNode = allNodes.find((n) => n.id === id); + return (thisNode as { parentNode?: string } | undefined)?.parentNode ?? null; + }, [allNodes, id]); + const isInsideIterator = !!parentIteratorId; + const ensureWorkflowId = async () => { let wfId = workflowId; if (!wfId || isDirty) { @@ -705,6 +712,7 @@ function CustomNodeComponent({ ${!running && !selected && status === "unconfirmed" ? "border-orange-500/70" : ""} ${!running && !selected && status === "error" ? "border-red-500/70" : ""} ${!running && !selected && status === "idle" ? (hovered ? "border-[hsl(var(--border))] shadow-lg" : "border-[hsl(var(--border))] shadow-md") : ""} + ${isInsideIterator && !running && !selected && status === "idle" ? "ring-1 ring-cyan-500/20" : ""} `} style={{ width: savedWidth, minHeight: savedHeight, fontSize: 13 }} > @@ -938,7 +946,9 @@ function CustomNodeComponent({ + + {/* Expanded expose controls */} + {expanded && ( +
+ {/* Parameters as inputs */} + {visibleParamDefs.length > 0 && ( +
+
+ {t("workflow.params", "Parameters")} +
+ {visibleParamDefs.map((def) => ( + handleToggle(def.key, "input", def.dataType ?? "any")} + /> + ))} +
+ )} + + {/* Input ports as inputs */} + {inputDefs.length > 0 && ( +
+
+ {t("workflow.inputPorts", "Input Ports")} +
+ {inputDefs.map((port) => ( + handleToggle(port.key, "input", port.dataType)} + /> + ))} +
+ )} + + {/* Output ports as outputs */} + {outputDefs.length > 0 && ( +
+
+ {t("workflow.outputPorts", "Output Ports")} +
+ {outputDefs.map((port) => ( + handleToggle(port.key, "output", port.dataType)} + /> + ))} +
+ )} +
+ )} +
+ ); +} + +/* ── Single toggle row ─────────────────────────────────────────────── */ + +function ExposeToggleRow({ + label, + exposed, + direction, + onToggle, +}: { + label: string; + exposed: boolean; + direction: "input" | "output"; + onToggle: () => void; +}) { + const { t } = useTranslation(); + const dirIcon = direction === "input" ? "←" : "→"; + + return ( +
+ + {dirIcon} {label} + + +
+ ); +} diff --git a/src/workflow/components/canvas/iterator-node/IteratorNodeContainer.tsx b/src/workflow/components/canvas/iterator-node/IteratorNodeContainer.tsx new file mode 100644 index 00000000..210834d6 --- /dev/null +++ b/src/workflow/components/canvas/iterator-node/IteratorNodeContainer.tsx @@ -0,0 +1,809 @@ +/** + * IteratorNodeContainer — ReactFlow custom node for the Iterator container. + * + * Layout: [Left input port strip] [Internal canvas area] [Right output port strip] + * + * Exposed params flow: + * External edge → Iterator left handle → (runtime maps to) child node param + * Child node output → (runtime maps to) Iterator right handle → External edge + * + * The ExposeParamPicker floats ABOVE the iterator (portal-style z-index) + * so internal child nodes never obscure it. + */ +import React, { + memo, + useCallback, + useRef, + useState, + useMemo, + useEffect, +} from "react"; +import { createPortal } from "react-dom"; +import { useTranslation } from "react-i18next"; +import { Handle, Position, useReactFlow, type NodeProps } from "reactflow"; +import { useWorkflowStore } from "../../../stores/workflow.store"; +import { useUIStore } from "../../../stores/ui.store"; +import { useExecutionStore } from "../../../stores/execution.store"; +import type { PortDefinition } from "@/workflow/types/node-defs"; +import type { NodeStatus } from "@/workflow/types/execution"; +import type { ExposedParam } from "@/workflow/types/workflow"; +import { handleLeft, handleRight } from "../custom-node/CustomNodeHandleAnchor"; +import { + Tooltip, + TooltipTrigger, + TooltipContent, +} from "@/components/ui/tooltip"; +import { ChevronDown, ChevronUp } from "lucide-react"; + +/* ── constants ─────────────────────────────────────────────────────── */ + +const MIN_ITERATOR_WIDTH = 600; +const MIN_ITERATOR_HEIGHT = 400; +const CHILD_PADDING = 40; +const TITLE_BAR_HEIGHT = 40; +const PORT_STRIP_WIDTH = 140; +const PORT_STRIP_EMPTY_WIDTH = 24; +const PORT_ROW_HEIGHT = 32; +const PORT_HEADER_HEIGHT = 28; + +/* ── types ─────────────────────────────────────────────────────────── */ + +export interface IteratorNodeData { + nodeType: string; + label: string; + params: Record; + childNodeIds?: string[]; + inputDefinitions?: PortDefinition[]; + outputDefinitions?: PortDefinition[]; + paramDefinitions?: unknown[]; +} + +/* ── Gear icon (reusable) ──────────────────────────────────────────── */ + +const GearIcon = ({ size = 12 }: { size?: number }) => ( + + + + +); + +/* ── Expose-param picker — floats above the iterator ───────────────── */ + +function ExposeParamPicker({ + iteratorId, + direction, + onClose, +}: { + iteratorId: string; + direction: "input" | "output"; + onClose: () => void; +}) { + const { t } = useTranslation(); + const nodes = useWorkflowStore((s) => s.nodes); + const exposeParam = useWorkflowStore((s) => s.exposeParam); + const unexposeParam = useWorkflowStore((s) => s.unexposeParam); + + const iteratorNode = nodes.find((n) => n.id === iteratorId); + const iteratorParams = (iteratorNode?.data?.params ?? {}) as Record; + const childNodes = nodes.filter((n) => n.parentNode === iteratorId); + + const exposedList: ExposedParam[] = useMemo(() => { + const key = direction === "input" ? "exposedInputs" : "exposedOutputs"; + try { + const raw = iteratorParams[key]; + return typeof raw === "string" ? JSON.parse(raw) : Array.isArray(raw) ? raw : []; + } catch { return []; } + }, [iteratorParams, direction]); + + const isExposed = useCallback( + (subNodeId: string, paramKey: string) => + exposedList.some((p) => p.subNodeId === subNodeId && p.paramKey === paramKey), + [exposedList], + ); + + const handleToggle = useCallback( + (subNodeId: string, subNodeLabel: string, paramKey: string, dataType: string) => { + const nk = `${subNodeLabel}.${paramKey}`; + if (isExposed(subNodeId, paramKey)) { + unexposeParam(iteratorId, nk, direction); + } else { + exposeParam(iteratorId, { + subNodeId, subNodeLabel, paramKey, namespacedKey: nk, direction, + dataType: dataType as ExposedParam["dataType"], + }); + } + }, + [isExposed, exposeParam, unexposeParam, iteratorId, direction], + ); + + + if (childNodes.length === 0) { + return ( +
e.stopPropagation()}> +
+ + {direction === "input" ? t("workflow.configureInputs", "Configure Inputs") : t("workflow.configureOutputs", "Configure Outputs")} + + +
+

{t("workflow.noChildNodes", "Add child nodes first to expose their parameters")}

+
+ ); + } + + return ( +
e.stopPropagation()}> +
+ + {direction === "input" ? t("workflow.configureInputs", "Configure Inputs") : t("workflow.configureOutputs", "Configure Outputs")} + + +
+
+ {childNodes.map((child) => { + const childLabel = String(child.data?.label ?? child.id.slice(0, 8)); + const paramDefs = (child.data?.paramDefinitions ?? []) as Array<{ key: string; label: string; dataType?: string }>; + const childInputDefs = (child.data?.inputDefinitions ?? []) as PortDefinition[]; + const childOutputDefs = (child.data?.outputDefinitions ?? []) as PortDefinition[]; + const modelSchema = (child.data?.modelInputSchema ?? []) as Array<{ name: string; label?: string; type?: string; mediaType?: string; required?: boolean }>; + + let items: Array<{ key: string; label: string; dataType: string }>; + + if (direction === "input") { + // For inputs: show model input schema fields (the actual user-facing params like Image, Source Image, etc.) + // plus any input port definitions, but skip internal paramDefinitions like modelId + const modelItems = modelSchema.map((m) => ({ + key: m.name, + label: m.label || m.name.split("_").map((w: string) => w.charAt(0).toUpperCase() + w.slice(1)).join(" "), + dataType: m.mediaType ?? m.type ?? "any", + })); + const inputPortItems = childInputDefs.map((d) => ({ key: d.key, label: d.label, dataType: d.dataType })); + // If no model schema, fall back to visible paramDefinitions (for non-ai-task nodes like free-tools) + if (modelItems.length === 0) { + const visibleParams = paramDefs + .filter((d) => !d.key.startsWith("__") && d.key !== "modelId") + .map((d) => ({ key: d.key, label: d.label, dataType: d.dataType ?? "any" })); + items = [...visibleParams, ...inputPortItems]; + } else { + items = [...modelItems, ...inputPortItems]; + } + } else { + // For outputs: show each child node's output ports + items = childOutputDefs.map((d) => ({ key: d.key, label: d.label, dataType: d.dataType })); + } + + if (items.length === 0) return null; + + return ( +
+
{childLabel}
+ {items.map((item) => ( + + ))} +
+ ); + })} +
+
+ ); +} + +/* ── Portal wrapper — positions a floating panel relative to the iterator node ── */ + +function PickerPortal({ + nodeRef, + side, + offsetTop, + children, +}: { + nodeRef: React.RefObject; + side: "left" | "right"; + offsetTop: number; + children: React.ReactNode; +}) { + const [pos, setPos] = useState<{ top: number; left?: number; right?: number }>({ top: 0 }); + const portalRef = useRef(null); + + useEffect(() => { + const update = () => { + const rect = nodeRef.current?.getBoundingClientRect(); + if (!rect) return; + if (side === "left") { + setPos({ top: rect.top + offsetTop, left: rect.left + 8 }); + } else { + setPos({ top: rect.top + offsetTop, right: window.innerWidth - rect.right + 8 }); + } + }; + update(); + // Track viewport transform changes (pan/zoom) + const viewport = nodeRef.current?.closest(".react-flow__viewport"); + let mo: MutationObserver | undefined; + if (viewport) { + mo = new MutationObserver(update); + mo.observe(viewport, { attributes: true, attributeFilter: ["style"] }); + } + window.addEventListener("resize", update); + return () => { mo?.disconnect(); window.removeEventListener("resize", update); }; + }, [nodeRef, side, offsetTop]); + + return ( +
e.stopPropagation()} + onMouseDown={(e) => e.stopPropagation()} + > + {children} +
+ ); +} + +/* ── Add Node button portal — floats at bottom-center of iterator ── */ + +function AddNodePortal({ + nodeRef, + onClick, + label, + title, +}: { + nodeRef: React.RefObject; + onClick: (e: React.MouseEvent) => void; + label: string; + title: string; +}) { + const [pos, setPos] = useState<{ top: number; left: number } | null>(null); + + useEffect(() => { + const update = () => { + const rect = nodeRef.current?.getBoundingClientRect(); + if (!rect) return; + setPos({ + top: rect.bottom - 36, + left: rect.left + rect.width / 2, + }); + }; + update(); + const viewport = nodeRef.current?.closest(".react-flow__viewport"); + let mo: MutationObserver | undefined; + if (viewport) { + mo = new MutationObserver(update); + mo.observe(viewport, { attributes: true, attributeFilter: ["style"] }); + } + const ro = nodeRef.current ? new ResizeObserver(update) : null; + if (nodeRef.current && ro) ro.observe(nodeRef.current); + window.addEventListener("resize", update); + return () => { mo?.disconnect(); ro?.disconnect(); window.removeEventListener("resize", update); }; + }, [nodeRef]); + + if (!pos) return null; + + return ( +
e.stopPropagation()} + > + +
+ ); +} + +/* ── main component ────────────────────────────────────────────────── */ + +function IteratorNodeContainerComponent({ + id, + data, + selected, +}: NodeProps) { + const { t } = useTranslation(); + const nodeRef = useRef(null); + const [resizing, setResizing] = useState(false); + const [hovered, setHovered] = useState(false); + const [editingCount, setEditingCount] = useState(false); + const [countDraft, setCountDraft] = useState(""); + const countInputRef = useRef(null); + const [showInputPicker, setShowInputPicker] = useState(false); + const [showOutputPicker, setShowOutputPicker] = useState(false); + const { getViewport, setNodes } = useReactFlow(); + const updateNodeParams = useWorkflowStore((s) => s.updateNodeParams); + const workflowId = useWorkflowStore((s) => s.workflowId); + const removeNode = useWorkflowStore((s) => s.removeNode); + const toggleNodePalette = useUIStore((s) => s.toggleNodePalette); + const status = useExecutionStore( + (s) => s.nodeStatuses[id] ?? "idle", + ) as NodeStatus; + const progress = useExecutionStore((s) => s.progressMap[id]); + const errorMessage = useExecutionStore((s) => s.errorMessages[id]); + const { runNode, cancelNode, retryNode, continueFrom } = useExecutionStore(); + const running = status === "running"; + + const iterationCount = Number(data.params?.iterationCount ?? 1); + const savedWidth = (data.params?.__nodeWidth as number) ?? MIN_ITERATOR_WIDTH; + const savedHeight = (data.params?.__nodeHeight as number) ?? MIN_ITERATOR_HEIGHT; + const collapsed = (data.params?.__nodeCollapsed as boolean | undefined) ?? false; + const shortId = id.slice(0, 8); + + const inputDefs = data.inputDefinitions ?? []; + const outputDefs = data.outputDefinitions ?? []; + const childNodeIds = data.childNodeIds ?? []; + const hasChildren = childNodeIds.length > 0; + + /* ── Collapse toggle ───────────────────────────────────────────── */ + const setCollapsed = useCallback( + (value: boolean) => updateNodeParams(id, { ...data.params, __nodeCollapsed: value }), + [id, data.params, updateNodeParams], + ); + const toggleCollapsed = useCallback( + (e: React.MouseEvent) => { e.stopPropagation(); setCollapsed(!collapsed); }, + [collapsed, setCollapsed], + ); + + /* ── Effective size — uses saved dimensions (updated by updateBoundingBox) */ + const effectiveWidth = savedWidth; + const effectiveHeight = collapsed ? TITLE_BAR_HEIGHT : savedHeight; + + /* ── Auto-expand: observe child DOM size changes ───────────────── */ + useEffect(() => { + if (collapsed || childNodeIds.length === 0) return; + const updateBB = useWorkflowStore.getState().updateBoundingBox; + const observer = new ResizeObserver(() => { updateBB(id); }); + for (const cid of childNodeIds) { + const el = document.querySelector(`[data-id="${cid}"]`) as HTMLElement | null; + if (el) observer.observe(el); + } + return () => observer.disconnect(); + }, [id, childNodeIds, collapsed]); + + /* ── Iteration count editing ───────────────────────────────────── */ + const startEditingCount = useCallback((e: React.MouseEvent) => { + e.stopPropagation(); setCountDraft(String(iterationCount)); setEditingCount(true); + }, [iterationCount]); + + useEffect(() => { + if (editingCount && countInputRef.current) { countInputRef.current.focus(); countInputRef.current.select(); } + }, [editingCount]); + + const commitCount = useCallback(() => { + const val = Math.max(1, Math.floor(Number(countDraft) || 1)); + updateNodeParams(id, { ...data.params, iterationCount: val }); + setEditingCount(false); + }, [countDraft, id, data.params, updateNodeParams]); + + const onCountKeyDown = useCallback( + (e: React.KeyboardEvent) => { if (e.key === "Enter") commitCount(); if (e.key === "Escape") setEditingCount(false); }, + [commitCount], + ); + + /* ── Actions ───────────────────────────────────────────────────── */ + const onRun = useCallback(async (e: React.MouseEvent) => { + e.stopPropagation(); + if (running) cancelNode(workflowId ?? "", id); else runNode(workflowId ?? "", id); + }, [running, workflowId, id, runNode, cancelNode]); + + const onRunFromHere = useCallback(async (e: React.MouseEvent) => { + e.stopPropagation(); continueFrom(workflowId ?? "", id); + }, [workflowId, id, continueFrom]); + + const onDelete = useCallback((e: React.MouseEvent) => { + e.stopPropagation(); removeNode(id); + }, [removeNode, id]); + + const handleAddNodeInside = useCallback((e: React.MouseEvent) => { + e.stopPropagation(); + useUIStore.getState().setPendingIteratorParentId(id); + toggleNodePalette(); + }, [toggleNodePalette, id]); + + /* ── Resize handler ────────────────────────────────────────────── */ + const onEdgeResizeStart = useCallback( + (e: React.MouseEvent, xDir: number, yDir: number) => { + e.stopPropagation(); e.preventDefault(); + const el = nodeRef.current; if (!el) return; + setResizing(true); + const startX = e.clientX, startY = e.clientY; + const startW = el.offsetWidth, startH = el.offsetHeight; + const zoom = getViewport().zoom; + const onMove = (ev: MouseEvent) => { + const dx = ev.clientX - startX, dy = ev.clientY - startY; + if (xDir !== 0) el.style.width = `${Math.max(MIN_ITERATOR_WIDTH, startW + dx * xDir)}px`; + if (yDir !== 0) el.style.height = `${Math.max(MIN_ITERATOR_HEIGHT, startH + dy * yDir)}px`; + }; + const onUp = (ev: MouseEvent) => { + document.removeEventListener("mousemove", onMove); + document.removeEventListener("mouseup", onUp); + el.style.width = ""; el.style.height = ""; + setResizing(false); + const dx = ev.clientX - startX, dy = ev.clientY - startY; + const newW = xDir !== 0 ? Math.max(MIN_ITERATOR_WIDTH, startW + dx * xDir) : undefined; + const newH = yDir !== 0 ? Math.max(MIN_ITERATOR_HEIGHT, startH + dy * yDir) : undefined; + setNodes((nds) => nds.map((n) => { + if (n.id !== id) return n; + const pos = { ...n.position }; + if (xDir === -1) pos.x += dx / zoom; + if (yDir === -1) pos.y += dy / zoom; + const p = { ...n.data.params }; + if (newW !== undefined) p.__nodeWidth = newW; + if (newH !== undefined) p.__nodeHeight = newH; + return { ...n, position: pos, data: { ...n.data, params: p } }; + })); + useWorkflowStore.setState({ isDirty: true }); + }; + document.addEventListener("mousemove", onMove); + document.addEventListener("mouseup", onUp); + }, + [id, getViewport, setNodes], + ); + + /* ── Handle positions — aligned with port rows ─────────────────── */ + const getHandleTop = (index: number) => + TITLE_BAR_HEIGHT + PORT_HEADER_HEIGHT + PORT_ROW_HEIGHT * index + PORT_ROW_HEIGHT / 2; + + /* ── Port strip widths ─────────────────────────────────────────── */ + const leftStripWidth = inputDefs.length > 0 ? PORT_STRIP_WIDTH : PORT_STRIP_EMPTY_WIDTH; + const rightStripWidth = outputDefs.length > 0 ? PORT_STRIP_WIDTH : PORT_STRIP_EMPTY_WIDTH; + const contentHeight = effectiveHeight - TITLE_BAR_HEIGHT; + + /* ── Picker toggle helpers ─────────────────────────────────────── */ + const toggleInputPicker = useCallback((e: React.MouseEvent) => { + e.stopPropagation(); setShowInputPicker((v) => !v); setShowOutputPicker(false); + }, []); + const toggleOutputPicker = useCallback((e: React.MouseEvent) => { + e.stopPropagation(); setShowOutputPicker((v) => !v); setShowInputPicker(false); + }, []); + + return ( +
setHovered(true)} + onMouseLeave={() => { setHovered(false); }} + className="relative" + > + {/* Invisible hover extension above */} +
+ + {/* ── Hover toolbar ──────────────────────────────────────── */} + {hovered && ( +
+ {running ? ( + + ) : ( + <> + + + + + )} +
+ )} + + {/* ── Main container ─────────────────────────────────────── */} +
+ + {/* ── Title bar ──────────────────────────────────────── */} +
+ + +
+ + + + +
+ {data.label || t("workflow.iterator", "Iterator")} + {shortId} +
+ + {/* ── Config buttons: Input / Output — always in title bar ── */} + + + + + {t("workflow.configureInputs", "Configure exposed input parameters")} + + + + + + {t("workflow.configureOutputs", "Configure exposed output parameters")} + + + {/* ── Iteration count badge ── */} +
+ {editingCount ? ( + setCountDraft(e.target.value)} onBlur={commitCount} onKeyDown={onCountKeyDown} + className="nodrag nopan w-14 h-6 text-center text-[11px] font-medium rounded-full bg-cyan-500/20 text-cyan-400 border border-cyan-500/30 outline-none focus:ring-1 focus:ring-cyan-500/50" /> + ) : ( + + )} +
+
+ + {/* ── Expose-param pickers — rendered via portal to sit above child nodes ── */} + {showInputPicker && createPortal( + + setShowInputPicker(false)} /> + , + document.body, + )} + {showOutputPicker && createPortal( + + setShowOutputPicker(false)} /> + , + document.body, + )} + + {/* ── Running progress bar ───────────────────────────── */} + {running && !collapsed && ( +
+
+ + + + {progress?.message || t("workflow.running", "Running...")} + {progress && {Math.round(progress.progress)}%} +
+
+
+
+
+ )} + + {/* ── Error details + Retry ──────────────────────────── */} + {status === "error" && errorMessage && !collapsed && ( +
+
+ + {errorMessage} + +
+
+ )} + + {/* ── Body: Left port strip | Internal area | Right port strip ── */} + {!collapsed && ( +
+ + {/* ── Left strip: exposed inputs ──────────────────── */} +
+ {inputDefs.length > 0 ? ( + <> +
+ + {t("workflow.inputs", "IN")} + +
+
+ {inputDefs.map((port) => ( +
+ {port.label} +
+ ))} +
+ + ) : ( +
+ )} +
+ + {/* ── Internal canvas area ────────────────────────── */} +
+ {/* Empty state — arrow pointing down to Add Node button */} + {!hasChildren && ( +
+
+ {t("workflow.iteratorEmpty", "No child nodes yet")} + + + +
+
+ )} +
+ + {/* ── Right strip: exposed outputs ────────────────── */} +
+ {outputDefs.length > 0 ? ( + <> +
+ + {t("workflow.outputs", "OUT")} + +
+
+ {outputDefs.map((port) => ( +
+ {port.label} +
+ ))} +
+ + ) : ( +
+ )} +
+ +
+ )} + + {/* Collapsed child count */} + {collapsed && hasChildren && ( +
+ {t("workflow.childNodesCount", "{{count}} child node(s)", { count: childNodeIds.length })} +
+ )} + + {/* ── Resize handles ─────────────────────────────────── */} + {selected && !collapsed && ( + <> +
onEdgeResizeStart(e, 1, 0)} className="nodrag absolute top-2 right-0 bottom-2 w-[5px] cursor-ew-resize z-20 hover:bg-cyan-500/20" /> +
onEdgeResizeStart(e, -1, 0)} className="nodrag absolute top-2 left-0 bottom-2 w-[5px] cursor-ew-resize z-20 hover:bg-cyan-500/20" /> +
onEdgeResizeStart(e, 0, 1)} className="nodrag absolute bottom-0 left-2 right-2 h-[5px] cursor-ns-resize z-20 hover:bg-cyan-500/20" /> +
onEdgeResizeStart(e, 0, -1)} className="nodrag absolute top-0 left-2 right-2 h-[5px] cursor-ns-resize z-20 hover:bg-cyan-500/20" /> +
onEdgeResizeStart(e, 1, 1)} className="nodrag absolute bottom-0 right-0 w-3 h-3 cursor-se-resize z-30" /> +
onEdgeResizeStart(e, -1, 1)} className="nodrag absolute bottom-0 left-0 w-3 h-3 cursor-sw-resize z-30" /> +
onEdgeResizeStart(e, 1, -1)} className="nodrag absolute top-0 right-0 w-3 h-3 cursor-ne-resize z-30" /> +
onEdgeResizeStart(e, -1, -1)} className="nodrag absolute top-0 left-0 w-3 h-3 cursor-nw-resize z-30" /> + + )} +
+ + {/* ── Add Node button — portal to sit above ReactFlow child nodes ── */} + {!collapsed && createPortal( + , + document.body, + )} + + {/* ── Input handles (left border — external connections) ──── */} + {!collapsed && inputDefs.map((port, i) => ( + + ))} + + {/* ── Output handles (right border — external connections) ── */} + {!collapsed && outputDefs.map((port, i) => ( + + ))} + + {/* ── External "+" button — right side, for downstream nodes ── */} + {(hovered || selected) && ( + + + + + + {t("workflow.addNode", "Add Node")} + + + )} +
+ ); +} + +export default memo(IteratorNodeContainerComponent); +export { MIN_ITERATOR_WIDTH, MIN_ITERATOR_HEIGHT, CHILD_PADDING }; diff --git a/src/workflow/components/panels/NodeConfigPanel.tsx b/src/workflow/components/panels/NodeConfigPanel.tsx index e286d259..e1c9077b 100644 --- a/src/workflow/components/panels/NodeConfigPanel.tsx +++ b/src/workflow/components/panels/NodeConfigPanel.tsx @@ -2,7 +2,7 @@ * Node configuration panel — model selection for AI Task nodes. * Reuses the playground ModelSelector. */ -import { useState, useEffect, useCallback } from "react"; +import { useState, useEffect, useCallback, useMemo } from "react"; import { useTranslation } from "react-i18next"; import { useWorkflowStore } from "../../stores/workflow.store"; import { useExecutionStore } from "../../stores/execution.store"; @@ -13,8 +13,10 @@ import { convertDesktopModel } from "../../lib/model-converter"; import { ModelSelector } from "@/components/playground/ModelSelector"; import type { ParamDefinition, + PortDefinition, WaveSpeedModel, } from "@/workflow/types/node-defs"; +import type { ExposedParam } from "@/workflow/types/workflow"; /* ── Recent models (localStorage) ──────────────────────────────────── */ @@ -82,10 +84,37 @@ export function NodeConfigPanel({ } const isAITask = node.data.nodeType === "ai-task/run"; + const isIterator = node.data.nodeType === "control/iterator"; const params = node.data.params ?? {}; const handleChange = (key: string, value: unknown) => updateNodeParams(selectedNodeId, { ...params, [key]: value }); + // Detect if this node is a sub-node inside an Iterator + const parentIteratorId = (node as { parentNode?: string }).parentNode ?? null; + const parentIterator = parentIteratorId + ? nodes.find((n) => n.id === parentIteratorId) + : null; + const isInsideIterator = !!parentIterator; + + // Iterator self-config: show port management when the Iterator itself is selected + if (isIterator) { + return ( +
+ {!embeddedInNode && ( +

+ {t("workflow.iteratorConfig", "Iterator Configuration")} +

+ )} +
+ +
+
+ ); + } + return (
)} + {isInsideIterator && parentIterator && ( + + )} +
+ ); +} + +/* ── Expose/Unexpose Controls for sub-nodes inside Iterator ─────────── */ + +function ExposeParamControls({ + node, + parentIterator, + paramDefs, +}: { + node: { id: string; data: Record }; + parentIterator: { id: string; data: Record }; + paramDefs: ParamDefinition[]; +}) { + const { t } = useTranslation(); + const exposeParam = useWorkflowStore((s) => s.exposeParam); + const unexposeParam = useWorkflowStore((s) => s.unexposeParam); + + const subNodeLabel = String(node.data.label ?? node.id); + const iteratorParams = (parentIterator.data.params ?? {}) as Record; + + // Parse currently exposed inputs/outputs from the parent iterator + const exposedInputs: ExposedParam[] = useMemo(() => { + try { + const raw = iteratorParams.exposedInputs; + return typeof raw === "string" ? JSON.parse(raw) : []; + } catch { + return []; + } + }, [iteratorParams.exposedInputs]); + + const exposedOutputs: ExposedParam[] = useMemo(() => { + try { + const raw = iteratorParams.exposedOutputs; + return typeof raw === "string" ? JSON.parse(raw) : []; + } catch { + return []; + } + }, [iteratorParams.exposedOutputs]); + + // Filter paramDefs to only user-visible params (exclude internal __ params) + const visibleParamDefs = paramDefs.filter((d) => !d.key.startsWith("__")); + + // Get input/output port definitions from the node data + const inputDefs: PortDefinition[] = (node.data.inputDefinitions as PortDefinition[] | undefined) ?? []; + const outputDefs: PortDefinition[] = (node.data.outputDefinitions as PortDefinition[] | undefined) ?? []; + + const isExposed = (paramKey: string, direction: "input" | "output") => { + const nk = `${subNodeLabel}.${paramKey}`; + const list = direction === "input" ? exposedInputs : exposedOutputs; + return list.some((p) => p.namespacedKey === nk && p.subNodeId === node.id); + }; + + const getNamespacedKey = (paramKey: string) => `${subNodeLabel}.${paramKey}`; + + const handleExpose = (paramKey: string, direction: "input" | "output", dataType: string) => { + const param: ExposedParam = { + subNodeId: node.id, + subNodeLabel, + paramKey, + namespacedKey: getNamespacedKey(paramKey), + direction, + dataType: dataType as ExposedParam["dataType"], + }; + exposeParam(parentIterator.id, param); + }; + + const handleUnexpose = (paramKey: string, direction: "input" | "output") => { + unexposeParam(parentIterator.id, getNamespacedKey(paramKey), direction); + }; + + const hasExposableItems = + visibleParamDefs.length > 0 || inputDefs.length > 0 || outputDefs.length > 0; + + if (!hasExposableItems) return null; + + return ( +
+

+ {t("workflow.exposeParams", "Expose to Iterator")} +

+ + {/* Expose params as inputs */} + {visibleParamDefs.length > 0 && ( +
+
+ {t("workflow.params", "Parameters")} +
+ {visibleParamDefs.map((def) => { + const exposed = isExposed(def.key, "input"); + return ( + handleExpose(def.key, "input", def.dataType ?? "any")} + onUnexpose={() => handleUnexpose(def.key, "input")} + /> + ); + })} +
+ )} + + {/* Expose input ports as inputs */} + {inputDefs.length > 0 && ( +
+
+ {t("workflow.inputPorts", "Input Ports")} +
+ {inputDefs.map((port) => { + const exposed = isExposed(port.key, "input"); + return ( + handleExpose(port.key, "input", port.dataType)} + onUnexpose={() => handleUnexpose(port.key, "input")} + /> + ); + })} +
+ )} + + {/* Expose output ports as outputs */} + {outputDefs.length > 0 && ( +
+
+ {t("workflow.outputPorts", "Output Ports")} +
+ {outputDefs.map((port) => { + const exposed = isExposed(port.key, "output"); + return ( + handleExpose(port.key, "output", port.dataType)} + onUnexpose={() => handleUnexpose(port.key, "output")} + /> + ); + })} +
+ )} +
+ ); +} + +/* ── Single expose/unexpose row ─────────────────────────────────────── */ + +function ExposeRow({ + label, + paramKey: _paramKey, + direction, + exposed, + namespacedKey, + onExpose, + onUnexpose, +}: { + label: string; + paramKey: string; + direction: "input" | "output"; + exposed: boolean; + namespacedKey: string; + onExpose: () => void; + onUnexpose: () => void; +}) { + const { t } = useTranslation(); + const dirLabel = + direction === "input" + ? t("workflow.exposeAsInput", "Input") + : t("workflow.exposeAsOutput", "Output"); + + return ( +
+
+ {label} + {exposed && ( + + {namespacedKey} + + )} +
+ +
+ ); +} + +/* ── Iterator Self Config — shown when the Iterator node itself is selected ── */ + +function IteratorSelfConfig({ + iteratorNode, + allNodes, +}: { + iteratorNode: { id: string; data: Record }; + allNodes: Array<{ id: string; data: Record; parentNode?: string }>; +}) { + const { t } = useTranslation(); + const unexposeParam = useWorkflowStore((s) => s.unexposeParam); + const updateNodeParams = useWorkflowStore((s) => s.updateNodeParams); + + const iteratorParams = (iteratorNode.data.params ?? {}) as Record; + const iterationCount = Number(iteratorParams.iterationCount ?? 1); + + // Find child nodes + const childNodes = allNodes.filter((n) => n.parentNode === iteratorNode.id); + + // Parse currently exposed params + const exposedInputs: ExposedParam[] = useMemo(() => { + try { + const raw = iteratorParams.exposedInputs; + return typeof raw === "string" ? JSON.parse(raw) : []; + } catch { return []; } + }, [iteratorParams.exposedInputs]); + + const exposedOutputs: ExposedParam[] = useMemo(() => { + try { + const raw = iteratorParams.exposedOutputs; + return typeof raw === "string" ? JSON.parse(raw) : []; + } catch { return []; } + }, [iteratorParams.exposedOutputs]); + + return ( +
+ {/* Iteration count */} +
+ + { + const val = Math.max(1, Math.floor(Number(e.target.value) || 1)); + updateNodeParams(iteratorNode.id, { ...iteratorParams, iterationCount: val }); + }} + className="w-full rounded border border-input bg-background px-2 py-1.5 text-xs" + /> +
+ + {/* Child nodes summary */} +
+
+ {t("workflow.childNodes", "Child Nodes")} ({childNodes.length}) +
+ {childNodes.length === 0 ? ( +
+ {t("workflow.noChildNodes", "No child nodes. Drag nodes into the Iterator or use the Add Node button.")} +
+ ) : ( +
+ {childNodes.map((child) => ( +
+ + {String(child.data.label ?? child.data.nodeType ?? child.id)} +
+ ))} +
+ )} +
+ + {/* Exposed inputs */} +
+
+ {t("workflow.exposedInputs", "Exposed Inputs")} ({exposedInputs.length}) +
+ {exposedInputs.length === 0 ? ( +
+ {t("workflow.noExposedInputs", "Select a child node to expose its parameters as Iterator inputs.")} +
+ ) : ( +
+ {exposedInputs.map((ep) => ( +
+ {ep.namespacedKey} + +
+ ))} +
+ )} +
+ + {/* Exposed outputs */} +
+
+ {t("workflow.exposedOutputs", "Exposed Outputs")} ({exposedOutputs.length}) +
+ {exposedOutputs.length === 0 ? ( +
+ {t("workflow.noExposedOutputs", "Select a child node to expose its outputs as Iterator outputs.")} +
+ ) : ( +
+ {exposedOutputs.map((ep) => ( +
+ {ep.namespacedKey} + +
+ ))} +
+ )} +
+ + {/* Hint */} + {childNodes.length > 0 && ( +
+ {t("workflow.iteratorHint", "Tip: Click on a child node inside the Iterator to expose/unexpose its parameters and outputs.")} +
+ )}
); } diff --git a/src/workflow/hooks/useIteratorAdoption.ts b/src/workflow/hooks/useIteratorAdoption.ts new file mode 100644 index 00000000..5b4aeb36 --- /dev/null +++ b/src/workflow/hooks/useIteratorAdoption.ts @@ -0,0 +1,208 @@ +/** + * useIteratorAdoption — Hook that manages the relationship between Iterator + * containers and their child nodes. + * + * Key behaviors: + * - Child nodes are locked inside their parent Iterator (extent: "parent") + * - External nodes cannot be dragged into an Iterator — they must be created inside + * - Child nodes cannot be dragged out of their parent Iterator + * - Auto-adopts newly created nodes if their position falls inside an Iterator + * - Releases children before deletion and updates bounding boxes + * - Clamps child node positions to stay within the Iterator bounding box + */ +import { useCallback, useRef } from "react"; +import type { NodeChange } from "reactflow"; +import { useWorkflowStore } from "../stores/workflow.store"; + +/* ── constants ─────────────────────────────────────────────────────── */ + +const TITLE_BAR_HEIGHT = 40; +const CLAMP_PADDING = 10; +const PORT_STRIP_MIN_WIDTH = 24; // minimum width of port strips (when no ports) + +/* ── helpers ───────────────────────────────────────────────────────── */ + +/* ── hook ──────────────────────────────────────────────────────────── */ + +export function useIteratorAdoption() { + const draggingNodesRef = useRef>(new Set()); + + /** + * Call this from the onNodesChange wrapper to: + * 1. Clamp child nodes within their parent Iterator bounds during drag + * 2. Update bounding boxes when children move + * + * Child nodes are locked inside — they cannot be dragged out. + * External nodes are NOT auto-adopted on drag — only on creation. + */ + const handleNodesChangeForAdoption = useCallback( + (changes: NodeChange[]) => { + const posChanges = changes.filter( + (c): c is NodeChange & { + type: "position"; + id: string; + dragging?: boolean; + position?: { x: number; y: number }; + } => c.type === "position", + ); + if (posChanges.length === 0) return; + + const { nodes } = useWorkflowStore.getState(); + + // Track dragging state + for (const change of posChanges) { + if (change.dragging === true) { + draggingNodesRef.current.add(change.id); + } + if (change.dragging === false) { + draggingNodesRef.current.delete(change.id); + } + } + + // Clamp child nodes within their parent Iterator bounds + const nodesToClamp: Array<{ nodeId: string; clampedPos: { x: number; y: number } }> = []; + + // Collect all iterator nodes for external-node rejection + const iteratorNodes = nodes.filter((n) => n.type === "control/iterator"); + + for (const change of posChanges) { + const node = nodes.find((n) => n.id === change.id); + if (!node) continue; + + if (node.parentNode) { + // This node is a child of an Iterator — enforce bounds (keep inside) + const parentIterator = nodes.find((n) => n.id === node.parentNode); + if (!parentIterator) continue; + + const itW = (parentIterator.data?.params?.__nodeWidth as number) ?? 600; + const itH = (parentIterator.data?.params?.__nodeHeight as number) ?? 400; + + const pos = change.position ?? node.position; + const childW = (node.data?.params?.__nodeWidth as number) ?? 300; + const childH = (node.data?.params?.__nodeHeight as number) ?? 80; + + const inputDefs = parentIterator.data?.inputDefinitions ?? []; + const outputDefs = parentIterator.data?.outputDefinitions ?? []; + const leftStrip = (inputDefs as unknown[]).length > 0 ? 140 : PORT_STRIP_MIN_WIDTH; + const rightStrip = (outputDefs as unknown[]).length > 0 ? 140 : PORT_STRIP_MIN_WIDTH; + const minX = leftStrip + CLAMP_PADDING; + const maxX = Math.max(minX, itW - childW - rightStrip - CLAMP_PADDING); + const minY = TITLE_BAR_HEIGHT + CLAMP_PADDING; + const maxY = Math.max(minY, itH - childH - CLAMP_PADDING); + + const clampedX = Math.min(Math.max(pos.x, minX), maxX); + const clampedY = Math.min(Math.max(pos.y, minY), maxY); + + if (clampedX !== pos.x || clampedY !== pos.y) { + nodesToClamp.push({ + nodeId: change.id, + clampedPos: { x: clampedX, y: clampedY }, + }); + } + } else if (node.type !== "control/iterator" && change.dragging) { + // External node being dragged — reject if it overlaps any iterator + const pos = change.position ?? node.position; + const nodeW = (node.data?.params?.__nodeWidth as number) ?? 300; + const nodeH = (node.data?.params?.__nodeHeight as number) ?? 80; + + for (const it of iteratorNodes) { + const itX = it.position.x; + const itY = it.position.y; + const itW = (it.data?.params?.__nodeWidth as number) ?? 600; + const itH = (it.data?.params?.__nodeHeight as number) ?? 400; + + // Check overlap (AABB intersection) + const overlapsX = pos.x < itX + itW && pos.x + nodeW > itX; + const overlapsY = pos.y < itY + itH && pos.y + nodeH > itY; + + if (overlapsX && overlapsY) { + // Push the node to the nearest edge outside the iterator + const pushLeft = itX - nodeW - CLAMP_PADDING; + const pushRight = itX + itW + CLAMP_PADDING; + const pushTop = itY - nodeH - CLAMP_PADDING; + const pushBottom = itY + itH + CLAMP_PADDING; + + // Find the smallest displacement + const dLeft = Math.abs(pos.x - pushLeft); + const dRight = Math.abs(pos.x - pushRight); + const dTop = Math.abs(pos.y - pushTop); + const dBottom = Math.abs(pos.y - pushBottom); + const minD = Math.min(dLeft, dRight, dTop, dBottom); + + let newX = pos.x; + let newY = pos.y; + if (minD === dLeft) newX = pushLeft; + else if (minD === dRight) newX = pushRight; + else if (minD === dTop) newY = pushTop; + else newY = pushBottom; + + nodesToClamp.push({ + nodeId: change.id, + clampedPos: { x: newX, y: newY }, + }); + break; // only need to resolve one iterator collision + } + } + } + } + + // Apply clamped positions + if (nodesToClamp.length > 0) { + useWorkflowStore.setState((state) => ({ + nodes: state.nodes.map((n) => { + const clamp = nodesToClamp.find((c) => c.nodeId === n.id); + if (clamp) { + return { ...n, position: clamp.clampedPos }; + } + return n; + }), + })); + } + }, + [], + ); + + /** + * Call after a new node is created. + * + * External nodes (dragged from palette or pasted) are NOT auto-adopted + * into iterators. Adoption is handled directly by the NodePalette when + * pendingIteratorParentId is set. + */ + const handleNodeCreated = useCallback((_newNodeId: string) => { + // No-op: adoption is handled by NodePalette.handleClick when + // pendingIteratorParentId is set. External drag/paste should NOT + // auto-adopt into iterators. + }, []); + + /** + * Call before nodes are deleted. For each deleted node that has a parentNode + * (i.e., is a child of an Iterator), release it and update the bounding box. + */ + const handleNodesDeleted = useCallback((deletedNodeIds: string[]) => { + const { nodes, releaseNode, updateBoundingBox } = + useWorkflowStore.getState(); + + const affectedIteratorIds = new Set(); + + for (const nodeId of deletedNodeIds) { + const node = nodes.find((n) => n.id === nodeId); + if (!node || !node.parentNode) continue; + + const parentId = node.parentNode; + releaseNode(parentId, nodeId); + affectedIteratorIds.add(parentId); + } + + // Update bounding boxes for all affected iterators + for (const itId of affectedIteratorIds) { + updateBoundingBox(itId); + } + }, []); + + return { + handleNodesChangeForAdoption, + handleNodeCreated, + handleNodesDeleted, + }; +} diff --git a/src/workflow/lib/cycle-detection.ts b/src/workflow/lib/cycle-detection.ts new file mode 100644 index 00000000..41e5fac0 --- /dev/null +++ b/src/workflow/lib/cycle-detection.ts @@ -0,0 +1,51 @@ +/** + * Lightweight frontend cycle detection for sub-workflow validation. + * Mirrors the logic in electron/workflow/engine/dag-utils.ts so the + * canvas can reject cyclic connections without an IPC round-trip. + */ + +export interface SimpleEdge { + sourceNodeId: string; + targetNodeId: string; +} + +/** + * DFS-based cycle detection scoped to the given node set and edges. + */ +function hasCycle(nodeIds: string[], edges: SimpleEdge[]): boolean { + const adj = new Map(); + for (const id of nodeIds) adj.set(id, []); + for (const e of edges) adj.get(e.sourceNodeId)?.push(e.targetNodeId); + + const WHITE = 0, GRAY = 1, BLACK = 2; + const color = new Map(); + for (const id of nodeIds) color.set(id, WHITE); + + function dfs(node: string): boolean { + color.set(node, GRAY); + for (const neighbor of adj.get(node) ?? []) { + const c = color.get(neighbor); + if (c === GRAY) return true; + if (c === WHITE && dfs(neighbor)) return true; + } + color.set(node, BLACK); + return false; + } + + for (const id of nodeIds) { + if (color.get(id) === WHITE && dfs(id)) return true; + } + return false; +} + +/** + * Check whether adding `newEdge` would create a cycle within a sub-workflow + * defined by `subNodeIds` and `internalEdges`. + */ +export function wouldCreateCycleInSubWorkflow( + subNodeIds: string[], + internalEdges: SimpleEdge[], + newEdge: SimpleEdge, +): boolean { + return hasCycle(subNodeIds, [...internalEdges, newEdge]); +} diff --git a/src/workflow/stores/ui.store.ts b/src/workflow/stores/ui.store.ts index 959c8533..5f66e56f 100644 --- a/src/workflow/stores/ui.store.ts +++ b/src/workflow/stores/ui.store.ts @@ -37,6 +37,9 @@ export interface UIState { getViewportCenter: () => { x: number; y: number }; /** Register the getter (called once from WorkflowCanvas onInit) */ setGetViewportCenter: (fn: () => { x: number; y: number }) => void; + /** When set, the next node created from the palette will be adopted by this Iterator */ + pendingIteratorParentId: string | null; + setPendingIteratorParentId: (id: string | null) => void; selectNode: (nodeId: string | null) => void; selectNodes: (nodeIds: string[]) => void; @@ -84,6 +87,8 @@ export const useUIStore = create((set, get) => ({ interactionMode: "hand", getViewportCenter: () => ({ x: 200, y: 150 }), setGetViewportCenter: (fn) => set({ getViewportCenter: fn }), + pendingIteratorParentId: null, + setPendingIteratorParentId: (id) => set({ pendingIteratorParentId: id }), selectNode: (nodeId) => set({ @@ -117,6 +122,8 @@ export const useUIStore = create((set, get) => ({ set((s) => ({ showNodePalette: !s.showNodePalette, showWorkflowPanel: false, + // Clear pending iterator parent when closing the palette + ...(!s.showNodePalette ? {} : { pendingIteratorParentId: null }), })), toggleWorkflowPanel: () => set((s) => ({ diff --git a/src/workflow/stores/workflow.store.ts b/src/workflow/stores/workflow.store.ts index 2dea1742..74aae629 100644 --- a/src/workflow/stores/workflow.store.ts +++ b/src/workflow/stores/workflow.store.ts @@ -15,7 +15,15 @@ import { } from "reactflow"; import { v4 as uuid } from "uuid"; import { workflowIpc, registryIpc } from "../ipc/ipc-client"; -import type { WorkflowNode, WorkflowEdge } from "@/workflow/types/workflow"; +import type { WorkflowNode, WorkflowEdge, ExposedParam } from "@/workflow/types/workflow"; +import type { PortDefinition } from "@/workflow/types/node-defs"; +import { wouldCreateCycleInSubWorkflow } from "@/workflow/lib/cycle-detection"; + +/* ── Iterator bounding-box constants ──────────────────────────────── */ +const MIN_ITERATOR_WIDTH = 600; +const MIN_ITERATOR_HEIGHT = 400; +const CHILD_PADDING = 40; +const PORT_STRIP_WIDTH = 140; /** Lazy getter to avoid circular import with execution.store */ function getActiveExecutions(): Set { @@ -215,6 +223,7 @@ async function _doSaveWorkflow( }, }, currentOutputId: null, // placeholder — repo will preserve existing DB value + parentNodeId: n.parentNode ?? null, })); const wfEdges: WorkflowEdge[] = edges.map((e) => ({ @@ -224,6 +233,7 @@ async function _doSaveWorkflow( sourceOutputKey: e.sourceHandle ?? "output", targetNodeId: e.target, targetInputKey: e.targetHandle ?? "input", + isInternal: e.data?.isInternal === true, })); await workflowIpc.save({ @@ -282,6 +292,11 @@ export interface WorkflowState { newWorkflow: (name: string) => Promise; setWorkflowName: (name: string) => void; renameWorkflow: (newName: string) => Promise; + adoptNode: (iteratorId: string, childId: string) => void; + releaseNode: (iteratorId: string, childId: string) => void; + updateBoundingBox: (iteratorId: string) => void; + exposeParam: (iteratorId: string, param: ExposedParam) => void; + unexposeParam: (iteratorId: string, namespacedKey: string, direction: "input" | "output") => void; reset: () => void; } @@ -308,7 +323,7 @@ export const useWorkflowStore = create((set, get) => ({ const id = uuid(); const newNode: ReactFlowNode = { id, - type: "custom", + type: type === "control/iterator" ? "control/iterator" : "custom", position, data: { nodeType: type, @@ -411,6 +426,50 @@ export const useWorkflowStore = create((set, get) => ({ e.targetHandle === (connection.targetHandle ?? "input"), ); if (duplicate) return; + + // ── Sub-workflow cycle detection ── + // If both source and target are inside the same Iterator, validate that + // the new edge won't create a cycle within the sub-workflow. + const sourceNode = nodes.find((n) => n.id === connection.source); + const targetNode = nodes.find((n) => n.id === connection.target); + const isInternal = + sourceNode?.parentNode && + targetNode?.parentNode && + sourceNode.parentNode === targetNode.parentNode; + + if (isInternal) { + const parentId = sourceNode!.parentNode!; + const subNodeIds = nodes + .filter((n) => n.parentNode === parentId) + .map((n) => n.id); + const internalEdges = edges + .filter( + (e) => + e.data?.isInternal && + subNodeIds.includes(e.source) && + subNodeIds.includes(e.target), + ) + .map((e) => ({ sourceNodeId: e.source, targetNodeId: e.target })); + + const wouldCycle = wouldCreateCycleInSubWorkflow( + subNodeIds, + internalEdges, + { sourceNodeId: connection.source, targetNodeId: connection.target }, + ); + if (wouldCycle) { + // Reject the connection — dispatch a toast event for the UI + window.dispatchEvent( + new CustomEvent("workflow:toast", { + detail: { + type: "error", + msg: "Cannot connect: this would create a cycle inside the Iterator", + }, + }), + ); + return; + } + } + // Guard: a target handle can only have one incoming connection const targetHandle = connection.targetHandle ?? "input"; const existingToTarget = edges.some( @@ -431,6 +490,7 @@ export const useWorkflowStore = create((set, get) => ({ sourceHandle: connection.sourceHandle ?? "output", targetHandle, type: "custom", + ...(isInternal ? { data: { isInternal: true } } : {}), }; set({ edges: [...filtered, newEdge], @@ -448,6 +508,7 @@ export const useWorkflowStore = create((set, get) => ({ sourceHandle: connection.sourceHandle ?? "output", targetHandle, type: "custom", + ...(isInternal ? { data: { isInternal: true } } : {}), }; set((state) => ({ edges: [...state.edges, newEdge], @@ -572,6 +633,13 @@ export const useWorkflowStore = create((set, get) => ({ canUndo: true, canRedo: false, })); + + // If this node is a child of an Iterator, recalculate the bounding box + // so the Iterator auto-expands when child node size changes + const node = nodes.find((n) => n.id === nodeId); + if (node?.parentNode) { + get().updateBoundingBox(node.parentNode); + } }, updateNodeData: (nodeId, dataUpdate) => { @@ -621,6 +689,11 @@ export const useWorkflowStore = create((set, get) => ({ ? { canUndo: true, canRedo: false } : {}), })); + + // NOTE: We intentionally do NOT call updateBoundingBox on position changes. + // The iterator border should only expand when child nodes are added or their + // UI size changes (e.g. model switch), not when dragging children around. + // Children are clamped within the iterator bounds by useIteratorAdoption. }, onEdgesChange: (changes) => { @@ -708,6 +781,16 @@ export const useWorkflowStore = create((set, get) => ({ // Keep empty map as fallback; nodes still load with persisted params. } + // Build a map of iteratorId → childNodeIds from parent relationships + const iteratorChildMap = new Map(); + for (const n of wf.graphDefinition.nodes) { + if (n.parentNodeId) { + const children = iteratorChildMap.get(n.parentNodeId) ?? []; + children.push(n.id); + iteratorChildMap.set(n.parentNodeId, children); + } + } + const rfNodes: ReactFlowNode[] = wf.graphDefinition.nodes.map((n) => { // Restore modelInputSchema and label from saved params metadata const meta = (n.params as Record).__meta as @@ -718,9 +801,11 @@ export const useWorkflowStore = create((set, get) => ({ const label = (meta?.label as string) || (def ? def.label : n.nodeType); // Strip __meta from the params passed to the node const { __meta: _, ...cleanParams } = n.params as Record; - return { + + const isIterator = n.nodeType === "control/iterator"; + const rfNode: ReactFlowNode = { id: n.id, - type: "custom", + type: isIterator ? "control/iterator" : "custom", position: n.position, data: { nodeType: n.nodeType, @@ -730,8 +815,19 @@ export const useWorkflowStore = create((set, get) => ({ paramDefinitions: def?.params ?? [], inputDefinitions: def?.inputs ?? [], outputDefinitions: def?.outputs ?? [], + ...(isIterator + ? { childNodeIds: iteratorChildMap.get(n.id) ?? [] } + : {}), }, }; + + // Restore parent-child relationship for sub-nodes + if (n.parentNodeId) { + rfNode.parentNode = n.parentNodeId; + rfNode.extent = "parent" as const; + } + + return rfNode; }); const rfEdges: ReactFlowEdge[] = wf.graphDefinition.edges.map((e) => ({ id: e.id, @@ -740,6 +836,7 @@ export const useWorkflowStore = create((set, get) => ({ sourceHandle: e.sourceOutputKey, targetHandle: e.targetInputKey, type: "custom", + ...(e.isInternal ? { data: { isInternal: true } } : {}), })); set({ workflowId: wf.id, @@ -749,6 +846,11 @@ export const useWorkflowStore = create((set, get) => ({ isDirty: false, }); + // Recalculate bounding boxes for all iterator nodes + for (const iteratorId of iteratorChildMap.keys()) { + get().updateBoundingBox(iteratorId); + } + // Restore previous execution results for all nodes try { const executionStoreModule = await import("./execution.store"); @@ -797,6 +899,313 @@ export const useWorkflowStore = create((set, get) => ({ } }, + adoptNode: (iteratorId, childId) => { + const { nodes, edges } = get(); + + const iteratorNode = nodes.find((n) => n.id === iteratorId); + const childNode = nodes.find((n) => n.id === childId); + if (!iteratorNode || !childNode) return; + + // Prevent nesting: reject if the child is itself an Iterator node + if (childNode.data.nodeType === "control/iterator") return; + + // Reject if child already has a parent + if (childNode.parentNode) return; + + pushUndo({ nodes, edges }); + + // Convert child position to relative coordinates (relative to iterator) + const relativePosition = { + x: childNode.position.x - iteratorNode.position.x, + y: childNode.position.y - iteratorNode.position.y, + }; + + // Update childNodeIds in iterator data + const currentChildIds: string[] = + iteratorNode.data.childNodeIds ?? []; + const updatedChildIds = currentChildIds.includes(childId) + ? currentChildIds + : [...currentChildIds, childId]; + + set((state) => ({ + nodes: state.nodes.map((n) => { + if (n.id === childId) { + return { + ...n, + position: relativePosition, + parentNode: iteratorId, + extent: "parent" as const, + data: { ...n.data }, + }; + } + if (n.id === iteratorId) { + return { + ...n, + data: { + ...n.data, + childNodeIds: updatedChildIds, + }, + }; + } + return n; + }), + isDirty: true, + canUndo: true, + canRedo: false, + })); + + // Recalculate bounding box after adopting the child + get().updateBoundingBox(iteratorId); + }, + + releaseNode: (iteratorId, childId) => { + const { nodes, edges } = get(); + + const iteratorNode = nodes.find((n) => n.id === iteratorId); + const childNode = nodes.find((n) => n.id === childId); + if (!iteratorNode || !childNode) return; + + // Only release if the child actually belongs to this iterator + if (childNode.parentNode !== iteratorId) return; + + pushUndo({ nodes, edges }); + + // Convert child position back to absolute coordinates + const absolutePosition = { + x: childNode.position.x + iteratorNode.position.x, + y: childNode.position.y + iteratorNode.position.y, + }; + + // Remove from childNodeIds in iterator data + const currentChildIds: string[] = + iteratorNode.data.childNodeIds ?? []; + const updatedChildIds = currentChildIds.filter( + (id: string) => id !== childId, + ); + + set((state) => ({ + nodes: state.nodes.map((n) => { + if (n.id === childId) { + // Remove parentNode and extent by spreading without them + const { parentNode: _, extent: _e, ...rest } = n; + return { + ...rest, + position: absolutePosition, + data: { ...n.data }, + }; + } + if (n.id === iteratorId) { + return { + ...n, + data: { + ...n.data, + childNodeIds: updatedChildIds, + }, + }; + } + return n; + }), + isDirty: true, + canUndo: true, + canRedo: false, + })); + + // Recalculate bounding box after releasing the child + get().updateBoundingBox(iteratorId); + }, + + updateBoundingBox: (iteratorId) => { + const { nodes } = get(); + const iteratorNode = nodes.find((n) => n.id === iteratorId); + if (!iteratorNode) return; + + const children = nodes.filter((n) => n.parentNode === iteratorId); + const currentParams = iteratorNode.data.params ?? {}; + const currentW = (currentParams.__nodeWidth as number) ?? MIN_ITERATOR_WIDTH; + const currentH = (currentParams.__nodeHeight as number) ?? MIN_ITERATOR_HEIGHT; + + // Only expand — never shrink. If children fit inside the current size, do nothing. + let requiredWidth = MIN_ITERATOR_WIDTH; + let requiredHeight = MIN_ITERATOR_HEIGHT; + + if (children.length > 0) { + let maxRight = 0; + let maxBottom = 0; + for (const child of children) { + const cw = (child.data?.params?.__nodeWidth as number) ?? 300; + // Use DOM measurement for height when available (child nodes auto-size vertically) + let ch = (child.data?.params?.__nodeHeight as number) ?? 80; + try { + const el = document.querySelector(`[data-id="${child.id}"]`) as HTMLElement | null; + if (el) ch = Math.max(ch, el.offsetHeight); + } catch { /* ignore DOM errors */ } + const right = child.position.x + cw + CHILD_PADDING; + const bottom = child.position.y + ch + CHILD_PADDING; + if (right > maxRight) maxRight = right; + if (bottom > maxBottom) maxBottom = bottom; + } + requiredWidth = Math.max(MIN_ITERATOR_WIDTH, maxRight + PORT_STRIP_WIDTH); + requiredHeight = Math.max(MIN_ITERATOR_HEIGHT, maxBottom); + } + + // Only grow, never shrink + const newWidth = Math.max(currentW, requiredWidth); + const newHeight = Math.max(currentH, requiredHeight); + + if (currentW === newWidth && currentH === newHeight) { + return; // no change needed + } + + set((state) => ({ + nodes: state.nodes.map((n) => { + if (n.id === iteratorId) { + return { + ...n, + data: { + ...n.data, + params: { + ...n.data.params, + __nodeWidth: newWidth, + __nodeHeight: newHeight, + }, + }, + }; + } + return n; + }), + isDirty: true, + })); + }, + + exposeParam: (iteratorId, param) => { + const { nodes, edges } = get(); + const iteratorNode = nodes.find((n) => n.id === iteratorId); + if (!iteratorNode) return; + + pushUndo({ nodes, edges }); + + const params = iteratorNode.data.params ?? {}; + const paramListKey = param.direction === "input" ? "exposedInputs" : "exposedOutputs"; + const defKey = param.direction === "input" ? "inputDefinitions" : "outputDefinitions"; + + // Parse existing exposed params + const currentList: ExposedParam[] = (() => { + try { + const raw = params[paramListKey]; + return typeof raw === "string" ? JSON.parse(raw) : []; + } catch { + return []; + } + })(); + + // Don't add duplicates + if (currentList.some((p: ExposedParam) => p.namespacedKey === param.namespacedKey)) return; + + const updatedList = [...currentList, param]; + + // Build new port definition + const newPort: PortDefinition = { + key: param.namespacedKey, + label: param.namespacedKey, + dataType: param.dataType, + required: false, + }; + + const currentDefs: PortDefinition[] = iteratorNode.data[defKey] ?? []; + const updatedDefs = [...currentDefs, newPort]; + + set((state) => ({ + nodes: state.nodes.map((n) => { + if (n.id === iteratorId) { + return { + ...n, + data: { + ...n.data, + params: { + ...n.data.params, + [paramListKey]: JSON.stringify(updatedList), + }, + [defKey]: updatedDefs, + }, + }; + } + return n; + }), + isDirty: true, + canUndo: true, + canRedo: false, + })); + }, + + unexposeParam: (iteratorId, namespacedKey, direction) => { + const { nodes, edges } = get(); + const iteratorNode = nodes.find((n) => n.id === iteratorId); + if (!iteratorNode) return; + + pushUndo({ nodes, edges }); + + const params = iteratorNode.data.params ?? {}; + const paramListKey = direction === "input" ? "exposedInputs" : "exposedOutputs"; + const defKey = direction === "input" ? "inputDefinitions" : "outputDefinitions"; + + // Parse existing exposed params and remove the matching entry + const currentList: ExposedParam[] = (() => { + try { + const raw = params[paramListKey]; + return typeof raw === "string" ? JSON.parse(raw) : []; + } catch { + return []; + } + })(); + + const updatedList = currentList.filter( + (p: ExposedParam) => p.namespacedKey !== namespacedKey, + ); + + // Remove the corresponding port definition + const currentDefs: PortDefinition[] = iteratorNode.data[defKey] ?? []; + const updatedDefs = currentDefs.filter( + (d: PortDefinition) => d.key !== namespacedKey, + ); + + // Remove any connected edges to/from the handle + const handleId = direction === "input" + ? `input-${namespacedKey}` + : `output-${namespacedKey}`; + + const edgesToRemove = edges.filter((e) => + direction === "input" + ? e.target === iteratorId && e.targetHandle === handleId + : e.source === iteratorId && e.sourceHandle === handleId, + ); + const edgeIdsToRemove = new Set(edgesToRemove.map((e) => e.id)); + + set((state) => ({ + nodes: state.nodes.map((n) => { + if (n.id === iteratorId) { + return { + ...n, + data: { + ...n.data, + params: { + ...n.data.params, + [paramListKey]: JSON.stringify(updatedList), + }, + [defKey]: updatedDefs, + }, + }; + } + return n; + }), + edges: edgeIdsToRemove.size > 0 + ? state.edges.filter((e) => !edgeIdsToRemove.has(e.id)) + : state.edges, + isDirty: true, + canUndo: true, + canRedo: false, + })); + }, + reset: () => { const { nodes, edges } = getDefaultNewWorkflowContent(); set({ diff --git a/src/workflow/types/index.ts b/src/workflow/types/index.ts index 7f2d002f..e48dd29c 100644 --- a/src/workflow/types/index.ts +++ b/src/workflow/types/index.ts @@ -4,6 +4,7 @@ export type { GraphDefinition, WorkflowNode, WorkflowEdge, + ExposedParam, } from "./workflow"; export type { diff --git a/src/workflow/types/workflow.ts b/src/workflow/types/workflow.ts index e6ddd66e..36ce3588 100644 --- a/src/workflow/types/workflow.ts +++ b/src/workflow/types/workflow.ts @@ -2,6 +2,8 @@ * Workflow data types — core domain models for workflow persistence and graph structure. */ +import type { PortDataType } from "./node-defs"; + export type WorkflowStatus = "draft" | "ready" | "archived"; export interface Workflow { @@ -25,6 +27,7 @@ export interface WorkflowNode { position: { x: number; y: number }; params: Record; currentOutputId: string | null; + parentNodeId?: string | null; } export interface WorkflowEdge { @@ -36,4 +39,14 @@ export interface WorkflowEdge { targetInputKey: string; connectionType?: "port" | "parameter"; targetParamType?: string; + isInternal?: boolean; +} + +export interface ExposedParam { + subNodeId: string; + subNodeLabel: string; + paramKey: string; + namespacedKey: string; + direction: "input" | "output"; + dataType: PortDataType; } From d0e970fbe39709a1bb96a6d3c25d755d46bcb9eb Mon Sep 17 00:00:00 2001 From: Leon Date: Fri, 13 Mar 2026 17:42:51 +1100 Subject: [PATCH 02/18] feat: directory-import node, iterator capsule fix, execution yield, results cleanup - Directory Import node: full electron + browser implementation with IPC - Remove custom file preview grid from DirectoryImportBody, use shared ResultsPanel - Fix iterator capsule positioning: remove dynamic capsuleExtraOffset, use fixed CAPSULE_TOP_OFFSET - Add event loop yield between topological levels to prevent UI freeze - File Export: support array inputs from iterator output --- electron/main.ts | 43 ++ electron/preload.ts | 4 + electron/workflow/nodes/control/iterator.ts | 101 ++- .../workflow/nodes/input/directory-import.ts | 134 ++++ electron/workflow/nodes/register-all.ts | 2 + src/workflow/WorkflowPage.tsx | 14 +- src/workflow/browser/node-definitions.ts | 33 + src/workflow/browser/run-in-browser.ts | 418 ++++++++++-- .../components/canvas/ExecutionToolbar.tsx | 1 + .../canvas/custom-node/CustomNode.tsx | 21 +- .../canvas/custom-node/CustomNodeBody.tsx | 15 +- .../custom-node/CustomNodeInputBodies.tsx | 168 +++++ .../canvas/custom-node/NodeIcons.tsx | 2 + .../iterator-node/IteratorNodeContainer.tsx | 608 +++++++++++------- src/workflow/hooks/useIteratorAdoption.ts | 11 +- src/workflow/stores/execution.store.ts | 10 + src/workflow/stores/workflow.store.ts | 400 ++++++++++-- 17 files changed, 1643 insertions(+), 342 deletions(-) create mode 100644 electron/workflow/nodes/input/directory-import.ts diff --git a/electron/main.ts b/electron/main.ts index ff1f11a1..e0df2ad0 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -776,6 +776,49 @@ ipcMain.handle("select-directory", async () => { return { success: true, path: result.filePaths[0] }; }); +// Directory Import node — pick a directory for media scanning +ipcMain.handle("pick-directory", async () => { + const focusedWindow = BrowserWindow.getFocusedWindow(); + if (!focusedWindow) return { success: false, error: "No focused window" }; + + const result = await dialog.showOpenDialog(focusedWindow, { + properties: ["openDirectory"], + title: "Select Media Directory", + }); + + if (result.canceled || !result.filePaths[0]) { + return { success: false, canceled: true }; + } + + return { success: true, path: result.filePaths[0] }; +}); + +// Directory Import node — scan a directory for media files +ipcMain.handle( + "scan-directory", + async (_, dirPath: string, allowedExts: string[]) => { + const { readdirSync } = require("fs"); + const { join, extname } = require("path"); + + const extSet = new Set(allowedExts.map((e: string) => e.toLowerCase())); + const results: string[] = []; + try { + const entries = readdirSync(dirPath, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isFile()) { + const ext = extname(entry.name).toLowerCase(); + if (extSet.has(ext)) { + results.push(join(dirPath, entry.name)); + } + } + } + } catch { + // Skip unreadable + } + return results; + }, +); + ipcMain.handle( "save-asset", async (_, url: string, _type: string, fileName: string, subDir: string) => { diff --git a/electron/preload.ts b/electron/preload.ts index b3013a08..7a4a24c5 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -139,6 +139,10 @@ const electronAPI = { ipcRenderer.invoke("get-zimage-output-path"), selectDirectory: (): Promise => ipcRenderer.invoke("select-directory"), + pickDirectory: (): Promise => + ipcRenderer.invoke("pick-directory"), + scanDirectory: (dirPath: string, allowedExts: string[]): Promise => + ipcRenderer.invoke("scan-directory", dirPath, allowedExts), saveAsset: ( url: string, type: string, diff --git a/electron/workflow/nodes/control/iterator.ts b/electron/workflow/nodes/control/iterator.ts index d66a599e..8ee6945f 100644 --- a/electron/workflow/nodes/control/iterator.ts +++ b/electron/workflow/nodes/control/iterator.ts @@ -28,6 +28,12 @@ export const iteratorDef: NodeTypeDefinition = { default: 1, validation: { min: 1 }, }, + { + key: "iterationMode", + label: "Iteration Mode", + type: "string", + default: "fixed", + }, { key: "exposedInputs", label: "Exposed Inputs", @@ -52,7 +58,8 @@ export class IteratorNodeHandler extends BaseNodeHandler { const start = Date.now(); // 1. Parse iteration config from params - const iterationCount = Math.max(1, Number(ctx.params.iterationCount) || 1); + const iterationMode = String(ctx.params.iterationMode ?? "fixed"); + const fixedCount = Math.max(1, Number(ctx.params.iterationCount) || 1); const exposedInputs = this.parseExposedParams(ctx.params.exposedInputs); const exposedOutputs = this.parseExposedParams(ctx.params.exposedOutputs); @@ -87,18 +94,63 @@ export class IteratorNodeHandler extends BaseNodeHandler { const childNodeMap = new Map(childNodes.map((n) => [n.id, n])); // Build input routing: map from subNodeId -> paramKey -> external value - const inputRouting = new Map>(); + // In auto mode, store the raw values (may be arrays) for per-iteration slicing + const inputRoutingRaw = new Map>(); for (const ep of exposedInputs) { const externalValue = ctx.inputs[ep.namespacedKey]; if (externalValue !== undefined) { - if (!inputRouting.has(ep.subNodeId)) { - inputRouting.set(ep.subNodeId, new Map()); + if (!inputRoutingRaw.has(ep.subNodeId)) { + inputRoutingRaw.set(ep.subNodeId, new Map()); + } + inputRoutingRaw.get(ep.subNodeId)!.set(ep.paramKey, externalValue); + } + } + + // 5. Determine iteration count + let iterationCount: number; + // Collect all external input values for auto-mode analysis + const allExternalValues: unknown[] = []; + for (const ep of exposedInputs) { + const v = ctx.inputs[ep.namespacedKey]; + if (v !== undefined) allExternalValues.push(v); + } + + if (iterationMode === "auto") { + // Find the longest array among external inputs + const arrayLengths = allExternalValues + .filter((v) => Array.isArray(v)) + .map((v) => (v as unknown[]).length); + + if (arrayLengths.length === 0) { + // No array inputs found — if there are any inputs at all, run once; otherwise error + if (allExternalValues.length > 0) { + iterationCount = 1; + } else { + return { + status: "error", + outputs: {}, + durationMs: Date.now() - start, + cost: 0, + error: "Auto mode: no external inputs connected. Connect an array input or switch to fixed mode.", + }; + } + } else { + iterationCount = Math.max(...arrayLengths); + // Empty array → 0 iterations + if (iterationCount === 0) { + return { + status: "success", + outputs: {}, + durationMs: Date.now() - start, + cost: 0, + }; } - inputRouting.get(ep.subNodeId)!.set(ep.paramKey, externalValue); } + } else { + iterationCount = fixedCount; } - // 5. Execute iterations + // 6. Execute iterations const iterationResults: Array> = []; let totalCost = 0; @@ -134,11 +186,22 @@ export class IteratorNodeHandler extends BaseNodeHandler { // Build params for this sub-node: base params + external inputs + iteration index const subParams: Record = { ...subNode.params }; - // Inject external input values - const externalInputs = inputRouting.get(subNodeId); + // Inject external input values (with auto-mode array slicing) + const externalInputs = inputRoutingRaw.get(subNodeId); if (externalInputs) { - for (const [paramKey, value] of externalInputs) { - subParams[paramKey] = value; + for (const [paramKey, rawValue] of externalInputs) { + if (iterationMode === "auto" && Array.isArray(rawValue)) { + // Slice: use element at index i, pad with last element if shorter + const arr = rawValue as unknown[]; + subParams[paramKey] = arr.length > 0 ? arr[Math.min(i, arr.length - 1)] : undefined; + } else if (iterationMode === "fixed" && Array.isArray(rawValue)) { + // Fixed mode with array: cycle with modulo + const arr = rawValue as unknown[]; + subParams[paramKey] = arr.length > 0 ? arr[i % arr.length] : undefined; + } else { + // Non-array: broadcast same value to all iterations + subParams[paramKey] = rawValue; + } } } @@ -221,19 +284,13 @@ export class IteratorNodeHandler extends BaseNodeHandler { ctx.onProgress(((i + 1) / iterationCount) * 100, `Iteration ${i + 1}/${iterationCount} complete`); } - // 6. Aggregate results + // 6. Aggregate results — ALWAYS output arrays regardless of iteration count + // This ensures downstream nodes always receive a consistent format. + // N=1 → ["value"], N=3 → ["v1","v2","v3"], N=0 → [] const outputs: Record = {}; - if (iterationCount === 1) { - // N=1: return results directly - Object.assign(outputs, iterationResults[0]); - } else { - // N>1: aggregate into arrays per exposed output - for (const ep of exposedOutputs) { - const handleKey = `output-${ep.namespacedKey}`; - outputs[handleKey] = iterationResults.map( - (r) => r[handleKey], - ); - } + for (const ep of exposedOutputs) { + const handleKey = `output-${ep.namespacedKey}`; + outputs[handleKey] = iterationResults.map((r) => r[handleKey]); } return { diff --git a/electron/workflow/nodes/input/directory-import.ts b/electron/workflow/nodes/input/directory-import.ts new file mode 100644 index 00000000..91510ecf --- /dev/null +++ b/electron/workflow/nodes/input/directory-import.ts @@ -0,0 +1,134 @@ +/** + * Directory Import node — scans a local directory for media files + * and outputs an array of file URLs. + * + * Designed to feed into an Iterator (auto mode) for batch processing. + * Output is always an array of local-asset:// URLs. + */ +import { + BaseNodeHandler, + type NodeExecutionContext, + type NodeExecutionResult, +} from "../base"; +import type { NodeTypeDefinition } from "../../../../src/workflow/types/node-defs"; +import { readdirSync, statSync, existsSync } from "fs"; +import { join, extname } from "path"; + +const MEDIA_EXTENSIONS: Record = { + image: [".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".tiff", ".tif", ".svg", ".avif"], + video: [".mp4", ".webm", ".mov", ".avi", ".mkv", ".flv", ".wmv", ".m4v"], + audio: [".mp3", ".wav", ".flac", ".m4a", ".ogg", ".aac", ".wma"], + all: [], // populated at runtime from all above +}; +MEDIA_EXTENSIONS.all = [ + ...MEDIA_EXTENSIONS.image, + ...MEDIA_EXTENSIONS.video, + ...MEDIA_EXTENSIONS.audio, +]; + +export const directoryImportDef: NodeTypeDefinition = { + type: "input/directory-import", + category: "input", + label: "Directory", + inputs: [], + outputs: [{ key: "output", label: "Files", dataType: "any", required: true }], + params: [ + { + key: "directoryPath", + label: "Directory", + type: "string", + dataType: "text", + connectable: false, + default: "", + }, + { + key: "mediaType", + label: "File Type", + type: "select", + dataType: "text", + connectable: false, + default: "image", + options: [ + { label: "Images", value: "image" }, + { label: "Videos", value: "video" }, + { label: "Audio", value: "audio" }, + { label: "All Media", value: "all" }, + ], + }, + ], +}; + +export class DirectoryImportHandler extends BaseNodeHandler { + constructor() { + super(directoryImportDef); + } + + async execute(ctx: NodeExecutionContext): Promise { + const start = Date.now(); + const dirPath = String(ctx.params.directoryPath ?? "").trim(); + const mediaType = String(ctx.params.mediaType ?? "image"); + + if (!dirPath) { + return { + status: "error", + outputs: {}, + durationMs: Date.now() - start, + cost: 0, + error: "No directory selected. Please choose a directory.", + }; + } + + if (!existsSync(dirPath)) { + return { + status: "error", + outputs: {}, + durationMs: Date.now() - start, + cost: 0, + error: `Directory not found: ${dirPath}`, + }; + } + + const allowedExts = new Set(MEDIA_EXTENSIONS[mediaType] ?? MEDIA_EXTENSIONS.all); + const files = scanDirectory(dirPath, allowedExts); + files.sort(); // deterministic order + + // Convert to local-asset:// URLs + const urls = files.map((f) => `local-asset://${encodeURIComponent(f)}`); + + ctx.onProgress(100, `Found ${urls.length} file(s)`); + + return { + status: "success", + outputs: { output: urls }, + resultPath: urls[0] ?? "", + resultMetadata: { + output: urls, + resultUrl: urls[0] ?? "", + resultUrls: urls, + fileCount: urls.length, + directory: dirPath, + mediaType, + }, + durationMs: Date.now() - start, + cost: 0, + }; + } +} + +function scanDirectory(dir: string, allowedExts: Set): string[] { + const results: string[] = []; + try { + const entries = readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isFile()) { + const ext = extname(entry.name).toLowerCase(); + if (allowedExts.has(ext)) { + results.push(join(dir, entry.name)); + } + } + } + } catch { + // Skip unreadable directories + } + return results; +} diff --git a/electron/workflow/nodes/register-all.ts b/electron/workflow/nodes/register-all.ts index 6e588a89..0ca5c317 100644 --- a/electron/workflow/nodes/register-all.ts +++ b/electron/workflow/nodes/register-all.ts @@ -1,6 +1,7 @@ import { nodeRegistry } from "./registry"; import { mediaUploadDef, MediaUploadHandler } from "./input/media-upload"; import { textInputDef, TextInputHandler } from "./input/text-input"; +import { directoryImportDef, DirectoryImportHandler } from "./input/directory-import"; import { aiTaskDef, AITaskHandler } from "./ai-task/run"; import { fileExportDef, FileExportHandler } from "./output/file"; import { previewDisplayDef, PreviewDisplayHandler } from "./output/preview"; @@ -12,6 +13,7 @@ import { iteratorDef, IteratorNodeHandler } from "./control/iterator"; export function registerAllNodes(): void { nodeRegistry.register(mediaUploadDef, new MediaUploadHandler()); nodeRegistry.register(textInputDef, new TextInputHandler()); + nodeRegistry.register(directoryImportDef, new DirectoryImportHandler()); nodeRegistry.register(aiTaskDef, new AITaskHandler()); nodeRegistry.register(fileExportDef, new FileExportHandler()); nodeRegistry.register(previewDisplayDef, new PreviewDisplayHandler()); diff --git a/src/workflow/WorkflowPage.tsx b/src/workflow/WorkflowPage.tsx index 402f0ef0..9229fc48 100644 --- a/src/workflow/WorkflowPage.tsx +++ b/src/workflow/WorkflowPage.tsx @@ -223,6 +223,8 @@ export function WorkflowPage() { const { cancelAll, activeExecutions } = useExecutionStore(); const initListeners = useExecutionStore((s) => s.initListeners); const wasRunning = useExecutionStore((s) => s._wasRunning); + const lastRunType = useExecutionStore((s) => s._lastRunType); + const lastRunNodeLabel = useExecutionStore((s) => s._lastRunNodeLabel); const nodeStatuses = useExecutionStore((s) => s.nodeStatuses); const isRunning = activeExecutions.size > 0; const [lastSavedAt, setLastSavedAt] = useState(null); @@ -1046,16 +1048,21 @@ export function WorkflowPage() { useEffect(() => { if (prevWasRunning.current && !wasRunning && !isRunning) { const hasError = Object.values(nodeStatuses).some((s) => s === "error"); + const nodeName = lastRunType === "single" && lastRunNodeLabel ? lastRunNodeLabel : null; setExecToast({ type: hasError ? "error" : "success", msg: hasError - ? "Workflow completed with errors" - : "All nodes executed successfully", + ? nodeName + ? `${nodeName} executed with errors` + : "Workflow completed with errors" + : nodeName + ? `${nodeName} executed successfully` + : "All nodes executed successfully", }); setTimeout(() => setExecToast(null), 4000); } prevWasRunning.current = wasRunning; - }, [wasRunning, isRunning, nodeStatuses]); + }, [wasRunning, isRunning, nodeStatuses, lastRunType, lastRunNodeLabel]); // Listen for workflow:toast events dispatched from the store (e.g. cycle detection) useEffect(() => { @@ -1256,6 +1263,7 @@ export function WorkflowPage() { const runAllInBrowser = useExecutionStore.getState().runAllInBrowser; const browserNodes = latestNodes.map((n) => ({ id: n.id, + parentNode: n.parentNode, data: { nodeType: n.data?.nodeType ?? "", params: { diff --git a/src/workflow/browser/node-definitions.ts b/src/workflow/browser/node-definitions.ts index 8a0d62dc..df62ce45 100644 --- a/src/workflow/browser/node-definitions.ts +++ b/src/workflow/browser/node-definitions.ts @@ -57,6 +57,38 @@ export const textInputDef: NodeTypeDefinition = { ], }; +export const directoryImportDef: NodeTypeDefinition = { + type: "input/directory-import", + category: "input", + label: "Directory", + inputs: [], + outputs: [{ key: "output", label: "Files", dataType: "any", required: true }], + params: [ + { + key: "directoryPath", + label: "Directory", + type: "string", + dataType: "text", + connectable: false, + default: "", + }, + { + key: "mediaType", + label: "File Type", + type: "select", + dataType: "text", + connectable: false, + default: "image", + options: [ + { label: "Images", value: "image" }, + { label: "Videos", value: "video" }, + { label: "Audio", value: "audio" }, + { label: "All Media", value: "all" }, + ], + }, + ], +}; + // ─── AI Task ─────────────────────────────────────────────────────────────── export const aiTaskDef: NodeTypeDefinition = { type: "ai-task/run", @@ -521,6 +553,7 @@ export const selectDef: NodeTypeDefinition = { export const BROWSER_NODE_DEFINITIONS: NodeTypeDefinition[] = [ mediaUploadDef, textInputDef, + directoryImportDef, aiTaskDef, fileExportDef, previewDisplayDef, diff --git a/src/workflow/browser/run-in-browser.ts b/src/workflow/browser/run-in-browser.ts index 271f1da8..b20cf643 100644 --- a/src/workflow/browser/run-in-browser.ts +++ b/src/workflow/browser/run-in-browser.ts @@ -31,6 +31,7 @@ const nodeDefMap = new Map(BROWSER_NODE_DEFINITIONS.map((d) => [d.type, d])); export interface BrowserNode { id: string; + parentNode?: string; data: { nodeType: string; params?: Record }; } @@ -391,20 +392,56 @@ export async function executeWorkflowInBrowser( filteredNodes = nodes; filteredEdges = edges; } + + // Ensure child nodes of any iterator in the graph are always included + const iteratorIds = new Set(filteredNodes.filter((n) => n.data.nodeType === "control/iterator").map((n) => n.id)); + if (iteratorIds.size > 0) { + const filteredIdSet = new Set(filteredNodes.map((n) => n.id)); + for (const n of nodes) { + if (n.parentNode && iteratorIds.has(n.parentNode) && !filteredIdSet.has(n.id)) { + filteredNodes.push(n); + nodeIds.push(n.id); + filteredIdSet.add(n.id); + } + } + // Include internal edges between child nodes + const allFilteredIds = new Set(filteredNodes.map((n) => n.id)); + for (const e of edges) { + if (allFilteredIds.has(e.source) && allFilteredIds.has(e.target)) { + if (!filteredEdges.some((fe) => fe.source === e.source && fe.target === e.target && fe.sourceHandle === e.sourceHandle && fe.targetHandle === e.targetHandle)) { + filteredEdges.push(e); + } + } + } + } + const nodeMap = new Map(filteredNodes.map((n) => [n.id, n])); + + // Filter out child nodes (nodes with parentNode) from top-level execution. + // They are only executed inside their parent iterator's handler. + const childNodeIds = new Set(filteredNodes.filter((n) => n.parentNode).map((n) => n.id)); + const topLevelNodeIds = nodeIds.filter((id) => !childNodeIds.has(id)); + const edgesWithHandles = filteredEdges.map((e) => ({ source: e.source, target: e.target, sourceHandle: e.sourceHandle ?? "output", targetHandle: e.targetHandle ?? "input", })); - const simpleEdgesSubgraph: SimpleEdge[] = filteredEdges.map((e) => ({ - sourceNodeId: e.source, - targetNodeId: e.target, - })); + + // Top-level simple edges: exclude edges involving child nodes (internal to iterators) + // Critical: edges FROM child nodes to iterator (exposed output auto-edges) would + // incorrectly increment the iterator's in-degree in topological sort, preventing it + // from ever executing. We must exclude any edge where source OR target is a child node. + const simpleEdgesSubgraph: SimpleEdge[] = edgesWithHandles + .filter((e) => !childNodeIds.has(e.source) && !childNodeIds.has(e.target)) + .map((e) => ({ + sourceNodeId: e.source, + targetNodeId: e.target, + })); const results = new Map(); const failedNodes = new Set(); - const levels = topologicalLevels(nodeIds, simpleEdgesSubgraph); + const levels = topologicalLevels(topLevelNodeIds, simpleEdgesSubgraph); const upstreamMap = new Map(); for (const e of simpleEdgesSubgraph) { const deps = upstreamMap.get(e.targetNodeId) ?? []; @@ -416,6 +453,9 @@ export async function executeWorkflowInBrowser( throwIfAborted(); // Stop the entire workflow if any node has failed if (failedNodes.size > 0) break; + // Yield to the event loop between levels so React can flush UI updates + // (prevents perceived "freeze" between e.g. directory-import completing and iterator starting) + await new Promise((r) => setTimeout(r, 0)); await Promise.all( level.map(async (nodeId) => { throwIfAborted(); @@ -578,6 +618,54 @@ export async function executeWorkflowInBrowser( return; } + if (nodeType === "input/directory-import") { + const dirPath = String(params.directoryPath ?? "").trim(); + if (!dirPath) throw new Error("No directory selected. Please choose a directory."); + + const mediaType = String(params.mediaType ?? "image"); + + const MEDIA_EXTS: Record = { + image: [".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".tiff", ".tif", ".svg", ".avif"], + video: [".mp4", ".webm", ".mov", ".avi", ".mkv", ".flv", ".wmv", ".m4v"], + audio: [".mp3", ".wav", ".flac", ".m4a", ".ogg", ".aac", ".wma"], + all: [], + }; + MEDIA_EXTS.all = [...MEDIA_EXTS.image, ...MEDIA_EXTS.video, ...MEDIA_EXTS.audio]; + + let urls: string[] = []; + try { + const api = (window as unknown as Record).electronAPI as + | { scanDirectory?: (path: string, exts: string[]) => Promise } + | undefined; + if (api?.scanDirectory) { + const files = await api.scanDirectory(dirPath, MEDIA_EXTS[mediaType] ?? MEDIA_EXTS.all); + // Convert raw file paths to local-asset:// URLs (consistent with electron handler) + urls = files.sort().map((f) => `local-asset://${encodeURIComponent(f)}`); + } else { + throw new Error("Directory scanning requires the desktop app."); + } + } catch (err) { + throw new Error(`Failed to scan directory: ${err instanceof Error ? err.message : String(err)}`); + } + + results.set(nodeId, { + outputUrl: urls[0] ?? "", + resultMetadata: { + output: urls, + resultUrl: urls[0] ?? "", + resultUrls: urls, + fileCount: urls.length, + }, + }); + callbacks.onNodeStatus(nodeId, "confirmed"); + callbacks.onNodeComplete(nodeId, { + urls: urls.length > 0 ? urls : [""], + cost: 0, + durationMs: Date.now() - start, + }); + return; + } + if (nodeType === "output/preview") { const url = String(inputs.input ?? ""); if (!url) throw new Error("No URL provided for preview."); @@ -595,63 +683,74 @@ export async function executeWorkflowInBrowser( } if (nodeType === "output/file") { - const url = String(inputs.url ?? inputs.input ?? ""); - if (!url) throw new Error("No URL provided for export."); + const rawUrl = inputs.url ?? inputs.input ?? ""; + // Handle array inputs (e.g. from iterator output) — save all files + const urlList: string[] = Array.isArray(rawUrl) + ? rawUrl.map(String).filter(Boolean) + : [String(rawUrl)].filter(Boolean); + if (urlList.length === 0) throw new Error("No URL provided for export."); + const filenamePrefix = String(params.filename ?? "output").replace( /[<>:"/\\|?*]/g, "_", ) || "output"; const format = String(params.format ?? "auto"); - const ext = - format === "auto" - ? guessExtFromUrl(url) - : format.replace(/^\./, "").toLowerCase(); - const fileName = `${filenamePrefix}_${Date.now()}.${ext || "png"}`; - const isElectron = typeof window !== "undefined" && !!window.electronAPI?.saveFileSilent; - if (isElectron) { - // Electron: save silently to outputDir via IPC (no dialog) - const outputDir = String(params.outputDir || ""); - const result = await window.electronAPI!.saveFileSilent( - url, - outputDir, - fileName, - ); - if (!result.success) - throw new Error(result.error || "Save failed"); - } else { - // Browser: download via + blob - try { - const resp = await fetch(url); - if (!resp.ok) - throw new Error(`Download failed: HTTP ${resp.status}`); - const blob = await resp.blob(); - const blobUrl = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = blobUrl; - a.download = fileName; - a.style.display = "none"; - document.body.appendChild(a); - a.click(); - setTimeout(() => { - document.body.removeChild(a); - URL.revokeObjectURL(blobUrl); - }, 100); - } catch { - window.open(url, "_blank"); + const savedFiles: string[] = []; + + for (let fi = 0; fi < urlList.length; fi++) { + const url = urlList[fi]; + const ext = + format === "auto" + ? guessExtFromUrl(url) + : format.replace(/^\./, "").toLowerCase(); + const suffix = urlList.length > 1 ? `_${fi + 1}` : ""; + const fileName = `${filenamePrefix}${suffix}_${Date.now()}.${ext || "png"}`; + + if (isElectron) { + const outputDir = String(params.outputDir || ""); + const result = await window.electronAPI!.saveFileSilent( + url, + outputDir, + fileName, + ); + if (!result.success) + throw new Error(result.error || `Save failed for file ${fi + 1}`); + } else { + try { + const resp = await fetch(url); + if (!resp.ok) + throw new Error(`Download failed: HTTP ${resp.status}`); + const blob = await resp.blob(); + const blobUrl = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = blobUrl; + a.download = fileName; + a.style.display = "none"; + document.body.appendChild(a); + a.click(); + setTimeout(() => { + document.body.removeChild(a); + URL.revokeObjectURL(blobUrl); + }, 100); + } catch { + window.open(url, "_blank"); + } } + savedFiles.push(url); + onProgress(((fi + 1) / urlList.length) * 100, `Saved ${fi + 1}/${urlList.length}`); } results.set(nodeId, { - outputUrl: url, - resultMetadata: { output: url, exportedFileName: fileName }, + outputUrl: savedFiles[0] ?? "", + resultMetadata: { output: savedFiles, exportedFileCount: savedFiles.length }, }); callbacks.onNodeStatus(nodeId, "confirmed"); callbacks.onNodeComplete(nodeId, { - urls: [url], + urls: savedFiles.length > 0 ? savedFiles : [""], cost: 0, durationMs: Date.now() - start, }); @@ -1102,6 +1201,233 @@ export async function executeWorkflowInBrowser( return; } + // ── Iterator: execute child nodes as a sub-workflow N times ── + if (nodeType === "control/iterator") { + const iterationMode = String(params.iterationMode ?? "fixed"); + const fixedCount = Math.max(1, Number(params.iterationCount ?? 1)); + + // Parse exposed params + const parseExposed = (v: unknown): Array<{ subNodeId: string; subNodeLabel: string; paramKey: string; namespacedKey: string; direction: string; dataType: string }> => { + if (typeof v === "string") { try { return JSON.parse(v); } catch { return []; } } + return Array.isArray(v) ? v : []; + }; + const exposedInputs = parseExposed(params.exposedInputs); + const exposedOutputs = parseExposed(params.exposedOutputs); + // Collect child nodes and internal edges + const allNodes = Array.from(nodeMap.values()); + const childNodes = allNodes.filter((n) => n.parentNode === nodeId); + const childIdSet = new Set(childNodes.map((n) => n.id)); + const internalEdges = edgesWithHandles.filter( + (e) => childIdSet.has(e.source) && childIdSet.has(e.target), + ); + + if (childNodes.length === 0) { + results.set(nodeId, { outputUrl: "", resultMetadata: {} }); + callbacks.onNodeStatus(nodeId, "confirmed"); + callbacks.onNodeComplete(nodeId, { urls: [], cost: 0, durationMs: Date.now() - start }); + return; + } + + // Topological sort of child nodes + const childSimpleEdges = internalEdges.map((e) => ({ sourceNodeId: e.source, targetNodeId: e.target })); + const childLevels = topologicalLevels(Array.from(childIdSet), childSimpleEdges); + + // Build input routing from exposed inputs (keep raw values for auto-mode slicing) + const inputRoutingRaw = new Map>(); + for (const ep of exposedInputs) { + const externalValue = inputs[ep.namespacedKey] ?? inputs[`input-${ep.namespacedKey}`]; + if (externalValue !== undefined) { + if (!inputRoutingRaw.has(ep.subNodeId)) inputRoutingRaw.set(ep.subNodeId, new Map()); + inputRoutingRaw.get(ep.subNodeId)!.set(ep.paramKey, externalValue); + } + } + + // Determine iteration count + let iterationCount: number; + const allExternalValues: unknown[] = []; + for (const ep of exposedInputs) { + const v = inputs[ep.namespacedKey] ?? inputs[`input-${ep.namespacedKey}`]; + if (v !== undefined) allExternalValues.push(v); + } + + if (iterationMode === "auto") { + const arrayLengths = allExternalValues + .filter((v) => Array.isArray(v)) + .map((v) => (v as unknown[]).length); + + if (arrayLengths.length === 0) { + if (allExternalValues.length > 0) { + iterationCount = 1; + } else { + throw new Error("Auto mode: no external inputs connected. Connect an array input or switch to fixed mode."); + } + } else { + iterationCount = Math.max(...arrayLengths); + if (iterationCount === 0) { + results.set(nodeId, { outputUrl: "", resultMetadata: {} }); + callbacks.onNodeStatus(nodeId, "confirmed"); + callbacks.onNodeComplete(nodeId, { urls: [], cost: 0, durationMs: Date.now() - start }); + return; + } + } + } else { + iterationCount = fixedCount; + } + + // Execute N iterations + const iterationOutputs: Array> = []; + let totalCost = 0; + + for (let iter = 0; iter < iterationCount; iter++) { + throwIfAborted(); + const subResults = new Map(); + let iterFailed = false; + + for (const level of childLevels) { + if (iterFailed) break; + for (const childId of level) { + if (iterFailed) break; + throwIfAborted(); + + const childNode = nodeMap.get(childId); + if (!childNode) continue; + + const childType = childNode.data.nodeType; + const childParams = { ...(childNode.data.params ?? {}) }; + + // Inject external inputs (with auto-mode array slicing) + const extInputs = inputRoutingRaw.get(childId); + if (extInputs) { + for (const [pk, rawVal] of extInputs) { + if (iterationMode === "auto" && Array.isArray(rawVal)) { + const arr = rawVal as unknown[]; + childParams[pk] = arr.length > 0 ? arr[Math.min(iter, arr.length - 1)] : undefined; + } else if (iterationMode === "fixed" && Array.isArray(rawVal)) { + const arr = rawVal as unknown[]; + childParams[pk] = arr.length > 0 ? arr[iter % arr.length] : undefined; + } else { + childParams[pk] = rawVal; + } + } + } + childParams.__iterationIndex = iter; + + // Resolve internal edges from upstream child outputs + const childInputs = resolveInputs(childId, nodeMap, internalEdges, subResults); + + // Create a virtual node with merged params for execution + const virtualNode: BrowserNode = { id: childId, data: { nodeType: childType, params: childParams } }; + const virtualNodeMap = new Map(nodeMap); + virtualNodeMap.set(childId, virtualNode); + + // Execute child node inline + callbacks.onNodeStatus(childId, "running"); + const childStart = Date.now(); + try { + if (childType === "ai-task/run") { + const modelId = String(childParams.modelId ?? ""); + if (!modelId) throw new Error("No model selected in child node."); + const apiParams = buildApiParams(childParams, childInputs); + const resolvedParams = await uploadLocalUrls(apiParams, signal); + callbacks.onProgress(childId, 10, `Running ${modelId}...`); + const result = await apiClient.run(modelId, resolvedParams, { signal }); + const outputUrl = Array.isArray(result.outputs) && result.outputs.length > 0 ? String(result.outputs[0]) : ""; + const model = useModelsStore.getState().getModelById(modelId); + const cost = model?.base_price ?? 0; + totalCost += cost; + subResults.set(childId, { + outputUrl, + resultMetadata: { output: outputUrl, resultUrl: outputUrl, resultUrls: Array.isArray(result.outputs) ? result.outputs : [outputUrl] }, + }); + callbacks.onNodeStatus(childId, "confirmed"); + callbacks.onNodeComplete(childId, { urls: Array.isArray(result.outputs) ? result.outputs.map(String) : [outputUrl], cost, durationMs: Date.now() - childStart }); + } else if (childType === "input/text-input") { + const text = String(childParams.text ?? childInputs.text ?? ""); + subResults.set(childId, { outputUrl: text, resultMetadata: { output: text } }); + callbacks.onNodeStatus(childId, "confirmed"); + callbacks.onNodeComplete(childId, { urls: [text], cost: 0, durationMs: Date.now() - childStart }); + } else if (childType === "input/media-upload") { + const url = String(childParams.uploadedUrl ?? childInputs.input ?? ""); + subResults.set(childId, { outputUrl: url, resultMetadata: { output: url } }); + callbacks.onNodeStatus(childId, "confirmed"); + callbacks.onNodeComplete(childId, { urls: [url], cost: 0, durationMs: Date.now() - childStart }); + } else if (childType === "processing/concat") { + const arr: string[] = []; + for (let ci = 1; ci <= 5; ci++) { + const v = childInputs[`input${ci}`] ?? childParams[`input${ci}`]; + if (v) arr.push(...(Array.isArray(v) ? v.map(String) : [String(v)])); + } + subResults.set(childId, { outputUrl: arr[0] ?? "", resultMetadata: { output: arr, resultUrl: arr[0], resultUrls: arr } }); + callbacks.onNodeStatus(childId, "confirmed"); + callbacks.onNodeComplete(childId, { urls: arr, cost: 0, durationMs: Date.now() - childStart }); + } else if (childType === "processing/select") { + const raw = childInputs.input ?? childParams.input; + const arr = Array.isArray(raw) ? raw.map(String) : raw ? [String(raw)] : []; + const idx = Math.floor(Number(childParams.index ?? 0)); + const value = arr[idx] ?? ""; + subResults.set(childId, { outputUrl: value, resultMetadata: { output: value } }); + callbacks.onNodeStatus(childId, "confirmed"); + callbacks.onNodeComplete(childId, { urls: [value], cost: 0, durationMs: Date.now() - childStart }); + } else { + // Generic pass-through for free-tools and other types + const inputUrl = String(childInputs.input ?? childInputs.media ?? childParams.uploadedUrl ?? ""); + if (inputUrl) { + subResults.set(childId, { outputUrl: inputUrl, resultMetadata: { output: inputUrl } }); + callbacks.onNodeStatus(childId, "confirmed"); + callbacks.onNodeComplete(childId, { urls: [inputUrl], cost: 0, durationMs: Date.now() - childStart }); + } else { + throw new Error(`Unsupported child node type: ${childType}`); + } + } + } catch (err) { + iterFailed = true; + const msg = err instanceof Error ? err.message : String(err); + callbacks.onNodeStatus(childId, "error", msg); + failedNodes.add(nodeId); + callbacks.onNodeStatus(nodeId, "error", `Child node failed: ${msg}`); + return; + } + } + } + + // Collect exposed outputs for this iteration + const iterOut: Record = {}; + for (const ep of exposedOutputs) { + const nodeOut = subResults.get(ep.subNodeId); + if (nodeOut) { + iterOut[`output-${ep.namespacedKey}`] = nodeOut.resultMetadata[ep.paramKey] ?? nodeOut.outputUrl; + } + } + iterationOutputs.push(iterOut); + onProgress(((iter + 1) / iterationCount) * 100, `Iteration ${iter + 1}/${iterationCount} complete`); + } + + // Aggregate results — ALWAYS output arrays regardless of iteration count + // N=1 → ["value"], N=3 → ["v1","v2","v3"], N=0 → [] + const finalOutputs: Record = {}; + for (const ep of exposedOutputs) { + const hk = `output-${ep.namespacedKey}`; + finalOutputs[hk] = iterationOutputs.map((r) => r[hk]); + } + + // Collect all output URLs for the iterator result + const allUrls: string[] = []; + for (const out of iterationOutputs) { + for (const v of Object.values(out)) { + if (typeof v === "string" && v) allUrls.push(v); + if (Array.isArray(v)) allUrls.push(...v.filter((x): x is string => typeof x === "string" && !!x)); + } + } + + results.set(nodeId, { + outputUrl: allUrls[0] ?? "", + resultMetadata: { ...finalOutputs, output: allUrls[0] ?? "", resultUrl: allUrls[0] ?? "", resultUrls: allUrls }, + }); + callbacks.onNodeStatus(nodeId, "confirmed"); + callbacks.onNodeComplete(nodeId, { urls: allUrls, cost: totalCost, durationMs: Date.now() - start }); + return; + } + // Unsupported node type — treat as pass-through if it has a single input const inputUrl = String( inputs.input ?? inputs.media ?? params.uploadedUrl ?? "", diff --git a/src/workflow/components/canvas/ExecutionToolbar.tsx b/src/workflow/components/canvas/ExecutionToolbar.tsx index ed064c6b..95c6c538 100644 --- a/src/workflow/components/canvas/ExecutionToolbar.tsx +++ b/src/workflow/components/canvas/ExecutionToolbar.tsx @@ -59,6 +59,7 @@ export function ExecutionToolbar() { onClick={() => { const browserNodes = nodes.map((n) => ({ id: n.id, + parentNode: (n as { parentNode?: string }).parentNode, data: { nodeType: n.data?.nodeType ?? "", params: { diff --git a/src/workflow/components/canvas/custom-node/CustomNode.tsx b/src/workflow/components/canvas/custom-node/CustomNode.tsx index bf9145a2..a22f614a 100644 --- a/src/workflow/components/canvas/custom-node/CustomNode.tsx +++ b/src/workflow/components/canvas/custom-node/CustomNode.tsx @@ -60,6 +60,7 @@ function CustomNodeComponent({ const edges = useWorkflowStore((s) => s.edges); const updateNodeParams = useWorkflowStore((s) => s.updateNodeParams); const updateNodeData = useWorkflowStore((s) => s.updateNodeData); + const syncExposedParamsOnModelSwitch = useWorkflowStore((s) => s.syncExposedParamsOnModelSwitch); const workflowId = useWorkflowStore((s) => s.workflowId); const isDirty = useWorkflowStore((s) => s.isDirty); const { runNode, cancelNode, retryNode } = useExecutionStore(); @@ -299,6 +300,17 @@ function CustomNodeComponent({ label: finalLabel, }); + // Sync iterator exposed params if this node is inside an iterator + // - Remove exposed inputs whose param keys no longer exist in the new model + // - Update labels/namespacedKeys for params that still exist (label changed) + // - Keep output exposed params (output handle is always "output") + // Include both model schema fields and static input port keys + const newInputKeys = [ + ...inputSchemaForNode.map((p) => p.name), + ...(data.inputDefinitions ?? []).map((d: { key: string }) => d.key), + ]; + syncExposedParamsOnModelSwitch(id, finalLabel, newInputKeys); + const execStore = useExecutionStore.getState(); execStore.updateNodeStatus(id, "idle"); useExecutionStore.setState((s) => { @@ -316,6 +328,7 @@ function CustomNodeComponent({ id, updateNodeData, updateNodeParams, + syncExposedParamsOnModelSwitch, removeEdgesByIds, allNodes, ], @@ -706,8 +719,8 @@ function CustomNodeComponent({ bg-[hsl(var(--card))] text-[hsl(var(--card-foreground))] border-2 ${resizing ? "" : "transition-all duration-300"} - ${running ? "border-blue-500 animate-pulse-subtle" : ""} - ${!running && selected ? "border-blue-500 shadow-[0_0_20px_rgba(96,165,250,.25)] ring-1 ring-blue-500/30" : ""} + ${running ? (isInsideIterator ? "border-cyan-500 animate-pulse-subtle" : "border-blue-500 animate-pulse-subtle") : ""} + ${!running && selected ? (isInsideIterator ? "border-cyan-500 shadow-[0_0_20px_rgba(6,182,212,.25)] ring-1 ring-cyan-500/30" : "border-blue-500 shadow-[0_0_20px_rgba(96,165,250,.25)] ring-1 ring-blue-500/30") : ""} ${!running && !selected && status === "confirmed" ? "border-green-500/70" : ""} ${!running && !selected && status === "unconfirmed" ? "border-orange-500/70" : ""} ${!running && !selected && status === "error" ? "border-red-500/70" : ""} @@ -719,13 +732,13 @@ function CustomNodeComponent({ {/* ── Title bar ──────────── */}
{ const hid = `param-${p.key}`; const canConnect = @@ -446,6 +447,16 @@ export function CustomNodeBody(props: CustomNodeBodyProps) { /> )} + {/* Directory Import node — special UI */} + {data.nodeType === "input/directory-import" && ( + { + updateNodeParams(id, { ...data.params, ...updates }); + }} + /> + )} + {isAITask && (
e.stopPropagation()}> { if (data.nodeType === "input/media-upload") return null; + if (data.nodeType === "input/directory-import") return null; const hid = `input-${inp.key}`; const conn = connectedSet.has(hid); const portFieldConfig = portToFormFieldConfig(inp, data.nodeType); @@ -925,6 +937,7 @@ export function CustomNodeBody(props: CustomNodeBodyProps) { {/* defParams */} {data.nodeType !== "input/media-upload" && data.nodeType !== "input/text-input" && + data.nodeType !== "input/directory-import" && paramDefs.map((p) => { const hid = `param-${p.key}`; const canConnect = diff --git a/src/workflow/components/canvas/custom-node/CustomNodeInputBodies.tsx b/src/workflow/components/canvas/custom-node/CustomNodeInputBodies.tsx index 82a0bdc3..24ac01dc 100644 --- a/src/workflow/components/canvas/custom-node/CustomNodeInputBodies.tsx +++ b/src/workflow/components/canvas/custom-node/CustomNodeInputBodies.tsx @@ -747,3 +747,171 @@ export function TextInputBody({
); } + +/* ══════════════════════════════════════════════════════════════════════ + DirectoryImportBody + ══════════════════════════════════════════════════════════════════════ */ + +const MEDIA_TYPE_OPTIONS: Array<{ value: string; label: string; exts: string }> = [ + { value: "image", label: "Images", exts: ".jpg .png .webp .gif .bmp .svg" }, + { value: "video", label: "Videos", exts: ".mp4 .webm .mov .avi .mkv" }, + { value: "audio", label: "Audio", exts: ".mp3 .wav .flac .m4a .ogg" }, + { value: "all", label: "All Media", exts: "all types" }, +]; + +const MEDIA_EXTS: Record = { + image: [".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".tiff", ".svg", ".avif"], + video: [".mp4", ".webm", ".mov", ".avi", ".mkv", ".flv", ".wmv", ".m4v"], + audio: [".mp3", ".wav", ".flac", ".m4a", ".ogg", ".aac", ".wma"], + all: [], +}; +MEDIA_EXTS.all = [...MEDIA_EXTS.image, ...MEDIA_EXTS.video, ...MEDIA_EXTS.audio]; + +export function DirectoryImportBody({ + params, + onParamChange, +}: { + params: Record; + onParamChange: (updates: Record) => void; +}) { + const { t } = useTranslation(); + const dirPath = String(params.directoryPath ?? ""); + const mediaType = String(params.mediaType ?? "image"); + const [files, setFiles] = useState( + Array.isArray(params.__cachedFiles) ? (params.__cachedFiles as string[]) : [], + ); + const [scanning, setScanning] = useState(false); + + const handlePickDirectory = async () => { + try { + const api = (window as unknown as Record).electronAPI as + | { pickDirectory?: () => Promise<{ success: boolean; path?: string }> } + | undefined; + if (!api?.pickDirectory) return; + const result = await api.pickDirectory(); + if (result.success && result.path) { + // Set path + trigger scan; scanDir will batch the file results together with the path + scanDir(result.path, mediaType, true); + } + } catch { + // ignore + } + }; + + const scanDir = async (dir: string, type: string, updatePath = false) => { + if (!dir) return; + setScanning(true); + try { + const api = (window as unknown as Record).electronAPI as + | { scanDirectory?: (path: string, exts: string[]) => Promise } + | undefined; + if (api?.scanDirectory) { + const result = await api.scanDirectory(dir, MEDIA_EXTS[type] ?? MEDIA_EXTS.all); + setFiles(result); + // Batch all updates in a single call so the store merges them atomically + const updates: Record = { + __cachedFileCount: result.length, + __cachedFiles: result, + }; + if (updatePath) updates.directoryPath = dir; + onParamChange(updates); + } else if (updatePath) { + onParamChange({ directoryPath: dir }); + } + } catch { + setFiles([]); + if (updatePath) onParamChange({ directoryPath: dir }); + } finally { + setScanning(false); + } + }; + + // Re-scan when mediaType changes + useEffect(() => { + if (dirPath) scanDir(dirPath, mediaType); + }, [mediaType]); + + const inputCls = + "rounded-md border border-[hsl(var(--border))] bg-[hsl(var(--background))] px-2 py-1.5 text-xs text-[hsl(var(--foreground))] focus:outline-none focus:ring-1 focus:ring-blue-500/50"; + + return ( +
e.stopPropagation()}> + {/* Row 1: Directory */} +
+
+ + {t("workflow.directoryImport.directory", "Directory")} + +
+ { + onParamChange({ directoryPath: e.target.value }); + setFiles([]); + }} + onBlur={() => { if (dirPath) scanDir(dirPath, mediaType); }} + placeholder={t("workflow.directoryImport.enterPath", "Path or browse...")} + className={`${inputCls} flex-1 min-w-0`} + onClick={(e) => e.stopPropagation()} + /> + +
+
+
+ + {/* Row 2: File Type */} +
+
+ + {t("workflow.directoryImport.fileType", "File Type")} + + +
+
+ + {/* Scan status row */} +
+
+ + {t("workflow.directoryImport.status", "Status")} + + + {scanning ? ( + + + + + {t("workflow.directoryImport.scanning", "Scanning...")} + + ) : dirPath && files.length > 0 ? ( + {files.length} {t("workflow.directoryImport.filesFound", "file(s) matched")} + ) : dirPath ? ( + {t("workflow.directoryImport.noFiles", "No matching files")} + ) : ( + + )} + +
+
+
+ ); +} diff --git a/src/workflow/components/canvas/custom-node/NodeIcons.tsx b/src/workflow/components/canvas/custom-node/NodeIcons.tsx index 9de33e97..94e0a2bd 100644 --- a/src/workflow/components/canvas/custom-node/NodeIcons.tsx +++ b/src/workflow/components/canvas/custom-node/NodeIcons.tsx @@ -22,6 +22,7 @@ import { Download, GitMerge, ListFilter, + FolderOpen, type LucideIcon, } from "lucide-react"; @@ -42,6 +43,7 @@ const NODE_ICON_MAP: Record = { // Input "input/media-upload": Upload, "input/text-input": Type, + "input/directory-import": FolderOpen, // AI Task "ai-task/run": Cpu, // Output diff --git a/src/workflow/components/canvas/iterator-node/IteratorNodeContainer.tsx b/src/workflow/components/canvas/iterator-node/IteratorNodeContainer.tsx index 210834d6..57408f08 100644 --- a/src/workflow/components/canvas/iterator-node/IteratorNodeContainer.tsx +++ b/src/workflow/components/canvas/iterator-node/IteratorNodeContainer.tsx @@ -1,14 +1,19 @@ /** * IteratorNodeContainer — ReactFlow custom node for the Iterator container. * - * Layout: [Left input port strip] [Internal canvas area] [Right output port strip] + * Exposed params appear as "capsule" handles on the left/right border: + * [●──param name──●] * - * Exposed params flow: - * External edge → Iterator left handle → (runtime maps to) child node param - * Child node output → (runtime maps to) Iterator right handle → External edge + * IN capsules (left border): + * Left dot = external target (outside nodes connect here) + * Right dot = internal source (auto-connected to child node input) * - * The ExposeParamPicker floats ABOVE the iterator (portal-style z-index) - * so internal child nodes never obscure it. + * OUT capsules (right border): + * Left dot = internal target (auto-connected from child node output) + * Right dot = external source (outside nodes connect from here) + * + * When a param is exposed via the picker, an internal edge is auto-created + * between the capsule's inner handle and the child node's corresponding handle. */ import React, { memo, @@ -20,14 +25,13 @@ import React, { } from "react"; import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; -import { Handle, Position, useReactFlow, type NodeProps } from "reactflow"; +import { Handle, Position, useReactFlow, useUpdateNodeInternals, type NodeProps } from "reactflow"; import { useWorkflowStore } from "../../../stores/workflow.store"; import { useUIStore } from "../../../stores/ui.store"; import { useExecutionStore } from "../../../stores/execution.store"; import type { PortDefinition } from "@/workflow/types/node-defs"; import type { NodeStatus } from "@/workflow/types/execution"; import type { ExposedParam } from "@/workflow/types/workflow"; -import { handleLeft, handleRight } from "../custom-node/CustomNodeHandleAnchor"; import { Tooltip, TooltipTrigger, @@ -41,10 +45,11 @@ const MIN_ITERATOR_WIDTH = 600; const MIN_ITERATOR_HEIGHT = 400; const CHILD_PADDING = 40; const TITLE_BAR_HEIGHT = 40; -const PORT_STRIP_WIDTH = 140; -const PORT_STRIP_EMPTY_WIDTH = 24; -const PORT_ROW_HEIGHT = 32; -const PORT_HEADER_HEIGHT = 28; +const CAPSULE_HEIGHT = 28; +const CAPSULE_GAP = 6; +const CAPSULE_TOP_OFFSET = TITLE_BAR_HEIGHT + 56; +const HANDLE_DOT = 10; +const CAPSULE_LABEL_WIDTH = 110; // fixed width for capsule label area /* ── types ─────────────────────────────────────────────────────────── */ @@ -58,7 +63,7 @@ export interface IteratorNodeData { paramDefinitions?: unknown[]; } -/* ── Gear icon (reusable) ──────────────────────────────────────────── */ +/* ── Gear icon ─────────────────────────────────────────────────────── */ const GearIcon = ({ size = 12 }: { size?: number }) => ( @@ -116,7 +121,6 @@ function ExposeParamPicker({ [isExposed, exposeParam, unexposeParam, iteratorId, direction], ); - if (childNodes.length === 0) { return (
; if (direction === "input") { - // For inputs: show model input schema fields (the actual user-facing params like Image, Source Image, etc.) - // plus any input port definitions, but skip internal paramDefinitions like modelId const modelItems = modelSchema.map((m) => ({ key: m.name, label: m.label || m.name.split("_").map((w: string) => w.charAt(0).toUpperCase() + w.slice(1)).join(" "), dataType: m.mediaType ?? m.type ?? "any", })); const inputPortItems = childInputDefs.map((d) => ({ key: d.key, label: d.label, dataType: d.dataType })); - // If no model schema, fall back to visible paramDefinitions (for non-ai-task nodes like free-tools) if (modelItems.length === 0) { const visibleParams = paramDefs .filter((d) => !d.key.startsWith("__") && d.key !== "modelId") @@ -174,7 +175,6 @@ function ExposeParamPicker({ items = [...modelItems, ...inputPortItems]; } } else { - // For outputs: show each child node's output ports items = childOutputDefs.map((d) => ({ key: d.key, label: d.label, dataType: d.dataType })); } @@ -235,7 +235,6 @@ function PickerPortal({ } }; update(); - // Track viewport transform changes (pan/zoom) const viewport = nodeRef.current?.closest(".react-flow__viewport"); let mo: MutationObserver | undefined; if (viewport) { @@ -259,66 +258,24 @@ function PickerPortal({ ); } -/* ── Add Node button portal — floats at bottom-center of iterator ── */ - -function AddNodePortal({ - nodeRef, - onClick, - label, - title, -}: { - nodeRef: React.RefObject; - onClick: (e: React.MouseEvent) => void; - label: string; - title: string; -}) { - const [pos, setPos] = useState<{ top: number; left: number } | null>(null); - - useEffect(() => { - const update = () => { - const rect = nodeRef.current?.getBoundingClientRect(); - if (!rect) return; - setPos({ - top: rect.bottom - 36, - left: rect.left + rect.width / 2, - }); - }; - update(); - const viewport = nodeRef.current?.closest(".react-flow__viewport"); - let mo: MutationObserver | undefined; - if (viewport) { - mo = new MutationObserver(update); - mo.observe(viewport, { attributes: true, attributeFilter: ["style"] }); - } - const ro = nodeRef.current ? new ResizeObserver(update) : null; - if (nodeRef.current && ro) ro.observe(nodeRef.current); - window.addEventListener("resize", update); - return () => { mo?.disconnect(); ro?.disconnect(); window.removeEventListener("resize", update); }; - }, [nodeRef]); - - if (!pos) return null; - - return ( -
e.stopPropagation()} - > - -
- ); -} +/* ── Capsule handle style helpers ──────────────────────────────────── */ + +const dotStyle = (connected: boolean): React.CSSProperties => ({ + width: HANDLE_DOT, + height: HANDLE_DOT, + borderRadius: "50%", + border: "2px solid hsl(var(--primary))", + background: connected ? "hsl(var(--primary))" : "hsl(var(--card))", + minWidth: HANDLE_DOT, + minHeight: HANDLE_DOT, + position: "relative" as const, + top: "auto", + left: "auto", + right: "auto", + bottom: "auto", + transform: "none", + zIndex: 40, +}); /* ── main component ────────────────────────────────────────────────── */ @@ -337,10 +294,12 @@ function IteratorNodeContainerComponent({ const [showInputPicker, setShowInputPicker] = useState(false); const [showOutputPicker, setShowOutputPicker] = useState(false); const { getViewport, setNodes } = useReactFlow(); + const updateNodeInternals = useUpdateNodeInternals(); const updateNodeParams = useWorkflowStore((s) => s.updateNodeParams); const workflowId = useWorkflowStore((s) => s.workflowId); const removeNode = useWorkflowStore((s) => s.removeNode); const toggleNodePalette = useUIStore((s) => s.toggleNodePalette); + const edges = useWorkflowStore((s) => s.edges); const status = useExecutionStore( (s) => s.nodeStatuses[id] ?? "idle", ) as NodeStatus; @@ -350,16 +309,50 @@ function IteratorNodeContainerComponent({ const running = status === "running"; const iterationCount = Number(data.params?.iterationCount ?? 1); + const iterationMode = String(data.params?.iterationMode ?? "fixed"); const savedWidth = (data.params?.__nodeWidth as number) ?? MIN_ITERATOR_WIDTH; const savedHeight = (data.params?.__nodeHeight as number) ?? MIN_ITERATOR_HEIGHT; const collapsed = (data.params?.__nodeCollapsed as boolean | undefined) ?? false; const shortId = id.slice(0, 8); - const inputDefs = data.inputDefinitions ?? []; - const outputDefs = data.outputDefinitions ?? []; + const inputDefs = useMemo(() => { + // Reconstruct from exposedInputs params (source of truth) to be resilient + // against data.inputDefinitions being reset by other state updates + try { + const raw = data.params?.exposedInputs; + const list: ExposedParam[] = typeof raw === "string" ? JSON.parse(raw) : Array.isArray(raw) ? raw : []; + return list.map((ep): PortDefinition => { + const readableParam = ep.paramKey.split("_").map((w: string) => w.charAt(0).toUpperCase() + w.slice(1)).join(" "); + const shortLabel = ep.subNodeLabel.includes("/") ? ep.subNodeLabel.split("/").pop()! : ep.subNodeLabel; + return { key: ep.namespacedKey, label: `${readableParam} · ${shortLabel}`, dataType: ep.dataType, required: false }; + }); + } catch { return data.inputDefinitions ?? []; } + }, [data.params?.exposedInputs, data.inputDefinitions]); + + const outputDefs = useMemo(() => { + try { + const raw = data.params?.exposedOutputs; + const list: ExposedParam[] = typeof raw === "string" ? JSON.parse(raw) : Array.isArray(raw) ? raw : []; + return list.map((ep): PortDefinition => { + const readableParam = ep.paramKey.split("_").map((w: string) => w.charAt(0).toUpperCase() + w.slice(1)).join(" "); + const shortLabel = ep.subNodeLabel.includes("/") ? ep.subNodeLabel.split("/").pop()! : ep.subNodeLabel; + return { key: ep.namespacedKey, label: `${readableParam} · ${shortLabel}`, dataType: ep.dataType, required: false }; + }); + } catch { return data.outputDefinitions ?? []; } + }, [data.params?.exposedOutputs, data.outputDefinitions]); const childNodeIds = data.childNodeIds ?? []; const hasChildren = childNodeIds.length > 0; + /* ── Force ReactFlow to recalculate handle positions when ports change ── */ + const portFingerprint = useMemo( + () => inputDefs.map((d) => d.key).join(",") + "|" + outputDefs.map((d) => d.key).join(","), + [inputDefs, outputDefs], + ); + useEffect(() => { + // After new handles render, tell ReactFlow to update its internal handle cache + requestAnimationFrame(() => updateNodeInternals(id)); + }, [portFingerprint, id, updateNodeInternals]); + /* ── Collapse toggle ───────────────────────────────────────────── */ const setCollapsed = useCallback( (value: boolean) => updateNodeParams(id, { ...data.params, __nodeCollapsed: value }), @@ -370,7 +363,7 @@ function IteratorNodeContainerComponent({ [collapsed, setCollapsed], ); - /* ── Effective size — uses saved dimensions (updated by updateBoundingBox) */ + /* ── Effective size ─────────────────────────────────────────────── */ const effectiveWidth = savedWidth; const effectiveHeight = collapsed ? TITLE_BAR_HEIGHT : savedHeight; @@ -433,47 +426,103 @@ function IteratorNodeContainerComponent({ const el = nodeRef.current; if (!el) return; setResizing(true); const startX = e.clientX, startY = e.clientY; - const startW = el.offsetWidth, startH = el.offsetHeight; + const startW = savedWidth; + const startH = savedHeight; const zoom = getViewport().zoom; + + // Capture the starting position of the iterator node + const startPos = (() => { + const n = useWorkflowStore.getState().nodes.find((nd) => nd.id === id); + return n ? { ...n.position } : { x: 0, y: 0 }; + })(); + const onMove = (ev: MouseEvent) => { - const dx = ev.clientX - startX, dy = ev.clientY - startY; - if (xDir !== 0) el.style.width = `${Math.max(MIN_ITERATOR_WIDTH, startW + dx * xDir)}px`; - if (yDir !== 0) el.style.height = `${Math.max(MIN_ITERATOR_HEIGHT, startH + dy * yDir)}px`; + const dx = (ev.clientX - startX) / zoom; + const dy = (ev.clientY - startY) / zoom; + const newW = xDir !== 0 ? Math.max(MIN_ITERATOR_WIDTH, startW + dx * xDir) : startW; + const newH = yDir !== 0 ? Math.max(MIN_ITERATOR_HEIGHT, startH + dy * yDir) : startH; + const newX = xDir === -1 ? startPos.x + (startW - newW) : startPos.x; + const newY = yDir === -1 ? startPos.y + (startH - newH) : startPos.y; + + setNodes((nds) => nds.map((n) => { + if (n.id !== id) return n; + const p = { ...n.data.params, __nodeWidth: newW, __nodeHeight: newH }; + return { ...n, position: { x: newX, y: newY }, data: { ...n.data, params: p } }; + })); }; + const onUp = (ev: MouseEvent) => { document.removeEventListener("mousemove", onMove); document.removeEventListener("mouseup", onUp); - el.style.width = ""; el.style.height = ""; setResizing(false); - const dx = ev.clientX - startX, dy = ev.clientY - startY; - const newW = xDir !== 0 ? Math.max(MIN_ITERATOR_WIDTH, startW + dx * xDir) : undefined; - const newH = yDir !== 0 ? Math.max(MIN_ITERATOR_HEIGHT, startH + dy * yDir) : undefined; - setNodes((nds) => nds.map((n) => { - if (n.id !== id) return n; - const pos = { ...n.position }; - if (xDir === -1) pos.x += dx / zoom; - if (yDir === -1) pos.y += dy / zoom; - const p = { ...n.data.params }; - if (newW !== undefined) p.__nodeWidth = newW; - if (newH !== undefined) p.__nodeHeight = newH; - return { ...n, position: pos, data: { ...n.data, params: p } }; - })); + + const dx = (ev.clientX - startX) / zoom; + const dy = (ev.clientY - startY) / zoom; + const finalW = xDir !== 0 ? Math.max(MIN_ITERATOR_WIDTH, startW + dx * xDir) : startW; + const finalH = yDir !== 0 ? Math.max(MIN_ITERATOR_HEIGHT, startH + dy * yDir) : startH; + useWorkflowStore.setState({ isDirty: true }); + + // Re-clamp child nodes + const { nodes: currentNodes } = useWorkflowStore.getState(); + const clampPad = 10; + const childUpdates: Array<{ nodeId: string; pos: { x: number; y: number } }> = []; + for (const cn of currentNodes) { + if (cn.parentNode !== id) continue; + const cw = (cn.data?.params?.__nodeWidth as number) ?? 300; + const ch = (cn.data?.params?.__nodeHeight as number) ?? 80; + const minCX = clampPad; + const maxCX = Math.max(minCX, finalW - cw - clampPad); + const minCY = TITLE_BAR_HEIGHT + clampPad; + const maxCY = Math.max(minCY, finalH - ch - clampPad - 40); + const cx = Math.min(Math.max(cn.position.x, minCX), maxCX); + const cy = Math.min(Math.max(cn.position.y, minCY), maxCY); + if (cx !== cn.position.x || cy !== cn.position.y) { + childUpdates.push({ nodeId: cn.id, pos: { x: cx, y: cy } }); + } + } + if (childUpdates.length > 0) { + useWorkflowStore.setState((state) => ({ + nodes: state.nodes.map((n) => { + const upd = childUpdates.find((u) => u.nodeId === n.id); + return upd ? { ...n, position: upd.pos } : n; + }), + })); + } }; document.addEventListener("mousemove", onMove); document.addEventListener("mouseup", onUp); }, - [id, getViewport, setNodes], + [id, getViewport, setNodes, savedWidth, savedHeight], ); - /* ── Handle positions — aligned with port rows ─────────────────── */ - const getHandleTop = (index: number) => - TITLE_BAR_HEIGHT + PORT_HEADER_HEIGHT + PORT_ROW_HEIGHT * index + PORT_ROW_HEIGHT / 2; - - /* ── Port strip widths ─────────────────────────────────────────── */ - const leftStripWidth = inputDefs.length > 0 ? PORT_STRIP_WIDTH : PORT_STRIP_EMPTY_WIDTH; - const rightStripWidth = outputDefs.length > 0 ? PORT_STRIP_WIDTH : PORT_STRIP_EMPTY_WIDTH; - const contentHeight = effectiveHeight - TITLE_BAR_HEIGHT; + /* ── Capsule vertical position ─────────────────────────────────── */ + const getCapsuleTop = (index: number) => + CAPSULE_TOP_OFFSET + index * (CAPSULE_HEIGHT + CAPSULE_GAP); + + /* ── Exposed param lookup — maps namespacedKey → ExposedParam for tooltip info ── */ + const exposedParamMap = useMemo(() => { + const map = new Map(); + for (const key of ["exposedInputs", "exposedOutputs"] as const) { + try { + const raw = data.params?.[key]; + const list: ExposedParam[] = typeof raw === "string" ? JSON.parse(raw) : Array.isArray(raw) ? raw : []; + for (const ep of list) map.set(ep.namespacedKey, ep); + } catch { /* ignore */ } + } + return map; + }, [data.params]); + + /* ── Check if a handle has a connected edge ────────────────────── */ + const isHandleConnected = useCallback( + (handleId: string, type: "source" | "target") => + edges.some((e) => + type === "source" + ? e.source === id && e.sourceHandle === handleId + : e.target === id && e.targetHandle === handleId, + ), + [edges, id], + ); /* ── Picker toggle helpers ─────────────────────────────────────── */ const toggleInputPicker = useCallback((e: React.MouseEvent) => { @@ -559,7 +608,7 @@ function IteratorNodeContainerComponent({ {shortId}
- {/* ── Config buttons: Input / Output — always in title bar ── */} + {/* ── Config buttons: IN / OUT ── */} - )} -
+ {/* ── Unified iteration mode + count capsule ── */} + + +
+ {/* Left half: mode toggle */} + + {/* Divider + count — only in fixed mode */} + {iterationMode === "fixed" && ( + <> +
+ {editingCount ? ( + setCountDraft(e.target.value)} onBlur={commitCount} onKeyDown={onCountKeyDown} + className="nodrag nopan w-10 h-full text-center text-[11px] font-bold bg-transparent text-cyan-400 outline-none" /> + ) : ( + + )} + + )} +
+ + + {iterationMode === "auto" + ? t("workflow.iterationModeAutoTip", "Auto: iterations = longest array input. Click the mode to switch.") + : t("workflow.iterationModeFixedTip", "Fixed: runs exactly ×N times. Click the mode to switch.")} + +
- {/* ── Expose-param pickers — rendered via portal to sit above child nodes ── */} + {/* ── Expose-param pickers — rendered via portal ── */} {showInputPicker && createPortal( setShowInputPicker(false)} /> @@ -648,77 +743,40 @@ function IteratorNodeContainerComponent({
)} - {/* ── Body: Left port strip | Internal area | Right port strip ── */} + {/* ── Body: Internal canvas area (full width, no port strips) ── */} {!collapsed && ( -
- - {/* ── Left strip: exposed inputs ──────────────────── */} -
- {inputDefs.length > 0 ? ( - <> -
- - {t("workflow.inputs", "IN")} - -
-
- {inputDefs.map((port) => ( -
- {port.label} -
- ))} -
- - ) : ( -
- )} -
- - {/* ── Internal canvas area ────────────────────────── */} -
- {/* Empty state — arrow pointing down to Add Node button */} - {!hasChildren && ( -
-
- {t("workflow.iteratorEmpty", "No child nodes yet")} - - - -
+
+ {/* Empty state */} + {!hasChildren && ( +
+
+ {t("workflow.iteratorEmpty", "No child nodes yet")} + + +
- )} -
+
+ )} - {/* ── Right strip: exposed outputs ────────────────── */} -
- {outputDefs.length > 0 ? ( - <> -
- - {t("workflow.outputs", "OUT")} - -
-
- {outputDefs.map((port) => ( -
- {port.label} -
- ))} -
- - ) : ( -
- )} + {/* ── Add Node button — positioned at bottom center inside the container ── */} +
e.stopPropagation()} + > +
-
)} @@ -744,37 +802,147 @@ function IteratorNodeContainerComponent({ )}
- {/* ── Add Node button — portal to sit above ReactFlow child nodes ── */} - {!collapsed && createPortal( - , - document.body, - )} + {/* ── LEFT SIDE: exposed input capsules ──────────────────── */} + {!collapsed && inputDefs.map((port, i) => { + const top = getCapsuleTop(i); + const extHandleId = `input-${port.key}`; + const intHandleId = `input-inner-${port.key}`; + const extConnected = isHandleConnected(extHandleId, "target"); + const intConnected = isHandleConnected(intHandleId, "source"); + const ep = exposedParamMap.get(port.key); + const tooltipText = ep + ? `${ep.paramKey.split("_").map((w: string) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ")} — ${ep.subNodeLabel}` + : port.label; + return ( + + {/* External target handle — on the left border */} + + {/* Capsule label between the two dots */} + + +
+
+ + {port.label} + +
+
+
+ + {tooltipText} + +
+ {/* Internal source handle — right side of capsule label area */} + +
+ ); + })} + + {/* ── RIGHT SIDE: exposed output capsules ──────────────────── */} + {!collapsed && outputDefs.map((port, i) => { + const top = getCapsuleTop(i); + const intHandleId = `output-inner-${port.key}`; + const extHandleId = `output-${port.key}`; + const extConnected = isHandleConnected(extHandleId, "source"); + const intConnected = isHandleConnected(intHandleId, "target"); + const ep = exposedParamMap.get(port.key); + const tooltipText = ep + ? `${ep.paramKey.split("_").map((w: string) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ")} — ${ep.subNodeLabel}` + : port.label; + // Compute left-based positions so ReactFlow handle lookup is reliable + const capsuleLabelLeft = effectiveWidth - HANDLE_DOT - CAPSULE_LABEL_WIDTH; + const intHandleLeft = capsuleLabelLeft - HANDLE_DOT - 4; + const extHandleLeft = effectiveWidth - HANDLE_DOT / 2; + return ( + + {/* Internal target handle — left side of capsule */} + + {/* Capsule label */} + + +
+
+ + {port.label} + +
+
+
+ + {tooltipText} + +
+ {/* External source handle — on the right border */} + +
+ ); + })} - {/* ── Input handles (left border — external connections) ──── */} - {!collapsed && inputDefs.map((port, i) => ( - - ))} - - {/* ── Output handles (right border — external connections) ── */} - {!collapsed && outputDefs.map((port, i) => ( - - ))} - - {/* ── External "+" button — right side, for downstream nodes ── */} + {/* ── External "+" button — right side ── */} {(hovered || selected) && ( diff --git a/src/workflow/hooks/useIteratorAdoption.ts b/src/workflow/hooks/useIteratorAdoption.ts index 5b4aeb36..482e1eb8 100644 --- a/src/workflow/hooks/useIteratorAdoption.ts +++ b/src/workflow/hooks/useIteratorAdoption.ts @@ -18,7 +18,6 @@ import { useWorkflowStore } from "../stores/workflow.store"; const TITLE_BAR_HEIGHT = 40; const CLAMP_PADDING = 10; -const PORT_STRIP_MIN_WIDTH = 24; // minimum width of port strips (when no ports) /* ── helpers ───────────────────────────────────────────────────────── */ @@ -81,14 +80,10 @@ export function useIteratorAdoption() { const childW = (node.data?.params?.__nodeWidth as number) ?? 300; const childH = (node.data?.params?.__nodeHeight as number) ?? 80; - const inputDefs = parentIterator.data?.inputDefinitions ?? []; - const outputDefs = parentIterator.data?.outputDefinitions ?? []; - const leftStrip = (inputDefs as unknown[]).length > 0 ? 140 : PORT_STRIP_MIN_WIDTH; - const rightStrip = (outputDefs as unknown[]).length > 0 ? 140 : PORT_STRIP_MIN_WIDTH; - const minX = leftStrip + CLAMP_PADDING; - const maxX = Math.max(minX, itW - childW - rightStrip - CLAMP_PADDING); + const minX = CLAMP_PADDING; + const maxX = Math.max(minX, itW - childW - CLAMP_PADDING); const minY = TITLE_BAR_HEIGHT + CLAMP_PADDING; - const maxY = Math.max(minY, itH - childH - CLAMP_PADDING); + const maxY = Math.max(minY, itH - childH - CLAMP_PADDING - 40); // 40 = add node button area const clampedX = Math.min(Math.max(pos.x, minX), maxX); const clampedY = Math.min(Math.max(pos.y, minY), maxY); diff --git a/src/workflow/stores/execution.store.ts b/src/workflow/stores/execution.store.ts index 80ab498f..6975219e 100644 --- a/src/workflow/stores/execution.store.ts +++ b/src/workflow/stores/execution.store.ts @@ -103,6 +103,8 @@ export interface ExecutionState { Array<{ urls: string[]; time: string; cost?: number; durationMs?: number }> >; _wasRunning: boolean; + _lastRunType: "all" | "single" | null; + _lastRunNodeLabel: string | null; _fetchedNodes: Set; /** Run sessions for the global monitor panel */ @@ -172,6 +174,8 @@ export const useExecutionStore = create((set, get) => ({ errorMessages: {}, lastResults: {}, _wasRunning: false, + _lastRunType: null, + _lastRunNodeLabel: null, _fetchedNodes: new Set(), runSessions: [], showRunMonitor: false, @@ -179,6 +183,7 @@ export const useExecutionStore = create((set, get) => ({ toggleRunMonitor: () => set((s) => ({ showRunMonitor: !s.showRunMonitor })), runAllInBrowser: async (nodes, edges) => { + set({ _lastRunType: "all", _lastRunNodeLabel: null }); const nodeLabels: Record = {}; for (const n of nodes) { nodeLabels[n.id] = @@ -278,6 +283,9 @@ export const useExecutionStore = create((set, get) => ({ }, runNodeInBrowser: async (nodes, edges, nodeId) => { + const targetNode = nodes.find((n) => n.id === nodeId); + const targetLabel = (targetNode?.data?.label as string) || targetNode?.data?.nodeType || nodeId.slice(0, 8); + set({ _lastRunType: "single", _lastRunNodeLabel: targetLabel }); const upstream = new Set([nodeId]); const reverse = new Map(); for (const e of edges) { @@ -410,6 +418,7 @@ export const useExecutionStore = create((set, get) => ({ const { nodes, edges } = useWorkflowStore.getState(); const browserNodes = nodes.map((n) => ({ id: n.id, + parentNode: n.parentNode, data: { nodeType: n.data?.nodeType ?? "", params: { @@ -432,6 +441,7 @@ export const useExecutionStore = create((set, get) => ({ const { nodes, edges } = useWorkflowStore.getState(); const browserNodes = nodes.map((n) => ({ id: n.id, + parentNode: n.parentNode, data: { nodeType: n.data?.nodeType ?? "", params: { diff --git a/src/workflow/stores/workflow.store.ts b/src/workflow/stores/workflow.store.ts index 74aae629..6711e7f2 100644 --- a/src/workflow/stores/workflow.store.ts +++ b/src/workflow/stores/workflow.store.ts @@ -23,7 +23,111 @@ import { wouldCreateCycleInSubWorkflow } from "@/workflow/lib/cycle-detection"; const MIN_ITERATOR_WIDTH = 600; const MIN_ITERATOR_HEIGHT = 400; const CHILD_PADDING = 40; -const PORT_STRIP_WIDTH = 140; + +/** + * Purge exposed params (and their port definitions + connected edges) from + * iterator nodes when child nodes are removed or released. + * Returns updated nodes and edges arrays. + */ +function purgeExposedParamsForChildren( + removedChildIds: Set, + nodes: ReactFlowNode[], + edges: ReactFlowEdge[], +): { nodes: ReactFlowNode[]; edges: ReactFlowEdge[] } { + // Find all iterator nodes that reference any of the removed children + const iteratorNodes = nodes.filter( + (n) => n.data.nodeType === "control/iterator", + ); + if (iteratorNodes.length === 0) return { nodes, edges }; + + let updatedNodes = nodes; + let updatedEdges = edges; + + for (const iter of iteratorNodes) { + const params = iter.data.params ?? {}; + let dirty = false; + + for (const direction of ["input", "output"] as const) { + const paramListKey = direction === "input" ? "exposedInputs" : "exposedOutputs"; + const defKey = direction === "input" ? "inputDefinitions" : "outputDefinitions"; + + const currentList: ExposedParam[] = (() => { + try { + const raw = params[paramListKey]; + return typeof raw === "string" ? JSON.parse(raw) : Array.isArray(raw) ? raw : []; + } catch { + return []; + } + })(); + + const filtered = currentList.filter((p) => !removedChildIds.has(p.subNodeId)); + if (filtered.length === currentList.length) continue; // nothing to remove + dirty = true; + + // Keys to remove + const removedKeys = new Set( + currentList.filter((p) => removedChildIds.has(p.subNodeId)).map((p) => p.namespacedKey), + ); + + // Remove port definitions + const currentDefs: PortDefinition[] = iter.data[defKey] ?? []; + const filteredDefs = currentDefs.filter((d) => !removedKeys.has(d.key)); + + // Remove connected edges (both external and internal handles + auto-edges) + const extHandleIds = new Set(); + const intHandleIds = new Set(); + const autoEdgeIds = new Set(); + for (const k of removedKeys) { + if (direction === "input") { + extHandleIds.add(`input-${k}`); + intHandleIds.add(`input-inner-${k}`); + } else { + extHandleIds.add(`output-${k}`); + intHandleIds.add(`output-inner-${k}`); + } + autoEdgeIds.add(`iter-auto-${iter.id}-${k}`); + } + updatedEdges = updatedEdges.filter((e) => { + if (autoEdgeIds.has(e.id)) return false; + if (direction === "input") { + if (e.target === iter.id && extHandleIds.has(e.targetHandle ?? "")) return false; + if (e.source === iter.id && intHandleIds.has(e.sourceHandle ?? "")) return false; + } else { + if (e.source === iter.id && extHandleIds.has(e.sourceHandle ?? "")) return false; + if (e.target === iter.id && intHandleIds.has(e.targetHandle ?? "")) return false; + } + return true; + }); + + // Update the iterator node + updatedNodes = updatedNodes.map((n) => { + if (n.id !== iter.id) return n; + return { + ...n, + data: { + ...n.data, + params: { ...n.data.params, [paramListKey]: JSON.stringify(filtered) }, + [defKey]: filteredDefs, + }, + }; + }); + } + + // Also clean up childNodeIds + if (dirty) { + const currentChildIds: string[] = iter.data.childNodeIds ?? []; + const filteredChildIds = currentChildIds.filter((id: string) => !removedChildIds.has(id)); + if (filteredChildIds.length !== currentChildIds.length) { + updatedNodes = updatedNodes.map((n) => { + if (n.id !== iter.id) return n; + return { ...n, data: { ...n.data, childNodeIds: filteredChildIds } }; + }); + } + } + } + + return { nodes: updatedNodes, edges: updatedEdges }; +} /** Lazy getter to avoid circular import with execution.store */ function getActiveExecutions(): Set { @@ -297,6 +401,7 @@ export interface WorkflowState { updateBoundingBox: (iteratorId: string) => void; exposeParam: (iteratorId: string, param: ExposedParam) => void; unexposeParam: (iteratorId: string, namespacedKey: string, direction: "input" | "output") => void; + syncExposedParamsOnModelSwitch: (childNodeId: string, newLabel: string, newInputParamKeys: string[]) => void; reset: () => void; } @@ -358,10 +463,23 @@ export const useWorkflowStore = create((set, get) => ({ } const { nodes, edges } = get(); pushUndo({ nodes, edges }); - set((state) => ({ - nodes: state.nodes.filter((n) => n.id !== nodeId), - edges: state.edges.filter( - (e) => e.source !== nodeId && e.target !== nodeId, + + // If this is an iterator, collect all child nodes to delete them too + const nodeToDelete = nodes.find((n) => n.id === nodeId); + const idsToRemove = new Set([nodeId]); + if (nodeToDelete?.data?.nodeType === "control/iterator") { + for (const n of nodes) { + if (n.parentNode === nodeId) idsToRemove.add(n.id); + } + } + + // Purge exposed params from parent iterator if this is a child node + const purged = purgeExposedParamsForChildren(idsToRemove, nodes, edges); + + set(() => ({ + nodes: purged.nodes.filter((n) => !idsToRemove.has(n.id)), + edges: purged.edges.filter( + (e) => !idsToRemove.has(e.source) && !idsToRemove.has(e.target), ), isDirty: true, canUndo: true, @@ -380,9 +498,23 @@ export const useWorkflowStore = create((set, get) => ({ const { nodes, edges } = get(); pushUndo({ nodes, edges }); const removeSet = new Set(nodeIds); - set((state) => ({ - nodes: state.nodes.filter((n) => !removeSet.has(n.id)), - edges: state.edges.filter( + + // If any iterator is being deleted, also delete its child nodes + for (const nid of nodeIds) { + const node = nodes.find((n) => n.id === nid); + if (node?.data?.nodeType === "control/iterator") { + for (const n of nodes) { + if (n.parentNode === nid) removeSet.add(n.id); + } + } + } + + // Purge exposed params from parent iterators + const purged = purgeExposedParamsForChildren(removeSet, nodes, edges); + + set(() => ({ + nodes: purged.nodes.filter((n) => !removeSet.has(n.id)), + edges: purged.edges.filter( (e) => !removeSet.has(e.source) && !removeSet.has(e.target), ), isDirty: true, @@ -970,21 +1102,17 @@ export const useWorkflowStore = create((set, get) => ({ pushUndo({ nodes, edges }); + // Purge exposed params for this child from the iterator + const purged = purgeExposedParamsForChildren(new Set([childId]), nodes, edges); + // Convert child position back to absolute coordinates const absolutePosition = { x: childNode.position.x + iteratorNode.position.x, y: childNode.position.y + iteratorNode.position.y, }; - // Remove from childNodeIds in iterator data - const currentChildIds: string[] = - iteratorNode.data.childNodeIds ?? []; - const updatedChildIds = currentChildIds.filter( - (id: string) => id !== childId, - ); - - set((state) => ({ - nodes: state.nodes.map((n) => { + set(() => ({ + nodes: purged.nodes.map((n) => { if (n.id === childId) { // Remove parentNode and extent by spreading without them const { parentNode: _, extent: _e, ...rest } = n; @@ -994,17 +1122,9 @@ export const useWorkflowStore = create((set, get) => ({ data: { ...n.data }, }; } - if (n.id === iteratorId) { - return { - ...n, - data: { - ...n.data, - childNodeIds: updatedChildIds, - }, - }; - } return n; }), + edges: purged.edges, isDirty: true, canUndo: true, canRedo: false, @@ -1044,7 +1164,7 @@ export const useWorkflowStore = create((set, get) => ({ if (right > maxRight) maxRight = right; if (bottom > maxBottom) maxBottom = bottom; } - requiredWidth = Math.max(MIN_ITERATOR_WIDTH, maxRight + PORT_STRIP_WIDTH); + requiredWidth = Math.max(MIN_ITERATOR_WIDTH, maxRight + CHILD_PADDING); requiredHeight = Math.max(MIN_ITERATOR_HEIGHT, maxBottom); } @@ -1103,10 +1223,18 @@ export const useWorkflowStore = create((set, get) => ({ const updatedList = [...currentList, param]; - // Build new port definition + // Build new port definition — label shows "ParamName · NodeLabel" + const readableParam = param.paramKey + .split("_") + .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) + .join(" "); + // Shorten the node label for display (e.g. "bytedance/seedream-v3" → "seedream-v3") + const shortNodeLabel = param.subNodeLabel.includes("/") + ? param.subNodeLabel.split("/").pop()! + : param.subNodeLabel; const newPort: PortDefinition = { key: param.namespacedKey, - label: param.namespacedKey, + label: `${readableParam} · ${shortNodeLabel}`, dataType: param.dataType, required: false, }; @@ -1114,6 +1242,42 @@ export const useWorkflowStore = create((set, get) => ({ const currentDefs: PortDefinition[] = iteratorNode.data[defKey] ?? []; const updatedDefs = [...currentDefs, newPort]; + // Auto-create internal edge between capsule inner handle and child node handle + const autoEdgeId = `iter-auto-${iteratorId}-${param.namespacedKey}`; + let autoEdge: ReactFlowEdge | null = null; + + if (param.direction === "input") { + // Capsule inner source → child node input + // The child node's input handle is either `input-{key}` or `param-{key}` + // For ai-task model schema fields, the handle is `param-{key}` + const childNode = nodes.find((n) => n.id === param.subNodeId); + const childInputDefs = (childNode?.data?.inputDefinitions ?? []) as PortDefinition[]; + const hasInputPort = childInputDefs.some((d: PortDefinition) => d.key === param.paramKey); + const targetHandle = hasInputPort ? `input-${param.paramKey}` : `param-${param.paramKey}`; + + autoEdge = { + id: autoEdgeId, + source: iteratorId, + sourceHandle: `input-inner-${param.namespacedKey}`, + target: param.subNodeId, + targetHandle, + type: "custom", + data: { isInternal: true }, + }; + } else { + // Child node output → capsule inner target + autoEdge = { + id: autoEdgeId, + source: param.subNodeId, + sourceHandle: param.paramKey, + target: iteratorId, + targetHandle: `output-inner-${param.namespacedKey}`, + type: "custom", + data: { isInternal: true }, + }; + } + + // Update node definitions AND add auto-edge in a single state update set((state) => ({ nodes: state.nodes.map((n) => { if (n.id === iteratorId) { @@ -1131,6 +1295,7 @@ export const useWorkflowStore = create((set, get) => ({ } return n; }), + edges: autoEdge ? [...state.edges, autoEdge] : state.edges, isDirty: true, canUndo: true, canRedo: false, @@ -1168,17 +1333,31 @@ export const useWorkflowStore = create((set, get) => ({ (d: PortDefinition) => d.key !== namespacedKey, ); - // Remove any connected edges to/from the handle - const handleId = direction === "input" + // Remove any connected edges to/from both external and internal handles + const extHandleId = direction === "input" ? `input-${namespacedKey}` : `output-${namespacedKey}`; - - const edgesToRemove = edges.filter((e) => - direction === "input" - ? e.target === iteratorId && e.targetHandle === handleId - : e.source === iteratorId && e.sourceHandle === handleId, - ); - const edgeIdsToRemove = new Set(edgesToRemove.map((e) => e.id)); + const intHandleId = direction === "input" + ? `input-inner-${namespacedKey}` + : `output-inner-${namespacedKey}`; + const autoEdgeId = `iter-auto-${iteratorId}-${namespacedKey}`; + + const edgeIdsToRemove = new Set(); + edgeIdsToRemove.add(autoEdgeId); // auto-created internal edge + + for (const e of edges) { + if (direction === "input") { + // External edges connecting to the external target handle + if (e.target === iteratorId && e.targetHandle === extHandleId) edgeIdsToRemove.add(e.id); + // Internal edges from the inner source handle + if (e.source === iteratorId && e.sourceHandle === intHandleId) edgeIdsToRemove.add(e.id); + } else { + // External edges from the external source handle + if (e.source === iteratorId && e.sourceHandle === extHandleId) edgeIdsToRemove.add(e.id); + // Internal edges to the inner target handle + if (e.target === iteratorId && e.targetHandle === intHandleId) edgeIdsToRemove.add(e.id); + } + } set((state) => ({ nodes: state.nodes.map((n) => { @@ -1206,6 +1385,151 @@ export const useWorkflowStore = create((set, get) => ({ })); }, + /** + * Sync exposed params when a child node inside an iterator switches models. + * + * Scenarios handled: + * 1. Label changed → update subNodeLabel + namespacedKey in all exposed params & edges + * 2. Input param removed by new model → remove that exposed input + auto-edge + external edges + * 3. Input param still exists in new model → keep it, update label + * 4. Output always exists (key="output") → keep it, update label with new model name + * 5. Multiple child nodes in same iterator → only affect the switched node's params + */ + syncExposedParamsOnModelSwitch: (childNodeId, newLabel, newInputParamKeys) => { + const { nodes, edges } = get(); + const childNode = nodes.find((n) => n.id === childNodeId); + if (!childNode?.parentNode) return; // not inside an iterator + + const iteratorId = childNode.parentNode; + const iteratorNode = nodes.find((n) => n.id === iteratorId); + if (!iteratorNode || iteratorNode.data.nodeType !== "control/iterator") return; + + const params = iteratorNode.data.params ?? {}; + const newInputKeySet = new Set(newInputParamKeys); + let updatedNodes = nodes; + let updatedEdges = edges; + let dirty = false; + + for (const direction of ["input", "output"] as const) { + const paramListKey = direction === "input" ? "exposedInputs" : "exposedOutputs"; + const defKey = direction === "input" ? "inputDefinitions" : "outputDefinitions"; + + const currentList: ExposedParam[] = (() => { + try { + const raw = params[paramListKey]; + return typeof raw === "string" ? JSON.parse(raw) : Array.isArray(raw) ? raw : []; + } catch { return []; } + })(); + + // Only process params belonging to this child node + const mine = currentList.filter((p) => p.subNodeId === childNodeId); + const others = currentList.filter((p) => p.subNodeId !== childNodeId); + if (mine.length === 0) continue; + + const kept: ExposedParam[] = []; + const removedKeys = new Set(); + + for (const ep of mine) { + if (direction === "input" && !newInputKeySet.has(ep.paramKey)) { + // Input param no longer exists in new model → remove + removedKeys.add(ep.namespacedKey); + } else { + // Param still valid → update label and namespacedKey + const oldNk = ep.namespacedKey; + const newNk = `${newLabel}.${ep.paramKey}`; + const updated: ExposedParam = { ...ep, subNodeLabel: newLabel, namespacedKey: newNk }; + kept.push(updated); + + if (oldNk !== newNk) { + // Rename handle IDs in edges + const oldAutoId = `iter-auto-${iteratorId}-${oldNk}`; + const newAutoId = `iter-auto-${iteratorId}-${newNk}`; + updatedEdges = updatedEdges.map((e) => { + if (e.id === oldAutoId) { + if (direction === "input") { + return { ...e, id: newAutoId, sourceHandle: `input-inner-${newNk}` }; + } else { + return { ...e, id: newAutoId, targetHandle: `output-inner-${newNk}` }; + } + } + // Update external edges referencing old handle IDs + if (direction === "input") { + if (e.target === iteratorId && e.targetHandle === `input-${oldNk}`) { + return { ...e, targetHandle: `input-${newNk}` }; + } + if (e.source === iteratorId && e.sourceHandle === `input-inner-${oldNk}`) { + return { ...e, sourceHandle: `input-inner-${newNk}` }; + } + } else { + if (e.source === iteratorId && e.sourceHandle === `output-${oldNk}`) { + return { ...e, sourceHandle: `output-${newNk}` }; + } + if (e.target === iteratorId && e.targetHandle === `output-inner-${oldNk}`) { + return { ...e, targetHandle: `output-inner-${newNk}` }; + } + } + return e; + }); + } + } + } + + // Remove edges for removed params + if (removedKeys.size > 0) { + const removeHandles = new Set(); + const removeAutoIds = new Set(); + for (const k of removedKeys) { + removeAutoIds.add(`iter-auto-${iteratorId}-${k}`); + if (direction === "input") { + removeHandles.add(`input-${k}`); + removeHandles.add(`input-inner-${k}`); + } else { + removeHandles.add(`output-${k}`); + removeHandles.add(`output-inner-${k}`); + } + } + updatedEdges = updatedEdges.filter((e) => { + if (removeAutoIds.has(e.id)) return false; + if (direction === "input") { + if (e.target === iteratorId && removeHandles.has(e.targetHandle ?? "")) return false; + if (e.source === iteratorId && removeHandles.has(e.sourceHandle ?? "")) return false; + } else { + if (e.source === iteratorId && removeHandles.has(e.sourceHandle ?? "")) return false; + if (e.target === iteratorId && removeHandles.has(e.targetHandle ?? "")) return false; + } + return true; + }); + } + + const updatedList = [...others, ...kept]; + const currentDefs: PortDefinition[] = iteratorNode.data[defKey] ?? []; + // Rebuild defs: keep others' defs, rebuild this child's defs from kept params + const otherDefs = currentDefs.filter((d) => !mine.some((m) => m.namespacedKey === d.key)); + const keptDefs = kept.map((ep): PortDefinition => { + const readableParam = ep.paramKey.split("_").map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" "); + const shortLabel = ep.subNodeLabel.includes("/") ? ep.subNodeLabel.split("/").pop()! : ep.subNodeLabel; + return { key: ep.namespacedKey, label: `${readableParam} · ${shortLabel}`, dataType: ep.dataType, required: false }; + }); + + updatedNodes = updatedNodes.map((n) => { + if (n.id !== iteratorId) return n; + return { + ...n, + data: { + ...n.data, + params: { ...n.data.params, [paramListKey]: JSON.stringify(updatedList) }, + [defKey]: [...otherDefs, ...keptDefs], + }, + }; + }); + dirty = true; + } + + if (dirty) { + set({ nodes: updatedNodes, edges: updatedEdges, isDirty: true }); + } + }, + reset: () => { const { nodes, edges } = getDefaultNewWorkflowContent(); set({ From a8f491adadada2666cc54bca25be7763b8d057f0 Mon Sep 17 00:00:00 2001 From: Leon Date: Fri, 13 Mar 2026 18:01:14 +1100 Subject: [PATCH 03/18] feat: add iterator icon + hover hints for iterator & directory-import in all 18 locales --- src/i18n/locales/ar.json | 8 ++++++++ src/i18n/locales/de.json | 8 ++++++++ src/i18n/locales/en.json | 8 ++++++++ src/i18n/locales/es.json | 8 ++++++++ src/i18n/locales/fr.json | 8 ++++++++ src/i18n/locales/hi.json | 8 ++++++++ src/i18n/locales/id.json | 8 ++++++++ src/i18n/locales/it.json | 8 ++++++++ src/i18n/locales/ja.json | 8 ++++++++ src/i18n/locales/ko.json | 8 ++++++++ src/i18n/locales/ms.json | 8 ++++++++ src/i18n/locales/pt.json | 8 ++++++++ src/i18n/locales/ru.json | 8 ++++++++ src/i18n/locales/th.json | 8 ++++++++ src/i18n/locales/tr.json | 8 ++++++++ src/i18n/locales/vi.json | 8 ++++++++ src/i18n/locales/zh-CN.json | 8 ++++++++ src/i18n/locales/zh-TW.json | 8 ++++++++ src/workflow/components/canvas/custom-node/NodeIcons.tsx | 3 +++ 19 files changed, 147 insertions(+) diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index 78b7b50b..b258bf33 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -1348,6 +1348,14 @@ "processing/select": { "label": "اختيار", "hint": "اختيار عنصر واحد من مصفوفة حسب الفهرس" + }, + "control/iterator": { + "label": "مكرر", + "hint": "التكرار عبر المدخلات — تشغيل العقد الفرعية N مرة أو مرة واحدة لكل عنصر في المصفوفة" + }, + "input/directory-import": { + "label": "استيراد مجلد", + "hint": "مسح مجلد محلي واستيراد ملفات الوسائط المطابقة كمصفوفة" } }, "modelSelector": { diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index b0c10d5c..fc5cfcae 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -1349,6 +1349,14 @@ "processing/select": { "label": "Auswählen", "hint": "Ein Element aus einem Array per Index auswählen" + }, + "control/iterator": { + "label": "Iterator", + "hint": "Eingaben durchlaufen — Kindknoten N-mal oder einmal pro Array-Element ausführen" + }, + "input/directory-import": { + "label": "Verzeichnis importieren", + "hint": "Lokalen Ordner scannen und passende Mediendateien als Array importieren" } }, "modelSelector": { diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 9a1cfd59..540f068d 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1386,6 +1386,14 @@ "processing/select": { "label": "Select", "hint": "Pick one item from an array by index" + }, + "control/iterator": { + "label": "Iterator", + "hint": "Loop over inputs — run child nodes N times or once per array item" + }, + "input/directory-import": { + "label": "Directory Import", + "hint": "Scan a local folder and import matching media files as an array" } }, "modelSelector": { diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 0973af79..57b0c654 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -1348,6 +1348,14 @@ "processing/select": { "label": "Seleccionar", "hint": "Elegir un elemento de un array por índice" + }, + "control/iterator": { + "label": "Iterador", + "hint": "Iterar sobre entradas — ejecutar nodos hijos N veces o una vez por elemento del array" + }, + "input/directory-import": { + "label": "Importar directorio", + "hint": "Escanear una carpeta local e importar archivos multimedia coincidentes como array" } }, "modelSelector": { diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 516df2db..7cf39b31 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -1349,6 +1349,14 @@ "processing/select": { "label": "Sélectionner", "hint": "Choisir un élément d'un tableau par index" + }, + "control/iterator": { + "label": "Itérateur", + "hint": "Boucler sur les entrées — exécuter les nœuds enfants N fois ou une fois par élément du tableau" + }, + "input/directory-import": { + "label": "Importer un répertoire", + "hint": "Scanner un dossier local et importer les fichiers multimédias correspondants sous forme de tableau" } }, "modelSelector": { diff --git a/src/i18n/locales/hi.json b/src/i18n/locales/hi.json index fe49348b..1772558c 100644 --- a/src/i18n/locales/hi.json +++ b/src/i18n/locales/hi.json @@ -1348,6 +1348,14 @@ "processing/select": { "label": "चुनें", "hint": "इंडेक्स द्वारा ऐरे से एक आइटम चुनें" + }, + "control/iterator": { + "label": "इटरेटर", + "hint": "इनपुट पर लूप — चाइल्ड नोड्स को N बार या प्रत्येक ऐरे आइटम के लिए एक बार चलाएं" + }, + "input/directory-import": { + "label": "डायरेक्टरी आयात", + "hint": "स्थानीय फ़ोल्डर स्कैन करें और मिलान करने वाली मीडिया फ़ाइलों को ऐरे के रूप में आयात करें" } }, "modelSelector": { diff --git a/src/i18n/locales/id.json b/src/i18n/locales/id.json index 93211156..a324e51c 100644 --- a/src/i18n/locales/id.json +++ b/src/i18n/locales/id.json @@ -1348,6 +1348,14 @@ "processing/select": { "label": "Pilih", "hint": "Pilih satu item dari array berdasarkan indeks" + }, + "control/iterator": { + "label": "Iterator", + "hint": "Ulangi input — jalankan node anak N kali atau sekali per item array" + }, + "input/directory-import": { + "label": "Impor Direktori", + "hint": "Pindai folder lokal dan impor file media yang cocok sebagai array" } }, "modelSelector": { diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index 62a900f3..e1cbdd01 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -1348,6 +1348,14 @@ "processing/select": { "label": "Seleziona", "hint": "Scegli un elemento da un array per indice" + }, + "control/iterator": { + "label": "Iteratore", + "hint": "Ciclo sugli input — esegui i nodi figli N volte o una volta per elemento dell'array" + }, + "input/directory-import": { + "label": "Importa directory", + "hint": "Scansiona una cartella locale e importa i file multimediali corrispondenti come array" } }, "modelSelector": { diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index ebdd4ec2..a87faf62 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1316,6 +1316,14 @@ "processing/select": { "label": "選択", "hint": "配列からインデックスで1つの項目を選択" + }, + "control/iterator": { + "label": "イテレーター", + "hint": "入力をループ — 子ノードをN回またはアレイ項目ごとに1回実行" + }, + "input/directory-import": { + "label": "ディレクトリインポート", + "hint": "ローカルフォルダをスキャンし、一致するメディアファイルを配列としてインポート" } }, "modelSelector": { diff --git a/src/i18n/locales/ko.json b/src/i18n/locales/ko.json index e696558b..9eb85740 100644 --- a/src/i18n/locales/ko.json +++ b/src/i18n/locales/ko.json @@ -1349,6 +1349,14 @@ "processing/select": { "label": "선택", "hint": "배열에서 인덱스로 항목 하나를 선택" + }, + "control/iterator": { + "label": "반복기", + "hint": "입력을 반복 — 자식 노드를 N번 또는 배열 항목당 한 번 실행" + }, + "input/directory-import": { + "label": "디렉토리 가져오기", + "hint": "로컬 폴더를 스캔하고 일치하는 미디어 파일을 배열로 가져오기" } }, "modelSelector": { diff --git a/src/i18n/locales/ms.json b/src/i18n/locales/ms.json index 7c7f254d..79d3e166 100644 --- a/src/i18n/locales/ms.json +++ b/src/i18n/locales/ms.json @@ -1348,6 +1348,14 @@ "processing/select": { "label": "Pilih", "hint": "Pilih satu item dari tatasusunan mengikut indeks" + }, + "control/iterator": { + "label": "Iterator", + "hint": "Gelung melalui input — jalankan nod anak N kali atau sekali bagi setiap item tatasusunan" + }, + "input/directory-import": { + "label": "Import Direktori", + "hint": "Imbas folder tempatan dan import fail media yang sepadan sebagai tatasusunan" } }, "modelSelector": { diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 868ed50f..d0258eac 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -1348,6 +1348,14 @@ "processing/select": { "label": "Selecionar", "hint": "Escolher um item de um array por índice" + }, + "control/iterator": { + "label": "Iterador", + "hint": "Iterar sobre entradas — executar nós filhos N vezes ou uma vez por item do array" + }, + "input/directory-import": { + "label": "Importar Diretório", + "hint": "Verificar uma pasta local e importar ficheiros multimédia correspondentes como array" } }, "modelSelector": { diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 868135a5..6a6f0ce4 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -1348,6 +1348,14 @@ "processing/select": { "label": "Выбрать", "hint": "Выбрать один элемент из массива по индексу" + }, + "control/iterator": { + "label": "Итератор", + "hint": "Цикл по входным данным — запуск дочерних узлов N раз или по одному на элемент массива" + }, + "input/directory-import": { + "label": "Импорт каталога", + "hint": "Сканировать локальную папку и импортировать подходящие медиафайлы как массив" } }, "modelSelector": { diff --git a/src/i18n/locales/th.json b/src/i18n/locales/th.json index ad9d9e25..bbccabd6 100644 --- a/src/i18n/locales/th.json +++ b/src/i18n/locales/th.json @@ -1348,6 +1348,14 @@ "processing/select": { "label": "เลือก", "hint": "เลือกรายการหนึ่งจากอาร์เรย์ตามดัชนี" + }, + "control/iterator": { + "label": "ตัววนซ้ำ", + "hint": "วนซ้ำอินพุต — รันโหนดย่อย N ครั้งหรือครั้งละรายการในอาร์เรย์" + }, + "input/directory-import": { + "label": "นำเข้าไดเรกทอรี", + "hint": "สแกนโฟลเดอร์ในเครื่องและนำเข้าไฟล์สื่อที่ตรงกันเป็นอาร์เรย์" } }, "modelSelector": { diff --git a/src/i18n/locales/tr.json b/src/i18n/locales/tr.json index d6639537..1626e3ed 100644 --- a/src/i18n/locales/tr.json +++ b/src/i18n/locales/tr.json @@ -1348,6 +1348,14 @@ "processing/select": { "label": "Seç", "hint": "Bir diziden indekse göre bir öğe seçin" + }, + "control/iterator": { + "label": "Yineleyici", + "hint": "Girdiler üzerinde döngü — alt düğümleri N kez veya dizi öğesi başına bir kez çalıştır" + }, + "input/directory-import": { + "label": "Dizin İçe Aktar", + "hint": "Yerel klasörü tara ve eşleşen medya dosyalarını dizi olarak içe aktar" } }, "modelSelector": { diff --git a/src/i18n/locales/vi.json b/src/i18n/locales/vi.json index 4db88b7b..8dbb3cc2 100644 --- a/src/i18n/locales/vi.json +++ b/src/i18n/locales/vi.json @@ -1348,6 +1348,14 @@ "processing/select": { "label": "Chọn", "hint": "Chọn một mục từ mảng theo chỉ mục" + }, + "control/iterator": { + "label": "Bộ lặp", + "hint": "Lặp qua đầu vào — chạy nút con N lần hoặc một lần cho mỗi phần tử mảng" + }, + "input/directory-import": { + "label": "Nhập thư mục", + "hint": "Quét thư mục cục bộ và nhập các tệp phương tiện phù hợp dưới dạng mảng" } }, "modelSelector": { diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 82d1c7d5..77e33d6a 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -1377,6 +1377,14 @@ "processing/select": { "label": "选择", "hint": "按索引从数组中取出一个元素" + }, + "control/iterator": { + "label": "迭代器", + "hint": "循环执行子节点 — 固定 N 次或按数组长度自动迭代" + }, + "input/directory-import": { + "label": "目录导入", + "hint": "扫描本地文件夹,将匹配的媒体文件导入为数组" } }, "modelSelector": { diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index 375a2b2d..7bee9f0b 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -1313,6 +1313,14 @@ "processing/select": { "label": "選擇", "hint": "按索引從陣列中取出一個元素" + }, + "control/iterator": { + "label": "迭代器", + "hint": "循環執行子節點 — 固定 N 次或按陣列長度自動迭代" + }, + "input/directory-import": { + "label": "目錄匯入", + "hint": "掃描本機資料夾,將符合的媒體檔案匯入為陣列" } }, "modelSelector": { diff --git a/src/workflow/components/canvas/custom-node/NodeIcons.tsx b/src/workflow/components/canvas/custom-node/NodeIcons.tsx index 94e0a2bd..9f3d7684 100644 --- a/src/workflow/components/canvas/custom-node/NodeIcons.tsx +++ b/src/workflow/components/canvas/custom-node/NodeIcons.tsx @@ -23,6 +23,7 @@ import { GitMerge, ListFilter, FolderOpen, + Repeat, type LucideIcon, } from "lucide-react"; @@ -52,6 +53,8 @@ const NODE_ICON_MAP: Record = { // Processing "processing/concat": GitMerge, "processing/select": ListFilter, + // Control + "control/iterator": Repeat, }; export function getNodeIcon(nodeType: string): LucideIcon | null { From f8d18cd04c8824294ff6d570cb2ea87617c583ea Mon Sep 17 00:00:00 2001 From: Leon Date: Tue, 17 Mar 2026 20:13:24 +1100 Subject: [PATCH 04/18] feat: add iterator DB migration (parent_node_id, is_internal), fix browser executor client, add 7zip-bin dep --- electron/workflow/db/schema.ts | 28 ++++++++++++++++++++++++++ package-lock.json | 6 +++--- package.json | 1 + src/workflow/browser/run-in-browser.ts | 2 +- 4 files changed, 33 insertions(+), 4 deletions(-) diff --git a/electron/workflow/db/schema.ts b/electron/workflow/db/schema.ts index 45d7f2f5..bfd2e7df 100644 --- a/electron/workflow/db/schema.ts +++ b/electron/workflow/db/schema.ts @@ -84,6 +84,26 @@ const migrations: NamedMigration[] = [ } }, }, + { + id: "004_add_iterator_support", + apply: (db: SqlJsDatabase) => { + console.log("[Schema] Applying migration: 004_add_iterator_support"); + // Add parent_node_id to nodes + const nodeCols = db.exec("PRAGMA table_info(nodes)"); + const hasParentNodeId = nodeCols[0]?.values?.some((row) => row[1] === "parent_node_id"); + if (!hasParentNodeId) { + db.run("ALTER TABLE nodes ADD COLUMN parent_node_id TEXT REFERENCES nodes(id) ON DELETE SET NULL"); + db.run("CREATE INDEX IF NOT EXISTS idx_nodes_parent ON nodes(parent_node_id)"); + } + // Add is_internal to edges + const edgeCols = db.exec("PRAGMA table_info(edges)"); + const hasIsInternal = edgeCols[0]?.values?.some((row) => row[1] === "is_internal"); + if (!hasIsInternal) { + db.run("ALTER TABLE edges ADD COLUMN is_internal INTEGER NOT NULL DEFAULT 0 CHECK (is_internal IN (0, 1))"); + db.run("CREATE INDEX IF NOT EXISTS idx_edges_internal ON edges(is_internal)"); + } + }, + }, ]; export function initializeSchema(db: SqlJsDatabase): void { @@ -110,6 +130,7 @@ export function initializeSchema(db: SqlJsDatabase): void { position_y REAL NOT NULL, params TEXT NOT NULL DEFAULT '{}', current_output_id TEXT, + parent_node_id TEXT REFERENCES nodes(id) ON DELETE SET NULL, FOREIGN KEY (current_output_id) REFERENCES node_executions(id) ON DELETE SET NULL )`); @@ -136,6 +157,7 @@ export function initializeSchema(db: SqlJsDatabase): void { source_output_key TEXT NOT NULL, target_node_id TEXT NOT NULL REFERENCES nodes(id) ON DELETE CASCADE, target_input_key TEXT NOT NULL, + is_internal INTEGER NOT NULL DEFAULT 0 CHECK (is_internal IN (0, 1)), UNIQUE(source_node_id, source_output_key, target_node_id, target_input_key) )`); @@ -200,6 +222,12 @@ export function initializeSchema(db: SqlJsDatabase): void { db.run( "CREATE INDEX IF NOT EXISTS idx_wf_edges_target ON edges(target_node_id)", ); + db.run( + "CREATE INDEX IF NOT EXISTS idx_nodes_parent ON nodes(parent_node_id)", + ); + db.run( + "CREATE INDEX IF NOT EXISTS idx_edges_internal ON edges(is_internal)", + ); db.run( "CREATE INDEX IF NOT EXISTS idx_wf_daily_spend_date ON daily_spend(date)", ); diff --git a/package-lock.json b/package-lock.json index 80bc39f6..906909ac 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,15 @@ { "name": "wavespeed-desktop", - "version": "2.0.15", + "version": "2.0.23", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "wavespeed-desktop", - "version": "2.0.15", + "version": "2.0.23", "hasInstallScript": true, "dependencies": { + "7zip-bin": "^5.2.0", "adm-zip": "^0.5.16", "electron-log": "^5.4.3", "electron-updater": "^6.6.2", @@ -4833,7 +4834,6 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/7zip-bin/-/7zip-bin-5.2.0.tgz", "integrity": "sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A==", - "dev": true, "license": "MIT" }, "node_modules/abbrev": { diff --git a/package.json b/package.json index 8e06148f..66ff117d 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "format:check": "prettier --check \"src/**/*.{ts,tsx,css}\" \"electron/**/*.ts\"" }, "dependencies": { + "7zip-bin": "^5.2.0", "adm-zip": "^0.5.16", "electron-log": "^5.4.3", "electron-updater": "^6.6.2", diff --git a/src/workflow/browser/run-in-browser.ts b/src/workflow/browser/run-in-browser.ts index adba48f3..7b844511 100644 --- a/src/workflow/browser/run-in-browser.ts +++ b/src/workflow/browser/run-in-browser.ts @@ -1330,7 +1330,7 @@ export async function executeWorkflowInBrowser( const apiParams = buildApiParams(childParams, childInputs); const resolvedParams = await uploadLocalUrls(apiParams, signal); callbacks.onProgress(childId, 10, `Running ${modelId}...`); - const result = await apiClient.run(modelId, resolvedParams, { signal }); + const result = await workflowClient.run(modelId, resolvedParams, { signal }); const outputUrl = Array.isArray(result.outputs) && result.outputs.length > 0 ? String(result.outputs[0]) : ""; const model = useModelsStore.getState().getModelById(modelId); const cost = model?.base_price ?? 0; From 095d6faa435636c2c5f656e4d485743e847ff555 Mon Sep 17 00:00:00 2001 From: linqiquan <735525520@qq.com> Date: Thu, 19 Mar 2026 01:06:09 +0800 Subject: [PATCH 05/18] feat: replace iterator with subgraph groups, add trigger nodes and HTTP server - Refactor iterator node into generalized subgraph/group node system - GroupNodeContainer, GroupIONode, SubgraphBreadcrumb, SubgraphToolbar - useGroupAdoption hook (replaces useIteratorAdoption) - Dynamic fields editor for group I/O configuration - Add trigger node system (base, HTTP trigger, directory trigger) - HTTP server service with start/stop/status management - HTTP response output node - HTTP server IPC handlers - Add ImportWorkflowDialog for importing workflows as subgraphs - Update execution engine for subgraph and trigger support - Update workflow/execution/UI stores for group node operations - Update node palette, config panel, results panel - Add i18n strings (en, zh-CN) for new features - Update DB schema for subgraph support --- .vscode/settings.json | 3 +- electron/workflow/db/edge.repo.ts | 1 - electron/workflow/db/schema.ts | 44 +- electron/workflow/db/workflow.repo.ts | 4 +- electron/workflow/engine/dag-utils.ts | 3 +- electron/workflow/engine/executor.ts | 180 +++- electron/workflow/index.ts | 4 + electron/workflow/ipc/http-server.ipc.ts | 26 + electron/workflow/nodes/control/iterator.ts | 353 ------- electron/workflow/nodes/control/subgraph.ts | 267 +++++ .../workflow/nodes/input/directory-import.ts | 17 +- .../workflow/nodes/output/http-response.ts | 119 +++ electron/workflow/nodes/register-all.ts | 33 +- electron/workflow/nodes/trigger/base.ts | 43 + electron/workflow/nodes/trigger/directory.ts | 160 +++ electron/workflow/nodes/trigger/http.ts | 153 +++ electron/workflow/services/http-server.ts | 373 +++++++ src/i18n/locales/en.json | 50 +- src/i18n/locales/zh-CN.json | 50 +- src/index.css | 14 + src/workflow/WorkflowPage.tsx | 9 +- src/workflow/browser/run-in-browser.ts | 772 ++++++++++---- .../canvas/ImportWorkflowDialog.tsx | 189 ++++ .../components/canvas/NodePalette.tsx | 114 +- .../components/canvas/SubgraphBreadcrumb.tsx | 76 ++ .../components/canvas/SubgraphToolbar.tsx | 515 +++++++++ .../components/canvas/WorkflowCanvas.tsx | 650 +++++++++++- .../canvas/custom-node/CustomNode.tsx | 100 +- .../canvas/custom-node/CustomNodeBody.tsx | 320 +++++- .../custom-node/CustomNodeInputBodies.tsx | 71 +- .../custom-node/DynamicFieldsEditor.tsx | 146 +++ .../canvas/custom-node/IteratorExposeBar.tsx | 237 ----- .../canvas/custom-node/NodeIcons.tsx | 8 + .../canvas/group-node/GroupIONode.tsx | 163 +++ .../canvas/group-node/GroupNodeContainer.tsx | 963 +++++++++++++++++ .../iterator-node/IteratorNodeContainer.tsx | 977 ------------------ .../components/panels/NodeConfigPanel.tsx | 115 ++- .../components/panels/ResultsPanel.tsx | 17 +- src/workflow/hooks/useGroupAdoption.ts | 110 ++ src/workflow/hooks/useIteratorAdoption.ts | 203 ---- src/workflow/lib/cycle-detection.ts | 4 +- src/workflow/stores/execution.store.ts | 118 ++- src/workflow/stores/ui.store.ts | 24 + src/workflow/stores/workflow.store.ts | 741 +++++++++++-- src/workflow/types/node-defs.ts | 1 + src/workflow/types/workflow.ts | 1 + 46 files changed, 6337 insertions(+), 2204 deletions(-) create mode 100644 electron/workflow/ipc/http-server.ipc.ts delete mode 100644 electron/workflow/nodes/control/iterator.ts create mode 100644 electron/workflow/nodes/control/subgraph.ts create mode 100644 electron/workflow/nodes/output/http-response.ts create mode 100644 electron/workflow/nodes/trigger/base.ts create mode 100644 electron/workflow/nodes/trigger/directory.ts create mode 100644 electron/workflow/nodes/trigger/http.ts create mode 100644 electron/workflow/services/http-server.ts create mode 100644 src/workflow/components/canvas/ImportWorkflowDialog.tsx create mode 100644 src/workflow/components/canvas/SubgraphBreadcrumb.tsx create mode 100644 src/workflow/components/canvas/SubgraphToolbar.tsx create mode 100644 src/workflow/components/canvas/custom-node/DynamicFieldsEditor.tsx delete mode 100644 src/workflow/components/canvas/custom-node/IteratorExposeBar.tsx create mode 100644 src/workflow/components/canvas/group-node/GroupIONode.tsx create mode 100644 src/workflow/components/canvas/group-node/GroupNodeContainer.tsx delete mode 100644 src/workflow/components/canvas/iterator-node/IteratorNodeContainer.tsx create mode 100644 src/workflow/hooks/useGroupAdoption.ts delete mode 100644 src/workflow/hooks/useIteratorAdoption.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index 2c63c085..0967ef42 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,2 +1 @@ -{ -} +{} diff --git a/electron/workflow/db/edge.repo.ts b/electron/workflow/db/edge.repo.ts index fe47f0bd..36332975 100644 --- a/electron/workflow/db/edge.repo.ts +++ b/electron/workflow/db/edge.repo.ts @@ -53,4 +53,3 @@ export function getInternalEdges(workflowId: string): WorkflowEdge[] { if (!result.length) return []; return result[0].values.map(rowToEdge); } - diff --git a/electron/workflow/db/schema.ts b/electron/workflow/db/schema.ts index bfd2e7df..06038446 100644 --- a/electron/workflow/db/schema.ts +++ b/electron/workflow/db/schema.ts @@ -78,7 +78,9 @@ const migrations: NamedMigration[] = [ apply: (db: SqlJsDatabase) => { console.log("[Schema] Applying migration: 003_add_search_text"); const cols = db.exec("PRAGMA table_info(templates)"); - const hasColumn = cols[0]?.values?.some((row) => row[1] === "search_text"); + const hasColumn = cols[0]?.values?.some( + (row) => row[1] === "search_text", + ); if (!hasColumn) { db.run("ALTER TABLE templates ADD COLUMN search_text TEXT"); } @@ -90,17 +92,29 @@ const migrations: NamedMigration[] = [ console.log("[Schema] Applying migration: 004_add_iterator_support"); // Add parent_node_id to nodes const nodeCols = db.exec("PRAGMA table_info(nodes)"); - const hasParentNodeId = nodeCols[0]?.values?.some((row) => row[1] === "parent_node_id"); + const hasParentNodeId = nodeCols[0]?.values?.some( + (row) => row[1] === "parent_node_id", + ); if (!hasParentNodeId) { - db.run("ALTER TABLE nodes ADD COLUMN parent_node_id TEXT REFERENCES nodes(id) ON DELETE SET NULL"); - db.run("CREATE INDEX IF NOT EXISTS idx_nodes_parent ON nodes(parent_node_id)"); + db.run( + "ALTER TABLE nodes ADD COLUMN parent_node_id TEXT REFERENCES nodes(id) ON DELETE SET NULL", + ); + db.run( + "CREATE INDEX IF NOT EXISTS idx_nodes_parent ON nodes(parent_node_id)", + ); } // Add is_internal to edges const edgeCols = db.exec("PRAGMA table_info(edges)"); - const hasIsInternal = edgeCols[0]?.values?.some((row) => row[1] === "is_internal"); + const hasIsInternal = edgeCols[0]?.values?.some( + (row) => row[1] === "is_internal", + ); if (!hasIsInternal) { - db.run("ALTER TABLE edges ADD COLUMN is_internal INTEGER NOT NULL DEFAULT 0 CHECK (is_internal IN (0, 1))"); - db.run("CREATE INDEX IF NOT EXISTS idx_edges_internal ON edges(is_internal)"); + db.run( + "ALTER TABLE edges ADD COLUMN is_internal INTEGER NOT NULL DEFAULT 0 CHECK (is_internal IN (0, 1))", + ); + db.run( + "CREATE INDEX IF NOT EXISTS idx_edges_internal ON edges(is_internal)", + ); } }, }, @@ -225,9 +239,7 @@ export function initializeSchema(db: SqlJsDatabase): void { db.run( "CREATE INDEX IF NOT EXISTS idx_nodes_parent ON nodes(parent_node_id)", ); - db.run( - "CREATE INDEX IF NOT EXISTS idx_edges_internal ON edges(is_internal)", - ); + db.run("CREATE INDEX IF NOT EXISTS idx_edges_internal ON edges(is_internal)"); db.run( "CREATE INDEX IF NOT EXISTS idx_wf_daily_spend_date ON daily_spend(date)", ); @@ -271,9 +283,13 @@ export function runMigrations(db: SqlJsDatabase): void { if (hasLegacyTable && !hasNewTable) { // Migrate from old numeric system to named migrations - console.log("[Schema] Upgrading from legacy schema_version to named migrations"); + console.log( + "[Schema] Upgrading from legacy schema_version to named migrations", + ); - const result = db.exec("SELECT MAX(version) as version FROM schema_version"); + const result = db.exec( + "SELECT MAX(version) as version FROM schema_version", + ); const legacyVersion = (result[0]?.values?.[0]?.[0] as number) ?? 0; // Create the new table @@ -283,7 +299,9 @@ export function runMigrations(db: SqlJsDatabase): void { )`); // Map old version number to known migration IDs - const knownApplied = LEGACY_VERSION_MAP[legacyVersion] ?? ["001_initial_schema"]; + const knownApplied = LEGACY_VERSION_MAP[legacyVersion] ?? [ + "001_initial_schema", + ]; for (const id of knownApplied) { db.run("INSERT OR IGNORE INTO schema_migrations (id) VALUES (?)", [id]); } diff --git a/electron/workflow/db/workflow.repo.ts b/electron/workflow/db/workflow.repo.ts index 0828f378..1be09a0b 100644 --- a/electron/workflow/db/workflow.repo.ts +++ b/electron/workflow/db/workflow.repo.ts @@ -286,7 +286,9 @@ export function duplicateWorkflow(sourceId: string): Workflow { id: nodeIdMap.get(n.id)!, workflowId: newWf.id, currentOutputId: null, - parentNodeId: n.parentNodeId ? (nodeIdMap.get(n.parentNodeId) ?? null) : null, + parentNodeId: n.parentNodeId + ? (nodeIdMap.get(n.parentNodeId) ?? null) + : null, }), ); diff --git a/electron/workflow/engine/dag-utils.ts b/electron/workflow/engine/dag-utils.ts index 70fc596f..70d68dad 100644 --- a/electron/workflow/engine/dag-utils.ts +++ b/electron/workflow/engine/dag-utils.ts @@ -113,8 +113,7 @@ export function buildOuterDAGView( // Start with edges that don't touch any child node at all const edges: SimpleEdge[] = allEdges.filter( - (e) => - !allChildIds.has(e.sourceNodeId) && !allChildIds.has(e.targetNodeId), + (e) => !allChildIds.has(e.sourceNodeId) && !allChildIds.has(e.targetNodeId), ); // Add remapped external edges for each iterator diff --git a/electron/workflow/engine/executor.ts b/electron/workflow/engine/executor.ts index 2c0e978b..16af463c 100644 --- a/electron/workflow/engine/executor.ts +++ b/electron/workflow/engine/executor.ts @@ -19,6 +19,7 @@ import { getFileStorageInstance } from "../utils/file-storage"; import { saveWorkflowResultToAssets } from "../utils/save-to-assets"; import { getWorkflowById } from "../db/workflow.repo"; import type { NodeExecutionContext, NodeExecutionResult } from "../nodes/base"; +import { isTriggerHandler } from "../nodes/trigger/base"; import type { NodeStatus } from "../../../src/workflow/types/execution"; import type { WorkflowNode, @@ -61,26 +62,117 @@ export class ExecutionEngine { private callbacks: ExecutionCallbacks, ) {} - /** Run all nodes in topological order. */ - async runAll(workflowId: string): Promise { + /** Run all nodes in topological order. Detects trigger nodes for batch execution. + * Returns collected HTTP response data if an HTTP Response node exists. */ + async runAll( + workflowId: string, + triggerValue?: Record, + ): Promise | void> { const allNodes = getNodesByWorkflowId(workflowId); const allEdges = getEdgesByWorkflowId(workflowId); - // Exclude child nodes (they are executed internally by their parent Iterator handler) + // Exclude child nodes (executed internally by their parent Group handler) const nodes = allNodes.filter((n) => !n.parentNodeId); - // Exclude internal edges (edges between sub-nodes inside an Iterator) + // Exclude internal edges (edges between sub-nodes inside a Group) const edges = allEdges.filter((e) => !e.isInternal); + // If triggerValue is provided (from HTTP server), inject it into the HTTP Trigger node + const httpTriggerNode = nodes.find((n) => n.nodeType === "trigger/http"); + if (triggerValue && httpTriggerNode) { + httpTriggerNode.params = { + ...httpTriggerNode.params, + __triggerValue: triggerValue, + }; + } + + // Detect batch trigger node (e.g. directory trigger) + const triggerNode = nodes.find( + (n) => n.nodeType.startsWith("trigger/") && n.nodeType !== "trigger/http", + ); + const triggerHandler = triggerNode + ? this.registry.getHandler(triggerNode.nodeType) + : undefined; + + if ( + triggerNode && + triggerHandler && + isTriggerHandler(triggerHandler) && + triggerHandler.triggerMode === "batch" && + triggerHandler.getItems + ) { + // Batch execution: get all items, run workflow once per item + const items = await triggerHandler.getItems(triggerNode.params); + console.log( + `[Executor] Batch trigger: ${items.length} items from ${triggerNode.nodeType}`, + ); + + for (let i = 0; i < items.length; i++) { + const item = items[i]; + this.callbacks.onProgress( + workflowId, + triggerNode.id, + ((i + 1) / items.length) * 100, + `Processing ${item.label ?? `item ${i + 1}`} (${i + 1}/${items.length})`, + ); + + const originalParams = { ...triggerNode.params }; + triggerNode.params = { + ...triggerNode.params, + __triggerValue: item.value, + }; + + try { + await this.runWorkflowOnce(workflowId, nodes, edges); + } finally { + triggerNode.params = originalParams; + } + } + return; + } + + // Single execution + const failures = await this.runWorkflowOnce(workflowId, nodes, edges); + + // Collect HTTP Response node result if present + const httpResponse = this.collectHttpResponse(nodes); + if (httpResponse) return httpResponse; + + // If there were failures and no HTTP Response was collected, return error info + if (failures.length > 0) { + const firstReal = failures.find((f) => !f.error.startsWith("Skipped")); + const errMsg = firstReal?.error ?? failures[0].error; + return { + statusCode: 500, + body: { + error_msg: errMsg, + }, + }; + } + + return undefined; + } + + /** + * Execute the workflow graph once in topological order. + * Extracted from the old runAll so it can be called in a loop for batch triggers. + * Returns an array of { nodeId, nodeType, error } for any failed nodes. + */ + private async runWorkflowOnce( + workflowId: string, + nodes: WorkflowNode[], + edges: WorkflowEdge[], + ): Promise> { const nodeIds = nodes.map((n) => n.id); const simpleEdges = edges.map((e) => ({ sourceNodeId: e.sourceNodeId, targetNodeId: e.targetNodeId, })); - // Cost estimate (for UI only) is done via cost:estimate IPC; we don't block runs on budget since actual API cost varies by inputs. const levels = topologicalLevels(nodeIds, simpleEdges); const nodeMap = new Map(nodes.map((n) => [n.id, n])); const failedNodes = new Set(); + const failures: Array<{ nodeId: string; nodeType: string; error: string }> = + []; // Build upstream dependency map for quick lookup const upstreamMap = new Map(); @@ -91,17 +183,20 @@ export class ExecutionEngine { } for (const level of levels) { - // Stop the entire workflow if any node has failed if (failedNodes.size > 0) break; const batch = level.slice(0, MAX_PARALLEL_EXECUTIONS); await Promise.all( batch.map(async (nodeId) => { - // If another node in this batch failed, skip remaining if (failedNodes.size > 0) return; - // Skip if any upstream node failed const upstreams = upstreamMap.get(nodeId) ?? []; if (upstreams.some((uid) => failedNodes.has(uid))) { failedNodes.add(nodeId); + const node = nodeMap.get(nodeId); + failures.push({ + nodeId, + nodeType: node?.nodeType ?? "unknown", + error: "Skipped: upstream node failed", + }); this.callbacks.onNodeStatus( workflowId, nodeId, @@ -117,10 +212,66 @@ export class ExecutionEngine { edges, true, ); - if (!success) failedNodes.add(nodeId); + if (!success) { + failedNodes.add(nodeId); + const node = nodeMap.get(nodeId); + // Retrieve error message from the latest execution + const errMsg = node?.currentOutputId + ? (() => { + const exec = getExecutionById(node.currentOutputId); + if (!exec?.resultMetadata) return "Execution failed"; + const meta = + typeof exec.resultMetadata === "string" + ? JSON.parse(exec.resultMetadata) + : exec.resultMetadata; + return (meta.error as string) ?? "Execution failed"; + })() + : "Execution failed"; + failures.push({ + nodeId, + nodeType: node?.nodeType ?? "unknown", + error: errMsg, + }); + } }), ); } + + return failures; + } + + /** + * After workflow execution, find the HTTP Response node and extract its result. + * Returns the response body or undefined if no HTTP Response node exists. + */ + private collectHttpResponse( + nodes: WorkflowNode[], + ): Record | undefined { + const responseNode = nodes.find( + (n) => n.nodeType === "output/http-response", + ); + console.log( + `[Executor] collectHttpResponse: responseNode=${responseNode?.id}, currentOutputId=${responseNode?.currentOutputId}`, + ); + if (!responseNode?.currentOutputId) return undefined; + + const execution = getExecutionById(responseNode.currentOutputId); + console.log( + `[Executor] collectHttpResponse: execution status=${execution?.status}, hasMeta=${!!execution?.resultMetadata}`, + ); + if (!execution?.resultMetadata) return undefined; + + const meta = + typeof execution.resultMetadata === "string" + ? JSON.parse(execution.resultMetadata) + : execution.resultMetadata; + + console.log( + `[Executor] collectHttpResponse: meta keys=${Object.keys(meta).join(",")}`, + ); + + const body = meta.__httpResponseBody ?? meta; + return { statusCode: 200, body }; } /** Run a single node, resolving upstream inputs. Always skips cache (user explicitly re-runs). */ @@ -517,6 +668,17 @@ export class ExecutionEngine { ? JSON.parse(execution.resultMetadata) : execution.resultMetadata; outputValue = meta[edge.sourceOutputKey]; + // Debug: log when resolving from a group/iterator node + if (sourceNode.nodeType === "control/iterator") { + console.log( + `[Executor] resolveInputs from group: sourceOutputKey="${edge.sourceOutputKey}", meta keys=`, + Object.keys(meta), + "value=", + outputValue !== undefined + ? String(outputValue).slice(0, 100) + : "undefined", + ); + } // Fallback: if not found by handle key, try 'resultUrl' (common pattern) if (outputValue === undefined) outputValue = meta.resultUrl; } diff --git a/electron/workflow/index.ts b/electron/workflow/index.ts index 996ba31c..899e95a4 100644 --- a/electron/workflow/index.ts +++ b/electron/workflow/index.ts @@ -27,6 +27,8 @@ import { registerUploadIpc } from "./ipc/upload.ipc"; import { registerSettingsIpc } from "./ipc/settings.ipc"; import { registerFreeToolIpc } from "./ipc/free-tool.ipc"; import { registerTemplateIpc } from "./ipc/template.ipc"; +import { registerHttpServerIpc } from "./ipc/http-server.ipc"; +import { setHttpServerEngine } from "./services/http-server"; import { migrateTemplatesFromLocalStorage } from "./services/template-migration"; import { initializeDefaultTemplates } from "./services/template-init"; @@ -79,6 +81,7 @@ export async function initWorkflowModule(): Promise { // 5. Wire up singletons setExecutionEngine(engine); + setHttpServerEngine(engine); setCostDeps(costService, nodeRegistry); setMarkDownstreamStale((workflowId, nodeId) => engine.markDownstreamStale(workflowId, nodeId), @@ -94,6 +97,7 @@ export async function initWorkflowModule(): Promise { registerSettingsIpc(); registerFreeToolIpc(); registerTemplateIpc(); + registerHttpServerIpc(); // 7. Migrate templates from localStorage (if needed) try { diff --git a/electron/workflow/ipc/http-server.ipc.ts b/electron/workflow/ipc/http-server.ipc.ts new file mode 100644 index 00000000..d9b003e8 --- /dev/null +++ b/electron/workflow/ipc/http-server.ipc.ts @@ -0,0 +1,26 @@ +/** + * IPC handlers for the global HTTP server — start, stop, status. + */ +import { ipcMain } from "electron"; +import { + startHttpServer, + stopHttpServer, + getHttpServerStatus, +} from "../services/http-server"; + +export function registerHttpServerIpc(): void { + ipcMain.handle( + "http-server:start", + async (_event, args: { port?: number; workflowId?: string }) => { + return startHttpServer(args?.port ?? 3100, args?.workflowId); + }, + ); + + ipcMain.handle("http-server:stop", async () => { + return stopHttpServer(); + }); + + ipcMain.handle("http-server:status", async () => { + return getHttpServerStatus(); + }); +} diff --git a/electron/workflow/nodes/control/iterator.ts b/electron/workflow/nodes/control/iterator.ts deleted file mode 100644 index 8ee6945f..00000000 --- a/electron/workflow/nodes/control/iterator.ts +++ /dev/null @@ -1,353 +0,0 @@ -/** - * Iterator node — container node that executes an internal sub-workflow - * multiple times, aggregating results across iterations. - */ -import { - BaseNodeHandler, - type NodeExecutionContext, - type NodeExecutionResult, -} from "../base"; -import type { NodeTypeDefinition } from "../../../../src/workflow/types/node-defs"; -import type { ExposedParam } from "../../../../src/workflow/types/workflow"; -import type { NodeRegistry } from "../registry"; -import { getChildNodes } from "../../db/node.repo"; -import { getInternalEdges } from "../../db/edge.repo"; -import { topologicalLevels } from "../../engine/scheduler"; - -export const iteratorDef: NodeTypeDefinition = { - type: "control/iterator", - category: "control", - label: "Iterator", - inputs: [], - outputs: [], - params: [ - { - key: "iterationCount", - label: "Iteration Count", - type: "number", - default: 1, - validation: { min: 1 }, - }, - { - key: "iterationMode", - label: "Iteration Mode", - type: "string", - default: "fixed", - }, - { - key: "exposedInputs", - label: "Exposed Inputs", - type: "string", - default: "[]", - }, - { - key: "exposedOutputs", - label: "Exposed Outputs", - type: "string", - default: "[]", - }, - ], -}; - -export class IteratorNodeHandler extends BaseNodeHandler { - constructor(private registry: NodeRegistry) { - super(iteratorDef); - } - - async execute(ctx: NodeExecutionContext): Promise { - const start = Date.now(); - - // 1. Parse iteration config from params - const iterationMode = String(ctx.params.iterationMode ?? "fixed"); - const fixedCount = Math.max(1, Number(ctx.params.iterationCount) || 1); - const exposedInputs = this.parseExposedParams(ctx.params.exposedInputs); - const exposedOutputs = this.parseExposedParams(ctx.params.exposedOutputs); - - // 2. Load child nodes and internal edges - const childNodes = getChildNodes(ctx.nodeId); - const internalEdges = getInternalEdges(ctx.workflowId); - - // Filter internal edges to only those between our child nodes - const childNodeIds = childNodes.map((n) => n.id); - const childNodeIdSet = new Set(childNodeIds); - const relevantEdges = internalEdges.filter( - (e) => childNodeIdSet.has(e.sourceNodeId) && childNodeIdSet.has(e.targetNodeId), - ); - - if (childNodes.length === 0) { - return { - status: "success", - outputs: {}, - durationMs: Date.now() - start, - cost: 0, - }; - } - - // 3. Topologically sort child nodes - const simpleEdges = relevantEdges.map((e) => ({ - sourceNodeId: e.sourceNodeId, - targetNodeId: e.targetNodeId, - })); - const levels = topologicalLevels(childNodeIds, simpleEdges); - - // 4. Build lookup maps - const childNodeMap = new Map(childNodes.map((n) => [n.id, n])); - - // Build input routing: map from subNodeId -> paramKey -> external value - // In auto mode, store the raw values (may be arrays) for per-iteration slicing - const inputRoutingRaw = new Map>(); - for (const ep of exposedInputs) { - const externalValue = ctx.inputs[ep.namespacedKey]; - if (externalValue !== undefined) { - if (!inputRoutingRaw.has(ep.subNodeId)) { - inputRoutingRaw.set(ep.subNodeId, new Map()); - } - inputRoutingRaw.get(ep.subNodeId)!.set(ep.paramKey, externalValue); - } - } - - // 5. Determine iteration count - let iterationCount: number; - // Collect all external input values for auto-mode analysis - const allExternalValues: unknown[] = []; - for (const ep of exposedInputs) { - const v = ctx.inputs[ep.namespacedKey]; - if (v !== undefined) allExternalValues.push(v); - } - - if (iterationMode === "auto") { - // Find the longest array among external inputs - const arrayLengths = allExternalValues - .filter((v) => Array.isArray(v)) - .map((v) => (v as unknown[]).length); - - if (arrayLengths.length === 0) { - // No array inputs found — if there are any inputs at all, run once; otherwise error - if (allExternalValues.length > 0) { - iterationCount = 1; - } else { - return { - status: "error", - outputs: {}, - durationMs: Date.now() - start, - cost: 0, - error: "Auto mode: no external inputs connected. Connect an array input or switch to fixed mode.", - }; - } - } else { - iterationCount = Math.max(...arrayLengths); - // Empty array → 0 iterations - if (iterationCount === 0) { - return { - status: "success", - outputs: {}, - durationMs: Date.now() - start, - cost: 0, - }; - } - } - } else { - iterationCount = fixedCount; - } - - // 6. Execute iterations - const iterationResults: Array> = []; - let totalCost = 0; - - for (let i = 0; i < iterationCount; i++) { - // Track outputs per sub-node for this iteration (for internal edge resolution) - const subNodeOutputs = new Map>(); - - // Execute sub-nodes level by level - let iterationFailed = false; - let failedSubNodeId = ""; - let failedError = ""; - - for (const level of levels) { - if (iterationFailed) break; - - for (const subNodeId of level) { - if (iterationFailed) break; - - const subNode = childNodeMap.get(subNodeId); - if (!subNode) continue; - - const handler = this.registry.getHandler(subNode.nodeType); - if (!handler) { - return { - status: "error", - outputs: {}, - durationMs: Date.now() - start, - cost: totalCost, - error: `No handler found for sub-node type: ${subNode.nodeType} (node: ${subNodeId})`, - }; - } - - // Build params for this sub-node: base params + external inputs + iteration index - const subParams: Record = { ...subNode.params }; - - // Inject external input values (with auto-mode array slicing) - const externalInputs = inputRoutingRaw.get(subNodeId); - if (externalInputs) { - for (const [paramKey, rawValue] of externalInputs) { - if (iterationMode === "auto" && Array.isArray(rawValue)) { - // Slice: use element at index i, pad with last element if shorter - const arr = rawValue as unknown[]; - subParams[paramKey] = arr.length > 0 ? arr[Math.min(i, arr.length - 1)] : undefined; - } else if (iterationMode === "fixed" && Array.isArray(rawValue)) { - // Fixed mode with array: cycle with modulo - const arr = rawValue as unknown[]; - subParams[paramKey] = arr.length > 0 ? arr[i % arr.length] : undefined; - } else { - // Non-array: broadcast same value to all iterations - subParams[paramKey] = rawValue; - } - } - } - - // Inject iteration index - subParams.__iterationIndex = i; - - // Resolve internal edge inputs from upstream sub-node outputs - const subInputs = this.resolveSubNodeInputs( - subNodeId, - relevantEdges, - subNodeOutputs, - ); - - // Also inject unconnected param defaults (already in subParams from subNode.params) - // External inputs that aren't connected fall back to the sub-node's default value - // which is already present in subNode.params - - const subCtx: NodeExecutionContext = { - nodeId: subNodeId, - nodeType: subNode.nodeType, - params: subParams, - inputs: subInputs, - workflowId: ctx.workflowId, - abortSignal: ctx.abortSignal, - onProgress: (_progress, message) => { - // Forward sub-node progress as part of overall iteration progress - const iterationProgress = (i / iterationCount) * 100; - ctx.onProgress(iterationProgress, message); - }, - }; - - try { - const result = await handler.execute(subCtx); - totalCost += result.cost; - - if (result.status === "error") { - iterationFailed = true; - failedSubNodeId = subNodeId; - failedError = result.error || "Unknown sub-node error"; - break; - } - - // Store sub-node outputs for downstream internal edge resolution - subNodeOutputs.set(subNodeId, result.outputs); - } catch (error) { - return { - status: "error", - outputs: {}, - durationMs: Date.now() - start, - cost: totalCost, - error: `Sub-node ${subNodeId} threw: ${error instanceof Error ? error.message : String(error)}`, - }; - } - } - } - - if (iterationFailed) { - return { - status: "error", - outputs: {}, - durationMs: Date.now() - start, - cost: totalCost, - error: `Sub-node ${failedSubNodeId} failed: ${failedError}`, - }; - } - - // Collect exposed output values for this iteration - const iterOutputs: Record = {}; - for (const ep of exposedOutputs) { - const nodeOutputs = subNodeOutputs.get(ep.subNodeId); - if (nodeOutputs) { - // Key by handle ID format "output-{namespacedKey}" so the executor's - // resolveInputs can find the value via edge.sourceOutputKey - iterOutputs[`output-${ep.namespacedKey}`] = nodeOutputs[ep.paramKey]; - } - } - iterationResults.push(iterOutputs); - - // Report progress - ctx.onProgress(((i + 1) / iterationCount) * 100, `Iteration ${i + 1}/${iterationCount} complete`); - } - - // 6. Aggregate results — ALWAYS output arrays regardless of iteration count - // This ensures downstream nodes always receive a consistent format. - // N=1 → ["value"], N=3 → ["v1","v2","v3"], N=0 → [] - const outputs: Record = {}; - for (const ep of exposedOutputs) { - const handleKey = `output-${ep.namespacedKey}`; - outputs[handleKey] = iterationResults.map((r) => r[handleKey]); - } - - return { - status: "success", - outputs, - resultMetadata: { ...outputs }, - durationMs: Date.now() - start, - cost: totalCost, - }; - } - - /** - * Resolve inputs for a sub-node from upstream sub-node outputs via internal edges. - */ - private resolveSubNodeInputs( - subNodeId: string, - internalEdges: { sourceNodeId: string; targetNodeId: string; sourceOutputKey: string; targetInputKey: string }[], - subNodeOutputs: Map>, - ): Record { - const inputs: Record = {}; - const incomingEdges = internalEdges.filter((e) => e.targetNodeId === subNodeId); - - for (const edge of incomingEdges) { - const sourceOutputs = subNodeOutputs.get(edge.sourceNodeId); - if (!sourceOutputs) continue; - - const value = sourceOutputs[edge.sourceOutputKey]; - if (value === undefined) continue; - - // Parse target handle key the same way the main executor does - const targetKey = edge.targetInputKey; - if (targetKey.startsWith("param-")) { - inputs[targetKey.slice(6)] = value; - } else if (targetKey.startsWith("input-")) { - inputs[targetKey.slice(6)] = value; - } else { - inputs[targetKey] = value; - } - } - - return inputs; - } - - /** - * Parse exposed params from JSON string stored in node params. - */ - private parseExposedParams(value: unknown): ExposedParam[] { - if (typeof value === "string") { - try { - return JSON.parse(value) as ExposedParam[]; - } catch { - return []; - } - } - if (Array.isArray(value)) { - return value as ExposedParam[]; - } - return []; - } -} diff --git a/electron/workflow/nodes/control/subgraph.ts b/electron/workflow/nodes/control/subgraph.ts new file mode 100644 index 00000000..c3d9f20e --- /dev/null +++ b/electron/workflow/nodes/control/subgraph.ts @@ -0,0 +1,267 @@ +/** + * Iterator node — now simplified to a Group container. + * + * Executes its internal sub-workflow exactly ONCE (no iteration). + * Batch/repeat logic is handled by Trigger nodes and Run Count at the engine level. + * + * This node is purely an organizational/encapsulation tool: + * - Contains child nodes (sub-workflow) + * - Routes external inputs to child nodes via exposedInputs + * - Collects child node outputs via exposedOutputs + * + * The type remains "control/iterator" for backward compatibility with + * existing workflows and frontend components. The label shown in the UI + * is "Group" (via i18n). + */ +import { + BaseNodeHandler, + type NodeExecutionContext, + type NodeExecutionResult, +} from "../base"; +import type { NodeTypeDefinition } from "../../../../src/workflow/types/node-defs"; +import type { ExposedParam } from "../../../../src/workflow/types/workflow"; +import type { NodeRegistry } from "../registry"; +import { getChildNodes } from "../../db/node.repo"; +import { getInternalEdges } from "../../db/edge.repo"; +import { topologicalLevels } from "../../engine/scheduler"; + +export const subgraphDef: NodeTypeDefinition = { + type: "control/iterator", + category: "control", + label: "Group", + inputs: [], + outputs: [], + params: [ + // Legacy params kept for backward compat — ignored at runtime + { + key: "iterationCount", + label: "Iteration Count", + type: "number", + default: 1, + validation: { min: 1 }, + }, + { + key: "iterationMode", + label: "Iteration Mode", + type: "string", + default: "fixed", + }, + { + key: "exposedInputs", + label: "Exposed Inputs", + type: "string", + default: "[]", + }, + { + key: "exposedOutputs", + label: "Exposed Outputs", + type: "string", + default: "[]", + }, + ], +}; + +export class SubgraphNodeHandler extends BaseNodeHandler { + constructor(private registry: NodeRegistry) { + super(subgraphDef); + } + + async execute(ctx: NodeExecutionContext): Promise { + const start = Date.now(); + + const exposedInputs = this.parseExposedParams(ctx.params.exposedInputs); + const exposedOutputs = this.parseExposedParams(ctx.params.exposedOutputs); + + // Load child nodes and internal edges + const childNodes = getChildNodes(ctx.nodeId); + const internalEdges = getInternalEdges(ctx.workflowId); + + const childNodeIds = childNodes.map((n) => n.id); + const childNodeIdSet = new Set(childNodeIds); + const relevantEdges = internalEdges.filter( + (e) => + childNodeIdSet.has(e.sourceNodeId) && + childNodeIdSet.has(e.targetNodeId), + ); + + if (childNodes.length === 0) { + return { + status: "success", + outputs: {}, + durationMs: Date.now() - start, + cost: 0, + }; + } + + // Topologically sort child nodes + const simpleEdges = relevantEdges.map((e) => ({ + sourceNodeId: e.sourceNodeId, + targetNodeId: e.targetNodeId, + })); + const levels = topologicalLevels(childNodeIds, simpleEdges); + const childNodeMap = new Map(childNodes.map((n) => [n.id, n])); + + // Route external inputs to child nodes + const inputRouting = new Map>(); + for (const ep of exposedInputs) { + const externalValue = ctx.inputs[ep.namespacedKey]; + if (externalValue !== undefined) { + if (!inputRouting.has(ep.subNodeId)) { + inputRouting.set(ep.subNodeId, new Map()); + } + inputRouting.get(ep.subNodeId)!.set(ep.paramKey, externalValue); + } + } + + // Execute child nodes level by level — single pass, no iteration + const subNodeOutputs = new Map>(); + let totalCost = 0; + + for (const level of levels) { + for (const subNodeId of level) { + const subNode = childNodeMap.get(subNodeId); + if (!subNode) continue; + + const handler = this.registry.getHandler(subNode.nodeType); + if (!handler) { + return { + status: "error", + outputs: {}, + durationMs: Date.now() - start, + cost: totalCost, + error: `No handler for sub-node type: ${subNode.nodeType} (node: ${subNodeId})`, + }; + } + + // Build params: base + external inputs + const subParams: Record = { ...subNode.params }; + const externalInputs = inputRouting.get(subNodeId); + if (externalInputs) { + for (const [paramKey, value] of externalInputs) { + subParams[paramKey] = value; + } + } + + // Resolve internal edge inputs + const subInputs = this.resolveSubNodeInputs( + subNodeId, + relevantEdges, + subNodeOutputs, + ); + + const subCtx: NodeExecutionContext = { + nodeId: subNodeId, + nodeType: subNode.nodeType, + params: subParams, + inputs: subInputs, + workflowId: ctx.workflowId, + abortSignal: ctx.abortSignal, + onProgress: (_progress, message) => { + ctx.onProgress(_progress, message); + }, + }; + + try { + const result = await handler.execute(subCtx); + totalCost += result.cost; + + if (result.status === "error") { + return { + status: "error", + outputs: {}, + durationMs: Date.now() - start, + cost: totalCost, + error: `Sub-node ${subNodeId} failed: ${result.error || "Unknown error"}`, + }; + } + + console.log( + `[Iterator] Sub-node ${subNodeId} (${subNode.nodeType}) outputs:`, + JSON.stringify(result.outputs).slice(0, 300), + ); + subNodeOutputs.set(subNodeId, result.outputs); + } catch (error) { + return { + status: "error", + outputs: {}, + durationMs: Date.now() - start, + cost: totalCost, + error: `Sub-node ${subNodeId} threw: ${error instanceof Error ? error.message : String(error)}`, + }; + } + } + } + + // Collect exposed outputs — single values, no array aggregation + const outputs: Record = {}; + for (const ep of exposedOutputs) { + const nodeOutputs = subNodeOutputs.get(ep.subNodeId); + console.log( + `[Iterator] Collecting exposedOutput: subNodeId=${ep.subNodeId}, paramKey=${ep.paramKey}, nk=${ep.namespacedKey}, nodeOutputs=`, + nodeOutputs ? JSON.stringify(nodeOutputs).slice(0, 200) : "null", + ); + if (nodeOutputs) { + outputs[`output-${ep.namespacedKey}`] = nodeOutputs[ep.paramKey]; + } + } + console.log( + `[Iterator] Final outputs:`, + JSON.stringify(outputs).slice(0, 500), + ); + + return { + status: "success", + outputs, + resultMetadata: { ...outputs }, + durationMs: Date.now() - start, + cost: totalCost, + }; + } + + private resolveSubNodeInputs( + subNodeId: string, + internalEdges: { + sourceNodeId: string; + targetNodeId: string; + sourceOutputKey: string; + targetInputKey: string; + }[], + subNodeOutputs: Map>, + ): Record { + const inputs: Record = {}; + const incomingEdges = internalEdges.filter( + (e) => e.targetNodeId === subNodeId, + ); + + for (const edge of incomingEdges) { + const sourceOutputs = subNodeOutputs.get(edge.sourceNodeId); + if (!sourceOutputs) continue; + + const value = sourceOutputs[edge.sourceOutputKey]; + if (value === undefined) continue; + + const targetKey = edge.targetInputKey; + if (targetKey.startsWith("param-")) { + inputs[targetKey.slice(6)] = value; + } else if (targetKey.startsWith("input-")) { + inputs[targetKey.slice(6)] = value; + } else { + inputs[targetKey] = value; + } + } + + return inputs; + } + + private parseExposedParams(value: unknown): ExposedParam[] { + if (typeof value === "string") { + try { + return JSON.parse(value) as ExposedParam[]; + } catch { + return []; + } + } + if (Array.isArray(value)) return value as ExposedParam[]; + return []; + } +} diff --git a/electron/workflow/nodes/input/directory-import.ts b/electron/workflow/nodes/input/directory-import.ts index 91510ecf..54b856bb 100644 --- a/electron/workflow/nodes/input/directory-import.ts +++ b/electron/workflow/nodes/input/directory-import.ts @@ -15,7 +15,18 @@ import { readdirSync, statSync, existsSync } from "fs"; import { join, extname } from "path"; const MEDIA_EXTENSIONS: Record = { - image: [".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".tiff", ".tif", ".svg", ".avif"], + image: [ + ".jpg", + ".jpeg", + ".png", + ".webp", + ".gif", + ".bmp", + ".tiff", + ".tif", + ".svg", + ".avif", + ], video: [".mp4", ".webm", ".mov", ".avi", ".mkv", ".flv", ".wmv", ".m4v"], audio: [".mp3", ".wav", ".flac", ".m4a", ".ogg", ".aac", ".wma"], all: [], // populated at runtime from all above @@ -88,7 +99,9 @@ export class DirectoryImportHandler extends BaseNodeHandler { }; } - const allowedExts = new Set(MEDIA_EXTENSIONS[mediaType] ?? MEDIA_EXTENSIONS.all); + const allowedExts = new Set( + MEDIA_EXTENSIONS[mediaType] ?? MEDIA_EXTENSIONS.all, + ); const files = scanDirectory(dirPath, allowedExts); files.sort(); // deterministic order diff --git a/electron/workflow/nodes/output/http-response.ts b/electron/workflow/nodes/output/http-response.ts new file mode 100644 index 00000000..23122965 --- /dev/null +++ b/electron/workflow/nodes/output/http-response.ts @@ -0,0 +1,119 @@ +/** + * HTTP Response — declares what the workflow returns to the HTTP caller. + * + * Pairs with HTTP Trigger. The user configures "response fields" — each + * field becomes an input port. Connect upstream outputs to these ports. + * After workflow execution, the HTTP server reads this node's collected + * inputs and sends them as the JSON response body. + * + * Example: + * responseFields = [{ "key": "result", "label": "Result", "type": "url" }] + * + * → input port "result" receives the final image URL from upstream + * → HTTP response: { "result": "https://cdn.../output.png" } + */ +import { + BaseNodeHandler, + type NodeExecutionContext, + type NodeExecutionResult, +} from "../base"; +import type { + NodeTypeDefinition, + PortDefinition, + PortDataType, +} from "../../../../src/workflow/types/node-defs"; + +export interface ResponseFieldConfig { + key: string; + label: string; + type: PortDataType; +} + +export function parseResponseFields(raw: unknown): ResponseFieldConfig[] { + if (typeof raw === "string") { + try { + return JSON.parse(raw) as ResponseFieldConfig[]; + } catch { + return []; + } + } + if (Array.isArray(raw)) return raw as ResponseFieldConfig[]; + return []; +} + +export function buildHttpResponseInputDefs( + fields: ResponseFieldConfig[], +): PortDefinition[] { + return fields.map((f) => ({ + key: f.key, + label: f.label || f.key, + dataType: f.type || "any", + required: true, + })); +} + +export const httpResponseDef: NodeTypeDefinition = { + type: "output/http-response", + category: "output", + label: "HTTP Response", + inputs: [], // Dynamic — built from responseFields + outputs: [], + params: [ + { + key: "responseFields", + label: "Response Fields", + type: "textarea", + dataType: "text", + connectable: false, + default: '[{"key":"image","label":"Image","type":"text"}]', + description: + "Define API response fields. Each field becomes an input port.", + }, + ], +}; + +export class HttpResponseHandler extends BaseNodeHandler { + constructor() { + super(httpResponseDef); + } + + /** + * Collect all input values and package them as the response body. + * The HTTP server reads resultMetadata.__httpResponseBody after execution. + */ + async execute(ctx: NodeExecutionContext): Promise { + const start = Date.now(); + const fields = parseResponseFields(ctx.params.responseFields); + + const responseBody: Record = {}; + for (const field of fields) { + const value = ctx.inputs[field.key]; + if (value !== undefined && value !== null) { + responseBody[field.key] = value; + } + } + + // Fallback: if no fields matched, include all inputs directly + if ( + Object.keys(responseBody).length === 0 && + Object.keys(ctx.inputs).length > 0 + ) { + for (const [k, v] of Object.entries(ctx.inputs)) { + if (v !== undefined && v !== null) { + responseBody[k] = v; + } + } + } + + return { + status: "success", + outputs: responseBody, + resultMetadata: { + ...responseBody, + __httpResponseBody: responseBody, + }, + durationMs: Date.now() - start, + cost: 0, + }; + } +} diff --git a/electron/workflow/nodes/register-all.ts b/electron/workflow/nodes/register-all.ts index 0ca5c317..1c65d70e 100644 --- a/electron/workflow/nodes/register-all.ts +++ b/electron/workflow/nodes/register-all.ts @@ -1,26 +1,53 @@ import { nodeRegistry } from "./registry"; import { mediaUploadDef, MediaUploadHandler } from "./input/media-upload"; import { textInputDef, TextInputHandler } from "./input/text-input"; -import { directoryImportDef, DirectoryImportHandler } from "./input/directory-import"; +import { + directoryImportDef, + DirectoryImportHandler, +} from "./input/directory-import"; import { aiTaskDef, AITaskHandler } from "./ai-task/run"; import { fileExportDef, FileExportHandler } from "./output/file"; import { previewDisplayDef, PreviewDisplayHandler } from "./output/preview"; import { registerFreeToolNodes } from "./free-tool/register"; import { concatDef, ConcatHandler } from "./processing/concat"; import { selectDef, SelectHandler } from "./processing/select"; -import { iteratorDef, IteratorNodeHandler } from "./control/iterator"; +import { subgraphDef, SubgraphNodeHandler } from "./control/subgraph"; +// Trigger nodes +import { + directoryTriggerDef, + DirectoryTriggerHandler, +} from "./trigger/directory"; +import { httpTriggerDef, HttpTriggerHandler } from "./trigger/http"; +import { httpResponseDef, HttpResponseHandler } from "./output/http-response"; export function registerAllNodes(): void { + // Trigger nodes + nodeRegistry.register(directoryTriggerDef, new DirectoryTriggerHandler()); + nodeRegistry.register(httpTriggerDef, new HttpTriggerHandler()); + + // Input nodes nodeRegistry.register(mediaUploadDef, new MediaUploadHandler()); nodeRegistry.register(textInputDef, new TextInputHandler()); nodeRegistry.register(directoryImportDef, new DirectoryImportHandler()); + + // AI task nodeRegistry.register(aiTaskDef, new AITaskHandler()); + + // Output nodeRegistry.register(fileExportDef, new FileExportHandler()); nodeRegistry.register(previewDisplayDef, new PreviewDisplayHandler()); + nodeRegistry.register(httpResponseDef, new HttpResponseHandler()); + + // Free tools registerFreeToolNodes(); + + // Processing nodeRegistry.register(concatDef, new ConcatHandler()); nodeRegistry.register(selectDef, new SelectHandler()); - nodeRegistry.register(iteratorDef, new IteratorNodeHandler(nodeRegistry)); + + // Control (Iterator simplified to Group — no iteration, just sub-workflow container) + nodeRegistry.register(subgraphDef, new SubgraphNodeHandler(nodeRegistry)); + console.log( `[Registry] Registered ${nodeRegistry.getAll().length} node types`, ); diff --git a/electron/workflow/nodes/trigger/base.ts b/electron/workflow/nodes/trigger/base.ts new file mode 100644 index 00000000..f4d3386a --- /dev/null +++ b/electron/workflow/nodes/trigger/base.ts @@ -0,0 +1,43 @@ +/** + * Trigger node base — defines the interface for trigger nodes that drive workflow execution. + * + * Trigger nodes are special input nodes that determine: + * - What data enters the workflow + * - How many times the workflow executes (single vs batch) + * + * A workflow has at most one trigger node. If the trigger is a batch type, + * the engine calls getItems() and executes the full workflow once per item. + */ +import type { NodeHandler } from "../base"; + +export type TriggerMode = "single" | "batch"; + +export interface BatchItem { + /** Unique ID for dedup and progress tracking */ + id: string; + /** The value passed to downstream nodes for this item */ + value: unknown; + /** Display label for UI (e.g. filename, message ID) */ + label?: string; +} + +export interface TriggerHandler extends NodeHandler { + /** Whether this trigger produces a single value or a batch of items */ + readonly triggerMode: TriggerMode; + + /** + * For batch triggers: return all items to iterate over. + * The engine will execute the workflow once per item. + * For single triggers: this is not called. + */ + getItems?(params: Record): Promise; +} + +/** + * Type guard to check if a NodeHandler is a TriggerHandler. + */ +export function isTriggerHandler( + handler: NodeHandler, +): handler is TriggerHandler { + return "triggerMode" in handler; +} diff --git a/electron/workflow/nodes/trigger/directory.ts b/electron/workflow/nodes/trigger/directory.ts new file mode 100644 index 00000000..a34964f1 --- /dev/null +++ b/electron/workflow/nodes/trigger/directory.ts @@ -0,0 +1,160 @@ +/** + * Directory Trigger — scans a local directory for media files. + * Batch trigger: the engine executes the workflow once per file. + * + * Unlike the old directory-import node which output an array, + * this trigger produces one item per file. Each workflow execution + * receives a single local-asset:// URL. + */ +import { + BaseNodeHandler, + type NodeExecutionContext, + type NodeExecutionResult, +} from "../base"; +import type { NodeTypeDefinition } from "../../../../src/workflow/types/node-defs"; +import type { TriggerHandler, TriggerMode, BatchItem } from "./base"; +import { readdirSync, existsSync } from "fs"; +import { join, extname, basename } from "path"; + +const MEDIA_EXTENSIONS: Record = { + image: [ + ".jpg", + ".jpeg", + ".png", + ".webp", + ".gif", + ".bmp", + ".tiff", + ".tif", + ".svg", + ".avif", + ], + video: [".mp4", ".webm", ".mov", ".avi", ".mkv", ".flv", ".wmv", ".m4v"], + audio: [".mp3", ".wav", ".flac", ".m4a", ".ogg", ".aac", ".wma"], + all: [], +}; +MEDIA_EXTENSIONS.all = [ + ...MEDIA_EXTENSIONS.image, + ...MEDIA_EXTENSIONS.video, + ...MEDIA_EXTENSIONS.audio, +]; + +export const directoryTriggerDef: NodeTypeDefinition = { + type: "trigger/directory", + category: "trigger", + label: "Directory", + inputs: [], + outputs: [{ key: "output", label: "File", dataType: "url", required: true }], + params: [ + { + key: "directoryPath", + label: "Directory", + type: "string", + dataType: "text", + connectable: false, + default: "", + }, + { + key: "mediaType", + label: "File Type", + type: "select", + dataType: "text", + connectable: false, + default: "image", + options: [ + { label: "Images", value: "image" }, + { label: "Videos", value: "video" }, + { label: "Audio", value: "audio" }, + { label: "All Media", value: "all" }, + ], + }, + ], +}; + +export class DirectoryTriggerHandler + extends BaseNodeHandler + implements TriggerHandler +{ + readonly triggerMode: TriggerMode = "batch"; + + constructor() { + super(directoryTriggerDef); + } + + /** + * Return all files in the directory as batch items. + * Each item is a single local-asset:// URL. + */ + async getItems(params: Record): Promise { + const dirPath = String(params.directoryPath ?? "").trim(); + const mediaType = String(params.mediaType ?? "image"); + + if (!dirPath || !existsSync(dirPath)) return []; + + const allowedExts = new Set( + MEDIA_EXTENSIONS[mediaType] ?? MEDIA_EXTENSIONS.all, + ); + const files = scanDirectory(dirPath, allowedExts); + files.sort(); + + return files.map((filePath) => ({ + id: filePath, + value: `local-asset://${encodeURIComponent(filePath)}`, + label: basename(filePath), + })); + } + + /** + * Execute for a single file. When called by the engine in batch mode, + * ctx.params.__triggerValue contains the current item's value. + */ + async execute(ctx: NodeExecutionContext): Promise { + const start = Date.now(); + + // In batch mode, the engine injects the current item value + const triggerValue = ctx.params.__triggerValue as string | undefined; + const url = triggerValue ?? ""; + + if (!url) { + return { + status: "error", + outputs: {}, + durationMs: Date.now() - start, + cost: 0, + error: "No file provided.", + }; + } + + return { + status: "success", + outputs: { output: url }, + resultPath: url, + resultMetadata: { + output: url, + resultUrl: url, + resultUrls: [url], + mediaType: ctx.params.mediaType ?? "image", + }, + durationMs: Date.now() - start, + cost: 0, + }; + } +} + +function scanDirectory(dir: string, allowedExts: Set): string[] { + const results: string[] = []; + try { + const entries = readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isFile()) { + const ext = extname(entry.name).toLowerCase(); + if (allowedExts.has(ext)) { + results.push(join(dir, entry.name)); + } + } + } + } catch { + // Skip unreadable directories + } + return results; +} diff --git a/electron/workflow/nodes/trigger/http.ts b/electron/workflow/nodes/trigger/http.ts new file mode 100644 index 00000000..83ff79ee --- /dev/null +++ b/electron/workflow/nodes/trigger/http.ts @@ -0,0 +1,153 @@ +/** + * HTTP Trigger — declares the API input schema for a workflow. + * + * This node does NOT start a server itself. Instead, a global HTTP server + * service routes incoming requests to workflows that contain an HTTP Trigger. + * + * The user configures "output fields" — each field becomes an output port + * on the canvas that can be connected to downstream nodes. When a request + * arrives, the server extracts the matching JSON body fields and injects + * them as this node's outputs. + * + * Example: + * outputFields = [ + * { "key": "image", "label": "Image", "type": "url" }, + * { "key": "prompt", "label": "Prompt", "type": "text" } + * ] + * + * POST /api/workflows/{id}/run + * { "image": "https://...", "prompt": "a cat" } + * + * → output port "image" = "https://..." + * → output port "prompt" = "a cat" + */ +import { + BaseNodeHandler, + type NodeExecutionContext, + type NodeExecutionResult, +} from "../base"; +import type { + NodeTypeDefinition, + PortDefinition, + PortDataType, +} from "../../../../src/workflow/types/node-defs"; +import type { TriggerHandler, TriggerMode } from "./base"; + +export interface OutputFieldConfig { + key: string; + label: string; + type: PortDataType; +} + +export function parseOutputFields(raw: unknown): OutputFieldConfig[] { + if (typeof raw === "string") { + try { + return JSON.parse(raw) as OutputFieldConfig[]; + } catch { + return []; + } + } + if (Array.isArray(raw)) return raw as OutputFieldConfig[]; + return []; +} + +export function buildHttpOutputDefs( + fields: OutputFieldConfig[], +): PortDefinition[] { + return fields.map((f) => ({ + key: f.key, + label: f.label || f.key, + dataType: f.type || "any", + required: true, + })); +} + +export const httpTriggerDef: NodeTypeDefinition = { + type: "trigger/http", + category: "trigger", + label: "HTTP Trigger", + inputs: [], + outputs: [], // Dynamic — built from outputFields + params: [ + { + key: "port", + label: "Port", + type: "number", + dataType: "text", + connectable: false, + default: 3100, + description: "HTTP server port number.", + }, + { + key: "outputFields", + label: "Output Fields", + type: "textarea", + dataType: "text", + connectable: false, + default: + '[{"key":"image","label":"Image","type":"url"},{"key":"prompt","label":"Prompt","type":"text"}]', + description: + "Define API input fields. Each field becomes an output port.", + }, + ], +}; + +export class HttpTriggerHandler + extends BaseNodeHandler + implements TriggerHandler +{ + readonly triggerMode: TriggerMode = "single"; + + constructor() { + super(httpTriggerDef); + } + + /** + * Extract values from the injected request body (__triggerValue) + * and output them on the corresponding ports. + */ + async execute(ctx: NodeExecutionContext): Promise { + const start = Date.now(); + const fields = parseOutputFields(ctx.params.outputFields); + const body = (ctx.params.__triggerValue ?? {}) as Record; + + if (fields.length === 0) { + return { + status: "error", + outputs: {}, + durationMs: Date.now() - start, + cost: 0, + error: "No output fields configured.", + }; + } + + const outputs: Record = {}; + for (const field of fields) { + const value = body[field.key]; + if (value !== undefined && value !== null && value !== "") { + outputs[field.key] = + typeof value === "string" ? value : JSON.stringify(value); + } + } + + if (Object.keys(outputs).length === 0) { + return { + status: "error", + outputs: {}, + durationMs: Date.now() - start, + cost: 0, + error: `No matching fields in request body. Expected: ${fields.map((f) => f.key).join(", ")}`, + }; + } + + const resultMetadata: Record = { ...outputs }; + + return { + status: "success", + outputs, + resultMetadata, + durationMs: Date.now() - start, + cost: 0, + }; + } +} diff --git a/electron/workflow/services/http-server.ts b/electron/workflow/services/http-server.ts new file mode 100644 index 00000000..b039438f --- /dev/null +++ b/electron/workflow/services/http-server.ts @@ -0,0 +1,373 @@ +/** + * Global HTTP server — exposes workflows as REST API endpoints. + * + * Uses Node's built-in http module (no external dependencies). + * + * Endpoints: + * POST /api/workflows/:id/run — execute a workflow with JSON body as input + * GET /api/workflows/:id/schema — get the workflow's input/output schema + * GET /api/health — health check + * + * The server only works with workflows that have an HTTP Trigger node. + * The trigger's outputFields define the expected request body fields. + * The HTTP Response node (if present) defines what gets returned. + */ +import * as http from "http"; +import { ExecutionEngine } from "../engine/executor"; +import { getNodesByWorkflowId } from "../db/node.repo"; +import { getEdgesByWorkflowId } from "../db/edge.repo"; +import { getWorkflowById } from "../db/workflow.repo"; +import { getExecutionById } from "../db/execution.repo"; +import { parseOutputFields } from "../nodes/trigger/http"; +import { parseResponseFields } from "../nodes/output/http-response"; + +export interface HttpServerStatus { + running: boolean; + port: number | null; + url: string | null; +} + +let server: http.Server | null = null; +let currentPort: number | null = null; +let engine: ExecutionEngine | null = null; +/** The workflow ID that initiated the server (for the simple POST / route). */ +let activeWorkflowId: string | null = null; + +export function setHttpServerEngine(e: ExecutionEngine): void { + engine = e; +} + +export function getHttpServerStatus(): HttpServerStatus { + return { + running: server !== null && server.listening, + port: currentPort, + url: currentPort ? `http://localhost:${currentPort}` : null, + }; +} + +export async function startHttpServer( + port = 3100, + workflowId?: string, +): Promise { + if (server?.listening) { + // Already running — update active workflow and return current status + if (workflowId) activeWorkflowId = workflowId; + return getHttpServerStatus(); + } + + if (workflowId) activeWorkflowId = workflowId; + + return new Promise((resolve, reject) => { + const srv = http.createServer(handleRequest); + + srv.on("error", (err: NodeJS.ErrnoException) => { + if (err.code === "EADDRINUSE") { + reject(new Error(`Port ${port} is already in use`)); + } else { + reject(err); + } + }); + + srv.listen(port, () => { + server = srv; + currentPort = port; + console.log( + `[HTTP Server] Listening on http://localhost:${port} for workflow ${activeWorkflowId}`, + ); + resolve(getHttpServerStatus()); + }); + }); +} + +export function stopHttpServer(): HttpServerStatus { + if (server) { + server.close(); + server = null; + currentPort = null; + activeWorkflowId = null; + console.log("[HTTP Server] Stopped"); + } + return getHttpServerStatus(); +} + +// ── Request handler ────────────────────────────────────────────────── + +function handleRequest( + req: http.IncomingMessage, + res: http.ServerResponse, +): void { + const url = new URL(req.url ?? "/", `http://localhost`); + const path = url.pathname; + const method = req.method?.toUpperCase() ?? "GET"; + + console.log( + `[HTTP Server] ${method} ${path} (activeWorkflowId=${activeWorkflowId})`, + ); + + // CORS headers + res.setHeader("Access-Control-Allow-Origin", "*"); + res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); + res.setHeader("Access-Control-Allow-Headers", "Content-Type"); + + if (method === "OPTIONS") { + res.writeHead(204); + res.end(); + return; + } + + // GET /api/health + if (method === "GET" && path === "/api/health") { + sendJson(res, 200, { status: "ok" }); + return; + } + + // GET /schema — schema for the active workflow + if (method === "GET" && path === "/schema") { + if (!activeWorkflowId) { + sendJson(res, 400, { error: "No active workflow." }); + return; + } + handleGetSchema(activeWorkflowId, res); + return; + } + + // Route: /api/workflows/:id/run or /api/workflows/:id/schema + const runMatch = path.match(/^\/api\/workflows\/([^/]+)\/run$/); + const schemaMatch = path.match(/^\/api\/workflows\/([^/]+)\/schema$/); + + if (runMatch && method === "POST") { + const workflowId = decodeURIComponent(runMatch[1]); + readBody(req) + .then((body) => { + handleRunWorkflow(workflowId, body, res); + }) + .catch((err) => { + sendJson(res, 400, { error: `Invalid request body: ${err.message}` }); + }); + return; + } + + if (schemaMatch && method === "GET") { + const workflowId = decodeURIComponent(schemaMatch[1]); + handleGetSchema(workflowId, res); + return; + } + + // POST to any path — use the active workflow (simple default route) + if (method === "POST") { + if (!activeWorkflowId) { + sendJson(res, 400, { + error: + "No active workflow. Start the server from a workflow with an HTTP Trigger.", + }); + return; + } + readBody(req) + .then((body) => { + handleRunWorkflow(activeWorkflowId!, body, res); + }) + .catch((err) => { + sendJson(res, 400, { error: `Invalid request body: ${err.message}` }); + }); + return; + } + + sendJson(res, 404, { error: "Not found" }); +} + +// ── Route handlers + +async function handleRunWorkflow( + workflowId: string, + body: Record, + res: http.ServerResponse, +): Promise { + if (!engine) { + sendJson(res, 500, { error: "Execution engine not initialized" }); + return; + } + + // Verify workflow exists + const workflow = getWorkflowById(workflowId); + if (!workflow) { + sendJson(res, 404, { error: `Workflow not found: ${workflowId}` }); + return; + } + + // Verify workflow has an HTTP Trigger + const nodes = getNodesByWorkflowId(workflowId); + const httpTrigger = nodes.find((n) => n.nodeType === "trigger/http"); + if (!httpTrigger) { + sendJson(res, 400, { + error: + "This workflow does not have an HTTP Trigger node. Only workflows with HTTP Trigger can be called via API.", + }); + return; + } + + try { + console.log( + `[HTTP Server] Running workflow ${workflowId} with body:`, + JSON.stringify(body).slice(0, 200), + ); + const result = await engine.runAll(workflowId, body); + console.log( + `[HTTP Server] runAll result:`, + JSON.stringify(result)?.slice(0, 500), + ); + + if (result && typeof result === "object") { + const statusCode = (result.statusCode as number) ?? 200; + const responseBody = result.body ?? result; + const bodyObj = responseBody as Record; + + // If the engine returned an error (e.g. node failure without HTTP Response) + if (statusCode >= 400) { + sendJson(res, statusCode, { + error_msg: String( + bodyObj.error ?? bodyObj.message ?? "Workflow execution failed", + ), + }); + return; + } + + // Clean response — remove internal keys + const cleanBody = { ...bodyObj }; + delete cleanBody.statusCode; + if (Object.keys(cleanBody).length === 0) { + const fallback = collectLastNodeOutputs(workflowId); + sendJson(res, 200, fallback); + } else { + sendJson(res, 200, cleanBody); + } + } else { + // No HTTP Response node — collect outputs from terminal nodes + const fallback = collectLastNodeOutputs(workflowId); + sendJson(res, 200, fallback); + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.error(`[HTTP Server] Workflow execution failed:`, message); + sendJson(res, 500, { error_msg: message }); + } +} + +function handleGetSchema(workflowId: string, res: http.ServerResponse): void { + const workflow = getWorkflowById(workflowId); + if (!workflow) { + sendJson(res, 404, { error: `Workflow not found: ${workflowId}` }); + return; + } + + const nodes = getNodesByWorkflowId(workflowId); + const httpTrigger = nodes.find((n) => n.nodeType === "trigger/http"); + const httpResponse = nodes.find((n) => n.nodeType === "output/http-response"); + + if (!httpTrigger) { + sendJson(res, 400, { + error: "This workflow does not have an HTTP Trigger node.", + }); + return; + } + + const inputFields = parseOutputFields(httpTrigger.params.outputFields); + const outputFields = httpResponse + ? parseResponseFields(httpResponse.params.responseFields) + : []; + + sendJson(res, 200, { + workflowId, + name: workflow.name, + inputs: inputFields.map((f) => ({ + key: f.key, + label: f.label, + type: f.type, + })), + outputs: outputFields.map((f) => ({ + key: f.key, + label: f.label, + type: f.type, + })), + }); +} + +function readBody(req: http.IncomingMessage): Promise> { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + req.on("data", (chunk: Buffer) => chunks.push(chunk)); + req.on("end", () => { + try { + const raw = Buffer.concat(chunks).toString("utf-8"); + resolve(raw ? JSON.parse(raw) : {}); + } catch (err) { + reject(err); + } + }); + req.on("error", reject); + }); +} + +function sendJson( + res: http.ServerResponse, + status: number, + data: Record, +): void { + res.writeHead(status, { "Content-Type": "application/json" }); + res.end(JSON.stringify(data)); +} + +/** + * Fallback: when no HTTP Response node exists, collect outputs from terminal nodes + * (nodes with no outgoing edges, excluding trigger nodes). + */ +function collectLastNodeOutputs(workflowId: string): Record { + const nodes = getNodesByWorkflowId(workflowId); + const edges = getEdgesByWorkflowId(workflowId); + + // Find terminal nodes: nodes that are NOT a source of any edge + const sourceIds = new Set(edges.map((e) => e.sourceNodeId)); + const terminalNodes = nodes.filter( + (n) => !sourceIds.has(n.id) && !n.nodeType.startsWith("trigger/"), + ); + + const outputs: Record = {}; + for (const node of terminalNodes) { + if (!node.currentOutputId) continue; + const exec = getExecutionById(node.currentOutputId); + if (!exec || exec.status !== "success") continue; + + const meta = exec.resultMetadata as Record | null; + if (!meta) continue; + + // Use resultUrls or resultPath as the output value + const resultUrls = meta.resultUrls as string[] | undefined; + const resultUrl = (meta.resultUrl as string) ?? exec.resultPath; + const value = + resultUrls && resultUrls.length > 0 + ? resultUrls.length === 1 + ? resultUrls[0] + : resultUrls + : (resultUrl ?? meta.output); + + if (value !== undefined && value !== null) { + // Use node label or type as key + const label = + ((node.params?.__meta as Record)?.label as string) ?? + node.nodeType.split("/").pop() ?? + node.id; + outputs[label] = value; + } + } + + if (Object.keys(outputs).length === 0) { + return { status: "completed" }; + } + + // If only one terminal node, flatten the output + const keys = Object.keys(outputs); + if (keys.length === 1) { + return { status: "completed", output: outputs[keys[0]] }; + } + + return { status: "completed", outputs }; +} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 07acccd8..a5335645 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1154,6 +1154,27 @@ "zoomOut": "Zoom out", "autoLayout": "Auto Layout", "freeToolModelHint": "First run will auto-download the AI model, please wait", + "triggerHint": "This trigger will repeatedly run the downstream workflow each time it fires", + "dropToAddToGroup": "Release to add to Group", + "dropToRemoveFromGroup": "Release to remove from Group", + "editSubgraph": "Edit Subgraph", + "mainWorkflow": "Main Workflow", + "exitSubgraph": "Exit subgraph (ESC)", + "editingSubgraph": "Editing subgraph", + "childNodesCount": "{{count}} child node(s)", + "groupInput": "Group Input", + "groupOutput": "Group Output", + "noExposedPorts": "No exposed ports", + "doubleClickToRename": "Double-click to rename", + "clickToSetAlias": "Click to set display name", + "setAlias": "Set display name...", + "aliasPlaceholder": "Display name on main graph...", + "mappedAs": "Mapped as:", + "group": "Group", + "importWorkflow": "Import Workflow", + "importWorkflowAsSubgraph": "Import Workflow as Subgraph", + "noWorkflowsToImport": "No other workflows available", + "nodeCountLabel": "{{count}} nodes", "more": "More", "previousImage": "Previous image", "nextImage": "Next image", @@ -1171,6 +1192,9 @@ "open": "Open", "paste": "Paste", "addNode": "Add Node", + "addDownstreamNode": "Add Downstream Node", + "triggerLimitTitle": "Only one trigger allowed", + "triggerLimitDesc": "A workflow can only have one trigger node. Remove the existing trigger first.", "addNote": "Add Note", "note": "Note", "deleteConnection": "Delete Connection", @@ -1220,6 +1244,11 @@ "hideRun": "Hide this run", "output": "Output", "outputLowercase": "output", + "httpTriggerFields": "API Input Fields", + "httpResponseFields": "API Response Fields", + "addField": "Add", + "noFieldsHint": "No fields defined. Click Add to create one.", + "statusCode": "Status Code", "expandNode": "Expand", "collapseNode": "Collapse", "collapseAll": "Collapse All", @@ -1235,6 +1264,7 @@ "noNodesAvailable": "No nodes available", "nodeCategory": { "recent": "Recent", + "trigger": "Trigger", "input": "Input", "ai-task": "AI Model", "free-tool": "Free Tools", @@ -1243,6 +1273,14 @@ "control": "Control" }, "nodeDefs": { + "trigger/directory": { + "label": "Directory", + "hint": "Scan a local folder — workflow runs once per file" + }, + "trigger/http": { + "label": "HTTP Trigger", + "hint": "Expose this workflow as an HTTP API — define input fields that callers provide" + }, "ai-task/run": { "label": "WaveSpeed API", "hint": "Run any AI model on the platform — image, video, audio, and more", @@ -1329,6 +1367,10 @@ } } }, + "output/http-response": { + "label": "HTTP Response", + "hint": "Define what this workflow returns to HTTP callers" + }, "free-tool/image-enhancer": { "label": "Image Enhancer", "hint": "Upscale and sharpen images (2×–4×) for free" @@ -1399,8 +1441,12 @@ "hint": "Pick one item from an array by index" }, "control/iterator": { - "label": "Iterator", - "hint": "Loop over inputs — run child nodes N times or once per array item" + "label": "Group", + "hint": "Group nodes into a sub-workflow for organization" + }, + "control/group": { + "label": "Group", + "hint": "Group nodes into a sub-workflow for organization" }, "input/directory-import": { "label": "Directory Import", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 3b8226c6..5ff3a829 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -1147,6 +1147,27 @@ "zoomOut": "缩小", "autoLayout": "整理布局", "freeToolModelHint": "首次运行时将自动下载 AI 模型,请耐心等待", + "triggerHint": "此触发器每次触发时都会重复运行下游工作流", + "dropToAddToGroup": "松开以添加到 Group", + "dropToRemoveFromGroup": "松开以从 Group 移出", + "editSubgraph": "编辑子图", + "mainWorkflow": "主工作流", + "exitSubgraph": "退出子图 (ESC)", + "editingSubgraph": "正在编辑子图", + "childNodesCount": "{{count}} 个子节点", + "groupInput": "组输入", + "groupOutput": "组输出", + "noExposedPorts": "暂无暴露端口", + "doubleClickToRename": "双击重命名", + "clickToSetAlias": "点击设置显示名称", + "setAlias": "设置显示名称...", + "aliasPlaceholder": "主图上的显示名称...", + "mappedAs": "映射为:", + "group": "分组", + "importWorkflow": "导入工作流", + "importWorkflowAsSubgraph": "导入工作流为子图", + "noWorkflowsToImport": "没有其他可导入的工作流", + "nodeCountLabel": "{{count}} 个节点", "more": "更多", "previousImage": "上一张图片", "nextImage": "下一张图片", @@ -1162,6 +1183,9 @@ "open": "打开", "paste": "粘贴", "addNode": "添加节点", + "addDownstreamNode": "添加下游节点", + "triggerLimitTitle": "只允许一个触发器", + "triggerLimitDesc": "每个工作流只能有一个触发器节点,请先删除现有的触发器。", "addNote": "添加备注", "note": "备注", "deleteConnection": "删除连接", @@ -1211,6 +1235,11 @@ "hideRun": "隐藏本次运行", "output": "输出", "outputLowercase": "输出", + "httpTriggerFields": "API 输入字段", + "httpResponseFields": "API 响应字段", + "addField": "添加", + "noFieldsHint": "暂无字段,点击添加创建。", + "statusCode": "状态码", "expandNode": "展开", "collapseNode": "收起", "collapseAll": "全部收起", @@ -1226,6 +1255,7 @@ "noNodesAvailable": "暂无可用节点", "nodeCategory": { "recent": "最近使用", + "trigger": "触发器", "input": "输入", "ai-task": "AI 任务", "free-tool": "免费工具", @@ -1234,6 +1264,14 @@ "control": "控制" }, "nodeDefs": { + "trigger/directory": { + "label": "目录触发", + "hint": "扫描本地文件夹,每个文件执行一次工作流" + }, + "trigger/http": { + "label": "HTTP 触发", + "hint": "将工作流发布为 HTTP API,定义调用方需要提供的输入字段" + }, "ai-task/run": { "label": "WaveSpeed API", "hint": "运行平台上的任意 AI 模型——图像、视频、音频等", @@ -1320,6 +1358,10 @@ } } }, + "output/http-response": { + "label": "HTTP 响应", + "hint": "定义工作流通过 HTTP 返回的内容" + }, "free-tool/image-enhancer": { "label": "图片增强", "hint": "免费放大和锐化图片(2×–4×)" @@ -1390,8 +1432,12 @@ "hint": "按索引从数组中取出一个元素" }, "control/iterator": { - "label": "迭代器", - "hint": "循环执行子节点 — 固定 N 次或按数组长度自动迭代" + "label": "分组", + "hint": "将节点分组为子工作流,便于组织管理" + }, + "control/group": { + "label": "分组", + "hint": "将节点分组为子工作流,便于组织管理" }, "input/directory-import": { "label": "目录导入", diff --git a/src/index.css b/src/index.css index 172fb247..1d58f1c4 100644 --- a/src/index.css +++ b/src/index.css @@ -423,6 +423,20 @@ animation: pulse-subtle 1.8s ease-in-out infinite; } +/* Group drop-target breathe animation */ +@keyframes group-breathe { + 0%, + 100% { + transform: scale(1); + } + 50% { + transform: scale(1.004); + } +} +.animate-group-breathe { + animation: group-breathe 0.8s ease-in-out infinite; +} + /* Carousel slide animations for results panel */ @keyframes carousel-slide-left { from { diff --git a/src/workflow/WorkflowPage.tsx b/src/workflow/WorkflowPage.tsx index 210d4424..0fd29833 100644 --- a/src/workflow/WorkflowPage.tsx +++ b/src/workflow/WorkflowPage.tsx @@ -383,6 +383,8 @@ export function WorkflowPage() { >["edges"], isDirty: target.isDirty, }); + // Exit subgraph editing when switching tabs + useUIStore.getState().exitGroupEdit(); setActiveTabId(tabId); }, [activeTabId, tabs, saveCurrentTabSnapshot], @@ -422,6 +424,8 @@ export function WorkflowPage() { edges, isDirty: false, }); + // Exit subgraph editing when creating a new tab + useUIStore.getState().exitGroupEdit(); setActiveTabId(newTabId); // Auto-scroll to show the newly created tab requestAnimationFrame(() => { @@ -1049,7 +1053,8 @@ export function WorkflowPage() { useEffect(() => { if (prevWasRunning.current && !wasRunning && !isRunning) { const hasError = Object.values(nodeStatuses).some((s) => s === "error"); - const nodeName = lastRunType === "single" && lastRunNodeLabel ? lastRunNodeLabel : null; + const nodeName = + lastRunType === "single" && lastRunNodeLabel ? lastRunNodeLabel : null; setExecToast({ type: hasError ? "error" : "success", msg: hasError @@ -1858,7 +1863,7 @@ export function WorkflowPage() { className="h-7 w-7 rounded-lg flex items-center justify-center bg-red-900/60 text-red-300 hover:bg-red-800/70 transition-colors" onClick={() => { runCancelRef.current = true; - if (workflowId) cancelAll(workflowId); + cancelAll(workflowId || "browser"); }} > { if (signal?.aborted) throw new DOMException("Cancelled", "AbortError"); }; + + // ── Batch trigger detection: if a trigger/directory node exists, run the + // entire downstream workflow once per file (matching Electron backend behavior) ── + if (!options?.runOnlyNodeId && !options?.continueFromNodeId) { + const batchTriggerNode = nodes.find( + (n) => + n.data.nodeType === "trigger/directory" && + !n.data.params?.__triggerValue, + ); + if (batchTriggerNode) { + const params = batchTriggerNode.data.params ?? {}; + const dirPath = String(params.directoryPath ?? "").trim(); + if (!dirPath) { + callbacks.onNodeStatus( + batchTriggerNode.id, + "error", + "No directory selected.", + ); + return; + } + const mediaType = String(params.mediaType ?? "image"); + const BATCH_MEDIA_EXTS: Record = { + image: [ + ".jpg", + ".jpeg", + ".png", + ".webp", + ".gif", + ".bmp", + ".tiff", + ".tif", + ".svg", + ".avif", + ], + video: [ + ".mp4", + ".webm", + ".mov", + ".avi", + ".mkv", + ".flv", + ".wmv", + ".m4v", + ], + audio: [".mp3", ".wav", ".flac", ".m4a", ".ogg", ".aac", ".wma"], + all: [], + }; + BATCH_MEDIA_EXTS.all = [ + ...BATCH_MEDIA_EXTS.image, + ...BATCH_MEDIA_EXTS.video, + ...BATCH_MEDIA_EXTS.audio, + ]; + + let urls: string[] = []; + try { + const api = (window as unknown as Record) + .electronAPI as + | { + scanDirectory?: ( + path: string, + exts: string[], + ) => Promise; + } + | undefined; + if (api?.scanDirectory) { + const files = await api.scanDirectory( + dirPath, + BATCH_MEDIA_EXTS[mediaType] ?? BATCH_MEDIA_EXTS.all, + ); + urls = files + .sort() + .map((f) => `local-asset://${encodeURIComponent(f)}`); + } else { + callbacks.onNodeStatus( + batchTriggerNode.id, + "error", + "Directory scanning requires the desktop app.", + ); + return; + } + } catch (err) { + callbacks.onNodeStatus( + batchTriggerNode.id, + "error", + `Failed to scan directory: ${err instanceof Error ? err.message : String(err)}`, + ); + return; + } + + if (urls.length === 0) { + callbacks.onNodeStatus( + batchTriggerNode.id, + "error", + "No matching files found in directory.", + ); + return; + } + + // Run the workflow once per file, injecting __triggerValue into the trigger node + for (let i = 0; i < urls.length; i++) { + throwIfAborted(); + const fileUrl = urls[i]; + const fileName = + decodeURIComponent(fileUrl.replace(/^local-asset:\/\//i, "")) + .split(/[/\\]/) + .pop() || `file ${i + 1}`; + callbacks.onProgress( + batchTriggerNode.id, + ((i + 1) / urls.length) * 100, + `Processing ${fileName} (${i + 1}/${urls.length})`, + ); + + // Temporarily inject __triggerValue so the trigger handler outputs this specific file + const originalParams = { ...batchTriggerNode.data.params }; + batchTriggerNode.data.params = { + ...originalParams, + __triggerValue: fileUrl, + }; + + try { + await executeWorkflowInBrowser(nodes, edges, callbacks, { + ...options, + // Use a sentinel to prevent infinite recursion — the recursive call + // will see __triggerValue and the trigger handler will use it directly + runOnlyNodeId: undefined, + continueFromNodeId: undefined, + }); + } finally { + batchTriggerNode.data.params = originalParams; + } + } + return; + } + } + const simpleEdges: SimpleEdge[] = edges.map((e) => ({ sourceNodeId: e.source, targetNodeId: e.target, @@ -394,11 +529,19 @@ export async function executeWorkflowInBrowser( } // Ensure child nodes of any iterator in the graph are always included - const iteratorIds = new Set(filteredNodes.filter((n) => n.data.nodeType === "control/iterator").map((n) => n.id)); + const iteratorIds = new Set( + filteredNodes + .filter((n) => n.data.nodeType === "control/iterator") + .map((n) => n.id), + ); if (iteratorIds.size > 0) { const filteredIdSet = new Set(filteredNodes.map((n) => n.id)); for (const n of nodes) { - if (n.parentNode && iteratorIds.has(n.parentNode) && !filteredIdSet.has(n.id)) { + if ( + n.parentNode && + iteratorIds.has(n.parentNode) && + !filteredIdSet.has(n.id) + ) { filteredNodes.push(n); nodeIds.push(n.id); filteredIdSet.add(n.id); @@ -408,7 +551,15 @@ export async function executeWorkflowInBrowser( const allFilteredIds = new Set(filteredNodes.map((n) => n.id)); for (const e of edges) { if (allFilteredIds.has(e.source) && allFilteredIds.has(e.target)) { - if (!filteredEdges.some((fe) => fe.source === e.source && fe.target === e.target && fe.sourceHandle === e.sourceHandle && fe.targetHandle === e.targetHandle)) { + if ( + !filteredEdges.some( + (fe) => + fe.source === e.source && + fe.target === e.target && + fe.sourceHandle === e.sourceHandle && + fe.targetHandle === e.targetHandle, + ) + ) { filteredEdges.push(e); } } @@ -419,7 +570,9 @@ export async function executeWorkflowInBrowser( // Filter out child nodes (nodes with parentNode) from top-level execution. // They are only executed inside their parent iterator's handler. - const childNodeIds = new Set(filteredNodes.filter((n) => n.parentNode).map((n) => n.id)); + const childNodeIds = new Set( + filteredNodes.filter((n) => n.parentNode).map((n) => n.id), + ); const topLevelNodeIds = nodeIds.filter((id) => !childNodeIds.has(id)); const edgesWithHandles = filteredEdges.map((e) => ({ @@ -620,32 +773,72 @@ export async function executeWorkflowInBrowser( if (nodeType === "input/directory-import") { const dirPath = String(params.directoryPath ?? "").trim(); - if (!dirPath) throw new Error("No directory selected. Please choose a directory."); + if (!dirPath) + throw new Error( + "No directory selected. Please choose a directory.", + ); const mediaType = String(params.mediaType ?? "image"); const MEDIA_EXTS: Record = { - image: [".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".tiff", ".tif", ".svg", ".avif"], - video: [".mp4", ".webm", ".mov", ".avi", ".mkv", ".flv", ".wmv", ".m4v"], + image: [ + ".jpg", + ".jpeg", + ".png", + ".webp", + ".gif", + ".bmp", + ".tiff", + ".tif", + ".svg", + ".avif", + ], + video: [ + ".mp4", + ".webm", + ".mov", + ".avi", + ".mkv", + ".flv", + ".wmv", + ".m4v", + ], audio: [".mp3", ".wav", ".flac", ".m4a", ".ogg", ".aac", ".wma"], all: [], }; - MEDIA_EXTS.all = [...MEDIA_EXTS.image, ...MEDIA_EXTS.video, ...MEDIA_EXTS.audio]; + MEDIA_EXTS.all = [ + ...MEDIA_EXTS.image, + ...MEDIA_EXTS.video, + ...MEDIA_EXTS.audio, + ]; let urls: string[] = []; try { - const api = (window as unknown as Record).electronAPI as - | { scanDirectory?: (path: string, exts: string[]) => Promise } + const api = (window as unknown as Record) + .electronAPI as + | { + scanDirectory?: ( + path: string, + exts: string[], + ) => Promise; + } | undefined; if (api?.scanDirectory) { - const files = await api.scanDirectory(dirPath, MEDIA_EXTS[mediaType] ?? MEDIA_EXTS.all); + const files = await api.scanDirectory( + dirPath, + MEDIA_EXTS[mediaType] ?? MEDIA_EXTS.all, + ); // Convert raw file paths to local-asset:// URLs (consistent with electron handler) - urls = files.sort().map((f) => `local-asset://${encodeURIComponent(f)}`); + urls = files + .sort() + .map((f) => `local-asset://${encodeURIComponent(f)}`); } else { throw new Error("Directory scanning requires the desktop app."); } } catch (err) { - throw new Error(`Failed to scan directory: ${err instanceof Error ? err.message : String(err)}`); + throw new Error( + `Failed to scan directory: ${err instanceof Error ? err.message : String(err)}`, + ); } results.set(nodeId, { @@ -666,6 +859,142 @@ export async function executeWorkflowInBrowser( return; } + if (nodeType === "trigger/directory") { + // In batch mode, __triggerValue is injected by the outer batch loop + const triggerValue = params.__triggerValue as string | undefined; + if (triggerValue) { + results.set(nodeId, { + outputUrl: triggerValue, + resultMetadata: { + output: triggerValue, + resultUrl: triggerValue, + resultUrls: [triggerValue], + }, + }); + callbacks.onNodeStatus(nodeId, "confirmed"); + callbacks.onNodeComplete(nodeId, { + urls: [triggerValue], + cost: 0, + durationMs: Date.now() - start, + }); + return; + } + + // Non-batch fallback (e.g. runOnlyNodeId targeting this trigger) + const dirPath = String(params.directoryPath ?? "").trim(); + if (!dirPath) + throw new Error( + "No directory selected. Please choose a directory.", + ); + + const mediaType = String(params.mediaType ?? "image"); + + const TRIGGER_MEDIA_EXTS: Record = { + image: [ + ".jpg", + ".jpeg", + ".png", + ".webp", + ".gif", + ".bmp", + ".tiff", + ".tif", + ".svg", + ".avif", + ], + video: [ + ".mp4", + ".webm", + ".mov", + ".avi", + ".mkv", + ".flv", + ".wmv", + ".m4v", + ], + audio: [".mp3", ".wav", ".flac", ".m4a", ".ogg", ".aac", ".wma"], + all: [], + }; + TRIGGER_MEDIA_EXTS.all = [ + ...TRIGGER_MEDIA_EXTS.image, + ...TRIGGER_MEDIA_EXTS.video, + ...TRIGGER_MEDIA_EXTS.audio, + ]; + + let urls: string[] = []; + try { + const api = (window as unknown as Record) + .electronAPI as + | { + scanDirectory?: ( + path: string, + exts: string[], + ) => Promise; + } + | undefined; + if (api?.scanDirectory) { + const files = await api.scanDirectory( + dirPath, + TRIGGER_MEDIA_EXTS[mediaType] ?? TRIGGER_MEDIA_EXTS.all, + ); + urls = files + .sort() + .map((f) => `local-asset://${encodeURIComponent(f)}`); + } else { + throw new Error("Directory scanning requires the desktop app."); + } + } catch (err) { + throw new Error( + `Failed to scan directory: ${err instanceof Error ? err.message : String(err)}`, + ); + } + + if (urls.length === 0) + throw new Error("No matching files found in directory."); + + // Single-node run: output first file only + results.set(nodeId, { + outputUrl: urls[0], + resultMetadata: { + output: urls[0], + resultUrl: urls[0], + resultUrls: urls, + fileCount: urls.length, + }, + }); + callbacks.onNodeStatus(nodeId, "confirmed"); + callbacks.onNodeComplete(nodeId, { + urls: [urls[0]], + cost: 0, + durationMs: Date.now() - start, + }); + return; + } + + if (nodeType === "trigger/http") { + // HTTP trigger in browser mode: pass through the injected trigger value + const triggerValue = params.__triggerValue as + | Record + | undefined; + const outputMeta: Record = {}; + if (triggerValue) { + for (const [k, v] of Object.entries(triggerValue)) { + outputMeta[k] = typeof v === "string" ? v : JSON.stringify(v); + } + } + results.set(nodeId, { + outputUrl: "", + resultMetadata: outputMeta, + }); + callbacks.onNodeStatus(nodeId, "confirmed"); + callbacks.onNodeComplete(nodeId, { + urls: [""], + cost: 0, + durationMs: Date.now() - start, + }); + return; + } + if (nodeType === "output/preview") { const url = String(inputs.input ?? ""); if (!url) throw new Error("No URL provided for preview."); @@ -688,7 +1017,8 @@ export async function executeWorkflowInBrowser( const urlList: string[] = Array.isArray(rawUrl) ? rawUrl.map(String).filter(Boolean) : [String(rawUrl)].filter(Boolean); - if (urlList.length === 0) throw new Error("No URL provided for export."); + if (urlList.length === 0) + throw new Error("No URL provided for export."); const filenamePrefix = String(params.filename ?? "output").replace( @@ -718,7 +1048,9 @@ export async function executeWorkflowInBrowser( fileName, ); if (!result.success) - throw new Error(result.error || `Save failed for file ${fi + 1}`); + throw new Error( + result.error || `Save failed for file ${fi + 1}`, + ); } else { try { const resp = await fetch(url); @@ -741,12 +1073,18 @@ export async function executeWorkflowInBrowser( } } savedFiles.push(url); - onProgress(((fi + 1) / urlList.length) * 100, `Saved ${fi + 1}/${urlList.length}`); + onProgress( + ((fi + 1) / urlList.length) * 100, + `Saved ${fi + 1}/${urlList.length}`, + ); } results.set(nodeId, { outputUrl: savedFiles[0] ?? "", - resultMetadata: { output: savedFiles, exportedFileCount: savedFiles.length }, + resultMetadata: { + output: savedFiles, + exportedFileCount: savedFiles.length, + }, }); callbacks.onNodeStatus(nodeId, "confirmed"); callbacks.onNodeComplete(nodeId, { @@ -1201,18 +1539,31 @@ export async function executeWorkflowInBrowser( return; } - // ── Iterator: execute child nodes as a sub-workflow N times ── + // ── Group (formerly Iterator): execute child nodes as a sub-workflow ONCE ── if (nodeType === "control/iterator") { - const iterationMode = String(params.iterationMode ?? "fixed"); - const fixedCount = Math.max(1, Number(params.iterationCount ?? 1)); - // Parse exposed params - const parseExposed = (v: unknown): Array<{ subNodeId: string; subNodeLabel: string; paramKey: string; namespacedKey: string; direction: string; dataType: string }> => { - if (typeof v === "string") { try { return JSON.parse(v); } catch { return []; } } + const parseExposed = ( + v: unknown, + ): Array<{ + subNodeId: string; + subNodeLabel: string; + paramKey: string; + namespacedKey: string; + direction: string; + dataType: string; + }> => { + if (typeof v === "string") { + try { + return JSON.parse(v); + } catch { + return []; + } + } return Array.isArray(v) ? v : []; }; const exposedInputs = parseExposed(params.exposedInputs); const exposedOutputs = parseExposed(params.exposedOutputs); + // Collect child nodes and internal edges const allNodes = Array.from(nodeMap.values()); const childNodes = allNodes.filter((n) => n.parentNode === nodeId); @@ -1224,207 +1575,248 @@ export async function executeWorkflowInBrowser( if (childNodes.length === 0) { results.set(nodeId, { outputUrl: "", resultMetadata: {} }); callbacks.onNodeStatus(nodeId, "confirmed"); - callbacks.onNodeComplete(nodeId, { urls: [], cost: 0, durationMs: Date.now() - start }); + callbacks.onNodeComplete(nodeId, { + urls: [], + cost: 0, + durationMs: Date.now() - start, + }); return; } // Topological sort of child nodes - const childSimpleEdges = internalEdges.map((e) => ({ sourceNodeId: e.source, targetNodeId: e.target })); - const childLevels = topologicalLevels(Array.from(childIdSet), childSimpleEdges); + const childSimpleEdges = internalEdges.map((e) => ({ + sourceNodeId: e.source, + targetNodeId: e.target, + })); + const childLevels = topologicalLevels( + Array.from(childIdSet), + childSimpleEdges, + ); - // Build input routing from exposed inputs (keep raw values for auto-mode slicing) - const inputRoutingRaw = new Map>(); + // Build input routing from exposed inputs + const inputRouting = new Map>(); for (const ep of exposedInputs) { - const externalValue = inputs[ep.namespacedKey] ?? inputs[`input-${ep.namespacedKey}`]; + const externalValue = + inputs[ep.namespacedKey] ?? inputs[`input-${ep.namespacedKey}`]; if (externalValue !== undefined) { - if (!inputRoutingRaw.has(ep.subNodeId)) inputRoutingRaw.set(ep.subNodeId, new Map()); - inputRoutingRaw.get(ep.subNodeId)!.set(ep.paramKey, externalValue); + if (!inputRouting.has(ep.subNodeId)) + inputRouting.set(ep.subNodeId, new Map()); + inputRouting.get(ep.subNodeId)!.set(ep.paramKey, externalValue); } } - // Determine iteration count - let iterationCount: number; - const allExternalValues: unknown[] = []; - for (const ep of exposedInputs) { - const v = inputs[ep.namespacedKey] ?? inputs[`input-${ep.namespacedKey}`]; - if (v !== undefined) allExternalValues.push(v); - } + // Execute child nodes — single pass, no iteration + const subResults = new Map(); + let totalCost = 0; - if (iterationMode === "auto") { - const arrayLengths = allExternalValues - .filter((v) => Array.isArray(v)) - .map((v) => (v as unknown[]).length); + for (const level of childLevels) { + for (const childId of level) { + throwIfAborted(); - if (arrayLengths.length === 0) { - if (allExternalValues.length > 0) { - iterationCount = 1; - } else { - throw new Error("Auto mode: no external inputs connected. Connect an array input or switch to fixed mode."); - } - } else { - iterationCount = Math.max(...arrayLengths); - if (iterationCount === 0) { - results.set(nodeId, { outputUrl: "", resultMetadata: {} }); - callbacks.onNodeStatus(nodeId, "confirmed"); - callbacks.onNodeComplete(nodeId, { urls: [], cost: 0, durationMs: Date.now() - start }); - return; + const childNode = nodeMap.get(childId); + if (!childNode) continue; + + const childType = childNode.data.nodeType; + const childParams = { ...(childNode.data.params ?? {}) }; + + // Inject external inputs + const extInputs = inputRouting.get(childId); + if (extInputs) { + for (const [pk, val] of extInputs) { + childParams[pk] = val; + } } - } - } else { - iterationCount = fixedCount; - } - // Execute N iterations - const iterationOutputs: Array> = []; - let totalCost = 0; + // Resolve internal edges from upstream child outputs + const childInputs = resolveInputs( + childId, + nodeMap, + internalEdges, + subResults, + ); - for (let iter = 0; iter < iterationCount; iter++) { - throwIfAborted(); - const subResults = new Map(); - let iterFailed = false; - - for (const level of childLevels) { - if (iterFailed) break; - for (const childId of level) { - if (iterFailed) break; - throwIfAborted(); - - const childNode = nodeMap.get(childId); - if (!childNode) continue; - - const childType = childNode.data.nodeType; - const childParams = { ...(childNode.data.params ?? {}) }; - - // Inject external inputs (with auto-mode array slicing) - const extInputs = inputRoutingRaw.get(childId); - if (extInputs) { - for (const [pk, rawVal] of extInputs) { - if (iterationMode === "auto" && Array.isArray(rawVal)) { - const arr = rawVal as unknown[]; - childParams[pk] = arr.length > 0 ? arr[Math.min(iter, arr.length - 1)] : undefined; - } else if (iterationMode === "fixed" && Array.isArray(rawVal)) { - const arr = rawVal as unknown[]; - childParams[pk] = arr.length > 0 ? arr[iter % arr.length] : undefined; - } else { - childParams[pk] = rawVal; - } + callbacks.onNodeStatus(childId, "running"); + const childStart = Date.now(); + try { + if (childType === "ai-task/run") { + const modelId = String(childParams.modelId ?? ""); + if (!modelId) + throw new Error("No model selected in child node."); + const apiParams = buildApiParams(childParams, childInputs); + const resolvedParams = await uploadLocalUrls( + apiParams, + signal, + ); + callbacks.onProgress(childId, 10, `Running ${modelId}...`); + const result = await workflowClient.run( + modelId, + resolvedParams, + { signal }, + ); + const outputUrl = + Array.isArray(result.outputs) && result.outputs.length > 0 + ? String(result.outputs[0]) + : ""; + const model = useModelsStore + .getState() + .getModelById(modelId); + const cost = model?.base_price ?? 0; + totalCost += cost; + subResults.set(childId, { + outputUrl, + resultMetadata: { + output: outputUrl, + resultUrl: outputUrl, + resultUrls: Array.isArray(result.outputs) + ? result.outputs + : [outputUrl], + }, + }); + callbacks.onNodeStatus(childId, "confirmed"); + callbacks.onNodeComplete(childId, { + urls: Array.isArray(result.outputs) + ? result.outputs.map(String) + : [outputUrl], + cost, + durationMs: Date.now() - childStart, + }); + } else if (childType === "input/text-input") { + const text = String( + childParams.text ?? childInputs.text ?? "", + ); + subResults.set(childId, { + outputUrl: text, + resultMetadata: { output: text }, + }); + callbacks.onNodeStatus(childId, "confirmed"); + callbacks.onNodeComplete(childId, { + urls: [text], + cost: 0, + durationMs: Date.now() - childStart, + }); + } else if (childType === "input/media-upload") { + const url = String( + childParams.uploadedUrl ?? childInputs.input ?? "", + ); + subResults.set(childId, { + outputUrl: url, + resultMetadata: { output: url }, + }); + callbacks.onNodeStatus(childId, "confirmed"); + callbacks.onNodeComplete(childId, { + urls: [url], + cost: 0, + durationMs: Date.now() - childStart, + }); + } else if (childType === "processing/concat") { + const arr: string[] = []; + for (let ci = 1; ci <= 5; ci++) { + const v = + childInputs[`input${ci}`] ?? childParams[`input${ci}`]; + if (v) + arr.push( + ...(Array.isArray(v) ? v.map(String) : [String(v)]), + ); } - } - childParams.__iterationIndex = iter; - - // Resolve internal edges from upstream child outputs - const childInputs = resolveInputs(childId, nodeMap, internalEdges, subResults); - - // Create a virtual node with merged params for execution - const virtualNode: BrowserNode = { id: childId, data: { nodeType: childType, params: childParams } }; - const virtualNodeMap = new Map(nodeMap); - virtualNodeMap.set(childId, virtualNode); - - // Execute child node inline - callbacks.onNodeStatus(childId, "running"); - const childStart = Date.now(); - try { - if (childType === "ai-task/run") { - const modelId = String(childParams.modelId ?? ""); - if (!modelId) throw new Error("No model selected in child node."); - const apiParams = buildApiParams(childParams, childInputs); - const resolvedParams = await uploadLocalUrls(apiParams, signal); - callbacks.onProgress(childId, 10, `Running ${modelId}...`); - const result = await workflowClient.run(modelId, resolvedParams, { signal }); - const outputUrl = Array.isArray(result.outputs) && result.outputs.length > 0 ? String(result.outputs[0]) : ""; - const model = useModelsStore.getState().getModelById(modelId); - const cost = model?.base_price ?? 0; - totalCost += cost; + subResults.set(childId, { + outputUrl: arr[0] ?? "", + resultMetadata: { + output: arr, + resultUrl: arr[0], + resultUrls: arr, + }, + }); + callbacks.onNodeStatus(childId, "confirmed"); + callbacks.onNodeComplete(childId, { + urls: arr, + cost: 0, + durationMs: Date.now() - childStart, + }); + } else if (childType === "processing/select") { + const raw = childInputs.input ?? childParams.input; + const arr = Array.isArray(raw) + ? raw.map(String) + : raw + ? [String(raw)] + : []; + const idx = Math.floor(Number(childParams.index ?? 0)); + const value = arr[idx] ?? ""; + subResults.set(childId, { + outputUrl: value, + resultMetadata: { output: value }, + }); + callbacks.onNodeStatus(childId, "confirmed"); + callbacks.onNodeComplete(childId, { + urls: [value], + cost: 0, + durationMs: Date.now() - childStart, + }); + } else { + const inputUrl = String( + childInputs.input ?? + childInputs.media ?? + childParams.uploadedUrl ?? + "", + ); + if (inputUrl) { subResults.set(childId, { - outputUrl, - resultMetadata: { output: outputUrl, resultUrl: outputUrl, resultUrls: Array.isArray(result.outputs) ? result.outputs : [outputUrl] }, + outputUrl: inputUrl, + resultMetadata: { output: inputUrl }, }); callbacks.onNodeStatus(childId, "confirmed"); - callbacks.onNodeComplete(childId, { urls: Array.isArray(result.outputs) ? result.outputs.map(String) : [outputUrl], cost, durationMs: Date.now() - childStart }); - } else if (childType === "input/text-input") { - const text = String(childParams.text ?? childInputs.text ?? ""); - subResults.set(childId, { outputUrl: text, resultMetadata: { output: text } }); - callbacks.onNodeStatus(childId, "confirmed"); - callbacks.onNodeComplete(childId, { urls: [text], cost: 0, durationMs: Date.now() - childStart }); - } else if (childType === "input/media-upload") { - const url = String(childParams.uploadedUrl ?? childInputs.input ?? ""); - subResults.set(childId, { outputUrl: url, resultMetadata: { output: url } }); - callbacks.onNodeStatus(childId, "confirmed"); - callbacks.onNodeComplete(childId, { urls: [url], cost: 0, durationMs: Date.now() - childStart }); - } else if (childType === "processing/concat") { - const arr: string[] = []; - for (let ci = 1; ci <= 5; ci++) { - const v = childInputs[`input${ci}`] ?? childParams[`input${ci}`]; - if (v) arr.push(...(Array.isArray(v) ? v.map(String) : [String(v)])); - } - subResults.set(childId, { outputUrl: arr[0] ?? "", resultMetadata: { output: arr, resultUrl: arr[0], resultUrls: arr } }); - callbacks.onNodeStatus(childId, "confirmed"); - callbacks.onNodeComplete(childId, { urls: arr, cost: 0, durationMs: Date.now() - childStart }); - } else if (childType === "processing/select") { - const raw = childInputs.input ?? childParams.input; - const arr = Array.isArray(raw) ? raw.map(String) : raw ? [String(raw)] : []; - const idx = Math.floor(Number(childParams.index ?? 0)); - const value = arr[idx] ?? ""; - subResults.set(childId, { outputUrl: value, resultMetadata: { output: value } }); - callbacks.onNodeStatus(childId, "confirmed"); - callbacks.onNodeComplete(childId, { urls: [value], cost: 0, durationMs: Date.now() - childStart }); + callbacks.onNodeComplete(childId, { + urls: [inputUrl], + cost: 0, + durationMs: Date.now() - childStart, + }); } else { - // Generic pass-through for free-tools and other types - const inputUrl = String(childInputs.input ?? childInputs.media ?? childParams.uploadedUrl ?? ""); - if (inputUrl) { - subResults.set(childId, { outputUrl: inputUrl, resultMetadata: { output: inputUrl } }); - callbacks.onNodeStatus(childId, "confirmed"); - callbacks.onNodeComplete(childId, { urls: [inputUrl], cost: 0, durationMs: Date.now() - childStart }); - } else { - throw new Error(`Unsupported child node type: ${childType}`); - } + throw new Error( + `Unsupported child node type: ${childType}`, + ); } - } catch (err) { - iterFailed = true; - const msg = err instanceof Error ? err.message : String(err); - callbacks.onNodeStatus(childId, "error", msg); - failedNodes.add(nodeId); - callbacks.onNodeStatus(nodeId, "error", `Child node failed: ${msg}`); - return; } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + callbacks.onNodeStatus(childId, "error", msg); + failedNodes.add(nodeId); + callbacks.onNodeStatus( + nodeId, + "error", + `Child node failed: ${msg}`, + ); + return; } } - - // Collect exposed outputs for this iteration - const iterOut: Record = {}; - for (const ep of exposedOutputs) { - const nodeOut = subResults.get(ep.subNodeId); - if (nodeOut) { - iterOut[`output-${ep.namespacedKey}`] = nodeOut.resultMetadata[ep.paramKey] ?? nodeOut.outputUrl; - } - } - iterationOutputs.push(iterOut); - onProgress(((iter + 1) / iterationCount) * 100, `Iteration ${iter + 1}/${iterationCount} complete`); } - // Aggregate results — ALWAYS output arrays regardless of iteration count - // N=1 → ["value"], N=3 → ["v1","v2","v3"], N=0 → [] - const finalOutputs: Record = {}; - for (const ep of exposedOutputs) { - const hk = `output-${ep.namespacedKey}`; - finalOutputs[hk] = iterationOutputs.map((r) => r[hk]); - } - - // Collect all output URLs for the iterator result + // Collect exposed outputs — single values, no array aggregation + const groupOutputs: Record = {}; const allUrls: string[] = []; - for (const out of iterationOutputs) { - for (const v of Object.values(out)) { - if (typeof v === "string" && v) allUrls.push(v); - if (Array.isArray(v)) allUrls.push(...v.filter((x): x is string => typeof x === "string" && !!x)); + for (const ep of exposedOutputs) { + const nodeOut = subResults.get(ep.subNodeId); + if (nodeOut) { + const val = + nodeOut.resultMetadata[ep.paramKey] ?? nodeOut.outputUrl; + groupOutputs[`output-${ep.namespacedKey}`] = val; + if (typeof val === "string" && val) allUrls.push(val); } } results.set(nodeId, { outputUrl: allUrls[0] ?? "", - resultMetadata: { ...finalOutputs, output: allUrls[0] ?? "", resultUrl: allUrls[0] ?? "", resultUrls: allUrls }, + resultMetadata: { + ...groupOutputs, + output: allUrls[0] ?? "", + resultUrl: allUrls[0] ?? "", + resultUrls: allUrls, + }, }); callbacks.onNodeStatus(nodeId, "confirmed"); - callbacks.onNodeComplete(nodeId, { urls: allUrls, cost: totalCost, durationMs: Date.now() - start }); + callbacks.onNodeComplete(nodeId, { + urls: allUrls, + cost: totalCost, + durationMs: Date.now() - start, + }); return; } diff --git a/src/workflow/components/canvas/ImportWorkflowDialog.tsx b/src/workflow/components/canvas/ImportWorkflowDialog.tsx new file mode 100644 index 00000000..2a52502a --- /dev/null +++ b/src/workflow/components/canvas/ImportWorkflowDialog.tsx @@ -0,0 +1,189 @@ +/** + * ImportWorkflowDialog — modal dialog to pick an existing workflow + * and import its nodes/edges into a Group as a subgraph. + */ +import { useState, useEffect, useCallback } from "react"; +import { createPortal } from "react-dom"; +import { useTranslation } from "react-i18next"; +import { workflowIpc } from "../../ipc/ipc-client"; +import { useWorkflowStore } from "../../stores/workflow.store"; +import type { WorkflowSummary } from "@/workflow/types/ipc"; + +interface Props { + groupId: string; + onClose: () => void; +} + +export function ImportWorkflowDialog({ groupId, onClose }: Props) { + const { t } = useTranslation(); + const [workflows, setWorkflows] = useState([]); + const [loading, setLoading] = useState(true); + const [importing, setImporting] = useState(null); + const currentWorkflowId = useWorkflowStore((s) => s.workflowId); + const importWorkflowIntoGroup = useWorkflowStore( + (s) => s.importWorkflowIntoGroup, + ); + + useEffect(() => { + workflowIpc + .list() + .then((list) => { + // Exclude the current workflow to prevent self-import + setWorkflows(list.filter((w) => w.id !== currentWorkflowId)); + setLoading(false); + }) + .catch(() => setLoading(false)); + }, [currentWorkflowId]); + + const handleImport = useCallback( + async (wfId: string) => { + setImporting(wfId); + try { + await importWorkflowIntoGroup(groupId, wfId); + onClose(); + } catch (err) { + console.error("Import failed:", err); + setImporting(null); + } + }, + [groupId, importWorkflowIntoGroup, onClose], + ); + + // ESC to close + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + document.addEventListener("keydown", handler); + return () => document.removeEventListener("keydown", handler); + }, [onClose]); + + return createPortal( +
+
e.stopPropagation()} + > + {/* Header */} +
+ + {t( + "workflow.importWorkflowAsSubgraph", + "Import Workflow as Subgraph", + )} + + +
+ + {/* Body */} +
+ {loading ? ( +
+ {t("common.loading", "Loading...")} +
+ ) : workflows.length === 0 ? ( +
+ {t( + "workflow.noWorkflowsToImport", + "No other workflows available", + )} +
+ ) : ( + workflows.map((wf) => ( + + )) + )} +
+
+
, + document.body, + ); +} diff --git a/src/workflow/components/canvas/NodePalette.tsx b/src/workflow/components/canvas/NodePalette.tsx index 766fd079..4bd2864a 100644 --- a/src/workflow/components/canvas/NodePalette.tsx +++ b/src/workflow/components/canvas/NodePalette.tsx @@ -26,6 +26,7 @@ import { TooltipTrigger, TooltipContent, } from "@/components/ui/tooltip"; +import { toast } from "@/hooks/useToast"; /* ── category colour dots ─────────────────────────────────── */ const catDot: Record = { @@ -107,7 +108,31 @@ export function NodePalette({ definitions }: NodePaletteProps) { (def: NodeTypeDefinition) => { // Don't allow creating iterators inside iterators const pendingParentId = useUIStore.getState().pendingIteratorParentId; - if (pendingParentId && def.type === "control/iterator") return; + const editGroupId = useUIStore.getState().editingGroupId; + const adoptParent = pendingParentId || editGroupId; + if (adoptParent && def.type === "control/iterator") return; + + // Only one trigger node per workflow + if (def.category === "trigger") { + const existingTrigger = useWorkflowStore + .getState() + .nodes.find( + (n) => + n.type?.startsWith("trigger/") || + n.data?.nodeType?.startsWith("trigger/"), + ); + if (existingTrigger) { + toast({ + title: t("workflow.triggerLimitTitle", "Only one trigger allowed"), + description: t( + "workflow.triggerLimitDesc", + "A workflow can only have one trigger node. Remove the existing trigger first.", + ), + variant: "destructive", + }); + return; + } + } const defaultParams: Record = {}; for (const p of def.params) { @@ -116,12 +141,15 @@ export function NodePalette({ definitions }: NodePaletteProps) { let x: number, y: number; - if (pendingParentId) { + if (adoptParent) { // Creating inside an Iterator — compute absolute position in the internal canvas area - const iteratorNode = useWorkflowStore.getState().nodes.find((n) => n.id === pendingParentId); + const iteratorNode = useWorkflowStore + .getState() + .nodes.find((n) => n.id === adoptParent); if (iteratorNode) { const itW = (iteratorNode.data?.params?.__nodeWidth as number) ?? 600; - const itH = (iteratorNode.data?.params?.__nodeHeight as number) ?? 400; + const itH = + (iteratorNode.data?.params?.__nodeHeight as number) ?? 400; const childW = (defaultParams.__nodeWidth as number) ?? 300; // Account for port strips — width depends on whether ports are exposed const inputDefs = iteratorNode.data?.inputDefinitions ?? []; @@ -130,7 +158,10 @@ export function NodePalette({ definitions }: NodePaletteProps) { const rightStrip = (outputDefs as unknown[]).length > 0 ? 140 : 24; const internalW = itW - leftStrip - rightStrip; // Center the child in the internal canvas area - x = iteratorNode.position.x + leftStrip + Math.max(10, (internalW - childW) / 2); + x = + iteratorNode.position.x + + leftStrip + + Math.max(10, (internalW - childW) / 2); y = iteratorNode.position.y + Math.max(60, itH / 2 - 40); } else { const center = useUIStore.getState().getViewportCenter(); @@ -160,9 +191,10 @@ export function NodePalette({ definitions }: NodePaletteProps) { // If creating inside an Iterator, adopt immediately // adoptNode converts absolute position to relative and sets parentNode + extent - if (pendingParentId) { - useWorkflowStore.getState().adoptNode(pendingParentId, newNodeId); - useUIStore.getState().setPendingIteratorParentId(null); + if (adoptParent) { + useWorkflowStore.getState().adoptNode(adoptParent, newNodeId); + if (pendingParentId) + useUIStore.getState().setPendingIteratorParentId(null); } recordRecentNodeType(def.type); @@ -174,6 +206,9 @@ export function NodePalette({ definitions }: NodePaletteProps) { const handleModelClick = useCallback( (model: { model_id: string; name: string }) => { + const pendingParentId = useUIStore.getState().pendingIteratorParentId; + const editGroupId = useUIStore.getState().editingGroupId; + const adoptParent = pendingParentId || editGroupId; const aiTaskDef = definitions.find((d) => d.type === "ai-task/run"); const defaultParams: Record = {}; if (aiTaskDef) { @@ -183,9 +218,37 @@ export function NodePalette({ definitions }: NodePaletteProps) { } defaultParams.modelId = model.model_id; - const center = useUIStore.getState().getViewportCenter(); - const x = center.x + (Math.random() - 0.5) * 60; - const y = center.y + (Math.random() - 0.5) * 60; + let x: number, y: number; + + if (adoptParent) { + const iteratorNode = useWorkflowStore + .getState() + .nodes.find((n) => n.id === adoptParent); + if (iteratorNode) { + const itW = (iteratorNode.data?.params?.__nodeWidth as number) ?? 600; + const itH = + (iteratorNode.data?.params?.__nodeHeight as number) ?? 400; + const childW = (defaultParams.__nodeWidth as number) ?? 300; + const inputDefs = iteratorNode.data?.inputDefinitions ?? []; + const outputDefs = iteratorNode.data?.outputDefinitions ?? []; + const leftStrip = (inputDefs as unknown[]).length > 0 ? 140 : 24; + const rightStrip = (outputDefs as unknown[]).length > 0 ? 140 : 24; + const internalW = itW - leftStrip - rightStrip; + x = + iteratorNode.position.x + + leftStrip + + Math.max(10, (internalW - childW) / 2); + y = iteratorNode.position.y + Math.max(60, itH / 2 - 40); + } else { + const center = useUIStore.getState().getViewportCenter(); + x = center.x + (Math.random() - 0.5) * 60; + y = center.y + (Math.random() - 0.5) * 60; + } + } else { + const center = useUIStore.getState().getViewportCenter(); + x = center.x + (Math.random() - 0.5) * 60; + y = center.y + (Math.random() - 0.5) * 60; + } const desktopModel = useModelsStore .getState() @@ -219,6 +282,13 @@ export function NodePalette({ definitions }: NodePaletteProps) { label: model.name, }); + // If creating inside an Iterator, adopt immediately + if (adoptParent) { + useWorkflowStore.getState().adoptNode(adoptParent, newNodeId); + if (pendingParentId) + useUIStore.getState().setPendingIteratorParentId(null); + } + recordRecentNodeType("ai-task/run"); toggleNodePalette(); }, @@ -226,12 +296,13 @@ export function NodePalette({ definitions }: NodePaletteProps) { ); const categoryOrder = [ + "trigger", "ai-task", "input", "output", "processing", - "free-tool", "control", + "free-tool", ]; const categoryLabel = useCallback( (cat: string) => t(`workflow.nodeCategory.${cat}`, cat), @@ -241,7 +312,8 @@ export function NodePalette({ definitions }: NodePaletteProps) { const displayDefs = useMemo(() => { let defs = definitions; // When creating inside an Iterator, filter out the iterator type (no nesting) - if (pendingIteratorParentId) { + const editGroupId = useUIStore.getState().editingGroupId; + if (pendingIteratorParentId || editGroupId) { defs = defs.filter((d) => d.type !== "control/iterator"); } const q = query.trim(); @@ -352,9 +424,19 @@ export function NodePalette({ definitions }: NodePaletteProps) { {/* ── iterator context banner ── */} {pendingIteratorParentId && (
- - - + + + + + {t("workflow.addingInsideIterator", "Adding inside Iterator")} diff --git a/src/workflow/components/canvas/SubgraphBreadcrumb.tsx b/src/workflow/components/canvas/SubgraphBreadcrumb.tsx new file mode 100644 index 00000000..07ee430e --- /dev/null +++ b/src/workflow/components/canvas/SubgraphBreadcrumb.tsx @@ -0,0 +1,76 @@ +/** + * SubgraphBreadcrumb — shows navigation path when editing inside a Group. + * Displays: "主工作流 / GroupName" with click-to-navigate and ESC to exit. + */ +import { useCallback, useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { useUIStore } from "../../stores/ui.store"; +import { useWorkflowStore } from "../../stores/workflow.store"; +import { ChevronRight, X } from "lucide-react"; + +export function SubgraphBreadcrumb() { + const { t } = useTranslation(); + const editingGroupId = useUIStore((s) => s.editingGroupId); + const exitGroupEdit = useUIStore((s) => s.exitGroupEdit); + const nodes = useWorkflowStore((s) => s.nodes); + + const groupNode = nodes.find((n) => n.id === editingGroupId); + const groupLabel = groupNode + ? String(groupNode.data?.label || t("workflow.group", "Group")) + : t("workflow.group", "Group"); + const groupShortId = editingGroupId?.slice(0, 8) ?? ""; + + // ESC to exit subgraph editing — but NOT if a popover/picker is open + useEffect(() => { + if (!editingGroupId) return; + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") { + // Skip if an input is focused (alias editing, etc.) + const active = document.activeElement; + if ( + active && + (active.tagName === "INPUT" || active.tagName === "TEXTAREA") + ) + return; + // Skip if the SubgraphToolbar picker is open — it handles its own ESC + if (document.body.hasAttribute("data-subgraph-picker-open")) return; + e.preventDefault(); + e.stopPropagation(); + exitGroupEdit(); + } + }; + document.addEventListener("keydown", handleKeyDown, true); + return () => document.removeEventListener("keydown", handleKeyDown, true); + }, [editingGroupId, exitGroupEdit]); + + const handleExitClick = useCallback(() => { + exitGroupEdit(); + }, [exitGroupEdit]); + + if (!editingGroupId) return null; + + return ( +
+ + + + {groupLabel} + + + #{groupShortId} + + +
+ ); +} diff --git a/src/workflow/components/canvas/SubgraphToolbar.tsx b/src/workflow/components/canvas/SubgraphToolbar.tsx new file mode 100644 index 00000000..8a57481f --- /dev/null +++ b/src/workflow/components/canvas/SubgraphToolbar.tsx @@ -0,0 +1,515 @@ +/** + * SubgraphToolbar — floating toolbar shown when editing a Group subgraph. + * Provides IN/OUT parameter configuration with alias support. + */ +import { useState, useCallback, useRef, useEffect, useMemo } from "react"; +import { createPortal } from "react-dom"; +import { useTranslation } from "react-i18next"; +import { useUIStore } from "../../stores/ui.store"; +import { useWorkflowStore } from "../../stores/workflow.store"; +import type { PortDefinition } from "@/workflow/types/node-defs"; +import type { ExposedParam } from "@/workflow/types/workflow"; +import { Settings2, ChevronDown, ChevronRight } from "lucide-react"; + +/* ── Expose-param picker ── */ + +function SubgraphParamPicker({ + groupId, + direction, + onEditingAliasChange, +}: { + groupId: string; + direction: "input" | "output"; + onEditingAliasChange: (editing: boolean) => void; +}) { + const { t } = useTranslation(); + const nodes = useWorkflowStore((s) => s.nodes); + const exposeParam = useWorkflowStore((s) => s.exposeParam); + const unexposeParam = useWorkflowStore((s) => s.unexposeParam); + const updateAlias = useWorkflowStore((s) => s.updateExposedParamAlias); + const [editingAlias, setEditingAlias] = useState(null); + const [aliasValue, setAliasValue] = useState(""); + + const groupNode = nodes.find((n) => n.id === groupId); + const groupParams = (groupNode?.data?.params ?? {}) as Record< + string, + unknown + >; + const childNodes = nodes.filter((n) => n.parentNode === groupId); + + const exposedList: ExposedParam[] = useMemo(() => { + const key = direction === "input" ? "exposedInputs" : "exposedOutputs"; + try { + const raw = groupParams[key]; + return typeof raw === "string" + ? JSON.parse(raw) + : Array.isArray(raw) + ? raw + : []; + } catch { + return []; + } + }, [groupParams, direction]); + + const isExposed = useCallback( + (subNodeId: string, paramKey: string) => + exposedList.some( + (p) => p.subNodeId === subNodeId && p.paramKey === paramKey, + ), + [exposedList], + ); + + const getExposedParam = useCallback( + (subNodeId: string, paramKey: string) => + exposedList.find( + (p) => p.subNodeId === subNodeId && p.paramKey === paramKey, + ), + [exposedList], + ); + + const handleToggle = useCallback( + ( + subNodeId: string, + subNodeLabel: string, + paramKey: string, + dataType: string, + ) => { + if (isExposed(subNodeId, paramKey)) { + const ep = exposedList.find( + (p) => p.subNodeId === subNodeId && p.paramKey === paramKey, + ); + if (ep) unexposeParam(groupId, ep.namespacedKey, direction); + } else { + const nk = `${subNodeId}.${paramKey}`; + exposeParam(groupId, { + subNodeId, + subNodeLabel, + paramKey, + namespacedKey: nk, + direction, + dataType: dataType as ExposedParam["dataType"], + }); + } + }, + [isExposed, exposedList, exposeParam, unexposeParam, groupId, direction], + ); + + const startAliasEdit = useCallback( + (nk: string, currentAlias: string) => { + setEditingAlias(nk); + setAliasValue(currentAlias); + onEditingAliasChange(true); + }, + [onEditingAliasChange], + ); + + const commitAlias = useCallback(() => { + if (editingAlias) { + updateAlias(groupId, editingAlias, direction, aliasValue.trim()); + setEditingAlias(null); + onEditingAliasChange(false); + } + }, [ + editingAlias, + aliasValue, + groupId, + direction, + updateAlias, + onEditingAliasChange, + ]); + + const cancelAliasEdit = useCallback(() => { + setEditingAlias(null); + setAliasValue(""); + onEditingAliasChange(false); + }, [onEditingAliasChange]); + + // Accent colors + const accentDot = direction === "input" ? "bg-cyan-500" : "bg-emerald-500"; + const accentCheck = direction === "input" ? "bg-cyan-500" : "bg-emerald-500"; + const accentText = + direction === "input" ? "text-cyan-400" : "text-emerald-400"; + + // Collapsible node sections + const [collapsedSections, setCollapsedSections] = useState>( + new Set(), + ); + const toggleSection = useCallback((nodeId: string) => { + setCollapsedSections((prev) => { + const next = new Set(prev); + if (next.has(nodeId)) next.delete(nodeId); + else next.add(nodeId); + return next; + }); + }, []); + + return ( +
+ {childNodes.length === 0 ? ( +

+ {t( + "workflow.noChildNodes", + "Add child nodes first to expose their parameters", + )} +

+ ) : ( + childNodes.map((child) => { + const fullLabel = String(child.data?.label ?? child.id.slice(0, 8)); + const shortId = child.id.slice(0, 6); + const childInputDefs = (child.data?.inputDefinitions ?? + []) as PortDefinition[]; + const childOutputDefs = (child.data?.outputDefinitions ?? + []) as PortDefinition[]; + const modelSchema = (child.data?.modelInputSchema ?? []) as Array<{ + name: string; + label?: string; + type?: string; + mediaType?: string; + }>; + const paramDefs = (child.data?.paramDefinitions ?? []) as Array<{ + key: string; + label: string; + dataType?: string; + }>; + + let items: Array<{ key: string; label: string; dataType: string }>; + if (direction === "input") { + const modelItems = modelSchema.map((m) => ({ + key: m.name, + label: + m.label || + m.name + .split("_") + .map((w: string) => w.charAt(0).toUpperCase() + w.slice(1)) + .join(" "), + dataType: m.mediaType ?? m.type ?? "any", + })); + const inputPortItems = childInputDefs.map((d) => ({ + key: d.key, + label: d.label, + dataType: d.dataType, + })); + if (modelItems.length === 0) { + const visibleParams = paramDefs + .filter((d) => !d.key.startsWith("__") && d.key !== "modelId") + .map((d) => ({ + key: d.key, + label: d.label, + dataType: d.dataType ?? "any", + })); + items = [...visibleParams, ...inputPortItems]; + } else { + items = [...modelItems, ...inputPortItems]; + } + } else { + items = childOutputDefs.map((d) => ({ + key: d.key, + label: d.label, + dataType: d.dataType, + })); + } + if (items.length === 0) return null; + + const exposedCount = items.filter((it) => + isExposed(child.id, it.key), + ).length; + const isCollapsed = collapsedSections.has(child.id); + + return ( +
+ {/* Collapsible header */} + + {/* Param list */} + {!isCollapsed && ( +
+ {items.map((item) => { + const exposed = isExposed(child.id, item.key); + const ep = exposed + ? getExposedParam(child.id, item.key) + : null; + const nk = ep?.namespacedKey ?? `${child.id}.${item.key}`; + const isEditingThis = editingAlias === nk; + + return ( +
+ + {exposed && ( +
+ {isEditingThis ? ( + setAliasValue(e.target.value)} + onBlur={commitAlias} + onKeyDown={(e) => { + if (e.key === "Enter") commitAlias(); + if (e.key === "Escape") { + e.stopPropagation(); + e.preventDefault(); + cancelAliasEdit(); + } + }} + placeholder={t( + "workflow.aliasPlaceholder", + "Display name on main graph...", + )} + autoFocus + onClick={(e) => e.stopPropagation()} + /> + ) : ( + + )} +
+ )} +
+ ); + })} +
+ )} +
+ ); + }) + )} +
+ ); +} + +/* ── Main toolbar — owns the picker open/close state and all dismiss logic ── */ + +export function SubgraphToolbar() { + const editingGroupId = useUIStore((s) => s.editingGroupId); + const nodes = useWorkflowStore((s) => s.nodes); + const [showPicker, setShowPicker] = useState<"input" | "output" | null>(null); + const [isEditingAlias, setIsEditingAlias] = useState(false); + const panelRef = useRef(null); + const portalRef = useRef(null); + + const groupNode = editingGroupId + ? nodes.find((n) => n.id === editingGroupId) + : undefined; + const inputCount = useMemo(() => { + try { + const raw = groupNode?.data?.params?.exposedInputs; + const list = + typeof raw === "string" + ? JSON.parse(raw) + : Array.isArray(raw) + ? raw + : []; + return list.length; + } catch { + return 0; + } + }, [groupNode?.data?.params?.exposedInputs]); + const outputCount = useMemo(() => { + try { + const raw = groupNode?.data?.params?.exposedOutputs; + const list = + typeof raw === "string" + ? JSON.parse(raw) + : Array.isArray(raw) + ? raw + : []; + return list.length; + } catch { + return 0; + } + }, [groupNode?.data?.params?.exposedOutputs]); + + const closePicker = useCallback(() => { + setShowPicker(null); + setIsEditingAlias(false); + }, []); + + // Signal picker open state via data attribute so SubgraphBreadcrumb can skip ESC + useEffect(() => { + if (showPicker) { + document.body.setAttribute("data-subgraph-picker-open", "true"); + } else { + document.body.removeAttribute("data-subgraph-picker-open"); + } + return () => { + document.body.removeAttribute("data-subgraph-picker-open"); + }; + }, [showPicker]); + + // Click outside: close picker (capture phase so ReactFlow canvas stopPropagation won't block it) + useEffect(() => { + if (!showPicker) return; + const handler = (e: MouseEvent) => { + const target = e.target as Node; + if (panelRef.current?.contains(target)) return; + if (portalRef.current?.contains(target)) return; + closePicker(); + }; + const timer = setTimeout( + () => document.addEventListener("mousedown", handler, true), + 0, + ); + return () => { + clearTimeout(timer); + document.removeEventListener("mousedown", handler, true); + }; + }, [showPicker, closePicker]); + + // ESC handling — capture phase, before SubgraphBreadcrumb + useEffect(() => { + if (!showPicker) return; + const handler = (e: KeyboardEvent) => { + if (e.key !== "Escape") return; + if (isEditingAlias) return; + e.stopPropagation(); + e.preventDefault(); + closePicker(); + }; + document.addEventListener("keydown", handler, true); + return () => document.removeEventListener("keydown", handler, true); + }, [showPicker, isEditingAlias, closePicker]); + + if (!editingGroupId) return null; + + return ( +
+ {/* Picker popover — above the buttons */} + {showPicker && + createPortal( +
+ +
, + document.body, + )} + + {/* Toolbar buttons */} +
+ + +
+
+ ); +} diff --git a/src/workflow/components/canvas/WorkflowCanvas.tsx b/src/workflow/components/canvas/WorkflowCanvas.tsx index 79a714bf..c6765a06 100644 --- a/src/workflow/components/canvas/WorkflowCanvas.tsx +++ b/src/workflow/components/canvas/WorkflowCanvas.tsx @@ -23,6 +23,7 @@ import ReactFlow, { type Node, type Edge, type NodeChange, + type EdgeChange, type OnSelectionChangeParams, } from "reactflow"; import "reactflow/dist/style.css"; @@ -32,13 +33,15 @@ import { useUIStore } from "../../stores/ui.store"; import { CustomNode } from "./CustomNode"; import { CustomEdge } from "./CustomEdge"; import { AnnotationNode } from "./AnnotationNode"; -import IteratorNodeContainer from "./iterator-node/IteratorNodeContainer"; -import { useIteratorAdoption } from "../../hooks/useIteratorAdoption"; +import GroupNodeContainer from "./group-node/GroupNodeContainer"; +import { GroupIONode } from "./group-node/GroupIONode"; +import { useGroupAdoption } from "../../hooks/useGroupAdoption"; import { ContextMenu, type ContextMenuItem } from "./ContextMenu"; import type { NodeTypeDefinition, NodeCategory, } from "@/workflow/types/node-defs"; +import type { ExposedParam } from "@/workflow/types/workflow"; import { fuzzySearch } from "@/lib/fuzzySearch"; import { getNodeIcon } from "./custom-node/NodeIcons"; import { useModelsStore } from "@/stores/modelsStore"; @@ -55,16 +58,19 @@ import { Search, ChevronDown, } from "lucide-react"; +import { toast } from "@/hooks/useToast"; import { cn } from "@/lib/utils"; +import { SubgraphBreadcrumb } from "./SubgraphBreadcrumb"; +import { SubgraphToolbar } from "./SubgraphToolbar"; const CATEGORY_ORDER: NodeCategory[] = [ "ai-task", "input", "output", "processing", + "control", "free-tool", "ai-generation", - "control", ]; const catDot: Record = { @@ -101,7 +107,12 @@ function saveRecentNodeTypes(types: string[]) { } } -const nodeTypes = { custom: CustomNode, annotation: AnnotationNode, "control/iterator": IteratorNodeContainer }; +const nodeTypes = { + custom: CustomNode, + annotation: AnnotationNode, + "control/iterator": GroupNodeContainer, + "group-io": GroupIONode, +}; const edgeTypes = { custom: CustomEdge }; /** Zoom in/out and fit view controls, positioned on the right of the canvas. */ @@ -367,6 +378,11 @@ export function WorkflowCanvas({ nodeDefs = [] }: WorkflowCanvasProps) { const interactionMode = useUIStore((s) => s.interactionMode); const setInteractionMode = useUIStore((s) => s.setInteractionMode); const showGrid = useUIStore((s) => s.showGrid); + const editingGroupId = useUIStore((s) => s.editingGroupId); + // Track ReactFlow viewport for pinning IO nodes to screen edges + const [rfVpX, setRfVpX] = useState(0); + const [rfVpY, setRfVpY] = useState(0); + const [rfVpZoom, setRfVpZoom] = useState(1); const reactFlowWrapper = useRef(null); const reactFlowInstance = useRef(null); const [contextMenu, setContextMenu] = useState<{ @@ -407,17 +423,205 @@ export function WorkflowCanvas({ nodeDefs = [] }: WorkflowCanvasProps) { handleNodesChangeForAdoption, handleNodeCreated, handleNodesDeleted, - } = useIteratorAdoption(); + } = useGroupAdoption(); /** Wrapped onNodesChange that also triggers iterator adoption detection */ const onNodesChangeWithAdoption = useCallback( (changes: NodeChange[]) => { - onNodesChange(changes); - handleNodesChangeForAdoption(changes); + // Filter out changes for virtual Group IO nodes (they don't exist in the store) + const realChanges = changes.filter((c) => { + const id = (c as any).id; + return !id || !String(id).startsWith("__group-"); + }); + if (realChanges.length > 0) { + onNodesChange(realChanges); + } + handleNodesChangeForAdoption(realChanges); }, [onNodesChange, handleNodesChangeForAdoption], ); + /** Wrapped onEdgesChange that filters out virtual IO edge changes */ + const onEdgesChangeFiltered = useCallback( + (changes: EdgeChange[]) => { + const realChanges = changes.filter((c) => { + const id = (c as any).id; + return !id || !String(id).startsWith("__io-"); + }); + if (realChanges.length > 0) { + onEdgesChange(realChanges); + } + }, + [onEdgesChange], + ); + + /* ── Subgraph filtering: when editing a Group, show only its children ── */ + const displayNodes = useMemo(() => { + if (!editingGroupId) { + // Main view: hide children of all groups (they're inside subgraphs) + return nodes.map((n) => (n.parentNode ? { ...n, hidden: true } : n)); + } + // Subgraph view: show only children of the editing group, as top-level nodes + const childNodes: Node[] = nodes + .filter((n) => n.parentNode === editingGroupId) + .map((n) => ({ + ...n, + parentNode: undefined, + position: { ...n.position }, + })); + + // Inject virtual Group Input / Group Output proxy nodes pinned to screen edges + const groupNode = nodes.find((n) => n.id === editingGroupId); + if (!groupNode) return childNodes; + + const parseExposed = (key: string): ExposedParam[] => { + try { + const raw = groupNode.data?.params?.[key]; + return typeof raw === "string" + ? JSON.parse(raw) + : Array.isArray(raw) + ? raw + : []; + } catch { + return []; + } + }; + const exposedInputs = parseExposed("exposedInputs"); + const exposedOutputs = parseExposed("exposedOutputs"); + + // Convert screen pixel position to flow coordinates so IO nodes stay + // pinned to the left/right edges of the visible viewport. + const el = reactFlowWrapper.current; + const wrapperW = el ? el.clientWidth : 1200; + const wrapperH = el ? el.clientHeight : 800; + const { x: vpX, y: vpY, zoom } = { x: rfVpX, y: rfVpY, zoom: rfVpZoom }; + const screenToFlowX = (sx: number) => (-vpX + sx) / zoom; + const screenToFlowY = (sy: number) => (-vpY + sy) / zoom; + + const inputIOHeight = exposedInputs.length * 32 + 68; + const outputIOHeight = exposedOutputs.length * 32 + 68; + + // Pin to screen edges with margin, vertically centered + const MARGIN = 50; + const result: Node[] = [...childNodes]; + if (exposedInputs.length > 0) { + result.push({ + id: `__group-input-${editingGroupId}`, + type: "group-io", + position: { + x: screenToFlowX(MARGIN), + y: screenToFlowY(wrapperH / 2 - inputIOHeight / 2), + }, + data: { + direction: "input", + exposedParams: exposedInputs, + groupId: editingGroupId, + }, + selectable: false, + draggable: false, + connectable: false, + deletable: false, + }); + } + if (exposedOutputs.length > 0) { + result.push({ + id: `__group-output-${editingGroupId}`, + type: "group-io", + position: { + x: screenToFlowX(wrapperW - MARGIN - 150), + y: screenToFlowY(wrapperH / 2 - outputIOHeight / 2), + }, + data: { + direction: "output", + exposedParams: exposedOutputs, + groupId: editingGroupId, + }, + selectable: false, + draggable: false, + connectable: false, + deletable: false, + }); + } + + return result; + }, [nodes, editingGroupId, rfVpX, rfVpY, rfVpZoom]); + + const displayEdges = useMemo(() => { + if (!editingGroupId) return edges; + const childIds = new Set( + nodes.filter((n) => n.parentNode === editingGroupId).map((n) => n.id), + ); + + // Regular edges between children + const childEdges = edges.filter( + (e) => childIds.has(e.source) && childIds.has(e.target), + ); + + // Remap auto-edges from Group capsule inner handles to virtual IO nodes + const groupNode = nodes.find((n) => n.id === editingGroupId); + if (!groupNode) return childEdges; + + const parseExposed = (key: string): ExposedParam[] => { + try { + const raw = groupNode.data?.params?.[key]; + return typeof raw === "string" + ? JSON.parse(raw) + : Array.isArray(raw) + ? raw + : []; + } catch { + return []; + } + }; + const exposedInputs = parseExposed("exposedInputs"); + const exposedOutputs = parseExposed("exposedOutputs"); + + const ioEdges: typeof edges = []; + const inputProxyId = `__group-input-${editingGroupId}`; + const outputProxyId = `__group-output-${editingGroupId}`; + + // For each auto-edge from Group inner handle → child, remap to virtual IO node + for (const e of edges) { + if (!e.data?.isInternal) continue; + // Input auto-edges: source=groupId, sourceHandle=input-inner-{nk} → target=child + if ( + e.source === editingGroupId && + e.sourceHandle?.startsWith("input-inner-") && + childIds.has(e.target) + ) { + const nk = e.sourceHandle.replace("input-inner-", ""); + if (exposedInputs.some((ep) => ep.namespacedKey === nk)) { + ioEdges.push({ + ...e, + id: `__io-${e.id}`, + source: inputProxyId, + sourceHandle: `group-io-${nk}`, + type: "custom", + }); + } + } + // Output auto-edges: source=child → target=groupId, targetHandle=output-inner-{nk} + if ( + e.target === editingGroupId && + e.targetHandle?.startsWith("output-inner-") && + childIds.has(e.source) + ) { + const nk = e.targetHandle.replace("output-inner-", ""); + if (exposedOutputs.some((ep) => ep.namespacedKey === nk)) { + ioEdges.push({ + ...e, + id: `__io-${e.id}`, + target: outputProxyId, + targetHandle: `group-io-${nk}`, + type: "custom", + }); + } + } + } + + return [...childEdges, ...ioEdges]; + }, [edges, nodes, editingGroupId]); + /** Wrapped removeNode that releases child from iterator before deletion */ const removeNodeWithRelease = useCallback( (nodeId: string) => { @@ -523,6 +727,11 @@ export function WorkflowCanvas({ nodeDefs = [] }: WorkflowCanvasProps) { const current = useUIStore.getState().interactionMode; setInteractionMode(current === "select" ? "hand" : "select"); } + // `/` — toggle Node Palette (add node panel) + if (event.key === "/") { + event.preventDefault(); + useUIStore.getState().toggleNodePalette(); + } if (ctrlOrCmd && event.key === "v") { event.preventDefault(); const copiedNode = localStorage.getItem("copiedNode"); @@ -666,6 +875,12 @@ export function WorkflowCanvas({ nodeDefs = [] }: WorkflowCanvasProps) { (_: React.MouseEvent, node: { id: string }) => selectNode(node.id), [selectNode], ); + + const onNodeDoubleClick = useCallback((_: React.MouseEvent, node: Node) => { + if (node.type === "control/iterator") { + useUIStore.getState().enterGroupEdit(node.id); + } + }, []); const onPaneClick = useCallback(() => { selectNode(null); setContextMenu(null); @@ -755,17 +970,32 @@ export function WorkflowCanvas({ nodeDefs = [] }: WorkflowCanvasProps) { ) as HTMLElement | null; const sourceW = sourceEl?.offsetWidth ?? 380; const GAP = 80; + + // If source node is inside an iterator, its position is relative to the parent. + // Convert to absolute coordinates so the new node is placed correctly. + let absX = sourceNode.position.x; + let absY = sourceNode.position.y; + if (sourceNode.parentNode) { + const parentNode = nodes.find( + (n) => n.id === sourceNode.parentNode, + ); + if (parentNode) { + absX += parentNode.position.x; + absY += parentNode.position.y; + } + } + if (sideInfo.side === "right") { position = { - x: sourceNode.position.x + sourceW + GAP, - y: sourceNode.position.y, + x: absX + sourceW + GAP, + y: absY, }; } else { // Place to the left; estimate new node width as default const newNodeW = (defaultParams.__nodeWidth as number) ?? 380; position = { - x: sourceNode.position.x - newNodeW - GAP, - y: sourceNode.position.y, + x: absX - newNodeW - GAP, + y: absY, }; } } else { @@ -791,15 +1021,25 @@ export function WorkflowCanvas({ nodeDefs = [] }: WorkflowCanvasProps) { // If pendingIteratorParentId is set (e.g. from child node "+" button), // adopt the new node into the iterator const pendingItId = useUIStore.getState().pendingIteratorParentId; - if (pendingItId) { + const editGroupId = useUIStore.getState().editingGroupId; + const adoptParent = pendingItId || editGroupId; + if (adoptParent) { const { adoptNode } = useWorkflowStore.getState(); - adoptNode(pendingItId, newNodeId); - useUIStore.getState().setPendingIteratorParentId(null); + adoptNode(adoptParent, newNodeId); + if (pendingItId) useUIStore.getState().setPendingIteratorParentId(null); } setContextMenu(null); }, - [addNode, contextMenu, nodes, projectMenuPosition, t, recordRecentNodeType, handleNodeCreated], + [ + addNode, + contextMenu, + nodes, + projectMenuPosition, + t, + recordRecentNodeType, + handleNodeCreated, + ], ); const addNodeDisplayDefs = useMemo(() => { @@ -965,10 +1205,12 @@ export function WorkflowCanvas({ nodeDefs = [] }: WorkflowCanvasProps) { // If pendingIteratorParentId is set, adopt the new node into the iterator const pendingItId = useUIStore.getState().pendingIteratorParentId; - if (pendingItId) { + const editGroupId = useUIStore.getState().editingGroupId; + const adoptParent = pendingItId || editGroupId; + if (adoptParent) { const { adoptNode: adopt } = useWorkflowStore.getState(); - adopt(pendingItId, newNodeId); - useUIStore.getState().setPendingIteratorParentId(null); + adopt(adoptParent, newNodeId); + if (pendingItId) useUIStore.getState().setPendingIteratorParentId(null); } selectNode(newNodeId); @@ -1256,26 +1498,40 @@ export function WorkflowCanvas({ nodeDefs = [] }: WorkflowCanvasProps) { // --- Drop from node palette (existing behaviour) --- if (nodeType) { + // Only one trigger node per workflow + if (nodeType.startsWith("trigger/")) { + const existingTrigger = useWorkflowStore + .getState() + .nodes.find( + (n) => + n.type?.startsWith("trigger/") || + n.data?.nodeType?.startsWith("trigger/"), + ); + if (existingTrigger) { + toast({ + title: t( + "workflow.triggerLimitTitle", + "Only one trigger allowed", + ), + description: t( + "workflow.triggerLimitDesc", + "A workflow can only have one trigger node. Remove the existing trigger first.", + ), + variant: "destructive", + }); + return; + } + } + const bounds = reactFlowWrapper.current.getBoundingClientRect(); const position = reactFlowInstance.current.project({ x: event.clientX - bounds.left, y: event.clientY - bounds.top, }); - // Reject drop if it lands inside an iterator boundary (external nodes can't enter) - if (nodeType !== "control/iterator") { - const iteratorNodes = useWorkflowStore.getState().nodes.filter((n) => n.type === "control/iterator"); - for (const it of iteratorNodes) { - const itX = it.position.x; - const itY = it.position.y; - const itW = (it.data?.params?.__nodeWidth as number) ?? 600; - const itH = (it.data?.params?.__nodeHeight as number) ?? 400; - if (position.x >= itX && position.x <= itX + itW && position.y >= itY && position.y <= itY + itH) { - // Don't allow drop inside iterator — user must use the internal Add Node button - return; - } - } - } + // If dropping inside an iterator, adopt the new node into it + // DISABLED: nodes should only be added to groups via subgraph editing mode + // Skip this check when in subgraph editing mode (children shown as top-level) const def = nodeDefs.find((d) => d.type === nodeType); const defaultParams: Record = {}; @@ -1300,6 +1556,12 @@ export function WorkflowCanvas({ nodeDefs = [] }: WorkflowCanvasProps) { ); recordRecentNodeType(nodeType); handleNodeCreated(newNodeId); + // If in subgraph editing mode, adopt the node into the group + if (useUIStore.getState().editingGroupId) { + useWorkflowStore + .getState() + .adoptNode(useUIStore.getState().editingGroupId!, newNodeId); + } selectNode(newNodeId); return; } @@ -1409,6 +1671,59 @@ export function WorkflowCanvas({ nodeDefs = [] }: WorkflowCanvasProps) { return () => window.removeEventListener("workflow:fit-view", handleFitView); }, []); + // FitView when entering/exiting subgraph editing mode + useEffect(() => { + // Delay to let displayNodes update and render (including virtual IO nodes) + const timer = setTimeout(() => { + reactFlowInstance.current?.fitView({ + padding: 0.3, + duration: 300, + minZoom: 0.5, + maxZoom: 1.2, + includeHiddenNodes: false, + }); + }, 200); + return () => clearTimeout(timer); + }, [editingGroupId]); + + // Restore saved viewport when a workflow is loaded + useEffect(() => { + const handleWorkflowLoaded = (e: Event) => { + const wfId = (e as CustomEvent).detail?.workflowId; + if (!wfId || !reactFlowInstance.current) return; + try { + const raw = localStorage.getItem(`wf_viewport_${wfId}`); + if (raw) { + const vp = JSON.parse(raw); + if ( + typeof vp.x === "number" && + typeof vp.y === "number" && + typeof vp.zoom === "number" + ) { + setTimeout(() => { + reactFlowInstance.current?.setViewport(vp, { duration: 0 }); + }, 50); + return; + } + } + } catch { + /* ignore */ + } + // No saved viewport — fit to content + setTimeout(() => { + reactFlowInstance.current?.fitView({ + padding: 0.2, + duration: 0, + minZoom: 0.05, + maxZoom: 1.5, + }); + }, 50); + }; + window.addEventListener("workflow:loaded", handleWorkflowLoaded); + return () => + window.removeEventListener("workflow:loaded", handleWorkflowLoaded); + }, []); + // Listen for "add node" button clicks from CustomNode side buttons useEffect(() => { const handleOpenAddNodeMenu = (e: Event) => { @@ -1417,9 +1732,13 @@ export function WorkflowCanvas({ nodeDefs = [] }: WorkflowCanvasProps) { sideAddRef.current = { sourceNodeId, side }; // If the source node is inside an iterator, set pendingIteratorParentId // so the new node is also created inside the same iterator - const sourceNode = useWorkflowStore.getState().nodes.find((n) => n.id === sourceNodeId); + const sourceNode = useWorkflowStore + .getState() + .nodes.find((n) => n.id === sourceNodeId); if (sourceNode?.parentNode) { - useUIStore.getState().setPendingIteratorParentId(sourceNode.parentNode); + useUIStore + .getState() + .setPendingIteratorParentId(sourceNode.parentNode); } } else { sideAddRef.current = null; @@ -1504,22 +1823,35 @@ export function WorkflowCanvas({ nodeDefs = [] }: WorkflowCanvasProps) { } = useWorkflowStore.getState(); if (currentNodes.length === 0) return; + // ── Separate top-level nodes from group children ── + const topLevelNodes = currentNodes.filter((n) => !n.parentNode); + const childNodesByParent = new Map(); + for (const n of currentNodes) { + if (n.parentNode) { + const arr = childNodesByParent.get(n.parentNode) ?? []; + arr.push(n); + childNodesByParent.set(n.parentNode, arr); + } + } + + // Only use top-level nodes for the DAG layout + // Filter edges to only include connections between top-level nodes + const topLevelIds = new Set(topLevelNodes.map((n) => n.id)); + const topLevelEdges = currentEdges.filter( + (e) => topLevelIds.has(e.source) && topLevelIds.has(e.target), + ); + // ── Measure actual node sizes from DOM ── - // Also check which nodes have execution results (expanded results make nodes taller) const executionResults = useExecutionStore.getState().lastResults; const nodeSize = new Map(); - // Extra height to reserve for nodes whose results panel may not yet be - // reflected in the DOM (e.g. results exist but panel is collapsed). const RESULTS_RESERVE = 260; - for (const n of currentNodes) { + for (const n of topLevelNodes) { const el = document.querySelector( `[data-id="${n.id}"]`, ) as HTMLElement | null; const hasResults = (executionResults[n.id] ?? []).length > 0; if (el) { const measuredH = el.offsetHeight; - // If the node has results but measured height is small, the results - // panel is likely collapsed — reserve space for when it expands. const h = hasResults && measuredH < 300 ? measuredH + RESULTS_RESERVE @@ -1531,14 +1863,14 @@ export function WorkflowCanvas({ nodeDefs = [] }: WorkflowCanvasProps) { } } - // ── Build adjacency ── + // ── Build adjacency (top-level only) ── const outgoing = new Map(); const incoming = new Map(); - for (const n of currentNodes) { + for (const n of topLevelNodes) { outgoing.set(n.id, []); incoming.set(n.id, []); } - for (const e of currentEdges) { + for (const e of topLevelEdges) { outgoing.get(e.source)?.push(e.target); incoming.get(e.target)?.push(e.source); } @@ -1559,7 +1891,7 @@ export function WorkflowCanvas({ nodeDefs = [] }: WorkflowCanvasProps) { layer.set(id, depth); return depth; } - for (const n of currentNodes) assignLayer(n.id); + for (const n of topLevelNodes) assignLayer(n.id); // ── Group by layer ── const layers = new Map(); @@ -1572,16 +1904,12 @@ export function WorkflowCanvas({ nodeDefs = [] }: WorkflowCanvasProps) { const sortedLayerKeys = [...layers.keys()].sort((a, b) => a - b); // ── Barycenter ordering to minimize edge crossings ── - // For each layer (except the first), sort nodes by the average Y position - // of their connected nodes in the previous layer. - // Run multiple passes for better results. const nodeOrder = new Map(); - // Initialize order by original position (top to bottom) for (const l of sortedLayerKeys) { const ids = layers.get(l)!; ids.sort((a, b) => { - const na = currentNodes.find((n) => n.id === a); - const nb = currentNodes.find((n) => n.id === b); + const na = topLevelNodes.find((n) => n.id === a); + const nb = topLevelNodes.find((n) => n.id === b); return (na?.position?.y ?? 0) - (nb?.position?.y ?? 0); }); ids.forEach((id, i) => nodeOrder.set(id, i)); @@ -1627,11 +1955,10 @@ export function WorkflowCanvas({ nodeDefs = [] }: WorkflowCanvasProps) { currentX += maxW + H_GAP; } - // ── Position nodes: center each column vertically ── + // ── Position top-level nodes: center each column vertically ── const changes: NodeChange[] = []; for (const l of sortedLayerKeys) { const ids = layers.get(l)!; - // Calculate total height of this column const heights = ids.map((id) => nodeSize.get(id)?.h ?? 250); const totalHeight = heights.reduce((sum, h) => sum + h, 0) + (ids.length - 1) * V_GAP; @@ -1651,6 +1978,142 @@ export function WorkflowCanvas({ nodeDefs = [] }: WorkflowCanvasProps) { } applyChanges(changes); + // ── Re-arrange children inside their parent groups ── + // Perform a mini left-to-right DAG layout within each group + const TITLE_BAR_H = 40; + const CLAMP_PAD = 20; + const CHILD_H_GAP = 80; + const CHILD_V_GAP = 30; + const childChanges: NodeChange[] = []; + + for (const [parentId, children] of childNodesByParent) { + const parentNode = currentNodes.find((n) => n.id === parentId); + if (!parentNode) continue; + let pw = (parentNode.data?.params?.__nodeWidth as number) ?? 600; + let ph = (parentNode.data?.params?.__nodeHeight as number) ?? 400; + + // Measure child sizes from DOM + const childSize = new Map(); + for (const c of children) { + const el = document.querySelector( + `[data-id="${c.id}"]`, + ) as HTMLElement | null; + if (el) { + childSize.set(c.id, { w: el.offsetWidth, h: el.offsetHeight }); + } else { + const w = (c.data?.params?.__nodeWidth as number) ?? 300; + childSize.set(c.id, { w, h: 250 }); + } + } + + const childIds = new Set(children.map((c) => c.id)); + + // Build internal adjacency (edges between children of this group) + const childOutgoing = new Map(); + const childIncoming = new Map(); + for (const c of children) { + childOutgoing.set(c.id, []); + childIncoming.set(c.id, []); + } + for (const e of currentEdges) { + if (childIds.has(e.source) && childIds.has(e.target)) { + childOutgoing.get(e.source)?.push(e.target); + childIncoming.get(e.target)?.push(e.source); + } + } + + // Assign layers via longest-path + const cLayer = new Map(); + const cVisited = new Set(); + function assignChildLayer(cid: string): number { + if (cLayer.has(cid)) return cLayer.get(cid)!; + if (cVisited.has(cid)) return 0; + cVisited.add(cid); + const parents = childIncoming.get(cid) ?? []; + const depth = + parents.length === 0 + ? 0 + : Math.max(...parents.map((p) => assignChildLayer(p) + 1)); + cLayer.set(cid, depth); + return depth; + } + for (const c of children) assignChildLayer(c.id); + + // Group by layer + const cLayers = new Map(); + for (const [cid, l] of cLayer) { + if (!cLayers.has(l)) cLayers.set(l, []); + cLayers.get(l)!.push(cid); + } + const cSortedKeys = [...cLayers.keys()].sort((a, b) => a - b); + + // Sort within each layer by original Y position + for (const l of cSortedKeys) { + const ids = cLayers.get(l)!; + ids.sort((a, b) => { + const na = children.find((c) => c.id === a); + const nb = children.find((c) => c.id === b); + return (na?.position?.y ?? 0) - (nb?.position?.y ?? 0); + }); + } + + // Compute column X positions + const colX = new Map(); + let cx = CLAMP_PAD; + for (const l of cSortedKeys) { + colX.set(l, cx); + const ids = cLayers.get(l)!; + const maxW = Math.max( + ...ids.map((cid) => childSize.get(cid)?.w ?? 300), + ); + cx += maxW + CHILD_H_GAP; + } + const totalW = cx - CHILD_H_GAP + CLAMP_PAD; + + // Position children within each column + let maxBottom = 0; + for (const l of cSortedKeys) { + const ids = cLayers.get(l)!; + const heights = ids.map((cid) => childSize.get(cid)?.h ?? 250); + let cy = TITLE_BAR_H + CLAMP_PAD; + ids.forEach((cid, i) => { + childChanges.push({ + type: "position", + id: cid, + position: { x: colX.get(l) ?? CLAMP_PAD, y: cy }, + } as NodeChange); + cy += heights[i] + CHILD_V_GAP; + }); + maxBottom = Math.max(maxBottom, cy - CHILD_V_GAP + CLAMP_PAD); + } + + // Expand group if children overflow + let needsUpdate = false; + if (totalW > pw) { + pw = totalW; + needsUpdate = true; + } + if (maxBottom > ph) { + ph = maxBottom; + needsUpdate = true; + } + if (needsUpdate) { + useWorkflowStore.getState().updateNodeParams(parentId, { + ...parentNode.data.params, + __nodeWidth: pw, + __nodeHeight: ph, + }); + } + } + if (childChanges.length > 0) { + applyChanges(childChanges); + } + + // Update bounding boxes for all groups + for (const parentId of childNodesByParent.keys()) { + useWorkflowStore.getState().updateBoundingBox(parentId); + } + // Fit view after layout setTimeout(() => { reactFlowInstance.current?.fitView({ @@ -1670,15 +2133,16 @@ export function WorkflowCanvas({ nodeDefs = [] }: WorkflowCanvasProps) {
{ + if (editingGroupId && viewport) { + setRfVpX(viewport.x); + setRfVpY(viewport.y); + setRfVpZoom(viewport.zoom); + } + }} + onMoveEnd={(_event, viewport) => { + const wfId = useWorkflowStore.getState().workflowId; + if (wfId && viewport) { + try { + localStorage.setItem( + `wf_viewport_${wfId}`, + JSON.stringify({ + x: viewport.x, + y: viewport.y, + zoom: viewport.zoom, + }), + ); + } catch { + /* ignore */ + } + } + // Also update viewport state for IO node pinning + if (viewport) { + setRfVpX(viewport.x); + setRfVpY(viewport.y); + setRfVpZoom(viewport.zoom); + } }} nodeTypes={nodeTypes} edgeTypes={edgeTypes} @@ -1712,7 +2249,6 @@ export function WorkflowCanvas({ nodeDefs = [] }: WorkflowCanvasProps) { deleteKeyCode={null} minZoom={0.05} maxZoom={2.5} - fitView className="bg-background" > {showGrid && ( @@ -1724,6 +2260,8 @@ export function WorkflowCanvas({ nodeDefs = [] }: WorkflowCanvasProps) { /> )} + + {contextMenu && contextMenu.type !== "addNode" && ( s.edges); const updateNodeParams = useWorkflowStore((s) => s.updateNodeParams); const updateNodeData = useWorkflowStore((s) => s.updateNodeData); - const syncExposedParamsOnModelSwitch = useWorkflowStore((s) => s.syncExposedParamsOnModelSwitch); + const syncExposedParamsOnModelSwitch = useWorkflowStore( + (s) => s.syncExposedParamsOnModelSwitch, + ); const workflowId = useWorkflowStore((s) => s.workflowId); const isDirty = useWorkflowStore((s) => s.isDirty); const { runNode, cancelNode, retryNode } = useExecutionStore(); @@ -430,7 +432,9 @@ function CustomNodeComponent({ // Detect if this node is inside an Iterator container const parentIteratorId = useMemo(() => { const thisNode = allNodes.find((n) => n.id === id); - return (thisNode as { parentNode?: string } | undefined)?.parentNode ?? null; + return ( + (thisNode as { parentNode?: string } | undefined)?.parentNode ?? null + ); }, [allNodes, id]); const isInsideIterator = !!parentIteratorId; @@ -738,7 +742,9 @@ function CustomNodeComponent({ className={`w-2 h-2 rounded-full flex-shrink-0 ${ running - ? (isInsideIterator ? "bg-cyan-500 animate-pulse" : "bg-blue-500 animate-pulse") + ? isInsideIterator + ? "bg-cyan-500 animate-pulse" + : "bg-blue-500 animate-pulse" : status === "confirmed" ? "bg-green-500" : status === "error" @@ -941,17 +947,76 @@ function CustomNodeComponent({ )}
- {/* ── Output handle — placed on outer div so React Flow positions it correctly ───── */} - -
- {t("workflow.outputLowercase", "output")} -
+ {/* ── Output handles — one per outputDefinition, or single default ───── */} + {(() => { + // HTTP Response has no outputs + if (data.nodeType === "output/http-response") return null; + // HTTP Trigger: handles are rendered inline by DynamicFieldsEditor + if (data.nodeType === "trigger/http") return null; + + const outputDefs = (data.outputDefinitions ?? []) as Array<{ + key: string; + label?: string; + }>; + if (outputDefs.length > 1) { + // Multiple output ports (e.g. HTTP Trigger with dynamic mappings) + return outputDefs.map((def, idx) => { + const spacing = 30; + const startY = 22; + const y = startY + idx * spacing; + return ( +
+ +
+ {def.label ?? def.key} +
+
+ ); + }); + } + if (outputDefs.length === 1) { + // Single dynamic output port — use its key/label + const def = outputDefs[0]; + return ( + <> + +
+ {def.label ?? def.key} +
+ + ); + } + // Default: single output handle + return ( + <> + +
+ {t("workflow.outputLowercase", "output")} +
+ + ); + })()} {/* ── Side "Add Node" button — right side only, visible on hover / selected ───── */} {(hovered || selected) && ( @@ -959,9 +1024,12 @@ function CustomNodeComponent({
)} + {/* Trigger node hint — explains repeated triggering */} + {(data.nodeType === "trigger/http" || + data.nodeType === "trigger/directory") && ( +
+ + + + + {t( + "workflow.triggerHint", + "This trigger will repeatedly run the downstream workflow each time it fires", + )} + +
+ )} + {/* Media Upload node — special UI */} {data.nodeType === "input/media-upload" && ( <> @@ -463,6 +523,251 @@ export function CustomNodeBody(props: CustomNodeBodyProps) { /> )} + {/* Directory Trigger node — reuse directory picker UI */} + {data.nodeType === "trigger/directory" && ( + { + updateNodeParams(id, { ...data.params, ...updates }); + }} + /> + )} + + {/* HTTP Trigger — port + dynamic output fields editor */} + {data.nodeType === "trigger/http" && ( + <> +
e.stopPropagation()} + > +
+ + {t("workflow.port", "Port")} + + setParam("port", Number(e.target.value))} + className="w-20 px-2 py-1 rounded border border-border bg-background text-[11px] text-right focus:outline-none focus:ring-1 focus:ring-primary/50" + /> +
+
+ { + try { + const raw = data.params.outputFields; + return typeof raw === "string" + ? JSON.parse(raw) + : Array.isArray(raw) + ? raw + : []; + } catch { + return []; + } + })()} + onChange={(fields: FieldConfig[]) => { + // Get old fields to detect key renames + let oldFields: FieldConfig[] = []; + try { + const raw = data.params.outputFields; + oldFields = + typeof raw === "string" + ? JSON.parse(raw) + : Array.isArray(raw) + ? raw + : []; + } catch { + /* ignore */ + } + + // Detect renamed keys and update edge sourceHandles + const { edges: currentEdges } = useWorkflowStore.getState(); + let updatedEdges = currentEdges; + let edgesChanged = false; + for ( + let i = 0; + i < Math.min(oldFields.length, fields.length); + i++ + ) { + const oldKey = oldFields[i]?.key; + const newKey = fields[i]?.key; + if (oldKey && newKey && oldKey !== newKey) { + updatedEdges = updatedEdges.map((e) => { + if (e.source === id && e.sourceHandle === oldKey) { + edgesChanged = true; + return { ...e, sourceHandle: newKey }; + } + return e; + }); + } + } + + if (edgesChanged) { + useWorkflowStore.setState({ edges: updatedEdges }); + } + // Also update outputDefinitions to match the new fields + const newOutputDefs = fields.map((f: FieldConfig) => ({ + key: f.key, + label: f.label || f.key, + dataType: f.type || "any", + required: true, + })); + updateNodeParams(id, { + ...data.params, + outputFields: JSON.stringify(fields), + }); + useWorkflowStore.setState((s) => ({ + nodes: s.nodes.map((n) => + n.id === id + ? { + ...n, + data: { ...n.data, outputDefinitions: newOutputDefs }, + } + : n, + ), + })); + }} + renderHandle={(fieldKey) => ( + + )} + /> + + )} + + {/* HTTP Response — dynamic input fields editor + statusCode */} + {data.nodeType === "output/http-response" && ( + <> + {/* Dynamic input port rows — each responseField becomes a connectable input */} + {(() => { + const dynInputDefs = (data.inputDefinitions ?? []) as Array<{ + key: string; + label: string; + dataType?: string; + required?: boolean; + }>; + // Only show dynamic inputs for http-response (not the static inputDefs) + if (dynInputDefs.length > 0) { + return dynInputDefs.map((inp) => { + const hid = `input-${inp.key}`; + const conn = connectedSet.has(hid); + return ( + +
+ + + {inp.label || inp.key} + {inp.required && ( + * + )} + + {conn && ( + + )} +
+
+ ); + }); + } + return null; + })()} + { + try { + const raw = data.params.responseFields; + return typeof raw === "string" + ? JSON.parse(raw) + : Array.isArray(raw) + ? raw + : []; + } catch { + return []; + } + })()} + onChange={(fields: FieldConfig[]) => { + // Get old fields to detect key renames + let oldFields: FieldConfig[] = []; + try { + const raw = data.params.responseFields; + oldFields = + typeof raw === "string" + ? JSON.parse(raw) + : Array.isArray(raw) + ? raw + : []; + } catch { + /* ignore */ + } + + // Detect renamed keys (same index, different key) and update edge handles + const { edges: currentEdges } = useWorkflowStore.getState(); + let updatedEdges = currentEdges; + let edgesChanged = false; + for ( + let i = 0; + i < Math.min(oldFields.length, fields.length); + i++ + ) { + const oldKey = oldFields[i]?.key; + const newKey = fields[i]?.key; + if (oldKey && newKey && oldKey !== newKey) { + const oldHandle = `input-${oldKey}`; + const newHandle = `input-${newKey}`; + updatedEdges = updatedEdges.map((e) => { + if (e.target === id && e.targetHandle === oldHandle) { + edgesChanged = true; + return { ...e, targetHandle: newHandle }; + } + return e; + }); + } + } + + // Update params + edges together + if (edgesChanged) { + useWorkflowStore.setState({ edges: updatedEdges }); + } + // Also update inputDefinitions to match the new fields + const newInputDefs = fields.map((f: FieldConfig) => ({ + key: f.key, + label: f.label || f.key, + dataType: f.type || "any", + required: true, + })); + updateNodeParams(id, { + ...data.params, + responseFields: JSON.stringify(fields), + }); + // Update node data inputDefinitions directly + useWorkflowStore.setState((s) => ({ + nodes: s.nodes.map((n) => + n.id === id + ? { + ...n, + data: { ...n.data, inputDefinitions: newInputDefs }, + } + : n, + ), + })); + }} + /> + + )} + {isAITask && (
e.stopPropagation()}> { if (data.nodeType === "input/media-upload") return null; if (data.nodeType === "input/directory-import") return null; + if (data.nodeType === "output/http-response") return null; const hid = `input-${inp.key}`; const conn = connectedSet.has(hid); const portFieldConfig = portToFormFieldConfig(inp, data.nodeType); @@ -946,7 +1252,19 @@ export function CustomNodeBody(props: CustomNodeBodyProps) { {data.nodeType !== "input/media-upload" && data.nodeType !== "input/text-input" && data.nodeType !== "input/directory-import" && + data.nodeType !== "trigger/directory" && paramDefs.map((p) => { + // Skip fields managed by DynamicFieldsEditor + if ( + data.nodeType === "trigger/http" && + (p.key === "outputFields" || p.key === "port") + ) + return null; + if ( + data.nodeType === "output/http-response" && + (p.key === "responseFields" || p.key === "statusCode") + ) + return null; const hid = `param-${p.key}`; const canConnect = p.connectable !== false && p.dataType !== undefined; diff --git a/src/workflow/components/canvas/custom-node/CustomNodeInputBodies.tsx b/src/workflow/components/canvas/custom-node/CustomNodeInputBodies.tsx index d90636b2..bbd55a87 100644 --- a/src/workflow/components/canvas/custom-node/CustomNodeInputBodies.tsx +++ b/src/workflow/components/canvas/custom-node/CustomNodeInputBodies.tsx @@ -752,7 +752,11 @@ export function TextInputBody({ DirectoryImportBody ══════════════════════════════════════════════════════════════════════ */ -const MEDIA_TYPE_OPTIONS: Array<{ value: string; label: string; exts: string }> = [ +const MEDIA_TYPE_OPTIONS: Array<{ + value: string; + label: string; + exts: string; +}> = [ { value: "image", label: "Images", exts: ".jpg .png .webp .gif .bmp .svg" }, { value: "video", label: "Videos", exts: ".mp4 .webm .mov .avi .mkv" }, { value: "audio", label: "Audio", exts: ".mp3 .wav .flac .m4a .ogg" }, @@ -760,12 +764,26 @@ const MEDIA_TYPE_OPTIONS: Array<{ value: string; label: string; exts: string }> ]; const MEDIA_EXTS: Record = { - image: [".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".tiff", ".svg", ".avif"], + image: [ + ".jpg", + ".jpeg", + ".png", + ".webp", + ".gif", + ".bmp", + ".tiff", + ".svg", + ".avif", + ], video: [".mp4", ".webm", ".mov", ".avi", ".mkv", ".flv", ".wmv", ".m4v"], audio: [".mp3", ".wav", ".flac", ".m4a", ".ogg", ".aac", ".wma"], all: [], }; -MEDIA_EXTS.all = [...MEDIA_EXTS.image, ...MEDIA_EXTS.video, ...MEDIA_EXTS.audio]; +MEDIA_EXTS.all = [ + ...MEDIA_EXTS.image, + ...MEDIA_EXTS.video, + ...MEDIA_EXTS.audio, +]; export function DirectoryImportBody({ params, @@ -778,7 +796,9 @@ export function DirectoryImportBody({ const dirPath = String(params.directoryPath ?? ""); const mediaType = String(params.mediaType ?? "image"); const [files, setFiles] = useState( - Array.isArray(params.__cachedFiles) ? (params.__cachedFiles as string[]) : [], + Array.isArray(params.__cachedFiles) + ? (params.__cachedFiles as string[]) + : [], ); const [scanning, setScanning] = useState(false); @@ -803,10 +823,15 @@ export function DirectoryImportBody({ setScanning(true); try { const api = (window as unknown as Record).electronAPI as - | { scanDirectory?: (path: string, exts: string[]) => Promise } + | { + scanDirectory?: (path: string, exts: string[]) => Promise; + } | undefined; if (api?.scanDirectory) { - const result = await api.scanDirectory(dir, MEDIA_EXTS[type] ?? MEDIA_EXTS.all); + const result = await api.scanDirectory( + dir, + MEDIA_EXTS[type] ?? MEDIA_EXTS.all, + ); setFiles(result); // Batch all updates in a single call so the store merges them atomically const updates: Record = { @@ -850,8 +875,13 @@ export function DirectoryImportBody({ onParamChange({ directoryPath: e.target.value }); setFiles([]); }} - onBlur={() => { if (dirPath) scanDir(dirPath, mediaType); }} - placeholder={t("workflow.directoryImport.enterPath", "Path or browse...")} + onBlur={() => { + if (dirPath) scanDir(dirPath, mediaType); + }} + placeholder={t( + "workflow.directoryImport.enterPath", + "Path or browse...", + )} className={`${inputCls} flex-1 min-w-0`} onClick={(e) => e.stopPropagation()} /> @@ -897,15 +927,32 @@ export function DirectoryImportBody({ {scanning ? ( - - + + {t("workflow.directoryImport.scanning", "Scanning...")} ) : dirPath && files.length > 0 ? ( - {files.length} {t("workflow.directoryImport.filesFound", "file(s) matched")} + + {files.length}{" "} + {t("workflow.directoryImport.filesFound", "file(s) matched")} + ) : dirPath ? ( - {t("workflow.directoryImport.noFiles", "No matching files")} + + {t("workflow.directoryImport.noFiles", "No matching files")} + ) : ( )} diff --git a/src/workflow/components/canvas/custom-node/DynamicFieldsEditor.tsx b/src/workflow/components/canvas/custom-node/DynamicFieldsEditor.tsx new file mode 100644 index 00000000..0d2bdbd6 --- /dev/null +++ b/src/workflow/components/canvas/custom-node/DynamicFieldsEditor.tsx @@ -0,0 +1,146 @@ +/** + * DynamicFieldsEditor — visual editor for HTTP Trigger output fields + * and HTTP Response input fields. + * + * Each field has: key (field name, also used as label) and type (data type). + * When renderHandle is provided, a ReactFlow handle is rendered inline with each row. + */ +import { useCallback, type ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import { Plus, Trash2 } from "lucide-react"; +import type { PortDataType } from "@/workflow/types/node-defs"; + +export interface FieldConfig { + key: string; + label: string; + type: PortDataType; +} + +interface DynamicFieldsEditorProps { + fields: FieldConfig[]; + onChange: (fields: FieldConfig[]) => void; + direction: "output" | "input"; + /** Render a ReactFlow handle anchor for a given field key */ + renderHandle?: (fieldKey: string) => ReactNode; +} + +const TYPE_OPTIONS: { value: PortDataType; label: string }[] = [ + { value: "text", label: "Text" }, + { value: "url", label: "URL" }, + { value: "image", label: "Image" }, + { value: "video", label: "Video" }, + { value: "audio", label: "Audio" }, + { value: "any", label: "Any" }, +]; + +/** Response fields use plain data types (no media-specific types) */ +const RESPONSE_TYPE_OPTIONS: { value: PortDataType; label: string }[] = [ + { value: "text", label: "String" }, + { value: "any", label: "JSON" }, + { value: "number" as PortDataType, label: "Number" }, +]; + +export function DynamicFieldsEditor({ + fields, + onChange, + direction, + renderHandle, +}: DynamicFieldsEditorProps) { + const { t } = useTranslation(); + + const addField = useCallback(() => { + const idx = fields.length + 1; + const key = `field_${idx}`; + onChange([...fields, { key, label: key, type: "text" }]); + }, [fields, onChange]); + + const removeField = useCallback( + (index: number) => onChange(fields.filter((_, i) => i !== index)), + [fields, onChange], + ); + + const updateField = useCallback( + (index: number, patch: Partial) => { + onChange( + fields.map((f, i) => { + if (i !== index) return f; + const updated = { ...f, ...patch }; + // Keep label in sync with key + if (patch.key !== undefined) updated.label = patch.key; + return updated; + }), + ); + }, + [fields, onChange], + ); + + const dirLabel = + direction === "output" + ? t("workflow.httpTriggerFields", "API Input Fields") + : t("workflow.httpResponseFields", "API Response Fields"); + + return ( +
e.stopPropagation()} + > +
+ + {dirLabel} + + +
+ {fields.length === 0 && ( +
+ {t( + "workflow.noFieldsHint", + "No fields defined. Click Add to create one.", + )} +
+ )} + {fields.map((field, idx) => ( +
+ + updateField(idx, { key: e.target.value.replace(/\s/g, "_") }) + } + placeholder="field name" + className="flex-1 min-w-0 px-2 py-1 rounded border border-border bg-background text-[11px] focus:outline-none focus:ring-1 focus:ring-primary/50" + /> + + + {renderHandle && renderHandle(field.key)} +
+ ))} +
+ ); +} diff --git a/src/workflow/components/canvas/custom-node/IteratorExposeBar.tsx b/src/workflow/components/canvas/custom-node/IteratorExposeBar.tsx deleted file mode 100644 index 722fc09d..00000000 --- a/src/workflow/components/canvas/custom-node/IteratorExposeBar.tsx +++ /dev/null @@ -1,237 +0,0 @@ -/** - * IteratorExposeBar — Compact bar shown at the bottom of child nodes - * inside an Iterator container. Provides quick expose/unexpose toggles - * for the node's parameters and ports. - */ -import { useMemo, useState, useCallback } from "react"; -import { useTranslation } from "react-i18next"; -import { useWorkflowStore } from "../../../stores/workflow.store"; -import type { ParamDefinition, PortDefinition } from "@/workflow/types/node-defs"; -import type { ExposedParam } from "@/workflow/types/workflow"; - -interface IteratorExposeBarProps { - nodeId: string; - nodeLabel: string; - parentIteratorId: string; - paramDefs: ParamDefinition[]; - inputDefs: PortDefinition[]; - outputDefs: PortDefinition[]; -} - -export function IteratorExposeBar({ - nodeId, - nodeLabel, - parentIteratorId, - paramDefs, - inputDefs, - outputDefs, -}: IteratorExposeBarProps) { - const { t } = useTranslation(); - const [expanded, setExpanded] = useState(false); - const nodes = useWorkflowStore((s) => s.nodes); - const exposeParam = useWorkflowStore((s) => s.exposeParam); - const unexposeParam = useWorkflowStore((s) => s.unexposeParam); - - const parentIterator = nodes.find((n) => n.id === parentIteratorId); - const iteratorParams = (parentIterator?.data?.params ?? {}) as Record; - - const exposedInputs: ExposedParam[] = useMemo(() => { - try { - const raw = iteratorParams.exposedInputs; - return typeof raw === "string" ? JSON.parse(raw) : Array.isArray(raw) ? raw : []; - } catch { return []; } - }, [iteratorParams.exposedInputs]); - - const exposedOutputs: ExposedParam[] = useMemo(() => { - try { - const raw = iteratorParams.exposedOutputs; - return typeof raw === "string" ? JSON.parse(raw) : Array.isArray(raw) ? raw : []; - } catch { return []; } - }, [iteratorParams.exposedOutputs]); - - const subNodeLabel = nodeLabel; - const getNamespacedKey = (paramKey: string) => `${subNodeLabel}.${paramKey}`; - - const isExposed = useCallback( - (paramKey: string, direction: "input" | "output") => { - const nk = getNamespacedKey(paramKey); - const list = direction === "input" ? exposedInputs : exposedOutputs; - return list.some((p) => p.namespacedKey === nk && p.subNodeId === nodeId); - }, - [exposedInputs, exposedOutputs, nodeId, subNodeLabel], - ); - - const handleToggle = useCallback( - (paramKey: string, direction: "input" | "output", dataType: string) => { - const nk = getNamespacedKey(paramKey); - if (isExposed(paramKey, direction)) { - unexposeParam(parentIteratorId, nk, direction); - } else { - const param: ExposedParam = { - subNodeId: nodeId, - subNodeLabel, - paramKey, - namespacedKey: nk, - direction, - dataType: dataType as ExposedParam["dataType"], - }; - exposeParam(parentIteratorId, param); - } - }, - [isExposed, exposeParam, unexposeParam, parentIteratorId, nodeId, subNodeLabel], - ); - - // Filter to user-visible params only - const visibleParamDefs = paramDefs.filter((d) => !d.key.startsWith("__")); - - // Count total exposed - const exposedCount = useMemo(() => { - let count = 0; - for (const d of visibleParamDefs) { - if (isExposed(d.key, "input")) count++; - } - for (const d of inputDefs) { - if (isExposed(d.key, "input")) count++; - } - for (const d of outputDefs) { - if (isExposed(d.key, "output")) count++; - } - return count; - }, [visibleParamDefs, inputDefs, outputDefs, isExposed]); - - const hasExposableItems = visibleParamDefs.length > 0 || inputDefs.length > 0 || outputDefs.length > 0; - if (!hasExposableItems) return null; - - return ( -
- {/* Toggle bar */} - - - {/* Expanded expose controls */} - {expanded && ( -
- {/* Parameters as inputs */} - {visibleParamDefs.length > 0 && ( -
-
- {t("workflow.params", "Parameters")} -
- {visibleParamDefs.map((def) => ( - handleToggle(def.key, "input", def.dataType ?? "any")} - /> - ))} -
- )} - - {/* Input ports as inputs */} - {inputDefs.length > 0 && ( -
-
- {t("workflow.inputPorts", "Input Ports")} -
- {inputDefs.map((port) => ( - handleToggle(port.key, "input", port.dataType)} - /> - ))} -
- )} - - {/* Output ports as outputs */} - {outputDefs.length > 0 && ( -
-
- {t("workflow.outputPorts", "Output Ports")} -
- {outputDefs.map((port) => ( - handleToggle(port.key, "output", port.dataType)} - /> - ))} -
- )} -
- )} -
- ); -} - -/* ── Single toggle row ─────────────────────────────────────────────── */ - -function ExposeToggleRow({ - label, - exposed, - direction, - onToggle, -}: { - label: string; - exposed: boolean; - direction: "input" | "output"; - onToggle: () => void; -}) { - const { t } = useTranslation(); - const dirIcon = direction === "input" ? "←" : "→"; - - return ( -
- - {dirIcon} {label} - - -
- ); -} diff --git a/src/workflow/components/canvas/custom-node/NodeIcons.tsx b/src/workflow/components/canvas/custom-node/NodeIcons.tsx index 9f3d7684..57f4f1f9 100644 --- a/src/workflow/components/canvas/custom-node/NodeIcons.tsx +++ b/src/workflow/components/canvas/custom-node/NodeIcons.tsx @@ -24,6 +24,9 @@ import { ListFilter, FolderOpen, Repeat, + FolderSearch, + Globe, + Send, type LucideIcon, } from "lucide-react"; @@ -55,6 +58,11 @@ const NODE_ICON_MAP: Record = { "processing/select": ListFilter, // Control "control/iterator": Repeat, + // Trigger + "trigger/directory": FolderSearch, + "trigger/http": Globe, + // Output + "output/http-response": Send, }; export function getNodeIcon(nodeType: string): LucideIcon | null { diff --git a/src/workflow/components/canvas/group-node/GroupIONode.tsx b/src/workflow/components/canvas/group-node/GroupIONode.tsx new file mode 100644 index 00000000..e1a233e4 --- /dev/null +++ b/src/workflow/components/canvas/group-node/GroupIONode.tsx @@ -0,0 +1,163 @@ +/** + * GroupIONode — virtual proxy nodes shown in subgraph editing mode. + * + * Inspired by ComfyUI's IO rail concept but with our own twist: + * A vertical accent line runs through all port dots, creating a + * "connector strip" feel. Port labels float beside the line. + * A small direction indicator sits at the top. No card background — + * the node is intentionally minimal so it doesn't compete with + * real child nodes on the canvas. + * + * Group Input: labels on LEFT, line + handles on RIGHT → source + * Group Output: handles on LEFT ← line, labels on RIGHT + */ +import { memo, useMemo } from "react"; +import { Handle, Position, type NodeProps } from "reactflow"; +import { useTranslation } from "react-i18next"; +import type { ExposedParam } from "@/workflow/types/workflow"; + +const DOT = 10; +const PORT_SPACING = 32; +const HEADER_HEIGHT = 28; +const LINE_EXTEND = 20; // how far the line extends beyond first/last port + +export interface GroupIONodeData { + direction: "input" | "output"; + exposedParams: ExposedParam[]; + groupId: string; +} + +function GroupIONodeComponent({ data }: NodeProps) { + const { t } = useTranslation(); + const { direction, exposedParams } = data; + const isInput = direction === "input"; + + const ports = useMemo( + () => + exposedParams.map((ep) => { + if (ep.alias) { + return { key: ep.namespacedKey, label: ep.alias, ep }; + } + const label = ep.paramKey + .split("_") + .map((w: string) => w.charAt(0).toUpperCase() + w.slice(1)) + .join(" "); + return { key: ep.namespacedKey, label, ep }; + }), + [exposedParams], + ); + + if (ports.length === 0) return null; + + // Total height of the port area + const totalPortHeight = ports.length * PORT_SPACING; + // The vertical line position (x offset within the node) + const lineX = isInput ? 130 : 10; + + return ( +
+ {/* Direction label at top */} +
+ {isInput + ? t("workflow.groupInput", "Group Input") + : t("workflow.groupOutput", "Group Output")} +
+ + {/* Vertical accent line — extends above first port and below last port */} +
+ + {/* Port rows */} + {ports.map((port, i) => { + const y = HEADER_HEIGHT + i * PORT_SPACING + PORT_SPACING / 2; + + return isInput ? ( +
+ {/* Label on the left side */} + + {port.label} + + {/* Handle dot on the line */} + +
+ ) : ( +
+ {/* Handle dot on the line */} + + {/* Label on the right side */} + + {port.label} + +
+ ); + })} +
+ ); +} + +export const GroupIONode = memo(GroupIONodeComponent); diff --git a/src/workflow/components/canvas/group-node/GroupNodeContainer.tsx b/src/workflow/components/canvas/group-node/GroupNodeContainer.tsx new file mode 100644 index 00000000..c6a51243 --- /dev/null +++ b/src/workflow/components/canvas/group-node/GroupNodeContainer.tsx @@ -0,0 +1,963 @@ +/** + * IteratorNodeContainer — ReactFlow custom node for the Iterator container. + * + * Exposed params appear as "capsule" handles on the left/right border: + * [●──param name──●] + * + * IN capsules (left border): + * Left dot = external target (outside nodes connect here) + * Right dot = internal source (auto-connected to child node input) + * + * OUT capsules (right border): + * Left dot = internal target (auto-connected from child node output) + * Right dot = external source (outside nodes connect from here) + * + * When a param is exposed via the picker, an internal edge is auto-created + * between the capsule's inner handle and the child node's corresponding handle. + */ +import React, { + memo, + useCallback, + useRef, + useState, + useMemo, + useEffect, +} from "react"; +import { useTranslation } from "react-i18next"; +import { + Handle, + Position, + useReactFlow, + useUpdateNodeInternals, + type NodeProps, +} from "reactflow"; +import { useWorkflowStore } from "../../../stores/workflow.store"; +import { useUIStore } from "../../../stores/ui.store"; +import { useExecutionStore } from "../../../stores/execution.store"; +import type { PortDefinition } from "@/workflow/types/node-defs"; +import type { NodeStatus } from "@/workflow/types/execution"; +import type { ExposedParam } from "@/workflow/types/workflow"; +import { + Tooltip, + TooltipTrigger, + TooltipContent, +} from "@/components/ui/tooltip"; +import { ChevronDown, ChevronUp, Pencil, FolderInput } from "lucide-react"; +import { ImportWorkflowDialog } from "../ImportWorkflowDialog"; + +/* ── constants ─────────────────────────────────────────────────────── */ + +const MIN_ITERATOR_WIDTH = 600; +const MIN_ITERATOR_HEIGHT = 400; +const CHILD_PADDING = 40; +const TITLE_BAR_HEIGHT = 40; +const CAPSULE_HEIGHT = 28; +const CAPSULE_GAP = 6; +const CAPSULE_TOP_OFFSET = TITLE_BAR_HEIGHT + 12; +const HANDLE_DOT = 10; +const CAPSULE_LABEL_WIDTH = 110; // fixed width for capsule label area + +/* ── types ─────────────────────────────────────────────────────────── */ + +export interface IteratorNodeData { + nodeType: string; + label: string; + params: Record; + childNodeIds?: string[]; + inputDefinitions?: PortDefinition[]; + outputDefinitions?: PortDefinition[]; + paramDefinitions?: unknown[]; +} + +/* ── Capsule handle style helpers ──────────────────────────────────── */ + +const dotStyle = (connected: boolean): React.CSSProperties => ({ + width: HANDLE_DOT, + height: HANDLE_DOT, + borderRadius: "50%", + border: "2px solid hsl(var(--primary))", + background: connected ? "hsl(var(--primary))" : "hsl(var(--card))", + minWidth: HANDLE_DOT, + minHeight: HANDLE_DOT, + position: "relative" as const, + top: "auto", + left: "auto", + right: "auto", + bottom: "auto", + transform: "none", + zIndex: 40, +}); + +/* ── main component ────────────────────────────────────────────────── */ + +function IteratorNodeContainerComponent({ + id, + data, + selected, +}: NodeProps) { + const { t } = useTranslation(); + const nodeRef = useRef(null); + const [hovered, setHovered] = useState(false); + const [showImportDialog, setShowImportDialog] = useState(false); + const [editingName, setEditingName] = useState(false); + const [nameValue, setNameValue] = useState(""); + const nameInputRef = useRef(null); + const { setNodes } = useReactFlow(); + const updateNodeInternals = useUpdateNodeInternals(); + const updateNodeParams = useWorkflowStore((s) => s.updateNodeParams); + const updateNodeData = useWorkflowStore((s) => s.updateNodeData); + const workflowId = useWorkflowStore((s) => s.workflowId); + const removeNode = useWorkflowStore((s) => s.removeNode); + const edges = useWorkflowStore((s) => s.edges); + const status = useExecutionStore( + (s) => s.nodeStatuses[id] ?? "idle", + ) as NodeStatus; + const progress = useExecutionStore((s) => s.progressMap[id]); + const errorMessage = useExecutionStore((s) => s.errorMessages[id]); + const { runNode, cancelNode, retryNode, continueFrom } = useExecutionStore(); + const running = status === "running"; + + const collapsed = + (data.params?.__nodeCollapsed as boolean | undefined) ?? false; + const shortId = id.slice(0, 8); + + const inputDefs = useMemo(() => { + // Reconstruct from exposedInputs params (source of truth) to be resilient + // against data.inputDefinitions being reset by other state updates + try { + const raw = data.params?.exposedInputs; + const list: ExposedParam[] = + typeof raw === "string" + ? JSON.parse(raw) + : Array.isArray(raw) + ? raw + : []; + return list.map((ep): PortDefinition => { + if (ep.alias) { + return { + key: ep.namespacedKey, + label: ep.alias, + dataType: ep.dataType, + required: false, + }; + } + const readableParam = ep.paramKey + .split("_") + .map((w: string) => w.charAt(0).toUpperCase() + w.slice(1)) + .join(" "); + // Use node label (or short ID) for disambiguation + const nodeLabel = ep.subNodeLabel || ep.subNodeId.slice(0, 6); + const shortLabel = nodeLabel.includes("/") + ? nodeLabel.split("/").pop()! + : nodeLabel; + return { + key: ep.namespacedKey, + label: `${readableParam} · ${shortLabel}`, + dataType: ep.dataType, + required: false, + }; + }); + } catch { + return data.inputDefinitions ?? []; + } + }, [data.params?.exposedInputs, data.inputDefinitions]); + + const outputDefs = useMemo(() => { + try { + const raw = data.params?.exposedOutputs; + const list: ExposedParam[] = + typeof raw === "string" + ? JSON.parse(raw) + : Array.isArray(raw) + ? raw + : []; + return list.map((ep): PortDefinition => { + if (ep.alias) { + return { + key: ep.namespacedKey, + label: ep.alias, + dataType: ep.dataType, + required: false, + }; + } + const readableParam = ep.paramKey + .split("_") + .map((w: string) => w.charAt(0).toUpperCase() + w.slice(1)) + .join(" "); + const nodeLabel = ep.subNodeLabel || ep.subNodeId.slice(0, 6); + const shortLabel = nodeLabel.includes("/") + ? nodeLabel.split("/").pop()! + : nodeLabel; + return { + key: ep.namespacedKey, + label: `${readableParam} · ${shortLabel}`, + dataType: ep.dataType, + required: false, + }; + }); + } catch { + return data.outputDefinitions ?? []; + } + }, [data.params?.exposedOutputs, data.outputDefinitions]); + const childNodeIds = data.childNodeIds ?? []; + const hasChildren = childNodeIds.length > 0; + + /* ── Force ReactFlow to recalculate handle positions when ports change ── */ + const portFingerprint = useMemo( + () => + inputDefs.map((d) => d.key).join(",") + + "|" + + outputDefs.map((d) => d.key).join(","), + [inputDefs, outputDefs], + ); + useEffect(() => { + // After new handles render, tell ReactFlow to update its internal handle cache + requestAnimationFrame(() => updateNodeInternals(id)); + }, [portFingerprint, id, updateNodeInternals]); + + /* ── Collapse toggle ───────────────────────────────────────────── */ + const setCollapsed = useCallback( + (value: boolean) => { + updateNodeParams(id, { ...data.params, __nodeCollapsed: value }); + // Hide/show child nodes when collapsing/expanding + const childIds = data.childNodeIds ?? []; + if (childIds.length > 0) { + setNodes((nds) => + nds.map((n) => + childIds.includes(n.id) ? { ...n, hidden: value } : n, + ), + ); + } + }, + [id, data.params, data.childNodeIds, updateNodeParams, setNodes], + ); + const toggleCollapsed = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + setCollapsed(!collapsed); + }, + [collapsed, setCollapsed], + ); + + /* ── Effective size ─────────────────────────────────────────────── */ + const COMPACT_WIDTH = 320; + const maxCapsules = Math.max(inputDefs.length, outputDefs.length); + + /* ── Sync child hidden state with collapsed ────────────────────── */ + useEffect(() => { + const childIds = data.childNodeIds ?? []; + if (childIds.length === 0) return; + setNodes((nds) => + nds.map((n) => + childIds.includes(n.id) ? { ...n, hidden: collapsed } : n, + ), + ); + }, [collapsed, data.childNodeIds, setNodes]); + + /* ── Auto-expand: observe child DOM size changes ───────────────── */ + useEffect(() => { + if (collapsed || childNodeIds.length === 0) return; + const updateBB = useWorkflowStore.getState().updateBoundingBox; + const observer = new ResizeObserver(() => { + updateBB(id); + }); + for (const cid of childNodeIds) { + const el = document.querySelector( + `[data-id="${cid}"]`, + ) as HTMLElement | null; + if (el) observer.observe(el); + } + return () => observer.disconnect(); + }, [id, childNodeIds, collapsed]); + + /* ── Actions ───────────────────────────────────────────────────── */ + const onRun = useCallback( + async (e: React.MouseEvent) => { + e.stopPropagation(); + if (running) cancelNode(workflowId ?? "", id); + else runNode(workflowId ?? "", id); + }, + [running, workflowId, id, runNode, cancelNode], + ); + + const onRunFromHere = useCallback( + async (e: React.MouseEvent) => { + e.stopPropagation(); + continueFrom(workflowId ?? "", id); + }, + [workflowId, id, continueFrom], + ); + + const onDelete = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + removeNode(id); + }, + [removeNode, id], + ); + + /* ── Inline name editing ───────────────────────────────────────── */ + const startEditingName = useCallback(() => { + setNameValue(data.label || ""); + setEditingName(true); + setTimeout(() => nameInputRef.current?.select(), 0); + }, [data.label]); + + const commitName = useCallback(() => { + setEditingName(false); + const trimmed = nameValue.trim(); + if (trimmed && trimmed !== data.label) { + updateNodeData(id, { label: trimmed }); + } + }, [nameValue, data.label, id, updateNodeData]); + + const cancelEditingName = useCallback(() => { + setEditingName(false); + }, []); + + /* ── Capsule vertical position ─────────────────────────────────── */ + const getCapsuleTop = (index: number) => + CAPSULE_TOP_OFFSET + index * (CAPSULE_HEIGHT + CAPSULE_GAP); + + /* ── Exposed param lookup — maps namespacedKey → ExposedParam for tooltip info ── */ + const exposedParamMap = useMemo(() => { + const map = new Map(); + for (const key of ["exposedInputs", "exposedOutputs"] as const) { + try { + const raw = data.params?.[key]; + const list: ExposedParam[] = + typeof raw === "string" + ? JSON.parse(raw) + : Array.isArray(raw) + ? raw + : []; + for (const ep of list) map.set(ep.namespacedKey, ep); + } catch { + /* ignore */ + } + } + return map; + }, [data.params]); + + /* ── Check if a handle has a connected edge ────────────────────── */ + const isHandleConnected = useCallback( + (handleId: string, type: "source" | "target") => + edges.some((e) => + type === "source" + ? e.source === id && e.sourceHandle === handleId + : e.target === id && e.targetHandle === handleId, + ), + [edges, id], + ); + + /* ── Drop target hint ────────────────────────────────────────────── */ + const dropTarget = useUIStore((s) => s.iteratorDropTarget); + const isAdoptTarget = + dropTarget?.iteratorId === id && dropTarget.mode === "adopt"; + const isReleaseTarget = + dropTarget?.iteratorId === id && dropTarget.mode === "release"; + + return ( +
setHovered(true)} + onMouseLeave={() => { + setHovered(false); + }} + className="relative" + > + {/* Invisible hover extension above */} +
+ + {/* ── Hover toolbar ──────────────────────────────────────── */} + {hovered && ( +
+ {running ? ( + + ) : ( + <> + + + + + )} +
+ )} + + {/* ── Main container ─────────────────────────────────────── */} +
+ {/* ── Title bar ──────────────────────────────────────── */} +
+ + +
+ + + + + + +
+ {editingName ? ( + setNameValue(e.target.value)} + onBlur={commitName} + onKeyDown={(e) => { + if (e.key === "Enter") commitName(); + if (e.key === "Escape") cancelEditingName(); + }} + autoFocus + /> + ) : ( + { + e.stopPropagation(); + startEditingName(); + }} + title={t( + "workflow.doubleClickToRename", + "Double-click to rename", + )} + > + {data.label || t("workflow.group", "Group")} + + )} + + {shortId} + + {/* Drop target capsule tag */} + {isAdoptTarget && ( + + + + + + {t("workflow.dropToAddToGroup", "Release to add to Group")} + + )} + {isReleaseTarget && ( + + + + + + {t( + "workflow.dropToRemoveFromGroup", + "Release to remove from Group", + )} + + )} +
+
+ + {/* ── Running progress bar ───────────────────────────── */} + {running && !collapsed && ( +
+
+ + + + + {progress?.message || t("workflow.running", "Running...")} + + {progress && ( + + {Math.round(progress.progress)}% + + )} +
+
+
+
+
+ )} + + {/* ── Error details + Retry ──────────────────────────── */} + {status === "error" && errorMessage && !collapsed && ( +
+
+ + ⚠ + + + {errorMessage} + + +
+
+ )} + + {/* ── Compact body: child summary + action buttons ── */} + {!collapsed && ( +
0 + ? maxCapsules * (CAPSULE_HEIGHT + CAPSULE_GAP) + 4 + : 0, + }} + > + {/* Child count summary */} +
+ + + + + + + + {hasChildren + ? t("workflow.childNodesCount", "{{count}} child node(s)", { + count: childNodeIds.length, + }) + : t("workflow.iteratorEmpty", "No child nodes yet")} + +
+ + {/* Action buttons */} +
+ + +
+
+ )} + + {/* Import workflow dialog */} + {showImportDialog && ( + setShowImportDialog(false)} + /> + )} + + {/* Collapsed child count */} + {collapsed && hasChildren && ( +
+ {t("workflow.childNodesCount", "{{count}} child node(s)", { + count: childNodeIds.length, + })} +
+ )} + + {/* Resize handles removed — compact view doesn't need resizing */} +
+ + {/* ── LEFT SIDE: exposed input capsules ──────────────────── */} + {!collapsed && + inputDefs.map((port, i) => { + const top = getCapsuleTop(i); + const extHandleId = `input-${port.key}`; + const intHandleId = `input-inner-${port.key}`; + const extConnected = isHandleConnected(extHandleId, "target"); + const ep = exposedParamMap.get(port.key); + const tooltipText = ep + ? `${ep.paramKey + .split("_") + .map((w: string) => w.charAt(0).toUpperCase() + w.slice(1)) + .join(" ")} — ${ep.subNodeLabel}` + : port.label; + return ( + + {/* External target handle — on the left border */} + + {/* Capsule label */} + + +
+
+ + {port.label} + +
+
+
+ + {tooltipText} + +
+ {/* Internal source handle — hidden but kept for auto-edge wiring */} + +
+ ); + })} + + {/* ── RIGHT SIDE: exposed output capsules ──────────────────── */} + {!collapsed && + outputDefs.map((port, i) => { + const top = getCapsuleTop(i); + const intHandleId = `output-inner-${port.key}`; + const extHandleId = `output-${port.key}`; + const extConnected = isHandleConnected(extHandleId, "source"); + const ep = exposedParamMap.get(port.key); + const tooltipText = ep + ? `${ep.paramKey + .split("_") + .map((w: string) => w.charAt(0).toUpperCase() + w.slice(1)) + .join(" ")} — ${ep.subNodeLabel}` + : port.label; + // Compute left-based positions so ReactFlow handle lookup is reliable + const capsuleLabelLeft = + COMPACT_WIDTH - HANDLE_DOT - CAPSULE_LABEL_WIDTH; + const extHandleLeft = COMPACT_WIDTH - HANDLE_DOT / 2; + return ( + + {/* Internal target handle — hidden but kept for auto-edge wiring */} + + {/* Capsule label */} + + +
+
+ + {port.label} + +
+
+
+ + {tooltipText} + +
+ {/* External source handle — on the right border */} + +
+ ); + })} + + {/* ── External "+" button — right side ── */} + {(hovered || selected) && ( + + + + + + {t("workflow.addDownstreamNode", "Add Downstream Node")} + + + )} +
+ ); +} + +export default memo(IteratorNodeContainerComponent); +export { MIN_ITERATOR_WIDTH, MIN_ITERATOR_HEIGHT, CHILD_PADDING }; diff --git a/src/workflow/components/canvas/iterator-node/IteratorNodeContainer.tsx b/src/workflow/components/canvas/iterator-node/IteratorNodeContainer.tsx deleted file mode 100644 index 57408f08..00000000 --- a/src/workflow/components/canvas/iterator-node/IteratorNodeContainer.tsx +++ /dev/null @@ -1,977 +0,0 @@ -/** - * IteratorNodeContainer — ReactFlow custom node for the Iterator container. - * - * Exposed params appear as "capsule" handles on the left/right border: - * [●──param name──●] - * - * IN capsules (left border): - * Left dot = external target (outside nodes connect here) - * Right dot = internal source (auto-connected to child node input) - * - * OUT capsules (right border): - * Left dot = internal target (auto-connected from child node output) - * Right dot = external source (outside nodes connect from here) - * - * When a param is exposed via the picker, an internal edge is auto-created - * between the capsule's inner handle and the child node's corresponding handle. - */ -import React, { - memo, - useCallback, - useRef, - useState, - useMemo, - useEffect, -} from "react"; -import { createPortal } from "react-dom"; -import { useTranslation } from "react-i18next"; -import { Handle, Position, useReactFlow, useUpdateNodeInternals, type NodeProps } from "reactflow"; -import { useWorkflowStore } from "../../../stores/workflow.store"; -import { useUIStore } from "../../../stores/ui.store"; -import { useExecutionStore } from "../../../stores/execution.store"; -import type { PortDefinition } from "@/workflow/types/node-defs"; -import type { NodeStatus } from "@/workflow/types/execution"; -import type { ExposedParam } from "@/workflow/types/workflow"; -import { - Tooltip, - TooltipTrigger, - TooltipContent, -} from "@/components/ui/tooltip"; -import { ChevronDown, ChevronUp } from "lucide-react"; - -/* ── constants ─────────────────────────────────────────────────────── */ - -const MIN_ITERATOR_WIDTH = 600; -const MIN_ITERATOR_HEIGHT = 400; -const CHILD_PADDING = 40; -const TITLE_BAR_HEIGHT = 40; -const CAPSULE_HEIGHT = 28; -const CAPSULE_GAP = 6; -const CAPSULE_TOP_OFFSET = TITLE_BAR_HEIGHT + 56; -const HANDLE_DOT = 10; -const CAPSULE_LABEL_WIDTH = 110; // fixed width for capsule label area - -/* ── types ─────────────────────────────────────────────────────────── */ - -export interface IteratorNodeData { - nodeType: string; - label: string; - params: Record; - childNodeIds?: string[]; - inputDefinitions?: PortDefinition[]; - outputDefinitions?: PortDefinition[]; - paramDefinitions?: unknown[]; -} - -/* ── Gear icon ─────────────────────────────────────────────────────── */ - -const GearIcon = ({ size = 12 }: { size?: number }) => ( - - - - -); - -/* ── Expose-param picker — floats above the iterator ───────────────── */ - -function ExposeParamPicker({ - iteratorId, - direction, - onClose, -}: { - iteratorId: string; - direction: "input" | "output"; - onClose: () => void; -}) { - const { t } = useTranslation(); - const nodes = useWorkflowStore((s) => s.nodes); - const exposeParam = useWorkflowStore((s) => s.exposeParam); - const unexposeParam = useWorkflowStore((s) => s.unexposeParam); - - const iteratorNode = nodes.find((n) => n.id === iteratorId); - const iteratorParams = (iteratorNode?.data?.params ?? {}) as Record; - const childNodes = nodes.filter((n) => n.parentNode === iteratorId); - - const exposedList: ExposedParam[] = useMemo(() => { - const key = direction === "input" ? "exposedInputs" : "exposedOutputs"; - try { - const raw = iteratorParams[key]; - return typeof raw === "string" ? JSON.parse(raw) : Array.isArray(raw) ? raw : []; - } catch { return []; } - }, [iteratorParams, direction]); - - const isExposed = useCallback( - (subNodeId: string, paramKey: string) => - exposedList.some((p) => p.subNodeId === subNodeId && p.paramKey === paramKey), - [exposedList], - ); - - const handleToggle = useCallback( - (subNodeId: string, subNodeLabel: string, paramKey: string, dataType: string) => { - const nk = `${subNodeLabel}.${paramKey}`; - if (isExposed(subNodeId, paramKey)) { - unexposeParam(iteratorId, nk, direction); - } else { - exposeParam(iteratorId, { - subNodeId, subNodeLabel, paramKey, namespacedKey: nk, direction, - dataType: dataType as ExposedParam["dataType"], - }); - } - }, - [isExposed, exposeParam, unexposeParam, iteratorId, direction], - ); - - if (childNodes.length === 0) { - return ( -
e.stopPropagation()}> -
- - {direction === "input" ? t("workflow.configureInputs", "Configure Inputs") : t("workflow.configureOutputs", "Configure Outputs")} - - -
-

{t("workflow.noChildNodes", "Add child nodes first to expose their parameters")}

-
- ); - } - - return ( -
e.stopPropagation()}> -
- - {direction === "input" ? t("workflow.configureInputs", "Configure Inputs") : t("workflow.configureOutputs", "Configure Outputs")} - - -
-
- {childNodes.map((child) => { - const childLabel = String(child.data?.label ?? child.id.slice(0, 8)); - const paramDefs = (child.data?.paramDefinitions ?? []) as Array<{ key: string; label: string; dataType?: string }>; - const childInputDefs = (child.data?.inputDefinitions ?? []) as PortDefinition[]; - const childOutputDefs = (child.data?.outputDefinitions ?? []) as PortDefinition[]; - const modelSchema = (child.data?.modelInputSchema ?? []) as Array<{ name: string; label?: string; type?: string; mediaType?: string; required?: boolean }>; - - let items: Array<{ key: string; label: string; dataType: string }>; - - if (direction === "input") { - const modelItems = modelSchema.map((m) => ({ - key: m.name, - label: m.label || m.name.split("_").map((w: string) => w.charAt(0).toUpperCase() + w.slice(1)).join(" "), - dataType: m.mediaType ?? m.type ?? "any", - })); - const inputPortItems = childInputDefs.map((d) => ({ key: d.key, label: d.label, dataType: d.dataType })); - if (modelItems.length === 0) { - const visibleParams = paramDefs - .filter((d) => !d.key.startsWith("__") && d.key !== "modelId") - .map((d) => ({ key: d.key, label: d.label, dataType: d.dataType ?? "any" })); - items = [...visibleParams, ...inputPortItems]; - } else { - items = [...modelItems, ...inputPortItems]; - } - } else { - items = childOutputDefs.map((d) => ({ key: d.key, label: d.label, dataType: d.dataType })); - } - - if (items.length === 0) return null; - - return ( -
-
{childLabel}
- {items.map((item) => ( - - ))} -
- ); - })} -
-
- ); -} - -/* ── Portal wrapper — positions a floating panel relative to the iterator node ── */ - -function PickerPortal({ - nodeRef, - side, - offsetTop, - children, -}: { - nodeRef: React.RefObject; - side: "left" | "right"; - offsetTop: number; - children: React.ReactNode; -}) { - const [pos, setPos] = useState<{ top: number; left?: number; right?: number }>({ top: 0 }); - const portalRef = useRef(null); - - useEffect(() => { - const update = () => { - const rect = nodeRef.current?.getBoundingClientRect(); - if (!rect) return; - if (side === "left") { - setPos({ top: rect.top + offsetTop, left: rect.left + 8 }); - } else { - setPos({ top: rect.top + offsetTop, right: window.innerWidth - rect.right + 8 }); - } - }; - update(); - const viewport = nodeRef.current?.closest(".react-flow__viewport"); - let mo: MutationObserver | undefined; - if (viewport) { - mo = new MutationObserver(update); - mo.observe(viewport, { attributes: true, attributeFilter: ["style"] }); - } - window.addEventListener("resize", update); - return () => { mo?.disconnect(); window.removeEventListener("resize", update); }; - }, [nodeRef, side, offsetTop]); - - return ( -
e.stopPropagation()} - onMouseDown={(e) => e.stopPropagation()} - > - {children} -
- ); -} - -/* ── Capsule handle style helpers ──────────────────────────────────── */ - -const dotStyle = (connected: boolean): React.CSSProperties => ({ - width: HANDLE_DOT, - height: HANDLE_DOT, - borderRadius: "50%", - border: "2px solid hsl(var(--primary))", - background: connected ? "hsl(var(--primary))" : "hsl(var(--card))", - minWidth: HANDLE_DOT, - minHeight: HANDLE_DOT, - position: "relative" as const, - top: "auto", - left: "auto", - right: "auto", - bottom: "auto", - transform: "none", - zIndex: 40, -}); - -/* ── main component ────────────────────────────────────────────────── */ - -function IteratorNodeContainerComponent({ - id, - data, - selected, -}: NodeProps) { - const { t } = useTranslation(); - const nodeRef = useRef(null); - const [resizing, setResizing] = useState(false); - const [hovered, setHovered] = useState(false); - const [editingCount, setEditingCount] = useState(false); - const [countDraft, setCountDraft] = useState(""); - const countInputRef = useRef(null); - const [showInputPicker, setShowInputPicker] = useState(false); - const [showOutputPicker, setShowOutputPicker] = useState(false); - const { getViewport, setNodes } = useReactFlow(); - const updateNodeInternals = useUpdateNodeInternals(); - const updateNodeParams = useWorkflowStore((s) => s.updateNodeParams); - const workflowId = useWorkflowStore((s) => s.workflowId); - const removeNode = useWorkflowStore((s) => s.removeNode); - const toggleNodePalette = useUIStore((s) => s.toggleNodePalette); - const edges = useWorkflowStore((s) => s.edges); - const status = useExecutionStore( - (s) => s.nodeStatuses[id] ?? "idle", - ) as NodeStatus; - const progress = useExecutionStore((s) => s.progressMap[id]); - const errorMessage = useExecutionStore((s) => s.errorMessages[id]); - const { runNode, cancelNode, retryNode, continueFrom } = useExecutionStore(); - const running = status === "running"; - - const iterationCount = Number(data.params?.iterationCount ?? 1); - const iterationMode = String(data.params?.iterationMode ?? "fixed"); - const savedWidth = (data.params?.__nodeWidth as number) ?? MIN_ITERATOR_WIDTH; - const savedHeight = (data.params?.__nodeHeight as number) ?? MIN_ITERATOR_HEIGHT; - const collapsed = (data.params?.__nodeCollapsed as boolean | undefined) ?? false; - const shortId = id.slice(0, 8); - - const inputDefs = useMemo(() => { - // Reconstruct from exposedInputs params (source of truth) to be resilient - // against data.inputDefinitions being reset by other state updates - try { - const raw = data.params?.exposedInputs; - const list: ExposedParam[] = typeof raw === "string" ? JSON.parse(raw) : Array.isArray(raw) ? raw : []; - return list.map((ep): PortDefinition => { - const readableParam = ep.paramKey.split("_").map((w: string) => w.charAt(0).toUpperCase() + w.slice(1)).join(" "); - const shortLabel = ep.subNodeLabel.includes("/") ? ep.subNodeLabel.split("/").pop()! : ep.subNodeLabel; - return { key: ep.namespacedKey, label: `${readableParam} · ${shortLabel}`, dataType: ep.dataType, required: false }; - }); - } catch { return data.inputDefinitions ?? []; } - }, [data.params?.exposedInputs, data.inputDefinitions]); - - const outputDefs = useMemo(() => { - try { - const raw = data.params?.exposedOutputs; - const list: ExposedParam[] = typeof raw === "string" ? JSON.parse(raw) : Array.isArray(raw) ? raw : []; - return list.map((ep): PortDefinition => { - const readableParam = ep.paramKey.split("_").map((w: string) => w.charAt(0).toUpperCase() + w.slice(1)).join(" "); - const shortLabel = ep.subNodeLabel.includes("/") ? ep.subNodeLabel.split("/").pop()! : ep.subNodeLabel; - return { key: ep.namespacedKey, label: `${readableParam} · ${shortLabel}`, dataType: ep.dataType, required: false }; - }); - } catch { return data.outputDefinitions ?? []; } - }, [data.params?.exposedOutputs, data.outputDefinitions]); - const childNodeIds = data.childNodeIds ?? []; - const hasChildren = childNodeIds.length > 0; - - /* ── Force ReactFlow to recalculate handle positions when ports change ── */ - const portFingerprint = useMemo( - () => inputDefs.map((d) => d.key).join(",") + "|" + outputDefs.map((d) => d.key).join(","), - [inputDefs, outputDefs], - ); - useEffect(() => { - // After new handles render, tell ReactFlow to update its internal handle cache - requestAnimationFrame(() => updateNodeInternals(id)); - }, [portFingerprint, id, updateNodeInternals]); - - /* ── Collapse toggle ───────────────────────────────────────────── */ - const setCollapsed = useCallback( - (value: boolean) => updateNodeParams(id, { ...data.params, __nodeCollapsed: value }), - [id, data.params, updateNodeParams], - ); - const toggleCollapsed = useCallback( - (e: React.MouseEvent) => { e.stopPropagation(); setCollapsed(!collapsed); }, - [collapsed, setCollapsed], - ); - - /* ── Effective size ─────────────────────────────────────────────── */ - const effectiveWidth = savedWidth; - const effectiveHeight = collapsed ? TITLE_BAR_HEIGHT : savedHeight; - - /* ── Auto-expand: observe child DOM size changes ───────────────── */ - useEffect(() => { - if (collapsed || childNodeIds.length === 0) return; - const updateBB = useWorkflowStore.getState().updateBoundingBox; - const observer = new ResizeObserver(() => { updateBB(id); }); - for (const cid of childNodeIds) { - const el = document.querySelector(`[data-id="${cid}"]`) as HTMLElement | null; - if (el) observer.observe(el); - } - return () => observer.disconnect(); - }, [id, childNodeIds, collapsed]); - - /* ── Iteration count editing ───────────────────────────────────── */ - const startEditingCount = useCallback((e: React.MouseEvent) => { - e.stopPropagation(); setCountDraft(String(iterationCount)); setEditingCount(true); - }, [iterationCount]); - - useEffect(() => { - if (editingCount && countInputRef.current) { countInputRef.current.focus(); countInputRef.current.select(); } - }, [editingCount]); - - const commitCount = useCallback(() => { - const val = Math.max(1, Math.floor(Number(countDraft) || 1)); - updateNodeParams(id, { ...data.params, iterationCount: val }); - setEditingCount(false); - }, [countDraft, id, data.params, updateNodeParams]); - - const onCountKeyDown = useCallback( - (e: React.KeyboardEvent) => { if (e.key === "Enter") commitCount(); if (e.key === "Escape") setEditingCount(false); }, - [commitCount], - ); - - /* ── Actions ───────────────────────────────────────────────────── */ - const onRun = useCallback(async (e: React.MouseEvent) => { - e.stopPropagation(); - if (running) cancelNode(workflowId ?? "", id); else runNode(workflowId ?? "", id); - }, [running, workflowId, id, runNode, cancelNode]); - - const onRunFromHere = useCallback(async (e: React.MouseEvent) => { - e.stopPropagation(); continueFrom(workflowId ?? "", id); - }, [workflowId, id, continueFrom]); - - const onDelete = useCallback((e: React.MouseEvent) => { - e.stopPropagation(); removeNode(id); - }, [removeNode, id]); - - const handleAddNodeInside = useCallback((e: React.MouseEvent) => { - e.stopPropagation(); - useUIStore.getState().setPendingIteratorParentId(id); - toggleNodePalette(); - }, [toggleNodePalette, id]); - - /* ── Resize handler ────────────────────────────────────────────── */ - const onEdgeResizeStart = useCallback( - (e: React.MouseEvent, xDir: number, yDir: number) => { - e.stopPropagation(); e.preventDefault(); - const el = nodeRef.current; if (!el) return; - setResizing(true); - const startX = e.clientX, startY = e.clientY; - const startW = savedWidth; - const startH = savedHeight; - const zoom = getViewport().zoom; - - // Capture the starting position of the iterator node - const startPos = (() => { - const n = useWorkflowStore.getState().nodes.find((nd) => nd.id === id); - return n ? { ...n.position } : { x: 0, y: 0 }; - })(); - - const onMove = (ev: MouseEvent) => { - const dx = (ev.clientX - startX) / zoom; - const dy = (ev.clientY - startY) / zoom; - const newW = xDir !== 0 ? Math.max(MIN_ITERATOR_WIDTH, startW + dx * xDir) : startW; - const newH = yDir !== 0 ? Math.max(MIN_ITERATOR_HEIGHT, startH + dy * yDir) : startH; - const newX = xDir === -1 ? startPos.x + (startW - newW) : startPos.x; - const newY = yDir === -1 ? startPos.y + (startH - newH) : startPos.y; - - setNodes((nds) => nds.map((n) => { - if (n.id !== id) return n; - const p = { ...n.data.params, __nodeWidth: newW, __nodeHeight: newH }; - return { ...n, position: { x: newX, y: newY }, data: { ...n.data, params: p } }; - })); - }; - - const onUp = (ev: MouseEvent) => { - document.removeEventListener("mousemove", onMove); - document.removeEventListener("mouseup", onUp); - setResizing(false); - - const dx = (ev.clientX - startX) / zoom; - const dy = (ev.clientY - startY) / zoom; - const finalW = xDir !== 0 ? Math.max(MIN_ITERATOR_WIDTH, startW + dx * xDir) : startW; - const finalH = yDir !== 0 ? Math.max(MIN_ITERATOR_HEIGHT, startH + dy * yDir) : startH; - - useWorkflowStore.setState({ isDirty: true }); - - // Re-clamp child nodes - const { nodes: currentNodes } = useWorkflowStore.getState(); - const clampPad = 10; - const childUpdates: Array<{ nodeId: string; pos: { x: number; y: number } }> = []; - for (const cn of currentNodes) { - if (cn.parentNode !== id) continue; - const cw = (cn.data?.params?.__nodeWidth as number) ?? 300; - const ch = (cn.data?.params?.__nodeHeight as number) ?? 80; - const minCX = clampPad; - const maxCX = Math.max(minCX, finalW - cw - clampPad); - const minCY = TITLE_BAR_HEIGHT + clampPad; - const maxCY = Math.max(minCY, finalH - ch - clampPad - 40); - const cx = Math.min(Math.max(cn.position.x, minCX), maxCX); - const cy = Math.min(Math.max(cn.position.y, minCY), maxCY); - if (cx !== cn.position.x || cy !== cn.position.y) { - childUpdates.push({ nodeId: cn.id, pos: { x: cx, y: cy } }); - } - } - if (childUpdates.length > 0) { - useWorkflowStore.setState((state) => ({ - nodes: state.nodes.map((n) => { - const upd = childUpdates.find((u) => u.nodeId === n.id); - return upd ? { ...n, position: upd.pos } : n; - }), - })); - } - }; - document.addEventListener("mousemove", onMove); - document.addEventListener("mouseup", onUp); - }, - [id, getViewport, setNodes, savedWidth, savedHeight], - ); - - /* ── Capsule vertical position ─────────────────────────────────── */ - const getCapsuleTop = (index: number) => - CAPSULE_TOP_OFFSET + index * (CAPSULE_HEIGHT + CAPSULE_GAP); - - /* ── Exposed param lookup — maps namespacedKey → ExposedParam for tooltip info ── */ - const exposedParamMap = useMemo(() => { - const map = new Map(); - for (const key of ["exposedInputs", "exposedOutputs"] as const) { - try { - const raw = data.params?.[key]; - const list: ExposedParam[] = typeof raw === "string" ? JSON.parse(raw) : Array.isArray(raw) ? raw : []; - for (const ep of list) map.set(ep.namespacedKey, ep); - } catch { /* ignore */ } - } - return map; - }, [data.params]); - - /* ── Check if a handle has a connected edge ────────────────────── */ - const isHandleConnected = useCallback( - (handleId: string, type: "source" | "target") => - edges.some((e) => - type === "source" - ? e.source === id && e.sourceHandle === handleId - : e.target === id && e.targetHandle === handleId, - ), - [edges, id], - ); - - /* ── Picker toggle helpers ─────────────────────────────────────── */ - const toggleInputPicker = useCallback((e: React.MouseEvent) => { - e.stopPropagation(); setShowInputPicker((v) => !v); setShowOutputPicker(false); - }, []); - const toggleOutputPicker = useCallback((e: React.MouseEvent) => { - e.stopPropagation(); setShowOutputPicker((v) => !v); setShowInputPicker(false); - }, []); - - return ( -
setHovered(true)} - onMouseLeave={() => { setHovered(false); }} - className="relative" - > - {/* Invisible hover extension above */} -
- - {/* ── Hover toolbar ──────────────────────────────────────── */} - {hovered && ( -
- {running ? ( - - ) : ( - <> - - - - - )} -
- )} - - {/* ── Main container ─────────────────────────────────────── */} -
- - {/* ── Title bar ──────────────────────────────────────── */} -
- - -
- - - - -
- {data.label || t("workflow.iterator", "Iterator")} - {shortId} -
- - {/* ── Config buttons: IN / OUT ── */} - - - - - {t("workflow.configureInputs", "Configure exposed input parameters")} - - - - - - {t("workflow.configureOutputs", "Configure exposed output parameters")} - - - {/* ── Unified iteration mode + count capsule ── */} - - -
- {/* Left half: mode toggle */} - - {/* Divider + count — only in fixed mode */} - {iterationMode === "fixed" && ( - <> -
- {editingCount ? ( - setCountDraft(e.target.value)} onBlur={commitCount} onKeyDown={onCountKeyDown} - className="nodrag nopan w-10 h-full text-center text-[11px] font-bold bg-transparent text-cyan-400 outline-none" /> - ) : ( - - )} - - )} -
- - - {iterationMode === "auto" - ? t("workflow.iterationModeAutoTip", "Auto: iterations = longest array input. Click the mode to switch.") - : t("workflow.iterationModeFixedTip", "Fixed: runs exactly ×N times. Click the mode to switch.")} - - -
- - {/* ── Expose-param pickers — rendered via portal ── */} - {showInputPicker && createPortal( - - setShowInputPicker(false)} /> - , - document.body, - )} - {showOutputPicker && createPortal( - - setShowOutputPicker(false)} /> - , - document.body, - )} - - {/* ── Running progress bar ───────────────────────────── */} - {running && !collapsed && ( -
-
- - - - {progress?.message || t("workflow.running", "Running...")} - {progress && {Math.round(progress.progress)}%} -
-
-
-
-
- )} - - {/* ── Error details + Retry ──────────────────────────── */} - {status === "error" && errorMessage && !collapsed && ( -
-
- - {errorMessage} - -
-
- )} - - {/* ── Body: Internal canvas area (full width, no port strips) ── */} - {!collapsed && ( -
- {/* Empty state */} - {!hasChildren && ( -
-
- {t("workflow.iteratorEmpty", "No child nodes yet")} - - - -
-
- )} - - {/* ── Add Node button — positioned at bottom center inside the container ── */} -
e.stopPropagation()} - > - -
-
- )} - - {/* Collapsed child count */} - {collapsed && hasChildren && ( -
- {t("workflow.childNodesCount", "{{count}} child node(s)", { count: childNodeIds.length })} -
- )} - - {/* ── Resize handles ─────────────────────────────────── */} - {selected && !collapsed && ( - <> -
onEdgeResizeStart(e, 1, 0)} className="nodrag absolute top-2 right-0 bottom-2 w-[5px] cursor-ew-resize z-20 hover:bg-cyan-500/20" /> -
onEdgeResizeStart(e, -1, 0)} className="nodrag absolute top-2 left-0 bottom-2 w-[5px] cursor-ew-resize z-20 hover:bg-cyan-500/20" /> -
onEdgeResizeStart(e, 0, 1)} className="nodrag absolute bottom-0 left-2 right-2 h-[5px] cursor-ns-resize z-20 hover:bg-cyan-500/20" /> -
onEdgeResizeStart(e, 0, -1)} className="nodrag absolute top-0 left-2 right-2 h-[5px] cursor-ns-resize z-20 hover:bg-cyan-500/20" /> -
onEdgeResizeStart(e, 1, 1)} className="nodrag absolute bottom-0 right-0 w-3 h-3 cursor-se-resize z-30" /> -
onEdgeResizeStart(e, -1, 1)} className="nodrag absolute bottom-0 left-0 w-3 h-3 cursor-sw-resize z-30" /> -
onEdgeResizeStart(e, 1, -1)} className="nodrag absolute top-0 right-0 w-3 h-3 cursor-ne-resize z-30" /> -
onEdgeResizeStart(e, -1, -1)} className="nodrag absolute top-0 left-0 w-3 h-3 cursor-nw-resize z-30" /> - - )} -
- - {/* ── LEFT SIDE: exposed input capsules ──────────────────── */} - {!collapsed && inputDefs.map((port, i) => { - const top = getCapsuleTop(i); - const extHandleId = `input-${port.key}`; - const intHandleId = `input-inner-${port.key}`; - const extConnected = isHandleConnected(extHandleId, "target"); - const intConnected = isHandleConnected(intHandleId, "source"); - const ep = exposedParamMap.get(port.key); - const tooltipText = ep - ? `${ep.paramKey.split("_").map((w: string) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ")} — ${ep.subNodeLabel}` - : port.label; - return ( - - {/* External target handle — on the left border */} - - {/* Capsule label between the two dots */} - - -
-
- - {port.label} - -
-
-
- - {tooltipText} - -
- {/* Internal source handle — right side of capsule label area */} - -
- ); - })} - - {/* ── RIGHT SIDE: exposed output capsules ──────────────────── */} - {!collapsed && outputDefs.map((port, i) => { - const top = getCapsuleTop(i); - const intHandleId = `output-inner-${port.key}`; - const extHandleId = `output-${port.key}`; - const extConnected = isHandleConnected(extHandleId, "source"); - const intConnected = isHandleConnected(intHandleId, "target"); - const ep = exposedParamMap.get(port.key); - const tooltipText = ep - ? `${ep.paramKey.split("_").map((w: string) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ")} — ${ep.subNodeLabel}` - : port.label; - // Compute left-based positions so ReactFlow handle lookup is reliable - const capsuleLabelLeft = effectiveWidth - HANDLE_DOT - CAPSULE_LABEL_WIDTH; - const intHandleLeft = capsuleLabelLeft - HANDLE_DOT - 4; - const extHandleLeft = effectiveWidth - HANDLE_DOT / 2; - return ( - - {/* Internal target handle — left side of capsule */} - - {/* Capsule label */} - - -
-
- - {port.label} - -
-
-
- - {tooltipText} - -
- {/* External source handle — on the right border */} - -
- ); - })} - - {/* ── External "+" button — right side ── */} - {(hovered || selected) && ( - - - - - - {t("workflow.addNode", "Add Node")} - - - )} -
- ); -} - -export default memo(IteratorNodeContainerComponent); -export { MIN_ITERATOR_WIDTH, MIN_ITERATOR_HEIGHT, CHILD_PADDING }; diff --git a/src/workflow/components/panels/NodeConfigPanel.tsx b/src/workflow/components/panels/NodeConfigPanel.tsx index e1c9077b..03ba539f 100644 --- a/src/workflow/components/panels/NodeConfigPanel.tsx +++ b/src/workflow/components/panels/NodeConfigPanel.tsx @@ -99,17 +99,20 @@ export function NodeConfigPanel({ // Iterator self-config: show port management when the Iterator itself is selected if (isIterator) { return ( -
+
{!embeddedInNode && (

{t("workflow.iteratorConfig", "Iterator Configuration")}

)}
- +
); @@ -179,7 +182,10 @@ function ExposeParamControls({ const unexposeParam = useWorkflowStore((s) => s.unexposeParam); const subNodeLabel = String(node.data.label ?? node.id); - const iteratorParams = (parentIterator.data.params ?? {}) as Record; + const iteratorParams = (parentIterator.data.params ?? {}) as Record< + string, + unknown + >; // Parse currently exposed inputs/outputs from the parent iterator const exposedInputs: ExposedParam[] = useMemo(() => { @@ -204,8 +210,10 @@ function ExposeParamControls({ const visibleParamDefs = paramDefs.filter((d) => !d.key.startsWith("__")); // Get input/output port definitions from the node data - const inputDefs: PortDefinition[] = (node.data.inputDefinitions as PortDefinition[] | undefined) ?? []; - const outputDefs: PortDefinition[] = (node.data.outputDefinitions as PortDefinition[] | undefined) ?? []; + const inputDefs: PortDefinition[] = + (node.data.inputDefinitions as PortDefinition[] | undefined) ?? []; + const outputDefs: PortDefinition[] = + (node.data.outputDefinitions as PortDefinition[] | undefined) ?? []; const isExposed = (paramKey: string, direction: "input" | "output") => { const nk = `${subNodeLabel}.${paramKey}`; @@ -215,7 +223,11 @@ function ExposeParamControls({ const getNamespacedKey = (paramKey: string) => `${subNodeLabel}.${paramKey}`; - const handleExpose = (paramKey: string, direction: "input" | "output", dataType: string) => { + const handleExpose = ( + paramKey: string, + direction: "input" | "output", + dataType: string, + ) => { const param: ExposedParam = { subNodeId: node.id, subNodeLabel, @@ -232,7 +244,9 @@ function ExposeParamControls({ }; const hasExposableItems = - visibleParamDefs.length > 0 || inputDefs.length > 0 || outputDefs.length > 0; + visibleParamDefs.length > 0 || + inputDefs.length > 0 || + outputDefs.length > 0; if (!hasExposableItems) return null; @@ -258,7 +272,9 @@ function ExposeParamControls({ direction="input" exposed={exposed} namespacedKey={getNamespacedKey(def.key)} - onExpose={() => handleExpose(def.key, "input", def.dataType ?? "any")} + onExpose={() => + handleExpose(def.key, "input", def.dataType ?? "any") + } onUnexpose={() => handleUnexpose(def.key, "input")} /> ); @@ -380,13 +396,20 @@ function IteratorSelfConfig({ allNodes, }: { iteratorNode: { id: string; data: Record }; - allNodes: Array<{ id: string; data: Record; parentNode?: string }>; + allNodes: Array<{ + id: string; + data: Record; + parentNode?: string; + }>; }) { const { t } = useTranslation(); const unexposeParam = useWorkflowStore((s) => s.unexposeParam); const updateNodeParams = useWorkflowStore((s) => s.updateNodeParams); - const iteratorParams = (iteratorNode.data.params ?? {}) as Record; + const iteratorParams = (iteratorNode.data.params ?? {}) as Record< + string, + unknown + >; const iterationCount = Number(iteratorParams.iterationCount ?? 1); // Find child nodes @@ -397,14 +420,18 @@ function IteratorSelfConfig({ try { const raw = iteratorParams.exposedInputs; return typeof raw === "string" ? JSON.parse(raw) : []; - } catch { return []; } + } catch { + return []; + } }, [iteratorParams.exposedInputs]); const exposedOutputs: ExposedParam[] = useMemo(() => { try { const raw = iteratorParams.exposedOutputs; return typeof raw === "string" ? JSON.parse(raw) : []; - } catch { return []; } + } catch { + return []; + } }, [iteratorParams.exposedOutputs]); return ( @@ -420,7 +447,10 @@ function IteratorSelfConfig({ value={iterationCount} onChange={(e) => { const val = Math.max(1, Math.floor(Number(e.target.value) || 1)); - updateNodeParams(iteratorNode.id, { ...iteratorParams, iterationCount: val }); + updateNodeParams(iteratorNode.id, { + ...iteratorParams, + iterationCount: val, + }); }} className="w-full rounded border border-input bg-background px-2 py-1.5 text-xs" /> @@ -433,14 +463,22 @@ function IteratorSelfConfig({
{childNodes.length === 0 ? (
- {t("workflow.noChildNodes", "No child nodes. Drag nodes into the Iterator or use the Add Node button.")} + {t( + "workflow.noChildNodes", + "No child nodes. Drag nodes into the Iterator or use the Add Node button.", + )}
) : (
{childNodes.map((child) => ( -
+
- {String(child.data.label ?? child.data.nodeType ?? child.id)} + + {String(child.data.label ?? child.data.nodeType ?? child.id)} +
))}
@@ -450,19 +488,28 @@ function IteratorSelfConfig({ {/* Exposed inputs */}
- {t("workflow.exposedInputs", "Exposed Inputs")} ({exposedInputs.length}) + {t("workflow.exposedInputs", "Exposed Inputs")} ( + {exposedInputs.length})
{exposedInputs.length === 0 ? (
- {t("workflow.noExposedInputs", "Select a child node to expose its parameters as Iterator inputs.")} + {t( + "workflow.noExposedInputs", + "Select a child node to expose its parameters as Iterator inputs.", + )}
) : (
{exposedInputs.map((ep) => ( -
+
{ep.namespacedKey}
diff --git a/src/workflow/components/panels/ResultsPanel.tsx b/src/workflow/components/panels/ResultsPanel.tsx index 4bd15dd8..5d914b8a 100644 --- a/src/workflow/components/panels/ResultsPanel.tsx +++ b/src/workflow/components/panels/ResultsPanel.tsx @@ -158,7 +158,22 @@ export function ResultsPanel({ if (metaUrls && Array.isArray(metaUrls) && metaUrls.length > 0) { return metaUrls.filter((u) => u && typeof u === "string"); } - return rec.resultPath ? [rec.resultPath] : []; + if (rec.resultPath) return [rec.resultPath]; + // Fallback for nodes that produce structured data (e.g. HTTP Response): + // render non-internal metadata fields as a JSON text block + if (meta) { + const display: Record = {}; + for (const [k, v] of Object.entries(meta)) { + if (!k.startsWith("__") && v !== undefined && v !== null && v !== "") { + display[k] = v; + } + } + if (Object.keys(display).length > 0) { + const text = JSON.stringify(display, null, 2); + return [`data:text/plain;charset=utf-8,${encodeURIComponent(text)}`]; + } + } + return []; }; const panelImageUrls = displayRecords diff --git a/src/workflow/hooks/useGroupAdoption.ts b/src/workflow/hooks/useGroupAdoption.ts new file mode 100644 index 00000000..d4a3b4a9 --- /dev/null +++ b/src/workflow/hooks/useGroupAdoption.ts @@ -0,0 +1,110 @@ +/** + * useGroupAdoption — Hook that manages the relationship between Group + * containers and their child nodes. + * + * Key behaviors: + * - External nodes CAN be dragged into an Iterator — they are adopted on drop + * - Child nodes CAN be dragged out of their parent Iterator — released on drop outside + * - Auto-adopts newly created nodes if their position falls inside an Iterator + * - Releases children before deletion and updates bounding boxes + */ +import { useCallback, useRef } from "react"; +import type { NodeChange } from "reactflow"; +import { useWorkflowStore } from "../stores/workflow.store"; +import { useUIStore } from "../stores/ui.store"; + +/* ── constants ─────────────────────────────────────────────────────── */ + +const RELEASE_THRESHOLD = 30; // px beyond the iterator edge to trigger release + +/* ── hook ──────────────────────────────────────────────────────────── */ + +export function useGroupAdoption() { + const draggingNodesRef = useRef>(new Set()); + + /** + * Call this from the onNodesChange wrapper to: + * 1. When a child node is dropped near/outside the Iterator edge, release it + * 2. When an external node is dropped inside an Iterator, adopt it + */ + const handleNodesChangeForAdoption = useCallback((changes: NodeChange[]) => { + const posChanges = changes.filter( + ( + c, + ): c is NodeChange & { + type: "position"; + id: string; + dragging?: boolean; + position?: { x: number; y: number }; + } => c.type === "position", + ); + if (posChanges.length === 0) return; + + // Track dragging state + for (const change of posChanges) { + if (change.dragging === true) { + draggingNodesRef.current.add(change.id); + } + if (change.dragging === false) { + draggingNodesRef.current.delete(change.id); + } + } + + // Skip adoption/release when in subgraph editing mode + const editingGroupId = useUIStore.getState().editingGroupId; + if (editingGroupId) { + return; + } + + // Drag-to-adopt/release is disabled — nodes should only be added to groups + // via subgraph editing mode. Clear any stale drop hints. + const anyStillDragging = draggingNodesRef.current.size > 0; + if (!anyStillDragging) { + useUIStore.getState().setIteratorDropTarget(null); + } + }, []); + + /** + * Call after a new node is created. + * + * External nodes (dragged from palette or pasted) are NOT auto-adopted + * into iterators. Adoption is handled directly by the NodePalette when + * pendingIteratorParentId is set. + */ + const handleNodeCreated = useCallback((_newNodeId: string) => { + // No-op: adoption is handled by NodePalette.handleClick when + // pendingIteratorParentId is set. External drag/paste should NOT + // auto-adopt into iterators. + }, []); + + /** + * Call before nodes are deleted. For each deleted node that has a parentNode + * (i.e., is a child of an Iterator), release it and update the bounding box. + */ + const handleNodesDeleted = useCallback((deletedNodeIds: string[]) => { + const { nodes, releaseNode, updateBoundingBox } = + useWorkflowStore.getState(); + + const affectedIteratorIds = new Set(); + + for (const nodeId of deletedNodeIds) { + const node = nodes.find((n) => n.id === nodeId); + if (!node || !node.parentNode) continue; + + const parentId = node.parentNode; + releaseNode(parentId, nodeId); + affectedIteratorIds.add(parentId); + } + + // Update bounding boxes for all affected iterators + for (const itId of affectedIteratorIds) { + updateBoundingBox(itId); + } + }, []); + + return { + handleNodesChangeForAdoption, + handleNodeCreated, + handleNodesDeleted, + }; +} diff --git a/src/workflow/hooks/useIteratorAdoption.ts b/src/workflow/hooks/useIteratorAdoption.ts deleted file mode 100644 index 482e1eb8..00000000 --- a/src/workflow/hooks/useIteratorAdoption.ts +++ /dev/null @@ -1,203 +0,0 @@ -/** - * useIteratorAdoption — Hook that manages the relationship between Iterator - * containers and their child nodes. - * - * Key behaviors: - * - Child nodes are locked inside their parent Iterator (extent: "parent") - * - External nodes cannot be dragged into an Iterator — they must be created inside - * - Child nodes cannot be dragged out of their parent Iterator - * - Auto-adopts newly created nodes if their position falls inside an Iterator - * - Releases children before deletion and updates bounding boxes - * - Clamps child node positions to stay within the Iterator bounding box - */ -import { useCallback, useRef } from "react"; -import type { NodeChange } from "reactflow"; -import { useWorkflowStore } from "../stores/workflow.store"; - -/* ── constants ─────────────────────────────────────────────────────── */ - -const TITLE_BAR_HEIGHT = 40; -const CLAMP_PADDING = 10; - -/* ── helpers ───────────────────────────────────────────────────────── */ - -/* ── hook ──────────────────────────────────────────────────────────── */ - -export function useIteratorAdoption() { - const draggingNodesRef = useRef>(new Set()); - - /** - * Call this from the onNodesChange wrapper to: - * 1. Clamp child nodes within their parent Iterator bounds during drag - * 2. Update bounding boxes when children move - * - * Child nodes are locked inside — they cannot be dragged out. - * External nodes are NOT auto-adopted on drag — only on creation. - */ - const handleNodesChangeForAdoption = useCallback( - (changes: NodeChange[]) => { - const posChanges = changes.filter( - (c): c is NodeChange & { - type: "position"; - id: string; - dragging?: boolean; - position?: { x: number; y: number }; - } => c.type === "position", - ); - if (posChanges.length === 0) return; - - const { nodes } = useWorkflowStore.getState(); - - // Track dragging state - for (const change of posChanges) { - if (change.dragging === true) { - draggingNodesRef.current.add(change.id); - } - if (change.dragging === false) { - draggingNodesRef.current.delete(change.id); - } - } - - // Clamp child nodes within their parent Iterator bounds - const nodesToClamp: Array<{ nodeId: string; clampedPos: { x: number; y: number } }> = []; - - // Collect all iterator nodes for external-node rejection - const iteratorNodes = nodes.filter((n) => n.type === "control/iterator"); - - for (const change of posChanges) { - const node = nodes.find((n) => n.id === change.id); - if (!node) continue; - - if (node.parentNode) { - // This node is a child of an Iterator — enforce bounds (keep inside) - const parentIterator = nodes.find((n) => n.id === node.parentNode); - if (!parentIterator) continue; - - const itW = (parentIterator.data?.params?.__nodeWidth as number) ?? 600; - const itH = (parentIterator.data?.params?.__nodeHeight as number) ?? 400; - - const pos = change.position ?? node.position; - const childW = (node.data?.params?.__nodeWidth as number) ?? 300; - const childH = (node.data?.params?.__nodeHeight as number) ?? 80; - - const minX = CLAMP_PADDING; - const maxX = Math.max(minX, itW - childW - CLAMP_PADDING); - const minY = TITLE_BAR_HEIGHT + CLAMP_PADDING; - const maxY = Math.max(minY, itH - childH - CLAMP_PADDING - 40); // 40 = add node button area - - const clampedX = Math.min(Math.max(pos.x, minX), maxX); - const clampedY = Math.min(Math.max(pos.y, minY), maxY); - - if (clampedX !== pos.x || clampedY !== pos.y) { - nodesToClamp.push({ - nodeId: change.id, - clampedPos: { x: clampedX, y: clampedY }, - }); - } - } else if (node.type !== "control/iterator" && change.dragging) { - // External node being dragged — reject if it overlaps any iterator - const pos = change.position ?? node.position; - const nodeW = (node.data?.params?.__nodeWidth as number) ?? 300; - const nodeH = (node.data?.params?.__nodeHeight as number) ?? 80; - - for (const it of iteratorNodes) { - const itX = it.position.x; - const itY = it.position.y; - const itW = (it.data?.params?.__nodeWidth as number) ?? 600; - const itH = (it.data?.params?.__nodeHeight as number) ?? 400; - - // Check overlap (AABB intersection) - const overlapsX = pos.x < itX + itW && pos.x + nodeW > itX; - const overlapsY = pos.y < itY + itH && pos.y + nodeH > itY; - - if (overlapsX && overlapsY) { - // Push the node to the nearest edge outside the iterator - const pushLeft = itX - nodeW - CLAMP_PADDING; - const pushRight = itX + itW + CLAMP_PADDING; - const pushTop = itY - nodeH - CLAMP_PADDING; - const pushBottom = itY + itH + CLAMP_PADDING; - - // Find the smallest displacement - const dLeft = Math.abs(pos.x - pushLeft); - const dRight = Math.abs(pos.x - pushRight); - const dTop = Math.abs(pos.y - pushTop); - const dBottom = Math.abs(pos.y - pushBottom); - const minD = Math.min(dLeft, dRight, dTop, dBottom); - - let newX = pos.x; - let newY = pos.y; - if (minD === dLeft) newX = pushLeft; - else if (minD === dRight) newX = pushRight; - else if (minD === dTop) newY = pushTop; - else newY = pushBottom; - - nodesToClamp.push({ - nodeId: change.id, - clampedPos: { x: newX, y: newY }, - }); - break; // only need to resolve one iterator collision - } - } - } - } - - // Apply clamped positions - if (nodesToClamp.length > 0) { - useWorkflowStore.setState((state) => ({ - nodes: state.nodes.map((n) => { - const clamp = nodesToClamp.find((c) => c.nodeId === n.id); - if (clamp) { - return { ...n, position: clamp.clampedPos }; - } - return n; - }), - })); - } - }, - [], - ); - - /** - * Call after a new node is created. - * - * External nodes (dragged from palette or pasted) are NOT auto-adopted - * into iterators. Adoption is handled directly by the NodePalette when - * pendingIteratorParentId is set. - */ - const handleNodeCreated = useCallback((_newNodeId: string) => { - // No-op: adoption is handled by NodePalette.handleClick when - // pendingIteratorParentId is set. External drag/paste should NOT - // auto-adopt into iterators. - }, []); - - /** - * Call before nodes are deleted. For each deleted node that has a parentNode - * (i.e., is a child of an Iterator), release it and update the bounding box. - */ - const handleNodesDeleted = useCallback((deletedNodeIds: string[]) => { - const { nodes, releaseNode, updateBoundingBox } = - useWorkflowStore.getState(); - - const affectedIteratorIds = new Set(); - - for (const nodeId of deletedNodeIds) { - const node = nodes.find((n) => n.id === nodeId); - if (!node || !node.parentNode) continue; - - const parentId = node.parentNode; - releaseNode(parentId, nodeId); - affectedIteratorIds.add(parentId); - } - - // Update bounding boxes for all affected iterators - for (const itId of affectedIteratorIds) { - updateBoundingBox(itId); - } - }, []); - - return { - handleNodesChangeForAdoption, - handleNodeCreated, - handleNodesDeleted, - }; -} diff --git a/src/workflow/lib/cycle-detection.ts b/src/workflow/lib/cycle-detection.ts index 41e5fac0..25acf528 100644 --- a/src/workflow/lib/cycle-detection.ts +++ b/src/workflow/lib/cycle-detection.ts @@ -17,7 +17,9 @@ function hasCycle(nodeIds: string[], edges: SimpleEdge[]): boolean { for (const id of nodeIds) adj.set(id, []); for (const e of edges) adj.get(e.sourceNodeId)?.push(e.targetNodeId); - const WHITE = 0, GRAY = 1, BLACK = 2; + const WHITE = 0, + GRAY = 1, + BLACK = 2; const color = new Map(); for (const id of nodeIds) color.set(id, WHITE); diff --git a/src/workflow/stores/execution.store.ts b/src/workflow/stores/execution.store.ts index 6975219e..3119e7e9 100644 --- a/src/workflow/stores/execution.store.ts +++ b/src/workflow/stores/execution.store.ts @@ -31,6 +31,9 @@ const MAX_SESSIONS = 20; /** AbortController for the current in-browser run; Stop calls abort() on it. */ let browserRunAbortController: AbortController | null = null; +/** Track whether we started an HTTP server so cancelAll can stop it. */ +let httpServerListening = false; + function isAbortError(e: unknown): boolean { return ( (e instanceof DOMException && e.name === "AbortError") || @@ -218,6 +221,88 @@ export const useExecutionStore = create((set, get) => ({ ].slice(0, MAX_SESSIONS), })); + // ── HTTP Trigger: start server and wait for requests instead of executing ── + const httpTriggerNode = nodes.find( + (n) => n.data.nodeType === "trigger/http", + ); + if (httpTriggerNode) { + const wapi = (window as unknown as Record) + .workflowAPI as + | { invoke: (ch: string, args?: unknown) => Promise } + | undefined; + if (!wapi) { + get().updateNodeStatus( + httpTriggerNode.id, + "error", + "HTTP Trigger requires the desktop app.", + ); + set((s) => ({ + runSessions: s.runSessions.map((rs) => + rs.id === sessionId ? { ...rs, status: "error" as const } : rs, + ), + })); + return; + } + + // Save workflow first so the backend can load it + let savedWorkflowId: string | null = null; + try { + const { useWorkflowStore: wfs } = await import("./workflow.store"); + await wfs.getState().saveWorkflow(); + savedWorkflowId = wfs.getState().workflowId; + } catch { + /* user may cancel naming — continue anyway if workflowId exists */ + } + // Get workflowId (may have been set before or after save) + if (!savedWorkflowId) { + const { useWorkflowStore: wfs2 } = await import("./workflow.store"); + savedWorkflowId = wfs2.getState().workflowId; + } + if (!savedWorkflowId) { + get().updateNodeStatus( + httpTriggerNode.id, + "error", + "Please save the workflow first before starting the HTTP server.", + ); + set((s) => ({ + runSessions: s.runSessions.map((rs) => + rs.id === sessionId ? { ...rs, status: "error" as const } : rs, + ), + })); + return; + } + + const port = Number(httpTriggerNode.data.params?.port) || 3100; + try { + const status = (await wapi.invoke("http-server:start", { + port, + workflowId: savedWorkflowId, + })) as { + running: boolean; + port: number | null; + url: string | null; + }; + if (!status.running) throw new Error("Server failed to start"); + httpServerListening = true; + get().updateNodeStatus(httpTriggerNode.id, "running"); + get().updateProgress( + httpTriggerNode.id, + 0, + `Listening on http://localhost:${status.port} — POST / to trigger`, + ); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + get().updateNodeStatus(httpTriggerNode.id, "error", msg); + set((s) => ({ + runSessions: s.runSessions.map((rs) => + rs.id === sessionId ? { ...rs, status: "error" as const } : rs, + ), + })); + } + // Don't finish the session — it stays "running" until user clicks Stop + return; + } + const controller = new AbortController(); browserRunAbortController = controller; try { @@ -284,7 +369,10 @@ export const useExecutionStore = create((set, get) => ({ runNodeInBrowser: async (nodes, edges, nodeId) => { const targetNode = nodes.find((n) => n.id === nodeId); - const targetLabel = (targetNode?.data?.label as string) || targetNode?.data?.nodeType || nodeId.slice(0, 8); + const targetLabel = + (targetNode?.data?.label as string) || + targetNode?.data?.nodeType || + nodeId.slice(0, 8); set({ _lastRunType: "single", _lastRunNodeLabel: targetLabel }); const upstream = new Set([nodeId]); const reverse = new Map(); @@ -576,6 +664,19 @@ export const useExecutionStore = create((set, get) => ({ if (browserRunAbortController) { browserRunAbortController.abort(); } + // Stop HTTP server if it was started by an HTTP Trigger run + if (httpServerListening) { + httpServerListening = false; + try { + const wapi = (window as unknown as Record) + .workflowAPI as + | { invoke: (ch: string, args?: unknown) => Promise } + | undefined; + await wapi?.invoke("http-server:stop"); + } catch { + /* best effort */ + } + } set((s) => ({ runSessions: s.runSessions.map((rs) => rs.workflowId === workflowId && rs.status === "running" @@ -583,6 +684,21 @@ export const useExecutionStore = create((set, get) => ({ : rs, ), })); + // Reset all node statuses to idle + const currentStatuses = get().nodeStatuses; + const resetStatuses: Record = {}; + for (const nid of Object.keys(currentStatuses)) { + if (currentStatuses[nid] === "running") { + resetStatuses[nid] = "idle"; + } + } + if (Object.keys(resetStatuses).length > 0) { + set((s) => ({ + nodeStatuses: { ...s.nodeStatuses, ...resetStatuses }, + activeExecutions: new Set(), + progressMap: {}, + })); + } }, updateNodeStatus: (nodeId, status, errorMessage) => { diff --git a/src/workflow/stores/ui.store.ts b/src/workflow/stores/ui.store.ts index 5f66e56f..d620390a 100644 --- a/src/workflow/stores/ui.store.ts +++ b/src/workflow/stores/ui.store.ts @@ -40,6 +40,15 @@ export interface UIState { /** When set, the next node created from the palette will be adopted by this Iterator */ pendingIteratorParentId: string | null; setPendingIteratorParentId: (id: string | null) => void; + /** Visual hint for drag-in/out on iterator containers */ + iteratorDropTarget: { iteratorId: string; mode: "adopt" | "release" } | null; + setIteratorDropTarget: ( + target: { iteratorId: string; mode: "adopt" | "release" } | null, + ) => void; + /** Subgraph editing: when set, canvas shows only this Group's children */ + editingGroupId: string | null; + enterGroupEdit: (groupId: string) => void; + exitGroupEdit: () => void; selectNode: (nodeId: string | null) => void; selectNodes: (nodeIds: string[]) => void; @@ -89,6 +98,21 @@ export const useUIStore = create((set, get) => ({ setGetViewportCenter: (fn) => set({ getViewportCenter: fn }), pendingIteratorParentId: null, setPendingIteratorParentId: (id) => set({ pendingIteratorParentId: id }), + iteratorDropTarget: null, + setIteratorDropTarget: (target) => set({ iteratorDropTarget: target }), + editingGroupId: null, + enterGroupEdit: (groupId) => + set({ + editingGroupId: groupId, + selectedNodeId: null, + selectedNodeIds: new Set(), + }), + exitGroupEdit: () => + set({ + editingGroupId: null, + selectedNodeId: null, + selectedNodeIds: new Set(), + }), selectNode: (nodeId) => set({ diff --git a/src/workflow/stores/workflow.store.ts b/src/workflow/stores/workflow.store.ts index 6711e7f2..9aee7a04 100644 --- a/src/workflow/stores/workflow.store.ts +++ b/src/workflow/stores/workflow.store.ts @@ -15,7 +15,11 @@ import { } from "reactflow"; import { v4 as uuid } from "uuid"; import { workflowIpc, registryIpc } from "../ipc/ipc-client"; -import type { WorkflowNode, WorkflowEdge, ExposedParam } from "@/workflow/types/workflow"; +import type { + WorkflowNode, + WorkflowEdge, + ExposedParam, +} from "@/workflow/types/workflow"; import type { PortDefinition } from "@/workflow/types/node-defs"; import { wouldCreateCycleInSubWorkflow } from "@/workflow/lib/cycle-detection"; @@ -48,25 +52,35 @@ function purgeExposedParamsForChildren( let dirty = false; for (const direction of ["input", "output"] as const) { - const paramListKey = direction === "input" ? "exposedInputs" : "exposedOutputs"; - const defKey = direction === "input" ? "inputDefinitions" : "outputDefinitions"; + const paramListKey = + direction === "input" ? "exposedInputs" : "exposedOutputs"; + const defKey = + direction === "input" ? "inputDefinitions" : "outputDefinitions"; const currentList: ExposedParam[] = (() => { try { const raw = params[paramListKey]; - return typeof raw === "string" ? JSON.parse(raw) : Array.isArray(raw) ? raw : []; + return typeof raw === "string" + ? JSON.parse(raw) + : Array.isArray(raw) + ? raw + : []; } catch { return []; } })(); - const filtered = currentList.filter((p) => !removedChildIds.has(p.subNodeId)); + const filtered = currentList.filter( + (p) => !removedChildIds.has(p.subNodeId), + ); if (filtered.length === currentList.length) continue; // nothing to remove dirty = true; // Keys to remove const removedKeys = new Set( - currentList.filter((p) => removedChildIds.has(p.subNodeId)).map((p) => p.namespacedKey), + currentList + .filter((p) => removedChildIds.has(p.subNodeId)) + .map((p) => p.namespacedKey), ); // Remove port definitions @@ -90,11 +104,15 @@ function purgeExposedParamsForChildren( updatedEdges = updatedEdges.filter((e) => { if (autoEdgeIds.has(e.id)) return false; if (direction === "input") { - if (e.target === iter.id && extHandleIds.has(e.targetHandle ?? "")) return false; - if (e.source === iter.id && intHandleIds.has(e.sourceHandle ?? "")) return false; + if (e.target === iter.id && extHandleIds.has(e.targetHandle ?? "")) + return false; + if (e.source === iter.id && intHandleIds.has(e.sourceHandle ?? "")) + return false; } else { - if (e.source === iter.id && extHandleIds.has(e.sourceHandle ?? "")) return false; - if (e.target === iter.id && intHandleIds.has(e.targetHandle ?? "")) return false; + if (e.source === iter.id && extHandleIds.has(e.sourceHandle ?? "")) + return false; + if (e.target === iter.id && intHandleIds.has(e.targetHandle ?? "")) + return false; } return true; }); @@ -106,7 +124,10 @@ function purgeExposedParamsForChildren( ...n, data: { ...n.data, - params: { ...n.data.params, [paramListKey]: JSON.stringify(filtered) }, + params: { + ...n.data.params, + [paramListKey]: JSON.stringify(filtered), + }, [defKey]: filteredDefs, }, }; @@ -116,7 +137,9 @@ function purgeExposedParamsForChildren( // Also clean up childNodeIds if (dirty) { const currentChildIds: string[] = iter.data.childNodeIds ?? []; - const filteredChildIds = currentChildIds.filter((id: string) => !removedChildIds.has(id)); + const filteredChildIds = currentChildIds.filter( + (id: string) => !removedChildIds.has(id), + ); if (filteredChildIds.length !== currentChildIds.length) { updatedNodes = updatedNodes.map((n) => { if (n.id !== iter.id) return n; @@ -400,8 +423,26 @@ export interface WorkflowState { releaseNode: (iteratorId: string, childId: string) => void; updateBoundingBox: (iteratorId: string) => void; exposeParam: (iteratorId: string, param: ExposedParam) => void; - unexposeParam: (iteratorId: string, namespacedKey: string, direction: "input" | "output") => void; - syncExposedParamsOnModelSwitch: (childNodeId: string, newLabel: string, newInputParamKeys: string[]) => void; + unexposeParam: ( + iteratorId: string, + namespacedKey: string, + direction: "input" | "output", + ) => void; + updateExposedParamAlias: ( + iteratorId: string, + namespacedKey: string, + direction: "input" | "output", + alias: string, + ) => void; + syncExposedParamsOnModelSwitch: ( + childNodeId: string, + newLabel: string, + newInputParamKeys: string[], + ) => void; + importWorkflowIntoGroup: ( + groupId: string, + sourceWorkflowId: string, + ) => Promise; reset: () => void; } @@ -439,6 +480,61 @@ export const useWorkflowStore = create((set, get) => ({ outputDefinitions: outputDefs, }, }; + + // For HTTP Trigger: build dynamic outputDefinitions from default outputFields param + if ( + type === "trigger/http" && + outputDefs.length === 0 && + defaultParams.outputFields + ) { + try { + const raw = defaultParams.outputFields; + const fields: Array<{ key: string; label: string; type: string }> = + typeof raw === "string" + ? JSON.parse(raw) + : Array.isArray(raw) + ? raw + : []; + if (fields.length > 0) { + newNode.data.outputDefinitions = fields.map((f) => ({ + key: f.key, + label: f.label || f.key, + dataType: f.type || "any", + required: true, + })); + } + } catch { + /* keep empty */ + } + } + + // For HTTP Response: build dynamic inputDefinitions from default responseFields param + if ( + type === "output/http-response" && + inputDefs.length === 0 && + defaultParams.responseFields + ) { + try { + const raw = defaultParams.responseFields; + const fields: Array<{ key: string; label: string; type: string }> = + typeof raw === "string" + ? JSON.parse(raw) + : Array.isArray(raw) + ? raw + : []; + if (fields.length > 0) { + newNode.data.inputDefinitions = fields.map((f) => ({ + key: f.key, + label: f.label || f.key, + dataType: f.type || "any", + required: false, + })); + } + } catch { + /* keep empty */ + } + } + set((state) => ({ nodes: [...state.nodes, newNode], isDirty: true, @@ -757,9 +853,73 @@ export const useWorkflowStore = create((set, get) => ({ updateNodeParams: (nodeId, params) => { const { nodes, edges } = get(); pushUndoDebounced({ nodes, edges }); + + // Dynamic port sync for HTTP Trigger and HTTP Response nodes + let extraData: Record | null = null; + const node = nodes.find((n) => n.id === nodeId); + + if ( + node?.data?.nodeType === "trigger/http" && + params.outputFields !== undefined + ) { + try { + const raw = params.outputFields; + const fields: Array<{ key: string; label: string; type: string }> = + typeof raw === "string" + ? JSON.parse(raw) + : Array.isArray(raw) + ? raw + : []; + extraData = { + outputDefinitions: fields.map((f) => ({ + key: f.key, + label: f.label || f.key, + dataType: f.type || "any", + required: true, + })), + }; + } catch { + /* invalid JSON — leave unchanged */ + } + } + + if ( + node?.data?.nodeType === "output/http-response" && + params.responseFields !== undefined + ) { + try { + const raw = params.responseFields; + const fields: Array<{ key: string; label: string; type: string }> = + typeof raw === "string" + ? JSON.parse(raw) + : Array.isArray(raw) + ? raw + : []; + extraData = { + inputDefinitions: fields.map((f) => ({ + key: f.key, + label: f.label || f.key, + dataType: f.type || "any", + required: true, + })), + }; + } catch { + /* invalid JSON — leave unchanged */ + } + } + set((state) => ({ nodes: state.nodes.map((n) => - n.id === nodeId ? { ...n, data: { ...n.data, params } } : n, + n.id === nodeId + ? { + ...n, + data: { + ...n.data, + params, + ...(extraData ?? {}), + }, + } + : n, ), isDirty: true, canUndo: true, @@ -767,8 +927,6 @@ export const useWorkflowStore = create((set, get) => ({ })); // If this node is a child of an Iterator, recalculate the bounding box - // so the Iterator auto-expands when child node size changes - const node = nodes.find((n) => n.id === nodeId); if (node?.parentNode) { get().updateBoundingBox(node.parentNode); } @@ -935,6 +1093,50 @@ export const useWorkflowStore = create((set, get) => ({ const { __meta: _, ...cleanParams } = n.params as Record; const isIterator = n.nodeType === "control/iterator"; + // Rebuild dynamic port definitions for HTTP Trigger / HTTP Response + let inputDefs = def?.inputs ?? []; + let outputDefs = def?.outputs ?? []; + + if (n.nodeType === "trigger/http" && cleanParams.outputFields) { + try { + const raw = cleanParams.outputFields; + const fields: Array<{ key: string; label: string; type: string }> = + typeof raw === "string" + ? JSON.parse(raw) + : Array.isArray(raw) + ? raw + : []; + outputDefs = fields.map((f) => ({ + key: f.key, + label: f.label || f.key, + dataType: f.type || "any", + required: true, + })); + } catch { + /* keep static defs */ + } + } + + if (n.nodeType === "output/http-response" && cleanParams.responseFields) { + try { + const raw = cleanParams.responseFields; + const fields: Array<{ key: string; label: string; type: string }> = + typeof raw === "string" + ? JSON.parse(raw) + : Array.isArray(raw) + ? raw + : []; + inputDefs = fields.map((f) => ({ + key: f.key, + label: f.label || f.key, + dataType: f.type || "any", + required: true, + })); + } catch { + /* keep static defs */ + } + } + const rfNode: ReactFlowNode = { id: n.id, type: isIterator ? "control/iterator" : "custom", @@ -945,8 +1147,8 @@ export const useWorkflowStore = create((set, get) => ({ label, modelInputSchema: modelInputSchema ?? [], paramDefinitions: def?.params ?? [], - inputDefinitions: def?.inputs ?? [], - outputDefinitions: def?.outputs ?? [], + inputDefinitions: inputDefs, + outputDefinitions: outputDefs, ...(isIterator ? { childNodeIds: iteratorChildMap.get(n.id) ?? [] } : {}), @@ -956,7 +1158,6 @@ export const useWorkflowStore = create((set, get) => ({ // Restore parent-child relationship for sub-nodes if (n.parentNodeId) { rfNode.parentNode = n.parentNodeId; - rfNode.extent = "parent" as const; } return rfNode; @@ -978,11 +1179,67 @@ export const useWorkflowStore = create((set, get) => ({ isDirty: false, }); + // Exit subgraph editing when loading a different workflow + try { + const { useUIStore } = await import("./ui.store"); + useUIStore.getState().exitGroupEdit(); + } catch { + /* ignore */ + } + // Recalculate bounding boxes for all iterator nodes for (const iteratorId of iteratorChildMap.keys()) { get().updateBoundingBox(iteratorId); } + // Clamp child nodes within their parent iterator bounds after load + { + const currentNodes = get().nodes; + const clampUpdates: Array<{ + nodeId: string; + pos: { x: number; y: number }; + }> = []; + for (const iteratorId of iteratorChildMap.keys()) { + const itNode = currentNodes.find((n) => n.id === iteratorId); + if (!itNode) continue; + const itW = + (itNode.data?.params?.__nodeWidth as number) ?? MIN_ITERATOR_WIDTH; + const itH = + (itNode.data?.params?.__nodeHeight as number) ?? MIN_ITERATOR_HEIGHT; + const children = currentNodes.filter( + (n) => n.parentNode === iteratorId, + ); + for (const child of children) { + const cw = (child.data?.params?.__nodeWidth as number) ?? 300; + const ch = (child.data?.params?.__nodeHeight as number) ?? 80; + const minX = CHILD_PADDING; + const maxX = Math.max(minX, itW - cw - CHILD_PADDING); + const minY = CHILD_PADDING + 40; // 40 = title bar + const maxY = Math.max(minY, itH - ch - CHILD_PADDING - 40); + const cx = Math.min(Math.max(child.position.x, minX), maxX); + const cy = Math.min(Math.max(child.position.y, minY), maxY); + if (cx !== child.position.x || cy !== child.position.y) { + clampUpdates.push({ nodeId: child.id, pos: { x: cx, y: cy } }); + } + } + } + if (clampUpdates.length > 0) { + set((state) => ({ + nodes: state.nodes.map((n) => { + const upd = clampUpdates.find((u) => u.nodeId === n.id); + return upd ? { ...n, position: upd.pos } : n; + }), + })); + } + } + + // Notify canvas to restore viewport + if (typeof window !== "undefined") { + window.dispatchEvent( + new CustomEvent("workflow:loaded", { detail: { workflowId: wf.id } }), + ); + } + // Restore previous execution results for all nodes try { const executionStoreModule = await import("./execution.store"); @@ -1053,8 +1310,7 @@ export const useWorkflowStore = create((set, get) => ({ }; // Update childNodeIds in iterator data - const currentChildIds: string[] = - iteratorNode.data.childNodeIds ?? []; + const currentChildIds: string[] = iteratorNode.data.childNodeIds ?? []; const updatedChildIds = currentChildIds.includes(childId) ? currentChildIds : [...currentChildIds, childId]; @@ -1066,7 +1322,6 @@ export const useWorkflowStore = create((set, get) => ({ ...n, position: relativePosition, parentNode: iteratorId, - extent: "parent" as const, data: { ...n.data }, }; } @@ -1103,7 +1358,11 @@ export const useWorkflowStore = create((set, get) => ({ pushUndo({ nodes, edges }); // Purge exposed params for this child from the iterator - const purged = purgeExposedParamsForChildren(new Set([childId]), nodes, edges); + const purged = purgeExposedParamsForChildren( + new Set([childId]), + nodes, + edges, + ); // Convert child position back to absolute coordinates const absolutePosition = { @@ -1141,8 +1400,10 @@ export const useWorkflowStore = create((set, get) => ({ const children = nodes.filter((n) => n.parentNode === iteratorId); const currentParams = iteratorNode.data.params ?? {}; - const currentW = (currentParams.__nodeWidth as number) ?? MIN_ITERATOR_WIDTH; - const currentH = (currentParams.__nodeHeight as number) ?? MIN_ITERATOR_HEIGHT; + const currentW = + (currentParams.__nodeWidth as number) ?? MIN_ITERATOR_WIDTH; + const currentH = + (currentParams.__nodeHeight as number) ?? MIN_ITERATOR_HEIGHT; // Only expand — never shrink. If children fit inside the current size, do nothing. let requiredWidth = MIN_ITERATOR_WIDTH; @@ -1156,9 +1417,13 @@ export const useWorkflowStore = create((set, get) => ({ // Use DOM measurement for height when available (child nodes auto-size vertically) let ch = (child.data?.params?.__nodeHeight as number) ?? 80; try { - const el = document.querySelector(`[data-id="${child.id}"]`) as HTMLElement | null; + const el = document.querySelector( + `[data-id="${child.id}"]`, + ) as HTMLElement | null; if (el) ch = Math.max(ch, el.offsetHeight); - } catch { /* ignore DOM errors */ } + } catch { + /* ignore DOM errors */ + } const right = child.position.x + cw + CHILD_PADDING; const bottom = child.position.y + ch + CHILD_PADDING; if (right > maxRight) maxRight = right; @@ -1205,8 +1470,10 @@ export const useWorkflowStore = create((set, get) => ({ pushUndo({ nodes, edges }); const params = iteratorNode.data.params ?? {}; - const paramListKey = param.direction === "input" ? "exposedInputs" : "exposedOutputs"; - const defKey = param.direction === "input" ? "inputDefinitions" : "outputDefinitions"; + const paramListKey = + param.direction === "input" ? "exposedInputs" : "exposedOutputs"; + const defKey = + param.direction === "input" ? "inputDefinitions" : "outputDefinitions"; // Parse existing exposed params const currentList: ExposedParam[] = (() => { @@ -1219,7 +1486,12 @@ export const useWorkflowStore = create((set, get) => ({ })(); // Don't add duplicates - if (currentList.some((p: ExposedParam) => p.namespacedKey === param.namespacedKey)) return; + if ( + currentList.some( + (p: ExposedParam) => p.namespacedKey === param.namespacedKey, + ) + ) + return; const updatedList = [...currentList, param]; @@ -1228,13 +1500,13 @@ export const useWorkflowStore = create((set, get) => ({ .split("_") .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) .join(" "); - // Shorten the node label for display (e.g. "bytedance/seedream-v3" → "seedream-v3") + // Use the subNodeLabel passed from the picker (may include #N numbering for duplicates) const shortNodeLabel = param.subNodeLabel.includes("/") ? param.subNodeLabel.split("/").pop()! : param.subNodeLabel; const newPort: PortDefinition = { key: param.namespacedKey, - label: `${readableParam} · ${shortNodeLabel}`, + label: param.alias || `${readableParam} · ${shortNodeLabel}`, dataType: param.dataType, required: false, }; @@ -1251,9 +1523,14 @@ export const useWorkflowStore = create((set, get) => ({ // The child node's input handle is either `input-{key}` or `param-{key}` // For ai-task model schema fields, the handle is `param-{key}` const childNode = nodes.find((n) => n.id === param.subNodeId); - const childInputDefs = (childNode?.data?.inputDefinitions ?? []) as PortDefinition[]; - const hasInputPort = childInputDefs.some((d: PortDefinition) => d.key === param.paramKey); - const targetHandle = hasInputPort ? `input-${param.paramKey}` : `param-${param.paramKey}`; + const childInputDefs = (childNode?.data?.inputDefinitions ?? + []) as PortDefinition[]; + const hasInputPort = childInputDefs.some( + (d: PortDefinition) => d.key === param.paramKey, + ); + const targetHandle = hasInputPort + ? `input-${param.paramKey}` + : `param-${param.paramKey}`; autoEdge = { id: autoEdgeId, @@ -1310,8 +1587,10 @@ export const useWorkflowStore = create((set, get) => ({ pushUndo({ nodes, edges }); const params = iteratorNode.data.params ?? {}; - const paramListKey = direction === "input" ? "exposedInputs" : "exposedOutputs"; - const defKey = direction === "input" ? "inputDefinitions" : "outputDefinitions"; + const paramListKey = + direction === "input" ? "exposedInputs" : "exposedOutputs"; + const defKey = + direction === "input" ? "inputDefinitions" : "outputDefinitions"; // Parse existing exposed params and remove the matching entry const currentList: ExposedParam[] = (() => { @@ -1334,12 +1613,14 @@ export const useWorkflowStore = create((set, get) => ({ ); // Remove any connected edges to/from both external and internal handles - const extHandleId = direction === "input" - ? `input-${namespacedKey}` - : `output-${namespacedKey}`; - const intHandleId = direction === "input" - ? `input-inner-${namespacedKey}` - : `output-inner-${namespacedKey}`; + const extHandleId = + direction === "input" + ? `input-${namespacedKey}` + : `output-${namespacedKey}`; + const intHandleId = + direction === "input" + ? `input-inner-${namespacedKey}` + : `output-inner-${namespacedKey}`; const autoEdgeId = `iter-auto-${iteratorId}-${namespacedKey}`; const edgeIdsToRemove = new Set(); @@ -1348,14 +1629,18 @@ export const useWorkflowStore = create((set, get) => ({ for (const e of edges) { if (direction === "input") { // External edges connecting to the external target handle - if (e.target === iteratorId && e.targetHandle === extHandleId) edgeIdsToRemove.add(e.id); + if (e.target === iteratorId && e.targetHandle === extHandleId) + edgeIdsToRemove.add(e.id); // Internal edges from the inner source handle - if (e.source === iteratorId && e.sourceHandle === intHandleId) edgeIdsToRemove.add(e.id); + if (e.source === iteratorId && e.sourceHandle === intHandleId) + edgeIdsToRemove.add(e.id); } else { // External edges from the external source handle - if (e.source === iteratorId && e.sourceHandle === extHandleId) edgeIdsToRemove.add(e.id); + if (e.source === iteratorId && e.sourceHandle === extHandleId) + edgeIdsToRemove.add(e.id); // Internal edges to the inner target handle - if (e.target === iteratorId && e.targetHandle === intHandleId) edgeIdsToRemove.add(e.id); + if (e.target === iteratorId && e.targetHandle === intHandleId) + edgeIdsToRemove.add(e.id); } } @@ -1376,15 +1661,62 @@ export const useWorkflowStore = create((set, get) => ({ } return n; }), - edges: edgeIdsToRemove.size > 0 - ? state.edges.filter((e) => !edgeIdsToRemove.has(e.id)) - : state.edges, + edges: + edgeIdsToRemove.size > 0 + ? state.edges.filter((e) => !edgeIdsToRemove.has(e.id)) + : state.edges, isDirty: true, canUndo: true, canRedo: false, })); }, + updateExposedParamAlias: (iteratorId, namespacedKey, direction, alias) => { + const { nodes } = get(); + const iteratorNode = nodes.find((n) => n.id === iteratorId); + if (!iteratorNode) return; + + const paramListKey = + direction === "input" ? "exposedInputs" : "exposedOutputs"; + const currentList: ExposedParam[] = (() => { + try { + const raw = iteratorNode.data.params?.[paramListKey]; + return typeof raw === "string" + ? JSON.parse(raw) + : Array.isArray(raw) + ? raw + : []; + } catch { + return []; + } + })(); + + const updatedList = currentList.map((p: ExposedParam) => + p.namespacedKey === namespacedKey + ? { ...p, alias: alias || undefined } + : p, + ); + + set((state) => ({ + nodes: state.nodes.map((n) => { + if (n.id === iteratorId) { + return { + ...n, + data: { + ...n.data, + params: { + ...n.data.params, + [paramListKey]: JSON.stringify(updatedList), + }, + }, + }; + } + return n; + }), + isDirty: true, + })); + }, + /** * Sync exposed params when a child node inside an iterator switches models. * @@ -1395,14 +1727,19 @@ export const useWorkflowStore = create((set, get) => ({ * 4. Output always exists (key="output") → keep it, update label with new model name * 5. Multiple child nodes in same iterator → only affect the switched node's params */ - syncExposedParamsOnModelSwitch: (childNodeId, newLabel, newInputParamKeys) => { + syncExposedParamsOnModelSwitch: ( + childNodeId, + newLabel, + newInputParamKeys, + ) => { const { nodes, edges } = get(); const childNode = nodes.find((n) => n.id === childNodeId); if (!childNode?.parentNode) return; // not inside an iterator const iteratorId = childNode.parentNode; const iteratorNode = nodes.find((n) => n.id === iteratorId); - if (!iteratorNode || iteratorNode.data.nodeType !== "control/iterator") return; + if (!iteratorNode || iteratorNode.data.nodeType !== "control/iterator") + return; const params = iteratorNode.data.params ?? {}; const newInputKeySet = new Set(newInputParamKeys); @@ -1411,14 +1748,22 @@ export const useWorkflowStore = create((set, get) => ({ let dirty = false; for (const direction of ["input", "output"] as const) { - const paramListKey = direction === "input" ? "exposedInputs" : "exposedOutputs"; - const defKey = direction === "input" ? "inputDefinitions" : "outputDefinitions"; + const paramListKey = + direction === "input" ? "exposedInputs" : "exposedOutputs"; + const defKey = + direction === "input" ? "inputDefinitions" : "outputDefinitions"; const currentList: ExposedParam[] = (() => { try { const raw = params[paramListKey]; - return typeof raw === "string" ? JSON.parse(raw) : Array.isArray(raw) ? raw : []; - } catch { return []; } + return typeof raw === "string" + ? JSON.parse(raw) + : Array.isArray(raw) + ? raw + : []; + } catch { + return []; + } })(); // Only process params belonging to this child node @@ -1437,7 +1782,11 @@ export const useWorkflowStore = create((set, get) => ({ // Param still valid → update label and namespacedKey const oldNk = ep.namespacedKey; const newNk = `${newLabel}.${ep.paramKey}`; - const updated: ExposedParam = { ...ep, subNodeLabel: newLabel, namespacedKey: newNk }; + const updated: ExposedParam = { + ...ep, + subNodeLabel: newLabel, + namespacedKey: newNk, + }; kept.push(updated); if (oldNk !== newNk) { @@ -1447,24 +1796,44 @@ export const useWorkflowStore = create((set, get) => ({ updatedEdges = updatedEdges.map((e) => { if (e.id === oldAutoId) { if (direction === "input") { - return { ...e, id: newAutoId, sourceHandle: `input-inner-${newNk}` }; + return { + ...e, + id: newAutoId, + sourceHandle: `input-inner-${newNk}`, + }; } else { - return { ...e, id: newAutoId, targetHandle: `output-inner-${newNk}` }; + return { + ...e, + id: newAutoId, + targetHandle: `output-inner-${newNk}`, + }; } } // Update external edges referencing old handle IDs if (direction === "input") { - if (e.target === iteratorId && e.targetHandle === `input-${oldNk}`) { + if ( + e.target === iteratorId && + e.targetHandle === `input-${oldNk}` + ) { return { ...e, targetHandle: `input-${newNk}` }; } - if (e.source === iteratorId && e.sourceHandle === `input-inner-${oldNk}`) { + if ( + e.source === iteratorId && + e.sourceHandle === `input-inner-${oldNk}` + ) { return { ...e, sourceHandle: `input-inner-${newNk}` }; } } else { - if (e.source === iteratorId && e.sourceHandle === `output-${oldNk}`) { + if ( + e.source === iteratorId && + e.sourceHandle === `output-${oldNk}` + ) { return { ...e, sourceHandle: `output-${newNk}` }; } - if (e.target === iteratorId && e.targetHandle === `output-inner-${oldNk}`) { + if ( + e.target === iteratorId && + e.targetHandle === `output-inner-${oldNk}` + ) { return { ...e, targetHandle: `output-inner-${newNk}` }; } } @@ -1491,11 +1860,27 @@ export const useWorkflowStore = create((set, get) => ({ updatedEdges = updatedEdges.filter((e) => { if (removeAutoIds.has(e.id)) return false; if (direction === "input") { - if (e.target === iteratorId && removeHandles.has(e.targetHandle ?? "")) return false; - if (e.source === iteratorId && removeHandles.has(e.sourceHandle ?? "")) return false; + if ( + e.target === iteratorId && + removeHandles.has(e.targetHandle ?? "") + ) + return false; + if ( + e.source === iteratorId && + removeHandles.has(e.sourceHandle ?? "") + ) + return false; } else { - if (e.source === iteratorId && removeHandles.has(e.sourceHandle ?? "")) return false; - if (e.target === iteratorId && removeHandles.has(e.targetHandle ?? "")) return false; + if ( + e.source === iteratorId && + removeHandles.has(e.sourceHandle ?? "") + ) + return false; + if ( + e.target === iteratorId && + removeHandles.has(e.targetHandle ?? "") + ) + return false; } return true; }); @@ -1504,11 +1889,23 @@ export const useWorkflowStore = create((set, get) => ({ const updatedList = [...others, ...kept]; const currentDefs: PortDefinition[] = iteratorNode.data[defKey] ?? []; // Rebuild defs: keep others' defs, rebuild this child's defs from kept params - const otherDefs = currentDefs.filter((d) => !mine.some((m) => m.namespacedKey === d.key)); + const otherDefs = currentDefs.filter( + (d) => !mine.some((m) => m.namespacedKey === d.key), + ); const keptDefs = kept.map((ep): PortDefinition => { - const readableParam = ep.paramKey.split("_").map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" "); - const shortLabel = ep.subNodeLabel.includes("/") ? ep.subNodeLabel.split("/").pop()! : ep.subNodeLabel; - return { key: ep.namespacedKey, label: `${readableParam} · ${shortLabel}`, dataType: ep.dataType, required: false }; + const readableParam = ep.paramKey + .split("_") + .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) + .join(" "); + const shortLabel = ep.subNodeLabel.includes("/") + ? ep.subNodeLabel.split("/").pop()! + : ep.subNodeLabel; + return { + key: ep.namespacedKey, + label: `${readableParam} · ${shortLabel}`, + dataType: ep.dataType, + required: false, + }; }); updatedNodes = updatedNodes.map((n) => { @@ -1517,7 +1914,10 @@ export const useWorkflowStore = create((set, get) => ({ ...n, data: { ...n.data, - params: { ...n.data.params, [paramListKey]: JSON.stringify(updatedList) }, + params: { + ...n.data.params, + [paramListKey]: JSON.stringify(updatedList), + }, [defKey]: [...otherDefs, ...keptDefs], }, }; @@ -1530,6 +1930,201 @@ export const useWorkflowStore = create((set, get) => ({ } }, + importWorkflowIntoGroup: async (groupId, sourceWorkflowId) => { + const { nodes, edges } = get(); + const groupNode = nodes.find((n) => n.id === groupId); + if (!groupNode) return; + + // Load source workflow + const srcWf = await workflowIpc.load(sourceWorkflowId); + const srcNodes = srcWf.graphDefinition.nodes; + const srcEdges = srcWf.graphDefinition.edges; + if (srcNodes.length === 0) return; + + pushUndo({ nodes, edges }); + + // Build old→new ID mapping for cloned nodes + const idMap = new Map(); + for (const sn of srcNodes) { + idMap.set(sn.id, uuid()); + } + + // Fetch node type definitions for label/param/port restoration + let defMap = new Map< + string, + { + params: unknown[]; + inputs: unknown[]; + outputs: unknown[]; + label: string; + } + >(); + try { + const defs = await registryIpc.getAll(); + defMap = new Map( + defs.map((d) => [ + d.type, + { + params: d.params ?? [], + inputs: d.inputs ?? [], + outputs: d.outputs ?? [], + label: d.label ?? d.type, + }, + ]), + ); + } catch { + /* fallback */ + } + + // Clone nodes — skip nodes that are children of iterators within the source + // (they'll be handled when their parent iterator is cloned) + const clonedRfNodes: ReactFlowNode[] = []; + for (const sn of srcNodes) { + const newId = idMap.get(sn.id)!; + const meta = (sn.params as Record).__meta as + | Record + | undefined; + const def = defMap.get(sn.nodeType); + const label = (meta?.label as string) || (def ? def.label : sn.nodeType); + const { __meta: _, ...cleanParams } = sn.params as Record< + string, + unknown + >; + const isIterator = sn.nodeType === "control/iterator"; + + // If this node has a parent within the source workflow, map it + const srcParent = sn.parentNodeId; + const newParent = srcParent ? idMap.get(srcParent) : undefined; + + const rfNode: ReactFlowNode = { + id: newId, + type: isIterator ? "control/iterator" : "custom", + position: sn.position, + data: { + nodeType: sn.nodeType, + params: cleanParams, + label, + modelInputSchema: meta?.modelInputSchema ?? [], + paramDefinitions: def?.params ?? [], + inputDefinitions: def?.inputs ?? [], + outputDefinitions: def?.outputs ?? [], + ...(isIterator ? { childNodeIds: [] as string[] } : {}), + }, + }; + + // If this node was a child of an iterator in the source, keep that relationship + if (newParent) { + rfNode.parentNode = newParent; + } + + clonedRfNodes.push(rfNode); + } + + // Rebuild childNodeIds for cloned iterators + for (const sn of srcNodes) { + if (sn.parentNodeId && idMap.has(sn.parentNodeId)) { + const parentNewId = idMap.get(sn.parentNodeId)!; + const parentNode = clonedRfNodes.find((n) => n.id === parentNewId); + if (parentNode?.data?.childNodeIds) { + parentNode.data.childNodeIds.push(idMap.get(sn.id)!); + } + } + } + + // Remap exposed params in cloned iterator nodes + for (const cn of clonedRfNodes) { + if (cn.data.nodeType !== "control/iterator") continue; + for (const paramKey of ["exposedInputs", "exposedOutputs"] as const) { + try { + const raw = cn.data.params[paramKey]; + const list: ExposedParam[] = + typeof raw === "string" + ? JSON.parse(raw) + : Array.isArray(raw) + ? raw + : []; + if (list.length === 0) continue; + const remapped = list.map((ep) => ({ + ...ep, + subNodeId: idMap.get(ep.subNodeId) ?? ep.subNodeId, + })); + cn.data.params[paramKey] = JSON.stringify(remapped); + } catch { + /* ignore */ + } + } + } + + // Clone edges — remap source/target IDs + const clonedEdges: ReactFlowEdge[] = srcEdges + .filter((e) => idMap.has(e.sourceNodeId) && idMap.has(e.targetNodeId)) + .map((e) => ({ + id: uuid(), + source: idMap.get(e.sourceNodeId)!, + target: idMap.get(e.targetNodeId)!, + sourceHandle: e.sourceOutputKey, + targetHandle: e.targetInputKey, + type: "custom", + ...(e.isInternal ? { data: { isInternal: true } } : {}), + })); + + // Now adopt all top-level cloned nodes into the group + // (nodes that already have a parent within the source stay nested) + const topLevelCloned = clonedRfNodes.filter((n) => !n.parentNode); + + // Compute offset so cloned nodes are positioned relative to group + // Place them starting at a reasonable internal position + const startX = 60; + const startY = 80; + // Find bounding box of top-level source nodes to compute offset + let minX = Infinity, + minY = Infinity; + for (const n of topLevelCloned) { + if (n.position.x < minX) minX = n.position.x; + if (n.position.y < minY) minY = n.position.y; + } + const offsetX = startX - minX; + const offsetY = startY - minY; + + // Apply offset and set parentNode for top-level nodes + for (const n of topLevelCloned) { + n.position = { x: n.position.x + offsetX, y: n.position.y + offsetY }; + n.parentNode = groupId; + } + + // Also offset nested children (children of iterators within the source keep their relative positions) + + // Build updated childNodeIds for the group + const existingChildIds: string[] = groupNode.data.childNodeIds ?? []; + const newChildIds = [ + ...existingChildIds, + ...topLevelCloned.map((n) => n.id), + ]; + + set((state) => ({ + nodes: [ + ...state.nodes.map((n) => + n.id === groupId + ? { ...n, data: { ...n.data, childNodeIds: newChildIds } } + : n, + ), + ...clonedRfNodes, + ], + edges: [...state.edges, ...clonedEdges], + isDirty: true, + canUndo: true, + canRedo: false, + })); + + // Auto-save + const wfId = get().workflowId; + if (wfId) { + setTimeout(() => { + get().saveWorkflow().catch(console.error); + }, 100); + } + }, + reset: () => { const { nodes, edges } = getDefaultNewWorkflowContent(); set({ diff --git a/src/workflow/types/node-defs.ts b/src/workflow/types/node-defs.ts index e4293b42..44c2f4fb 100644 --- a/src/workflow/types/node-defs.ts +++ b/src/workflow/types/node-defs.ts @@ -40,6 +40,7 @@ export interface ParamDefinition { } export type NodeCategory = + | "trigger" | "input" | "ai-task" | "ai-generation" diff --git a/src/workflow/types/workflow.ts b/src/workflow/types/workflow.ts index 36ce3588..7166c24e 100644 --- a/src/workflow/types/workflow.ts +++ b/src/workflow/types/workflow.ts @@ -49,4 +49,5 @@ export interface ExposedParam { namespacedKey: string; direction: "input" | "output"; dataType: PortDataType; + alias?: string; } From ea0fe68d5ba4019efbb4a03e1537f3b21cbe56e8 Mon Sep 17 00:00:00 2001 From: linqiquan <735525520@qq.com> Date: Thu, 19 Mar 2026 02:23:00 +0800 Subject: [PATCH 06/18] feat: add multi-run (gacha) per node and output selection - Split run button with count picker (1/2/3/4/5/8/10x) on each node - Sequential runs with auto-randomized seed perturbation - Select which run result to use as downstream output - 'Use as output' button in results panel (stacked + full list views) - 'Use as output' button in fullscreen preview overlay - Preview overlay improvements - Video navigation support (prev/next arrows, thumbnail strip) - Thumbnail strip for multi-result navigation (images + videos) - Adjusted max-height for navigable previews - Reset stack index on new results arrival - Use selectedOutputIndex when resolving upstream results for execution - Add i18n strings for selectAsOutput/selectedAsOutput (all 18 locales) --- src/i18n/locales/ar.json | 2 + src/i18n/locales/de.json | 2 + src/i18n/locales/en.json | 2 + src/i18n/locales/es.json | 2 + src/i18n/locales/fr.json | 2 + src/i18n/locales/hi.json | 2 + src/i18n/locales/id.json | 2 + src/i18n/locales/it.json | 2 + src/i18n/locales/ja.json | 2 + src/i18n/locales/ko.json | 2 + src/i18n/locales/ms.json | 2 + src/i18n/locales/pt.json | 2 + src/i18n/locales/ru.json | 2 + src/i18n/locales/th.json | 2 + src/i18n/locales/tr.json | 2 + src/i18n/locales/vi.json | 2 + src/i18n/locales/zh-CN.json | 2 + src/i18n/locales/zh-TW.json | 2 + src/workflow/WorkflowPage.tsx | 229 ++++++++++++++++-- .../canvas/custom-node/CustomNode.tsx | 103 ++++++-- .../components/panels/ResultsPanel.tsx | 92 ++++++- src/workflow/stores/execution.store.ts | 25 +- 22 files changed, 437 insertions(+), 48 deletions(-) diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index ceeeff1c..a3318fb4 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -1137,6 +1137,8 @@ "modelIdLabel": "معرف النموذج", "selectNode": "اختر عقدة للتكوين", "noExecutions": "لا توجد عمليات تنفيذ بعد", + "selectAsOutput": "استخدام كمخرج", + "selectedAsOutput": "تم التحديد", "budgetExceeded": "تجاوز الميزانية", "dailySpend": "الإنفاق اليومي", "perExecutionLimit": "حد التنفيذ لكل عملية", diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 210f074f..4db6728c 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -1138,6 +1138,8 @@ "modelIdLabel": "Modell-ID", "selectNode": "Wählen Sie einen Knoten zur Konfiguration", "noExecutions": "Noch keine Ausführungen", + "selectAsOutput": "Als Ausgabe verwenden", + "selectedAsOutput": "Ausgewählt", "budgetExceeded": "Budget überschritten", "dailySpend": "Tägliche Ausgaben", "perExecutionLimit": "Limite pro Ausführung", diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index a5335645..49932f52 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1209,6 +1209,8 @@ "modelIdLabel": "Model ID", "selectNode": "Select a node to configure", "noExecutions": "No executions yet", + "selectAsOutput": "Use as output", + "selectedAsOutput": "Selected", "budgetExceeded": "Budget exceeded", "dailySpend": "Daily Spend", "perExecutionLimit": "Per-Execution Limit", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index f2f49e69..78a347eb 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -1137,6 +1137,8 @@ "modelIdLabel": "ID del modelo", "selectNode": "Seleccione un nodo para configurar", "noExecutions": "Aún no hay ejecuciones", + "selectAsOutput": "Usar como salida", + "selectedAsOutput": "Seleccionado", "budgetExceeded": "Presupuesto superado", "dailySpend": "Gasto diario", "perExecutionLimit": "Límite por ejecución", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 2d67afc7..dbc85518 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -1138,6 +1138,8 @@ "modelIdLabel": "ID du modèle", "selectNode": "Sélectionnez un nœud à configurer", "noExecutions": "Pas encore d'exécutions", + "selectAsOutput": "Utiliser comme sortie", + "selectedAsOutput": "Sélectionné", "budgetExceeded": "Budget dépassé", "dailySpend": "Dépenses quotidiennes", "perExecutionLimit": "Limite par exécution", diff --git a/src/i18n/locales/hi.json b/src/i18n/locales/hi.json index 081cb0e7..09045b16 100644 --- a/src/i18n/locales/hi.json +++ b/src/i18n/locales/hi.json @@ -1137,6 +1137,8 @@ "modelIdLabel": "मॉडल ID", "selectNode": "कॉन्फ़िगर करने के लिए नोड चुनें", "noExecutions": "अभी तक कोई निष्पादन नहीं", + "selectAsOutput": "आउटपुट के रूप में उपयोग करें", + "selectedAsOutput": "चयनित", "budgetExceeded": "बजट पार", "dailySpend": "दैनिक खर्च", "perExecutionLimit": "प्रति निष्पादन सीमा", diff --git a/src/i18n/locales/id.json b/src/i18n/locales/id.json index c9da1054..35802b11 100644 --- a/src/i18n/locales/id.json +++ b/src/i18n/locales/id.json @@ -1137,6 +1137,8 @@ "modelIdLabel": "ID model", "selectNode": "Pilih node untuk dikonfigurasi", "noExecutions": "Belum ada eksekusi", + "selectAsOutput": "Gunakan sebagai output", + "selectedAsOutput": "Terpilih", "budgetExceeded": "Anggaran terlampaui", "dailySpend": "Pengeluaran harian", "perExecutionLimit": "Batas per eksekusi", diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index 58f79fd9..3738b97c 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -1137,6 +1137,8 @@ "modelIdLabel": "ID modello", "selectNode": "Seleziona un nodo da configurare", "noExecutions": "Nessuna esecuzione ancora", + "selectAsOutput": "Usa come output", + "selectedAsOutput": "Selezionato", "budgetExceeded": "Budget superato", "dailySpend": "Spesa giornaliera", "perExecutionLimit": "Limite per esecuzione", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 475f1ca4..c6beb8ef 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1468,6 +1468,8 @@ "exportFailed": "エクスポートに失敗しました", "estimated": "推定コスト", "noExecutions": "実行履歴がまだありません", + "selectAsOutput": "出力として使用", + "selectedAsOutput": "選択済み", "budgetExceeded": "予算超過", "dailySpend": "1日の支出", "perExecutionLimit": "実行ごとの制限", diff --git a/src/i18n/locales/ko.json b/src/i18n/locales/ko.json index ceb91498..b57340fd 100644 --- a/src/i18n/locales/ko.json +++ b/src/i18n/locales/ko.json @@ -1138,6 +1138,8 @@ "modelIdLabel": "모델 ID", "selectNode": "구성할 노드 선택", "noExecutions": "아직 실행 없음", + "selectAsOutput": "출력으로 사용", + "selectedAsOutput": "선택됨", "budgetExceeded": "예산 초과", "dailySpend": "일일 지출", "perExecutionLimit": "실행당 제한", diff --git a/src/i18n/locales/ms.json b/src/i18n/locales/ms.json index c3675ef3..ab505a84 100644 --- a/src/i18n/locales/ms.json +++ b/src/i18n/locales/ms.json @@ -1137,6 +1137,8 @@ "modelIdLabel": "ID model", "selectNode": "Pilih nod untuk dikonfigurasi", "noExecutions": "Belum ada pelaksanaan", + "selectAsOutput": "Guna sebagai output", + "selectedAsOutput": "Dipilih", "budgetExceeded": "Budget terlampaui", "dailySpend": "Perbelanjaan harian", "perExecutionLimit": "Had per pelaksanaan", diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 4400f0c9..64fe0515 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -1137,6 +1137,8 @@ "modelIdLabel": "ID do modelo", "selectNode": "Selecione um nó para configurar", "noExecutions": "Ainda sem execuções", + "selectAsOutput": "Usar como saída", + "selectedAsOutput": "Selecionado", "budgetExceeded": "Orçamento excedido", "dailySpend": "Gasto diário", "perExecutionLimit": "Limite por execução", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 0031eb25..2a45f1ab 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -1137,6 +1137,8 @@ "modelIdLabel": "ID модели", "selectNode": "Выберите узел для настройки", "noExecutions": "Пока нет выполнений", + "selectAsOutput": "Использовать как выход", + "selectedAsOutput": "Выбрано", "budgetExceeded": "Превышен бюджет", "dailySpend": "Ежедневные расходы", "perExecutionLimit": "Лимит на выполнение", diff --git a/src/i18n/locales/th.json b/src/i18n/locales/th.json index f5c4b287..04877703 100644 --- a/src/i18n/locales/th.json +++ b/src/i18n/locales/th.json @@ -1137,6 +1137,8 @@ "modelIdLabel": "รหัสโมเดล", "selectNode": "เลือกโหนดเพื่อกำหนดค่า", "noExecutions": "ยังไม่มีการดำเนินการ", + "selectAsOutput": "ใช้เป็นเอาต์พุต", + "selectedAsOutput": "เลือกแล้ว", "budgetExceeded": "เกินงบประมาณ", "dailySpend": "การใช้จ่ายรายวัน", "perExecutionLimit": "ขีดจำกัดต่อการดำเนินการ", diff --git a/src/i18n/locales/tr.json b/src/i18n/locales/tr.json index a0e95aaa..327f2419 100644 --- a/src/i18n/locales/tr.json +++ b/src/i18n/locales/tr.json @@ -1137,6 +1137,8 @@ "modelIdLabel": "Model ID", "selectNode": "Yapılandırmak için düğüm seçin", "noExecutions": "Henüz yürütme yok", + "selectAsOutput": "Çıktı olarak kullan", + "selectedAsOutput": "Seçildi", "budgetExceeded": "Bütçe aşıldı", "dailySpend": "Günlük harcama", "perExecutionLimit": "Yürütme başına limit", diff --git a/src/i18n/locales/vi.json b/src/i18n/locales/vi.json index b560b80c..0272c26a 100644 --- a/src/i18n/locales/vi.json +++ b/src/i18n/locales/vi.json @@ -1137,6 +1137,8 @@ "modelIdLabel": "ID mô hình", "selectNode": "Chọn nút để cấu hình", "noExecutions": "Chưa có lần thực thi", + "selectAsOutput": "Dùng làm đầu ra", + "selectedAsOutput": "Đã chọn", "budgetExceeded": "Vượt ngân sách", "dailySpend": "Chi tiêu hàng ngày", "perExecutionLimit": "Giới hạn mỗi lần thực thi", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 5ff3a829..3e277fa8 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -1200,6 +1200,8 @@ "modelIdLabel": "模型 ID", "selectNode": "选择节点进行配置", "noExecutions": "暂无执行记录", + "selectAsOutput": "选为输出", + "selectedAsOutput": "已选为输出", "budgetExceeded": "超出预算", "dailySpend": "今日花费", "perExecutionLimit": "单次执行限额", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index 93168a8a..3d8f140f 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -1421,6 +1421,8 @@ "exportFailed": "導出失敗", "estimated": "費用", "noExecutions": "暫無執行記錄", + "selectAsOutput": "選為輸出", + "selectedAsOutput": "已選為輸出", "budgetExceeded": "超出預算", "dailySpend": "每日支出", "perExecutionLimit": "每次執行限制", diff --git a/src/workflow/WorkflowPage.tsx b/src/workflow/WorkflowPage.tsx index 0fd29833..964eec34 100644 --- a/src/workflow/WorkflowPage.tsx +++ b/src/workflow/WorkflowPage.tsx @@ -283,7 +283,9 @@ export function WorkflowPage() { ? blobMediaType : previewTypeBase; const previewIsImage = previewType === "image"; - const canNavigatePreview = previewIsImage && previewItems.length > 1; + const previewIsVideo = previewType === "video"; + const canNavigatePreview = + (previewIsImage || previewIsVideo) && previewItems.length > 1; useEffect(() => { if (!previewSrc || !isActive) return; @@ -2141,13 +2143,54 @@ export function WorkflowPage() { {previewType === "3d" ? ( ) : previewType === "video" ? ( -
) : previewType === "audio" ? (
+ {/* Select as Output — bottom-right corner of image */} + {canNavigatePreview && previewItems.length > 1 && ( +
+ +
+ )} {canNavigatePreview && (
-
- {canNavigatePreview - ? t("workflow.previewNavHint", { - current: previewIndex + 1, - total: previewItems.length, - defaultValue: - "Use ← / → to navigate images ({{current}}/{{total}})", - }) - : t("workflow.clickAnywhereToClose", "Click anywhere to close")} -
+ {/* Bottom: thumbnail strip or hint text */} + {canNavigatePreview && previewItems.length > 1 ? ( +
e.stopPropagation()} + > + { + useUIStore.setState({ + previewIndex: idx, + previewSrc: previewItems[idx], + }); + }} + /> +
+ ) : ( +
+ {t("workflow.clickAnywhereToClose", "Click anywhere to close")} +
+ )}
)} @@ -2926,3 +2994,128 @@ function ModelViewerOverlay({ src }: { src: string }) { /> ); } + +/* ── Preview Thumbnail Strip ───────────────────────────────────────── */ +function PreviewThumbnailStrip({ + items, + currentIndex, + onSelect, +}: { + items: string[]; + currentIndex: number; + onSelect: (index: number) => void; +}) { + const stripRef = useRef(null); + + // Auto-scroll to keep the active thumbnail visible + useEffect(() => { + const el = stripRef.current; + if (!el) return; + const thumb = el.children[currentIndex] as HTMLElement | undefined; + if (thumb) { + thumb.scrollIntoView({ + behavior: "smooth", + block: "nearest", + inline: "center", + }); + } + }, [currentIndex]); + + return ( +
+
+ {items.map((url, idx) => { + const itemType = getOutputItemType(url); + return ( + + ); + })} +
+
+ ); +} + +/* ── Preview Select As Output Button ───────────────────────────────── */ +function PreviewSelectAsOutput({ + previewItems, + previewIndex, +}: { + previewItems: string[]; + previewIndex: number; +}) { + const { t } = useTranslation(); + const selectedNodeId = useUIStore((s) => s.selectedNodeId); + const lastResults = useExecutionStore((s) => + selectedNodeId ? s.lastResults[selectedNodeId] : undefined, + ); + const selectedIdx = useExecutionStore((s) => + selectedNodeId ? (s.selectedOutputIndex[selectedNodeId] ?? 0) : 0, + ); + const currentUrl = previewItems[previewIndex]; + + // Find which lastResults group this preview URL belongs to + const groupIndex = useMemo(() => { + if (!lastResults || !currentUrl) return 0; + const idx = lastResults.findIndex((g) => g.urls.includes(currentUrl)); + return idx >= 0 ? idx : 0; + }, [lastResults, currentUrl]); + + const isCurrentOutput = selectedIdx === groupIndex; + + const handleSelect = useCallback(() => { + if (!selectedNodeId || !lastResults || !currentUrl) return; + useExecutionStore.setState((s) => ({ + selectedOutputIndex: { + ...s.selectedOutputIndex, + [selectedNodeId]: groupIndex, + }, + })); + }, [selectedNodeId, lastResults, currentUrl, groupIndex]); + + if (!selectedNodeId || !lastResults || lastResults.length <= 1) return null; + + return ( + + ); +} diff --git a/src/workflow/components/canvas/custom-node/CustomNode.tsx b/src/workflow/components/canvas/custom-node/CustomNode.tsx index 637827eb..1abc5ec5 100644 --- a/src/workflow/components/canvas/custom-node/CustomNode.tsx +++ b/src/workflow/components/canvas/custom-node/CustomNode.tsx @@ -72,6 +72,7 @@ function CustomNodeComponent({ const [hovered, setHovered] = useState(false); const [segmentPointPickerOpen, setSegmentPointPickerOpen] = useState(false); const [resultsExpanded, setResultsExpanded] = useState(false); + const [showRunCountPicker, setShowRunCountPicker] = useState(false); const storeModels = useModelsStore((s) => s.models); const getModelById = useModelsStore((s) => s.getModelById); const fetchModels = useModelsStore((s) => s.fetchModels); @@ -205,6 +206,7 @@ function CustomNodeComponent({ ); const running = status === "running"; + const runCount = (data.params.__runCount as number) || 1; const connectedSet = useMemo(() => { const s = new Set(); @@ -597,10 +599,30 @@ function CustomNodeComponent({ if (running) { cancelNode(wfId, id); } else if (data.nodeType?.startsWith("output/")) { - // Output nodes (File Export, Preview) should reuse upstream results, not re-run them continueFrom(wfId, id); } else { - runNode(wfId, id); + if (runCount > 1) { + // Multi-run (gacha): run N times sequentially with seed perturbation + const baseSeed = + typeof data.params.seed === "number" + ? data.params.seed + : Math.floor(Math.random() * 2147483647); + for (let i = 0; i < runCount; i++) { + const newSeed = + (baseSeed + i * 1000 + Math.floor(Math.random() * 999) + 1) % + 2147483647; + updateNodeParams(id, { + ...useWorkflowStore.getState().nodes.find((n) => n.id === id)?.data + .params, + seed: newSeed, + }); + // Small delay to let store update propagate + await new Promise((r) => setTimeout(r, 50)); + await runNode(wfId, id); + } + } else { + runNode(wfId, id); + } } }; @@ -631,7 +653,10 @@ function CustomNodeComponent({ return (
setHovered(true)} - onMouseLeave={() => setHovered(false)} + onMouseLeave={() => { + setHovered(false); + setShowRunCountPicker(false); + }} onWheel={onWheel} className="relative" > @@ -658,21 +683,65 @@ function CustomNodeComponent({ ) : ( <> - + + + {" "} + {t("workflow.run", "Run")} + {runCount > 1 && ( + ×{runCount} + )} + + + {showRunCountPicker && ( +
+ {[1, 2, 3, 4, 5, 8, 10].map((n) => ( + + ))} +
+ )} +
+ {/* Select as Output */} + {total > 1 && currentRec.status === "success" && ( + + )}
)}
@@ -604,6 +657,29 @@ export function ResultsPanel({ : "Latest"} )} + {/* Select as Output button in full list view */} + {rec.status === "success" && displayRecords.length > 1 && ( + + )}
{/* Result outputs — image, video, audio, text, 3D, file */} @@ -637,7 +713,7 @@ export function ResultsPanel({ openPreview(url, panelImageUrls)} + onClick={() => openPreview(url, panelMediaUrls)} className="w-full max-h-[160px] rounded border border-[hsl(var(--border))] object-contain cursor-pointer hover:ring-2 hover:ring-blue-500/40 bg-black/10" /> - )) + + + + + +
+
+ {wf.name} + {isSelf && ( + + ({t("workflow.currentWorkflow", "current")}) + + )} +
+
+ {t("workflow.nodeCountLabel", "{{count}} nodes", { + count: wf.nodeCount, + })} + {" · "} + {new Date(wf.updatedAt).toLocaleDateString()} +
+
+ {importing === wf.id ? ( + + + + ) : ( + + + + )} + + ); + })} + )}
diff --git a/src/workflow/stores/workflow.store.ts b/src/workflow/stores/workflow.store.ts index 9aee7a04..08789cce 100644 --- a/src/workflow/stores/workflow.store.ts +++ b/src/workflow/stores/workflow.store.ts @@ -1941,6 +1941,17 @@ export const useWorkflowStore = create((set, get) => ({ const srcEdges = srcWf.graphDefinition.edges; if (srcNodes.length === 0) return; + // Reject workflows that contain trigger nodes — triggers cannot run inside a group + const triggerNode = srcNodes.find((sn) => + sn.nodeType.startsWith("trigger/"), + ); + if (triggerNode) { + const err = new Error("IMPORT_CONTAINS_TRIGGER"); + (err as unknown as Record).triggerType = + triggerNode.nodeType; + throw err; + } + pushUndo({ nodes, edges }); // Build old→new ID mapping for cloned nodes From 8846c669d59387e13ed6e6ec6b9a7a427598888c Mon Sep 17 00:00:00 2001 From: Leon Date: Thu, 19 Mar 2026 15:37:24 +1100 Subject: [PATCH 08/18] fix: exit subgraph on tab close; remove directory-import node - Clear editingGroupId when closing tabs (doCloseTab, closeMultipleTabs) to prevent stale subgraph view leaking across workflow tabs - Remove input/directory-import node (replaced by trigger/directory) - Delete handler, registration, browser definition, execution logic - Remove from all 18 i18n locale files - Clean up CustomNodeBody and NodeIcons references --- .vscode/settings.json | 3 +- .../workflow/nodes/input/directory-import.ts | 147 ------------------ electron/workflow/nodes/register-all.ts | 5 - src/i18n/locales/ar.json | 4 - src/i18n/locales/de.json | 4 - src/i18n/locales/en.json | 4 - src/i18n/locales/es.json | 4 - src/i18n/locales/fr.json | 4 - src/i18n/locales/hi.json | 4 - src/i18n/locales/id.json | 4 - src/i18n/locales/it.json | 4 - src/i18n/locales/ja.json | 4 - src/i18n/locales/ko.json | 4 - src/i18n/locales/ms.json | 4 - src/i18n/locales/pt.json | 4 - src/i18n/locales/ru.json | 4 - src/i18n/locales/th.json | 4 - src/i18n/locales/tr.json | 4 - src/i18n/locales/vi.json | 4 - src/i18n/locales/zh-CN.json | 4 - src/i18n/locales/zh-TW.json | 4 - src/workflow/WorkflowPage.tsx | 6 + src/workflow/browser/node-definitions.ts | 32 ---- src/workflow/browser/run-in-browser.ts | 89 ----------- .../canvas/custom-node/CustomNodeBody.tsx | 13 -- .../canvas/custom-node/NodeIcons.tsx | 3 +- 26 files changed, 9 insertions(+), 361 deletions(-) delete mode 100644 electron/workflow/nodes/input/directory-import.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index 0967ef42..2c63c085 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1 +1,2 @@ -{} +{ +} diff --git a/electron/workflow/nodes/input/directory-import.ts b/electron/workflow/nodes/input/directory-import.ts deleted file mode 100644 index 54b856bb..00000000 --- a/electron/workflow/nodes/input/directory-import.ts +++ /dev/null @@ -1,147 +0,0 @@ -/** - * Directory Import node — scans a local directory for media files - * and outputs an array of file URLs. - * - * Designed to feed into an Iterator (auto mode) for batch processing. - * Output is always an array of local-asset:// URLs. - */ -import { - BaseNodeHandler, - type NodeExecutionContext, - type NodeExecutionResult, -} from "../base"; -import type { NodeTypeDefinition } from "../../../../src/workflow/types/node-defs"; -import { readdirSync, statSync, existsSync } from "fs"; -import { join, extname } from "path"; - -const MEDIA_EXTENSIONS: Record = { - image: [ - ".jpg", - ".jpeg", - ".png", - ".webp", - ".gif", - ".bmp", - ".tiff", - ".tif", - ".svg", - ".avif", - ], - video: [".mp4", ".webm", ".mov", ".avi", ".mkv", ".flv", ".wmv", ".m4v"], - audio: [".mp3", ".wav", ".flac", ".m4a", ".ogg", ".aac", ".wma"], - all: [], // populated at runtime from all above -}; -MEDIA_EXTENSIONS.all = [ - ...MEDIA_EXTENSIONS.image, - ...MEDIA_EXTENSIONS.video, - ...MEDIA_EXTENSIONS.audio, -]; - -export const directoryImportDef: NodeTypeDefinition = { - type: "input/directory-import", - category: "input", - label: "Directory", - inputs: [], - outputs: [{ key: "output", label: "Files", dataType: "any", required: true }], - params: [ - { - key: "directoryPath", - label: "Directory", - type: "string", - dataType: "text", - connectable: false, - default: "", - }, - { - key: "mediaType", - label: "File Type", - type: "select", - dataType: "text", - connectable: false, - default: "image", - options: [ - { label: "Images", value: "image" }, - { label: "Videos", value: "video" }, - { label: "Audio", value: "audio" }, - { label: "All Media", value: "all" }, - ], - }, - ], -}; - -export class DirectoryImportHandler extends BaseNodeHandler { - constructor() { - super(directoryImportDef); - } - - async execute(ctx: NodeExecutionContext): Promise { - const start = Date.now(); - const dirPath = String(ctx.params.directoryPath ?? "").trim(); - const mediaType = String(ctx.params.mediaType ?? "image"); - - if (!dirPath) { - return { - status: "error", - outputs: {}, - durationMs: Date.now() - start, - cost: 0, - error: "No directory selected. Please choose a directory.", - }; - } - - if (!existsSync(dirPath)) { - return { - status: "error", - outputs: {}, - durationMs: Date.now() - start, - cost: 0, - error: `Directory not found: ${dirPath}`, - }; - } - - const allowedExts = new Set( - MEDIA_EXTENSIONS[mediaType] ?? MEDIA_EXTENSIONS.all, - ); - const files = scanDirectory(dirPath, allowedExts); - files.sort(); // deterministic order - - // Convert to local-asset:// URLs - const urls = files.map((f) => `local-asset://${encodeURIComponent(f)}`); - - ctx.onProgress(100, `Found ${urls.length} file(s)`); - - return { - status: "success", - outputs: { output: urls }, - resultPath: urls[0] ?? "", - resultMetadata: { - output: urls, - resultUrl: urls[0] ?? "", - resultUrls: urls, - fileCount: urls.length, - directory: dirPath, - mediaType, - }, - durationMs: Date.now() - start, - cost: 0, - }; - } -} - -function scanDirectory(dir: string, allowedExts: Set): string[] { - const results: string[] = []; - try { - const entries = readdirSync(dir, { withFileTypes: true }); - for (const entry of entries) { - if (entry.isFile()) { - const ext = extname(entry.name).toLowerCase(); - if (allowedExts.has(ext)) { - results.push(join(dir, entry.name)); - } - } - } - } catch { - // Skip unreadable directories - } - return results; -} diff --git a/electron/workflow/nodes/register-all.ts b/electron/workflow/nodes/register-all.ts index 1c65d70e..0f2c4368 100644 --- a/electron/workflow/nodes/register-all.ts +++ b/electron/workflow/nodes/register-all.ts @@ -1,10 +1,6 @@ import { nodeRegistry } from "./registry"; import { mediaUploadDef, MediaUploadHandler } from "./input/media-upload"; import { textInputDef, TextInputHandler } from "./input/text-input"; -import { - directoryImportDef, - DirectoryImportHandler, -} from "./input/directory-import"; import { aiTaskDef, AITaskHandler } from "./ai-task/run"; import { fileExportDef, FileExportHandler } from "./output/file"; import { previewDisplayDef, PreviewDisplayHandler } from "./output/preview"; @@ -28,7 +24,6 @@ export function registerAllNodes(): void { // Input nodes nodeRegistry.register(mediaUploadDef, new MediaUploadHandler()); nodeRegistry.register(textInputDef, new TextInputHandler()); - nodeRegistry.register(directoryImportDef, new DirectoryImportHandler()); // AI task nodeRegistry.register(aiTaskDef, new AITaskHandler()); diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index 7f4fd595..58f764ec 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -1360,10 +1360,6 @@ "control/iterator": { "label": "مكرر", "hint": "التكرار عبر المدخلات — تشغيل العقد الفرعية N مرة أو مرة واحدة لكل عنصر في المصفوفة" - }, - "input/directory-import": { - "label": "استيراد مجلد", - "hint": "مسح مجلد محلي واستيراد ملفات الوسائط المطابقة كمصفوفة" } }, "modelSelector": { diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 49898197..b6288e29 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -1361,10 +1361,6 @@ "control/iterator": { "label": "Iterator", "hint": "Eingaben durchlaufen — Kindknoten N-mal oder einmal pro Array-Element ausführen" - }, - "input/directory-import": { - "label": "Verzeichnis importieren", - "hint": "Lokalen Ordner scannen und passende Mediendateien als Array importieren" } }, "modelSelector": { diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 148a9e51..d8172fdc 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1451,10 +1451,6 @@ "control/group": { "label": "Group", "hint": "Group nodes into a sub-workflow for organization" - }, - "input/directory-import": { - "label": "Directory Import", - "hint": "Scan a local folder and import matching media files as an array" } }, "modelSelector": { diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 995d009e..045d93dd 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -1360,10 +1360,6 @@ "control/iterator": { "label": "Iterador", "hint": "Iterar sobre entradas — ejecutar nodos hijos N veces o una vez por elemento del array" - }, - "input/directory-import": { - "label": "Importar directorio", - "hint": "Escanear una carpeta local e importar archivos multimedia coincidentes como array" } }, "modelSelector": { diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 095287ad..acdcdc3a 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -1361,10 +1361,6 @@ "control/iterator": { "label": "Itérateur", "hint": "Boucler sur les entrées — exécuter les nœuds enfants N fois ou une fois par élément du tableau" - }, - "input/directory-import": { - "label": "Importer un répertoire", - "hint": "Scanner un dossier local et importer les fichiers multimédias correspondants sous forme de tableau" } }, "modelSelector": { diff --git a/src/i18n/locales/hi.json b/src/i18n/locales/hi.json index 88385d5c..3b02a657 100644 --- a/src/i18n/locales/hi.json +++ b/src/i18n/locales/hi.json @@ -1360,10 +1360,6 @@ "control/iterator": { "label": "इटरेटर", "hint": "इनपुट पर लूप — चाइल्ड नोड्स को N बार या प्रत्येक ऐरे आइटम के लिए एक बार चलाएं" - }, - "input/directory-import": { - "label": "डायरेक्टरी आयात", - "hint": "स्थानीय फ़ोल्डर स्कैन करें और मिलान करने वाली मीडिया फ़ाइलों को ऐरे के रूप में आयात करें" } }, "modelSelector": { diff --git a/src/i18n/locales/id.json b/src/i18n/locales/id.json index 7f1d373d..98a92ffd 100644 --- a/src/i18n/locales/id.json +++ b/src/i18n/locales/id.json @@ -1360,10 +1360,6 @@ "control/iterator": { "label": "Iterator", "hint": "Ulangi input — jalankan node anak N kali atau sekali per item array" - }, - "input/directory-import": { - "label": "Impor Direktori", - "hint": "Pindai folder lokal dan impor file media yang cocok sebagai array" } }, "modelSelector": { diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index 9fff4b4a..13c081d2 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -1360,10 +1360,6 @@ "control/iterator": { "label": "Iteratore", "hint": "Ciclo sugli input — esegui i nodi figli N volte o una volta per elemento dell'array" - }, - "input/directory-import": { - "label": "Importa directory", - "hint": "Scansiona una cartella locale e importa i file multimediali corrispondenti come array" } }, "modelSelector": { diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 5fdf7c9f..affcd069 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1321,10 +1321,6 @@ "control/iterator": { "label": "イテレーター", "hint": "入力をループ — 子ノードをN回またはアレイ項目ごとに1回実行" - }, - "input/directory-import": { - "label": "ディレクトリインポート", - "hint": "ローカルフォルダをスキャンし、一致するメディアファイルを配列としてインポート" } }, "modelSelector": { diff --git a/src/i18n/locales/ko.json b/src/i18n/locales/ko.json index 1d3d9770..03d3cbac 100644 --- a/src/i18n/locales/ko.json +++ b/src/i18n/locales/ko.json @@ -1361,10 +1361,6 @@ "control/iterator": { "label": "반복기", "hint": "입력을 반복 — 자식 노드를 N번 또는 배열 항목당 한 번 실행" - }, - "input/directory-import": { - "label": "디렉토리 가져오기", - "hint": "로컬 폴더를 스캔하고 일치하는 미디어 파일을 배열로 가져오기" } }, "modelSelector": { diff --git a/src/i18n/locales/ms.json b/src/i18n/locales/ms.json index 8b5ecc1a..d925dbeb 100644 --- a/src/i18n/locales/ms.json +++ b/src/i18n/locales/ms.json @@ -1360,10 +1360,6 @@ "control/iterator": { "label": "Iterator", "hint": "Gelung melalui input — jalankan nod anak N kali atau sekali bagi setiap item tatasusunan" - }, - "input/directory-import": { - "label": "Import Direktori", - "hint": "Imbas folder tempatan dan import fail media yang sepadan sebagai tatasusunan" } }, "modelSelector": { diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 64703d5a..975aeb92 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -1360,10 +1360,6 @@ "control/iterator": { "label": "Iterador", "hint": "Iterar sobre entradas — executar nós filhos N vezes ou uma vez por item do array" - }, - "input/directory-import": { - "label": "Importar Diretório", - "hint": "Verificar uma pasta local e importar ficheiros multimédia correspondentes como array" } }, "modelSelector": { diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index bad440b1..34c7e5a1 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -1360,10 +1360,6 @@ "control/iterator": { "label": "Итератор", "hint": "Цикл по входным данным — запуск дочерних узлов N раз или по одному на элемент массива" - }, - "input/directory-import": { - "label": "Импорт каталога", - "hint": "Сканировать локальную папку и импортировать подходящие медиафайлы как массив" } }, "modelSelector": { diff --git a/src/i18n/locales/th.json b/src/i18n/locales/th.json index bf453bf8..ab9f46ba 100644 --- a/src/i18n/locales/th.json +++ b/src/i18n/locales/th.json @@ -1360,10 +1360,6 @@ "control/iterator": { "label": "ตัววนซ้ำ", "hint": "วนซ้ำอินพุต — รันโหนดย่อย N ครั้งหรือครั้งละรายการในอาร์เรย์" - }, - "input/directory-import": { - "label": "นำเข้าไดเรกทอรี", - "hint": "สแกนโฟลเดอร์ในเครื่องและนำเข้าไฟล์สื่อที่ตรงกันเป็นอาร์เรย์" } }, "modelSelector": { diff --git a/src/i18n/locales/tr.json b/src/i18n/locales/tr.json index be71028e..7f99062c 100644 --- a/src/i18n/locales/tr.json +++ b/src/i18n/locales/tr.json @@ -1360,10 +1360,6 @@ "control/iterator": { "label": "Yineleyici", "hint": "Girdiler üzerinde döngü — alt düğümleri N kez veya dizi öğesi başına bir kez çalıştır" - }, - "input/directory-import": { - "label": "Dizin İçe Aktar", - "hint": "Yerel klasörü tara ve eşleşen medya dosyalarını dizi olarak içe aktar" } }, "modelSelector": { diff --git a/src/i18n/locales/vi.json b/src/i18n/locales/vi.json index d87ad5ba..e42cc7d6 100644 --- a/src/i18n/locales/vi.json +++ b/src/i18n/locales/vi.json @@ -1360,10 +1360,6 @@ "control/iterator": { "label": "Bộ lặp", "hint": "Lặp qua đầu vào — chạy nút con N lần hoặc một lần cho mỗi phần tử mảng" - }, - "input/directory-import": { - "label": "Nhập thư mục", - "hint": "Quét thư mục cục bộ và nhập các tệp phương tiện phù hợp dưới dạng mảng" } }, "modelSelector": { diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 7688b3a5..9f0bba29 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -1442,10 +1442,6 @@ "control/group": { "label": "分组", "hint": "将节点分组为子工作流,便于组织管理" - }, - "input/directory-import": { - "label": "目录导入", - "hint": "扫描本地文件夹,将匹配的媒体文件导入为数组" } }, "modelSelector": { diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index 0c838b6a..b9ff73c1 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -1318,10 +1318,6 @@ "control/iterator": { "label": "迭代器", "hint": "循環執行子節點 — 固定 N 次或按陣列長度自動迭代" - }, - "input/directory-import": { - "label": "目錄匯入", - "hint": "掃描本機資料夾,將符合的媒體檔案匯入為陣列" } }, "modelSelector": { diff --git a/src/workflow/WorkflowPage.tsx b/src/workflow/WorkflowPage.tsx index 964eec34..fe5e5978 100644 --- a/src/workflow/WorkflowPage.tsx +++ b/src/workflow/WorkflowPage.tsx @@ -654,6 +654,8 @@ export function WorkflowPage() { edges, isDirty: false, }); + // Exit subgraph editing when closing the last tab + useUIStore.getState().exitGroupEdit(); setTabs([ { tabId: newTabId, @@ -683,6 +685,8 @@ export function WorkflowPage() { >["edges"], isDirty: target.isDirty, }); + // Exit subgraph editing when closing the active tab + useUIStore.getState().exitGroupEdit(); setActiveTabId(target.tabId); } }, @@ -743,6 +747,8 @@ export function WorkflowPage() { >["edges"], isDirty: target.isDirty, }); + // Exit subgraph editing when closing multiple tabs including the active one + useUIStore.getState().exitGroupEdit(); setActiveTabId(target.tabId); } }, diff --git a/src/workflow/browser/node-definitions.ts b/src/workflow/browser/node-definitions.ts index df62ce45..e9ccbd2f 100644 --- a/src/workflow/browser/node-definitions.ts +++ b/src/workflow/browser/node-definitions.ts @@ -57,37 +57,6 @@ export const textInputDef: NodeTypeDefinition = { ], }; -export const directoryImportDef: NodeTypeDefinition = { - type: "input/directory-import", - category: "input", - label: "Directory", - inputs: [], - outputs: [{ key: "output", label: "Files", dataType: "any", required: true }], - params: [ - { - key: "directoryPath", - label: "Directory", - type: "string", - dataType: "text", - connectable: false, - default: "", - }, - { - key: "mediaType", - label: "File Type", - type: "select", - dataType: "text", - connectable: false, - default: "image", - options: [ - { label: "Images", value: "image" }, - { label: "Videos", value: "video" }, - { label: "Audio", value: "audio" }, - { label: "All Media", value: "all" }, - ], - }, - ], -}; // ─── AI Task ─────────────────────────────────────────────────────────────── export const aiTaskDef: NodeTypeDefinition = { @@ -553,7 +522,6 @@ export const selectDef: NodeTypeDefinition = { export const BROWSER_NODE_DEFINITIONS: NodeTypeDefinition[] = [ mediaUploadDef, textInputDef, - directoryImportDef, aiTaskDef, fileExportDef, previewDisplayDef, diff --git a/src/workflow/browser/run-in-browser.ts b/src/workflow/browser/run-in-browser.ts index 8522b7e3..82d77076 100644 --- a/src/workflow/browser/run-in-browser.ts +++ b/src/workflow/browser/run-in-browser.ts @@ -607,7 +607,6 @@ export async function executeWorkflowInBrowser( // Stop the entire workflow if any node has failed if (failedNodes.size > 0) break; // Yield to the event loop between levels so React can flush UI updates - // (prevents perceived "freeze" between e.g. directory-import completing and iterator starting) await new Promise((r) => setTimeout(r, 0)); await Promise.all( level.map(async (nodeId) => { @@ -771,94 +770,6 @@ export async function executeWorkflowInBrowser( return; } - if (nodeType === "input/directory-import") { - const dirPath = String(params.directoryPath ?? "").trim(); - if (!dirPath) - throw new Error( - "No directory selected. Please choose a directory.", - ); - - const mediaType = String(params.mediaType ?? "image"); - - const MEDIA_EXTS: Record = { - image: [ - ".jpg", - ".jpeg", - ".png", - ".webp", - ".gif", - ".bmp", - ".tiff", - ".tif", - ".svg", - ".avif", - ], - video: [ - ".mp4", - ".webm", - ".mov", - ".avi", - ".mkv", - ".flv", - ".wmv", - ".m4v", - ], - audio: [".mp3", ".wav", ".flac", ".m4a", ".ogg", ".aac", ".wma"], - all: [], - }; - MEDIA_EXTS.all = [ - ...MEDIA_EXTS.image, - ...MEDIA_EXTS.video, - ...MEDIA_EXTS.audio, - ]; - - let urls: string[] = []; - try { - const api = (window as unknown as Record) - .electronAPI as - | { - scanDirectory?: ( - path: string, - exts: string[], - ) => Promise; - } - | undefined; - if (api?.scanDirectory) { - const files = await api.scanDirectory( - dirPath, - MEDIA_EXTS[mediaType] ?? MEDIA_EXTS.all, - ); - // Convert raw file paths to local-asset:// URLs (consistent with electron handler) - urls = files - .sort() - .map((f) => `local-asset://${encodeURIComponent(f)}`); - } else { - throw new Error("Directory scanning requires the desktop app."); - } - } catch (err) { - throw new Error( - `Failed to scan directory: ${err instanceof Error ? err.message : String(err)}`, - ); - } - - results.set(nodeId, { - outputUrl: urls[0] ?? "", - resultMetadata: { - output: urls, - resultUrl: urls[0] ?? "", - resultUrls: urls, - fileCount: urls.length, - }, - }); - callbacks.onNodeStatus(nodeId, "confirmed"); - callbacks.onNodeComplete(nodeId, { - urls: urls.length > 0 ? urls : [""], - cost: 0, - durationMs: Date.now() - start, - }); - return; - } - if (nodeType === "trigger/directory") { // In batch mode, __triggerValue is injected by the outer batch loop const triggerValue = params.__triggerValue as string | undefined; diff --git a/src/workflow/components/canvas/custom-node/CustomNodeBody.tsx b/src/workflow/components/canvas/custom-node/CustomNodeBody.tsx index 5addd50e..3b98c819 100644 --- a/src/workflow/components/canvas/custom-node/CustomNodeBody.tsx +++ b/src/workflow/components/canvas/custom-node/CustomNodeBody.tsx @@ -366,7 +366,6 @@ export function CustomNodeBody(props: CustomNodeBodyProps) { })} {data.nodeType !== "input/media-upload" && data.nodeType !== "input/text-input" && - data.nodeType !== "input/directory-import" && paramDefs.map((p) => { const hid = `param-${p.key}`; const canConnect = @@ -513,16 +512,6 @@ export function CustomNodeBody(props: CustomNodeBodyProps) { /> )} - {/* Directory Import node — special UI */} - {data.nodeType === "input/directory-import" && ( - { - updateNodeParams(id, { ...data.params, ...updates }); - }} - /> - )} - {/* Directory Trigger node — reuse directory picker UI */} {data.nodeType === "trigger/directory" && ( { if (data.nodeType === "input/media-upload") return null; - if (data.nodeType === "input/directory-import") return null; if (data.nodeType === "output/http-response") return null; const hid = `input-${inp.key}`; const conn = connectedSet.has(hid); @@ -1251,7 +1239,6 @@ export function CustomNodeBody(props: CustomNodeBodyProps) { {/* defParams */} {data.nodeType !== "input/media-upload" && data.nodeType !== "input/text-input" && - data.nodeType !== "input/directory-import" && data.nodeType !== "trigger/directory" && paramDefs.map((p) => { // Skip fields managed by DynamicFieldsEditor diff --git a/src/workflow/components/canvas/custom-node/NodeIcons.tsx b/src/workflow/components/canvas/custom-node/NodeIcons.tsx index 57f4f1f9..36702181 100644 --- a/src/workflow/components/canvas/custom-node/NodeIcons.tsx +++ b/src/workflow/components/canvas/custom-node/NodeIcons.tsx @@ -22,7 +22,6 @@ import { Download, GitMerge, ListFilter, - FolderOpen, Repeat, FolderSearch, Globe, @@ -47,7 +46,7 @@ const NODE_ICON_MAP: Record = { // Input "input/media-upload": Upload, "input/text-input": Type, - "input/directory-import": FolderOpen, + // AI Task "ai-task/run": Cpu, // Output From 81d6123aea320271aea28acea4db86ad183e1aea Mon Sep 17 00:00:00 2001 From: Leon Date: Thu, 19 Mar 2026 16:29:51 +1100 Subject: [PATCH 09/18] refactor: unify node UI with shadcn components; fix results spacing --- src/components/layout/Sidebar.tsx | 8 +- .../canvas/custom-node/CustomNode.tsx | 7 +- .../canvas/custom-node/CustomNodeBody.tsx | 14 ++-- .../custom-node/CustomNodeHandleAnchor.tsx | 4 +- .../custom-node/CustomNodeInputBodies.tsx | 68 +++++++++------- .../custom-node/CustomNodeParamControls.tsx | 23 ++---- .../canvas/custom-node/CustomNodeTypes.ts | 6 ++ .../custom-node/DynamicFieldsEditor.tsx | 81 ++++++++++++------- .../canvas/group-node/GroupNodeContainer.tsx | 40 +++++---- .../components/panels/ResultsPanel.tsx | 7 +- 10 files changed, 154 insertions(+), 104 deletions(-) diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index c2772ac4..d83bf775 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -116,13 +116,17 @@ export const Sidebar = memo(function Sidebar({ }; const handleFocus = () => { if (!blurredRef.current) return; - // Keep suppressed — will be re-enabled by mousemove + // Keep suppressed — will be re-enabled by mousemove after a short grace period + // The delay prevents tooltips from flashing when the OS synthesizes a + // mousemove event immediately upon window focus (common in Electron). const onMove = () => { blurredRef.current = false; setTooltipReady(true); window.removeEventListener("mousemove", onMove); }; - window.addEventListener("mousemove", onMove, { once: true }); + setTimeout(() => { + window.addEventListener("mousemove", onMove, { once: true }); + }, 150); }; window.addEventListener("blur", handleBlur); window.addEventListener("focus", handleFocus); diff --git a/src/workflow/components/canvas/custom-node/CustomNode.tsx b/src/workflow/components/canvas/custom-node/CustomNode.tsx index 1abc5ec5..92b54123 100644 --- a/src/workflow/components/canvas/custom-node/CustomNode.tsx +++ b/src/workflow/components/canvas/custom-node/CustomNode.tsx @@ -1098,7 +1098,12 @@ function CustomNodeComponent({ ? "bg-cyan-500 hover:bg-cyan-600" : "bg-blue-500 hover:bg-blue-600" }`} - style={{ top: "35%", right: -12 }} + style={ + data.nodeType === "trigger/http" || + data.nodeType === "control/iterator" + ? { top: 15, right: -12 } + : { top: "50%", right: -12, transform: "translateY(-50%)" } + } onClick={(e) => { e.stopPropagation(); const rect = ( diff --git a/src/workflow/components/canvas/custom-node/CustomNodeBody.tsx b/src/workflow/components/canvas/custom-node/CustomNodeBody.tsx index 3b98c819..a38e3bbe 100644 --- a/src/workflow/components/canvas/custom-node/CustomNodeBody.tsx +++ b/src/workflow/components/canvas/custom-node/CustomNodeBody.tsx @@ -29,6 +29,8 @@ import { ModelSelector } from "@/components/playground/ModelSelector"; import type { FormFieldConfig } from "@/lib/schemaToForm"; import type { Model } from "@/types/model"; import { workflowClient } from "@/api/client"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; import { type CustomNodeData, @@ -530,14 +532,14 @@ export function CustomNodeBody(props: CustomNodeBodyProps) { onClick={(e) => e.stopPropagation()} >
- + - + setParam("port", Number(e.target.value))} - className="w-20 px-2 py-1 rounded border border-border bg-background text-[11px] text-right focus:outline-none focus:ring-1 focus:ring-primary/50" + className="w-20 h-8 text-xs text-right" />
@@ -1427,11 +1429,11 @@ export function CustomNodeBody(props: CustomNodeBodyProps) { {/* Results — at bottom of card, collapsed by default */} {data.nodeType !== "annotation" && ( -
+
+
@@ -900,33 +909,36 @@ export function DirectoryImportBody({ {/* Row 2: File Type */}
- + - onParamChange({ mediaType: e.target.value })} - className={`nodrag ${inputCls} max-w-[200px]`} - onClick={(e) => e.stopPropagation()} + onValueChange={(v) => onParamChange({ mediaType: v })} > - {MEDIA_TYPE_OPTIONS.map((opt) => ( - - ))} - + + + + + {MEDIA_TYPE_OPTIONS.map((opt) => ( + + {opt.label} ({opt.exts}) + + ))} + +
{/* Scan status row */}
- + - + + {scanning ? ( - + ) : dirPath ? ( - + {t("workflow.directoryImport.noFiles", "No matching files")} ) : ( - + )}
diff --git a/src/workflow/components/canvas/custom-node/CustomNodeParamControls.tsx b/src/workflow/components/canvas/custom-node/CustomNodeParamControls.tsx index 9dc89572..e6098302 100644 --- a/src/workflow/components/canvas/custom-node/CustomNodeParamControls.tsx +++ b/src/workflow/components/canvas/custom-node/CustomNodeParamControls.tsx @@ -27,6 +27,8 @@ import { RANDOM_SEED_MAX, NODE_INPUT_ACCEPT_RULES, formatLabel, + inputCls, + selectCls, } from "./CustomNodeTypes"; import { HandleAnchor } from "./CustomNodeHandleAnchor"; import { @@ -82,11 +84,6 @@ export function ParamRow({ const cur = value ?? schema.default; const showEditor = !connected; - const inputCls = - "w-full rounded-md border border-[hsl(var(--border))] bg-[hsl(var(--background))] px-2 py-1.5 text-xs text-[hsl(var(--foreground))] focus:outline-none focus:ring-1 focus:ring-blue-500/50 focus:border-blue-500 placeholder:text-[hsl(var(--muted-foreground))]"; - const selectCls = - "rounded-md border border-[hsl(var(--border))] bg-[hsl(var(--background))] px-2 py-1.5 text-xs text-[hsl(var(--foreground))] focus:outline-none focus:ring-1 focus:ring-blue-500/50"; - // ── Textarea: full-width below label ── if (ft === "textarea") { const isPromptField = schema.name.toLowerCase() === "prompt"; @@ -848,8 +845,6 @@ export function DefParamControl({ onChange: (v: unknown) => void; }) { const { t } = useTranslation(); - const cls = - "rounded-md border border-[hsl(var(--border))] bg-[hsl(var(--background))] px-2 py-1.5 text-xs text-[hsl(var(--foreground))] focus:outline-none focus:ring-1 focus:ring-blue-500/50"; const cur = value ?? param.default; const workflowId = useWorkflowStore((s) => s.workflowId); const saveWorkflow = useWorkflowStore((s) => s.saveWorkflow); @@ -916,7 +911,7 @@ export function DefParamControl({ "workflow.nodeDefs.output/file.params.outputDir.placeholder", "Leave empty to use workflow default output directory", )} - className={`${cls} flex-1`} + className={`${inputCls} flex-1`} onClick={(e) => e.stopPropagation()} /> + + + {t("workflow.addField", "Add")} + +
{fields.length === 0 && ( -
+

{t( "workflow.noFieldsHint", "No fields defined. Click Add to create one.", )} -

+

)} {fields.map((field, idx) => (
- updateField(idx, { key: e.target.value.replace(/\s/g, "_") }) } placeholder="field name" - className="flex-1 min-w-0 px-2 py-1 rounded border border-border bg-background text-[11px] focus:outline-none focus:ring-1 focus:ring-primary/50" + className="flex-1 min-w-0 h-8 text-xs" /> - - + + {renderHandle && renderHandle(field.key)}
))} diff --git a/src/workflow/components/canvas/group-node/GroupNodeContainer.tsx b/src/workflow/components/canvas/group-node/GroupNodeContainer.tsx index c6a51243..b278dde6 100644 --- a/src/workflow/components/canvas/group-node/GroupNodeContainer.tsx +++ b/src/workflow/components/canvas/group-node/GroupNodeContainer.tsx @@ -44,6 +44,7 @@ import { } from "@/components/ui/tooltip"; import { ChevronDown, ChevronUp, Pencil, FolderInput } from "lucide-react"; import { ImportWorkflowDialog } from "../ImportWorkflowDialog"; +import { Button } from "@/components/ui/button"; /* ── constants ─────────────────────────────────────────────────────── */ @@ -71,12 +72,20 @@ export interface IteratorNodeData { /* ── Capsule handle style helpers ──────────────────────────────────── */ -const dotStyle = (connected: boolean): React.CSSProperties => ({ +const dotStyle = ( + connected: boolean, + side: "input" | "output" = "input", +): React.CSSProperties => ({ width: HANDLE_DOT, height: HANDLE_DOT, borderRadius: "50%", - border: "2px solid hsl(var(--primary))", - background: connected ? "hsl(var(--primary))" : "hsl(var(--card))", + border: "2px solid hsl(188 95% 43%)", + background: + side === "output" + ? "hsl(188 95% 43%)" + : connected + ? "hsl(188 95% 43%)" + : "hsl(var(--card))", minWidth: HANDLE_DOT, minHeight: HANDLE_DOT, position: "relative" as const, @@ -703,30 +712,30 @@ function IteratorNodeContainerComponent({ {/* Action buttons */}
- - +
)} @@ -896,7 +905,7 @@ function IteratorNodeContainerComponent({ position={Position.Right} id={extHandleId} style={{ - ...dotStyle(extConnected), + ...dotStyle(extConnected, "output"), position: "absolute", top: top + CAPSULE_HEIGHT / 2, left: extHandleLeft, @@ -915,9 +924,8 @@ function IteratorNodeContainerComponent({ type="button" className="nodrag nopan absolute z-40 flex items-center justify-center w-6 h-6 rounded-full shadow-lg backdrop-blur-sm bg-cyan-500 text-white hover:bg-cyan-600 hover:scale-110 transition-all duration-150" style={{ - top: TITLE_BAR_HEIGHT / 2, + top: 15, right: -12, - transform: "translateY(-50%)", }} onClick={(e) => { e.stopPropagation(); diff --git a/src/workflow/components/panels/ResultsPanel.tsx b/src/workflow/components/panels/ResultsPanel.tsx index cc07820a..73dbd006 100644 --- a/src/workflow/components/panels/ResultsPanel.tsx +++ b/src/workflow/components/panels/ResultsPanel.tsx @@ -294,12 +294,7 @@ export function ResultsPanel({ if (displayRecords.length === 0) { return (
-
-

- {t("workflow.results", "Results")} (0) -

-
-

+

{t("workflow.noExecutions", "No executions yet")}

From 842a387a408fb011015179951f09884a2b709408 Mon Sep 17 00:00:00 2001 From: Leon Date: Thu, 19 Mar 2026 20:18:49 +1100 Subject: [PATCH 10/18] fix: group hint z-index, subgraph node run, results carousel animation, pagination layout, file export inline params --- src/components/ui/tooltip.tsx | 2 +- src/workflow/WorkflowPage.tsx | 2 +- src/workflow/browser/run-in-browser.ts | 23 +- src/workflow/components/canvas/CustomEdge.tsx | 2 +- .../components/canvas/NodePalette.tsx | 9 +- .../components/canvas/SubgraphBreadcrumb.tsx | 7 +- .../components/canvas/WorkflowCanvas.tsx | 522 ++++++++++++--- .../canvas/custom-node/CustomNode.tsx | 189 +++--- .../canvas/custom-node/CustomNodeBody.tsx | 52 +- .../custom-node/CustomNodeInputBodies.tsx | 4 +- .../custom-node/CustomNodeParamControls.tsx | 2 +- .../canvas/group-node/GroupIONode.tsx | 623 ++++++++++++++---- .../canvas/group-node/GroupNodeContainer.tsx | 392 +++++------ .../components/panels/ResultsPanel.tsx | 23 +- src/workflow/stores/execution.store.ts | 21 +- 15 files changed, 1312 insertions(+), 561 deletions(-) diff --git a/src/components/ui/tooltip.tsx b/src/components/ui/tooltip.tsx index 742dd249..1912fef1 100644 --- a/src/components/ui/tooltip.tsx +++ b/src/components/ui/tooltip.tsx @@ -18,7 +18,7 @@ const TooltipContent = React.forwardRef< ref={ref} sideOffset={sideOffset} className={cn( - "z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", + "z-[9999] overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", className, )} {...props} diff --git a/src/workflow/WorkflowPage.tsx b/src/workflow/WorkflowPage.tsx index fe5e5978..c37814e0 100644 --- a/src/workflow/WorkflowPage.tsx +++ b/src/workflow/WorkflowPage.tsx @@ -1834,7 +1834,7 @@ export function WorkflowPage() { {nodes.length === 0 ? t("workflow.addNodesToRun", "Add nodes to run") - : t("workflow.runWorkflow", "Run")} + : t("workflow.runWorkflowHint", "Run Workflow")}
{/* Run count */} diff --git a/src/workflow/browser/run-in-browser.ts b/src/workflow/browser/run-in-browser.ts index 82d77076..83fc7a82 100644 --- a/src/workflow/browser/run-in-browser.ts +++ b/src/workflow/browser/run-in-browser.ts @@ -493,12 +493,19 @@ export async function executeWorkflowInBrowser( options?.continueFromNodeId && allNodeIds.includes(options.continueFromNodeId) ) { + // If the target node is a child of a group, redirect to the parent group + let effectiveContinueId = options.continueFromNodeId; + const continueTarget = nodes.find((n) => n.id === effectiveContinueId); + if (continueTarget?.parentNode) { + effectiveContinueId = continueTarget.parentNode; + } + // Run the target node + all downstream; include upstream in the graph but skip executing them const downstream = downstreamNodeIds( - options.continueFromNodeId, + effectiveContinueId, simpleEdges, ); - const upstream = upstreamNodeIds(options.continueFromNodeId, simpleEdges); + const upstream = upstreamNodeIds(effectiveContinueId, simpleEdges); // The subgraph is upstream ∪ downstream so edges resolve correctly const subset = new Set([...upstream, ...downstream]); nodeIds = allNodeIds.filter((id) => subset.has(id)); @@ -512,7 +519,15 @@ export async function executeWorkflowInBrowser( options?.runOnlyNodeId && allNodeIds.includes(options.runOnlyNodeId) ) { - const subset = upstreamNodeIds(options.runOnlyNodeId, simpleEdges); + // If the target node is a child of a group, redirect execution to the + // parent group node so the group handler runs all child nodes properly. + let effectiveRunNodeId = options.runOnlyNodeId; + const targetNode = nodes.find((n) => n.id === effectiveRunNodeId); + if (targetNode?.parentNode) { + effectiveRunNodeId = targetNode.parentNode; + } + + const subset = upstreamNodeIds(effectiveRunNodeId, simpleEdges); nodeIds = allNodeIds.filter((id) => subset.has(id)); filteredNodes = nodes.filter((n) => subset.has(n.id)); filteredEdges = edges.filter( @@ -520,7 +535,7 @@ export async function executeWorkflowInBrowser( ); // Only execute the target node itself; upstream nodes reuse existing results skipNodeIds = new Set( - [...subset].filter((id) => id !== options.runOnlyNodeId), + [...subset].filter((id) => id !== effectiveRunNodeId), ); } else { nodeIds = allNodeIds; diff --git a/src/workflow/components/canvas/CustomEdge.tsx b/src/workflow/components/canvas/CustomEdge.tsx index 18864a9c..e607242f 100644 --- a/src/workflow/components/canvas/CustomEdge.tsx +++ b/src/workflow/components/canvas/CustomEdge.tsx @@ -79,7 +79,7 @@ export function CustomEdge({ onMouseLeave={() => setIsHovered(false)} style={{ cursor: "pointer" }} /> - {(isHovered || selected) && ( + {(isHovered || selected) && !id.startsWith("__io-") && (
setIsHovered(true)} diff --git a/src/workflow/components/canvas/NodePalette.tsx b/src/workflow/components/canvas/NodePalette.tsx index 4bd2864a..17d5a5b7 100644 --- a/src/workflow/components/canvas/NodePalette.tsx +++ b/src/workflow/components/canvas/NodePalette.tsx @@ -309,11 +309,12 @@ export function NodePalette({ definitions }: NodePaletteProps) { [t], ); + const editingGroupId = useUIStore((s) => s.editingGroupId); + const displayDefs = useMemo(() => { let defs = definitions; - // When creating inside an Iterator, filter out the iterator type (no nesting) - const editGroupId = useUIStore.getState().editingGroupId; - if (pendingIteratorParentId || editGroupId) { + // When inside a Group subgraph, filter out the iterator type (no nesting allowed) + if (pendingIteratorParentId || editingGroupId) { defs = defs.filter((d) => d.type !== "control/iterator"); } const q = query.trim(); @@ -324,7 +325,7 @@ export function NodePalette({ definitions }: NodePaletteProps) { t(`workflow.nodeDefs.${def.type}.label`, def.label), def.category, ]).map((r) => r.item); - }, [definitions, query, t, pendingIteratorParentId]); + }, [definitions, query, t, pendingIteratorParentId, editingGroupId]); const groupedDefs = useMemo(() => { const groups = new Map(); diff --git a/src/workflow/components/canvas/SubgraphBreadcrumb.tsx b/src/workflow/components/canvas/SubgraphBreadcrumb.tsx index 07ee430e..2294286c 100644 --- a/src/workflow/components/canvas/SubgraphBreadcrumb.tsx +++ b/src/workflow/components/canvas/SubgraphBreadcrumb.tsx @@ -6,7 +6,7 @@ import { useCallback, useEffect } from "react"; import { useTranslation } from "react-i18next"; import { useUIStore } from "../../stores/ui.store"; import { useWorkflowStore } from "../../stores/workflow.store"; -import { ChevronRight, X } from "lucide-react"; +import { ChevronRight, ArrowLeft } from "lucide-react"; export function SubgraphBreadcrumb() { const { t } = useTranslation(); @@ -66,10 +66,11 @@ export function SubgraphBreadcrumb() {
); diff --git a/src/workflow/components/canvas/WorkflowCanvas.tsx b/src/workflow/components/canvas/WorkflowCanvas.tsx index c6765a06..0435a94e 100644 --- a/src/workflow/components/canvas/WorkflowCanvas.tsx +++ b/src/workflow/components/canvas/WorkflowCanvas.tsx @@ -61,7 +61,6 @@ import { import { toast } from "@/hooks/useToast"; import { cn } from "@/lib/utils"; import { SubgraphBreadcrumb } from "./SubgraphBreadcrumb"; -import { SubgraphToolbar } from "./SubgraphToolbar"; const CATEGORY_ORDER: NodeCategory[] = [ "ai-task", @@ -379,10 +378,6 @@ export function WorkflowCanvas({ nodeDefs = [] }: WorkflowCanvasProps) { const setInteractionMode = useUIStore((s) => s.setInteractionMode); const showGrid = useUIStore((s) => s.showGrid); const editingGroupId = useUIStore((s) => s.editingGroupId); - // Track ReactFlow viewport for pinning IO nodes to screen edges - const [rfVpX, setRfVpX] = useState(0); - const [rfVpY, setRfVpY] = useState(0); - const [rfVpZoom, setRfVpZoom] = useState(1); const reactFlowWrapper = useRef(null); const reactFlowInstance = useRef(null); const [contextMenu, setContextMenu] = useState<{ @@ -425,20 +420,111 @@ export function WorkflowCanvas({ nodeDefs = [] }: WorkflowCanvasProps) { handleNodesDeleted, } = useGroupAdoption(); + // Track virtual IO node positions across re-renders (they're not in the store). + // Use state (not ref) so displayNodes memo recomputes when drag ends. + const [ioNodePositions, setIoNodePositions] = useState>({}); + const lastEditingGroupIdRef = useRef(null); + + // Reset IO positions when entering a different group + if (editingGroupId !== lastEditingGroupIdRef.current) { + lastEditingGroupIdRef.current = editingGroupId; + // Schedule state update (can't setState during render directly in strict mode) + // Using a ref flag to batch it + if (Object.keys(ioNodePositions).length > 0) { + // Will be reset via the effect below + } + } + useEffect(() => { + if (!editingGroupId) { + setIoNodePositions({}); + return; + } + // Restore saved IO node positions from group params, or compute defaults. + const currentNodes = useWorkflowStore.getState().nodes; + const groupNode = currentNodes.find((n) => n.id === editingGroupId); + const savedPositions = groupNode?.data?.params?.__ioNodePositions as + | Record + | undefined; + + const inputId = `__group-input-${editingGroupId}`; + const outputId = `__group-output-${editingGroupId}`; + + if (savedPositions && savedPositions[inputId] && savedPositions[outputId]) { + setIoNodePositions(savedPositions); + } else { + // Compute initial positions from child bounding box + const childNodes = currentNodes.filter((n) => n.parentNode === editingGroupId); + let minX = 0, minY = 0, maxX = 400, maxY = 300; + if (childNodes.length > 0) { + minX = Math.min(...childNodes.map((n) => n.position.x)); + minY = Math.min(...childNodes.map((n) => n.position.y)); + maxX = Math.max(...childNodes.map((n) => n.position.x + 380)); + maxY = Math.max(...childNodes.map((n) => n.position.y + 200)); + } + const centerY = (minY + maxY) / 2 - 60; + const IO_MARGIN = 400; + const positions = { + [inputId]: { x: minX - IO_MARGIN, y: centerY }, + [outputId]: { x: maxX + IO_MARGIN - 220, y: centerY }, + }; + setIoNodePositions(positions); + // Persist the computed defaults + useWorkflowStore.getState().updateNodeParams(editingGroupId, { + ...groupNode?.data?.params, + __ioNodePositions: positions, + }); + } + }, [editingGroupId]); + /** Wrapped onNodesChange that also triggers iterator adoption detection */ const onNodesChangeWithAdoption = useCallback( (changes: NodeChange[]) => { - // Filter out changes for virtual Group IO nodes (they don't exist in the store) - const realChanges = changes.filter((c) => { + // Collect IO node position updates — commit to state when drag ends + // so displayNodes memo recomputes and the node stays in place. + const ioUpdates: Record = {}; + let hasIoUpdate = false; + let ioDragEnded = false; + for (const c of changes) { + const cid = (c as any).id as string | undefined; + if (cid && String(cid).startsWith("__group-") && c.type === "position") { + const posChange = c as any; + if (posChange.position) { + ioUpdates[cid] = { ...posChange.position }; + hasIoUpdate = true; + } + if (posChange.dragging === false) { + ioDragEnded = true; + } + } + } + if (hasIoUpdate) { + setIoNodePositions((prev) => { + const next = { ...prev, ...ioUpdates }; + // Persist to group node params when drag ends + if (ioDragEnded && editingGroupId) { + const groupNode = useWorkflowStore.getState().nodes.find((n) => n.id === editingGroupId); + if (groupNode) { + useWorkflowStore.getState().updateNodeParams(editingGroupId, { + ...groupNode.data?.params, + __ioNodePositions: next, + }); + } + } + return next; + }); + } + + // Only pass real (non-IO) changes to the store + const storeChanges = changes.filter((c) => { const id = (c as any).id; return !id || !String(id).startsWith("__group-"); }); - if (realChanges.length > 0) { - onNodesChange(realChanges); + if (storeChanges.length > 0) { + onNodesChange(storeChanges); } - handleNodesChangeForAdoption(realChanges); + handleNodesChangeForAdoption(storeChanges); }, - [onNodesChange, handleNodesChangeForAdoption], + [onNodesChange, handleNodesChangeForAdoption, editingGroupId], ); /** Wrapped onEdgesChange that filters out virtual IO edge changes */ @@ -467,6 +553,7 @@ export function WorkflowCanvas({ nodeDefs = [] }: WorkflowCanvasProps) { .map((n) => ({ ...n, parentNode: undefined, + hidden: false, position: { ...n.position }, })); @@ -489,62 +576,47 @@ export function WorkflowCanvas({ nodeDefs = [] }: WorkflowCanvasProps) { const exposedInputs = parseExposed("exposedInputs"); const exposedOutputs = parseExposed("exposedOutputs"); - // Convert screen pixel position to flow coordinates so IO nodes stay - // pinned to the left/right edges of the visible viewport. - const el = reactFlowWrapper.current; - const wrapperW = el ? el.clientWidth : 1200; - const wrapperH = el ? el.clientHeight : 800; - const { x: vpX, y: vpY, zoom } = { x: rfVpX, y: rfVpY, zoom: rfVpZoom }; - const screenToFlowX = (sx: number) => (-vpX + sx) / zoom; - const screenToFlowY = (sy: number) => (-vpY + sy) / zoom; + const inputId = `__group-input-${editingGroupId}`; + const outputId = `__group-output-${editingGroupId}`; - const inputIOHeight = exposedInputs.length * 32 + 68; - const outputIOHeight = exposedOutputs.length * 32 + 68; + // IO node positions are initialized once when entering the group (via useEffect) + // and updated only by user dragging. They never recalculate from child bounding box. + const inputPos = ioNodePositions[inputId] ?? { x: -280, y: 0 }; + const outputPos = ioNodePositions[outputId] ?? { x: 460, y: 0 }; - // Pin to screen edges with margin, vertically centered - const MARGIN = 50; const result: Node[] = [...childNodes]; - if (exposedInputs.length > 0) { - result.push({ - id: `__group-input-${editingGroupId}`, - type: "group-io", - position: { - x: screenToFlowX(MARGIN), - y: screenToFlowY(wrapperH / 2 - inputIOHeight / 2), - }, - data: { - direction: "input", - exposedParams: exposedInputs, - groupId: editingGroupId, - }, - selectable: false, - draggable: false, - connectable: false, - deletable: false, - }); - } - if (exposedOutputs.length > 0) { - result.push({ - id: `__group-output-${editingGroupId}`, - type: "group-io", - position: { - x: screenToFlowX(wrapperW - MARGIN - 150), - y: screenToFlowY(wrapperH / 2 - outputIOHeight / 2), - }, - data: { - direction: "output", - exposedParams: exposedOutputs, - groupId: editingGroupId, - }, - selectable: false, - draggable: false, - connectable: false, - deletable: false, - }); - } + // Always inject IO nodes so users can configure params even when none are exposed yet + result.push({ + id: inputId, + type: "group-io", + position: inputPos, + data: { + direction: "input", + exposedParams: exposedInputs, + groupId: editingGroupId, + }, + selectable: true, + draggable: true, + connectable: false, + deletable: false, + }); + result.push({ + id: outputId, + type: "group-io", + position: outputPos, + data: { + direction: "output", + exposedParams: exposedOutputs, + groupId: editingGroupId, + }, + selectable: true, + draggable: true, + connectable: false, + deletable: false, + }); return result; - }, [nodes, editingGroupId, rfVpX, rfVpY, rfVpZoom]); + }, [nodes, editingGroupId, ioNodePositions]); const displayEdges = useMemo(() => { if (!editingGroupId) return edges; @@ -678,12 +750,17 @@ export function WorkflowCanvas({ nodeDefs = [] }: WorkflowCanvasProps) { (event.key === "Delete" || event.key === "Backspace") && selectedNodeIds.size > 0 ) { + // Don't delete virtual IO nodes + const deletableIds = [...selectedNodeIds].filter( + (nid) => !nid.startsWith("__group-"), + ); + if (deletableIds.length === 0) return; event.preventDefault(); - if (selectedNodeIds.size === 1) { - removeNodeWithRelease([...selectedNodeIds][0]); + if (deletableIds.length === 1) { + removeNodeWithRelease(deletableIds[0]); selectNode(null); } else { - removeNodesWithRelease([...selectedNodeIds]); + removeNodesWithRelease(deletableIds); selectNode(null); } } @@ -876,6 +953,15 @@ export function WorkflowCanvas({ nodeDefs = [] }: WorkflowCanvasProps) { [selectNode], ); + // Select node on drag start so users can drag any node immediately + // without needing to click-select it first. + const onNodeDragStart = useCallback( + (_: React.MouseEvent, node: Node) => { + selectNode(node.id); + }, + [selectNode], + ); + const onNodeDoubleClick = useCallback((_: React.MouseEvent, node: Node) => { if (node.type === "control/iterator") { useUIStore.getState().enterGroupEdit(node.id); @@ -884,6 +970,8 @@ export function WorkflowCanvas({ nodeDefs = [] }: WorkflowCanvasProps) { const onPaneClick = useCallback(() => { selectNode(null); setContextMenu(null); + // Clear stale pending iterator parent when clicking on empty canvas + useUIStore.getState().setPendingIteratorParentId(null); }, [selectNode]); const onSelectionChange = useCallback( @@ -1024,6 +1112,24 @@ export function WorkflowCanvas({ nodeDefs = [] }: WorkflowCanvasProps) { const editGroupId = useUIStore.getState().editingGroupId; const adoptParent = pendingItId || editGroupId; if (adoptParent) { + // When in subgraph editing mode, the viewport shows child nodes at their + // relative positions (parentNode stripped). projectMenuPosition gives coords + // in that relative space. But adoptNode subtracts the group's absolute + // position, so we pre-add it to compensate. + if (editGroupId) { + const storeState = useWorkflowStore.getState(); + const groupNode = storeState.nodes.find((n) => n.id === editGroupId); + if (groupNode) { + storeState.onNodesChange([{ + type: "position" as const, + id: newNodeId, + position: { + x: position.x + groupNode.position.x, + y: position.y + groupNode.position.y, + }, + }]); + } + } const { adoptNode } = useWorkflowStore.getState(); adoptNode(adoptParent, newNodeId); if (pendingItId) useUIStore.getState().setPendingIteratorParentId(null); @@ -1043,15 +1149,20 @@ export function WorkflowCanvas({ nodeDefs = [] }: WorkflowCanvasProps) { ); const addNodeDisplayDefs = useMemo(() => { + let defs = nodeDefs; + // When inside a Group subgraph, hide the iterator type (no nesting allowed) + if (editingGroupId) { + defs = defs.filter((d) => d.type !== "control/iterator"); + } const q = addNodeQuery.trim(); - if (!q) return nodeDefs; - return fuzzySearch(nodeDefs, q, (def) => [ + if (!q) return defs; + return fuzzySearch(defs, q, (def) => [ def.type, def.category, def.label, t(`workflow.nodeDefs.${def.type}.label`, def.label), ]).map((r) => r.item); - }, [addNodeQuery, nodeDefs, t]); + }, [addNodeQuery, nodeDefs, t, editingGroupId]); const groupedAddNodeDefs = useMemo(() => { const recentVisible = recentNodeTypes @@ -1208,6 +1319,20 @@ export function WorkflowCanvas({ nodeDefs = [] }: WorkflowCanvasProps) { const editGroupId = useUIStore.getState().editingGroupId; const adoptParent = pendingItId || editGroupId; if (adoptParent) { + if (editGroupId) { + const storeState = useWorkflowStore.getState(); + const groupNode = storeState.nodes.find((n) => n.id === editGroupId); + if (groupNode) { + storeState.onNodesChange([{ + type: "position" as const, + id: newNodeId, + position: { + x: position.x + groupNode.position.x, + y: position.y + groupNode.position.y, + }, + }]); + } + } const { adoptNode: adopt } = useWorkflowStore.getState(); adopt(adoptParent, newNodeId); if (pendingItId) useUIStore.getState().setPendingIteratorParentId(null); @@ -1232,6 +1357,7 @@ export function WorkflowCanvas({ nodeDefs = [] }: WorkflowCanvasProps) { (e: React.KeyboardEvent) => { if (e.key === "Escape") { setContextMenu(null); + useUIStore.getState().setPendingIteratorParentId(null); return; } if (e.key === "ArrowDown") { @@ -1498,6 +1624,10 @@ export function WorkflowCanvas({ nodeDefs = [] }: WorkflowCanvasProps) { // --- Drop from node palette (existing behaviour) --- if (nodeType) { + // Prevent nesting Group inside Group + if (nodeType === "control/iterator" && useUIStore.getState().editingGroupId) { + return; + } // Only one trigger node per workflow if (nodeType.startsWith("trigger/")) { const existingTrigger = useWorkflowStore @@ -1739,9 +1869,14 @@ export function WorkflowCanvas({ nodeDefs = [] }: WorkflowCanvasProps) { useUIStore .getState() .setPendingIteratorParentId(sourceNode.parentNode); + } else { + // Source node is NOT inside a group — clear any stale pending parent + useUIStore.getState().setPendingIteratorParentId(null); } } else { sideAddRef.current = null; + // Not from a node's side button — clear any stale pending parent + useUIStore.getState().setPendingIteratorParentId(null); } openAddNodeMenu(x, y); }; @@ -1816,6 +1951,236 @@ export function WorkflowCanvas({ nodeDefs = [] }: WorkflowCanvasProps) { // Uses actual DOM measurements for node sizes to prevent overlap useEffect(() => { const handleAutoLayout = () => { + const editGroupId = useUIStore.getState().editingGroupId; + + // ── Subgraph editing mode: layout children + virtual IO nodes ── + if (editGroupId) { + const { + nodes: currentNodes, + edges: currentEdges, + onNodesChange: applyChanges, + } = useWorkflowStore.getState(); + + const childNodes = currentNodes.filter((n) => n.parentNode === editGroupId); + if (childNodes.length === 0) return; + + const groupNode = currentNodes.find((n) => n.id === editGroupId); + if (!groupNode) return; + + const inputProxyId = `__group-input-${editGroupId}`; + const outputProxyId = `__group-output-${editGroupId}`; + const childIds = new Set(childNodes.map((n) => n.id)); + + // Collect all layout nodes: IO proxies + real children + // We'll treat IO proxies as virtual entries in the DAG + const allIds = new Set([inputProxyId, outputProxyId, ...childIds]); + + // Build edges: child-to-child + IO proxy edges + const layoutEdges: { source: string; target: string }[] = []; + + // Child-to-child edges + for (const e of currentEdges) { + if (childIds.has(e.source) && childIds.has(e.target)) { + layoutEdges.push({ source: e.source, target: e.target }); + } + } + + // IO proxy edges (from internal auto-edges) + const parseExposed = (key: string): ExposedParam[] => { + try { + const raw = groupNode.data?.params?.[key]; + return typeof raw === "string" + ? JSON.parse(raw) + : Array.isArray(raw) + ? raw + : []; + } catch { + return []; + } + }; + const exposedInputs = parseExposed("exposedInputs"); + const exposedOutputs = parseExposed("exposedOutputs"); + + for (const e of currentEdges) { + if (!e.data?.isInternal) continue; + if ( + e.source === editGroupId && + e.sourceHandle?.startsWith("input-inner-") && + childIds.has(e.target) + ) { + const nk = e.sourceHandle.replace("input-inner-", ""); + if (exposedInputs.some((ep) => ep.namespacedKey === nk)) { + layoutEdges.push({ source: inputProxyId, target: e.target }); + } + } + if ( + e.target === editGroupId && + e.targetHandle?.startsWith("output-inner-") && + childIds.has(e.source) + ) { + const nk = e.targetHandle.replace("output-inner-", ""); + if (exposedOutputs.some((ep) => ep.namespacedKey === nk)) { + layoutEdges.push({ source: e.source, target: outputProxyId }); + } + } + } + + // Measure node sizes from DOM + const nodeSize = new Map(); + for (const id of allIds) { + const el = document.querySelector(`[data-id="${id}"]`) as HTMLElement | null; + if (el) { + nodeSize.set(id, { w: el.offsetWidth, h: el.offsetHeight }); + } else { + nodeSize.set(id, { w: id.startsWith("__group-") ? 260 : 380, h: 250 }); + } + } + + // Build adjacency + const outgoing = new Map(); + const incoming = new Map(); + for (const id of allIds) { + outgoing.set(id, []); + incoming.set(id, []); + } + for (const e of layoutEdges) { + if (allIds.has(e.source) && allIds.has(e.target)) { + outgoing.get(e.source)?.push(e.target); + incoming.get(e.target)?.push(e.source); + } + } + + // Assign layers via longest-path + const layer = new Map(); + const visited = new Set(); + function assignSubgraphLayer(id: string): number { + if (layer.has(id)) return layer.get(id)!; + if (visited.has(id)) return 0; + visited.add(id); + const parents = incoming.get(id) ?? []; + const depth = parents.length === 0 + ? 0 + : Math.max(...parents.map((p) => assignSubgraphLayer(p) + 1)); + layer.set(id, depth); + return depth; + } + for (const id of allIds) assignSubgraphLayer(id); + + // Force Group Input to layer 0 and Group Output to max+1 + layer.set(inputProxyId, 0); + // Shift any child nodes that were at layer 0 to layer 1 + for (const id of childIds) { + if (layer.get(id) === 0) { + if ((outgoing.get(inputProxyId) ?? []).length > 0) { + layer.set(id, (layer.get(id) ?? 0) + 1); + } + } + } + // Recompute max after shifts + const maxLayerAfterShift = Math.max(...layer.values(), 0); + layer.set(outputProxyId, maxLayerAfterShift + 1); + + // Group by layer + const layers = new Map(); + for (const [id, l] of layer) { + if (!layers.has(l)) layers.set(l, []); + layers.get(l)!.push(id); + } + const sortedLayerKeys = [...layers.keys()].sort((a, b) => a - b); + + // Barycenter ordering + const nodeOrder = new Map(); + for (const l of sortedLayerKeys) { + const ids = layers.get(l)!; + ids.forEach((id, i) => nodeOrder.set(id, i)); + } + for (let pass = 0; pass < 4; pass++) { + const keys = pass % 2 === 0 ? sortedLayerKeys : [...sortedLayerKeys].reverse(); + for (const l of keys) { + const ids = layers.get(l)!; + const bary = new Map(); + for (const id of ids) { + const neighbors = pass % 2 === 0 + ? (incoming.get(id) ?? []) + : (outgoing.get(id) ?? []); + bary.set(id, neighbors.length > 0 + ? neighbors.reduce((sum, nid) => sum + (nodeOrder.get(nid) ?? 0), 0) / neighbors.length + : (nodeOrder.get(id) ?? 0)); + } + ids.sort((a, b) => (bary.get(a) ?? 0) - (bary.get(b) ?? 0)); + ids.forEach((id, i) => nodeOrder.set(id, i)); + } + } + + // Compute column X positions + const SG_H_GAP = 120; + const SG_V_GAP = 60; + const layerX = new Map(); + let cx = 0; + for (const l of sortedLayerKeys) { + layerX.set(l, cx); + const ids = layers.get(l)!; + const maxW = Math.max(...ids.map((id) => nodeSize.get(id)?.w ?? 380)); + cx += maxW + SG_H_GAP; + } + + // Position all nodes + const ioPositions: Record = {}; + const childChanges: NodeChange[] = []; + + for (const l of sortedLayerKeys) { + const ids = layers.get(l)!; + const heights = ids.map((id) => nodeSize.get(id)?.h ?? 250); + const totalHeight = heights.reduce((sum, h) => sum + h, 0) + (ids.length - 1) * SG_V_GAP; + let y = -totalHeight / 2; + + ids.forEach((id, i) => { + const pos = { x: layerX.get(l) ?? 0, y }; + if (id === inputProxyId || id === outputProxyId) { + ioPositions[id] = pos; + } else { + // Child nodes: position is relative to parent, so store as relative + childChanges.push({ + type: "position", + id, + position: pos, + } as NodeChange); + } + y += heights[i] + SG_V_GAP; + }); + } + + // Apply child position changes to store + if (childChanges.length > 0) { + applyChanges(childChanges); + } + + // Update IO node positions via state setter and persist to group params + setIoNodePositions(ioPositions); + const gNode = currentNodes.find((n) => n.id === editGroupId); + if (gNode) { + useWorkflowStore.getState().updateNodeParams(editGroupId, { + ...gNode.data?.params, + __ioNodePositions: ioPositions, + }); + } + + // Update bounding box + useWorkflowStore.getState().updateBoundingBox(editGroupId); + + // Fit view + setTimeout(() => { + reactFlowInstance.current?.fitView({ + padding: 0.3, + duration: 300, + minZoom: 0.5, + maxZoom: 1.2, + }); + }, 50); + return; + } + + // ── Main view layout (not in subgraph editing mode) ── const { nodes: currentNodes, edges: currentEdges, @@ -2142,6 +2507,7 @@ export function WorkflowCanvas({ nodeDefs = [] }: WorkflowCanvasProps) { onEdgeUpdateStart={onEdgeUpdateStart} onEdgeUpdateEnd={onEdgeUpdateEnd} onNodeClick={onNodeClick} + onNodeDragStart={onNodeDragStart} onNodeDoubleClick={onNodeDoubleClick} onPaneClick={onPaneClick} onSelectionChange={onSelectionChange} @@ -2201,18 +2567,7 @@ export function WorkflowCanvas({ nodeDefs = [] }: WorkflowCanvasProps) { maxZoom: 1.5, }); } - // Initialize viewport state for IO node pinning - const initVp = instance.getViewport(); - setRfVpX(initVp.x); - setRfVpY(initVp.y); - setRfVpZoom(initVp.zoom); - }} - onMove={(_event, viewport) => { - if (editingGroupId && viewport) { - setRfVpX(viewport.x); - setRfVpY(viewport.y); - setRfVpZoom(viewport.zoom); - } + // Initialize viewport state }} onMoveEnd={(_event, viewport) => { const wfId = useWorkflowStore.getState().workflowId; @@ -2230,12 +2585,6 @@ export function WorkflowCanvas({ nodeDefs = [] }: WorkflowCanvasProps) { /* ignore */ } } - // Also update viewport state for IO node pinning - if (viewport) { - setRfVpX(viewport.x); - setRfVpY(viewport.y); - setRfVpZoom(viewport.zoom); - } }} nodeTypes={nodeTypes} edgeTypes={edgeTypes} @@ -2261,7 +2610,6 @@ export function WorkflowCanvas({ nodeDefs = [] }: WorkflowCanvasProps) { )} - {contextMenu && contextMenu.type !== "addNode" && ( (null); + const wrapperRef = useRef(null); const [resizing, setResizing] = useState(false); const { getViewport, setNodes } = useReactFlow(); const shortId = id.slice(0, 8); @@ -138,7 +139,8 @@ function CustomNodeComponent({ e.stopPropagation(); e.preventDefault(); const el = nodeRef.current; - if (!el) return; + const wrapper = wrapperRef.current; + if (!el || !wrapper) return; setResizing(true); const startX = e.clientX; @@ -159,14 +161,14 @@ function CustomNodeComponent({ if (xDir === -1 || yDir === -1) { const tx = xDir === -1 ? dx : 0; const ty = yDir === -1 ? dy : 0; - el.style.transform = `translate(${tx}px, ${ty}px)`; + wrapper.style.transform = `translate(${tx}px, ${ty}px)`; } }; const onUp = (ev: MouseEvent) => { document.removeEventListener("mousemove", onMove); document.removeEventListener("mouseup", onUp); - el.style.transform = ""; + wrapper.style.transform = ""; el.style.width = ""; el.style.minHeight = ""; setResizing(false); @@ -652,6 +654,7 @@ function CustomNodeComponent({ return (
setHovered(true)} onMouseLeave={() => { setHovered(false); @@ -685,41 +688,53 @@ function CustomNodeComponent({ <> {/* Split Run button: main area runs, chevron opens count picker */}
- - + + + + + + {t("workflow.runNode", "Run Node")} + + + + + + + + {t("workflow.runCount", "Run count")} + + {showRunCountPicker && (
{[1, 2, 3, 4, 5, 8, 10].map((n) => ( @@ -742,43 +757,55 @@ function CustomNodeComponent({
)}
- - + + + + + + {t("workflow.continueFrom", "Continue From")} + + + + + + + + {t("workflow.delete", "Delete")} + + )}
@@ -792,27 +819,27 @@ function CustomNodeComponent({ bg-[hsl(var(--card))] text-[hsl(var(--card-foreground))] border-2 ${resizing ? "" : "transition-all duration-300"} - ${running ? (isInsideIterator ? "border-cyan-500 animate-pulse-subtle" : "border-blue-500 animate-pulse-subtle") : ""} - ${!running && selected ? (isInsideIterator ? "border-cyan-500 shadow-[0_0_20px_rgba(6,182,212,.25)] ring-1 ring-cyan-500/30" : "border-blue-500 shadow-[0_0_20px_rgba(96,165,250,.25)] ring-1 ring-blue-500/30") : ""} + ${running ? (isInsideIterator ? "border-blue-500 animate-pulse-subtle" : "border-blue-500 animate-pulse-subtle") : ""} + ${!running && selected ? (isInsideIterator ? "border-blue-500 shadow-[0_0_20px_rgba(96,165,250,.25)] ring-1 ring-blue-500/30" : "border-blue-500 shadow-[0_0_20px_rgba(96,165,250,.25)] ring-1 ring-blue-500/30") : ""} ${!running && !selected && status === "confirmed" ? "border-green-500/70" : ""} ${!running && !selected && status === "unconfirmed" ? "border-orange-500/70" : ""} ${!running && !selected && status === "error" ? "border-red-500/70" : ""} ${!running && !selected && status === "idle" ? (hovered ? "border-[hsl(var(--border))] shadow-lg" : "border-[hsl(var(--border))] shadow-md") : ""} - ${isInsideIterator && !running && !selected && status === "idle" ? "ring-1 ring-cyan-500/20" : ""} + ${isInsideIterator && !running && !selected && status === "idle" ? "ring-1 ring-blue-500/20" : ""} `} style={{ width: savedWidth, minHeight: savedHeight, fontSize: 13 }} > {/* ── Title bar ──────────── */}
e.stopPropagation()} > - setParam(p.key, v)} - formValues={formValues} - onUploadFile={handleCdnUpload} - /> + {inlineParam ? ( +
+ +
+ setParam(p.key, v)} + formValues={formValues} + onUploadFile={handleCdnUpload} + hideLabel + /> +
+
+ ) : ( + setParam(p.key, v)} + formValues={formValues} + onUploadFile={handleCdnUpload} + /> + )}
); } @@ -1324,6 +1342,26 @@ export function CustomNodeBody(props: CustomNodeBodyProps) { } if (!canConnect) { + // Output Directory: stack label above control so hint text has full width + if (data.nodeType === "output/file" && p.key === "outputDir") { + return ( +
+
+ + {localizeParamLabel(p.key, p.label)} + +
+ setParam(p.key, v)} + /> +
+
+
+ ); + } return (
diff --git a/src/workflow/components/canvas/custom-node/CustomNodeInputBodies.tsx b/src/workflow/components/canvas/custom-node/CustomNodeInputBodies.tsx index 7a181058..070a602c 100644 --- a/src/workflow/components/canvas/custom-node/CustomNodeInputBodies.tsx +++ b/src/workflow/components/canvas/custom-node/CustomNodeInputBodies.tsx @@ -966,7 +966,9 @@ export function DirectoryImportBody({ {t("workflow.directoryImport.noFiles", "No matching files")} ) : ( - + + {t("workflow.directoryImport.awaitingDirectory", "Awaiting directory")} + )}
diff --git a/src/workflow/components/canvas/custom-node/CustomNodeParamControls.tsx b/src/workflow/components/canvas/custom-node/CustomNodeParamControls.tsx index e6098302..40026186 100644 --- a/src/workflow/components/canvas/custom-node/CustomNodeParamControls.tsx +++ b/src/workflow/components/canvas/custom-node/CustomNodeParamControls.tsx @@ -901,7 +901,7 @@ export function DefParamControl({ }; return ( -
+
) { +/* ── Data type color dot ───────────────────────────────────────────── */ + +const DATA_TYPE_COLORS: Record = { + image: "bg-blue-400", + video: "bg-purple-400", + audio: "bg-amber-400", + text: "bg-slate-400", + any: "bg-gray-400", +}; + +function DataTypeDot({ dataType }: { dataType?: string }) { + const color = DATA_TYPE_COLORS[dataType ?? "any"] ?? DATA_TYPE_COLORS.any; + return ; +} + +/* ── Inline param picker ───────────────────────────────────────────── */ + +function InlineParamPicker({ + groupId, + direction, +}: { + groupId: string; + direction: "input" | "output"; +}) { + const { t } = useTranslation(); + const nodes = useWorkflowStore((s) => s.nodes); + const exposeParam = useWorkflowStore((s) => s.exposeParam); + const unexposeParam = useWorkflowStore((s) => s.unexposeParam); + + const groupNode = nodes.find((n) => n.id === groupId); + const groupParams = (groupNode?.data?.params ?? {}) as Record; + const childNodes = nodes.filter((n) => n.parentNode === groupId); + + const exposedList: ExposedParam[] = useMemo(() => { + const key = direction === "input" ? "exposedInputs" : "exposedOutputs"; + try { + const raw = groupParams[key]; + return typeof raw === "string" ? JSON.parse(raw) : Array.isArray(raw) ? raw : []; + } catch { return []; } + }, [groupParams, direction]); + + const isExposed = useCallback( + (subNodeId: string, paramKey: string) => + exposedList.some((p) => p.subNodeId === subNodeId && p.paramKey === paramKey), + [exposedList], + ); + + const handleToggle = useCallback( + (subNodeId: string, subNodeLabel: string, paramKey: string, dataType: string) => { + if (isExposed(subNodeId, paramKey)) { + const ep = exposedList.find((p) => p.subNodeId === subNodeId && p.paramKey === paramKey); + if (ep) unexposeParam(groupId, ep.namespacedKey, direction); + } else { + exposeParam(groupId, { + subNodeId, subNodeLabel, paramKey, + namespacedKey: `${subNodeId}.${paramKey}`, + direction, + dataType: dataType as ExposedParam["dataType"], + }); + } + }, + [isExposed, exposedList, exposeParam, unexposeParam, groupId, direction], + ); + + const isInput = direction === "input"; + + const [collapsedSections, setCollapsedSections] = useState>(new Set()); + const toggleSection = useCallback((nodeId: string) => { + setCollapsedSections((prev) => { + const next = new Set(prev); + next.has(nodeId) ? next.delete(nodeId) : next.add(nodeId); + return next; + }); + }, []); + + return ( +
+ {childNodes.length === 0 ? ( +

+ {t("workflow.noChildNodes", "Add child nodes first to expose their parameters")} +

+ ) : ( + childNodes.map((child) => { + const fullLabel = String(child.data?.label ?? child.id.slice(0, 8)); + const shortId = child.id.slice(0, 6); + const childInputDefs = (child.data?.inputDefinitions ?? []) as PortDefinition[]; + const childOutputDefs = (child.data?.outputDefinitions ?? []) as PortDefinition[]; + const modelSchema = (child.data?.modelInputSchema ?? []) as Array<{ + name: string; label?: string; type?: string; mediaType?: string; + }>; + const paramDefs = (child.data?.paramDefinitions ?? []) as Array<{ + key: string; label: string; dataType?: string; + }>; + + let items: Array<{ key: string; label: string; dataType: string }>; + if (direction === "input") { + const modelItems = modelSchema.map((m) => ({ + key: m.name, + label: m.label || m.name.split("_").map((w: string) => w.charAt(0).toUpperCase() + w.slice(1)).join(" "), + dataType: m.mediaType ?? m.type ?? "any", + })); + const inputPortItems = childInputDefs.map((d) => ({ key: d.key, label: d.label, dataType: d.dataType })); + if (modelItems.length === 0) { + const visibleParams = paramDefs + .filter((d) => !d.key.startsWith("__") && d.key !== "modelId") + .map((d) => ({ key: d.key, label: d.label, dataType: d.dataType ?? "any" })); + items = [...visibleParams, ...inputPortItems]; + } else { + items = [...modelItems, ...inputPortItems]; + } + } else { + items = childOutputDefs.map((d) => ({ key: d.key, label: d.label, dataType: d.dataType })); + } + if (items.length === 0) return null; + + const exposedCount = items.filter((it) => isExposed(child.id, it.key)).length; + const isCollapsed = collapsedSections.has(child.id); + + return ( +
+ + {!isCollapsed && ( +
+ {items.map((item) => { + const exposed = isExposed(child.id, item.key); + return ( + + ); + })} +
+ )} +
+ ); + }) + )} +
+ ); +} + +/* ── Main GroupIONode component ─────────────────────────────────────── */ + +function GroupIONodeComponent({ data, id }: NodeProps) { const { t } = useTranslation(); - const { direction, exposedParams } = data; + const { direction, exposedParams, groupId } = data; const isInput = direction === "input"; + const [pickerOpen, setPickerOpen] = useState(false); + const cardRef = useRef(null); + const updateNodeInternals = useUpdateNodeInternals(); + + const unexposeParam = useWorkflowStore((s) => s.unexposeParam); + const updateAlias = useWorkflowStore((s) => s.updateExposedParamAlias); + const [editingAlias, setEditingAlias] = useState(null); + const [aliasValue, setAliasValue] = useState(""); + const [hoveredPort, setHoveredPort] = useState(null); + + const accentColor = isInput ? "hsl(188 95% 43%)" : "hsl(160 84% 39%)"; const ports = useMemo( () => exposedParams.map((ep) => { - if (ep.alias) { - return { key: ep.namespacedKey, label: ep.alias, ep }; - } - const label = ep.paramKey + const label = ep.alias || ep.paramKey .split("_") .map((w: string) => w.charAt(0).toUpperCase() + w.slice(1)) .join(" "); @@ -47,115 +266,277 @@ function GroupIONodeComponent({ data }: NodeProps) { [exposedParams], ); - if (ports.length === 0) return null; + // Force ReactFlow to recalculate handle positions when ports change + const portFingerprint = useMemo(() => ports.map((p) => p.key).join(","), [ports]); + useEffect(() => { + requestAnimationFrame(() => updateNodeInternals(id)); + }, [portFingerprint, id, updateNodeInternals]); + + // Signal picker open state so SubgraphBreadcrumb ESC doesn't fire + useEffect(() => { + if (pickerOpen) document.body.setAttribute("data-subgraph-picker-open", "true"); + else document.body.removeAttribute("data-subgraph-picker-open"); + return () => { document.body.removeAttribute("data-subgraph-picker-open"); }; + }, [pickerOpen]); + + // Click outside to close picker + useEffect(() => { + if (!pickerOpen) return; + const handler = (e: MouseEvent) => { + if (cardRef.current?.contains(e.target as Node)) return; + setPickerOpen(false); + }; + const timer = setTimeout(() => document.addEventListener("mousedown", handler, true), 0); + return () => { clearTimeout(timer); document.removeEventListener("mousedown", handler, true); }; + }, [pickerOpen]); + + // ESC to close picker + useEffect(() => { + if (!pickerOpen) return; + const handler = (e: KeyboardEvent) => { + if (e.key === "Escape") { e.stopPropagation(); e.preventDefault(); setPickerOpen(false); } + }; + document.addEventListener("keydown", handler, true); + return () => document.removeEventListener("keydown", handler, true); + }, [pickerOpen]); + + const startAliasEdit = useCallback((nk: string, currentAlias: string) => { + setEditingAlias(nk); + setAliasValue(currentAlias); + }, []); + + const commitAlias = useCallback(() => { + if (editingAlias) { + updateAlias(groupId, editingAlias, direction, aliasValue.trim()); + setEditingAlias(null); + } + }, [editingAlias, aliasValue, groupId, direction, updateAlias]); + + const handleRemoveParam = useCallback((nk: string) => { + unexposeParam(groupId, nk, direction); + }, [unexposeParam, groupId, direction]); - // Total height of the port area - const totalPortHeight = ports.length * PORT_SPACING; - // The vertical line position (x offset within the node) - const lineX = isInput ? 130 : 10; + /* Compute handle Y positions — handles are placed absolutely on the outer wrapper + so ReactFlow can measure them correctly relative to the node root. + Offset: accent strip (3px) + border (2px) + header height + row offset */ + const getHandleY = (index: number) => + ACCENT_STRIP_HEIGHT + BORDER_W + HEADER_HEIGHT + index * PORT_ROW_HEIGHT + PORT_ROW_HEIGHT / 2; return ( -
- {/* Direction label at top */} +
+ {/* ── Handles — absolutely positioned on the outer wrapper ── */} + {ports.map((port, i) => ( + + ))} + + {/* ── Card body — fully opaque bg-card, no grid bleed-through ── */}
- {isInput - ? t("workflow.groupInput", "Group Input") - : t("workflow.groupOutput", "Group Output")} -
+ {/* ── Top accent strip ── */} +
- {/* Vertical accent line — extends above first port and below last port */} -
- - {/* Port rows */} - {ports.map((port, i) => { - const y = HEADER_HEIGHT + i * PORT_SPACING + PORT_SPACING / 2; - - return isInput ? ( -
- {/* Label on the left side */} - - {port.label} - - {/* Handle dot on the line */} - + {/* ── Header — tinted overlay on top of opaque card bg ── */} +
+
+ {isInput + ? + : }
- ) : ( -
- {/* Handle dot on the line */} - - {/* Label on the right side */} - - {port.label} + + + {isInput ? t("workflow.groupInput", "Group Input") : t("workflow.groupOutput", "Group Output")} + + + {ports.length > 0 && ( + + {ports.length} + )} + + + + + + + {t("workflow.configureParams", "Configure parameters")} + + +
+ + {/* ── Separator line between header and ports ── */} + {(ports.length > 0 || pickerOpen) && ( +
+ )} + + {/* ── Port list ───────────────────────────────────────── */} + {ports.length === 0 && !pickerOpen ? ( +
+ +
+ ) : ports.length > 0 ? ( +
+ {ports.map((port) => { + const isEditing = editingAlias === port.ep.namespacedKey; + const isHovered = hoveredPort === port.key; + + return ( +
setHoveredPort(port.key)} + onMouseLeave={() => setHoveredPort(null)} + > + {/* Active indicator bar */} +
+ +
+ + + {isEditing ? ( + setAliasValue(e.target.value)} + onBlur={commitAlias} + onKeyDown={(e) => { + if (e.key === "Enter") commitAlias(); + if (e.key === "Escape") { e.stopPropagation(); setEditingAlias(null); } + }} + placeholder={port.ep.paramKey} + onClick={(e) => e.stopPropagation()} + autoFocus + /> + ) : ( + + {port.label} + + )} + + {/* Action buttons — always visible */} + {!isEditing && ( +
+ + + + + + {t("workflow.rename", "Rename")} + + + + + + + + {t("workflow.removeParam", "Remove")} + + +
+ )} +
+
+ ); + })}
- ); - })} + ) : null} + + {/* ── Inline picker ───────────────────────────────────── */} + {pickerOpen && ( +
+ +
+ )} + + {/* Bottom padding when ports visible */} + {ports.length > 0 && !pickerOpen &&
} +
); } diff --git a/src/workflow/components/canvas/group-node/GroupNodeContainer.tsx b/src/workflow/components/canvas/group-node/GroupNodeContainer.tsx index b278dde6..087bffbe 100644 --- a/src/workflow/components/canvas/group-node/GroupNodeContainer.tsx +++ b/src/workflow/components/canvas/group-node/GroupNodeContainer.tsx @@ -52,11 +52,7 @@ const MIN_ITERATOR_WIDTH = 600; const MIN_ITERATOR_HEIGHT = 400; const CHILD_PADDING = 40; const TITLE_BAR_HEIGHT = 40; -const CAPSULE_HEIGHT = 28; -const CAPSULE_GAP = 6; -const CAPSULE_TOP_OFFSET = TITLE_BAR_HEIGHT + 12; -const HANDLE_DOT = 10; -const CAPSULE_LABEL_WIDTH = 110; // fixed width for capsule label area +const HANDLE_DOT = 12; /* ── types ─────────────────────────────────────────────────────────── */ @@ -131,8 +127,6 @@ function IteratorNodeContainerComponent({ const shortId = id.slice(0, 8); const inputDefs = useMemo(() => { - // Reconstruct from exposedInputs params (source of truth) to be resilient - // against data.inputDefinitions being reset by other state updates try { const raw = data.params?.exposedInputs; const list: ExposedParam[] = @@ -141,33 +135,21 @@ function IteratorNodeContainerComponent({ : Array.isArray(raw) ? raw : []; - return list.map((ep): PortDefinition => { - if (ep.alias) { - return { - key: ep.namespacedKey, - label: ep.alias, - dataType: ep.dataType, - required: false, - }; - } + return list.map((ep): PortDefinition & { _ep: ExposedParam } => { const readableParam = ep.paramKey .split("_") .map((w: string) => w.charAt(0).toUpperCase() + w.slice(1)) .join(" "); - // Use node label (or short ID) for disambiguation - const nodeLabel = ep.subNodeLabel || ep.subNodeId.slice(0, 6); - const shortLabel = nodeLabel.includes("/") - ? nodeLabel.split("/").pop()! - : nodeLabel; return { key: ep.namespacedKey, - label: `${readableParam} · ${shortLabel}`, + label: ep.alias || readableParam, dataType: ep.dataType, required: false, + _ep: ep, }; }); } catch { - return data.inputDefinitions ?? []; + return (data.inputDefinitions ?? []).map((d) => ({ ...d, _ep: undefined as unknown as ExposedParam })); } }, [data.params?.exposedInputs, data.inputDefinitions]); @@ -180,32 +162,21 @@ function IteratorNodeContainerComponent({ : Array.isArray(raw) ? raw : []; - return list.map((ep): PortDefinition => { - if (ep.alias) { - return { - key: ep.namespacedKey, - label: ep.alias, - dataType: ep.dataType, - required: false, - }; - } + return list.map((ep): PortDefinition & { _ep: ExposedParam } => { const readableParam = ep.paramKey .split("_") .map((w: string) => w.charAt(0).toUpperCase() + w.slice(1)) .join(" "); - const nodeLabel = ep.subNodeLabel || ep.subNodeId.slice(0, 6); - const shortLabel = nodeLabel.includes("/") - ? nodeLabel.split("/").pop()! - : nodeLabel; return { key: ep.namespacedKey, - label: `${readableParam} · ${shortLabel}`, + label: ep.alias || readableParam, dataType: ep.dataType, required: false, + _ep: ep, }; }); } catch { - return data.outputDefinitions ?? []; + return (data.outputDefinitions ?? []).map((d) => ({ ...d, _ep: undefined as unknown as ExposedParam })); } }, [data.params?.exposedOutputs, data.outputDefinitions]); const childNodeIds = data.childNodeIds ?? []; @@ -250,7 +221,6 @@ function IteratorNodeContainerComponent({ /* ── Effective size ─────────────────────────────────────────────── */ const COMPACT_WIDTH = 320; - const maxCapsules = Math.max(inputDefs.length, outputDefs.length); /* ── Sync child hidden state with collapsed ────────────────────── */ useEffect(() => { @@ -324,29 +294,12 @@ function IteratorNodeContainerComponent({ setEditingName(false); }, []); - /* ── Capsule vertical position ─────────────────────────────────── */ - const getCapsuleTop = (index: number) => - CAPSULE_TOP_OFFSET + index * (CAPSULE_HEIGHT + CAPSULE_GAP); - - /* ── Exposed param lookup — maps namespacedKey → ExposedParam for tooltip info ── */ - const exposedParamMap = useMemo(() => { - const map = new Map(); - for (const key of ["exposedInputs", "exposedOutputs"] as const) { - try { - const raw = data.params?.[key]; - const list: ExposedParam[] = - typeof raw === "string" - ? JSON.parse(raw) - : Array.isArray(raw) - ? raw - : []; - for (const ep of list) map.set(ep.namespacedKey, ep); - } catch { - /* ignore */ - } - } - return map; - }, [data.params]); + /* ── Capsule layout constants ──────────────────────────────────── */ + const CAPSULE_H = 26; + const CAPSULE_GAP = 10; + const CAPSULE_TOP = TITLE_BAR_HEIGHT + 8; + const getCapsuleCenter = (index: number) => + CAPSULE_TOP + index * (CAPSULE_H + CAPSULE_GAP) + CAPSULE_H / 2; /* ── Check if a handle has a connected edge ────────────────────── */ const isHandleConnected = useCallback( @@ -397,56 +350,70 @@ function IteratorNodeContainerComponent({ ) : ( <> - - - + + + + + {t("workflow.runNode", "Run Node")} + + + + + + {t("workflow.continueFrom", "Continue From")} + + + + + + {t("workflow.delete", "Delete")} + )}
@@ -534,7 +501,7 @@ function IteratorNodeContainerComponent({ /> ) : ( { e.stopPropagation(); startEditingName(); @@ -550,6 +517,15 @@ function IteratorNodeContainerComponent({ {shortId} + {/* Child count — inline after shortId, nudged down slightly */} + {hasChildren && ( + + + + + {childNodeIds.length} child nodes + + )} {/* Drop target capsule tag */} {isAdoptTarget && ( @@ -591,6 +567,7 @@ function IteratorNodeContainerComponent({ )}
+ {/* Child count badge — top right */}
{/* ── Running progress bar ───────────────────────────── */} @@ -672,46 +649,55 @@ function IteratorNodeContainerComponent({
)} - {/* ── Compact body: child summary + action buttons ── */} - {!collapsed && ( -
0 - ? maxCapsules * (CAPSULE_HEIGHT + CAPSULE_GAP) + 4 - : 0, - }} - > - {/* Child count summary */} -
- - - - - - - - {hasChildren - ? t("workflow.childNodesCount", "{{count}} child node(s)", { - count: childNodeIds.length, - }) - : t("workflow.iteratorEmpty", "No child nodes yet")} - + {/* ── Capsule param pills ── */} + {!collapsed && (inputDefs.length > 0 || outputDefs.length > 0) && ( +
+ {/* Input capsules — left aligned */} +
+ {inputDefs.map((inp) => { + const ep = inp._ep; + const tooltip = ep + ? `${ep.paramKey.split("_").map((w: string) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ")} — ${ep.subNodeLabel || ep.subNodeId.slice(0, 8)}${ep.dataType ? ` (${ep.dataType})` : ""}` + : inp.label; + return ( + + +
+ {inp.label} +
+
+ {tooltip} +
+ ); + })} +
+ {/* Output capsules — right aligned, absolutely positioned */} +
+ {outputDefs.map((out) => { + const ep = out._ep; + const tooltip = ep + ? `${ep.paramKey.split("_").map((w: string) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ")} — ${ep.subNodeLabel || ep.subNodeId.slice(0, 8)}${ep.dataType ? ` (${ep.dataType})` : ""}` + : out.label; + return ( + + +
+ {out.label} +
+
+ {tooltip} +
+ ); + })}
+
+ )} + {/* ── Compact body: action buttons ── */} + {!collapsed && ( +
{/* Action buttons */} -
+
- {/* ── LEFT SIDE: exposed input capsules ──────────────────── */} + {/* ── LEFT SIDE: input handles ──────────────────── */} {!collapsed && inputDefs.map((port, i) => { - const top = getCapsuleTop(i); + const centerY = getCapsuleCenter(i); const extHandleId = `input-${port.key}`; const intHandleId = `input-inner-${port.key}`; const extConnected = isHandleConnected(extHandleId, "target"); - const ep = exposedParamMap.get(port.key); - const tooltipText = ep - ? `${ep.paramKey - .split("_") - .map((w: string) => w.charAt(0).toUpperCase() + w.slice(1)) - .join(" ")} — ${ep.subNodeLabel}` - : port.label; return ( - - {/* External target handle — on the left border */} + - {/* Capsule label */} - - -
-
- - {port.label} - -
-
-
- - {tooltipText} - -
- {/* Internal source handle — hidden but kept for auto-edge wiring */} { - const top = getCapsuleTop(i); + const centerY = getCapsuleCenter(i); const intHandleId = `output-inner-${port.key}`; const extHandleId = `output-${port.key}`; const extConnected = isHandleConnected(extHandleId, "source"); - const ep = exposedParamMap.get(port.key); - const tooltipText = ep - ? `${ep.paramKey - .split("_") - .map((w: string) => w.charAt(0).toUpperCase() + w.slice(1)) - .join(" ")} — ${ep.subNodeLabel}` - : port.label; - // Compute left-based positions so ReactFlow handle lookup is reliable - const capsuleLabelLeft = - COMPACT_WIDTH - HANDLE_DOT - CAPSULE_LABEL_WIDTH; - const extHandleLeft = COMPACT_WIDTH - HANDLE_DOT / 2; return ( - - {/* Internal target handle — hidden but kept for auto-edge wiring */} + - {/* Capsule label */} - - -
-
- - {port.label} - -
-
-
- - {tooltipText} - -
- {/* External source handle — on the right border */} diff --git a/src/workflow/components/panels/ResultsPanel.tsx b/src/workflow/components/panels/ResultsPanel.tsx index 73dbd006..c89d6542 100644 --- a/src/workflow/components/panels/ResultsPanel.tsx +++ b/src/workflow/components/panels/ResultsPanel.tsx @@ -65,12 +65,14 @@ export function ResultsPanel({ const [stackIndex, setStackIndex] = useState(0); /** Track slide direction for animation */ const prevIndexRef = useRef(0); + const slideKeyRef = useRef(0); const slideDirection = stackIndex > prevIndexRef.current ? "left" : stackIndex < prevIndexRef.current ? "right" : "none"; + if (slideDirection !== "none") slideKeyRef.current += 1; useEffect(() => { prevIndexRef.current = stackIndex; }, [stackIndex]); @@ -386,7 +388,16 @@ export function ResultsPanel({ (u) => getOutputItemType(u) === "text", ); return isTextResult ? ( -
+
{currentUrls.map((url, ui) => ( 1 && ( -
+
- + {clampedIndex + 1} / {total} - {/* Select as Output */} + {/* Select as Output — follows pagination visually but doesn't affect centering */} {total > 1 && currentRec.status === "success" && ( - {allTypes.map((type) => ( - - ))} - -
- + )}
+ {typeFiltersOpen && ( +
+ + {allTypes.map((type) => ( + + ))} +
+ )} {filteredModels.length === 0 ? (
diff --git a/src/stores/modelsStore.ts b/src/stores/modelsStore.ts index 6e844dd3..67e0971a 100644 --- a/src/stores/modelsStore.ts +++ b/src/stores/modelsStore.ts @@ -53,6 +53,7 @@ interface ModelsState { favorites: Set; showFavoritesOnly: boolean; hasFetched: boolean; + typeFiltersOpen: boolean; fetchModels: (force?: boolean) => Promise; setSearchQuery: (query: string) => void; setSelectedType: (type: string | null) => void; @@ -62,6 +63,7 @@ interface ModelsState { toggleFavorite: (modelId: string) => void; isFavorite: (modelId: string) => boolean; setShowFavoritesOnly: (show: boolean) => void; + setTypeFiltersOpen: (open: boolean) => void; getFilteredModels: () => Model[]; getModelById: (modelId: string) => Model | undefined; } @@ -77,6 +79,7 @@ export const useModelsStore = create((set, get) => ({ favorites: loadFavorites(), showFavoritesOnly: false, hasFetched: false, + typeFiltersOpen: true, fetchModels: async (force = false) => { if (get().hasFetched && !force) return; @@ -140,6 +143,10 @@ export const useModelsStore = create((set, get) => ({ set({ showFavoritesOnly: show }); }, + setTypeFiltersOpen: (open: boolean) => { + set({ typeFiltersOpen: open }); + }, + getFilteredModels: () => { const { models, From c174592bbabd7774feb45f3a30a2747f02bad7ee Mon Sep 17 00:00:00 2001 From: Leon Date: Fri, 20 Mar 2026 15:23:21 +1100 Subject: [PATCH 12/18] feat: sync all i18n locales for trigger/directory, trigger/http, output/http-response, group node; UI polish for run badge, tooltips, guide, collapse --- electron/workflow/nodes/trigger/directory.ts | 2 +- src/i18n/locales/ar.json | 28 ++++++- src/i18n/locales/de.json | 28 ++++++- src/i18n/locales/en.json | 16 +++- src/i18n/locales/es.json | 28 ++++++- src/i18n/locales/fr.json | 28 ++++++- src/i18n/locales/hi.json | 28 ++++++- src/i18n/locales/id.json | 28 ++++++- src/i18n/locales/it.json | 28 ++++++- src/i18n/locales/ja.json | 28 ++++++- src/i18n/locales/ko.json | 28 ++++++- src/i18n/locales/ms.json | 28 ++++++- src/i18n/locales/pt.json | 28 ++++++- src/i18n/locales/ru.json | 28 ++++++- src/i18n/locales/th.json | 28 ++++++- src/i18n/locales/tr.json | 28 ++++++- src/i18n/locales/vi.json | 28 ++++++- src/i18n/locales/zh-CN.json | 14 +++- src/i18n/locales/zh-TW.json | 28 ++++++- src/workflow/WorkflowPage.tsx | 2 +- src/workflow/components/WorkflowGuide.tsx | 42 ++++++++-- .../components/canvas/WorkflowCanvas.tsx | 4 +- .../canvas/custom-node/CustomNode.tsx | 8 +- .../custom-node/CustomNodeParamControls.tsx | 76 +++++++++++-------- .../canvas/group-node/GroupNodeContainer.tsx | 6 +- 25 files changed, 534 insertions(+), 84 deletions(-) diff --git a/electron/workflow/nodes/trigger/directory.ts b/electron/workflow/nodes/trigger/directory.ts index a34964f1..44697437 100644 --- a/electron/workflow/nodes/trigger/directory.ts +++ b/electron/workflow/nodes/trigger/directory.ts @@ -42,7 +42,7 @@ MEDIA_EXTENSIONS.all = [ export const directoryTriggerDef: NodeTypeDefinition = { type: "trigger/directory", category: "trigger", - label: "Directory", + label: "Directory Trigger", inputs: [], outputs: [{ key: "output", label: "File", dataType: "url", required: true }], params: [ diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index 58f764ec..a7f0721b 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -1202,6 +1202,14 @@ "control": "التحكم" }, "nodeDefs": { + "trigger/directory": { + "label": "مشغّل المجلد", + "hint": "مسح مجلد محلي — يعمل سير العمل مرة لكل ملف" + }, + "trigger/http": { + "label": "مشغّل HTTP", + "hint": "نشر سير العمل كـ HTTP API — تحديد حقول الإدخال التي يوفرها المتصلون" + }, "ai-task/run": { "label": "WaveSpeed API", "hint": "تشغيل أي نموذج ذكاء اصطناعي — صور وفيديو وصوت والمزيد", @@ -1288,6 +1296,10 @@ } } }, + "output/http-response": { + "label": "استجابة HTTP", + "hint": "حدد ما يعيده سير العمل لمستدعي HTTP" + }, "free-tool/image-enhancer": { "label": "تحسين الصورة", "hint": "تكبير وتحسين الصور (2×–4×) مجاناً" @@ -1358,8 +1370,8 @@ "hint": "اختيار عنصر واحد من مصفوفة حسب الفهرس" }, "control/iterator": { - "label": "مكرر", - "hint": "التكرار عبر المدخلات — تشغيل العقد الفرعية N مرة أو مرة واحدة لكل عنصر في المصفوفة" + "label": "مجموعة", + "hint": "تجميع العقد في سير عمل فرعي للتنظيم" } }, "modelSelector": { @@ -1474,6 +1486,18 @@ "title": "عقدة Concat — دمج مخرجات متعددة", "desc": "هل تحتاج لتمرير عدة صور إلى مدخل \"images\"؟ استخدم عقدة Concat:\n\n1. صِل مخرج image من كل عقدة سابقة بمدخلات Concat: value1، value2، value3، إلخ.\n2. صِل مخرج output من Concat (الآن مصفوفة) بمعامل images في العقدة التالية.\n\nمثال:\n[رفع A] → image → Concat → output (مصفوفة) → [مهمة AI].images\n[رفع B] → image ↗\n\nيعمل مع أي نوع مخرجات — صور، فيديو، نص — كلما احتجت لدمج عدة قيم فردية في مدخل مصفوفة واحد." }, + "directoryTrigger": { + "title": "مشغّل المجلد — معالجة دفعية للملفات المحلية", + "desc": "مسح مجلد محلي تلقائياً وتشغيل سير العمل مرة لكل ملف:\n\n• اختر مجلداً ونوع الملفات (صور، فيديو، صوت، أو الكل)\n• يجد المشغّل جميع الملفات المطابقة ويمررها واحداً تلو الآخر إلى خط الأنابيب\n• مثالي للمعالجة الدفعية — مثل تحسين كل صورة في مجلد أو تحويل جميع الفيديوهات\n\nكل تنفيذ يستقبل ملفاً واحداً، لذا تعمل العقد اللاحقة تماماً كما مع الرفع اليدوي." + }, + "httpTrigger": { + "title": "مشغّل HTTP — تشغيل سير العمل عبر API", + "desc": "حوّل أي سير عمل إلى نقطة نهاية HTTP API:\n\n• حدد حقول الإخراج (مثل image، prompt) — كل حقل يصبح منفذ إخراج على اللوحة\n• عند وصول طلب POST، يتم استخراج حقول JSON body وتمريرها للعقد اللاحقة\n• اقرنه بعقدة HTTP Response لإرجاع النتائج للمتصل\n\nهذا يتيح لك دمج سير العمل في تطبيقات خارجية أو سكربتات عبر إرسال طلب HTTP." + }, + "group": { + "title": "عقدة المجموعة — تنظيم سير العمل الفرعي", + "desc": "اجمع عدة عقد في حاوية واحدة قابلة للطي:\n\n• اسحب العقد إلى مجموعة لتغليف سير عمل فرعي\n• اكشف مدخلات/مخرجات محددة على سطح المجموعة ليتصل بها العقد الخارجية\n• انقر \"تعديل الرسم الفرعي\" للدخول وتعديل العقد الداخلية\n• استورد سير عمل موجود إلى مجموعة لإعادة استخدامه\n\nالمجموعات تحافظ على نظافة سير العمل المعقد — فكر بها كدوال يمكن ربطها معاً." + }, "canvas": { "title": "التفاعل مع اللوحة", "desc": "اللوحة هي مساحة عملك:\n• اسحب العقد لوضعها\n• اسحب من منافذ الإخراج إلى منافذ الإدخال لإنشاء اتصالات\n• انقر على عقدة لتحديدها — تتوسع المعلمات داخل العقدة للتعديل\n• انقر بزر الفأرة الأيمن لفتح القائمة السياقية (نسخ، لصق، حذف)\n• مرّر للتكبير، واسحب الخلفية لتحريك العرض\n• الاختصارات: Ctrl+Z للتراجع، Ctrl+C/V للنسخ واللصق، Delete للحذف" diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index b6288e29..8f9f330a 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -1203,6 +1203,14 @@ "control": "Steuerung" }, "nodeDefs": { + "trigger/directory": { + "label": "Verzeichnis-Trigger", + "hint": "Lokalen Ordner scannen — Workflow wird einmal pro Datei ausgeführt" + }, + "trigger/http": { + "label": "HTTP-Trigger", + "hint": "Workflow als HTTP-API bereitstellen — Eingabefelder definieren" + }, "ai-task/run": { "label": "WaveSpeed API", "hint": "Beliebiges KI-Modell ausführen — Bild, Video, Audio und mehr", @@ -1289,6 +1297,10 @@ } } }, + "output/http-response": { + "label": "HTTP-Antwort", + "hint": "Definieren Sie, was dieser Workflow an HTTP-Aufrufer zurückgibt" + }, "free-tool/image-enhancer": { "label": "Bildverbesserung", "hint": "Bilder kostenlos hochskalieren und schärfen (2×–4×)" @@ -1359,8 +1371,8 @@ "hint": "Ein Element aus einem Array per Index auswählen" }, "control/iterator": { - "label": "Iterator", - "hint": "Eingaben durchlaufen — Kindknoten N-mal oder einmal pro Array-Element ausführen" + "label": "Gruppe", + "hint": "Knoten in einem Sub-Workflow gruppieren" } }, "modelSelector": { @@ -1445,6 +1457,18 @@ "title": "Concat-Knoten — Mehrere Ausgaben zusammenführen", "desc": "Müssen Sie mehrere Bilder in einen \"images\"-Eingang übergeben? Verwenden Sie den Concat-Knoten:\n\n1. Verbinden Sie die image-Ausgabe jedes vorgelagerten Knotens mit Concat's value1, value2, value3 usw.\n2. Verbinden Sie Concat's output (jetzt ein Array) mit dem images-Parameter des nachgelagerten Knotens.\n\nBeispiel:\n[Upload A] → image → Concat → output (Array) → [AI-Aufgabe].images\n[Upload B] → image ↗\n\nDies funktioniert für jeden Ausgabetyp — Bilder, Videos, Text — wann immer Sie mehrere Einzelwerte zu einem Array-Eingang kombinieren müssen." }, + "directoryTrigger": { + "title": "Verzeichnis-Trigger — Lokale Dateien stapelweise verarbeiten", + "desc": "Automatisch einen lokalen Ordner scannen und den Workflow einmal pro Datei ausführen:\n\n• Wählen Sie ein Verzeichnis und einen Dateityp (Bilder, Videos, Audio oder Alle)\n• Der Trigger findet alle passenden Dateien und leitet sie einzeln in die Pipeline\n• Ideal für Stapelverarbeitung — z.B. jedes Foto in einem Ordner verbessern, alle Videos konvertieren usw.\n\nJede Ausführung erhält eine einzelne Datei, nachgelagerte Knoten funktionieren genau wie bei manuellem Upload." + }, + "httpTrigger": { + "title": "HTTP-Trigger — Workflows per API ausführen", + "desc": "Verwandeln Sie jeden Workflow in einen HTTP-API-Endpunkt:\n\n• Definieren Sie Ausgabefelder (z.B. image, prompt) — jedes wird zu einem Ausgangsport auf der Leinwand\n• Bei eingehenden POST-Anfragen werden die JSON-Body-Felder extrahiert und an nachgelagerte Knoten weitergeleitet\n• Kombinieren Sie mit dem HTTP-Response-Knoten, um Ergebnisse zurückzugeben\n\nSo können Sie Workflows in externe Apps, Skripte oder Automatisierungstools integrieren." + }, + "group": { + "title": "Gruppen-Knoten — Sub-Workflows organisieren", + "desc": "Fassen Sie mehrere Knoten in einem zusammenklappbaren Container zusammen:\n\n• Ziehen Sie Knoten in eine Gruppe, um einen Sub-Workflow zu kapseln\n• Legen Sie ausgewählte Ein-/Ausgänge auf der Gruppenoberfläche frei, damit externe Knoten sich verbinden können\n• Klicken Sie auf \"Subgraph bearbeiten\", um die internen Knoten zu bearbeiten\n• Importieren Sie einen bestehenden Workflow in eine Gruppe zur Wiederverwendung\n\nGruppen halten komplexe Workflows übersichtlich und modular — denken Sie an sie wie Funktionen, die Sie miteinander verbinden können." + }, "canvas": { "title": "Leinwand-Interaktionen", "desc": "Die Leinwand ist Ihr Arbeitsbereich:\n• Ziehen Sie Knoten, um sie zu positionieren\n• Ziehen Sie von Ausgangsports zu Eingangsports, um Verbindungen zu erstellen\n• Klicken Sie auf einen Knoten, um ihn auszuwählen — Parameter werden im Knoten zur Bearbeitung erweitert\n• Rechtsklick für Kontextmenü (Kopieren, Einfügen, Löschen)\n• Scrollen zum Zoomen, Hintergrund ziehen zum Schwenken\n• Tastenkürzel: Ctrl+Z Rückgängig, Ctrl+C/V Kopieren/Einfügen, Delete zum Entfernen" diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index d8172fdc..2e00e303 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1137,7 +1137,7 @@ "runTarget": "Run target", "runTargetAll": "Run All Nodes", "runTargetSelected": "Run Selected Node", - "runCount": "Run count", + "runCount": "Run Count", "stop": "Stop", "running": "Running...", "cancelAll": "Cancel All", @@ -1278,7 +1278,7 @@ }, "nodeDefs": { "trigger/directory": { - "label": "Directory", + "label": "Directory Trigger", "hint": "Scan a local folder — workflow runs once per file" }, "trigger/http": { @@ -1535,6 +1535,18 @@ "title": "Concat Node — Merge Multiple Outputs", "desc": "Need to pass multiple images into an \"images\" input? Use the Concat node:\n\n1. Connect each upstream node's image output to Concat's value1, value2, value3, etc.\n2. Connect Concat's output (now an array) to the downstream node's images parameter.\n\nExample:\n[Upload A] → image → Concat → output (array) → [AI Task].images\n[Upload B] → image ↗\n\nThis works for any type of output — images, videos, text — whenever you need to combine multiple single values into one array input." }, + "directoryTrigger": { + "title": "Directory Trigger — Batch Process Local Files", + "desc": "Automatically scan a local folder and run the workflow once per file:\n\n• Pick a directory and file type (Images, Videos, Audio, or All)\n• The trigger finds all matching files and feeds them one-by-one into the downstream pipeline\n• Great for batch processing — e.g. enhance every photo in a folder, convert all videos, etc.\n\nEach execution receives a single file, so downstream nodes work exactly the same as with a manual upload." + }, + "httpTrigger": { + "title": "HTTP Trigger — Run Workflows via API", + "desc": "Turn any workflow into an HTTP API endpoint:\n\n• Define output fields (e.g. image, prompt) — each becomes an output port on the canvas\n• When a POST request arrives, the JSON body fields are extracted and passed to downstream nodes\n• Pair with the HTTP Response node to return results back to the caller\n\nThis lets you integrate workflows into external apps, scripts, or automation tools by simply sending an HTTP request." + }, + "group": { + "title": "Group Node — Organize Sub-Workflows", + "desc": "Group multiple nodes into a single collapsible container:\n\n• Drag nodes into a Group to encapsulate a sub-workflow\n• Expose selected inputs/outputs on the Group's surface so external nodes can connect to them\n• Click \"Edit Subgraph\" to enter the Group and edit its internal nodes\n• Import an existing workflow into a Group to reuse it as a building block\n\nGroups keep complex workflows clean and modular — think of them as functions you can wire together." + }, "canvas": { "title": "Canvas Interactions", "desc": "The canvas is your workspace:\n• Drag nodes to position them\n• Drag from output ports to input ports to create connections\n• Click a node to select it — parameters expand inside the node for editing\n• Right-click for context menu (copy, paste, delete)\n• Scroll to zoom, drag background to pan\n• Shortcuts: Ctrl+Z undo, Ctrl+C/V copy-paste, Delete to remove" diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 045d93dd..02937085 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -1202,6 +1202,14 @@ "control": "Control" }, "nodeDefs": { + "trigger/directory": { + "label": "Disparador de directorio", + "hint": "Escanear carpeta local — el flujo se ejecuta una vez por archivo" + }, + "trigger/http": { + "label": "Disparador HTTP", + "hint": "Exponer este flujo como HTTP API — definir campos de entrada" + }, "ai-task/run": { "label": "WaveSpeed API", "hint": "Ejecuta cualquier modelo de IA — imagen, vídeo, audio y más", @@ -1288,6 +1296,10 @@ } } }, + "output/http-response": { + "label": "Respuesta HTTP", + "hint": "Define lo que este flujo devuelve a los llamadores HTTP" + }, "free-tool/image-enhancer": { "label": "Mejorador de imagen", "hint": "Ampliar y mejorar imágenes (2×–4×) gratis" @@ -1358,8 +1370,8 @@ "hint": "Elegir un elemento de un array por índice" }, "control/iterator": { - "label": "Iterador", - "hint": "Iterar sobre entradas — ejecutar nodos hijos N veces o una vez por elemento del array" + "label": "Grupo", + "hint": "Agrupar nodos en un sub-flujo para organización" } }, "modelSelector": { @@ -1474,6 +1486,18 @@ "title": "Nodo Concat — Combinar múltiples salidas", "desc": "¿Necesitas pasar múltiples imágenes a una entrada \"images\"? Usa el nodo Concat:\n\n1. Conecta la salida image de cada nodo anterior a value1, value2, value3, etc. de Concat\n2. Conecta la salida output de Concat (ahora un array) al parámetro images del nodo siguiente\n\nEjemplo:\n[Upload A] → image → Concat → output (array) → [Tarea AI].images\n[Upload B] → image ↗\n\nFunciona para cualquier tipo de salida — imágenes, videos, texto — siempre que necesites combinar múltiples valores individuales en una entrada de array." }, + "directoryTrigger": { + "title": "Disparador de directorio — Procesamiento por lotes de archivos locales", + "desc": "Escanea automáticamente una carpeta local y ejecuta el flujo una vez por archivo:\n\n• Elige un directorio y tipo de archivo (Imágenes, Videos, Audio o Todos)\n• El disparador encuentra todos los archivos coincidentes y los envía uno a uno al pipeline\n• Ideal para procesamiento por lotes — p.ej. mejorar cada foto de una carpeta, convertir todos los videos, etc.\n\nCada ejecución recibe un solo archivo, los nodos posteriores funcionan igual que con una subida manual." + }, + "httpTrigger": { + "title": "Disparador HTTP — Ejecutar flujos vía API", + "desc": "Convierte cualquier flujo en un endpoint HTTP API:\n\n• Define campos de salida (p.ej. image, prompt) — cada uno se convierte en un puerto de salida en el lienzo\n• Cuando llega una solicitud POST, los campos del JSON body se extraen y pasan a los nodos posteriores\n• Combínalo con el nodo HTTP Response para devolver resultados al llamante\n\nEsto te permite integrar flujos en apps externas, scripts o herramientas de automatización enviando una solicitud HTTP." + }, + "group": { + "title": "Nodo Grupo — Organizar sub-flujos", + "desc": "Agrupa múltiples nodos en un contenedor plegable:\n\n• Arrastra nodos a un Grupo para encapsular un sub-flujo\n• Expón entradas/salidas seleccionadas en la superficie del Grupo para que nodos externos se conecten\n• Haz clic en \"Editar subgrafo\" para entrar y editar los nodos internos\n• Importa un flujo existente a un Grupo para reutilizarlo como bloque\n\nLos Grupos mantienen los flujos complejos limpios y modulares — piensa en ellos como funciones que puedes conectar entre sí." + }, "canvas": { "title": "Interacciones del lienzo", "desc": "El lienzo es tu espacio de trabajo:\n• Arrastra nodos para colocarlos\n• Arrastra desde puertos de salida a puertos de entrada para crear conexiones\n• Haz clic en un nodo para seleccionarlo — los parámetros se expanden dentro del nodo para editar\n• Clic derecho para menú contextual (copiar, pegar, eliminar)\n• Desplaza para hacer zoom y arrastra el fondo para mover la vista\n• Atajos: Ctrl+Z deshacer, Ctrl+C/V copiar-pegar, Delete para eliminar" diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index acdcdc3a..8d792aaf 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -1203,6 +1203,14 @@ "control": "Contrôle" }, "nodeDefs": { + "trigger/directory": { + "label": "Déclencheur de répertoire", + "hint": "Scanner un dossier local — le workflow s'exécute une fois par fichier" + }, + "trigger/http": { + "label": "Déclencheur HTTP", + "hint": "Exposer ce workflow comme API HTTP — définir les champs d'entrée" + }, "ai-task/run": { "label": "WaveSpeed API", "hint": "Exécuter n'importe quel modèle IA — image, vidéo, audio et plus", @@ -1289,6 +1297,10 @@ } } }, + "output/http-response": { + "label": "Réponse HTTP", + "hint": "Définissez ce que ce workflow renvoie aux appelants HTTP" + }, "free-tool/image-enhancer": { "label": "Amélioration d'image", "hint": "Agrandir et affiner les images (2×–4×) gratuitement" @@ -1359,8 +1371,8 @@ "hint": "Choisir un élément d'un tableau par index" }, "control/iterator": { - "label": "Itérateur", - "hint": "Boucler sur les entrées — exécuter les nœuds enfants N fois ou une fois par élément du tableau" + "label": "Groupe", + "hint": "Regrouper des nœuds dans un sous-workflow pour l'organisation" } }, "modelSelector": { @@ -1445,6 +1457,18 @@ "title": "Nœud Concat — Fusionner plusieurs sorties", "desc": "Besoin de passer plusieurs images dans une entrée \"images\" ? Utilisez le nœud Concat :\n\n1. Connectez la sortie image de chaque nœud en amont aux entrées value1, value2, value3, etc. de Concat\n2. Connectez la sortie output de Concat (maintenant un tableau) au paramètre images du nœud en aval\n\nExemple :\n[Upload A] → image → Concat → output (tableau) → [Tâche AI].images\n[Upload B] → image ↗\n\nCela fonctionne pour tout type de sortie — images, vidéos, texte — chaque fois que vous devez combiner plusieurs valeurs en une seule entrée tableau." }, + "directoryTrigger": { + "title": "Déclencheur de répertoire — Traitement par lots de fichiers locaux", + "desc": "Scannez automatiquement un dossier local et exécutez le workflow une fois par fichier :\n\n• Choisissez un répertoire et un type de fichier (Images, Vidéos, Audio ou Tous)\n• Le déclencheur trouve tous les fichiers correspondants et les envoie un par un dans le pipeline\n• Idéal pour le traitement par lots — ex. améliorer chaque photo d'un dossier, convertir toutes les vidéos, etc.\n\nChaque exécution reçoit un seul fichier, les nœuds en aval fonctionnent exactement comme avec un upload manuel." + }, + "httpTrigger": { + "title": "Déclencheur HTTP — Exécuter des workflows via API", + "desc": "Transformez n'importe quel workflow en endpoint HTTP API :\n\n• Définissez des champs de sortie (ex. image, prompt) — chacun devient un port de sortie sur le canevas\n• Lorsqu'une requête POST arrive, les champs du JSON body sont extraits et transmis aux nœuds en aval\n• Associez-le au nœud HTTP Response pour renvoyer les résultats à l'appelant\n\nCela vous permet d'intégrer des workflows dans des apps externes, scripts ou outils d'automatisation en envoyant une requête HTTP." + }, + "group": { + "title": "Nœud Groupe — Organiser les sous-workflows", + "desc": "Regroupez plusieurs nœuds dans un conteneur repliable :\n\n• Glissez des nœuds dans un Groupe pour encapsuler un sous-workflow\n• Exposez des entrées/sorties sélectionnées sur la surface du Groupe pour que les nœuds externes puissent s'y connecter\n• Cliquez sur « Éditer le sous-graphe » pour entrer et modifier les nœuds internes\n• Importez un workflow existant dans un Groupe pour le réutiliser comme bloc\n\nLes Groupes gardent les workflows complexes propres et modulaires — pensez-y comme des fonctions que vous pouvez connecter entre elles." + }, "canvas": { "title": "Interactions du canevas", "desc": "Le canevas est votre espace de travail :\n• Glissez les nœuds pour les positionner\n• Glissez des ports de sortie vers les ports d'entrée pour créer des connexions\n• Cliquez sur un nœud pour le sélectionner — les paramètres s'étendent dans le nœud pour l'édition\n• Clic droit pour le menu contextuel (copier, coller, supprimer)\n• Défilez pour zoomer, glissez l'arrière-plan pour naviguer\n• Raccourcis : Ctrl+Z annuler, Ctrl+C/V copier-coller, Delete pour supprimer" diff --git a/src/i18n/locales/hi.json b/src/i18n/locales/hi.json index 3b02a657..aa273bc9 100644 --- a/src/i18n/locales/hi.json +++ b/src/i18n/locales/hi.json @@ -1202,6 +1202,14 @@ "control": "नियंत्रण" }, "nodeDefs": { + "trigger/directory": { + "label": "डायरेक्टरी ट्रिगर", + "hint": "स्थानीय फ़ोल्डर स्कैन करें — प्रत्येक फ़ाइल के लिए वर्कफ़्लो एक बार चलता है" + }, + "trigger/http": { + "label": "HTTP ट्रिगर", + "hint": "इस वर्कफ़्लो को HTTP API के रूप में प्रकाशित करें — इनपुट फ़ील्ड परिभाषित करें" + }, "ai-task/run": { "label": "WaveSpeed API", "hint": "कोई भी AI मॉडल चलाएं — छवि, वीडियो, ऑडियो और अधिक", @@ -1288,6 +1296,10 @@ } } }, + "output/http-response": { + "label": "HTTP प्रतिक्रिया", + "hint": "परिभाषित करें कि यह वर्कफ़्लो HTTP कॉलर्स को क्या लौटाता है" + }, "free-tool/image-enhancer": { "label": "छवि एन्हांसर", "hint": "छवियों को मुफ्त में अपस्केल और शार्प करें (2×–4×)" @@ -1358,8 +1370,8 @@ "hint": "इंडेक्स द्वारा ऐरे से एक आइटम चुनें" }, "control/iterator": { - "label": "इटरेटर", - "hint": "इनपुट पर लूप — चाइल्ड नोड्स को N बार या प्रत्येक ऐरे आइटम के लिए एक बार चलाएं" + "label": "ग्रुप", + "hint": "नोड्स को सब-वर्कफ़्लो में समूहित करें" } }, "modelSelector": { @@ -1474,6 +1486,18 @@ "title": "Concat नोड — कई आउटपुट मर्ज करें", "desc": "क्या आपको कई इमेज को \"images\" इनपुट में पास करना है? Concat नोड का उपयोग करें:\n\n1. प्रत्येक अपस्ट्रीम नोड के image आउटपुट को Concat के value1, value2, value3 आदि से कनेक्ट करें\n2. Concat के output (अब एक ऐरे) को डाउनस्ट्रीम नोड के images पैरामीटर से कनेक्ट करें\n\nउदाहरण:\n[अपलोड A] → image → Concat → output (ऐरे) → [AI टास्क].images\n[अपलोड B] → image ↗\n\nयह किसी भी प्रकार के आउटपुट के लिए काम करता है — इमेज, वीडियो, टेक्स्ट — जब भी आपको कई सिंगल वैल्यू को एक ऐरे इनपुट में जोड़ना हो।" }, + "directoryTrigger": { + "title": "डायरेक्टरी ट्रिगर — स्थानीय फ़ाइलों का बैच प्रोसेसिंग", + "desc": "स्वचालित रूप से एक स्थानीय फ़ोल्डर स्कैन करें और प्रत्येक फ़ाइल के लिए वर्कफ़्लो एक बार चलाएं:\n\n• एक डायरेक्टरी और फ़ाइल प्रकार चुनें (इमेज, वीडियो, ऑडियो, या सभी)\n• ट्रिगर सभी मिलान फ़ाइलें ढूंढता है और उन्हें एक-एक करके पाइपलाइन में भेजता है\n• बैच प्रोसेसिंग के लिए बढ़िया — जैसे फ़ोल्डर की हर फ़ोटो को बेहतर बनाना, सभी वीडियो कन्वर्ट करना आदि\n\nप्रत्येक एक्ज़ीक्यूशन एक फ़ाइल प्राप्त करता है, डाउनस्ट्रीम नोड्स मैनुअल अपलोड की तरह ही काम करते हैं।" + }, + "httpTrigger": { + "title": "HTTP ट्रिगर — API के ज़रिए वर्कफ़्लो चलाएं", + "desc": "किसी भी वर्कफ़्लो को HTTP API एंडपॉइंट में बदलें:\n\n• आउटपुट फ़ील्ड परिभाषित करें (जैसे image, prompt) — प्रत्येक कैनवास पर एक आउटपुट पोर्ट बन जाता है\n• जब POST रिक्वेस्ट आती है, JSON body फ़ील्ड निकाले जाते हैं और डाउनस्ट्रीम नोड्स को भेजे जाते हैं\n• HTTP Response नोड के साथ जोड़कर कॉलर को रिज़ल्ट लौटाएं\n\nइससे आप HTTP रिक्वेस्ट भेजकर वर्कफ़्लो को बाहरी ऐप्स, स्क्रिप्ट या ऑटोमेशन टूल्स में इंटीग्रेट कर सकते हैं।" + }, + "group": { + "title": "ग्रुप नोड — सब-वर्कफ़्लो व्यवस्थित करें", + "desc": "कई नोड्स को एक फ़ोल्ड करने योग्य कंटेनर में समूहित करें:\n\n• नोड्स को ग्रुप में ड्रैग करके सब-वर्कफ़्लो एनकैप्सुलेट करें\n• ग्रुप की सतह पर चुनिंदा इनपुट/आउटपुट एक्सपोज़ करें ताकि बाहरी नोड्स कनेक्ट हो सकें\n• \"सबग्राफ़ एडिट करें\" पर क्लिक करके अंदर जाएं और आंतरिक नोड्स एडिट करें\n• किसी मौजूदा वर्कफ़्लो को ग्रुप में इम्पोर्ट करके बिल्डिंग ब्लॉक के रूप में पुन: उपयोग करें\n\nग्रुप जटिल वर्कफ़्लो को साफ़ और मॉड्यूलर रखते हैं — इन्हें ऐसे फ़ंक्शन समझें जिन्हें आप आपस में जोड़ सकते हैं।" + }, "canvas": { "title": "कैनवास इंटरैक्शन", "desc": "कैनवास आपका कार्यक्षेत्र है:\n• नोड्स को ड्रैग करके स्थान दें\n• कनेक्शन बनाने के लिए आउटपुट पोर्ट से इनपुट पोर्ट तक ड्रैग करें\n• नोड पर क्लिक करके चुनें — पैरामीटर नोड के अंदर विस्तारित होकर एडिट के लिए दिखते हैं\n• कॉन्टेक्स्ट मेनू (कॉपी, पेस्ट, डिलीट) के लिए राइट-क्लिक करें\n• ज़ूम के लिए स्क्रॉल करें, पैन करने के लिए बैकग्राउंड ड्रैग करें\n• शॉर्टकट: Ctrl+Z undo, Ctrl+C/V copy-paste, Delete हटाने के लिए" diff --git a/src/i18n/locales/id.json b/src/i18n/locales/id.json index 98a92ffd..3360535c 100644 --- a/src/i18n/locales/id.json +++ b/src/i18n/locales/id.json @@ -1202,6 +1202,14 @@ "control": "Kontrol" }, "nodeDefs": { + "trigger/directory": { + "label": "Pemicu Direktori", + "hint": "Pindai folder lokal — workflow berjalan sekali per file" + }, + "trigger/http": { + "label": "Pemicu HTTP", + "hint": "Ekspos workflow ini sebagai HTTP API — tentukan field input" + }, "ai-task/run": { "label": "WaveSpeed API", "hint": "Jalankan model AI apa pun — gambar, video, audio, dan lainnya", @@ -1288,6 +1296,10 @@ } } }, + "output/http-response": { + "label": "Respons HTTP", + "hint": "Tentukan apa yang dikembalikan workflow ini kepada pemanggil HTTP" + }, "free-tool/image-enhancer": { "label": "Peningkat gambar", "hint": "Perbesar dan pertajam gambar (2×–4×) secara gratis" @@ -1358,8 +1370,8 @@ "hint": "Pilih satu item dari array berdasarkan indeks" }, "control/iterator": { - "label": "Iterator", - "hint": "Ulangi input — jalankan node anak N kali atau sekali per item array" + "label": "Grup", + "hint": "Kelompokkan node ke dalam sub-workflow untuk organisasi" } }, "modelSelector": { @@ -1474,6 +1486,18 @@ "title": "Node Concat — Gabungkan Beberapa Output", "desc": "Perlu memasukkan beberapa gambar ke input \"images\"? Gunakan node Concat:\n\n1. Hubungkan output image dari setiap node hulu ke value1, value2, value3, dll. milik Concat\n2. Hubungkan output Concat (sekarang berupa array) ke parameter images node hilir\n\nContoh:\n[Upload A] → image → Concat → output (array) → [AI Task].images\n[Upload B] → image ↗\n\nIni berlaku untuk semua jenis output — gambar, video, teks — kapan pun Anda perlu menggabungkan beberapa nilai tunggal menjadi satu input array." }, + "directoryTrigger": { + "title": "Pemicu Direktori — Pemrosesan Batch File Lokal", + "desc": "Pindai folder lokal secara otomatis dan jalankan workflow sekali per file:\n\n• Pilih direktori dan jenis file (Gambar, Video, Audio, atau Semua)\n• Pemicu menemukan semua file yang cocok dan mengirimnya satu per satu ke pipeline\n• Cocok untuk pemrosesan batch — misal meningkatkan setiap foto di folder, mengonversi semua video, dll.\n\nSetiap eksekusi menerima satu file, node hilir bekerja persis seperti upload manual." + }, + "httpTrigger": { + "title": "Pemicu HTTP — Jalankan Workflow via API", + "desc": "Ubah workflow apa pun menjadi endpoint HTTP API:\n\n• Tentukan field output (misal image, prompt) — masing-masing menjadi port output di kanvas\n• Saat permintaan POST masuk, field JSON body diekstrak dan diteruskan ke node hilir\n• Pasangkan dengan node HTTP Response untuk mengembalikan hasil ke pemanggil\n\nIni memungkinkan Anda mengintegrasikan workflow ke aplikasi eksternal, skrip, atau alat otomasi dengan mengirim permintaan HTTP." + }, + "group": { + "title": "Node Grup — Atur Sub-Workflow", + "desc": "Kelompokkan beberapa node ke dalam kontainer yang dapat dilipat:\n\n• Seret node ke dalam Grup untuk mengenkapsulasi sub-workflow\n• Ekspos input/output terpilih di permukaan Grup agar node eksternal dapat terhubung\n• Klik \"Edit Subgraph\" untuk masuk dan mengedit node internal\n• Impor workflow yang ada ke dalam Grup untuk digunakan kembali sebagai blok bangunan\n\nGrup menjaga workflow kompleks tetap rapi dan modular — anggap saja seperti fungsi yang bisa Anda hubungkan satu sama lain." + }, "canvas": { "title": "Interaksi Kanvas", "desc": "Kanvas adalah ruang kerja Anda:\n• Seret node untuk memposisikannya\n• Seret dari port output ke port input untuk membuat koneksi\n• Klik node untuk memilihnya — parameter meluas di dalam node untuk diedit\n• Klik kanan untuk menu konteks (salin, tempel, hapus)\n• Gulir untuk zoom, seret latar untuk pan\n• Pintasan: Ctrl+Z undo, Ctrl+C/V copy-paste, Delete untuk menghapus" diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index 13c081d2..32cc4636 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -1202,6 +1202,14 @@ "control": "Controllo" }, "nodeDefs": { + "trigger/directory": { + "label": "Trigger Directory", + "hint": "Scansiona una cartella locale — il workflow viene eseguito una volta per file" + }, + "trigger/http": { + "label": "Trigger HTTP", + "hint": "Esponi questo workflow come API HTTP — definisci i campi di input" + }, "ai-task/run": { "label": "WaveSpeed API", "hint": "Esegui qualsiasi modello IA — immagini, video, audio e altro", @@ -1288,6 +1296,10 @@ } } }, + "output/http-response": { + "label": "Risposta HTTP", + "hint": "Definisci cosa restituisce questo workflow ai chiamanti HTTP" + }, "free-tool/image-enhancer": { "label": "Migliora immagine", "hint": "Ingrandisci e migliora le immagini (2×–4×) gratis" @@ -1358,8 +1370,8 @@ "hint": "Scegli un elemento da un array per indice" }, "control/iterator": { - "label": "Iteratore", - "hint": "Ciclo sugli input — esegui i nodi figli N volte o una volta per elemento dell'array" + "label": "Gruppo", + "hint": "Raggruppa i nodi in un sub-workflow per organizzazione" } }, "modelSelector": { @@ -1474,6 +1486,18 @@ "title": "Nodo Concat — Unisci più output", "desc": "Devi passare più immagini a un input \"images\"? Usa il nodo Concat:\n\n1. Collega l'output image di ogni nodo a monte a value1, value2, value3, ecc. di Concat\n2. Collega l'output di Concat (ora un array) al parametro images del nodo a valle\n\nEsempio:\n[Upload A] → image → Concat → output (array) → [Task AI].images\n[Upload B] → image ↗\n\nFunziona per qualsiasi tipo di output — immagini, video, testo — ogni volta che devi combinare più valori singoli in un unico input array." }, + "directoryTrigger": { + "title": "Trigger Directory — Elaborazione batch di file locali", + "desc": "Scansiona automaticamente una cartella locale ed esegui il workflow una volta per file:\n\n• Scegli una directory e un tipo di file (Immagini, Video, Audio o Tutti)\n• Il trigger trova tutti i file corrispondenti e li invia uno alla volta nella pipeline\n• Ideale per l'elaborazione batch — es. migliorare ogni foto in una cartella, convertire tutti i video, ecc.\n\nOgni esecuzione riceve un singolo file, i nodi a valle funzionano esattamente come con un upload manuale." + }, + "httpTrigger": { + "title": "Trigger HTTP — Esegui workflow via API", + "desc": "Trasforma qualsiasi workflow in un endpoint HTTP API:\n\n• Definisci campi di output (es. image, prompt) — ognuno diventa una porta di output sul canvas\n• Quando arriva una richiesta POST, i campi del JSON body vengono estratti e passati ai nodi a valle\n• Abbinalo al nodo HTTP Response per restituire i risultati al chiamante\n\nQuesto ti permette di integrare i workflow in app esterne, script o strumenti di automazione inviando una richiesta HTTP." + }, + "group": { + "title": "Nodo Gruppo — Organizza sub-workflow", + "desc": "Raggruppa più nodi in un contenitore comprimibile:\n\n• Trascina i nodi in un Gruppo per incapsulare un sub-workflow\n• Esponi input/output selezionati sulla superficie del Gruppo per consentire ai nodi esterni di connettersi\n• Clicca \"Modifica sottografo\" per entrare e modificare i nodi interni\n• Importa un workflow esistente in un Gruppo per riutilizzarlo come blocco\n\nI Gruppi mantengono i workflow complessi puliti e modulari — pensali come funzioni che puoi collegare tra loro." + }, "canvas": { "title": "Interazioni della canvas", "desc": "La canvas e il tuo spazio di lavoro:\n• Trascina i nodi per posizionarli\n• Trascina dalle porte di output alle porte di input per creare connessioni\n• Fai clic su un nodo per selezionarlo — i parametri si espandono nel nodo per la modifica\n• Clic destro per menu contestuale (copia, incolla, elimina)\n• Scorri per zoom, trascina lo sfondo per pan\n• Scorciatoie: Ctrl+Z annulla, Ctrl+C/V copia-incolla, Delete per rimuovere" diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index affcd069..8a07d4fa 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1163,6 +1163,14 @@ "control": "制御" }, "nodeDefs": { + "trigger/directory": { + "label": "ディレクトリトリガー", + "hint": "ローカルフォルダをスキャン — ファイルごとにワークフローを1回実行" + }, + "trigger/http": { + "label": "HTTPトリガー", + "hint": "ワークフローをHTTP APIとして公開 — 入力フィールドを定義" + }, "ai-task/run": { "label": "WaveSpeed API", "hint": "画像・動画・音声など、あらゆるAIモデルを実行", @@ -1249,6 +1257,10 @@ } } }, + "output/http-response": { + "label": "HTTPレスポンス", + "hint": "このワークフローがHTTP呼び出し元に返す内容を定義" + }, "free-tool/image-enhancer": { "label": "画像強調", "hint": "画像を無料で高解像度化・シャープ化(2×〜4×)" @@ -1319,8 +1331,8 @@ "hint": "配列からインデックスで1つの項目を選択" }, "control/iterator": { - "label": "イテレーター", - "hint": "入力をループ — 子ノードをN回またはアレイ項目ごとに1回実行" + "label": "グループ", + "hint": "ノードをサブワークフローにグループ化して整理" } }, "modelSelector": { @@ -1405,6 +1417,18 @@ "title": "Concat ノード — 複数の出力を結合", "desc": "複数の画像を「images」入力に渡したい場合は、Concat ノードを使用します:\n\n1. 各上流ノードの image 出力を Concat の value1、value2、value3 などに接続\n2. Concat の output(配列)を下流ノードの images パラメータに接続\n\n例:\n[アップロード A] → image → Concat → output(配列)→ [AI タスク].images\n[アップロード B] → image ↗\n\n画像、動画、テキストなど、複数の単一値を1つの配列入力にまとめたい場合に使えます。" }, + "directoryTrigger": { + "title": "ディレクトリトリガー — ローカルファイルの一括処理", + "desc": "ローカルフォルダを自動スキャンし、ファイルごとにワークフローを1回実行します:\n\n• ディレクトリとファイルタイプ(画像、動画、音声、またはすべて)を選択\n• トリガーが一致するすべてのファイルを見つけ、1つずつパイプラインに送ります\n• 一括処理に最適 — フォルダ内のすべての写真を強化、すべての動画を変換など\n\n各実行は1つのファイルを受け取り、下流ノードは手動アップロードと同じように動作します。" + }, + "httpTrigger": { + "title": "HTTP トリガー — API 経由でワークフローを実行", + "desc": "任意のワークフローを HTTP API エンドポイントに変換します:\n\n• 出力フィールドを定義(例:image、prompt)— 各フィールドがキャンバス上の出力ポートになります\n• POST リクエストが届くと、JSON body のフィールドが抽出され下流ノードに渡されます\n• HTTP Response ノードと組み合わせて呼び出し元に結果を返します\n\nHTTP リクエストを送信するだけで、ワークフローを外部アプリ、スクリプト、自動化ツールに統合できます。" + }, + "group": { + "title": "グループノード — サブワークフローの整理", + "desc": "複数のノードを折りたたみ可能なコンテナにまとめます:\n\n• ノードをグループにドラッグしてサブワークフローをカプセル化\n• グループの表面に選択した入出力を公開し、外部ノードが接続できるようにします\n• 「サブグラフを編集」をクリックして内部ノードを編集\n• 既存のワークフローをグループにインポートして再利用\n\nグループは複雑なワークフローを整理しモジュール化します — 互いに接続できる関数のようなものです。" + }, "canvas": { "title": "キャンバス操作", "desc": "キャンバスがワークスペースです:\n• ノードをドラッグして配置\n• 出力ポートから入力ポートにドラッグして接続を作成\n• ノードをクリックして選択 — パラメータがノード内に展開して編集可能に\n• 右クリックでコンテキストメニュー(コピー、貼り付け、削除)\n• スクロールでズーム、背景をドラッグでパン\n• ショートカット:Ctrl+Z 元に戻す、Ctrl+C/V コピー&ペースト、Delete で削除" diff --git a/src/i18n/locales/ko.json b/src/i18n/locales/ko.json index 03d3cbac..8364bdbe 100644 --- a/src/i18n/locales/ko.json +++ b/src/i18n/locales/ko.json @@ -1203,6 +1203,14 @@ "control": "제어" }, "nodeDefs": { + "trigger/directory": { + "label": "디렉토리 트리거", + "hint": "로컬 폴더 스캔 — 파일당 한 번 워크플로 실행" + }, + "trigger/http": { + "label": "HTTP 트리거", + "hint": "이 워크플로를 HTTP API로 노출 — 입력 필드 정의" + }, "ai-task/run": { "label": "WaveSpeed API", "hint": "이미지, 비디오, 오디오 등 모든 AI 모델을 실행", @@ -1289,6 +1297,10 @@ } } }, + "output/http-response": { + "label": "HTTP 응답", + "hint": "이 워크플로가 HTTP 호출자에게 반환하는 내용을 정의" + }, "free-tool/image-enhancer": { "label": "이미지 향상", "hint": "이미지를 무료로 업스케일 및 선명하게 (2×–4×)" @@ -1359,8 +1371,8 @@ "hint": "배열에서 인덱스로 항목 하나를 선택" }, "control/iterator": { - "label": "반복기", - "hint": "입력을 반복 — 자식 노드를 N번 또는 배열 항목당 한 번 실행" + "label": "그룹", + "hint": "노드를 서브 워크플로로 그룹화하여 정리" } }, "modelSelector": { @@ -1445,6 +1457,18 @@ "title": "Concat 노드 — 여러 출력 병합", "desc": "여러 이미지를 \"images\" 입력에 전달해야 하나요? Concat 노드를 사용하세요:\n\n1. 각 업스트림 노드의 image 출력을 Concat의 value1, value2, value3 등에 연결\n2. Concat의 output(배열)을 다운스트림 노드의 images 매개변수에 연결\n\n예시:\n[업로드 A] → image → Concat → output(배열) → [AI 작업].images\n[업로드 B] → image ↗\n\n이미지, 비디오, 텍스트 등 여러 단일 값을 하나의 배열 입력으로 결합해야 할 때 사용합니다." }, + "directoryTrigger": { + "title": "디렉토리 트리거 — 로컬 파일 일괄 처리", + "desc": "로컬 폴더를 자동으로 스캔하고 파일당 한 번씩 워크플로를 실행합니다:\n\n• 디렉토리와 파일 유형(이미지, 비디오, 오디오 또는 전체)을 선택\n• 트리거가 일치하는 모든 파일을 찾아 하나씩 파이프라인에 전달\n• 일괄 처리에 적합 — 폴더의 모든 사진 향상, 모든 비디오 변환 등\n\n각 실행은 단일 파일을 수신하며, 다운스트림 노드는 수동 업로드와 동일하게 작동합니다." + }, + "httpTrigger": { + "title": "HTTP 트리거 — API로 워크플로 실행", + "desc": "모든 워크플로를 HTTP API 엔드포인트로 변환합니다:\n\n• 출력 필드 정의(예: image, prompt) — 각각 캔버스의 출력 포트가 됩니다\n• POST 요청이 도착하면 JSON body 필드가 추출되어 다운스트림 노드로 전달됩니다\n• HTTP Response 노드와 결합하여 호출자에게 결과를 반환\n\nHTTP 요청을 보내 워크플로를 외부 앱, 스크립트 또는 자동화 도구에 통합할 수 있습니다." + }, + "group": { + "title": "그룹 노드 — 서브 워크플로 구성", + "desc": "여러 노드를 접을 수 있는 컨테이너로 그룹화합니다:\n\n• 노드를 그룹으로 드래그하여 서브 워크플로를 캡슐화\n• 그룹 표면에 선택한 입출력을 노출하여 외부 노드가 연결할 수 있도록 합니다\n• \"서브그래프 편집\"을 클릭하여 내부 노드를 편집\n• 기존 워크플로를 그룹에 가져와 빌딩 블록으로 재사용\n\n그룹은 복잡한 워크플로를 깔끔하고 모듈화된 상태로 유지합니다 — 서로 연결할 수 있는 함수라고 생각하세요." + }, "canvas": { "title": "캔버스 상호작용", "desc": "캔버스가 작업 공간입니다:\n• 노드를 드래그하여 배치\n• 출력 포트에서 입력 포트로 드래그하여 연결 생성\n• 노드를 클릭하여 선택 — 매개변수가 노드 내에서 확장되어 편집 가능\n• 우클릭으로 컨텍스트 메뉴 (복사, 붙여넣기, 삭제)\n• 스크롤로 확대/축소, 배경 드래그로 이동\n• 단축키: Ctrl+Z 실행 취소, Ctrl+C/V 복사/붙여넣기, Delete로 삭제" diff --git a/src/i18n/locales/ms.json b/src/i18n/locales/ms.json index d925dbeb..f42ad93c 100644 --- a/src/i18n/locales/ms.json +++ b/src/i18n/locales/ms.json @@ -1202,6 +1202,14 @@ "control": "Kawalan" }, "nodeDefs": { + "trigger/directory": { + "label": "Pencetus Direktori", + "hint": "Imbas folder tempatan — aliran kerja berjalan sekali bagi setiap fail" + }, + "trigger/http": { + "label": "Pencetus HTTP", + "hint": "Dedahkan aliran kerja ini sebagai HTTP API — tentukan medan input" + }, "ai-task/run": { "label": "WaveSpeed API", "hint": "Jalankan mana-mana model AI — imej, video, audio dan lain-lain", @@ -1288,6 +1296,10 @@ } } }, + "output/http-response": { + "label": "Respons HTTP", + "hint": "Tentukan apa yang dikembalikan aliran kerja ini kepada pemanggil HTTP" + }, "free-tool/image-enhancer": { "label": "Penambah imej", "hint": "Besarkan dan pertajam imej (2×–4×) secara percuma" @@ -1358,8 +1370,8 @@ "hint": "Pilih satu item dari tatasusunan mengikut indeks" }, "control/iterator": { - "label": "Iterator", - "hint": "Gelung melalui input — jalankan nod anak N kali atau sekali bagi setiap item tatasusunan" + "label": "Kumpulan", + "hint": "Kumpulkan nod ke dalam sub-aliran kerja untuk penyusunan" } }, "modelSelector": { @@ -1474,6 +1486,18 @@ "title": "Nod Concat — Gabungkan Pelbagai Output", "desc": "Perlu menghantar beberapa imej ke input \"images\"? Gunakan nod Concat:\n\n1. Sambungkan output image dari setiap nod huluan ke value1, value2, value3, dll. Concat\n2. Sambungkan output Concat (kini satu tatasusunan) ke parameter images nod hiliran\n\nContoh:\n[Muat Naik A] → image → Concat → output (tatasusunan) → [Tugas AI].images\n[Muat Naik B] → image ↗\n\nIni berfungsi untuk semua jenis output — imej, video, teks — bila-bila masa anda perlu menggabungkan beberapa nilai tunggal menjadi satu input tatasusunan." }, + "directoryTrigger": { + "title": "Pencetus Direktori — Pemprosesan Kelompok Fail Tempatan", + "desc": "Imbas folder tempatan secara automatik dan jalankan aliran kerja sekali bagi setiap fail:\n\n• Pilih direktori dan jenis fail (Imej, Video, Audio, atau Semua)\n• Pencetus mencari semua fail yang sepadan dan menghantarnya satu demi satu ke saluran paip\n• Sesuai untuk pemprosesan kelompok — cth. tingkatkan setiap foto dalam folder, tukar semua video, dll.\n\nSetiap pelaksanaan menerima satu fail, nod hiliran berfungsi sama seperti muat naik manual." + }, + "httpTrigger": { + "title": "Pencetus HTTP — Jalankan Aliran Kerja melalui API", + "desc": "Tukar mana-mana aliran kerja menjadi titik akhir HTTP API:\n\n• Tentukan medan output (cth. image, prompt) — setiap satu menjadi port output di kanvas\n• Apabila permintaan POST tiba, medan JSON body diekstrak dan dihantar ke nod hiliran\n• Pasangkan dengan nod HTTP Response untuk mengembalikan hasil kepada pemanggil\n\nIni membolehkan anda mengintegrasikan aliran kerja ke dalam aplikasi luaran, skrip, atau alat automasi dengan menghantar permintaan HTTP." + }, + "group": { + "title": "Nod Kumpulan — Susun Sub-Aliran Kerja", + "desc": "Kumpulkan beberapa nod ke dalam bekas yang boleh dilipat:\n\n• Seret nod ke dalam Kumpulan untuk mengkapsulkan sub-aliran kerja\n• Dedahkan input/output terpilih di permukaan Kumpulan supaya nod luaran boleh bersambung\n• Klik \"Edit Subgraph\" untuk masuk dan mengedit nod dalaman\n• Import aliran kerja sedia ada ke dalam Kumpulan untuk digunakan semula sebagai blok binaan\n\nKumpulan memastikan aliran kerja kompleks kekal kemas dan modular — anggap ia seperti fungsi yang boleh anda sambungkan antara satu sama lain." + }, "canvas": { "title": "Interaksi Kanvas", "desc": "Kanvas ialah ruang kerja anda:\n• Seret nod untuk meletakkannya\n• Seret dari port output ke port input untuk membuat sambungan\n• Klik nod untuk memilihnya — parameter berkembang dalam nod untuk penyuntingan\n• Klik kanan untuk menu konteks (salin, tampal, padam)\n• Skrol untuk zum, seret latar belakang untuk pan\n• Pintasan: Ctrl+Z undur, Ctrl+C/V salin-tampal, Delete untuk buang" diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 975aeb92..e7c19fb4 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -1202,6 +1202,14 @@ "control": "Controlo" }, "nodeDefs": { + "trigger/directory": { + "label": "Gatilho de Diretório", + "hint": "Escanear pasta local — workflow executa uma vez por arquivo" + }, + "trigger/http": { + "label": "Gatilho HTTP", + "hint": "Expor este workflow como HTTP API — definir campos de entrada" + }, "ai-task/run": { "label": "WaveSpeed API", "hint": "Executar qualquer modelo de IA — imagem, vídeo, áudio e mais", @@ -1288,6 +1296,10 @@ } } }, + "output/http-response": { + "label": "Resposta HTTP", + "hint": "Defina o que este fluxo de trabalho retorna aos chamadores HTTP" + }, "free-tool/image-enhancer": { "label": "Melhorador de imagem", "hint": "Ampliar e melhorar imagens (2×–4×) gratuitamente" @@ -1358,8 +1370,8 @@ "hint": "Escolher um item de um array por índice" }, "control/iterator": { - "label": "Iterador", - "hint": "Iterar sobre entradas — executar nós filhos N vezes ou uma vez por item do array" + "label": "Grupo", + "hint": "Agrupar nós em um sub-workflow para organização" } }, "modelSelector": { @@ -1474,6 +1486,18 @@ "title": "Nó Concat — Mesclar múltiplas saídas", "desc": "Precisa passar múltiplas imagens para uma entrada \"images\"? Use o nó Concat:\n\n1. Conecte a saída image de cada nó anterior ao value1, value2, value3, etc. do Concat\n2. Conecte a saída output do Concat (agora um array) ao parâmetro images do nó seguinte\n\nExemplo:\n[Upload A] → image → Concat → output (array) → [Tarefa AI].images\n[Upload B] → image ↗\n\nFunciona para qualquer tipo de saída — imagens, vídeos, texto — sempre que precisar combinar múltiplos valores individuais em uma entrada de array." }, + "directoryTrigger": { + "title": "Gatilho de Diretório — Processamento em lote de arquivos locais", + "desc": "Escaneie automaticamente uma pasta local e execute o workflow uma vez por arquivo:\n\n• Escolha um diretório e tipo de arquivo (Imagens, Vídeos, Áudio ou Todos)\n• O gatilho encontra todos os arquivos correspondentes e os envia um a um para o pipeline\n• Ótimo para processamento em lote — ex. melhorar cada foto de uma pasta, converter todos os vídeos, etc.\n\nCada execução recebe um único arquivo, os nós seguintes funcionam exatamente como com upload manual." + }, + "httpTrigger": { + "title": "Gatilho HTTP — Executar workflows via API", + "desc": "Transforme qualquer workflow em um endpoint HTTP API:\n\n• Defina campos de saída (ex. image, prompt) — cada um se torna uma porta de saída no canvas\n• Quando uma requisição POST chega, os campos do JSON body são extraídos e passados aos nós seguintes\n• Combine com o nó HTTP Response para retornar resultados ao chamador\n\nIsso permite integrar workflows em apps externos, scripts ou ferramentas de automação enviando uma requisição HTTP." + }, + "group": { + "title": "Nó Grupo — Organizar sub-workflows", + "desc": "Agrupe múltiplos nós em um contêiner recolhível:\n\n• Arraste nós para um Grupo para encapsular um sub-workflow\n• Exponha entradas/saídas selecionadas na superfície do Grupo para que nós externos possam se conectar\n• Clique em \"Editar subgrafo\" para entrar e editar os nós internos\n• Importe um workflow existente para um Grupo para reutilizá-lo como bloco\n\nGrupos mantêm workflows complexos limpos e modulares — pense neles como funções que você pode conectar entre si." + }, "canvas": { "title": "Interacoes do canvas", "desc": "O canvas e seu espaco de trabalho:\n• Arraste nos para posiciona-los\n• Arraste de portas de saida para portas de entrada para criar conexoes\n• Clique em um no para seleciona-lo — os parametros se expandem dentro do no para edicao\n• Clique com o botao direito para menu de contexto (copiar, colar, excluir)\n• Role para zoom e arraste o fundo para mover\n• Atalhos: Ctrl+Z desfazer, Ctrl+C/V copiar-colar, Delete para remover" diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 34c7e5a1..1336da3f 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -1202,6 +1202,14 @@ "control": "Управление" }, "nodeDefs": { + "trigger/directory": { + "label": "Триггер каталога", + "hint": "Сканировать локальную папку — рабочий процесс запускается один раз для каждого файла" + }, + "trigger/http": { + "label": "HTTP-триггер", + "hint": "Открыть рабочий процесс как HTTP API — определите поля ввода для вызывающих" + }, "ai-task/run": { "label": "WaveSpeed API", "hint": "Запуск любой ИИ-модели — изображения, видео, аудио и другое", @@ -1288,6 +1296,10 @@ } } }, + "output/http-response": { + "label": "HTTP-ответ", + "hint": "Определите, что рабочий процесс возвращает HTTP-вызывающим" + }, "free-tool/image-enhancer": { "label": "Улучшение изображения", "hint": "Увеличение и улучшение изображений (2×–4×) бесплатно" @@ -1358,8 +1370,8 @@ "hint": "Выбрать один элемент из массива по индексу" }, "control/iterator": { - "label": "Итератор", - "hint": "Цикл по входным данным — запуск дочерних узлов N раз или по одному на элемент массива" + "label": "Группа", + "hint": "Группировка узлов в подпроцесс для организации" } }, "modelSelector": { @@ -1474,6 +1486,18 @@ "title": "Узел Concat — Объединение нескольких выходов", "desc": "Нужно передать несколько изображений во вход \"images\"? Используйте узел Concat:\n\n1. Подключите выход image каждого предыдущего узла к value1, value2, value3 и т.д. узла Concat\n2. Подключите выход output Concat (теперь массив) к параметру images следующего узла\n\nПример:\n[Загрузка A] → image → Concat → output (массив) → [AI задача].images\n[Загрузка B] → image ↗\n\nРаботает для любого типа выхода — изображения, видео, текст — когда нужно объединить несколько отдельных значений в один массив." }, + "directoryTrigger": { + "title": "Триггер каталога — Пакетная обработка локальных файлов", + "desc": "Автоматическое сканирование локальной папки и запуск рабочего процесса один раз для каждого файла:\n\n• Выберите каталог и тип файлов (Изображения, Видео, Аудио или Все)\n• Триггер находит все подходящие файлы и передаёт их по одному в конвейер\n• Отлично подходит для пакетной обработки — например, улучшение каждого фото в папке, конвертация всех видео и т.д.\n\nКаждое выполнение получает один файл, последующие узлы работают так же, как при ручной загрузке." + }, + "httpTrigger": { + "title": "HTTP-триггер — Запуск рабочих процессов через API", + "desc": "Превратите любой рабочий процесс в HTTP API эндпоинт:\n\n• Определите поля вывода (например, image, prompt) — каждое становится выходным портом на холсте\n• При поступлении POST-запроса поля JSON body извлекаются и передаются последующим узлам\n• Используйте с узлом HTTP Response для возврата результатов вызывающей стороне\n\nЭто позволяет интегрировать рабочие процессы во внешние приложения, скрипты или инструменты автоматизации, отправляя HTTP-запрос." + }, + "group": { + "title": "Узел Группа — Организация подпроцессов", + "desc": "Объедините несколько узлов в сворачиваемый контейнер:\n\n• Перетащите узлы в Группу для инкапсуляции подпроцесса\n• Откройте выбранные входы/выходы на поверхности Группы, чтобы внешние узлы могли подключаться\n• Нажмите «Редактировать подграф» для входа и редактирования внутренних узлов\n• Импортируйте существующий рабочий процесс в Группу для повторного использования\n\nГруппы поддерживают сложные рабочие процессы чистыми и модульными — думайте о них как о функциях, которые можно соединять друг с другом." + }, "canvas": { "title": "Взаимодействие с холстом", "desc": "Холст — ваше рабочее пространство:\n• Перетаскивайте узлы, чтобы размещать их\n• Тяните от выходных портов к входным, чтобы создать связи\n• Нажмите на узел, чтобы выбрать его — параметры раскрываются внутри узла для редактирования\n• Щелкните правой кнопкой для контекстного меню (копировать, вставить, удалить)\n• Прокрутка — масштаб, перетаскивание фона — панорамирование\n• Горячие клавиши: Ctrl+Z отмена, Ctrl+C/V копировать-вставить, Delete удалить" diff --git a/src/i18n/locales/th.json b/src/i18n/locales/th.json index ab9f46ba..70febe03 100644 --- a/src/i18n/locales/th.json +++ b/src/i18n/locales/th.json @@ -1202,6 +1202,14 @@ "control": "ควบคุม" }, "nodeDefs": { + "trigger/directory": { + "label": "ทริกเกอร์ไดเรกทอรี", + "hint": "สแกนโฟลเดอร์ในเครื่อง — เวิร์กโฟลว์ทำงานหนึ่งครั้งต่อไฟล์" + }, + "trigger/http": { + "label": "ทริกเกอร์ HTTP", + "hint": "เปิดเวิร์กโฟลว์เป็น HTTP API — กำหนดฟิลด์อินพุตที่ผู้เรียกต้องระบุ" + }, "ai-task/run": { "label": "WaveSpeed API", "hint": "เรียกใช้โมเดล AI ใดก็ได้ — รูปภาพ วิดีโอ เสียง และอื่นๆ", @@ -1288,6 +1296,10 @@ } } }, + "output/http-response": { + "label": "การตอบกลับ HTTP", + "hint": "กำหนดสิ่งที่เวิร์กโฟลว์ส่งกลับไปยังผู้เรียก HTTP" + }, "free-tool/image-enhancer": { "label": "ปรับปรุงรูปภาพ", "hint": "ขยายและเพิ่มความคมชัดของรูปภาพ (2×–4×) ฟรี" @@ -1358,8 +1370,8 @@ "hint": "เลือกรายการหนึ่งจากอาร์เรย์ตามดัชนี" }, "control/iterator": { - "label": "ตัววนซ้ำ", - "hint": "วนซ้ำอินพุต — รันโหนดย่อย N ครั้งหรือครั้งละรายการในอาร์เรย์" + "label": "กลุ่ม", + "hint": "จัดกลุ่มโหนดเป็นเวิร์กโฟลว์ย่อยเพื่อการจัดระเบียบ" } }, "modelSelector": { @@ -1474,6 +1486,18 @@ "title": "โหนด Concat — รวมเอาต์พุตหลายรายการ", "desc": "ต้องการส่งหลายภาพเข้าอินพุต \"images\"? ใช้โหนด Concat:\n\n1. เชื่อมต่อเอาต์พุต image ของแต่ละโหนดต้นทางไปยัง value1, value2, value3 ฯลฯ ของ Concat\n2. เชื่อมต่อเอาต์พุต output ของ Concat (ตอนนี้เป็นอาร์เรย์) ไปยังพารามิเตอร์ images ของโหนดปลายทาง\n\nตัวอย่าง:\n[อัปโหลด A] → image → Concat → output (อาร์เรย์) → [AI Task].images\n[อัปโหลด B] → image ↗\n\nใช้ได้กับเอาต์พุตทุกประเภท — ภาพ วิดีโอ ข้อความ — เมื่อใดก็ตามที่คุณต้องการรวมหลายค่าเดี่ยวเป็นอินพุตอาร์เรย์เดียว" }, + "directoryTrigger": { + "title": "ทริกเกอร์ไดเรกทอรี — ประมวลผลไฟล์ในเครื่องเป็นชุด", + "desc": "สแกนโฟลเดอร์ในเครื่องโดยอัตโนมัติและรันเวิร์กโฟลว์หนึ่งครั้งต่อไฟล์:\n\n• เลือกไดเรกทอรีและประเภทไฟล์ (ภาพ, วิดีโอ, เสียง หรือทั้งหมด)\n• ทริกเกอร์จะค้นหาไฟล์ที่ตรงกันทั้งหมดและส่งทีละไฟล์เข้าไปในไปป์ไลน์\n• เหมาะสำหรับการประมวลผลเป็นชุด — เช่น ปรับปรุงทุกรูปในโฟลเดอร์ แปลงวิดีโอทั้งหมด ฯลฯ\n\nแต่ละการรันจะรับไฟล์เดียว โหนดปลายทางทำงานเหมือนกับการอัปโหลดด้วยตนเอง" + }, + "httpTrigger": { + "title": "ทริกเกอร์ HTTP — รันเวิร์กโฟลว์ผ่าน API", + "desc": "เปลี่ยนเวิร์กโฟลว์ใดก็ได้เป็น HTTP API endpoint:\n\n• กำหนดฟิลด์เอาต์พุต (เช่น image, prompt) — แต่ละฟิลด์จะกลายเป็นพอร์ตเอาต์พุตบนแคนวาส\n• เมื่อคำขอ POST มาถึง ฟิลด์ JSON body จะถูกดึงออกและส่งไปยังโหนดปลายทาง\n• จับคู่กับโหนด HTTP Response เพื่อส่งผลลัพธ์กลับไปยังผู้เรียก\n\nช่วยให้คุณรวมเวิร์กโฟลว์เข้ากับแอปภายนอก สคริปต์ หรือเครื่องมืออัตโนมัติโดยส่งคำขอ HTTP" + }, + "group": { + "title": "โหนดกลุ่ม — จัดระเบียบเวิร์กโฟลว์ย่อย", + "desc": "จัดกลุ่มหลายโหนดเข้าในคอนเทนเนอร์ที่พับได้:\n\n• ลากโหนดเข้ากลุ่มเพื่อห่อหุ้มเวิร์กโฟลว์ย่อย\n• เปิดเผยอินพุต/เอาต์พุตที่เลือกบนพื้นผิวกลุ่มเพื่อให้โหนดภายนอกเชื่อมต่อได้\n• คลิก \"แก้ไขซับกราฟ\" เพื่อเข้าไปแก้ไขโหนดภายใน\n• นำเข้าเวิร์กโฟลว์ที่มีอยู่เข้ากลุ่มเพื่อนำกลับมาใช้ใหม่\n\nกลุ่มช่วยให้เวิร์กโฟลว์ที่ซับซ้อนเป็นระเบียบและเป็นโมดูล — คิดว่าเป็นฟังก์ชันที่คุณสามารถเชื่อมต่อเข้าด้วยกัน" + }, "canvas": { "title": "การโต้ตอบบนแคนวาส", "desc": "แคนวาสคือพื้นที่ทำงานของคุณ:\n• ลากโหนดเพื่อวางตำแหน่ง\n• ลากจากพอร์ตเอาต์พุตไปยังพอร์ตอินพุตเพื่อเชื่อมต่อ\n• คลิกโหนดเพื่อเลือก — พารามิเตอร์จะขยายภายในโหนดเพื่อแก้ไข\n• คลิกขวาเพื่อเปิดเมนูบริบท (คัดลอก วาง ลบ)\n• เลื่อนเพื่อซูม ลากพื้นหลังเพื่อแพน\n• คีย์ลัด: Ctrl+Z ย้อนกลับ, Ctrl+C/V คัดลอก-วาง, Delete เพื่อลบ" diff --git a/src/i18n/locales/tr.json b/src/i18n/locales/tr.json index 7f99062c..9f006fd9 100644 --- a/src/i18n/locales/tr.json +++ b/src/i18n/locales/tr.json @@ -1202,6 +1202,14 @@ "control": "Kontrol" }, "nodeDefs": { + "trigger/directory": { + "label": "Dizin Tetikleyici", + "hint": "Yerel klasörü tara — iş akışı her dosya için bir kez çalışır" + }, + "trigger/http": { + "label": "HTTP Tetikleyici", + "hint": "Bu iş akışını HTTP API olarak aç — arayanların sağlayacağı giriş alanlarını tanımlayın" + }, "ai-task/run": { "label": "WaveSpeed API", "hint": "Herhangi bir AI modelini çalıştırın — görsel, video, ses ve daha fazlası", @@ -1288,6 +1296,10 @@ } } }, + "output/http-response": { + "label": "HTTP Yanıtı", + "hint": "Bu iş akışının HTTP arayanlarına ne döndüreceğini tanımlayın" + }, "free-tool/image-enhancer": { "label": "Görsel geliştirici", "hint": "Görselleri ücretsiz olarak büyütün ve netleştirin (2×–4×)" @@ -1358,8 +1370,8 @@ "hint": "Bir diziden indekse göre bir öğe seçin" }, "control/iterator": { - "label": "Yineleyici", - "hint": "Girdiler üzerinde döngü — alt düğümleri N kez veya dizi öğesi başına bir kez çalıştır" + "label": "Grup", + "hint": "Düğümleri organizasyon için bir alt iş akışında grupla" } }, "modelSelector": { @@ -1474,6 +1486,18 @@ "title": "Concat Dugumu — Birden Fazla Ciktiyi Birlestir", "desc": "Birden fazla gorseli \"images\" girisine aktarmaniz mi gerekiyor? Concat dugumunu kullanin:\n\n1. Her ust dugumun image ciktisini Concat'in value1, value2, value3 vb. girislerine baglayin\n2. Concat'in output ciktisini (artik bir dizi) alt dugumun images parametresine baglayin\n\nOrnek:\n[Yukleme A] → image → Concat → output (dizi) → [AI Gorevi].images\n[Yukleme B] → image ↗\n\nBu, herhangi bir cikti turu icin calisir — gorsel, video, metin — birden fazla tekil degeri tek bir dizi girisinde birlestirmeniz gerektiginde." }, + "directoryTrigger": { + "title": "Dizin Tetikleyicisi — Yerel Dosyalari Toplu Isleme", + "desc": "Yerel bir klasoru otomatik olarak tarayin ve her dosya icin is akisini bir kez calistirin:\n\n• Bir dizin ve dosya turu secin (Gorseller, Videolar, Ses veya Tumu)\n• Tetikleyici eslesen tum dosyalari bulur ve bunlari birer birer boru hattina gonderir\n• Toplu isleme icin idealdir — ornegin bir klasordeki her fotoyu iyilestirme, tum videolari donusturme vb.\n\nHer calistirma tek bir dosya alir, alt dugumler manuel yuklemeyle ayni sekilde calisir." + }, + "httpTrigger": { + "title": "HTTP Tetikleyicisi — API ile Is Akisi Calistirma", + "desc": "Herhangi bir is akisini HTTP API uç noktasina donusturun:\n\n• Cikti alanlari tanimlayin (ornegin image, prompt) — her biri tuvalde bir cikti portu olur\n• POST istegi geldiginde, JSON body alanlari cikarilir ve alt dugumlere iletilir\n• Sonuclari arayana dondurmek icin HTTP Response dugumu ile eslestirin\n\nBu, HTTP istegi gondererek is akislarini harici uygulamalara, betiklere veya otomasyon araclarına entegre etmenizi saglar." + }, + "group": { + "title": "Grup Dugumu — Alt Is Akislarini Duzenleme", + "desc": "Birden fazla dugumu katlanabilir bir kapsayicida gruplayin:\n\n• Dugumleri bir Gruba surukleyerek alt is akisini kapsulleyin\n• Dis dugumlerin baglanabilmesi icin secili giris/cikislari Grup yuzeyinde aciga cikarin\n• \"Alt grafi duzenle\" ye tiklayarak ic dugumleri duzenleyin\n• Mevcut bir is akisini yeniden kullanmak icin bir Gruba aktarin\n\nGruplar karmasik is akislarini temiz ve moduler tutar — birbirine baglayabileceginiz fonksiyonlar gibi dusunun." + }, "canvas": { "title": "Tuval Etkilesimleri", "desc": "Tuval calisma alaninizdir:\n• Dugumleri surukleyip konumlandirin\n• Baglanti olusturmak icin cikis portundan giris portuna surukleyin\n• Bir dugume tiklayarak secin — parametreler dugum icinde genisleyerek duzenleme yapilabilir\n• Baglam menusu icin sag tiklayin (kopyala, yapistir, sil)\n• Zum icin kaydirin, kaydirmak icin arka plani surukleyin\n• Kisayollar: Ctrl+Z geri al, Ctrl+C/V kopyala-yapistir, Delete kaldir" diff --git a/src/i18n/locales/vi.json b/src/i18n/locales/vi.json index e42cc7d6..afa9a2d4 100644 --- a/src/i18n/locales/vi.json +++ b/src/i18n/locales/vi.json @@ -1202,6 +1202,14 @@ "control": "Điều khiển" }, "nodeDefs": { + "trigger/directory": { + "label": "Kích hoạt thư mục", + "hint": "Quét thư mục cục bộ — quy trình chạy một lần cho mỗi tệp" + }, + "trigger/http": { + "label": "Kích hoạt HTTP", + "hint": "Mở quy trình làm việc dưới dạng HTTP API — xác định các trường đầu vào mà người gọi cung cấp" + }, "ai-task/run": { "label": "WaveSpeed API", "hint": "Chạy bất kỳ mô hình AI nào — hình ảnh, video, âm thanh và hơn thế", @@ -1288,6 +1296,10 @@ } } }, + "output/http-response": { + "label": "Phản hồi HTTP", + "hint": "Xác định nội dung quy trình trả về cho người gọi HTTP" + }, "free-tool/image-enhancer": { "label": "Tăng cường hình ảnh", "hint": "Phóng to và làm sắc nét hình ảnh (2×–4×) miễn phí" @@ -1358,8 +1370,8 @@ "hint": "Chọn một mục từ mảng theo chỉ mục" }, "control/iterator": { - "label": "Bộ lặp", - "hint": "Lặp qua đầu vào — chạy nút con N lần hoặc một lần cho mỗi phần tử mảng" + "label": "Nhóm", + "hint": "Nhóm các node thành workflow con để tổ chức" } }, "modelSelector": { @@ -1474,6 +1486,18 @@ "title": "Node Concat — Gop Nhieu Dau Ra", "desc": "Can truyen nhieu hinh anh vao dau vao \"images\"? Su dung node Concat:\n\n1. Ket noi dau ra image cua moi node phia truoc voi value1, value2, value3, v.v. cua Concat\n2. Ket noi dau ra output cua Concat (bay gio la mang) voi tham so images cua node phia sau\n\nVi du:\n[Upload A] → image → Concat → output (mang) → [AI Task].images\n[Upload B] → image ↗\n\nDieu nay hoat dong voi bat ky loai dau ra nao — hinh anh, video, van ban — bat cu khi nao ban can gop nhieu gia tri don le thanh mot dau vao mang." }, + "directoryTrigger": { + "title": "Trigger Thu Muc — Xu Ly Hang Loat File Cuc Bo", + "desc": "Tu dong quet thu muc cuc bo va chay workflow mot lan cho moi file:\n\n• Chon thu muc va loai file (Hinh anh, Video, Am thanh hoac Tat ca)\n• Trigger tim tat ca file phu hop va gui tung file mot vao pipeline\n• Tuyet voi cho xu ly hang loat — vi du nang cap moi anh trong thu muc, chuyen doi tat ca video, v.v.\n\nMoi lan chay nhan mot file duy nhat, cac node phia sau hoat dong giong nhu upload thu cong." + }, + "httpTrigger": { + "title": "Trigger HTTP — Chay Workflow qua API", + "desc": "Bien bat ky workflow nao thanh HTTP API endpoint:\n\n• Dinh nghia cac truong dau ra (vi du: image, prompt) — moi truong tro thanh mot cong dau ra tren canvas\n• Khi yeu cau POST den, cac truong JSON body duoc trich xuat va chuyen den cac node phia sau\n• Ket hop voi node HTTP Response de tra ket qua cho nguoi goi\n\nDieu nay cho phep ban tich hop workflow vao ung dung ben ngoai, script hoac cong cu tu dong hoa bang cach gui yeu cau HTTP." + }, + "group": { + "title": "Node Nhom — To Chuc Workflow Con", + "desc": "Nhom nhieu node vao mot container co the gap lai:\n\n• Keo node vao Nhom de dong goi workflow con\n• Hien thi cac dau vao/dau ra duoc chon tren be mat Nhom de cac node ben ngoai co the ket noi\n• Nhan \"Chinh sua subgraph\" de vao va chinh sua cac node ben trong\n• Nhap workflow co san vao Nhom de tai su dung nhu khoi xay dung\n\nNhom giu cho workflow phuc tap gon gang va module — hay nghi chung nhu cac ham ma ban co the noi voi nhau." + }, "canvas": { "title": "Tuong tac Canvas", "desc": "Canvas la khong gian lam viec cua ban:\n• Keo node de dat vi tri\n• Keo tu cong dau ra sang cong dau vao de tao ket noi\n• Nhan vao node de chon — tham so se mo rong trong node de chinh sua\n• Nhap chuot phai de mo menu ngu canh (sao chep, dan, xoa)\n• Cuon de zoom, keo nen de pan\n• Phim tat: Ctrl+Z hoan tac, Ctrl+C/V sao chep-dan, Delete de xoa" diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 9f0bba29..ad6f3026 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -1269,7 +1269,7 @@ }, "nodeDefs": { "trigger/directory": { - "label": "目录触发", + "label": "目录触发器", "hint": "扫描本地文件夹,每个文件执行一次工作流" }, "trigger/http": { @@ -1526,6 +1526,18 @@ "title": "Concat 节点 — 合并多个输出", "desc": "需要将多张图片传入「images」参数?使用 Concat 节点:\n\n1. 将每个上游节点的 image 输出分别连接到 Concat 的 value1、value2、value3 等输入口\n2. 将 Concat 的 output(此时为数组)连接到下游节点的 images 参数\n\n示例:\n[上传 A] → image → Concat → output(数组)→ [AI 任务].images\n[上传 B] → image ↗\n\n适用于任何类型的输出——图片、视频、文本——只要你需要将多个单值合并为一个数组输入。" }, + "directoryTrigger": { + "title": "目录触发器 — 批量处理本地文件", + "desc": "自动扫描本地文件夹,对每个文件执行一次工作流:\n\n• 选择目录和文件类型(图片、视频、音频或全部)\n• 触发器会找到所有匹配的文件,逐个送入下游流水线\n• 非常适合批量处理——例如增强文件夹中的每张照片、转换所有视频等\n\n每次执行只接收一个文件,下游节点的工作方式与手动上传完全一致。" + }, + "httpTrigger": { + "title": "HTTP 触发器 — 通过 API 运行工作流", + "desc": "将任意工作流变成 HTTP API 端点:\n\n• 定义输出字段(如 image、prompt)——每个字段会成为画布上的一个输出端口\n• 当 POST 请求到达时,JSON body 中的字段会被提取并传递给下游节点\n• 搭配 HTTP Response 节点可将结果返回给调用方\n\n这样你就可以通过发送 HTTP 请求,将工作流集成到外部应用、脚本或自动化工具中。" + }, + "group": { + "title": "Group 节点 — 组织子工作流", + "desc": "将多个节点归入一个可折叠的容器:\n\n• 将节点拖入 Group 以封装子工作流\n• 在 Group 表面暴露选定的输入/输出,供外部节点连接\n• 点击「编辑子图」进入 Group 内部编辑节点\n• 可导入已有工作流到 Group 中作为构建模块复用\n\nGroup 让复杂工作流保持整洁和模块化——可以把它们看作可以互相连接的函数。" + }, "canvas": { "title": "画布操作", "desc": "画布是你的工作区:\n• 拖拽节点到画布上定位\n• 从输出端口拖拽到输入端口创建连接\n• 点击节点选中——参数会在节点内展开供编辑\n• 右键打开上下文菜单(复制、粘贴、删除)\n• 滚轮缩放,拖拽背景平移\n• 快捷键:Ctrl+Z 撤销、Ctrl+C/V 复制粘贴、Delete 删除" diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index b9ff73c1..00eb78fb 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -1160,6 +1160,14 @@ "control": "控制" }, "nodeDefs": { + "trigger/directory": { + "label": "目錄觸發器", + "hint": "掃描本機資料夾,每個檔案執行一次工作流" + }, + "trigger/http": { + "label": "HTTP 觸發器", + "hint": "將工作流發布為 HTTP API,定義呼叫方需要提供的輸入欄位" + }, "ai-task/run": { "label": "WaveSpeed API", "hint": "運行平台上的任意 AI 模型——圖片、影片、音訊等", @@ -1246,6 +1254,10 @@ } } }, + "output/http-response": { + "label": "HTTP 回應", + "hint": "定義工作流透過 HTTP 回傳的內容" + }, "free-tool/image-enhancer": { "label": "圖片增強", "hint": "免費放大和銳化圖片(2×–4×)" @@ -1316,8 +1328,8 @@ "hint": "按索引從陣列中取出一個元素" }, "control/iterator": { - "label": "迭代器", - "hint": "循環執行子節點 — 固定 N 次或按陣列長度自動迭代" + "label": "群組", + "hint": "將節點分組為子工作流,便於組織管理" } }, "modelSelector": { @@ -1476,6 +1488,18 @@ "title": "Concat 節點 — 合併多個輸出", "desc": "需要將多張圖片傳入「images」參數?使用 Concat 節點:\n\n1. 將每個上游節點的 image 輸出分別連接到 Concat 的 value1、value2、value3 等輸入口\n2. 將 Concat 的 output(此時為陣列)連接到下游節點的 images 參數\n\n範例:\n[上傳 A] → image → Concat → output(陣列)→ [AI 任務].images\n[上傳 B] → image ↗\n\n適用於任何類型的輸出——圖片、影片、文字——只要你需要將多個單值合併為一個陣列輸入。" }, + "directoryTrigger": { + "title": "目錄觸發器 — 批次處理本機檔案", + "desc": "自動掃描本機資料夾,對每個檔案執行一次工作流:\n\n• 選擇目錄和檔案類型(圖片、影片、音訊或全部)\n• 觸發器會找到所有符合的檔案,逐一送入下游流水線\n• 非常適合批次處理——例如增強資料夾中的每張照片、轉換所有影片等\n\n每次執行只接收一個檔案,下游節點的運作方式與手動上傳完全一致。" + }, + "httpTrigger": { + "title": "HTTP 觸發器 — 透過 API 執行工作流", + "desc": "將任意工作流變成 HTTP API 端點:\n\n• 定義輸出欄位(如 image、prompt)——每個欄位會成為畫布上的一個輸出埠\n• 當 POST 請求到達時,JSON body 中的欄位會被擷取並傳遞給下游節點\n• 搭配 HTTP Response 節點可將結果回傳給呼叫方\n\n這樣你就可以透過發送 HTTP 請求,將工作流整合到外部應用、腳本或自動化工具中。" + }, + "group": { + "title": "Group 節點 — 組織子工作流", + "desc": "將多個節點歸入一個可摺疊的容器:\n\n• 將節點拖入 Group 以封裝子工作流\n• 在 Group 表面暴露選定的輸入/輸出,供外部節點連接\n• 點擊「編輯子圖」進入 Group 內部編輯節點\n• 可匯入已有工作流到 Group 中作為建構模組複用\n\nGroup 讓複雜工作流保持整潔和模組化——可以把它們看作可以互相連接的函式。" + }, "canvas": { "title": "畫布操作", "desc": "畫布是你的工作區:\n• 拖曳節點到畫布上定位\n• 從輸出埠拖曳到輸入埠建立連線\n• 點擊節點選取——參數會在節點內展開供編輯\n• 右鍵開啟內容選單(複製、貼上、刪除)\n• 滾輪縮放,拖曳背景平移\n• 快捷鍵:Ctrl+Z 復原、Ctrl+C/V 複製貼上、Delete 刪除" diff --git a/src/workflow/WorkflowPage.tsx b/src/workflow/WorkflowPage.tsx index c37814e0..b35763e8 100644 --- a/src/workflow/WorkflowPage.tsx +++ b/src/workflow/WorkflowPage.tsx @@ -1859,7 +1859,7 @@ export function WorkflowPage() {
- {t("workflow.runCount", "Run count")} + {t("workflow.runCount", "Run Count")}
diff --git a/src/workflow/components/WorkflowGuide.tsx b/src/workflow/components/WorkflowGuide.tsx index 5629235f..54f452f3 100644 --- a/src/workflow/components/WorkflowGuide.tsx +++ b/src/workflow/components/WorkflowGuide.tsx @@ -104,6 +104,33 @@ function buildSteps(actions: { actions.scrollNodeIntoView('[data-guide-node="processing/concat"]'); }, }, + { + key: "directoryTrigger", + target: '[data-guide-node="trigger/directory"]', + side: "right", + prepare: () => { + actions.openNodePalette(); + actions.scrollNodeIntoView('[data-guide-node="trigger/directory"]'); + }, + }, + { + key: "httpTrigger", + target: '[data-guide-node="trigger/http"]', + side: "right", + prepare: () => { + actions.openNodePalette(); + actions.scrollNodeIntoView('[data-guide-node="trigger/http"]'); + }, + }, + { + key: "group", + target: '[data-guide-node="control/iterator"]', + side: "right", + prepare: () => { + actions.openNodePalette(); + actions.scrollNodeIntoView('[data-guide-node="control/iterator"]'); + }, + }, { key: "canvas", target: '[data-guide="canvas"]', @@ -155,10 +182,11 @@ function padRect(r: Rect, p: number): Rect { /** Clamp rect to viewport boundaries so cutout never overflows */ function clampRect(r: Rect, vw: number, vh: number): Rect { + const inset = 2; // keep border stroke visible at viewport edges const x = Math.max(0, r.x); const y = Math.max(0, r.y); - const right = Math.min(vw, r.x + r.w); - const bottom = Math.min(vh, r.y + r.h); + const right = Math.min(vw - inset, r.x + r.w); + const bottom = Math.min(vh - inset, r.y + r.h); return { x, y, w: Math.max(0, right - x), h: Math.max(0, bottom - y) }; } @@ -205,7 +233,7 @@ interface PopoverPos { actualSide: PopoverSide; } -const POPOVER_WIDTH = 340; +const POPOVER_WIDTH = 380; const POPOVER_EST_HEIGHT = 400; function computePopoverPos( @@ -520,16 +548,16 @@ export function WorkflowGuide({
{/* Footer — always visible, never clipped */} -
+
{/* Progress dots */} -
+
{steps.map((_, i) => (
{/* Buttons */} -
+
@@ -732,7 +734,7 @@ function CustomNodeComponent({ - {t("workflow.runCount", "Run count")} + {t("workflow.runCount", "Run Count")} {showRunCountPicker && ( @@ -775,7 +777,7 @@ function CustomNodeComponent({ {t("workflow.runFromHere", "Run from here")} - + {t("workflow.continueFrom", "Continue From")} diff --git a/src/workflow/components/canvas/custom-node/CustomNodeParamControls.tsx b/src/workflow/components/canvas/custom-node/CustomNodeParamControls.tsx index 40026186..6f6a4374 100644 --- a/src/workflow/components/canvas/custom-node/CustomNodeParamControls.tsx +++ b/src/workflow/components/canvas/custom-node/CustomNodeParamControls.tsx @@ -914,40 +914,50 @@ export function DefParamControl({ className={`${inputCls} flex-1`} onClick={(e) => e.stopPropagation()} /> - - + + + {t("workflow.selectDirectory", "Select directory")} + + + + + + + + {textVal.trim() ? t("workflow.openFolder", "Open folder") - : t("workflow.openWorkflowFolder", "Open workflow folder") - } - className={`flex-shrink-0 flex items-center justify-center w-8 h-8 rounded-md border border-[hsl(var(--border))] transition-colors ${ - openingDir - ? "bg-blue-500/25 animate-pulse text-blue-300" - : "bg-blue-500/15 text-blue-400 hover:bg-blue-500/25" - }`} - > - ↗ - + : t("workflow.openWorkflowFolder", "Open workflow folder")} + +
- {t("workflow.continueFrom", "Continue From")} + {t("workflow.continueFrom", "Continue From")} @@ -457,7 +457,7 @@ function IteratorNodeContainerComponent({
diff --git a/src/pages/TemplatesPage.tsx b/src/pages/TemplatesPage.tsx index 5c4d33b5..883763a0 100644 --- a/src/pages/TemplatesPage.tsx +++ b/src/pages/TemplatesPage.tsx @@ -28,7 +28,7 @@ import { ChevronRight, ChevronDown, } from "lucide-react"; -import type { Template, TemplateExport } from "@/types/template"; +import type { Template } from "@/types/template"; export function TemplatesPage() { const { t } = useTranslation(); @@ -38,12 +38,10 @@ export function TemplatesPage() { updateTemplate, deleteTemplate, deleteTemplates, - exportTemplates, exportSingleTemplate, exportBatchTemplates, exportMergedTemplates, importTemplates, - pickAndImportTemplates, useTemplate, queryTemplateNames, } = useTemplateStore(); diff --git a/src/types/model.ts b/src/types/model.ts index 396646a5..0765e928 100644 --- a/src/types/model.ts +++ b/src/types/model.ts @@ -18,6 +18,9 @@ export interface SchemaProperty { maxItems?: number; properties?: Record; required?: string[]; + enum?: string[]; + "x-enum"?: string[]; + "x-order-properties"?: string[]; }; minItems?: number; maxItems?: number; @@ -28,11 +31,12 @@ export interface SchemaProperty { }>; // Extended UI hints step?: number; - "x-ui-component"?: "slider" | "uploader" | "loras" | "select"; + "x-ui-component"?: "slider" | "uploader" | "uploaders" | "loras" | "select" | "array"; "x-accept"?: string; "x-placeholder"?: string; "x-hidden"?: boolean; nullable?: boolean; + "x-enum"?: string[]; } export interface Model { diff --git a/src/workflow/components/canvas/custom-node/CustomNode.tsx b/src/workflow/components/canvas/custom-node/CustomNode.tsx index cfe21528..5a3bf781 100644 --- a/src/workflow/components/canvas/custom-node/CustomNode.tsx +++ b/src/workflow/components/canvas/custom-node/CustomNode.tsx @@ -16,7 +16,13 @@ import React, { useEffect, } from "react"; import { useTranslation } from "react-i18next"; -import { Handle, Position, useReactFlow, type NodeProps } from "reactflow"; +import { + Handle, + Position, + useReactFlow, + useUpdateNodeInternals, + type NodeProps, +} from "reactflow"; import { useExecutionStore } from "../../../stores/execution.store"; import { useWorkflowStore } from "../../../stores/workflow.store"; import { useUIStore } from "../../../stores/ui.store"; @@ -69,6 +75,7 @@ function CustomNodeComponent({ const openPreview = useUIStore((s) => s.openPreview); const allNodes = useWorkflowStore((s) => s.nodes); const allLastResults = useExecutionStore((s) => s.lastResults); + const allSelectedOutputIndex = useExecutionStore((s) => s.selectedOutputIndex); const [hovered, setHovered] = useState(false); const [segmentPointPickerOpen, setSegmentPointPickerOpen] = useState(false); const [resultsExpanded, setResultsExpanded] = useState(false); @@ -85,6 +92,7 @@ function CustomNodeComponent({ const wrapperRef = useRef(null); const [resizing, setResizing] = useState(false); const { getViewport, setNodes } = useReactFlow(); + const updateNodeInternals = useUpdateNodeInternals(); const shortId = id.slice(0, 8); const collapsed = (data.params?.__nodeCollapsed as boolean | undefined) ?? false; @@ -429,6 +437,11 @@ function CustomNodeComponent({ if (resultGroups.length > 0) setResultsExpanded(true); }, [resultGroups.length]); + // Tell React Flow to re-measure handle positions when node size changes + useEffect(() => { + requestAnimationFrame(() => updateNodeInternals(id)); + }, [collapsed, resultsExpanded, resultGroups.length, schema.length, formFields.length, id, updateNodeInternals]); + const saveWorkflow = useWorkflowStore((s) => s.saveWorkflow); const removeNode = useWorkflowStore((s) => s.removeNode); const { continueFrom } = useExecutionStore(); @@ -478,7 +491,8 @@ function CustomNodeComponent({ /^file:\/\//i.test(u); const pickFromSourceNode = (sourceNodeId: string): string => { - const latest = allLastResults[sourceNodeId]?.[0]?.urls?.[0] ?? ""; + const selIdx = allSelectedOutputIndex[sourceNodeId] ?? 0; + const latest = allLastResults[sourceNodeId]?.[selIdx]?.urls?.[0] ?? ""; if (latest && isMediaLike(latest)) return latest; const sourceNode = allNodes.find((n) => n.id === sourceNodeId); @@ -512,6 +526,7 @@ function CustomNodeComponent({ return ""; }, [ allLastResults, + allSelectedOutputIndex, allNodes, data.params, edges, diff --git a/src/workflow/components/canvas/custom-node/CustomNodePrimitives.tsx b/src/workflow/components/canvas/custom-node/CustomNodePrimitives.tsx index 985cf9e2..b6fdd161 100644 --- a/src/workflow/components/canvas/custom-node/CustomNodePrimitives.tsx +++ b/src/workflow/components/canvas/custom-node/CustomNodePrimitives.tsx @@ -144,6 +144,7 @@ export function ConnectedInputControl({ showPreview?: boolean; }) { const lastResults = useExecutionStore((s) => s.lastResults); + const selectedOutputIndex = useExecutionStore((s) => s.selectedOutputIndex); if (!nodeId || !handleId || !edges || !nodes) { return ; @@ -156,7 +157,8 @@ export function ConnectedInputControl({ const sourceNode = nodes.find((n) => n.id === edge.source); const sourceParams = sourceNode?.data?.params ?? {}; - const latestResultUrls = lastResults[edge.source]?.[0]?.urls ?? []; + const selIdx = selectedOutputIndex[edge.source] ?? 0; + const latestResultUrls = lastResults[edge.source]?.[selIdx]?.urls ?? []; const isMediaLike = (u: string) => /^https?:\/\//i.test(u) || diff --git a/src/workflow/hooks/useGroupAdoption.ts b/src/workflow/hooks/useGroupAdoption.ts index d4a3b4a9..c916230c 100644 --- a/src/workflow/hooks/useGroupAdoption.ts +++ b/src/workflow/hooks/useGroupAdoption.ts @@ -15,8 +15,6 @@ import { useUIStore } from "../stores/ui.store"; /* ── constants ─────────────────────────────────────────────────────── */ -const RELEASE_THRESHOLD = 30; // px beyond the iterator edge to trigger release - /* ── hook ──────────────────────────────────────────────────────────── */ export function useGroupAdoption() { From 87ae29faaf2c2dc2c6d581d91eb54c47694701b5 Mon Sep 17 00:00:00 2001 From: Leon Date: Tue, 24 Mar 2026 15:10:12 +1100 Subject: [PATCH 14/18] fix: group node i18n label bug + update workflow editor screenshot - Fix GroupNodeContainer to use dynamic t() translation instead of stale data.label - Add __userRenamed flag so custom names are preserved after language switch - Update Visual Workflow Editor screenshot in README --- README.md | 199 +++++++++--------- .../canvas/group-node/GroupNodeContainer.tsx | 14 +- 2 files changed, 106 insertions(+), 107 deletions(-) diff --git a/README.md b/README.md index fdddcdc8..191d8411 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,22 @@ # WaveSpeed -Open-source, cross-platform application for running 100+ AI models — image generation, video generation, face swap, digital human, motion control, and more. Includes a visual workflow editor for building AI pipelines and 12 free creative tools. Available for **Windows**, **macOS**, **Linux**, and **Android**. +Open-source, cross-platform application for running 600+ AI models — image generation, video generation, face swap, digital human, motion control, and more. Features a visual workflow editor for building AI pipelines, Featured Models with smart variant switching, and 12 free creative tools. Available for **Windows**, **macOS**, **Linux**, and **Android**. [![GitHub Release](https://img.shields.io/github/v/release/WaveSpeedAI/wavespeed-desktop?style=flat-square&label=Latest)](https://github.com/WaveSpeedAI/wavespeed-desktop/releases/latest) [![License](https://img.shields.io/github/license/WaveSpeedAI/wavespeed-desktop?style=flat-square)](LICENSE) [![Stars](https://img.shields.io/github/stars/WaveSpeedAI/wavespeed-desktop?style=flat-square)](https://github.com/WaveSpeedAI/wavespeed-desktop/stargazers) -[![Windows](https://img.shields.io/badge/Windows-0078D6?style=for-the-badge&logo=windows&logoColor=white)](https://github.com/WaveSpeedAI/wavespeed-desktop/releases/latest/download/WaveSpeed-Desktop-win-x64.exe) +[![Windows](https://img.shields.io/badge/Windows-0078D6?style=for-the-badge&logo=data:image/svg%2bxml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0id2hpdGUiPjxwYXRoIGQ9Ik0wIDMuNWw5LjktMS40djkuNUgwem0xMS4xLTEuNUwyNCAwdjExLjVIMTEuMXpNMCAxMi42aDkuOXY5LjVMMCAyMC43em0xMS4xLS4xSDI0VjI0bC0xMi45LTEuOHoiLz48L3N2Zz4=&logoColor=white)](https://github.com/WaveSpeedAI/wavespeed-desktop/releases/latest/download/WaveSpeed-Desktop-win-x64.exe) [![macOS Intel](https://img.shields.io/badge/macOS_Intel-000000?style=for-the-badge&logo=apple&logoColor=white)](https://github.com/WaveSpeedAI/wavespeed-desktop/releases/latest/download/WaveSpeed-Desktop-mac-x64.dmg) [![macOS Apple Silicon](https://img.shields.io/badge/macOS_Silicon-000000?style=for-the-badge&logo=apple&logoColor=white)](https://github.com/WaveSpeedAI/wavespeed-desktop/releases/latest/download/WaveSpeed-Desktop-mac-arm64.dmg) [![Linux](https://img.shields.io/badge/Linux-FCC624?style=for-the-badge&logo=linux&logoColor=black)](https://github.com/WaveSpeedAI/wavespeed-desktop/releases/latest/download/WaveSpeed-Desktop-linux-x86_64.AppImage) [![Android](https://img.shields.io/badge/Android-3DDC84?style=for-the-badge&logo=android&logoColor=white)](https://github.com/WaveSpeedAI/wavespeed-desktop/releases/latest/download/WaveSpeed-Mobile.apk) -![Playground Screenshot](https://github.com/user-attachments/assets/7bd45689-5b24-40ab-9495-2296533e3b5a) +![Playground Screenshot](https://github.com/user-attachments/assets/054a45d8-9bbc-4f1b-8cc1-b6fa4b3b2aac) ## Android App -The Android app shares the same React codebase as the desktop version, giving you access to the AI Playground, Featured Models, Creative Studio, and all 100+ models from your phone. +The Android app shares the same React codebase as the desktop version, giving you access to the AI Playground, Featured Models, Creative Studio, and all 600+ models from your phone. - Full AI Playground with multi-tab support and all input types including camera capture - Featured Models with smart variant switching @@ -49,60 +49,33 @@ The Android app shares the same React codebase as the desktop version, giving yo | **Media Trimmer** | Trim video and audio by selecting start and end times | | **Media Merger** | Merge multiple video or audio files into one | -![WaveSpeed Creative Studio](https://github.com/user-attachments/assets/67359fa7-8ff4-4001-a982-eb4802e5b841) +![WaveSpeed Creative Studio](https://github.com/user-attachments/assets/2265a42b-4686-4eb4-87b5-5f70e8a99852) ## Visual Workflow Editor Node-based pipeline builder for designing and executing complex AI workflows. Chain any combination of AI models, free tools, and media processing steps into automated pipelines. -![WaveSpeed Visual Workflow Editor](https://github.com/user-attachments/assets/e1243d57-8d7b-4d42-bed3-94bf8adfa6f5) +![WaveSpeed Visual Workflow Editor](https://github.com/user-attachments/assets/31f6889a-aeff-41a9-ab15-c16f7c828712) ## Features -- **Model Browser**: Browse and search available AI models with fuzzy search, sortable by popularity, name, price, or type -- **Favorites**: Star your favorite models for quick access with a dedicated filter -- **Multi-Tab Playground**: Run predictions with multiple models simultaneously in separate tabs -- **Abort Execution**: Cancel running predictions with a smooth abort button (0.5s safety delay) -- **Batch Processing**: Run the same prediction multiple times (2-16) with auto-randomized seeds for variations -- **Dynamic Forms**: Auto-generated forms from model schemas with validation -- **Mask Drawing**: Interactive canvas-based mask editor for models that accept mask inputs, with brush, eraser, and bucket fill tools -- **Templates**: Save and reuse playground configurations as templates for quick access -- **LoRA Support**: Full support for LoRAs including high-noise and low-noise LoRAs for Wan 2.2 models -- **Visual Workflow Editor**: Node-based editor for building and executing AI/processing pipelines - - **Node Types**: Media upload, text input, AI task (any WaveSpeedAI model), 12 free tool nodes, file export, preview display, and annotation notes - - **Canvas Interaction**: Drag & drop nodes, connect handles, zoom/pan, context menus, copy/paste, duplicate, and keyboard shortcuts (Ctrl+Z/Y, Ctrl+C/V, Ctrl+S, Delete) - - **Execution Control**: Run all, run selected node, continue from any node, retry failed nodes, cancel individual or all, and batch runs (1-99x with auto-randomized seeds) - - **Execution Monitor**: Real-time progress panel with per-node status, progress bars, cost tracking, and I/O data inspection - - **Multi-Tab**: Chrome-style tabs with session persistence, tab renaming (double-click), unsaved changes indicator, and auto-restore on restart - - **Results Management**: Per-node execution history, fullscreen preview (images, videos, 3D models, audio), arrow key navigation, download, and clear results - - **Cost Estimation & Budget**: Real-time cost estimate per run, daily budget tracking, per-execution limits, and cost breakdown per node - - **Import/Export**: Save and load workflows as JSON with SQLite-backed persistence - - **Undo/Redo**: Snapshot-based (up to 50 states) with debounced text input support -- **Free Tools**: Free AI-powered image and video tools (no API key required) - - **Image Enhancer**: Upscale images 2x-4x with ESRGAN models (slim, medium, thick quality options) - - **Video Enhancer**: Frame-by-frame video upscaling with real-time progress and ETA - - **Face Enhancer**: Enhance and restore face quality using YOLO v8 for detection and GFPGAN v1.4 for enhancement (WebGPU accelerated) - - **Face Swapper**: Swap faces between images using InsightFace models (SCRFD detection, ArcFace embedding, Inswapper) with optional GFPGAN enhancement - - **Background Remover**: Remove image backgrounds instantly using AI, displaying foreground, background, and mask outputs simultaneously with individual download buttons - - **Image Eraser**: Remove unwanted objects from images using LaMa inpainting model with smart crop and blend (WebGPU accelerated) - - **Segment Anything**: Interactive object segmentation with point prompts using SlimSAM model - - **Video Converter**: Convert videos between formats (MP4, WebM, AVI, MOV, MKV) with codec and quality options - - **Audio Converter**: Convert audio between formats (MP3, WAV, AAC, FLAC, OGG) with bitrate control - - **Image Converter**: Batch convert images between formats (JPG, PNG, WebP, GIF, BMP) with quality settings - - **Media Trimmer**: Trim video/audio files by selecting start and end times - - **Media Merger**: Merge multiple video/audio files into one -- **Z-Image (Local)**: Run local image generation via stable-diffusion.cpp with model/aux downloads, progress, and logs -- **Multi-Phase Progress**: Compact progress bars with phase indicators, real-time status, and ETA for all Free Tools -- **History**: View your recent predictions (last 24 hours) with detailed view, download, and copy prediction ID -- **My Assets**: Save, browse, and manage generated outputs (images, videos, audio) with tags, favorites, and search -- **Auto-Save**: Automatically save generated outputs to your local assets folder (enabled by default) with error reporting -- **File Upload**: Support for image, video, and audio file inputs with drag & drop -- **Media Capture**: Built-in camera capture, video recording with audio waveform, and audio recording -- **View Documentation**: Quick access to model webpage and documentation from the titlebar (context-aware links when a model is selected) -- **Account Balance**: View your current WaveSpeed account balance in Settings with one-click refresh -- **Theme Support**: Auto (system), dark, and light theme options -- **Multi-Language**: Support for 18 languages including English, Chinese, Japanese, Korean, and more -- **Auto Updates**: Automatic update checking with stable and nightly channels +- **AI Playground**: Multi-tab playground with dynamic forms, batch processing (2-16x), mask drawing, LoRA support, abort control, and auto-randomized seeds +- **Featured Models**: Curated model families with smart variant switching — auto-selects the best variant based on inputs and toggles (Seedream 4.5, Seedance 1.5 Pro, Wan Spicy, InfiniteTalk, Kling 2.6, Nano Banana Pro, etc.) +- **Model Browser**: Fuzzy search, sort by popularity/name/price/type, favorites filter +- **Visual Workflow Editor**: Node-based pipeline builder with 20+ node types + - Triggers (directory scan, HTTP API), AI tasks, 12 free tool nodes, processing (concat, select), group/subgraph, I/O nodes + - Run all / run node / continue / retry / cancel / batch runs (1-99x), real-time execution monitor with cost tracking + - Group/subgraph containers with exposed I/O, breadcrumb navigation, and workflow import + - HTTP API mode: expose workflows as REST endpoints via built-in HTTP server + - Directory batch processing: auto-execute per media file in a folder + - Prompt optimizer, guided tour, result caching, circuit breaker, cycle detection + - Cost estimation & daily budget, import/export (JSON + SQLite), multi-tab, undo/redo, customizable output naming +- **Free Tools**: 12 AI-powered creative tools (no API key) — see [Creative Studio](#creative-studio) above +- **Z-Image**: Local image generation via stable-diffusion.cpp with model downloads, progress, and logs +- **Templates**: Playground + workflow templates with presets, i18n search, import/export, and usage tracking +- **History & Assets**: Recent predictions (24h), saved outputs with tags/favorites/search, auto-save to local folder +- **Media Input**: File upload (drag & drop), camera capture, video/audio recording +- **18 languages**, dark/light/auto theme, auto updates (stable + nightly), cross-platform (Windows, macOS, Linux, Android) - **Cross-Platform**: Available for Windows, macOS, Linux, and Android ## Installation @@ -111,7 +84,7 @@ Node-based pipeline builder for designing and executing complex AI workflows. Ch #### Desktop -[![Windows](https://img.shields.io/badge/Windows-0078D6?style=for-the-badge&logo=windows&logoColor=white)](https://github.com/WaveSpeedAI/wavespeed-desktop/releases/latest/download/WaveSpeed-Desktop-win-x64.exe) +[![Windows](https://img.shields.io/badge/Windows-0078D6?style=for-the-badge&logo=data:image/svg%2bxml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0id2hpdGUiPjxwYXRoIGQ9Ik0wIDMuNWw5LjktMS40djkuNUgwem0xMS4xLTEuNUwyNCAwdjExLjVIMTEuMXpNMCAxMi42aDkuOXY5LjVMMCAyMC43em0xMS4xLS4xSDI0VjI0bC0xMi45LTEuOHoiLz48L3N2Zz4=&logoColor=white)](https://github.com/WaveSpeedAI/wavespeed-desktop/releases/latest/download/WaveSpeed-Desktop-win-x64.exe) [![macOS Intel](https://img.shields.io/badge/macOS_Intel-000000?style=for-the-badge&logo=apple&logoColor=white)](https://github.com/WaveSpeedAI/wavespeed-desktop/releases/latest/download/WaveSpeed-Desktop-mac-x64.dmg) [![macOS Apple Silicon](https://img.shields.io/badge/macOS_Silicon-000000?style=for-the-badge&logo=apple&logoColor=white)](https://github.com/WaveSpeedAI/wavespeed-desktop/releases/latest/download/WaveSpeed-Desktop-mac-arm64.dmg) [![Linux](https://img.shields.io/badge/Linux-FCC624?style=for-the-badge&logo=linux&logoColor=black)](https://github.com/WaveSpeedAI/wavespeed-desktop/releases/latest/download/WaveSpeed-Desktop-linux-x86_64.AppImage) @@ -190,17 +163,19 @@ npm run dev ### Scripts -| Script | Description | -| ---------------------- | ---------------------------------------- | -| `npm run dev` | Start development server with hot reload | -| `npx vite` | Start web-only dev server (no Electron) | -| `npm run build` | Build the application | -| `npm run build:win` | Build for Windows | -| `npm run build:mac` | Build for macOS | -| `npm run build:linux` | Build for Linux | -| `npm run build:all` | Build for all platforms | -| `npm run format` | Format code with Prettier | -| `npm run format:check` | Check code formatting | +| Script | Description | +| ---------------------- | ------------------------------------------ | +| `npm run dev` | Start development server with hot reload | +| `npm run dev:web` | Start web-only dev server (no Electron) | +| `npm run build` | Build the application | +| `npm run build:web` | Build web-only version (no Electron) | +| `npm run build:win` | Build for Windows | +| `npm run build:mac` | Build for macOS | +| `npm run build:linux` | Build for Linux | +| `npm run build:all` | Build for all platforms | +| `npm run dist` | Build and package for distribution | +| `npm run format` | Format code with Prettier | +| `npm run format:check` | Check code formatting | ### Mobile Development @@ -229,41 +204,48 @@ See [mobile/README.md](mobile/README.md) for detailed mobile development guide. ``` wavespeed-desktop/ -├── electron/ # Electron main process -│ ├── main.ts # Main process entry -│ ├── preload.ts # Preload script (IPC bridge) -│ └── workflow/ # Workflow backend -│ ├── db/ # SQLite database (workflow, node, edge, execution repos) -│ ├── ipc/ # IPC handlers (workflow, execution, history, cost, storage) -│ ├── nodes/ # Node type definitions & handlers (AI task, free tools, I/O) -│ ├── engine/ # Execution engine (DAG runner, scheduler) -│ └── utils/ # File storage, cost estimation +├── data/templates/ # Preset workflow templates (AI generation, image/video/audio processing) +├── electron/ # Electron main process +│ ├── main.ts # Main process entry +│ ├── preload.ts # Preload script (IPC bridge) +│ ├── lib/ # Local generation (sdGenerator for stable-diffusion.cpp) +│ └── workflow/ # Workflow backend +│ ├── db/ # SQLite database (workflow, node, edge, execution, budget, template repos) +│ ├── engine/ # Execution engine (DAG runner, scheduler, cache, circuit breaker) +│ ├── ipc/ # IPC handlers (workflow, execution, history, cost, storage, http-server) +│ ├── nodes/ # Node handlers (AI task, free tools, I/O, triggers, processing, control) +│ ├── services/ # HTTP server, model list, retry, service locator, template loader +│ └── utils/ # File storage, hashing, save-to-assets ├── src/ -│ ├── api/ # API client -│ ├── components/ # React components -│ │ ├── layout/ # Layout components -│ │ ├── playground/ # Playground components -│ │ ├── shared/ # Shared components -│ │ └── ui/ # shadcn/ui components -│ ├── hooks/ # Custom React hooks -│ ├── i18n/ # Internationalization (18 languages) -│ ├── lib/ # Utility functions -│ ├── pages/ # Page components -│ ├── stores/ # Zustand stores -│ ├── types/ # TypeScript types -│ ├── workers/ # Web Workers (upscaler, background remover, image eraser, ffmpeg) -│ └── workflow/ # Workflow frontend -│ ├── components/ # Canvas, node palette, config panel, results panel, run monitor -│ ├── stores/ # Workflow, execution, UI stores (Zustand) -│ ├── hooks/ # Workflow-specific hooks -│ ├── ipc/ # Type-safe IPC client -│ └── types/ # Workflow type definitions -├── mobile/ # Mobile app (Android) -│ ├── src/ # Mobile-specific overrides -│ ├── android/ # Android native project +│ ├── api/ # API client +│ ├── components/ # React components +│ │ ├── ffmpeg/ # FFmpeg components +│ │ ├── layout/ # Layout components +│ │ ├── playground/ # Playground components +│ │ ├── shared/ # Shared components +│ │ ├── templates/ # Template components +│ │ └── ui/ # shadcn/ui components +│ ├── hooks/ # Custom React hooks +│ ├── i18n/ # Internationalization (18 languages) +│ ├── lib/ # Utilities (fuzzy search, schema-to-form, smart form config, etc.) +│ ├── pages/ # Page components +│ ├── stores/ # Zustand stores +│ ├── types/ # TypeScript types +│ ├── workers/ # Web Workers (upscaler, face enhancer/swapper, background remover, image eraser, segmentation, ffmpeg) +│ └── workflow/ # Workflow frontend +│ ├── browser/ # Browser-only workflow API (web mode without Electron) +│ ├── components/ # Canvas, node palette, config panel, results panel, run monitor, prompt optimizer +│ ├── hooks/ # Workflow-specific hooks (undo/redo, group adoption, free tool listener) +│ ├── ipc/ # Type-safe IPC client +│ ├── lib/ # Cycle detection, free tool runner, model converter, topological sort +│ ├── stores/ # Workflow, execution, UI stores (Zustand) +│ └── types/ # Workflow type definitions +├── mobile/ # Mobile app (Android) +│ ├── src/ # Mobile-specific overrides +│ ├── android/ # Android native project │ └── capacitor.config.ts -├── .github/workflows/ # GitHub Actions (desktop + mobile) -└── build/ # Build resources +├── .github/workflows/ # GitHub Actions (desktop + mobile) +└── build/ # Build resources ``` ## Tech Stack @@ -277,6 +259,10 @@ wavespeed-desktop/ - **HTTP Client**: Axios - **Workflow Canvas**: React Flow - **Workflow Database**: sql.js (SQLite in-process) +- **AI/ML (Free Tools)**: @huggingface/transformers, onnxruntime-web, @tensorflow/tfjs, upscaler (ESRGAN) +- **Media Processing**: @ffmpeg/core, mp4-muxer, webm-muxer +- **3D Preview**: @google/model-viewer +- **Local Generation**: stable-diffusion.cpp (via sdGenerator) ### Mobile @@ -298,14 +284,21 @@ Get your API key from [WaveSpeedAI](https://wavespeed.ai) The application uses the WaveSpeedAI API v3: -| Endpoint | Method | Description | -| --------------------------------- | ------ | ---------------------- | -| `/api/v3/models` | GET | List available models | -| `/api/v3/{model}` | POST | Run a prediction | -| `/api/v3/predictions/{id}/result` | GET | Get prediction result | -| `/api/v3/predictions` | POST | Get prediction history | -| `/api/v3/media/upload/binary` | POST | Upload files | -| `/api/v3/balance` | GET | Get account balance | +| Endpoint | Method | Description | +| --------------------------------- | ------ | ------------------------------------ | +| `/api/v3/models` | GET | List available models | +| `/api/v3/{model}` | POST | Run a prediction | +| `/api/v3/predictions/{id}/result` | GET | Get prediction result | +| `/api/v3/predictions` | POST | Get prediction history | +| `/api/v3/media/upload/binary` | POST | Upload files | +| `/api/v3/balance` | GET | Get account balance | + +The built-in workflow HTTP server also exposes: + +| Endpoint | Method | Description | +| --------------------------------- | ------ | ------------------------------------ | +| `/api/workflows/{id}/run` | POST | Trigger a workflow execution via API | +| `/api/workflows/{id}/schema` | GET | Get workflow input schema | ## Contributing diff --git a/src/workflow/components/canvas/group-node/GroupNodeContainer.tsx b/src/workflow/components/canvas/group-node/GroupNodeContainer.tsx index a6cee79f..ac3e8b58 100644 --- a/src/workflow/components/canvas/group-node/GroupNodeContainer.tsx +++ b/src/workflow/components/canvas/group-node/GroupNodeContainer.tsx @@ -277,18 +277,22 @@ function IteratorNodeContainerComponent({ /* ── Inline name editing ───────────────────────────────────────── */ const startEditingName = useCallback(() => { - setNameValue(data.label || ""); + const displayLabel = data.params?.__userRenamed + ? (data.label || t("workflow.nodeDefs.control/iterator.label", "Group")) + : t("workflow.nodeDefs.control/iterator.label", "Group"); + setNameValue(displayLabel); setEditingName(true); setTimeout(() => nameInputRef.current?.select(), 0); - }, [data.label]); + }, [data.label, data.params?.__userRenamed, t]); const commitName = useCallback(() => { setEditingName(false); const trimmed = nameValue.trim(); if (trimmed && trimmed !== data.label) { updateNodeData(id, { label: trimmed }); + updateNodeParams(id, { ...data.params, __userRenamed: true }); } - }, [nameValue, data.label, id, updateNodeData]); + }, [nameValue, data.label, id, data.params, updateNodeData, updateNodeParams]); const cancelEditingName = useCallback(() => { setEditingName(false); @@ -511,7 +515,9 @@ function IteratorNodeContainerComponent({ "Double-click to rename", )} > - {data.label || t("workflow.group", "Group")} + {data.params?.__userRenamed + ? (data.label || t("workflow.nodeDefs.control/iterator.label", "Group")) + : t("workflow.nodeDefs.control/iterator.label", "Group")} )} From 35393fb58fb1aa78e43220b1e9435aeb80c2c277 Mon Sep 17 00:00:00 2001 From: Leon Date: Tue, 24 Mar 2026 15:14:50 +1100 Subject: [PATCH 15/18] docs: add HTTP server API details and OpenClaw skill server integration --- README.md | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 191d8411..c00515df 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ Node-based pipeline builder for designing and executing complex AI workflows. Ch - Triggers (directory scan, HTTP API), AI tasks, 12 free tool nodes, processing (concat, select), group/subgraph, I/O nodes - Run all / run node / continue / retry / cancel / batch runs (1-99x), real-time execution monitor with cost tracking - Group/subgraph containers with exposed I/O, breadcrumb navigation, and workflow import - - HTTP API mode: expose workflows as REST endpoints via built-in HTTP server + - HTTP API mode: expose workflows as REST endpoints via built-in HTTP server — works as a skill server for [OpenClaw](https://github.com/anthropics/openclaw) and other AI agents - Directory batch processing: auto-execute per media file in a folder - Prompt optimizer, guided tour, result caching, circuit breaker, cycle detection - Cost estimation & daily budget, import/export (JSON + SQLite), multi-tab, undo/redo, customizable output naming @@ -297,8 +297,15 @@ The built-in workflow HTTP server also exposes: | Endpoint | Method | Description | | --------------------------------- | ------ | ------------------------------------ | +| `/api/health` | GET | Health check | | `/api/workflows/{id}/run` | POST | Trigger a workflow execution via API | -| `/api/workflows/{id}/schema` | GET | Get workflow input schema | +| `/api/workflows/{id}/schema` | GET | Get workflow input/output schema | +| `/schema` | GET | Get active workflow schema | +| `POST /` (any path) | POST | Run the active workflow | + +Add an HTTP Trigger node to your workflow to define the API input schema (each field becomes an output port), and optionally add an HTTP Response node to customize the response. Start the server from the workflow canvas — it listens on a configurable port (default `3100`) with CORS enabled. + +This turns any workflow into a callable REST endpoint, making it easy to integrate with [OpenClaw](https://github.com/anthropics/openclaw) or other AI agent frameworks as a skill server. For example, an OpenClaw agent can call `GET /api/workflows/{id}/schema` to discover the workflow's input/output contract, then `POST /api/workflows/{id}/run` with the required fields to execute the pipeline and receive results. ## Contributing From dfac20ac03426894bdb776f3b00c8826df17d294 Mon Sep 17 00:00:00 2001 From: Leon Date: Tue, 24 Mar 2026 15:16:52 +1100 Subject: [PATCH 16/18] docs: update Creative Studio screenshot --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index c00515df..5c5edd47 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ The Android app shares the same React codebase as the desktop version, giving yo | **Media Trimmer** | Trim video and audio by selecting start and end times | | **Media Merger** | Merge multiple video or audio files into one | -![WaveSpeed Creative Studio](https://github.com/user-attachments/assets/2265a42b-4686-4eb4-87b5-5f70e8a99852) +![WaveSpeed Creative Studio](https://github.com/user-attachments/assets/dea6a526-ec08-408a-810d-7f88cc28797a) ## Visual Workflow Editor From d1daf56bed0b11cbab12918a29294f98750c9402 Mon Sep 17 00:00:00 2001 From: Leon Date: Tue, 24 Mar 2026 15:24:56 +1100 Subject: [PATCH 17/18] chore: prettier formatting across entire codebase --- .vscode/settings.json | 3 +- README.md | 56 +-- src/components/playground/ExplorePanel.tsx | 11 +- src/types/model.ts | 8 +- src/workflow/browser/node-definitions.ts | 1 - src/workflow/browser/run-in-browser.ts | 5 +- .../components/canvas/SubgraphBreadcrumb.tsx | 4 +- .../components/canvas/WorkflowCanvas.tsx | 111 ++++-- .../canvas/custom-node/CustomNode.tsx | 14 +- .../canvas/custom-node/CustomNodeBody.tsx | 8 +- .../custom-node/CustomNodeInputBodies.tsx | 5 +- .../custom-node/DynamicFieldsEditor.tsx | 15 +- .../canvas/group-node/GroupIONode.tsx | 357 +++++++++++++----- .../canvas/group-node/GroupNodeContainer.tsx | 99 ++++- src/workflow/stores/execution.store.ts | 3 +- 15 files changed, 507 insertions(+), 193 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 2c63c085..0967ef42 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,2 +1 @@ -{ -} +{} diff --git a/README.md b/README.md index 5c5edd47..d6c79bc1 100644 --- a/README.md +++ b/README.md @@ -163,19 +163,19 @@ npm run dev ### Scripts -| Script | Description | -| ---------------------- | ------------------------------------------ | -| `npm run dev` | Start development server with hot reload | -| `npm run dev:web` | Start web-only dev server (no Electron) | -| `npm run build` | Build the application | -| `npm run build:web` | Build web-only version (no Electron) | -| `npm run build:win` | Build for Windows | -| `npm run build:mac` | Build for macOS | -| `npm run build:linux` | Build for Linux | -| `npm run build:all` | Build for all platforms | -| `npm run dist` | Build and package for distribution | -| `npm run format` | Format code with Prettier | -| `npm run format:check` | Check code formatting | +| Script | Description | +| ---------------------- | ---------------------------------------- | +| `npm run dev` | Start development server with hot reload | +| `npm run dev:web` | Start web-only dev server (no Electron) | +| `npm run build` | Build the application | +| `npm run build:web` | Build web-only version (no Electron) | +| `npm run build:win` | Build for Windows | +| `npm run build:mac` | Build for macOS | +| `npm run build:linux` | Build for Linux | +| `npm run build:all` | Build for all platforms | +| `npm run dist` | Build and package for distribution | +| `npm run format` | Format code with Prettier | +| `npm run format:check` | Check code formatting | ### Mobile Development @@ -284,24 +284,24 @@ Get your API key from [WaveSpeedAI](https://wavespeed.ai) The application uses the WaveSpeedAI API v3: -| Endpoint | Method | Description | -| --------------------------------- | ------ | ------------------------------------ | -| `/api/v3/models` | GET | List available models | -| `/api/v3/{model}` | POST | Run a prediction | -| `/api/v3/predictions/{id}/result` | GET | Get prediction result | -| `/api/v3/predictions` | POST | Get prediction history | -| `/api/v3/media/upload/binary` | POST | Upload files | -| `/api/v3/balance` | GET | Get account balance | +| Endpoint | Method | Description | +| --------------------------------- | ------ | ---------------------- | +| `/api/v3/models` | GET | List available models | +| `/api/v3/{model}` | POST | Run a prediction | +| `/api/v3/predictions/{id}/result` | GET | Get prediction result | +| `/api/v3/predictions` | POST | Get prediction history | +| `/api/v3/media/upload/binary` | POST | Upload files | +| `/api/v3/balance` | GET | Get account balance | The built-in workflow HTTP server also exposes: -| Endpoint | Method | Description | -| --------------------------------- | ------ | ------------------------------------ | -| `/api/health` | GET | Health check | -| `/api/workflows/{id}/run` | POST | Trigger a workflow execution via API | -| `/api/workflows/{id}/schema` | GET | Get workflow input/output schema | -| `/schema` | GET | Get active workflow schema | -| `POST /` (any path) | POST | Run the active workflow | +| Endpoint | Method | Description | +| ---------------------------- | ------ | ------------------------------------ | +| `/api/health` | GET | Health check | +| `/api/workflows/{id}/run` | POST | Trigger a workflow execution via API | +| `/api/workflows/{id}/schema` | GET | Get workflow input/output schema | +| `/schema` | GET | Get active workflow schema | +| `POST /` (any path) | POST | Run the active workflow | Add an HTTP Trigger node to your workflow to define the API input schema (each field becomes an output port), and optionally add an HTTP Response node to customize the response. Start the server from the workflow canvas — it listens on a configurable port (default `3100`) with CORS enabled. diff --git a/src/components/playground/ExplorePanel.tsx b/src/components/playground/ExplorePanel.tsx index 3c90a426..f3de8bae 100644 --- a/src/components/playground/ExplorePanel.tsx +++ b/src/components/playground/ExplorePanel.tsx @@ -261,7 +261,16 @@ export function ExplorePanel({ }: ExplorePanelProps) { const { t } = useTranslation(); const navigate = useNavigate(); - const { models, toggleFavorite, isFavorite, fetchModels, selectedType: typeFilter, setSelectedType: setTypeFilter, typeFiltersOpen, setTypeFiltersOpen } = useModelsStore(); + const { + models, + toggleFavorite, + isFavorite, + fetchModels, + selectedType: typeFilter, + setSelectedType: setTypeFilter, + typeFiltersOpen, + setTypeFiltersOpen, + } = useModelsStore(); const { createTab } = usePlaygroundStore(); const [showFavoritesOnly, setShowFavoritesOnly] = useState(false); const [isRefreshing, setIsRefreshing] = useState(false); diff --git a/src/types/model.ts b/src/types/model.ts index 0765e928..ca1b195f 100644 --- a/src/types/model.ts +++ b/src/types/model.ts @@ -31,7 +31,13 @@ export interface SchemaProperty { }>; // Extended UI hints step?: number; - "x-ui-component"?: "slider" | "uploader" | "uploaders" | "loras" | "select" | "array"; + "x-ui-component"?: + | "slider" + | "uploader" + | "uploaders" + | "loras" + | "select" + | "array"; "x-accept"?: string; "x-placeholder"?: string; "x-hidden"?: boolean; diff --git a/src/workflow/browser/node-definitions.ts b/src/workflow/browser/node-definitions.ts index e9ccbd2f..8a0d62dc 100644 --- a/src/workflow/browser/node-definitions.ts +++ b/src/workflow/browser/node-definitions.ts @@ -57,7 +57,6 @@ export const textInputDef: NodeTypeDefinition = { ], }; - // ─── AI Task ─────────────────────────────────────────────────────────────── export const aiTaskDef: NodeTypeDefinition = { type: "ai-task/run", diff --git a/src/workflow/browser/run-in-browser.ts b/src/workflow/browser/run-in-browser.ts index 83fc7a82..f6b4e709 100644 --- a/src/workflow/browser/run-in-browser.ts +++ b/src/workflow/browser/run-in-browser.ts @@ -501,10 +501,7 @@ export async function executeWorkflowInBrowser( } // Run the target node + all downstream; include upstream in the graph but skip executing them - const downstream = downstreamNodeIds( - effectiveContinueId, - simpleEdges, - ); + const downstream = downstreamNodeIds(effectiveContinueId, simpleEdges); const upstream = upstreamNodeIds(effectiveContinueId, simpleEdges); // The subgraph is upstream ∪ downstream so edges resolve correctly const subset = new Set([...upstream, ...downstream]); diff --git a/src/workflow/components/canvas/SubgraphBreadcrumb.tsx b/src/workflow/components/canvas/SubgraphBreadcrumb.tsx index 2294286c..2ec90b5e 100644 --- a/src/workflow/components/canvas/SubgraphBreadcrumb.tsx +++ b/src/workflow/components/canvas/SubgraphBreadcrumb.tsx @@ -70,7 +70,9 @@ export function SubgraphBreadcrumb() { title={t("workflow.exitSubgraph", "Exit subgraph (ESC)")} > - {t("workflow.back", "Back")} + + {t("workflow.back", "Back")} +
); diff --git a/src/workflow/components/canvas/WorkflowCanvas.tsx b/src/workflow/components/canvas/WorkflowCanvas.tsx index 895719d5..8200fb0d 100644 --- a/src/workflow/components/canvas/WorkflowCanvas.tsx +++ b/src/workflow/components/canvas/WorkflowCanvas.tsx @@ -127,7 +127,10 @@ function CanvasZoomControls() { // Check if any non-annotation node is currently expanded (not collapsed) const hasExpandedNodes = nodes.some( - (n) => n.type !== "annotation" && n.data?.nodeType !== "control/iterator" && !n.data?.params?.__nodeCollapsed, + (n) => + n.type !== "annotation" && + n.data?.nodeType !== "control/iterator" && + !n.data?.params?.__nodeCollapsed, ); const toggleAllCollapsed = useCallback(() => { @@ -424,7 +427,9 @@ export function WorkflowCanvas({ nodeDefs = [] }: WorkflowCanvasProps) { // Track virtual IO node positions across re-renders (they're not in the store). // Use state (not ref) so displayNodes memo recomputes when drag ends. - const [ioNodePositions, setIoNodePositions] = useState>({}); + const [ioNodePositions, setIoNodePositions] = useState< + Record + >({}); const lastEditingGroupIdRef = useRef(null); // Reset IO positions when entering a different group @@ -455,8 +460,13 @@ export function WorkflowCanvas({ nodeDefs = [] }: WorkflowCanvasProps) { setIoNodePositions(savedPositions); } else { // Compute initial positions from child bounding box - const childNodes = currentNodes.filter((n) => n.parentNode === editingGroupId); - let minX = 0, minY = 0, maxX = 400, maxY = 300; + const childNodes = currentNodes.filter( + (n) => n.parentNode === editingGroupId, + ); + let minX = 0, + minY = 0, + maxX = 400, + maxY = 300; if (childNodes.length > 0) { minX = Math.min(...childNodes.map((n) => n.position.x)); minY = Math.min(...childNodes.map((n) => n.position.y)); @@ -488,7 +498,11 @@ export function WorkflowCanvas({ nodeDefs = [] }: WorkflowCanvasProps) { let ioDragEnded = false; for (const c of changes) { const cid = (c as any).id as string | undefined; - if (cid && String(cid).startsWith("__group-") && c.type === "position") { + if ( + cid && + String(cid).startsWith("__group-") && + c.type === "position" + ) { const posChange = c as any; if (posChange.position) { ioUpdates[cid] = { ...posChange.position }; @@ -504,7 +518,9 @@ export function WorkflowCanvas({ nodeDefs = [] }: WorkflowCanvasProps) { const next = { ...prev, ...ioUpdates }; // Persist to group node params when drag ends if (ioDragEnded && editingGroupId) { - const groupNode = useWorkflowStore.getState().nodes.find((n) => n.id === editingGroupId); + const groupNode = useWorkflowStore + .getState() + .nodes.find((n) => n.id === editingGroupId); if (groupNode) { useWorkflowStore.getState().updateNodeParams(editingGroupId, { ...groupNode.data?.params, @@ -1122,14 +1138,16 @@ export function WorkflowCanvas({ nodeDefs = [] }: WorkflowCanvasProps) { const storeState = useWorkflowStore.getState(); const groupNode = storeState.nodes.find((n) => n.id === editGroupId); if (groupNode) { - storeState.onNodesChange([{ - type: "position" as const, - id: newNodeId, - position: { - x: position.x + groupNode.position.x, - y: position.y + groupNode.position.y, + storeState.onNodesChange([ + { + type: "position" as const, + id: newNodeId, + position: { + x: position.x + groupNode.position.x, + y: position.y + groupNode.position.y, + }, }, - }]); + ]); } } const { adoptNode } = useWorkflowStore.getState(); @@ -1325,14 +1343,16 @@ export function WorkflowCanvas({ nodeDefs = [] }: WorkflowCanvasProps) { const storeState = useWorkflowStore.getState(); const groupNode = storeState.nodes.find((n) => n.id === editGroupId); if (groupNode) { - storeState.onNodesChange([{ - type: "position" as const, - id: newNodeId, - position: { - x: position.x + groupNode.position.x, - y: position.y + groupNode.position.y, + storeState.onNodesChange([ + { + type: "position" as const, + id: newNodeId, + position: { + x: position.x + groupNode.position.x, + y: position.y + groupNode.position.y, + }, }, - }]); + ]); } } const { adoptNode: adopt } = useWorkflowStore.getState(); @@ -1627,7 +1647,10 @@ export function WorkflowCanvas({ nodeDefs = [] }: WorkflowCanvasProps) { // --- Drop from node palette (existing behaviour) --- if (nodeType) { // Prevent nesting Group inside Group - if (nodeType === "control/iterator" && useUIStore.getState().editingGroupId) { + if ( + nodeType === "control/iterator" && + useUIStore.getState().editingGroupId + ) { return; } // Only one trigger node per workflow @@ -1963,7 +1986,9 @@ export function WorkflowCanvas({ nodeDefs = [] }: WorkflowCanvasProps) { onNodesChange: applyChanges, } = useWorkflowStore.getState(); - const childNodes = currentNodes.filter((n) => n.parentNode === editGroupId); + const childNodes = currentNodes.filter( + (n) => n.parentNode === editGroupId, + ); if (childNodes.length === 0) return; const groupNode = currentNodes.find((n) => n.id === editGroupId); @@ -2030,11 +2055,16 @@ export function WorkflowCanvas({ nodeDefs = [] }: WorkflowCanvasProps) { // Measure node sizes from DOM const nodeSize = new Map(); for (const id of allIds) { - const el = document.querySelector(`[data-id="${id}"]`) as HTMLElement | null; + const el = document.querySelector( + `[data-id="${id}"]`, + ) as HTMLElement | null; if (el) { nodeSize.set(id, { w: el.offsetWidth, h: el.offsetHeight }); } else { - nodeSize.set(id, { w: id.startsWith("__group-") ? 260 : 380, h: 250 }); + nodeSize.set(id, { + w: id.startsWith("__group-") ? 260 : 380, + h: 250, + }); } } @@ -2060,9 +2090,10 @@ export function WorkflowCanvas({ nodeDefs = [] }: WorkflowCanvasProps) { if (visited.has(id)) return 0; visited.add(id); const parents = incoming.get(id) ?? []; - const depth = parents.length === 0 - ? 0 - : Math.max(...parents.map((p) => assignSubgraphLayer(p) + 1)); + const depth = + parents.length === 0 + ? 0 + : Math.max(...parents.map((p) => assignSubgraphLayer(p) + 1)); layer.set(id, depth); return depth; } @@ -2097,17 +2128,25 @@ export function WorkflowCanvas({ nodeDefs = [] }: WorkflowCanvasProps) { ids.forEach((id, i) => nodeOrder.set(id, i)); } for (let pass = 0; pass < 4; pass++) { - const keys = pass % 2 === 0 ? sortedLayerKeys : [...sortedLayerKeys].reverse(); + const keys = + pass % 2 === 0 ? sortedLayerKeys : [...sortedLayerKeys].reverse(); for (const l of keys) { const ids = layers.get(l)!; const bary = new Map(); for (const id of ids) { - const neighbors = pass % 2 === 0 - ? (incoming.get(id) ?? []) - : (outgoing.get(id) ?? []); - bary.set(id, neighbors.length > 0 - ? neighbors.reduce((sum, nid) => sum + (nodeOrder.get(nid) ?? 0), 0) / neighbors.length - : (nodeOrder.get(id) ?? 0)); + const neighbors = + pass % 2 === 0 + ? (incoming.get(id) ?? []) + : (outgoing.get(id) ?? []); + bary.set( + id, + neighbors.length > 0 + ? neighbors.reduce( + (sum, nid) => sum + (nodeOrder.get(nid) ?? 0), + 0, + ) / neighbors.length + : (nodeOrder.get(id) ?? 0), + ); } ids.sort((a, b) => (bary.get(a) ?? 0) - (bary.get(b) ?? 0)); ids.forEach((id, i) => nodeOrder.set(id, i)); @@ -2133,7 +2172,9 @@ export function WorkflowCanvas({ nodeDefs = [] }: WorkflowCanvasProps) { for (const l of sortedLayerKeys) { const ids = layers.get(l)!; const heights = ids.map((id) => nodeSize.get(id)?.h ?? 250); - const totalHeight = heights.reduce((sum, h) => sum + h, 0) + (ids.length - 1) * SG_V_GAP; + const totalHeight = + heights.reduce((sum, h) => sum + h, 0) + + (ids.length - 1) * SG_V_GAP; let y = -totalHeight / 2; ids.forEach((id, i) => { diff --git a/src/workflow/components/canvas/custom-node/CustomNode.tsx b/src/workflow/components/canvas/custom-node/CustomNode.tsx index 5a3bf781..8596e167 100644 --- a/src/workflow/components/canvas/custom-node/CustomNode.tsx +++ b/src/workflow/components/canvas/custom-node/CustomNode.tsx @@ -75,7 +75,9 @@ function CustomNodeComponent({ const openPreview = useUIStore((s) => s.openPreview); const allNodes = useWorkflowStore((s) => s.nodes); const allLastResults = useExecutionStore((s) => s.lastResults); - const allSelectedOutputIndex = useExecutionStore((s) => s.selectedOutputIndex); + const allSelectedOutputIndex = useExecutionStore( + (s) => s.selectedOutputIndex, + ); const [hovered, setHovered] = useState(false); const [segmentPointPickerOpen, setSegmentPointPickerOpen] = useState(false); const [resultsExpanded, setResultsExpanded] = useState(false); @@ -440,7 +442,15 @@ function CustomNodeComponent({ // Tell React Flow to re-measure handle positions when node size changes useEffect(() => { requestAnimationFrame(() => updateNodeInternals(id)); - }, [collapsed, resultsExpanded, resultGroups.length, schema.length, formFields.length, id, updateNodeInternals]); + }, [ + collapsed, + resultsExpanded, + resultGroups.length, + schema.length, + formFields.length, + id, + updateNodeInternals, + ]); const saveWorkflow = useWorkflowStore((s) => s.saveWorkflow); const removeNode = useWorkflowStore((s) => s.removeNode); diff --git a/src/workflow/components/canvas/custom-node/CustomNodeBody.tsx b/src/workflow/components/canvas/custom-node/CustomNodeBody.tsx index 69266193..13572285 100644 --- a/src/workflow/components/canvas/custom-node/CustomNodeBody.tsx +++ b/src/workflow/components/canvas/custom-node/CustomNodeBody.tsx @@ -1263,7 +1263,9 @@ export function CustomNodeBody(props: CustomNodeBodyProps) { if (fieldConfig) { if (!canConnect) { // Compact inline layout for file export's filename & format - const inlineParam = data.nodeType === "output/file" && (p.key === "filename" || p.key === "format"); + const inlineParam = + data.nodeType === "output/file" && + (p.key === "filename" || p.key === "format"); return (
{inlineParam ? (
- +
) : ( - {t("workflow.directoryImport.awaitingDirectory", "Awaiting directory")} + {t( + "workflow.directoryImport.awaitingDirectory", + "Awaiting directory", + )} )} diff --git a/src/workflow/components/canvas/custom-node/DynamicFieldsEditor.tsx b/src/workflow/components/canvas/custom-node/DynamicFieldsEditor.tsx index d173bf6f..92497ad8 100644 --- a/src/workflow/components/canvas/custom-node/DynamicFieldsEditor.tsx +++ b/src/workflow/components/canvas/custom-node/DynamicFieldsEditor.tsx @@ -91,8 +91,7 @@ export function DynamicFieldsEditor({ ? t("workflow.httpTriggerFields", "API Input Fields") : t("workflow.httpResponseFields", "API Response Fields"); - const options = - direction === "input" ? RESPONSE_TYPE_OPTIONS : TYPE_OPTIONS; + const options = direction === "input" ? RESPONSE_TYPE_OPTIONS : TYPE_OPTIONS; return (
e.stopPropagation()} >
- +
{ if (e.key === "Enter" || e.key === " ") addField(); }} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") addField(); + }} className="border-2 border-dashed rounded-lg px-4 py-1.5 cursor-pointer transition-all duration-200 flex items-center justify-center gap-1.5 hover:border-primary/50 hover:bg-muted/30 hover:shadow-sm min-h-[34px]" > @@ -136,9 +139,7 @@ export function DynamicFieldsEditor({ />