From 0e6baa8942fd88c2d5b92a5e43ec465072c85cdb Mon Sep 17 00:00:00 2001 From: Lightning Pixel Date: Thu, 9 Apr 2026 10:39:19 +0200 Subject: [PATCH 01/12] fix(workflow): fix workflow run --- api/runner.py | 12 +- api/services/generator_registry.py | 11 +- electron/main/ipc-handlers.ts | 2 + .../generate/components/WorkflowPanel.tsx | 30 ++-- src/areas/workflows/WorkflowsPage.tsx | 69 ++++++--- src/areas/workflows/mockExtensions.ts | 3 + src/areas/workflows/nodes/ExtensionNode.tsx | 131 +++++++++++++----- .../workflows/nodes/PreviewImageNode.tsx | 76 ++++++++++ src/areas/workflows/nodes/WorkflowEdge.tsx | 21 ++- src/areas/workflows/useWorkflowRunner.ts | 90 +++++++++--- src/areas/workflows/workflowRunStore.ts | 129 ++++++++++++----- src/shared/types/electron.d.ts | 1 + 12 files changed, 450 insertions(+), 125 deletions(-) create mode 100644 src/areas/workflows/nodes/PreviewImageNode.tsx diff --git a/api/runner.py b/api/runner.py index 43a040c..e7d46d3 100644 --- a/api/runner.py +++ b/api/runner.py @@ -109,8 +109,16 @@ def main() -> None: send({"type": "ready", "params_schema": schema}) # Support both flat manifest (legacy) and nodes[] format. - # Node-level fields take precedence; fall back to top-level for compatibility. - node = (manifest.get("nodes") or [{}])[0] + # Use MODEL_DIR to find the correct node for multi-node extensions: + # MODEL_DIR is set by ExtensionProcess to MODELS_DIR/ext_id/node_id, + # so its last component matches the node id. + nodes = manifest.get("nodes") or [] + node = {} + if nodes and _MODEL_DIR_OVERRIDE: + node_id = Path(_MODEL_DIR_OVERRIDE).name + node = next((n for n in nodes if n.get("id") == node_id), nodes[0]) + elif nodes: + node = nodes[0] # Use MODEL_DIR env var (set by ExtensionProcess) when available so the # generator uses the exact same path that is_downloaded() checks against. diff --git a/api/services/generator_registry.py b/api/services/generator_registry.py index 039db3b..de1f20c 100644 --- a/api/services/generator_registry.py +++ b/api/services/generator_registry.py @@ -227,11 +227,12 @@ def get_active(self) -> BaseGenerator: if not gen.is_loaded(): if not gen.is_downloaded(): if isinstance(gen, ExtensionProcess): - raise RuntimeError( - f"Model '{self._active_id}' is not downloaded. " - "Please install it from the Models page first." - ) - gen._auto_download() + # Let the subprocess handle its own download logic during + # load() — some extensions (e.g. mv-adapter) need custom + # multi-repo downloads that the standard HF endpoint can't do. + pass + else: + gen._auto_download() gen.load() return gen diff --git a/electron/main/ipc-handlers.ts b/electron/main/ipc-handlers.ts index 6cadd7d..2f58abb 100644 --- a/electron/main/ipc-handlers.ts +++ b/electron/main/ipc-handlers.ts @@ -510,6 +510,7 @@ export function setupIpcHandlers(pythonBridge: PythonBridge, getWindow: WindowGe id: string name?: string input?: 'mesh' | 'image' | 'text' + inputs?: ('mesh' | 'image' | 'text')[] output?: 'mesh' | 'image' | 'text' params_schema?: unknown[] hf_repo?: string @@ -534,6 +535,7 @@ export function setupIpcHandlers(pythonBridge: PythonBridge, getWindow: WindowGe id: n.id, name: n.name ?? n.id, input: n.input ?? 'image' as const, + inputs: n.inputs, output: n.output ?? 'mesh' as const, paramsSchema: n.params_schema ?? [], hfRepo: n.hf_repo, diff --git a/src/areas/generate/components/WorkflowPanel.tsx b/src/areas/generate/components/WorkflowPanel.tsx index 567719b..0f23770 100644 --- a/src/areas/generate/components/WorkflowPanel.tsx +++ b/src/areas/generate/components/WorkflowPanel.tsx @@ -420,20 +420,30 @@ function EmbeddedCanvas({ workflow, allExtensions }: { if (out) updateNodeData(out.id, { params: { outputUrl: runState.outputUrl } }) }, [runState.status, runState.outputUrl]) - // Type mismatch detection + // Type mismatch detection — edge-based to support multi-input nodes const typeMismatch = useMemo(() => { - const sorted = topoSortNodes(workflow.nodes, workflow.edges) - const extNodes = sorted.filter((n) => n.type === 'extensionNode') - // Determine initial type from the actual source node in the graph - const firstSource = sorted.find((n) => n.type === 'imageNode' || n.type === 'meshNode' || n.type === 'textNode') - let prev: string = firstSource?.type === 'meshNode' ? 'mesh' - : firstSource?.type === 'textNode' ? 'text' - : 'image' + // Build a map of what type each node produces + const nodeOutput = new Map() + for (const node of workflow.nodes) { + if (node.type === 'imageNode') { nodeOutput.set(node.id, 'image'); continue } + if (node.type === 'meshNode') { nodeOutput.set(node.id, 'mesh'); continue } + if (node.type === 'textNode') { nodeOutput.set(node.id, 'text'); continue } + if (node.type === 'extensionNode') { + const ext = getWorkflowExtension(node.data.extensionId ?? '', allExtensions) + if (ext) nodeOutput.set(node.id, ext.output) + } + } + // For each extension node, check that every incoming edge carries an accepted type + const extNodes = workflow.nodes.filter((n) => n.type === 'extensionNode') for (const node of extNodes) { const ext = getWorkflowExtension(node.data.extensionId ?? '', allExtensions) if (!ext) continue - if (prev !== ext.input) return true - prev = ext.output + const accepted = ext.inputs ?? [ext.input] + for (const edge of workflow.edges) { + if (edge.target !== node.id) continue + const srcType = nodeOutput.get(edge.source) + if (srcType && !accepted.includes(srcType as any)) return true + } } return false }, [workflow, allExtensions]) diff --git a/src/areas/workflows/WorkflowsPage.tsx b/src/areas/workflows/WorkflowsPage.tsx index 66440e2..e2575e6 100644 --- a/src/areas/workflows/WorkflowsPage.tsx +++ b/src/areas/workflows/WorkflowsPage.tsx @@ -21,18 +21,19 @@ import type { Workflow, WFNode, WFEdge, WFNodeData } from '@shared/types/electro import { buildAllWorkflowExtensions, getWorkflowExtension } from './mockExtensions' import type { WorkflowExtension } from './mockExtensions' import { useWorkflowRunStore } from './workflowRunStore' -import ExtensionNode from './nodes/ExtensionNode' -import ImageNode from './nodes/ImageNode' -import TextNode from './nodes/TextNode' -import AddToSceneNode from './nodes/AddToSceneNode' -import Load3DMeshNode from './nodes/Load3DMeshNode' -import WorkflowEdge from './nodes/WorkflowEdge' +import ExtensionNode from './nodes/ExtensionNode' +import ImageNode from './nodes/ImageNode' +import TextNode from './nodes/TextNode' +import AddToSceneNode from './nodes/AddToSceneNode' +import Load3DMeshNode from './nodes/Load3DMeshNode' +import PreviewImageNode from './nodes/PreviewImageNode' +import WorkflowEdge from './nodes/WorkflowEdge' // ─── Constants ──────────────────────────────────────────────────────────────── const DRAG_KEY = 'modly/extension-id' const DRAG_NODE_KEY = 'modly/node-type' -const NODE_TYPES = { extensionNode: ExtensionNode, imageNode: ImageNode, textNode: TextNode, outputNode: AddToSceneNode, meshNode: Load3DMeshNode } +const NODE_TYPES = { extensionNode: ExtensionNode, imageNode: ImageNode, textNode: TextNode, outputNode: AddToSceneNode, meshNode: Load3DMeshNode, previewNode: PreviewImageNode } const EDGE_TYPES = { workflowEdge: WorkflowEdge } const DEFAULT_EDGE_OPTS = { type: 'workflowEdge' } @@ -149,10 +150,11 @@ const PANEL_MIN = 240 const PANEL_MAX = 860 const PANEL_BUILTIN_NODES = [ - { type: 'imageNode', label: 'Image', color: '#38bdf8', icon: <> }, - { type: 'textNode', label: 'Text', color: '#fbbf24', icon: <> }, - { type: 'meshNode', label: 'Load 3D Mesh', color: '#a78bfa', icon: <> }, - { type: 'outputNode', label: 'Add to Scene', color: '#a78bfa', icon: <> }, + { type: 'imageNode', label: 'Image', color: '#38bdf8', icon: <> }, + { type: 'textNode', label: 'Text', color: '#fbbf24', icon: <> }, + { type: 'meshNode', label: 'Load 3D Mesh', color: '#a78bfa', icon: <> }, + { type: 'outputNode', label: 'Add to Scene', color: '#a78bfa', icon: <> }, + { type: 'previewNode', label: 'Preview Views', color: '#38bdf8', icon: <> }, ] function ExtGroupHeader({ title, author, expanded, onToggle, count }: { title: string; author?: string; expanded: boolean; onToggle: () => void; count: number }) { @@ -391,10 +393,11 @@ function PanelToggleIcon({ open }: { open: boolean }) { // ─── Node palette (Space to open) ──────────────────────────────────────────── const BUILTIN_NODES = [ - { type: 'imageNode', label: 'Image', color: '#38bdf8', description: 'Image input' }, - { type: 'textNode', label: 'Text', color: '#fbbf24', description: 'Text input' }, - { type: 'meshNode', label: 'Load 3D Mesh', color: '#a78bfa', description: 'Load a 3D mesh file or use current model' }, - { type: 'outputNode', label: 'Add to Scene', color: '#a78bfa', description: 'Output node' }, + { type: 'imageNode', label: 'Image', color: '#38bdf8', description: 'Image input' }, + { type: 'textNode', label: 'Text', color: '#fbbf24', description: 'Text input' }, + { type: 'meshNode', label: 'Load 3D Mesh', color: '#a78bfa', description: 'Load a 3D mesh file or use current model' }, + { type: 'outputNode', label: 'Add to Scene', color: '#a78bfa', description: 'Output node — adds the mesh to the 3D scene' }, + { type: 'previewNode', label: 'Preview Views', color: '#38bdf8', description: 'Displays multi-view image outputs in a 2×3 grid' }, ] type PaletteItem = @@ -732,6 +735,32 @@ function HelpModal({ onClose }: { onClose: () => void }) { ) } +// ─── Connection type helpers ────────────────────────────────────────────────── + +function getNodeOutputType(node: Node | undefined, allExts: WorkflowExtension[]): string | undefined { + if (!node) return undefined + if (node.type === 'imageNode') return 'image' + if (node.type === 'meshNode') return 'mesh' + if (node.type === 'textNode') return 'text' + return allExts.find((e) => e.id === (node.data as WFNodeData)?.extensionId)?.output +} + +function getNodeInputType( + node: Node | undefined, + targetHandle: string | null | undefined, + allExts: WorkflowExtension[], +): string | undefined { + if (!node) return undefined + if (node.type === 'outputNode') return 'mesh' + if (node.type === 'previewNode') return 'image' + const ext = allExts.find((e) => e.id === (node.data as WFNodeData)?.extensionId) + if (ext?.inputs && ext.inputs.length > 1 && targetHandle) { + const idx = parseInt(targetHandle.replace('input-', ''), 10) + return ext.inputs[isNaN(idx) ? 0 : idx] ?? ext.input + } + return ext?.input +} + // ─── Workflow canvas (inner, requires ReactFlowProvider) ────────────────────── function WorkflowCanvasInner({ @@ -747,7 +776,7 @@ function WorkflowCanvasInner({ onNew: () => void onImport: () => void }) { - const { screenToFlowPosition, updateNodeData } = useReactFlow() + const { screenToFlowPosition, updateNodeData, getNode } = useReactFlow() const { runState, run: runWorkflow, cancel } = useWorkflowRunStore() const isRunning = runState.status === 'running' @@ -838,6 +867,13 @@ function WorkflowCanvasInner({ const canUndo = histIdx > 0 const canRedo = histIdx < historyRef.current.length - 1 + const isValidConnection = useCallback((connection: Connection) => { + const srcType = getNodeOutputType(getNode(connection.source) as Node, allExtensions) + const tgtType = getNodeInputType(getNode(connection.target) as Node, connection.targetHandle, allExtensions) + if (!srcType || !tgtType) return true // unknown type — allow + return srcType === tgtType + }, [getNode, allExtensions]) + const onConnectStart = useCallback((_: React.MouseEvent | React.TouchEvent, params: OnConnectStartParams) => { pendingConnectionRef.current = params connectionCompletedRef.current = false @@ -1133,6 +1169,7 @@ function WorkflowCanvasInner({ onEdgesChange={onEdgesChange} onConnectStart={onConnectStart} onConnect={onConnect} + isValidConnection={isValidConnection} onConnectEnd={onConnectEnd} onEdgeContextMenu={(e, edge) => { e.preventDefault(); setEdges((eds) => eds.filter((ed) => ed.id !== edge.id)) }} defaultEdgeOptions={DEFAULT_EDGE_OPTS} diff --git a/src/areas/workflows/mockExtensions.ts b/src/areas/workflows/mockExtensions.ts index 791f0ff..76e757f 100644 --- a/src/areas/workflows/mockExtensions.ts +++ b/src/areas/workflows/mockExtensions.ts @@ -11,6 +11,7 @@ export interface WorkflowExtension { name: string description: string input: 'image' | 'text' | 'mesh' + inputs?: ('image' | 'text' | 'mesh')[] // multi-input; overrides input when set output: 'image' | 'text' | 'mesh' params: ParamSchema[] builtin: boolean @@ -34,6 +35,7 @@ export function buildAllWorkflowExtensions( name: node.name, description: ext.description ?? '', input: node.input, + inputs: node.inputs, output: node.output, params: node.paramsSchema as ParamSchema[], builtin: ext.builtin, @@ -53,6 +55,7 @@ export function buildAllWorkflowExtensions( name: node.name, description: ext.description ?? '', input: node.input, + inputs: node.inputs, output: node.output, params: node.paramsSchema as ParamSchema[], builtin: ext.builtin, diff --git a/src/areas/workflows/nodes/ExtensionNode.tsx b/src/areas/workflows/nodes/ExtensionNode.tsx index f386989..8334089 100644 --- a/src/areas/workflows/nodes/ExtensionNode.tsx +++ b/src/areas/workflows/nodes/ExtensionNode.tsx @@ -119,30 +119,114 @@ function ParamControl({ param, value, onChange }: { export default function ExtensionNode({ id, data, selected }: { id: string; data: WFNodeData; selected?: boolean }) { const { updateNodeData } = useReactFlow() const running = useWorkflowRunStore((s) => s.activeNodeId === id) - const ioRowRef = useRef(null) - const [handleTop, setHandleTop] = useState('50%') - // Align handles with the IO row - useLayoutEffect(() => { - if (ioRowRef.current) { - const center = ioRowRef.current.offsetTop + ioRowRef.current.offsetHeight / 2 - setHandleTop(`${center}px`) - } - }, []) + // Refs for handle alignment — support up to 2 inputs + const ioRowRef = useRef(null) + const ioRow2Ref = useRef(null) + const [handleTop, setHandleTop] = useState('50%') + const [handle2Top, setHandle2Top] = useState('50%') const { modelExtensions, processExtensions } = useExtensionsStore() const ext = buildAllWorkflowExtensions(modelExtensions, processExtensions) .find((e) => e.id === data.extensionId) + const inputs = ext?.inputs // defined → multi-input mode + const isMulti = inputs && inputs.length > 1 const isTerminal = ext?.id === 'mesh-exporter' - const inputColor = HANDLE_COLOR[ext?.input ?? 'image'] const outputColor = HANDLE_COLOR[ext?.output ?? 'mesh'] const hasParams = (ext?.params.length ?? 0) > 0 + // Align handles with their respective IO rows after mount + useLayoutEffect(() => { + if (ioRowRef.current) { + const center = ioRowRef.current.offsetTop + ioRowRef.current.offsetHeight / 2 + setHandleTop(`${center}px`) + } + if (ioRow2Ref.current) { + const center = ioRow2Ref.current.offsetTop + ioRow2Ref.current.offsetHeight / 2 + setHandle2Top(`${center}px`) + } + }, [isMulti]) + const patchParam = useCallback((key: string, val: number | string) => { updateNodeData(id, { params: { ...data.params, [key]: val } }) }, [id, data.params, updateNodeData]) + // ── IO subheader ───────────────────────────────────────────────────────── + const ioSubheader = isMulti ? ( + // Multi-input layout: one row per input, output on first row +
+
+ + {inputs[0]} + + {!isTerminal && ( + <> + + + + + {ext?.output ?? '—'} + + + )} +
+
+ + {inputs[1]} + +
+
+ ) : ( + // Single-input layout (existing behavior) +
+ + {ext?.input ?? '—'} + + {!isTerminal && ( + <> + + + + + {ext?.output ?? '—'} + + + )} +
+ ) + + // ── Handles ────────────────────────────────────────────────────────────── + const handlesEl = ( + <> + {/* Primary input handle */} + + {/* Secondary input handle (multi-input only) */} + {isMulti && ( + + )} + {/* Output handle */} + {!isTerminal && ( + + )} + + ) + return ( - - {ext?.input ?? '—'} - - {!isTerminal && ( - <> - - - - - {ext?.output ?? '—'} - - - )} - - } - handles={<> - - {!isTerminal && ( - - )} - } + subheader={ioSubheader} + handles={handlesEl} > {hasParams && (
diff --git a/src/areas/workflows/nodes/PreviewImageNode.tsx b/src/areas/workflows/nodes/PreviewImageNode.tsx new file mode 100644 index 0000000..7d7cd46 --- /dev/null +++ b/src/areas/workflows/nodes/PreviewImageNode.tsx @@ -0,0 +1,76 @@ +import { Handle, Position, useReactFlow } from '@xyflow/react' +import { useWorkflowRunStore } from '../workflowRunStore' +import BaseNode from './BaseNode' + +const INPUT_COLOR = '#38bdf8' + +/** + * Preview node for multi-view image outputs (e.g. MV-Adapter Generate Views). + * + * Expects a vertical strip PNG where N views are stacked top→bottom. + * Displays them in a 2×3 grid using CSS background-position cropping. + */ +export default function PreviewImageNode({ id, selected }: { id: string; selected?: boolean }) { + const nodeImageOutputs = useWorkflowRunStore((s) => s.nodeImageOutputs) + const { getEdges } = useReactFlow() + + // Find the image URL fed into this node (first matching incoming edge) + const incomingEdge = getEdges().find((e) => e.target === id) + const imageUrl = incomingEdge ? nodeImageOutputs[incomingEdge.source] : undefined + + return ( + + + + + + } + subheader={ +
+ image + → preview +
+ } + handles={ + + } + > +
+ {imageUrl ? ( +
+ {[0, 1, 2, 3, 4, 5].map((i) => ( +
+ ))} +
+ ) : ( +

+ Connect a multi-view image to preview. +

+ )} +
+ + ) +} diff --git a/src/areas/workflows/nodes/WorkflowEdge.tsx b/src/areas/workflows/nodes/WorkflowEdge.tsx index ddb5236..02a4052 100644 --- a/src/areas/workflows/nodes/WorkflowEdge.tsx +++ b/src/areas/workflows/nodes/WorkflowEdge.tsx @@ -1,4 +1,4 @@ -import { getBezierPath, useReactFlow } from '@xyflow/react' +import { getBezierPath, useReactFlow, useEdges } from '@xyflow/react' import type { EdgeProps } from '@xyflow/react' import { useExtensionsStore } from '@shared/stores/extensionsStore' import { buildAllWorkflowExtensions } from '../mockExtensions' @@ -15,12 +15,17 @@ export default function WorkflowEdge({ sourcePosition, targetPosition, }: EdgeProps) { const { getNode } = useReactFlow() + const edges = useEdges() const { modelExtensions, processExtensions } = useExtensionsStore() const allExtensions = buildAllWorkflowExtensions(modelExtensions, processExtensions) const sourceNode = getNode(source) const targetNode = getNode(target) + // Read targetHandle directly from edge store — reliable regardless of EdgeProps version + const thisEdge = edges.find((e) => e.id === id) + const targetHandle = thisEdge?.targetHandle + const sourceColor = sourceNode?.type === 'imageNode' ? HANDLE_COLOR.image : sourceNode?.type === 'textNode' @@ -29,9 +34,21 @@ export default function WorkflowEdge({ ? HANDLE_COLOR.mesh : (HANDLE_COLOR[allExtensions.find((e) => e.id === sourceNode?.data?.extensionId)?.output ?? ''] ?? '#52525b') + // For multi-input nodes pick the color of the specific connected handle + const targetExt = allExtensions.find((e) => e.id === targetNode?.data?.extensionId) + const targetInputType = (() => { + if (targetExt?.inputs && targetExt.inputs.length > 1 && targetHandle) { + const idx = parseInt(targetHandle.replace('input-', ''), 10) + return targetExt.inputs[isNaN(idx) ? 0 : idx] ?? targetExt.input + } + return targetExt?.input + })() + const targetColor = targetNode?.type === 'outputNode' ? HANDLE_COLOR.mesh - : (HANDLE_COLOR[allExtensions.find((e) => e.id === targetNode?.data?.extensionId)?.input ?? ''] ?? '#52525b') + : targetNode?.type === 'previewNode' + ? HANDLE_COLOR.image + : (HANDLE_COLOR[targetInputType ?? ''] ?? '#52525b') const [edgePath] = getBezierPath({ sourceX, sourceY, sourcePosition, targetX, targetY, targetPosition }) const gradientId = `wf-edge-${id}` diff --git a/src/areas/workflows/useWorkflowRunner.ts b/src/areas/workflows/useWorkflowRunner.ts index a6c5cb8..5789e9a 100644 --- a/src/areas/workflows/useWorkflowRunner.ts +++ b/src/areas/workflows/useWorkflowRunner.ts @@ -80,23 +80,31 @@ export function useWorkflowRunner(allExtensions: WorkflowExtension[]) { const settings = await window.electron.settings.get() const workspaceDir = settings.workspaceDir.replace(/\\/g, '/') - // Track outputs per node so branches each get the correct predecessor output - const nodeOutputs = new Map() + // Track outputs per node so branches each get the correct predecessor output. + // outputType distinguishes image files from mesh files for multi-input routing. + const nodeOutputs = new Map() // Pre-populate source nodes for (const node of ordered) { - if (node.type === 'imageNode') nodeOutputs.set(node.id, { filePath: imagePath }) + if (node.type === 'imageNode') nodeOutputs.set(node.id, { filePath: imagePath, outputType: 'image' }) if (node.type === 'textNode') nodeOutputs.set(node.id, { text: node.data.params?.text as string | undefined }) if (node.type === 'meshNode') { const source = node.data.params?.source as 'file' | 'current' | undefined if (source === 'current') { if (currentMeshUrl) { - const rel = currentMeshUrl.replace(/^\/workspace\//, '') - nodeOutputs.set(node.id, { filePath: `${workspaceDir}/${rel}` }) + let meshFilePath: string + if (currentMeshUrl.includes('serve-file?path=')) { + const encoded = currentMeshUrl.split('serve-file?path=')[1] + meshFilePath = decodeURIComponent(encoded).replace(/\\/g, '/') + } else { + const rel = currentMeshUrl.replace(/^\/workspace\//, '') + meshFilePath = `${workspaceDir}/${rel}` + } + nodeOutputs.set(node.id, { filePath: meshFilePath, outputType: 'mesh' }) } } else { const fp = node.data.params?.filePath as string | undefined - if (fp) nodeOutputs.set(node.id, { filePath: fp }) + if (fp) nodeOutputs.set(node.id, { filePath: fp, outputType: 'mesh' }) } } } @@ -107,29 +115,63 @@ export function useWorkflowRunner(allExtensions: WorkflowExtension[]) { const node = execNodes[i] const ext = getWorkflowExtension(node.data.extensionId ?? '', allExtensions) - // Resolve this node's input from its actual predecessors in the graph + // Resolve this node's inputs from its actual predecessors in the graph. + // For multi-input nodes, route by outputType (image vs mesh). let nodeInputPath: string | undefined let nodeInputText: string | undefined - for (const edge of workflow.edges.filter((e) => e.target === node.id)) { - const src = nodeOutputs.get(edge.source) - if (src?.filePath !== undefined) nodeInputPath = src.filePath - if (src?.text !== undefined) nodeInputText = src.text - } - // Fallback: if no edge supplied a file/text, use the previous node's output - if (nodeInputPath === undefined && nodeInputText === undefined && i > 0) { - const prev = nodeOutputs.get(execNodes[i - 1].id) - if (prev?.filePath !== undefined) nodeInputPath = prev.filePath - if (prev?.text !== undefined) nodeInputText = prev.text + let nodeInputMeshPath: string | undefined // for multi-input: the mesh wire + + const incomingEdges = workflow.edges.filter((e) => e.target === node.id) + if (ext?.inputs && ext.inputs.length > 1) { + // Multi-input: route each edge by the source node's outputType + for (const edge of incomingEdges) { + const src = nodeOutputs.get(edge.source) + if (!src) continue + if (src.outputType === 'mesh') nodeInputMeshPath = src.filePath + else if (src.outputType === 'image') nodeInputPath = src.filePath + else if (src.filePath !== undefined) nodeInputPath = src.filePath + if (src.text !== undefined) nodeInputText = src.text + } + } else { + // Single-input: original behaviour + for (const edge of incomingEdges) { + const src = nodeOutputs.get(edge.source) + if (src?.filePath !== undefined) nodeInputPath = src.filePath + if (src?.text !== undefined) nodeInputText = src.text + } + // Fallback: if no edge supplied a file/text, use the previous node's output + if (nodeInputPath === undefined && nodeInputText === undefined && i > 0) { + const prev = nodeOutputs.get(execNodes[i - 1].id) + if (prev?.filePath !== undefined) nodeInputPath = prev.filePath + if (prev?.text !== undefined) nodeInputText = prev.text + } } setRunState((s) => ({ ...s, blockIndex: i, blockProgress: 0, blockStep: 'Starting…' })) - if (ext?.input === 'image' && ext?.output === 'mesh') { + // Model extensions always go through the HTTP API (job queue, progress, GPU). + // Process extensions always go through IPC runProcess (CPU, synchronous). + const isGeneratorNode = ext?.type === 'model' + + if (isGeneratorNode) { // ── Generator: call Python FastAPI ────────────────────────────────── - const base64 = imageData ?? await window.electron.fs.readFileBase64(imagePath) + // For multi-input nodes, use the resolved image path (not the global imagePath) + const activeImagePath = nodeInputPath ?? imagePath + const base64 = imageData && nodeInputPath === undefined + ? imageData + : await window.electron.fs.readFileBase64(activeImagePath) const bytes = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0)) const blob = new Blob([bytes], { type: 'image/png' }) - const fname = imagePath.split(/[\\/]/).pop() ?? 'image.png' + const fname = activeImagePath.split(/[\\/]/).pop() ?? 'image.png' + + // For multi-input nodes: inject the mesh input as params.mesh_path + const extraParams: Record = {} + if (nodeInputMeshPath) { + const norm = nodeInputMeshPath.replace(/\\/g, '/') + extraParams.mesh_path = norm.startsWith(workspaceDir) + ? norm.slice(workspaceDir.length).replace(/^\//, '') + : norm + } const fd = new FormData() fd.append('image', blob, fname) @@ -138,7 +180,7 @@ export function useWorkflowRunner(allExtensions: WorkflowExtension[]) { fd.append('remesh', 'none') fd.append('enable_texture', 'false') fd.append('texture_resolution', '1024') - fd.append('params', JSON.stringify(node.data.params)) + fd.append('params', JSON.stringify({ ...node.data.params, ...extraParams })) setRunState((s) => ({ ...s, blockProgress: 5, blockStep: 'Submitting to model…' })) @@ -195,8 +237,10 @@ export function useWorkflowRunner(allExtensions: WorkflowExtension[]) { setRunState((s) => ({ ...s, blockProgress: 100, blockStep: 'Done' })) } - // Store this node's output so downstream nodes (including other branches) can read it - nodeOutputs.set(node.id, { filePath: nodeInputPath, text: nodeInputText }) + // Store this node's output so downstream nodes (including other branches) can read it. + // Tag with outputType so multi-input nodes can route by type. + const outputType = ext?.output ?? (nodeInputPath ? 'mesh' : undefined) + nodeOutputs.set(node.id, { filePath: nodeInputPath, text: nodeInputText, outputType }) } // Determine outputUrl: prefer what feeds the outputNode (Add to Scene) diff --git a/src/areas/workflows/workflowRunStore.ts b/src/areas/workflows/workflowRunStore.ts index 1930907..44b8c3e 100644 --- a/src/areas/workflows/workflowRunStore.ts +++ b/src/areas/workflows/workflowRunStore.ts @@ -57,6 +57,8 @@ interface WorkflowRunStore { runState: WorkflowRunState activeNodeId: string | null activeWorkflowId: string | null + /** nodeId → workspace URL for image outputs (populated after each run) */ + nodeImageOutputs: Record run: (workflow: Workflow, allExtensions: WorkflowExtension[]) => Promise cancel: () => void @@ -67,6 +69,7 @@ export const useWorkflowRunStore = create((set) => ({ runState: IDLE, activeNodeId: null, activeWorkflowId: null, + nodeImageOutputs: {}, async run(workflow, allExtensions) { _cancel.current = false @@ -76,13 +79,13 @@ export const useWorkflowRunStore = create((set) => ({ const ordered = topoSort(workflow.nodes, workflow.edges) const execNodes = ordered.filter((n) => n.type === 'extensionNode' && n.data.enabled) - // Capture before setCurrentJob overwrites currentJob const selectedImagePath = appState.selectedImagePath ?? '' const selectedImageData = appState.selectedImageData ?? undefined const currentMeshUrl = appState.currentJob?.outputUrl set({ activeWorkflowId: workflow.id, + nodeImageOutputs: {}, runState: { status: 'running', blockIndex: 0, blockTotal: execNodes.length, blockProgress: 0, blockStep: 'Starting…' }, }) @@ -95,17 +98,22 @@ export const useWorkflowRunStore = create((set) => ({ }) try { - const client = axios.create({ baseURL: apiUrl }) - const settings = await window.electron.settings.get() + const client = axios.create({ baseURL: apiUrl }) + const settings = await window.electron.settings.get() const workspaceDir = settings.workspaceDir.replace(/\\/g, '/') - const nodeOutputs = new Map() + // Clean up tmp folder from previous run + const tmpAbsPath = settings.workspaceDir.replace(/[\\/]+$/, '') + '/tmp' + window.electron.fs.deleteDirectory(tmpAbsPath).catch(() => {}) + + // nodeId → { filePath, text, outputType } + const nodeOutputs = new Map() // Pre-populate source nodes for (const node of ordered) { if (node.type === 'imageNode') { const fp = node.data.params?.filePath as string | undefined - nodeOutputs.set(node.id, { filePath: fp ?? selectedImagePath }) + nodeOutputs.set(node.id, { filePath: fp ?? selectedImagePath, outputType: 'image' }) } if (node.type === 'textNode') { nodeOutputs.set(node.id, { text: node.data.params?.text as string | undefined }) @@ -113,11 +121,20 @@ export const useWorkflowRunStore = create((set) => ({ if (node.type === 'meshNode') { const source = node.data.params?.source as 'file' | 'current' | undefined if (source === 'current' && currentMeshUrl) { - const rel = currentMeshUrl.replace(/^\/workspace\//, '') - nodeOutputs.set(node.id, { filePath: `${workspaceDir}/${rel}` }) + let meshFilePath: string + if (currentMeshUrl.includes('serve-file?path=')) { + // URL like /optimize/serve-file?path=D%3A%5C... → extract and decode the real path + const encoded = currentMeshUrl.split('serve-file?path=')[1] + meshFilePath = decodeURIComponent(encoded).replace(/\\/g, '/') + } else { + // URL like /workspace/Workflows/file.glb → resolve to absolute path + const rel = currentMeshUrl.replace(/^\/workspace\//, '') + meshFilePath = `${workspaceDir}/${rel}` + } + nodeOutputs.set(node.id, { filePath: meshFilePath, outputType: 'mesh' }) } else { const fp = node.data.params?.filePath as string | undefined - if (fp) nodeOutputs.set(node.id, { filePath: fp }) + if (fp) nodeOutputs.set(node.id, { filePath: fp, outputType: 'mesh' }) } } } @@ -128,18 +145,36 @@ export const useWorkflowRunStore = create((set) => ({ const node = execNodes[i] const ext = getWorkflowExtension(node.data.extensionId ?? '', allExtensions) - let nodeInputPath: string | undefined - let nodeInputText: string | undefined - for (const edge of workflow.edges.filter((e) => e.target === node.id)) { - const src = nodeOutputs.get(edge.source) - if (src?.filePath !== undefined) nodeInputPath = src.filePath - if (src?.text !== undefined) nodeInputText = src.text - } - // Fallback: if no edge supplied a file/text, use the previous node's output - if (nodeInputPath === undefined && nodeInputText === undefined && i > 0) { - const prev = nodeOutputs.get(execNodes[i - 1].id) - if (prev?.filePath !== undefined) nodeInputPath = prev.filePath - if (prev?.text !== undefined) nodeInputText = prev.text + // ── Resolve inputs ──────────────────────────────────────────────── + let nodeInputPath: string | undefined + let nodeInputText: string | undefined + let nodeInputMeshPath: string | undefined + + const incomingEdges = workflow.edges.filter((e) => e.target === node.id) + + if (ext?.inputs && ext.inputs.length > 1) { + // Multi-input: route each incoming edge by the source node's outputType + for (const edge of incomingEdges) { + const src = nodeOutputs.get(edge.source) + if (!src) continue + if (src.outputType === 'mesh') nodeInputMeshPath = src.filePath + else if (src.outputType === 'image') nodeInputPath = src.filePath + else if (src.filePath !== undefined) nodeInputPath = src.filePath + if (src.text !== undefined) nodeInputText = src.text + } + } else { + // Single-input + for (const edge of incomingEdges) { + const src = nodeOutputs.get(edge.source) + if (src?.filePath !== undefined) nodeInputPath = src.filePath + if (src?.text !== undefined) nodeInputText = src.text + } + // Fallback to previous node's output + if (nodeInputPath === undefined && nodeInputText === undefined && i > 0) { + const prev = nodeOutputs.get(execNodes[i - 1].id) + if (prev?.filePath !== undefined) nodeInputPath = prev.filePath + if (prev?.text !== undefined) nodeInputText = prev.text + } } set((s) => ({ @@ -147,12 +182,27 @@ export const useWorkflowRunStore = create((set) => ({ runState: { ...s.runState, blockIndex: i, blockProgress: 0, blockStep: 'Starting…' }, })) - if (ext?.input === 'image' && ext?.output === 'mesh') { - const imagePath = nodeInputPath ?? selectedImagePath - const base64 = selectedImageData ?? await window.electron.fs.readFileBase64(imagePath) - const bytes = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0)) - const blob = new Blob([bytes], { type: 'image/png' }) - const fname = imagePath.split(/[\\/]/).pop() ?? 'image.png' + // ── Model extensions → HTTP API ─────────────────────────────────── + // Process extensions → IPC runProcess + const isModelNode = ext?.type === 'model' + + if (isModelNode) { + const activeImagePath = nodeInputPath ?? selectedImagePath + const base64 = selectedImageData && nodeInputPath === undefined + ? selectedImageData + : await window.electron.fs.readFileBase64(activeImagePath) + const bytes = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0)) + const blob = new Blob([bytes], { type: 'image/png' }) + const fname = activeImagePath.split(/[\\/]/).pop() ?? 'image.png' + + // For multi-input nodes: inject mesh path as params.mesh_path + const extraParams: Record = {} + if (nodeInputMeshPath) { + const norm = nodeInputMeshPath.replace(/\\/g, '/') + extraParams.mesh_path = norm.startsWith(workspaceDir) + ? norm.slice(workspaceDir.length).replace(/^\//, '') + : norm + } const fd = new FormData() fd.append('image', blob, fname) @@ -161,7 +211,7 @@ export const useWorkflowRunStore = create((set) => ({ fd.append('remesh', 'none') fd.append('enable_texture', 'false') fd.append('texture_resolution', '1024') - fd.append('params', JSON.stringify(node.data.params)) + fd.append('params', JSON.stringify({ ...node.data.params, ...extraParams })) set((s) => ({ runState: { ...s.runState, blockProgress: 5, blockStep: 'Submitting to model…' } })) @@ -204,6 +254,7 @@ export const useWorkflowRunStore = create((set) => ({ } } else { + // ── Process extension → IPC ───────────────────────────────────── const parts = (node.data.extensionId ?? '').split('/') const extId = parts[0] const nodeId = parts[1] ?? '' @@ -218,10 +269,23 @@ export const useWorkflowRunStore = create((set) => ({ set((s) => ({ runState: { ...s.runState, blockProgress: 100, blockStep: 'Done' } })) } - nodeOutputs.set(node.id, { filePath: nodeInputPath, text: nodeInputText }) + // Store output with type for downstream routing + const outputType = ext?.output ?? (nodeInputPath ? 'mesh' : undefined) + nodeOutputs.set(node.id, { filePath: nodeInputPath, text: nodeInputText, outputType }) + } + + // ── Collect image outputs for preview nodes ─────────────────────── + const imageOutputs: Record = {} + for (const [nodeId, out] of nodeOutputs) { + if (out.outputType === 'image' && out.filePath) { + const norm = out.filePath.replace(/\\/g, '/') + if (norm.startsWith(workspaceDir)) { + imageOutputs[nodeId] = `/workspace/${norm.slice(workspaceDir.length).replace(/^\//, '')}` + } + } } - // Resolve output URL + // ── Resolve final output URL ────────────────────────────────────── let outputUrl: string | undefined let outputPath: string | undefined @@ -252,7 +316,8 @@ export const useWorkflowRunStore = create((set) => ({ } set({ - activeNodeId: null, + activeNodeId: null, + nodeImageOutputs: imageOutputs, runState: { status: 'done', blockIndex: execNodes.length > 0 ? execNodes.length - 1 : 0, @@ -280,10 +345,10 @@ export const useWorkflowRunStore = create((set) => ({ axios.create({ baseURL: apiUrl }).post(`/generate/cancel/${_activeJobId.current}`).catch(() => {}) _activeJobId.current = null } - set({ runState: IDLE, activeNodeId: null, activeWorkflowId: null }) + set({ runState: IDLE, activeNodeId: null, activeWorkflowId: null, nodeImageOutputs: {} }) }, reset() { - set({ runState: IDLE, activeNodeId: null, activeWorkflowId: null }) + set({ runState: IDLE, activeNodeId: null, activeWorkflowId: null, nodeImageOutputs: {} }) }, })) diff --git a/src/shared/types/electron.d.ts b/src/shared/types/electron.d.ts index 259e12c..4977331 100644 --- a/src/shared/types/electron.d.ts +++ b/src/shared/types/electron.d.ts @@ -7,6 +7,7 @@ export interface ExtensionNode { id: string name: string input: 'image' | 'text' | 'mesh' + inputs?: ('image' | 'text' | 'mesh')[] // multi-input nodes; overrides input when set output: 'image' | 'text' | 'mesh' paramsSchema: ParamSchema[] hfRepo?: string From a7f9d11d66e4cf7ef00f518afe6c6e01880d4a9c Mon Sep 17 00:00:00 2001 From: Lightning Pixel Date: Thu, 9 Apr 2026 10:52:27 +0200 Subject: [PATCH 02/12] feature(generate): add light settings --- src/areas/generate/GeneratePage.tsx | 125 ++++++++++++++++++++- src/areas/generate/components/Viewer3D.tsx | 10 +- src/shared/components/ui/ColorPicker.tsx | 45 ++++++++ src/shared/components/ui/index.ts | 1 + 4 files changed, 175 insertions(+), 6 deletions(-) create mode 100644 src/shared/components/ui/ColorPicker.tsx diff --git a/src/areas/generate/GeneratePage.tsx b/src/areas/generate/GeneratePage.tsx index d4889aa..45a0018 100644 --- a/src/areas/generate/GeneratePage.tsx +++ b/src/areas/generate/GeneratePage.tsx @@ -2,6 +2,7 @@ import { useState, useRef, useCallback, useEffect } from 'react' import { useAppStore } from '@shared/stores/appStore' import type { GenerationJob } from '@shared/stores/appStore' import { useApi } from '@shared/hooks/useApi' +import { ColorPicker } from '@shared/components/ui' import GenerationHUD from './components/GenerationHUD' import Viewer3D from './components/Viewer3D' import WorkflowPanel from './components/WorkflowPanel' @@ -122,6 +123,92 @@ function DecimatePopover({ ) } +// --------------------------------------------------------------------------- +// Light popover +// --------------------------------------------------------------------------- + +export interface LightSettings { + ambientIntensity: number + ambientColor: string + mainIntensity: number + mainColor: string + fillIntensity: number + fillColor: string +} + +export const DEFAULT_LIGHT_SETTINGS: LightSettings = { + ambientIntensity: 1.2, + ambientColor: '#ffffff', + mainIntensity: 1.5, + mainColor: '#ffffff', + fillIntensity: 0.6, + fillColor: '#ffffff', +} + +function LightPopover({ + settings, + onChange, + onClose, +}: { + settings: LightSettings + onChange: (s: LightSettings) => void + onClose: () => void +}) { + function lightRow( + label: string, + colorKey: keyof LightSettings, + intensityKey: keyof LightSettings, + max: number, + ) { + const intensity = settings[intensityKey] as number + const color = settings[colorKey] as string + return ( +
+
+ onChange({ ...settings, [colorKey]: c })} + /> + {label} + {intensity.toFixed(1)} +
+ onChange({ ...settings, [intensityKey]: parseFloat(e.target.value) })} + className="w-full h-1.5 accent-violet-500 cursor-pointer" + /> +
+ ) + } + + return ( +
+
+

Lighting

+ +
+ {lightRow('Ambient', 'ambientColor', 'ambientIntensity', 3)} + {lightRow('Sun', 'mainColor', 'mainIntensity', 4)} + {lightRow('Fill', 'fillColor', 'fillIntensity', 2)} + +
+ ) +} + // --------------------------------------------------------------------------- // Smooth popover // --------------------------------------------------------------------------- @@ -191,7 +278,8 @@ function SmoothPopover({ export default function GeneratePage(): JSX.Element { const [unloadStatus, setUnloadStatus] = useState<'idle' | 'done'>('idle') const [panelWidth, setPanelWidth] = useState(DEFAULT_WIDTH) - const [openPanel, setOpenPanel] = useState<'export' | 'decimate' | 'smooth' | 'import' | null>(null) + const [openPanel, setOpenPanel] = useState<'export' | 'decimate' | 'smooth' | 'import' | 'light' | null>(null) + const [lightSettings, setLightSettings] = useState(DEFAULT_LIGHT_SETTINGS) const [decimating, setDecimating] = useState(false) const [smoothing, setSmoothing] = useState(false) const [importing, setImporting] = useState(false) @@ -499,13 +587,46 @@ export default function GeneratePage(): JSX.Element { /> )}
+ )} + + {/* Light — always visible, pushed to the right */} +
+ + {openPanel === 'light' && ( + setOpenPanel(null)} + /> + )} +
{/* Viewer area */}
- + {/* Free memory — overlay top-left */} diff --git a/src/areas/generate/components/Viewer3D.tsx b/src/areas/generate/components/Viewer3D.tsx index 0d88583..576a3a3 100644 --- a/src/areas/generate/components/Viewer3D.tsx +++ b/src/areas/generate/components/Viewer3D.tsx @@ -12,6 +12,8 @@ THREE.Mesh.prototype.raycast = acceleratedRaycast import { useGeneration } from '@shared/hooks/useGeneration' import { useAppStore } from '@shared/stores/appStore' import { ViewerToolbar, type ViewMode } from './ViewerToolbar' +import type { LightSettings } from '../GeneratePage' +import { DEFAULT_LIGHT_SETTINGS } from '../GeneratePage' // --------------------------------------------------------------------------- // Procedural textures @@ -331,7 +333,7 @@ function EmptyState(): JSX.Element { // Viewer3D // --------------------------------------------------------------------------- -export default function Viewer3D(): JSX.Element { +export default function Viewer3D({ lightSettings = DEFAULT_LIGHT_SETTINGS }: { lightSettings?: LightSettings }): JSX.Element { const { currentJob } = useGeneration() const apiUrl = useAppStore((s) => s.apiUrl) @@ -403,9 +405,9 @@ export default function Viewer3D(): JSX.Element { {modelUrl && currentJob ? ( - - - + + + void + size?: 'sm' | 'md' +} + +export function ColorPicker({ value, onChange, size = 'sm' }: ColorPickerProps): JSX.Element { + const inputRef = useRef(null) + + const dim = size === 'sm' ? 'w-5 h-5' : 'w-6 h-6' + + return ( + + ) +} diff --git a/src/shared/components/ui/index.ts b/src/shared/components/ui/index.ts index e33103f..17b6d61 100644 --- a/src/shared/components/ui/index.ts +++ b/src/shared/components/ui/index.ts @@ -1,3 +1,4 @@ export { Tooltip } from './Tooltip' export { FieldLabel } from './FieldLabel' export { ConfirmModal } from './ConfirmModal' +export { ColorPicker } from './ColorPicker' From c677fca8b1df79746d54fc899161ba8f9411ece6 Mon Sep 17 00:00:00 2001 From: Lorchie Date: Thu, 9 Apr 2026 16:05:35 +0200 Subject: [PATCH 03/12] Add copy button error generate --- .../generate/components/GenerationHUD.tsx | 49 ++++++++++++++++--- 1 file changed, 42 insertions(+), 7 deletions(-) diff --git a/src/areas/generate/components/GenerationHUD.tsx b/src/areas/generate/components/GenerationHUD.tsx index abef34c..768797a 100644 --- a/src/areas/generate/components/GenerationHUD.tsx +++ b/src/areas/generate/components/GenerationHUD.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { useGeneration } from '@shared/hooks/useGeneration' function formatElapsed(seconds: number): string { @@ -11,6 +11,15 @@ export default function GenerationHUD(): JSX.Element | null { const { currentJob, reset } = useGeneration() const [elapsed, setElapsed] = useState(0) const [tqdmLog, setTqdmLog] = useState(null) + const [copied, setCopied] = useState(false) + const copyTimeout = useRef | null>(null) + + function handleCopyError(text: string) { + navigator.clipboard.writeText(text) + setCopied(true) + if (copyTimeout.current) clearTimeout(copyTimeout.current) + copyTimeout.current = setTimeout(() => setCopied(false), 2000) + } const status = currentJob?.status const isActive = status === 'uploading' || status === 'generating' @@ -90,12 +99,38 @@ export default function GenerationHUD(): JSX.Element | null {

{error}

- +
+ + {error && ( + + )} +
)} From a82d3ddcff95ac6859d591ef547b04caa5934fc0 Mon Sep 17 00:00:00 2001 From: Lorchie Date: Thu, 9 Apr 2026 17:15:24 +0200 Subject: [PATCH 04/12] TextNode grows with textarea content (autoHeight) --- src/areas/workflows/nodes/BaseNode.tsx | 21 ++++++---------- src/areas/workflows/nodes/ExtensionNode.tsx | 2 +- src/areas/workflows/nodes/TextNode.tsx | 27 ++++++++++++++++----- 3 files changed, 30 insertions(+), 20 deletions(-) diff --git a/src/areas/workflows/nodes/BaseNode.tsx b/src/areas/workflows/nodes/BaseNode.tsx index 08821c4..856e528 100644 --- a/src/areas/workflows/nodes/BaseNode.tsx +++ b/src/areas/workflows/nodes/BaseNode.tsx @@ -1,7 +1,9 @@ -import { useState, useRef, useLayoutEffect } from 'react' +import { useState, useRef } from 'react' import { NodeResizer, useReactFlow } from '@xyflow/react' import type { ReactNode } from 'react' +const RESIZER_HANDLE_STYLE = { background: 'transparent', border: 'none', width: 12, height: 12 } + // ─── Props ──────────────────────────────────────────────────────────────────── export interface BaseNodeProps { @@ -28,6 +30,7 @@ export interface BaseNodeProps { // Resize minWidth?: number // default 180 minHeight?: number // default 60 + autoHeight?: boolean // when true: node sizes to content, no vertical resize // Body content (hidden when collapsed) children?: ReactNode @@ -45,27 +48,19 @@ export default function BaseNode({ subheader, handles, minWidth = 180, minHeight = 60, + autoHeight = false, children, }: BaseNodeProps) { const { updateNodeData, deleteElements } = useReactFlow() const [expanded, setExpanded] = useState(defaultExpanded) const rootRef = useRef(null) - const [minW, setMinW] = useState(minWidth) - const [minH, setMinH] = useState(minHeight) - - useLayoutEffect(() => { - if (rootRef.current) { - setMinW(rootRef.current.offsetWidth) - setMinH(rootRef.current.offsetHeight) - } - }, []) const isDisabled = enabled === false return (
{/* React Flow handles — must live at root level */} diff --git a/src/areas/workflows/nodes/ExtensionNode.tsx b/src/areas/workflows/nodes/ExtensionNode.tsx index 8334089..d0498ed 100644 --- a/src/areas/workflows/nodes/ExtensionNode.tsx +++ b/src/areas/workflows/nodes/ExtensionNode.tsx @@ -246,7 +246,7 @@ export default function ExtensionNode({ id, data, selected }: { id: string; data const val = (data.params[param.id] ?? param.default) as number | string return (
- +
patchParam(param.id, v)} />
diff --git a/src/areas/workflows/nodes/TextNode.tsx b/src/areas/workflows/nodes/TextNode.tsx index fd7cd6d..6e32989 100644 --- a/src/areas/workflows/nodes/TextNode.tsx +++ b/src/areas/workflows/nodes/TextNode.tsx @@ -1,4 +1,4 @@ -import { useLayoutEffect, useRef, useState } from 'react' +import { useEffect, useLayoutEffect, useRef, useState } from 'react' import { Handle, Position, useReactFlow } from '@xyflow/react' import type { WFNodeData } from '@shared/types/electron.d' import BaseNode from './BaseNode' @@ -6,8 +6,9 @@ import BaseNode from './BaseNode' const OUTPUT_COLOR = '#fbbf24' export default function TextNode({ id, data, selected }: { id: string; data: WFNodeData; selected?: boolean }) { - const { updateNodeData } = useReactFlow() - const ioRowRef = useRef(null) + const { updateNodeData, setNodes } = useReactFlow() + const ioRowRef = useRef(null) + const textareaRef = useRef(null) const [handleTop, setHandleTop] = useState('50%') useLayoutEffect(() => { @@ -17,8 +18,20 @@ export default function TextNode({ id, data, selected }: { id: string; data: WFN } }, []) + // Clear stored height so React Flow measures content naturally + useEffect(() => { + setNodes(nodes => nodes.map(n => n.id === id ? { ...n, height: undefined } : n)) + }, [id, setNodes]) + const text = (data.params.text as string | undefined) ?? '' + useEffect(() => { + const ta = textareaRef.current + if (!ta) return + ta.style.height = 'auto' + ta.style.height = `${ta.scrollHeight}px` + }, [text]) + return ( @@ -42,12 +55,14 @@ export default function TextNode({ id, data, selected }: { id: string; data: WFN style={{ background: OUTPUT_COLOR, width: 14, height: 14, border: '2.5px solid #18181b', top: handleTop }} /> } > -
+