diff --git a/src/areas/generate/components/WorkflowPanel.tsx b/src/areas/generate/components/WorkflowPanel.tsx index 4b02a55..3636d61 100644 --- a/src/areas/generate/components/WorkflowPanel.tsx +++ b/src/areas/generate/components/WorkflowPanel.tsx @@ -333,6 +333,39 @@ function TextParamRow({ nodeId, nodes, onPatch }: { nodeId: string; nodes: FlowN ) } +function WaitParamRow({ nodeId }: { nodeId: string }) { + const status = useWorkflowRunStore((s) => s.runState.status) + const activeNodeId = useWorkflowRunStore((s) => s.activeNodeId) + const continueRun = useWorkflowRunStore((s) => s.continueRun) + const isPaused = status === 'paused' && activeNodeId === nodeId + + return ( +
+
+ + + + Wait +
+ {isPaused ? ( + + ) : ( +

+ Pauses the workflow until you click Continue. +

+ )} +
+ ) +} + function ExtensionParamRow({ nodeId, ext, nodes, onPatch }: { nodeId: string; ext: WorkflowExtension; nodes: FlowNode[]; onPatch: PatchFn }) { const [expanded, setExpanded] = useState(true) const node = nodes.find((n) => n.id === nodeId) @@ -414,7 +447,7 @@ function EmbeddedCanvas({ workflow, allExtensions }: { const { setCurrentJob } = useAppStore() const { runState, run, cancel } = useWorkflowRunStore() - const isRunning = runState.status === 'running' + const isRunning = runState.status === 'running' || runState.status === 'paused' // Update AddToScene node when run completes useEffect(() => { @@ -458,7 +491,7 @@ function EmbeddedCanvas({ workflow, allExtensions }: { ) const paramNodes = sortedNodes.filter((n) => - (n.type === 'imageNode' || n.type === 'textNode' || n.type === 'meshNode' || n.type === 'extensionNode') + (n.type === 'imageNode' || n.type === 'textNode' || n.type === 'meshNode' || n.type === 'extensionNode' || n.type === 'waitNode') && (n.data as { showInGenerate?: boolean }).showInGenerate === true, ) @@ -479,6 +512,7 @@ function EmbeddedCanvas({ workflow, allExtensions }: { {node.type === 'imageNode' && } {node.type === 'textNode' && } {node.type === 'meshNode' && } + {node.type === 'waitNode' && } {node.type === 'extensionNode' && (() => { const ext = getWorkflowExtension(node.data.extensionId ?? '', allExtensions) return ext ? : null diff --git a/src/areas/workflows/WorkflowsPage.tsx b/src/areas/workflows/WorkflowsPage.tsx index e2575e6..05641f1 100644 --- a/src/areas/workflows/WorkflowsPage.tsx +++ b/src/areas/workflows/WorkflowsPage.tsx @@ -27,13 +27,14 @@ import TextNode from './nodes/TextNode' import AddToSceneNode from './nodes/AddToSceneNode' import Load3DMeshNode from './nodes/Load3DMeshNode' import PreviewImageNode from './nodes/PreviewImageNode' +import WaitNode from './nodes/WaitNode' 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, previewNode: PreviewImageNode } +const NODE_TYPES = { extensionNode: ExtensionNode, imageNode: ImageNode, textNode: TextNode, outputNode: AddToSceneNode, meshNode: Load3DMeshNode, previewNode: PreviewImageNode, waitNode: WaitNode } const EDGE_TYPES = { workflowEdge: WorkflowEdge } const DEFAULT_EDGE_OPTS = { type: 'workflowEdge' } @@ -155,6 +156,7 @@ const PANEL_BUILTIN_NODES = [ { 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: <> }, + { type: 'waitNode', label: 'Wait', color: '#71717a', icon: <> }, ] function ExtGroupHeader({ title, author, expanded, onToggle, count }: { title: string; author?: string; expanded: boolean; onToggle: () => void; count: number }) { @@ -398,6 +400,7 @@ const BUILTIN_NODES = [ { 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: 'waitNode', label: 'Wait', color: '#71717a', description: 'Pauses the workflow until you click Continue' }, ] type PaletteItem = @@ -778,7 +781,7 @@ function WorkflowCanvasInner({ }) { const { screenToFlowPosition, updateNodeData, getNode } = useReactFlow() const { runState, run: runWorkflow, cancel } = useWorkflowRunStore() - const isRunning = runState.status === 'running' + const isRunning = runState.status === 'running' || runState.status === 'paused' const [nodes, setNodes, onNodesChange] = useNodesState(workflow.nodes as Node[]) const [edges, setEdges, onEdgesChange] = useEdgesState(workflow.edges as Edge[]) diff --git a/src/areas/workflows/nodes/ExtensionNode.tsx b/src/areas/workflows/nodes/ExtensionNode.tsx index d0498ed..692a467 100644 --- a/src/areas/workflows/nodes/ExtensionNode.tsx +++ b/src/areas/workflows/nodes/ExtensionNode.tsx @@ -152,6 +152,16 @@ export default function ExtensionNode({ id, data, selected }: { id: string; data updateNodeData(id, { params: { ...data.params, [key]: val } }) }, [id, data.params, updateNodeData]) + const paramById = new Map(ext?.params.map((p) => [p.id, p])) + + const isVisible = (param: ParamSchema): boolean => { + if (!param.show_if) return true + return Object.entries(param.show_if).every(([key, expected]) => { + const current = data.params[key] ?? paramById.get(key)?.default + return Array.isArray(expected) ? expected.includes(current as string | number) : current === expected + }) + } + // ── IO subheader ───────────────────────────────────────────────────────── const ioSubheader = isMulti ? ( // Multi-input layout: one row per input, output on first row @@ -242,7 +252,7 @@ export default function ExtensionNode({ id, data, selected }: { id: string; data > {hasParams && (
- {ext!.params.map((param) => { + {ext!.params.filter(isVisible).map((param) => { const val = (data.params[param.id] ?? param.default) as number | string return (
diff --git a/src/areas/workflows/nodes/WaitNode.tsx b/src/areas/workflows/nodes/WaitNode.tsx new file mode 100644 index 0000000..8e30302 --- /dev/null +++ b/src/areas/workflows/nodes/WaitNode.tsx @@ -0,0 +1,52 @@ +import { Handle, Position } from '@xyflow/react' +import type { WFNodeData } from '@shared/types/electron.d' +import { useWorkflowRunStore } from '../workflowRunStore' +import BaseNode from './BaseNode' + +const HANDLE_STYLE = { background: '#71717a', width: 14, height: 14, border: '2.5px solid #18181b' } + +export default function WaitNode({ id, data, selected }: { id: string; data: WFNodeData; selected?: boolean }) { + const status = useWorkflowRunStore((s) => s.runState.status) + const activeNodeId = useWorkflowRunStore((s) => s.activeNodeId) + const continueRun = useWorkflowRunStore((s) => s.continueRun) + const isPaused = status === 'paused' && activeNodeId === id + + return ( + + + + + } + subheader={isPaused ? ( + + ) : undefined} + handles={ + <> + + + + } + > +
+

+ {isPaused ? 'Workflow paused — click Continue to resume.' : 'Pauses the workflow until you click Continue.'} +

+
+
+ ) +} diff --git a/src/areas/workflows/workflowRunStore.ts b/src/areas/workflows/workflowRunStore.ts index 3b677e4..0b955ea 100644 --- a/src/areas/workflows/workflowRunStore.ts +++ b/src/areas/workflows/workflowRunStore.ts @@ -8,7 +8,7 @@ import type { Workflow, WFNode, WFEdge } from '@shared/types/electron.d' // ─── Types ──────────────────────────────────────────────────────────────────── export interface WorkflowRunState { - status: 'idle' | 'running' | 'done' | 'error' + status: 'idle' | 'running' | 'paused' | 'done' | 'error' blockIndex: number blockTotal: number blockProgress: number @@ -25,6 +25,14 @@ const IDLE: WorkflowRunState = { // Module-level refs — survive component unmounts / navigation const _cancel = { current: false } const _activeJobId = { current: null as string | null } +const _resume = { current: null as (() => void) | null } + +function flushResume(): void { + const fn = _resume.current + if (!fn) return + _resume.current = null + fn() +} // ─── Topological sort ───────────────────────────────────────────────────────── @@ -60,9 +68,10 @@ interface WorkflowRunStore { /** nodeId → workspace URL for image outputs (populated after each run) */ nodeImageOutputs: Record - run: (workflow: Workflow, allExtensions: WorkflowExtension[], overrideImageData?: string) => Promise - cancel: () => void - reset: () => void + run: (workflow: Workflow, allExtensions: WorkflowExtension[], overrideImageData?: string) => Promise + cancel: () => void + reset: () => void + continueRun: () => void } export const useWorkflowRunStore = create((set) => ({ @@ -77,7 +86,9 @@ export const useWorkflowRunStore = create((set) => ({ const appState = useAppStore.getState() const apiUrl = appState.apiUrl const ordered = topoSort(workflow.nodes, workflow.edges) - const execNodes = ordered.filter((n) => n.type === 'extensionNode' && n.data.enabled) + const execNodes = ordered.filter((n) => + (n.type === 'extensionNode' || n.type === 'waitNode') && n.data.enabled, + ) const selectedImagePath = appState.selectedImagePath ?? '' const selectedImageData = overrideImageData ?? appState.selectedImageData ?? undefined @@ -108,6 +119,7 @@ export const useWorkflowRunStore = create((set) => ({ // nodeId → { filePath, text, outputType } const nodeOutputs = new Map() + const outputNodeIds = new Set(ordered.filter((n) => n.type === 'outputNode').map((n) => n.id)) // Pre-populate source nodes for (const node of ordered) { @@ -185,6 +197,21 @@ export const useWorkflowRunStore = create((set) => ({ runState: { ...s.runState, blockIndex: i, blockProgress: 0, blockStep: 'Starting…' }, })) + // ── Wait node → pause until continueRun(), then passthrough ─────── + if (node.type === 'waitNode') { + set((s) => ({ runState: { ...s.runState, status: 'paused', blockStep: 'Paused — click Continue' } })) + await new Promise((resolve) => { _resume.current = resolve }) + if (_cancel.current) { set({ runState: IDLE, activeNodeId: null }); return } + + nodeOutputs.set(node.id, { + filePath: nodeInputPath, + text: nodeInputText, + outputType: incomingEdges[0] ? nodeOutputs.get(incomingEdges[0].source)?.outputType : undefined, + }) + set((s) => ({ runState: { ...s.runState, status: 'running' } })) + continue + } + // ── Model extensions → HTTP API ─────────────────────────────────── // Process extensions → IPC runProcess const isModelNode = ext?.type === 'model' @@ -275,6 +302,20 @@ export const useWorkflowRunStore = create((set) => ({ // Store output with type for downstream routing const outputType = ext?.output ?? (nodeInputPath ? 'mesh' : undefined) nodeOutputs.set(node.id, { filePath: nodeInputPath, text: nodeInputText, outputType }) + + // If this node feeds an Add-to-Scene, push the mesh to currentJob + // immediately so the 3D viewer loads it without waiting for the rest of the run. + const norm = nodeInputPath?.replace(/\\/g, '/') + if ( + norm?.startsWith(workspaceDir) && + workflow.edges.some((e) => e.source === node.id && outputNodeIds.has(e.target)) + ) { + useAppStore.getState().updateCurrentJob({ + status: 'done', + progress: 100, + outputUrl: `/workspace/${norm.slice(workspaceDir.length).replace(/^\//, '')}`, + }) + } } // ── Collect image outputs for preview nodes ─────────────────────── @@ -292,7 +333,8 @@ export const useWorkflowRunStore = create((set) => ({ let outputUrl: string | undefined let outputPath: string | undefined - const outputNodeDef = ordered.find((n) => n.type === 'outputNode') + // Use the last AddToScene in topo order — its predecessor is the final scene mesh. + const outputNodeDef = [...ordered].reverse().find((n) => n.type === 'outputNode') if (outputNodeDef) { for (const edge of workflow.edges.filter((e) => e.target === outputNodeDef.id)) { const src = nodeOutputs.get(edge.source) @@ -343,6 +385,7 @@ export const useWorkflowRunStore = create((set) => ({ cancel() { _cancel.current = true + flushResume() if (_activeJobId.current) { const apiUrl = useAppStore.getState().apiUrl axios.create({ baseURL: apiUrl }).post(`/generate/cancel/${_activeJobId.current}`).catch(() => {}) @@ -354,4 +397,8 @@ export const useWorkflowRunStore = create((set) => ({ reset() { set({ runState: IDLE, activeNodeId: null, activeWorkflowId: null, nodeImageOutputs: {} }) }, + + continueRun() { + flushResume() + }, })) diff --git a/src/shared/types/electron.d.ts b/src/shared/types/electron.d.ts index acb21ad..f5d5ca8 100644 --- a/src/shared/types/electron.d.ts +++ b/src/shared/types/electron.d.ts @@ -38,6 +38,7 @@ export interface ParamSchema { max?: number step?: number tooltip?: string + show_if?: Record } export interface ProcessExtension {