Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 36 additions & 2 deletions src/areas/generate/components/WorkflowPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div className="flex flex-col gap-1.5">
<div className="flex items-center gap-1.5">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="#71717a" strokeWidth="2">
<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>
</svg>
<span className="text-[11px] font-medium text-zinc-300">Wait</span>
</div>
{isPaused ? (
<button
onClick={continueRun}
className="w-full flex items-center justify-center gap-1.5 px-2.5 py-2 rounded-md bg-amber-500/15 border border-amber-500/30 text-amber-400 hover:bg-amber-500/25 transition-colors text-[11px] font-medium animate-pulse"
>
<svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor">
<polygon points="5 3 19 12 5 21 5 3"/>
</svg>
Continue
</button>
) : (
<p className="text-[10px] text-zinc-600 italic px-0.5">
Pauses the workflow until you click Continue.
</p>
)}
</div>
)
}

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)
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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,
)

Expand All @@ -479,6 +512,7 @@ function EmbeddedCanvas({ workflow, allExtensions }: {
{node.type === 'imageNode' && <ImageParamRow nodeId={node.id} nodes={nodes} onPatch={patchNode} />}
{node.type === 'textNode' && <TextParamRow nodeId={node.id} nodes={nodes} onPatch={patchNode} />}
{node.type === 'meshNode' && <MeshParamRow nodeId={node.id} nodes={nodes} onPatch={patchNode} />}
{node.type === 'waitNode' && <WaitParamRow nodeId={node.id} />}
{node.type === 'extensionNode' && (() => {
const ext = getWorkflowExtension(node.data.extensionId ?? '', allExtensions)
return ext ? <ExtensionParamRow nodeId={node.id} ext={ext} nodes={nodes} onPatch={patchNode} /> : null
Expand Down
7 changes: 5 additions & 2 deletions src/areas/workflows/WorkflowsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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' }
Expand Down Expand Up @@ -155,6 +156,7 @@ const PANEL_BUILTIN_NODES = [
{ type: 'meshNode', label: 'Load 3D Mesh', color: '#a78bfa', icon: <><path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/></> },
{ type: 'outputNode', label: 'Add to Scene', color: '#a78bfa', icon: <><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/></> },
{ type: 'previewNode', label: 'Preview Views', color: '#38bdf8', icon: <><rect x="3" y="3" width="8" height="8" rx="1"/><rect x="13" y="3" width="8" height="8" rx="1"/><rect x="3" y="13" width="8" height="8" rx="1"/><rect x="13" y="13" width="8" height="8" rx="1"/></> },
{ type: 'waitNode', label: 'Wait', color: '#71717a', icon: <><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></> },
]

function ExtGroupHeader({ title, author, expanded, onToggle, count }: { title: string; author?: string; expanded: boolean; onToggle: () => void; count: number }) {
Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -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[])
Expand Down
12 changes: 11 additions & 1 deletion src/areas/workflows/nodes/ExtensionNode.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -242,7 +252,7 @@ export default function ExtensionNode({ id, data, selected }: { id: string; data
>
{hasParams && (
<div className="px-3 pb-3 pt-2.5 flex flex-col gap-2">
{ext!.params.map((param) => {
{ext!.params.filter(isVisible).map((param) => {
const val = (data.params[param.id] ?? param.default) as number | string
return (
<div key={param.id} className="flex items-center gap-2">
Expand Down
52 changes: 52 additions & 0 deletions src/areas/workflows/nodes/WaitNode.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<BaseNode
id={id}
selected={selected}
title="Wait"
minWidth={170}
showInGenerate={data.showInGenerate ?? false}
icon={
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="#71717a" strokeWidth="2">
<circle cx="12" cy="12" r="10"/>
<polyline points="12 6 12 12 16 14"/>
</svg>
}
subheader={isPaused ? (
<button
onClick={continueRun}
className="nodrag w-full flex items-center justify-center gap-1.5 px-2.5 py-2 bg-amber-500/15 border-y border-amber-500/30 text-amber-400 hover:bg-amber-500/25 transition-colors text-[10px] font-medium animate-pulse"
>
<svg width="9" height="9" viewBox="0 0 24 24" fill="currentColor">
<polygon points="5 3 19 12 5 21 5 3"/>
</svg>
Continue
</button>
) : undefined}
handles={
<>
<Handle type="target" position={Position.Left} style={HANDLE_STYLE} />
<Handle type="source" position={Position.Right} style={HANDLE_STYLE} />
</>
}
>
<div className="px-3 pb-3 pt-2.5">
<p className="text-[10px] text-zinc-500 italic">
{isPaused ? 'Workflow paused — click Continue to resume.' : 'Pauses the workflow until you click Continue.'}
</p>
</div>
</BaseNode>
)
}
59 changes: 53 additions & 6 deletions src/areas/workflows/workflowRunStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 ─────────────────────────────────────────────────────────

Expand Down Expand Up @@ -60,9 +68,10 @@ interface WorkflowRunStore {
/** nodeId → workspace URL for image outputs (populated after each run) */
nodeImageOutputs: Record<string, string>

run: (workflow: Workflow, allExtensions: WorkflowExtension[], overrideImageData?: string) => Promise<void>
cancel: () => void
reset: () => void
run: (workflow: Workflow, allExtensions: WorkflowExtension[], overrideImageData?: string) => Promise<void>
cancel: () => void
reset: () => void
continueRun: () => void
}

export const useWorkflowRunStore = create<WorkflowRunStore>((set) => ({
Expand All @@ -77,7 +86,9 @@ export const useWorkflowRunStore = create<WorkflowRunStore>((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
Expand Down Expand Up @@ -108,6 +119,7 @@ export const useWorkflowRunStore = create<WorkflowRunStore>((set) => ({

// nodeId → { filePath, text, outputType }
const nodeOutputs = new Map<string, { filePath?: string; text?: string; outputType?: string }>()
const outputNodeIds = new Set(ordered.filter((n) => n.type === 'outputNode').map((n) => n.id))

// Pre-populate source nodes
for (const node of ordered) {
Expand Down Expand Up @@ -185,6 +197,21 @@ export const useWorkflowRunStore = create<WorkflowRunStore>((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<void>((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'
Expand Down Expand Up @@ -275,6 +302,20 @@ export const useWorkflowRunStore = create<WorkflowRunStore>((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 ───────────────────────
Expand All @@ -292,7 +333,8 @@ export const useWorkflowRunStore = create<WorkflowRunStore>((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)
Expand Down Expand Up @@ -343,6 +385,7 @@ export const useWorkflowRunStore = create<WorkflowRunStore>((set) => ({

cancel() {
_cancel.current = true
flushResume()
if (_activeJobId.current) {
const apiUrl = useAppStore.getState().apiUrl
axios.create({ baseURL: apiUrl }).post(`/generate/cancel/${_activeJobId.current}`).catch(() => {})
Expand All @@ -354,4 +397,8 @@ export const useWorkflowRunStore = create<WorkflowRunStore>((set) => ({
reset() {
set({ runState: IDLE, activeNodeId: null, activeWorkflowId: null, nodeImageOutputs: {} })
},

continueRun() {
flushResume()
},
}))
1 change: 1 addition & 0 deletions src/shared/types/electron.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export interface ParamSchema {
max?: number
step?: number
tooltip?: string
show_if?: Record<string, string | number | (string | number)[]>
}

export interface ProcessExtension {
Expand Down
Loading