feat: add workflow node registry phase 2

This commit is contained in:
2026-04-07 08:59:27 +02:00
parent 63e35ce807
commit 56ee5fc5bf
8 changed files with 843 additions and 309 deletions
@@ -4,6 +4,7 @@ from copy import deepcopy
from typing import Any from typing import Any
from app.core.process_steps import StepName from app.core.process_steps import StepName
from app.domains.rendering.workflow_node_registry import get_node_type_for_step
WorkflowPresetType = str WorkflowPresetType = str
@@ -16,18 +17,10 @@ _PRESET_TYPES = {
"custom", "custom",
} }
_STEP_TO_NODE_TYPE: dict[str, str] = {
StepName.RESOLVE_STEP_PATH.value: "inputNode",
StepName.STL_CACHE_GENERATE.value: "convertNode",
StepName.BLENDER_STILL.value: "renderNode",
StepName.BLENDER_TURNTABLE.value: "renderFramesNode",
StepName.OUTPUT_SAVE.value: "outputNode",
StepName.EXPORT_BLEND.value: "outputNode",
}
_NODE_TYPE_TO_STEP: dict[str, str] = { _NODE_TYPE_TO_STEP: dict[str, str] = {
"inputNode": StepName.RESOLVE_STEP_PATH.value, "inputNode": StepName.RESOLVE_STEP_PATH.value,
"convertNode": StepName.STL_CACHE_GENERATE.value, "convertNode": StepName.STL_CACHE_GENERATE.value,
"processNode": StepName.ORDER_LINE_SETUP.value,
"renderNode": StepName.BLENDER_STILL.value, "renderNode": StepName.BLENDER_STILL.value,
"renderFramesNode": StepName.BLENDER_TURNTABLE.value, "renderFramesNode": StepName.BLENDER_TURNTABLE.value,
"ffmpegNode": StepName.OUTPUT_SAVE.value, "ffmpegNode": StepName.OUTPUT_SAVE.value,
@@ -50,7 +43,7 @@ def _make_node(
"step": step.value, "step": step.value,
"params": deepcopy(params or {}), "params": deepcopy(params or {}),
"ui": { "ui": {
"type": node_type or _STEP_TO_NODE_TYPE.get(step.value), "type": node_type or get_node_type_for_step(step.value),
"position": {"x": x, "y": y}, "position": {"x": x, "y": y},
"label": label, "label": label,
}, },
@@ -0,0 +1,417 @@
from __future__ import annotations
from typing import Any, Literal
from pydantic import BaseModel
from app.core.process_steps import StepName
StepCategory = Literal["input", "processing", "rendering", "output"]
FieldType = Literal["number", "select", "boolean"]
ExecutionKind = Literal["native", "bridge"]
class WorkflowNodeFieldOption(BaseModel):
value: str | int | float | bool
label: str
class WorkflowNodeFieldDefinition(BaseModel):
key: str
label: str
type: FieldType
description: str = ""
section: str = "General"
default: Any = None
min: float | None = None
max: float | None = None
step: float | None = None
unit: str | None = None
options: list[WorkflowNodeFieldOption] = []
class WorkflowNodeDefinition(BaseModel):
step: str
label: str
category: StepCategory
description: str
node_type: str
icon: str
defaults: dict[str, Any] = {}
fields: list[WorkflowNodeFieldDefinition] = []
execution_kind: ExecutionKind = "native"
legacy_compatible: bool = True
def _field(
key: str,
label: str,
field_type: FieldType,
*,
description: str = "",
section: str = "General",
default: Any = None,
min: float | None = None,
max: float | None = None,
step: float | None = None,
unit: str | None = None,
options: list[tuple[str | int | float | bool, str]] | None = None,
) -> WorkflowNodeFieldDefinition:
return WorkflowNodeFieldDefinition(
key=key,
label=label,
type=field_type,
description=description,
section=section,
default=default,
min=min,
max=max,
step=step,
unit=unit,
options=[
WorkflowNodeFieldOption(value=value, label=option_label)
for value, option_label in (options or [])
],
)
def _definition(
step: StepName,
label: str,
category: StepCategory,
description: str,
*,
node_type: str,
icon: str,
defaults: dict[str, Any] | None = None,
fields: list[WorkflowNodeFieldDefinition] | None = None,
execution_kind: ExecutionKind = "native",
) -> WorkflowNodeDefinition:
return WorkflowNodeDefinition(
step=step.value,
label=label,
category=category,
description=description,
node_type=node_type,
icon=icon,
defaults=defaults or {},
fields=fields or [],
execution_kind=execution_kind,
)
_NODE_DEFINITIONS: list[WorkflowNodeDefinition] = [
_definition(
StepName.RESOLVE_STEP_PATH,
"Resolve STEP Path",
"input",
"Locate the STEP file on disk from the CAD file record.",
node_type="inputNode",
icon="file-up",
execution_kind="bridge",
),
_definition(
StepName.OCC_OBJECT_EXTRACT,
"Extract STEP Objects",
"processing",
"Extract part objects and metadata from the STEP file via OCC/cadquery.",
node_type="processNode",
icon="layers",
execution_kind="bridge",
),
_definition(
StepName.OCC_GLB_EXPORT,
"Export GLB",
"processing",
"Convert STEP geometry into GLB for previews and downstream rendering.",
node_type="processNode",
icon="refresh-cw",
execution_kind="bridge",
),
_definition(
StepName.GLB_BBOX,
"Compute Bounding Box",
"processing",
"Compute the model bounding box from the exported GLB for framing decisions.",
node_type="processNode",
icon="layers",
execution_kind="bridge",
),
_definition(
StepName.MATERIAL_MAP_RESOLVE,
"Resolve Material Map",
"processing",
"Map raw part material names to HartOMat material records via aliases.",
node_type="processNode",
icon="layers",
execution_kind="bridge",
),
_definition(
StepName.AUTO_POPULATE_MATERIALS,
"Auto Populate Materials",
"processing",
"Create missing material records for newly discovered part materials.",
node_type="processNode",
icon="layers",
execution_kind="bridge",
),
_definition(
StepName.BLENDER_RENDER,
"Render Thumbnail (Blender)",
"rendering",
"Render a thumbnail image with Blender.",
node_type="renderNode",
icon="camera",
defaults={"render_engine": "cycles", "samples": 64, "width": 512, "height": 512},
fields=[
_field(
"render_engine",
"Render Engine",
"select",
description="Renderer backend for this Blender job.",
section="Render",
default="cycles",
options=[("cycles", "Cycles"), ("eevee", "EEVEE")],
),
_field(
"samples",
"Samples",
"number",
description="Quality samples for the render.",
section="Render",
default=64,
min=1,
max=4096,
step=1,
),
_field("width", "Width", "number", section="Output", default=512, min=64, max=8192, step=1, unit="px"),
_field("height", "Height", "number", section="Output", default=512, min=64, max=8192, step=1, unit="px"),
],
),
_definition(
StepName.THREEJS_RENDER,
"Render Thumbnail (Three.js)",
"rendering",
"Render a thumbnail image with the headless Three.js renderer.",
node_type="renderNode",
icon="camera",
defaults={"width": 512, "height": 512, "transparent_bg": True},
fields=[
_field("width", "Width", "number", section="Output", default=512, min=64, max=8192, step=1, unit="px"),
_field("height", "Height", "number", section="Output", default=512, min=64, max=8192, step=1, unit="px"),
_field(
"transparent_bg",
"Transparent Background",
"boolean",
description="Render with alpha channel instead of an opaque background.",
section="Output",
default=True,
),
],
execution_kind="bridge",
),
_definition(
StepName.THUMBNAIL_SAVE,
"Save Thumbnail",
"output",
"Persist the generated thumbnail back onto the CAD file record.",
node_type="outputNode",
icon="download",
execution_kind="bridge",
),
_definition(
StepName.ORDER_LINE_SETUP,
"Order Line Setup",
"processing",
"Validate order-line inputs and prepare the render job context.",
node_type="processNode",
icon="layers",
execution_kind="bridge",
),
_definition(
StepName.RESOLVE_TEMPLATE,
"Resolve Template",
"processing",
"Resolve the render template for the order line and output type.",
node_type="processNode",
icon="layers",
execution_kind="bridge",
),
_definition(
StepName.BLENDER_STILL,
"Render Still",
"rendering",
"Render a production still image with Blender.",
node_type="renderNode",
icon="camera",
defaults={"render_engine": "cycles", "samples": 256, "width": 2048, "height": 2048, "rotation_z": 0},
fields=[
_field(
"render_engine",
"Render Engine",
"select",
description="Renderer backend for the still render.",
section="Render",
default="cycles",
options=[("cycles", "Cycles"), ("eevee", "EEVEE")],
),
_field(
"samples",
"Samples",
"number",
description="Quality samples for the still render.",
section="Render",
default=256,
min=1,
max=4096,
step=1,
),
_field("width", "Width", "number", section="Output", default=2048, min=64, max=8192, step=1, unit="px"),
_field("height", "Height", "number", section="Output", default=2048, min=64, max=8192, step=1, unit="px"),
_field(
"rotation_z",
"Rotation Z",
"number",
description="Additional Z-axis rotation in degrees.",
section="Camera",
default=0,
min=-360,
max=360,
step=1,
unit="deg",
),
],
),
_definition(
StepName.BLENDER_TURNTABLE,
"Render Turntable",
"rendering",
"Render an animated turntable sequence with Blender.",
node_type="renderFramesNode",
icon="film",
defaults={
"render_engine": "cycles",
"samples": 64,
"width": 2048,
"height": 2048,
"fps": 24,
"duration_s": 5,
"rotation_z": 0,
},
fields=[
_field(
"render_engine",
"Render Engine",
"select",
description="Renderer backend for the turntable job.",
section="Render",
default="cycles",
options=[("cycles", "Cycles"), ("eevee", "EEVEE")],
),
_field(
"samples",
"Samples",
"number",
description="Quality samples for each frame.",
section="Render",
default=64,
min=1,
max=4096,
step=1,
),
_field("width", "Width", "number", section="Output", default=2048, min=64, max=8192, step=1, unit="px"),
_field("height", "Height", "number", section="Output", default=2048, min=64, max=8192, step=1, unit="px"),
_field("fps", "FPS", "number", section="Animation", default=24, min=1, max=120, step=1),
_field(
"duration_s",
"Duration",
"number",
description="Length of the animation clip.",
section="Animation",
default=5,
min=1,
max=120,
step=1,
unit="s",
),
_field(
"rotation_z",
"Rotation Z",
"number",
description="Base Z-axis rotation before the turntable motion is applied.",
section="Camera",
default=0,
min=-360,
max=360,
step=1,
unit="deg",
),
],
),
_definition(
StepName.OUTPUT_SAVE,
"Save Output",
"output",
"Persist the rendered output file and create the media record.",
node_type="outputNode",
icon="download",
execution_kind="bridge",
),
_definition(
StepName.EXPORT_BLEND,
"Export Blend",
"output",
"Persist the generated .blend file as a downloadable media asset.",
node_type="outputNode",
icon="download",
execution_kind="bridge",
),
_definition(
StepName.STL_CACHE_GENERATE,
"Generate STL Cache",
"processing",
"Generate and cache STL derivatives next to the STEP source.",
node_type="convertNode",
icon="refresh-cw",
execution_kind="bridge",
),
_definition(
StepName.NOTIFY,
"Notify",
"output",
"Emit a user-visible notification for workflow completion or failure.",
node_type="outputNode",
icon="bell",
defaults={"channel": "audit_log"},
fields=[
_field(
"channel",
"Channel",
"select",
description="Notification target channel.",
section="Notification",
default="audit_log",
options=[("audit_log", "Audit Log")],
),
],
execution_kind="bridge",
),
]
_NODE_DEFINITION_BY_STEP = {definition.step: definition for definition in _NODE_DEFINITIONS}
def list_node_definitions() -> list[WorkflowNodeDefinition]:
return list(_NODE_DEFINITIONS)
def get_node_definition(step: StepName | str) -> WorkflowNodeDefinition | None:
step_value = step.value if isinstance(step, StepName) else step
return _NODE_DEFINITION_BY_STEP.get(step_value)
def get_node_type_for_step(step: StepName | str) -> str | None:
definition = get_node_definition(step)
return definition.node_type if definition else None
@@ -1,16 +1,5 @@
"""Workflow definition CRUD API. """Workflow definition CRUD API."""
Endpoints:
GET /api/workflows/ — list all workflow definitions (admin/PM)
GET /api/workflows/pipeline-steps — list available pipeline step definitions
GET /api/workflows/{id} — get single definition (admin/PM)
POST /api/workflows/ — create definition (admin only)
PUT /api/workflows/{id} — update definition (admin only)
DELETE /api/workflows/{id} — delete definition (admin only)
GET /api/workflows/{id}/runs — list runs for a definition (admin/PM)
"""
import uuid import uuid
from typing import Literal
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, ValidationError from pydantic import BaseModel, ValidationError
@@ -29,53 +18,12 @@ from app.domains.rendering.schemas import (
WorkflowRunOut, WorkflowRunOut,
) )
from app.domains.rendering.workflow_config_utils import canonicalize_workflow_config from app.domains.rendering.workflow_config_utils import canonicalize_workflow_config
from app.domains.rendering.workflow_node_registry import (
StepCategory,
WorkflowNodeDefinition,
list_node_definitions,
)
from app.domains.rendering.workflow_schema import WorkflowConfig from app.domains.rendering.workflow_schema import WorkflowConfig
from app.core.process_steps import StepName
# ── Pipeline-step metadata helpers ──────────────────────────────────────────
StepCategory = Literal["input", "processing", "rendering", "output"]
_STEP_CATEGORIES: dict[StepName, StepCategory] = {
StepName.RESOLVE_STEP_PATH: "input",
StepName.OCC_OBJECT_EXTRACT: "processing",
StepName.OCC_GLB_EXPORT: "processing",
StepName.GLB_BBOX: "processing",
StepName.MATERIAL_MAP_RESOLVE: "processing",
StepName.AUTO_POPULATE_MATERIALS: "processing",
StepName.BLENDER_RENDER: "rendering",
StepName.THREEJS_RENDER: "rendering",
StepName.THUMBNAIL_SAVE: "output",
StepName.ORDER_LINE_SETUP: "processing",
StepName.RESOLVE_TEMPLATE: "processing",
StepName.BLENDER_STILL: "rendering",
StepName.BLENDER_TURNTABLE: "rendering",
StepName.OUTPUT_SAVE: "output",
StepName.EXPORT_BLEND: "output",
StepName.STL_CACHE_GENERATE: "processing",
StepName.NOTIFY: "output",
}
_STEP_DESCRIPTIONS: dict[StepName, str] = {
StepName.RESOLVE_STEP_PATH: "Locate the STEP file on disk from the CadFile record",
StepName.OCC_OBJECT_EXTRACT: "Extract part objects and metadata from the STEP file using cadquery/OCC",
StepName.OCC_GLB_EXPORT: "Convert STEP geometry to glTF/GLB via cadquery",
StepName.GLB_BBOX: "Compute bounding-box from the exported GLB for camera framing",
StepName.MATERIAL_MAP_RESOLVE: "Resolve raw part-material names to HARTOMAT library materials via alias table",
StepName.AUTO_POPULATE_MATERIALS: "Auto-create Material records for any newly discovered part names",
StepName.BLENDER_RENDER: "Render a thumbnail PNG using Blender (Cycles or EEVEE)",
StepName.THREEJS_RENDER: "Render a thumbnail PNG using Three.js / Playwright headless browser",
StepName.THUMBNAIL_SAVE: "Persist the rendered thumbnail bytes to the CadFile record",
StepName.ORDER_LINE_SETUP: "Validate and prepare an order line for rendering (check STEP path, output type)",
StepName.RESOLVE_TEMPLATE: "Look up the matching RenderTemplate for the order line's category + output type",
StepName.BLENDER_STILL: "Render a production still image (PNG) via Blender HTTP micro-service",
StepName.BLENDER_TURNTABLE: "Render all turntable animation frames via Blender HTTP micro-service",
StepName.OUTPUT_SAVE: "Upload the rendered output file to storage and create a MediaAsset record",
StepName.EXPORT_BLEND: "Save the production .blend file as a downloadable MediaAsset",
StepName.STL_CACHE_GENERATE: "Convert STEP → STL (low + high quality) and cache next to the STEP file",
StepName.NOTIFY: "Emit a user notification via the audit-log notification channel",
}
class PipelineStepOut(BaseModel): class PipelineStepOut(BaseModel):
@@ -88,6 +36,11 @@ class PipelineStepOut(BaseModel):
class PipelineStepsResponse(BaseModel): class PipelineStepsResponse(BaseModel):
steps: list[PipelineStepOut] steps: list[PipelineStepOut]
class NodeDefinitionsResponse(BaseModel):
definitions: list[WorkflowNodeDefinition]
router = APIRouter(prefix="/api/workflows", tags=["workflows"]) router = APIRouter(prefix="/api/workflows", tags=["workflows"])
@@ -102,19 +55,25 @@ def _workflow_to_out(wf: WorkflowDefinition) -> WorkflowDefinitionOut:
) )
@router.get("/node-definitions", response_model=NodeDefinitionsResponse)
async def get_node_definitions(
_user: User = Depends(require_admin_or_pm),
):
return NodeDefinitionsResponse(definitions=list_node_definitions())
@router.get("/pipeline-steps", response_model=PipelineStepsResponse) @router.get("/pipeline-steps", response_model=PipelineStepsResponse)
async def get_pipeline_steps( async def get_pipeline_steps(
_user: User = Depends(require_admin_or_pm), _user: User = Depends(require_admin_or_pm),
): ):
"""Return all available pipeline step definitions for the workflow editor."""
steps = [ steps = [
PipelineStepOut( PipelineStepOut(
name=step.value, name=definition.step,
label=step.value.replace("_", " ").title(), label=definition.label,
category=_STEP_CATEGORIES.get(step, "processing"), category=definition.category,
description=_STEP_DESCRIPTIONS.get(step, ""), description=definition.description,
) )
for step in StepName for definition in list_node_definitions()
] ]
return PipelineStepsResponse(steps=steps) return PipelineStepsResponse(steps=steps)
@@ -0,0 +1,47 @@
import pytest
from app.core.process_steps import StepName
from app.domains.rendering.workflow_node_registry import (
get_node_definition,
list_node_definitions,
)
def test_node_registry_covers_all_step_names():
registered_steps = {definition.step for definition in list_node_definitions()}
expected_steps = {step.value for step in StepName}
assert registered_steps == expected_steps
def test_turntable_node_definition_exposes_expected_schema():
definition = get_node_definition(StepName.BLENDER_TURNTABLE)
assert definition is not None
assert definition.node_type == "renderFramesNode"
assert definition.defaults["fps"] == 24
assert definition.defaults["duration_s"] == 5
assert {field.key for field in definition.fields} >= {
"render_engine",
"samples",
"width",
"height",
"fps",
"duration_s",
"rotation_z",
}
@pytest.mark.asyncio
async def test_node_definitions_endpoint_returns_registry(client, auth_headers):
response = await client.get("/api/workflows/node-definitions", headers=auth_headers)
assert response.status_code == 200
body = response.json()
assert len(body["definitions"]) == len(StepName)
blender_still = next(
definition for definition in body["definitions"] if definition["step"] == StepName.BLENDER_STILL.value
)
assert blender_still["node_type"] == "renderNode"
assert blender_still["defaults"]["render_engine"] == "cycles"
@@ -4,18 +4,18 @@
### Phase 1 ### Phase 1
- [ ] Canonical workflow schema finalized - [x] Canonical workflow schema finalized
- [ ] Frontend and backend workflow types aligned - [x] Frontend and backend workflow types aligned
- [ ] Preset workflow migration helpers added - [x] Preset workflow migration helpers added
- [ ] Tests added for legacy preset conversion - [x] Tests added for legacy preset conversion
- [ ] Legacy dispatch remains default - [x] Legacy dispatch remains default
### Phase 2 ### Phase 2
- [ ] Node registry implemented - [x] Node registry implemented
- [ ] Node definitions API available - [x] Node definitions API available
- [ ] All required nodes have settings schemas - [x] All required nodes have settings schemas
- [ ] Editor consumes node definitions from backend - [x] Editor consumes node definitions from backend
### Phase 3 ### Phase 3
@@ -4,6 +4,12 @@
Bring `/workflows` to full production parity with the existing legacy render pipeline without breaking the current legacy path at any time. Bring `/workflows` to full production parity with the existing legacy render pipeline without breaking the current legacy path at any time.
## Current Status
- Phase 1 completed on canonical config storage, preset migration, and legacy-safe runtime extraction.
- Phase 2 completed on backend node registry, node definitions API, and schema-driven editor palette/settings.
- Next execution target: Phase 3 legacy step extraction for runtime parity.
## Non-Negotiables ## Non-Negotiables
- The legacy render path remains operational throughout the migration. - The legacy render path remains operational throughout the migration.
+42 -1
View File
@@ -101,9 +101,47 @@ export const deleteWorkflow = (id: string): Promise<void> =>
export const getWorkflowRuns = (workflowId: string): Promise<WorkflowRun[]> => export const getWorkflowRuns = (workflowId: string): Promise<WorkflowRun[]> =>
api.get(`/workflows/${workflowId}/runs`).then(r => r.data) api.get(`/workflows/${workflowId}/runs`).then(r => r.data)
// ─── Pipeline Steps ─────────────────────────────────────────────────────────── // ─── Node Definitions / Pipeline Steps ───────────────────────────────────────
export type StepCategory = 'input' | 'processing' | 'rendering' | 'output' export type StepCategory = 'input' | 'processing' | 'rendering' | 'output'
export type WorkflowNodeFieldType = 'number' | 'select' | 'boolean'
export type WorkflowNodeExecutionKind = 'native' | 'bridge'
export interface WorkflowNodeFieldOption {
value: string | number | boolean
label: string
}
export interface WorkflowNodeFieldDefinition {
key: string
label: string
type: WorkflowNodeFieldType
description: string
section: string
default: unknown
min: number | null
max: number | null
step: number | null
unit: string | null
options: WorkflowNodeFieldOption[]
}
export interface WorkflowNodeDefinition {
step: string
label: string
category: StepCategory
description: string
node_type: string
icon: string
defaults: WorkflowParams
fields: WorkflowNodeFieldDefinition[]
execution_kind: WorkflowNodeExecutionKind
legacy_compatible: boolean
}
export interface WorkflowNodeDefinitionsResponse {
definitions: WorkflowNodeDefinition[]
}
export interface PipelineStep { export interface PipelineStep {
name: string name: string
@@ -116,6 +154,9 @@ export interface PipelineStepsResponse {
steps: PipelineStep[] steps: PipelineStep[]
} }
export const getNodeDefinitions = (): Promise<WorkflowNodeDefinitionsResponse> =>
api.get('/workflows/node-definitions').then(r => r.data)
export const getPipelineSteps = (): Promise<PipelineStepsResponse> => export const getPipelineSteps = (): Promise<PipelineStepsResponse> =>
api.get('/workflows/pipeline-steps').then(r => r.data) api.get('/workflows/pipeline-steps').then(r => r.data)
+295 -224
View File
@@ -1,4 +1,4 @@
import { useState, useCallback, useRef, DragEvent } from 'react' import { useState, useCallback, useRef, useEffect, type ChangeEvent, type DragEvent } from 'react'
import { import {
ReactFlow, ReactFlow,
Background, Background,
@@ -22,7 +22,7 @@ import {
createWorkflow, createWorkflow,
updateWorkflow, updateWorkflow,
deleteWorkflow, deleteWorkflow,
getPipelineSteps, getNodeDefinitions,
createPresetWorkflowConfig, createPresetWorkflowConfig,
getWorkflowPresetType, getWorkflowPresetType,
type WorkflowDefinition, type WorkflowDefinition,
@@ -30,8 +30,9 @@ import {
type WorkflowEdge, type WorkflowEdge,
type WorkflowPresetType, type WorkflowPresetType,
type WorkflowParams, type WorkflowParams,
type PipelineStep,
type StepCategory, type StepCategory,
type WorkflowNodeDefinition,
type WorkflowNodeFieldDefinition,
} from '../api/workflows' } from '../api/workflows'
import { import {
FileUp, FileUp,
@@ -40,6 +41,7 @@ import {
Film, Film,
Layers, Layers,
Download, Download,
Bell,
Plus, Plus,
Save, Save,
Trash2, Trash2,
@@ -59,15 +61,49 @@ function normalizeWorkflowParams(params: WorkflowParams): WorkflowParams {
return normalized return normalized
} }
function getResolutionSelection(params: WorkflowParams): number { type WorkflowCanvasNodeData = {
const resolution = Array.isArray(params.resolution) ? params.resolution : undefined label: string
if (resolution && typeof resolution[0] === 'number') { params: WorkflowParams
return Number(resolution[0]) step: string
description?: string
icon?: string
category?: StepCategory
} }
if (typeof params.width === 'number' && typeof params.height === 'number' && params.width === params.height) {
return params.width function renderWorkflowIcon(iconName?: string, size = 14) {
switch (iconName) {
case 'file-up':
return <FileUp size={size} />
case 'film':
return <Film size={size} />
case 'layers':
return <Layers size={size} />
case 'download':
return <Download size={size} />
case 'bell':
return <Bell size={size} />
case 'camera':
return <Camera size={size} />
case 'refresh-cw':
default:
return <RefreshCw size={size} />
}
}
function buildNodeData(
step: string,
params: WorkflowParams = {},
definition?: WorkflowNodeDefinition,
overrides?: Partial<WorkflowCanvasNodeData>,
): WorkflowCanvasNodeData {
return {
label: overrides?.label ?? definition?.label ?? inferNodeLabel(step),
params: normalizeWorkflowParams(params),
step,
description: overrides?.description ?? definition?.description,
icon: overrides?.icon ?? definition?.icon,
category: overrides?.category ?? definition?.category,
} }
return 2048
} }
// ─── Custom Node Components ────────────────────────────────────────────────── // ─── Custom Node Components ──────────────────────────────────────────────────
@@ -75,14 +111,14 @@ function getResolutionSelection(params: WorkflowParams): number {
interface BaseNodeProps { interface BaseNodeProps {
label: string label: string
icon: React.ReactNode icon: React.ReactNode
color: string accentClass: string
description?: string description?: string
selected?: boolean selected?: boolean
hasSource?: boolean hasSource?: boolean
hasTarget?: boolean hasTarget?: boolean
} }
function BaseNode({ label, icon, color, description, selected, hasSource = true, hasTarget = true }: BaseNodeProps) { function BaseNode({ label, icon, accentClass, description, selected, hasSource = true, hasTarget = true }: BaseNodeProps) {
return ( return (
<div <div
className={`rounded-lg border-2 p-3 min-w-[140px] bg-surface shadow-sm transition-colors ${ className={`rounded-lg border-2 p-3 min-w-[140px] bg-surface shadow-sm transition-colors ${
@@ -92,7 +128,7 @@ function BaseNode({ label, icon, color, description, selected, hasSource = true,
{hasTarget && ( {hasTarget && (
<Handle type="target" position={Position.Left} className="w-3 h-3 bg-content-muted border-2 border-surface" /> <Handle type="target" position={Position.Left} className="w-3 h-3 bg-content-muted border-2 border-surface" />
)} )}
<div className={`flex items-center gap-2 mb-1 text-${color}-600`}> <div className={`flex items-center gap-2 mb-1 ${accentClass}`}>
{icon} {icon}
<span className="font-medium text-sm">{label}</span> <span className="font-medium text-sm">{label}</span>
</div> </div>
@@ -104,76 +140,80 @@ function BaseNode({ label, icon, color, description, selected, hasSource = true,
) )
} }
function InputNode({ selected }: { selected?: boolean }) { function InputNode({ data, selected }: { data: WorkflowCanvasNodeData; selected?: boolean }) {
return ( return (
<BaseNode <BaseNode
label="STEP Input" label={data.label}
icon={<FileUp size={14} />} icon={renderWorkflowIcon(data.icon)}
color="green" accentClass="text-green-600"
description="STEP file input" description={data.description}
selected={selected} selected={selected}
hasTarget={false} hasTarget={false}
/> />
) )
} }
function ConvertNode({ selected }: { selected?: boolean }) { function ConvertNode({ data, selected }: { data: WorkflowCanvasNodeData; selected?: boolean }) {
return ( return (
<BaseNode <BaseNode
label="STL Conversion" label={data.label}
icon={<RefreshCw size={14} />} icon={renderWorkflowIcon(data.icon)}
color="blue" accentClass="text-blue-600"
description="STEP → STL (cadquery)" description={data.description}
selected={selected} selected={selected}
/> />
) )
} }
function RenderNode({ data, selected }: { data: { label?: string; params?: WorkflowParams }; selected?: boolean }) { function ProcessNode({ data, selected }: { data: WorkflowCanvasNodeData; selected?: boolean }) {
return (
<BaseNode
label={data.label}
icon={renderWorkflowIcon(data.icon)}
accentClass="text-sky-600"
description={data.description}
selected={selected}
/>
)
}
function RenderNode({ data, selected }: { data: WorkflowCanvasNodeData; selected?: boolean }) {
const params = data.params ?? {} const params = data.params ?? {}
return ( return (
<BaseNode <BaseNode
label={data.label ?? 'Still Render'} label={data.label}
icon={<Camera size={14} />} icon={renderWorkflowIcon(data.icon)}
color="orange" accentClass="text-orange-600"
description={params.render_engine ? `${params.render_engine} · ${params.samples ?? 256} samples` : undefined} description={
params.render_engine
? `${params.render_engine} · ${params.samples ?? 256} samples`
: data.description
}
selected={selected} selected={selected}
/> />
) )
} }
function RenderFramesNode({ data, selected }: { data: { params?: WorkflowParams }; selected?: boolean }) { function RenderFramesNode({ data, selected }: { data: WorkflowCanvasNodeData; selected?: boolean }) {
const params = data.params ?? {} const params = data.params ?? {}
return ( return (
<BaseNode <BaseNode
label="Frames Render" label={data.label}
icon={<Film size={14} />} icon={renderWorkflowIcon(data.icon)}
color="orange" accentClass="text-orange-600"
description={params.fps ? `${params.fps} fps · ${params.duration_s ?? '?'}s` : undefined} description={params.fps ? `${params.fps} fps · ${params.duration_s ?? '?'}s` : data.description}
selected={selected} selected={selected}
/> />
) )
} }
function FFmpegNode({ selected }: { selected?: boolean }) { function OutputNode({ data, selected }: { data: WorkflowCanvasNodeData; selected?: boolean }) {
return ( return (
<BaseNode <BaseNode
label="FFmpeg Composite" label={data.label}
icon={<Layers size={14} />} icon={renderWorkflowIcon(data.icon)}
color="purple" accentClass="text-slate-600"
description="Frames → MP4" description={data.description}
selected={selected}
/>
)
}
function OutputNode({ data, selected }: { data: { label?: string }; selected?: boolean }) {
return (
<BaseNode
label={data.label ?? 'Output'}
icon={<Download size={14} />}
color="gray"
description="Output file"
selected={selected} selected={selected}
hasSource={false} hasSource={false}
/> />
@@ -183,9 +223,9 @@ function OutputNode({ data, selected }: { data: { label?: string }; selected?: b
const nodeTypes: NodeTypes = { const nodeTypes: NodeTypes = {
inputNode: InputNode as any, inputNode: InputNode as any,
convertNode: ConvertNode as any, convertNode: ConvertNode as any,
processNode: ProcessNode as any,
renderNode: RenderNode as any, renderNode: RenderNode as any,
renderFramesNode: RenderFramesNode as any, renderFramesNode: RenderFramesNode as any,
ffmpegNode: FFmpegNode as any,
outputNode: OutputNode as any, outputNode: OutputNode as any,
} }
@@ -193,7 +233,11 @@ function inferNodeType(step: string): string {
if (step === 'resolve_step_path') return 'inputNode' if (step === 'resolve_step_path') return 'inputNode'
if (step === 'stl_cache_generate') return 'convertNode' if (step === 'stl_cache_generate') return 'convertNode'
if (step === 'blender_turntable') return 'renderFramesNode' if (step === 'blender_turntable') return 'renderFramesNode'
if (step === 'output_save' || step === 'export_blend') return 'outputNode' if (step === 'output_save' || step === 'export_blend' || step === 'notify' || step === 'thumbnail_save') return 'outputNode'
if (step.startsWith('blender_') || step === 'threejs_render') return 'renderNode'
if (step.startsWith('occ_') || step === 'glb_bbox' || step === 'material_map_resolve' || step === 'auto_populate_materials') {
return 'processNode'
}
return 'renderNode' return 'renderNode'
} }
@@ -207,22 +251,24 @@ function inferNodeLabel(step: string): string {
function inferStepFromNodeType(type?: string): string { function inferStepFromNodeType(type?: string): string {
if (type === 'inputNode') return 'resolve_step_path' if (type === 'inputNode') return 'resolve_step_path'
if (type === 'convertNode') return 'stl_cache_generate' if (type === 'convertNode') return 'stl_cache_generate'
if (type === 'processNode') return 'order_line_setup'
if (type === 'renderFramesNode') return 'blender_turntable' if (type === 'renderFramesNode') return 'blender_turntable'
if (type === 'outputNode') return 'output_save' if (type === 'outputNode') return 'output_save'
return 'blender_still' return 'blender_still'
} }
function workflowToGraph(config: WorkflowConfig): { nodes: Node[]; edges: Edge[] } { function workflowToGraph(
config: WorkflowConfig,
nodeDefinitionsByStep: Record<string, WorkflowNodeDefinition>,
): { nodes: Node[]; edges: Edge[] } {
return { return {
nodes: config.nodes.map(node => ({ nodes: config.nodes.map(node => ({
id: node.id, id: node.id,
type: node.ui?.type ?? inferNodeType(node.step), type: node.ui?.type ?? nodeDefinitionsByStep[node.step]?.node_type ?? inferNodeType(node.step),
position: node.ui?.position ?? { x: 0, y: 0 }, position: node.ui?.position ?? { x: 0, y: 0 },
data: { data: buildNodeData(node.step, node.params ?? {}, nodeDefinitionsByStep[node.step], {
label: node.ui?.label ?? inferNodeLabel(node.step), label: node.ui?.label ?? undefined,
params: node.params ?? {}, }),
step: node.step,
},
})), })),
edges: config.edges.map((edge, index) => ({ edges: config.edges.map((edge, index) => ({
id: `e_${edge.from}_${edge.to}_${index}`, id: `e_${edge.from}_${edge.to}_${index}`,
@@ -234,149 +280,155 @@ function workflowToGraph(config: WorkflowConfig): { nodes: Node[]; edges: Edge[]
// ─── Config Sidepanel ───────────────────────────────────────────────────────── // ─── Config Sidepanel ─────────────────────────────────────────────────────────
function groupFieldsBySection(fields: WorkflowNodeFieldDefinition[]) {
return fields.reduce<Record<string, WorkflowNodeFieldDefinition[]>>((sections, field) => {
const section = field.section || 'General'
sections[section] = [...(sections[section] ?? []), field]
return sections
}, {})
}
function ConfigSidepanel({ function ConfigSidepanel({
params, params,
onChange, onChange,
pipelineStep, nodeDefinition,
onPipelineStepChange, step,
pipelineSteps, onStepChange,
nodeDefinitions,
}: { }: {
params: WorkflowParams params: WorkflowParams
onChange: (p: WorkflowParams) => void onChange: (p: WorkflowParams) => void
pipelineStep?: string nodeDefinition?: WorkflowNodeDefinition
onPipelineStepChange?: (step: string) => void step?: string
pipelineSteps: PipelineStep[] onStepChange?: (step: string) => void
nodeDefinitions: WorkflowNodeDefinition[]
}) { }) {
const updateField = (field: WorkflowNodeFieldDefinition, value: unknown) => {
onChange(
normalizeWorkflowParams({
...params,
[field.key]: value,
}),
)
}
const handleNumberChange = (field: WorkflowNodeFieldDefinition, event: ChangeEvent<HTMLInputElement>) => {
const rawValue = event.target.value
if (rawValue === '') {
const nextParams = { ...params }
delete nextParams[field.key]
onChange(nextParams)
return
}
updateField(field, Number(rawValue))
}
const fieldsBySection = groupFieldsBySection(nodeDefinition?.fields ?? [])
return ( return (
<div className="w-72 border-l border-border-default bg-surface p-4 space-y-5 overflow-y-auto"> <div className="w-72 border-l border-border-default bg-surface p-4 space-y-5 overflow-y-auto">
<h3 className="font-semibold text-content">Node Configuration</h3> <h3 className="font-semibold text-content">Node Configuration</h3>
{/* Pipeline Step binding */} {nodeDefinitions.length > 0 && onStepChange && (
{pipelineSteps.length > 0 && onPipelineStepChange && (
<div> <div>
<label className="text-sm text-content-secondary mb-2 block">Pipeline Step</label> <label className="text-sm text-content-secondary mb-2 block">Workflow Node</label>
<select <select
value={pipelineStep ?? ''} value={step ?? ''}
onChange={e => onPipelineStepChange(e.target.value)} onChange={event => onStepChange(event.target.value)}
className="w-full border border-border-default rounded-lg px-3 py-2 text-sm bg-surface text-content focus:outline-none focus:ring-2 focus:ring-accent" className="w-full border border-border-default rounded-lg px-3 py-2 text-sm bg-surface text-content focus:outline-none focus:ring-2 focus:ring-accent"
> >
<option value="">(not bound)</option> {nodeDefinitions.map(definition => (
{pipelineSteps.map(s => ( <option key={definition.step} value={definition.step}>
<option key={s.name} value={s.name}> {definition.label}
{s.label}
</option> </option>
))} ))}
</select> </select>
{pipelineStep && ( {nodeDefinition && (
<p className="text-xs text-content-muted mt-1"> <div className="mt-2 space-y-1">
{pipelineSteps.find(s => s.name === pipelineStep)?.description ?? ''} <p className="text-xs text-content-muted">{nodeDefinition.description}</p>
<span
className={`inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium ${
nodeDefinition.execution_kind === 'bridge'
? 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300'
: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300'
}`}
>
{nodeDefinition.execution_kind === 'bridge' ? 'Legacy Bridge' : 'Native Node'}
</span>
</div>
)}
</div>
)}
{Object.keys(fieldsBySection).length === 0 && (
<p className="text-sm text-content-muted">
This node currently has no configurable settings in the editor.
</p> </p>
)} )}
</div>
{Object.entries(fieldsBySection).map(([section, fields]) => (
<div key={section} className="space-y-3">
<h4 className="text-xs font-semibold uppercase tracking-wide text-content-secondary">
{section}
</h4>
{fields.map(field => {
const rawValue = params[field.key]
const value = rawValue ?? field.default
return (
<div key={field.key}>
<label className="text-sm text-content-secondary mb-1 block">
{field.label}
{field.unit ? ` (${field.unit})` : ''}
</label>
{field.type === 'select' && (
<select
value={String(value ?? '')}
onChange={event => updateField(field, event.target.value)}
className="w-full border border-border-default rounded-lg px-3 py-2 text-sm bg-surface text-content focus:outline-none focus:ring-2 focus:ring-accent"
>
{field.options.map(option => (
<option key={String(option.value)} value={String(option.value)}>
{option.label}
</option>
))}
</select>
)} )}
{field.type === 'number' && (
{/* Render Engine */}
<div>
<label className="text-sm text-content-secondary mb-2 block">Render Engine</label>
<div className="flex gap-2">
{(['cycles', 'eevee'] as const).map(eng => (
<button
key={eng}
onClick={() => onChange({ ...params, render_engine: eng })}
className={`px-3 py-1.5 rounded text-sm font-medium transition-colors ${
(params.render_engine ?? 'cycles') === eng
? 'bg-accent text-white'
: 'bg-surface-hover text-content-secondary hover:bg-surface-muted'
}`}
>
{eng === 'cycles' ? 'Cycles' : 'EEVEE'}
</button>
))}
</div>
</div>
{/* Samples */}
<div>
<label className="text-sm text-content-secondary mb-2 block">
Samples: <span className="font-semibold text-content">{params.samples ?? 256}</span>
</label>
<input <input
type="range" type="number"
min={1} min={field.min ?? undefined}
max={4096} max={field.max ?? undefined}
step={1} step={field.step ?? undefined}
value={params.samples ?? 256} value={typeof value === 'number' ? value : value == null ? '' : Number(value)}
onChange={e => onChange({ ...params, samples: Number(e.target.value) })} onChange={event => handleNumberChange(field, event)}
className="w-full accent-accent" className="w-full border border-border-default rounded-lg px-3 py-2 text-sm bg-surface text-content focus:outline-none focus:ring-2 focus:ring-accent"
/> />
<div className="flex justify-between text-xs text-content-muted mt-1"> )}
<span>1</span> {field.type === 'boolean' && (
<span>4096</span> <label className="flex items-center gap-2 rounded-lg border border-border-default px-3 py-2 text-sm text-content">
</div>
</div>
{/* Resolution */}
<div>
<label className="text-sm text-content-secondary mb-2 block">Resolution</label>
<div className="flex gap-2">
{([[1024, 1024], [2048, 2048], [4096, 4096]] as [number, number][]).map(([w]) => (
<button
key={w}
onClick={() => onChange(normalizeWorkflowParams({ ...params, resolution: [w, w] }))}
className={`px-2 py-1.5 rounded text-xs font-medium transition-colors ${
getResolutionSelection(params) === w
? 'bg-accent text-white'
: 'bg-surface-hover text-content-secondary hover:bg-surface-muted'
}`}
>
{w}px
</button>
))}
</div>
</div>
{/* FPS (only relevant for animation nodes) */}
<div>
<label className="text-sm text-content-secondary mb-2 block">
FPS: <span className="font-semibold text-content">{params.fps ?? 24}</span>
</label>
<div className="flex gap-2">
{[12, 24, 30, 60].map(fps => (
<button
key={fps}
onClick={() => onChange({ ...params, fps })}
className={`px-2 py-1.5 rounded text-xs font-medium transition-colors ${
(params.fps ?? 24) === fps
? 'bg-accent text-white'
: 'bg-surface-hover text-content-secondary hover:bg-surface-muted'
}`}
>
{fps}
</button>
))}
</div>
</div>
{/* Duration */}
<div>
<label className="text-sm text-content-secondary mb-2 block">
Duration (s): <span className="font-semibold text-content">{params.duration_s ?? 5}</span>
</label>
<input <input
type="range" type="checkbox"
min={1} checked={Boolean(value)}
max={30} onChange={event => updateField(field, event.target.checked)}
step={1} className="accent-accent"
value={params.duration_s ?? 5}
onChange={e => onChange({ ...params, duration_s: Number(e.target.value) })}
className="w-full accent-accent"
/> />
<span>{Boolean(value) ? 'Enabled' : 'Disabled'}</span>
</label>
)}
{field.description && (
<p className="mt-1 text-xs text-content-muted">{field.description}</p>
)}
</div> </div>
)
})}
</div>
))}
</div> </div>
) )
} }
// ─── Pipeline Steps Panel ───────────────────────────────────────────────────── // ─── Node Definitions Panel ───────────────────────────────────────────────────
const CATEGORY_LABELS: Record<StepCategory, string> = { const CATEGORY_LABELS: Record<StepCategory, string> = {
input: 'Input', input: 'Input',
@@ -392,12 +444,12 @@ const CATEGORY_COLORS: Record<StepCategory, string> = {
output: 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300', output: 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300',
} }
function PipelineStepsPanel({ steps }: { steps: PipelineStep[] }) { function NodeDefinitionsPanel({ definitions }: { definitions: WorkflowNodeDefinition[] }) {
const [expanded, setExpanded] = useState<StepCategory | null>(null) const [expanded, setExpanded] = useState<StepCategory | null>(null)
const grouped = steps.reduce<Record<StepCategory, PipelineStep[]>>( const grouped = definitions.reduce<Record<StepCategory, WorkflowNodeDefinition[]>>(
(acc, step) => { (acc, definition) => {
acc[step.category] = [...(acc[step.category] ?? []), step] acc[definition.category] = [...(acc[definition.category] ?? []), definition]
return acc return acc
}, },
{ input: [], processing: [], rendering: [], output: [] }, { input: [], processing: [], rendering: [], output: [] },
@@ -408,7 +460,7 @@ function PipelineStepsPanel({ steps }: { steps: PipelineStep[] }) {
return ( return (
<div className="border-t border-border-default pt-3 mt-3"> <div className="border-t border-border-default pt-3 mt-3">
<p className="text-xs font-semibold text-content-secondary uppercase tracking-wide mb-2"> <p className="text-xs font-semibold text-content-secondary uppercase tracking-wide mb-2">
Pipeline Steps Available Nodes
</p> </p>
<div className="space-y-1"> <div className="space-y-1">
{categories.map(cat => ( {categories.map(cat => (
@@ -424,14 +476,26 @@ function PipelineStepsPanel({ steps }: { steps: PipelineStep[] }) {
</button> </button>
{expanded === cat && ( {expanded === cat && (
<div className="ml-2 mt-1 space-y-1"> <div className="ml-2 mt-1 space-y-1">
{grouped[cat].map(step => ( {grouped[cat].map(definition => (
<div <div
key={step.name} key={definition.step}
className="text-xs bg-surface-hover rounded px-2 py-1.5" className="text-xs bg-surface-hover rounded px-2 py-1.5"
title={step.description} title={definition.description}
> >
<p className="font-mono text-content-secondary truncate">{step.name}</p> <div className="flex items-center justify-between gap-2">
<p className="text-content-muted mt-0.5 line-clamp-2">{step.description}</p> <p className="font-medium text-content-secondary truncate">{definition.label}</p>
<span
className={`rounded-full px-1.5 py-0.5 text-[10px] font-medium ${
definition.execution_kind === 'bridge'
? 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300'
: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300'
}`}
>
{definition.execution_kind === 'bridge' ? 'Bridge' : 'Native'}
</span>
</div>
<p className="font-mono text-content-muted truncate mt-0.5">{definition.step}</p>
<p className="text-content-muted mt-0.5 line-clamp-2">{definition.description}</p>
</div> </div>
))} ))}
</div> </div>
@@ -443,16 +507,6 @@ function PipelineStepsPanel({ steps }: { steps: PipelineStep[] }) {
) )
} }
// ─── Node Palette ──────────────────────────────────────────────────────────────
const NODE_PALETTE = [
{ type: 'convertNode', label: 'STEP→STL', icon: <RefreshCw size={14} /> },
{ type: 'renderNode', label: 'Still Render', icon: <Camera size={14} /> },
{ type: 'renderFramesNode', label: 'Frame Render', icon: <Film size={14} /> },
{ type: 'ffmpegNode', label: 'FFmpeg', icon: <Layers size={14} /> },
{ type: 'outputNode', label: 'Output', icon: <Download size={14} /> },
]
// ─── New Workflow Modal ─────────────────────────────────────────────────────── // ─── New Workflow Modal ───────────────────────────────────────────────────────
interface NewWorkflowModalProps { interface NewWorkflowModalProps {
@@ -543,19 +597,26 @@ interface FlowCanvasProps {
} }
function FlowCanvas({ workflow, onSave, isSaving }: FlowCanvasProps) { function FlowCanvas({ workflow, onSave, isSaving }: FlowCanvasProps) {
const { nodes: initNodes, edges: initEdges } = workflowToGraph(workflow.config) const { data: nodeDefinitionsData } = useQuery({
queryKey: ['workflow-node-definitions'],
queryFn: getNodeDefinitions,
staleTime: 5 * 60 * 1000,
})
const nodeDefinitions = nodeDefinitionsData?.definitions ?? []
const nodeDefinitionsByStep = Object.fromEntries(nodeDefinitions.map(definition => [definition.step, definition]))
const { nodes: initNodes, edges: initEdges } = workflowToGraph(workflow.config, nodeDefinitionsByStep)
const [nodes, setNodes, onNodesChange] = useNodesState(initNodes) const [nodes, setNodes, onNodesChange] = useNodesState(initNodes)
const [edges, setEdges, onEdgesChange] = useEdgesState(initEdges) const [edges, setEdges, onEdgesChange] = useEdgesState(initEdges)
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null) const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null)
const reactFlowWrapper = useRef<HTMLDivElement>(null) const reactFlowWrapper = useRef<HTMLDivElement>(null)
const [reactFlowInstance, setReactFlowInstance] = useState<any>(null) const [reactFlowInstance, setReactFlowInstance] = useState<any>(null)
const { data: pipelineStepsData } = useQuery({ useEffect(() => {
queryKey: ['pipeline-steps'], const graph = workflowToGraph(workflow.config, nodeDefinitionsByStep)
queryFn: getPipelineSteps, setNodes(graph.nodes)
staleTime: 5 * 60 * 1000, setEdges(graph.edges)
}) setSelectedNodeId(null)
const pipelineSteps = pipelineStepsData?.steps ?? [] }, [nodeDefinitionsData, setEdges, setNodes, workflow.config])
const onConnect = useCallback( const onConnect = useCallback(
(connection: Connection) => setEdges(eds => addEdge(connection, eds)), (connection: Connection) => setEdges(eds => addEdge(connection, eds)),
@@ -586,15 +647,24 @@ function FlowCanvas({ workflow, onSave, isSaving }: FlowCanvasProps) {
const handlePipelineStepChange = useCallback( const handlePipelineStepChange = useCallback(
(stepName: string) => { (stepName: string) => {
const definition = nodeDefinitionsByStep[stepName]
setNodes(nds => setNodes(nds =>
nds.map(n => { nds.map(n => {
if (n.id === selectedNodeId) { if (n.id === selectedNodeId) {
const currentData = (n.data as WorkflowCanvasNodeData | undefined) ?? buildNodeData(stepName)
return { return {
...n, ...n,
type: definition?.node_type ?? inferNodeType(stepName),
data: { data: {
...n.data, ...buildNodeData(
stepName || inferStepFromNodeType(n.type),
{
...(definition?.defaults ?? {}),
...currentData.params,
},
definition,
),
step: stepName || inferStepFromNodeType(n.type), step: stepName || inferStepFromNodeType(n.type),
label: (n.data as any).label ?? inferNodeLabel(stepName),
}, },
} }
} }
@@ -602,7 +672,7 @@ function FlowCanvas({ workflow, onSave, isSaving }: FlowCanvasProps) {
}), }),
) )
}, },
[selectedNodeId, setNodes], [nodeDefinitionsByStep, selectedNodeId, setNodes],
) )
// Drag-drop new nodes from palette // Drag-drop new nodes from palette
@@ -614,8 +684,11 @@ function FlowCanvas({ workflow, onSave, isSaving }: FlowCanvasProps) {
const onDrop = useCallback( const onDrop = useCallback(
(event: DragEvent<HTMLDivElement>) => { (event: DragEvent<HTMLDivElement>) => {
event.preventDefault() event.preventDefault()
const type = event.dataTransfer.getData('application/reactflow') const step = event.dataTransfer.getData('application/workflow-step')
if (!type || !reactFlowInstance) return if (!step || !reactFlowInstance) return
const definition = nodeDefinitionsByStep[step]
const type = definition?.node_type ?? inferNodeType(step)
const position = reactFlowInstance.screenToFlowPosition({ const position = reactFlowInstance.screenToFlowPosition({
x: event.clientX, x: event.clientX,
@@ -623,18 +696,14 @@ function FlowCanvas({ workflow, onSave, isSaving }: FlowCanvasProps) {
}) })
const newNode: Node = { const newNode: Node = {
id: `${type}_${Date.now()}`, id: `${step}_${Date.now()}`,
type, type,
position, position,
data: { data: buildNodeData(step, definition?.defaults ?? {}, definition),
label: type,
params: {},
step: inferStepFromNodeType(type),
},
} }
setNodes(nds => [...nds, newNode]) setNodes(nds => [...nds, newNode])
}, },
[reactFlowInstance, setNodes], [nodeDefinitionsByStep, reactFlowInstance, setNodes],
) )
const handleSave = () => { const handleSave = () => {
@@ -667,20 +736,21 @@ function FlowCanvas({ workflow, onSave, isSaving }: FlowCanvasProps) {
return ( return (
<div className="flex flex-col flex-1 min-h-0"> <div className="flex flex-col flex-1 min-h-0">
{/* Canvas Toolbar */} {/* Canvas Toolbar */}
<div className="flex items-center gap-2 px-4 py-2 border-b border-border-default bg-surface"> <div className="flex items-center gap-2 px-4 py-2 border-b border-border-default bg-surface overflow-x-auto">
<span className="text-sm font-medium text-content-secondary mr-2">Nodes</span> <span className="text-sm font-medium text-content-secondary mr-2 whitespace-nowrap">Nodes</span>
{NODE_PALETTE.map(item => ( {nodeDefinitions.map(definition => (
<div <div
key={item.type} key={definition.step}
draggable draggable
onDragStart={e => { onDragStart={e => {
e.dataTransfer.setData('application/reactflow', item.type) e.dataTransfer.setData('application/workflow-step', definition.step)
e.dataTransfer.effectAllowed = 'move' e.dataTransfer.effectAllowed = 'move'
}} }}
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded border border-border-default bg-surface-hover text-xs text-content-secondary cursor-grab hover:bg-surface-muted select-none" className="flex items-center gap-1.5 px-2.5 py-1.5 rounded border border-border-default bg-surface-hover text-xs text-content-secondary cursor-grab hover:bg-surface-muted select-none whitespace-nowrap"
title={definition.description}
> >
{item.icon} {renderWorkflowIcon(definition.icon)}
{item.label} {definition.label}
</div> </div>
))} ))}
<div className="ml-auto"> <div className="ml-auto">
@@ -722,14 +792,15 @@ function FlowCanvas({ workflow, onSave, isSaving }: FlowCanvasProps) {
<ConfigSidepanel <ConfigSidepanel
params={((selectedNode.data as any).params as WorkflowParams | undefined) ?? {}} params={((selectedNode.data as any).params as WorkflowParams | undefined) ?? {}}
onChange={handleParamsChange} onChange={handleParamsChange}
pipelineStep={(selectedNode.data as any).step as string | undefined} step={(selectedNode.data as any).step as string | undefined}
onPipelineStepChange={handlePipelineStepChange} onStepChange={handlePipelineStepChange}
pipelineSteps={pipelineSteps} nodeDefinition={nodeDefinitionsByStep[((selectedNode.data as any).step as string | undefined) ?? '']}
nodeDefinitions={nodeDefinitions}
/> />
)} )}
{!selectedNode && pipelineSteps.length > 0 && ( {!selectedNode && nodeDefinitions.length > 0 && (
<div className="w-64 border-l border-border-default bg-surface p-4 overflow-y-auto"> <div className="w-64 border-l border-border-default bg-surface p-4 overflow-y-auto">
<PipelineStepsPanel steps={pipelineSteps} /> <NodeDefinitionsPanel definitions={nodeDefinitions} />
</div> </div>
)} )}
</div> </div>