chore: snapshot workflow migration progress
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -34,6 +34,32 @@ const EMPTY_FORM = {
|
||||
lighting_only: false,
|
||||
shadow_catcher_enabled: false,
|
||||
camera_orbit: true,
|
||||
workflow_input_schema_text: '[]',
|
||||
}
|
||||
|
||||
function stringifyWorkflowInputSchema(value: unknown): string {
|
||||
try {
|
||||
return JSON.stringify(Array.isArray(value) ? value : [], null, 2)
|
||||
} catch {
|
||||
return '[]'
|
||||
}
|
||||
}
|
||||
|
||||
function parseWorkflowInputSchemaText(rawValue: unknown): unknown[] {
|
||||
const text = typeof rawValue === 'string' ? rawValue.trim() : ''
|
||||
if (!text) return []
|
||||
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(text)
|
||||
} catch {
|
||||
throw new Error('Workflow input schema must be valid JSON')
|
||||
}
|
||||
|
||||
if (!Array.isArray(parsed)) {
|
||||
throw new Error('Workflow input schema must be a JSON array')
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
export default function RenderTemplateTable() {
|
||||
@@ -43,7 +69,7 @@ export default function RenderTemplateTable() {
|
||||
const [addFile, setAddFile] = useState<File | null>(null)
|
||||
const [cloneBlendFrom, setCloneBlendFrom] = useState<string>('')
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [editDraft, setEditDraft] = useState<Partial<RenderTemplate>>({})
|
||||
const [editDraft, setEditDraft] = useState<(Partial<RenderTemplate> & { workflow_input_schema_text?: string })>({})
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const reuploadRef = useRef<HTMLInputElement>(null)
|
||||
const [reuploadId, setReuploadId] = useState<string | null>(null)
|
||||
@@ -75,6 +101,7 @@ export default function RenderTemplateTable() {
|
||||
fd.append('lighting_only', String(form.lighting_only))
|
||||
fd.append('shadow_catcher_enabled', String(form.shadow_catcher_enabled))
|
||||
fd.append('camera_orbit', String(form.camera_orbit))
|
||||
fd.append('workflow_input_schema', JSON.stringify(parseWorkflowInputSchemaText(form.workflow_input_schema_text)))
|
||||
return createRenderTemplate(fd)
|
||||
},
|
||||
onSuccess: () => {
|
||||
@@ -85,7 +112,7 @@ export default function RenderTemplateTable() {
|
||||
setCloneBlendFrom('')
|
||||
setShowAdd(false)
|
||||
},
|
||||
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to create template'),
|
||||
onError: (e: any) => toast.error(e.response?.data?.detail || e.message || 'Failed to create template'),
|
||||
})
|
||||
|
||||
const updateMut = useMutation({
|
||||
@@ -96,7 +123,7 @@ export default function RenderTemplateTable() {
|
||||
qc.invalidateQueries({ queryKey: ['render-templates'] })
|
||||
setEditingId(null)
|
||||
},
|
||||
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to update'),
|
||||
onError: (e: any) => toast.error(e.response?.data?.detail || e.message || 'Failed to update'),
|
||||
})
|
||||
|
||||
const deleteMut = useMutation({
|
||||
@@ -128,6 +155,7 @@ export default function RenderTemplateTable() {
|
||||
shadow_catcher_enabled: t.shadow_catcher_enabled,
|
||||
camera_orbit: t.camera_orbit,
|
||||
output_type_ids: t.output_type_ids ?? [],
|
||||
workflow_input_schema: t.workflow_input_schema ?? [],
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast.success('Template duplicated')
|
||||
@@ -147,13 +175,19 @@ export default function RenderTemplateTable() {
|
||||
lighting_only: t.lighting_only,
|
||||
shadow_catcher_enabled: t.shadow_catcher_enabled,
|
||||
camera_orbit: t.camera_orbit,
|
||||
workflow_input_schema_text: stringifyWorkflowInputSchema(t.workflow_input_schema),
|
||||
is_active: t.is_active,
|
||||
})
|
||||
}
|
||||
|
||||
function saveEdit() {
|
||||
if (!editingId) return
|
||||
updateMut.mutate({ id: editingId, data: editDraft as Record<string, unknown> })
|
||||
const data: Record<string, unknown> = { ...editDraft }
|
||||
if (Object.prototype.hasOwnProperty.call(editDraft, 'workflow_input_schema_text')) {
|
||||
data.workflow_input_schema = parseWorkflowInputSchemaText(editDraft.workflow_input_schema_text)
|
||||
delete data.workflow_input_schema_text
|
||||
}
|
||||
updateMut.mutate({ id: editingId, data })
|
||||
}
|
||||
|
||||
// Render the edit form grid (shared between edit-row and add-row)
|
||||
@@ -174,6 +208,9 @@ export default function RenderTemplateTable() {
|
||||
if (field === 'lighting_only') return editDraft.lighting_only ?? t!.lighting_only
|
||||
if (field === 'shadow_catcher_enabled') return editDraft.shadow_catcher_enabled ?? t!.shadow_catcher_enabled
|
||||
if (field === 'camera_orbit') return editDraft.camera_orbit ?? t!.camera_orbit
|
||||
if (field === 'workflow_input_schema_text') {
|
||||
return editDraft.workflow_input_schema_text ?? stringifyWorkflowInputSchema(t!.workflow_input_schema)
|
||||
}
|
||||
if (field === 'is_active') return editDraft.is_active ?? t!.is_active
|
||||
return (editDraft as any)[field] ?? (t as any)[field]
|
||||
}
|
||||
@@ -381,7 +418,27 @@ export default function RenderTemplateTable() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Row 4: Active + Save/Cancel */}
|
||||
<div className="mt-4">
|
||||
<label className="block text-xs font-medium text-content-muted mb-1">
|
||||
Workflow Input Schema (JSON)
|
||||
</label>
|
||||
<textarea
|
||||
className="input-sm w-full min-h-36 font-mono text-xs"
|
||||
value={String(val('workflow_input_schema_text') ?? '[]')}
|
||||
onChange={(e) => set('workflow_input_schema_text', e.target.value)}
|
||||
placeholder='[{"key":"studio_variant","label":"Studio Variant","type":"select","options":[{"value":"default","label":"Default"}]}]'
|
||||
/>
|
||||
<p className="mt-1 text-xs text-content-muted">
|
||||
Defines additional `resolve_template` node inputs for this .blend template.
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-content-muted">
|
||||
Matching variants can be bound inside the template via markers like
|
||||
`template-input:studio_variant=warm` or a `template_input=studio_variant=warm`
|
||||
custom property on collections, objects, or worlds.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Row 5: Active + Save/Cancel */}
|
||||
<div className="flex items-center justify-between mt-4 pt-3 border-t border-border-light">
|
||||
{isEdit ? (
|
||||
<label className="flex items-center gap-2">
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { OutputTypeWorkflowRolloutMode } from '../../api/outputTypes'
|
||||
|
||||
export interface OutputTypeRolloutPresentation {
|
||||
badgeLabel: string
|
||||
badgeClassName: string
|
||||
statusLabel: string
|
||||
statusClassName: string
|
||||
operatorHint: string
|
||||
rowSummary: string
|
||||
}
|
||||
|
||||
interface OutputTypeRolloutPresentationOptions {
|
||||
hasWorkflowLink: boolean
|
||||
workflowRolloutMode: OutputTypeWorkflowRolloutMode
|
||||
hasBlockingIssues?: boolean
|
||||
}
|
||||
|
||||
export function getOutputTypeRolloutPresentation({
|
||||
hasWorkflowLink,
|
||||
workflowRolloutMode,
|
||||
hasBlockingIssues = false,
|
||||
}: OutputTypeRolloutPresentationOptions): OutputTypeRolloutPresentation {
|
||||
if (!hasWorkflowLink) {
|
||||
return {
|
||||
badgeLabel: 'Legacy Only',
|
||||
badgeClassName: 'bg-surface-muted text-content-muted',
|
||||
statusLabel: 'Production: Legacy',
|
||||
statusClassName: 'bg-slate-100 text-slate-700',
|
||||
operatorHint:
|
||||
'No workflow is linked. Production stays entirely on the legacy dispatcher until a compatible graph workflow is attached.',
|
||||
rowSummary: 'No linked graph workflow.',
|
||||
}
|
||||
}
|
||||
|
||||
if (hasBlockingIssues) {
|
||||
return {
|
||||
badgeLabel: 'Contract Blocked',
|
||||
badgeClassName: 'bg-red-100 text-red-700',
|
||||
statusLabel: 'Do Not Promote',
|
||||
statusClassName: 'bg-red-100 text-red-700',
|
||||
operatorHint:
|
||||
'The current workflow binding is contract-invalid. Keep legacy authoritative until family, artifact, and rollout settings are fixed.',
|
||||
rowSummary: 'Linked workflow needs contract fixes before rollout.',
|
||||
}
|
||||
}
|
||||
|
||||
switch (workflowRolloutMode) {
|
||||
case 'graph':
|
||||
return {
|
||||
badgeLabel: 'Graph Authoritative',
|
||||
badgeClassName: 'bg-status-success-bg text-status-success-text',
|
||||
statusLabel: 'Production: Graph',
|
||||
statusClassName: 'bg-emerald-100 text-emerald-700',
|
||||
operatorHint:
|
||||
'Graph dispatch is authoritative for production. Legacy remains the operational fallback if graph dispatch fails.',
|
||||
rowSummary: 'Graph drives production with legacy fallback armed.',
|
||||
}
|
||||
case 'shadow':
|
||||
return {
|
||||
badgeLabel: 'Shadow',
|
||||
badgeClassName: 'bg-status-info-bg text-status-info-text',
|
||||
statusLabel: 'Production: Legacy',
|
||||
statusClassName: 'bg-sky-100 text-sky-700',
|
||||
operatorHint:
|
||||
'Legacy stays authoritative while the graph runs as an observer for parity and rollout-gate checks.',
|
||||
rowSummary: 'Graph observes only; legacy remains authoritative.',
|
||||
}
|
||||
case 'legacy_only':
|
||||
default:
|
||||
return {
|
||||
badgeLabel: 'Legacy Only',
|
||||
badgeClassName: 'bg-surface-muted text-content-muted',
|
||||
statusLabel: 'Production: Legacy',
|
||||
statusClassName: 'bg-slate-100 text-slate-700',
|
||||
operatorHint:
|
||||
'A workflow is linked for authoring and future rollout, but production dispatch remains on the legacy path.',
|
||||
rowSummary: 'Linked graph is not active in production.',
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,15 +2,25 @@ import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'rea
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Canvas, useThree } from '@react-three/fiber'
|
||||
import { OrbitControls, useGLTF, Environment } from '@react-three/drei'
|
||||
import * as THREE from 'three'
|
||||
import {
|
||||
Box3,
|
||||
Group,
|
||||
Mesh,
|
||||
MeshStandardMaterial,
|
||||
Object3D,
|
||||
PerspectiveCamera,
|
||||
Vector3,
|
||||
type Material,
|
||||
} from 'three'
|
||||
import { mergeVertices } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
|
||||
import { Loader2, Box, RefreshCw, Grid3X3, Layers, Sun, AlertCircle, EyeOff, Zap } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { listMediaAssets as getMediaAssets } from '../../api/media'
|
||||
import { generateGltfGeometry, getPartMaterials, type PartMaterialMap } from '../../api/cad'
|
||||
import { generateGltfGeometry, getManualOverrides, getPartMaterials, type PartMaterialMap } from '../../api/cad'
|
||||
import { fetchSceneManifest } from '../../api/sceneManifest'
|
||||
import { useAuthStore } from '../../store/auth'
|
||||
import MaterialPanel, { type IsolateMode } from './MaterialPanel'
|
||||
import { normalizeMeshName, resolvePartMaterial, remapToPartKeys, applyPBRToMaterial, previewColorForEntry, forEachMeshMaterial, type MeshRegistryEntry } from './cadUtils'
|
||||
import { resolvePartMaterial, remapToPartKeys, applyPBRToMaterial, previewColorForEntry, forEachMeshMaterial, convertSceneManifestMaterials, alignSceneManifestToLogicalPartKeys, buildScenePartRegistry, buildEffectiveViewerMaterials, resolveObjectPartKey, type MeshRegistryEntry } from './cadUtils'
|
||||
import { fetchMaterialPBR, type MaterialPBRMap } from '../../api/assetLibraries'
|
||||
import { useGeometryMerge } from './useGeometryMerge'
|
||||
import WebGLErrorBoundary from './WebGLErrorBoundary'
|
||||
@@ -35,7 +45,7 @@ function CameraAutoFit({
|
||||
controlsRef,
|
||||
fitTrigger,
|
||||
}: {
|
||||
sceneRef: React.MutableRefObject<THREE.Object3D | null>
|
||||
sceneRef: React.MutableRefObject<Object3D | null>
|
||||
controlsRef: React.RefObject<any>
|
||||
fitTrigger: number
|
||||
}) {
|
||||
@@ -43,17 +53,17 @@ function CameraAutoFit({
|
||||
|
||||
useEffect(() => {
|
||||
if (fitTrigger === 0 || !sceneRef.current) return
|
||||
const box = new THREE.Box3()
|
||||
const box = new Box3()
|
||||
sceneRef.current.traverse((obj) => {
|
||||
if ((obj as THREE.Mesh).isMesh) box.expandByObject(obj)
|
||||
if ((obj as Mesh).isMesh) box.expandByObject(obj)
|
||||
})
|
||||
if (box.isEmpty()) return
|
||||
|
||||
const center = box.getCenter(new THREE.Vector3())
|
||||
const sizeVec = box.getSize(new THREE.Vector3())
|
||||
const center = box.getCenter(new Vector3())
|
||||
const sizeVec = box.getSize(new Vector3())
|
||||
const maxDim = Math.max(sizeVec.x, sizeVec.y, sizeVec.z)
|
||||
|
||||
const pc = camera as THREE.PerspectiveCamera
|
||||
const pc = camera as PerspectiveCamera
|
||||
const fovRad = (pc.fov * Math.PI) / 180
|
||||
const aspect = size.width / size.height
|
||||
const fovH = 2 * Math.atan(Math.tan(fovRad / 2) * aspect)
|
||||
@@ -91,19 +101,19 @@ function GlbModelWithFit({
|
||||
}: {
|
||||
url: string
|
||||
wireframe: boolean
|
||||
sceneRef: React.MutableRefObject<THREE.Object3D | null>
|
||||
sceneRef: React.MutableRefObject<Object3D | null>
|
||||
onReady: () => void
|
||||
onPointerOver?: (e: any) => void
|
||||
onPointerOut?: () => void
|
||||
onClick?: (e: any) => void
|
||||
}) {
|
||||
const { scene } = useGLTF(url)
|
||||
const cloned = useRef<THREE.Group | null>(null)
|
||||
const cloned = useRef<Group | null>(null)
|
||||
|
||||
if (!cloned.current) {
|
||||
cloned.current = scene.clone(true)
|
||||
cloned.current.traverse((obj) => {
|
||||
if (obj instanceof THREE.Mesh) {
|
||||
if (obj instanceof Mesh) {
|
||||
if (obj.geometry) {
|
||||
let geo = obj.geometry.clone()
|
||||
if (!geo.index) geo = mergeVertices(geo)
|
||||
@@ -116,7 +126,7 @@ function GlbModelWithFit({
|
||||
// Clone materials so emissive / color changes don't affect the shared GLTF cache
|
||||
if (obj.material) {
|
||||
obj.material = Array.isArray(obj.material)
|
||||
? obj.material.map((m: THREE.Material) => m.clone())
|
||||
? obj.material.map((m: Material) => m.clone())
|
||||
: obj.material.clone()
|
||||
}
|
||||
}
|
||||
@@ -125,10 +135,10 @@ function GlbModelWithFit({
|
||||
|
||||
useEffect(() => {
|
||||
cloned.current?.traverse((obj) => {
|
||||
if (obj instanceof THREE.Mesh && obj.material) {
|
||||
if (obj instanceof Mesh && obj.material) {
|
||||
const mats = Array.isArray(obj.material) ? obj.material : [obj.material]
|
||||
mats.forEach((m) => {
|
||||
;(m as THREE.MeshStandardMaterial).wireframe = wireframe
|
||||
;(m as MeshStandardMaterial).wireframe = wireframe
|
||||
m.needsUpdate = true
|
||||
})
|
||||
}
|
||||
@@ -195,13 +205,13 @@ export default function InlineCadViewer({
|
||||
const [hideAssigned, setHideAssigned] = useState(false)
|
||||
const [isolateMode, setIsolateMode] = useState<IsolateMode>('none')
|
||||
const [perfMode, setPerfMode] = useState(false)
|
||||
const [totalMeshCount, setTotalMeshCount] = useState(0)
|
||||
const [glbMeshNames, setGlbMeshNames] = useState<Set<string>>(new Set())
|
||||
const [logicalPartKeys, setLogicalPartKeys] = useState<Set<string>>(new Set())
|
||||
const [unresolvedMeshNames, setUnresolvedMeshNames] = useState<Set<string>>(new Set())
|
||||
const [partKeyMap, setPartKeyMap] = useState<Record<string, string>>({})
|
||||
|
||||
const sceneRef = useRef<THREE.Object3D | null>(null)
|
||||
const sceneRef = useRef<Object3D | null>(null)
|
||||
const controlsRef = useRef<any>(null)
|
||||
const hoveredMeshRef = useRef<THREE.Mesh | null>(null)
|
||||
const hoveredMeshRef = useRef<Mesh | null>(null)
|
||||
const meshRegistryRef = useRef<MeshRegistryEntry[]>([])
|
||||
|
||||
// Media asset queries
|
||||
@@ -220,6 +230,20 @@ export default function InlineCadViewer({
|
||||
retry: false,
|
||||
})
|
||||
|
||||
const { data: sceneManifest } = useQuery({
|
||||
queryKey: ['scene-manifest', cadFileId],
|
||||
queryFn: () => fetchSceneManifest(cadFileId),
|
||||
staleTime: Infinity,
|
||||
retry: false,
|
||||
})
|
||||
|
||||
const { data: manualOverrides = {} } = useQuery({
|
||||
queryKey: ['manual-overrides', cadFileId],
|
||||
queryFn: () => getManualOverrides(cadFileId),
|
||||
staleTime: 30_000,
|
||||
retry: false,
|
||||
})
|
||||
|
||||
// PBR material properties from Blender asset library
|
||||
const { data: pbrMap = {} as MaterialPBRMap } = useQuery({
|
||||
queryKey: ['material-pbr'],
|
||||
@@ -227,23 +251,33 @@ export default function InlineCadViewer({
|
||||
staleTime: 300_000,
|
||||
})
|
||||
|
||||
// Merge: initialPartMaterials (from Product Excel data) as base; savedPartMaterials overrides
|
||||
// Remap keys through partKeyMap so Excel-imported names match partKey slugs
|
||||
const partMaterials = useMemo(
|
||||
const manifestMaterials = useMemo(
|
||||
() => alignSceneManifestToLogicalPartKeys(
|
||||
convertSceneManifestMaterials(sceneManifest?.parts ?? []),
|
||||
logicalPartKeys,
|
||||
),
|
||||
[sceneManifest, logicalPartKeys],
|
||||
)
|
||||
|
||||
const fallbackMaterials = useMemo(
|
||||
() => remapToPartKeys({ ...initialPartMaterials, ...savedPartMaterials } as PartMaterialMap, partKeyMap),
|
||||
[initialPartMaterials, savedPartMaterials, partKeyMap],
|
||||
)
|
||||
|
||||
// Resolve partKey from normalized mesh name (identity fallback when no map loaded)
|
||||
const resolvePartKey = useCallback(
|
||||
(normalizedName: string): string => partKeyMap[normalizedName] ?? normalizedName,
|
||||
[partKeyMap],
|
||||
// Merge authoritative manifest assignments with legacy/viewer fallback so the
|
||||
// inline viewer consumes the same effective source contract as the main viewer.
|
||||
const partMaterials = useMemo(
|
||||
() => buildEffectiveViewerMaterials(manifestMaterials, fallbackMaterials, manualOverrides),
|
||||
[manifestMaterials, fallbackMaterials, manualOverrides],
|
||||
)
|
||||
|
||||
const resolvedMeshCount = logicalPartKeys.size
|
||||
const unresolvedMeshCount = unresolvedMeshNames.size
|
||||
|
||||
// Count how many unique GLB mesh types have a resolved material assignment
|
||||
const assignedCount = useMemo(
|
||||
() => [...glbMeshNames].filter(n => !!resolvePartMaterial(n, partMaterials)).length,
|
||||
[glbMeshNames, partMaterials],
|
||||
() => [...logicalPartKeys].filter(n => !!resolvePartMaterial(n, partMaterials)).length,
|
||||
[logicalPartKeys, partMaterials],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -282,7 +316,7 @@ export default function InlineCadViewer({
|
||||
// Clone materials on first PBR application (GLB loader shares instances)
|
||||
if (!mesh.userData._pbrApplied) {
|
||||
mesh.material = Array.isArray(mesh.material)
|
||||
? mesh.material.map((m: THREE.Material) => m.clone())
|
||||
? mesh.material.map((m: Material) => m.clone())
|
||||
: mesh.material.clone()
|
||||
mesh.userData._pbrApplied = true
|
||||
}
|
||||
@@ -294,7 +328,7 @@ export default function InlineCadViewer({
|
||||
}
|
||||
})
|
||||
}
|
||||
}, [modelReady, partMaterials, resolvePartKey, pbrMap])
|
||||
}, [modelReady, partMaterials, pbrMap])
|
||||
|
||||
// Unassigned glow — uses MeshRegistry instead of traverse
|
||||
useEffect(() => {
|
||||
@@ -313,7 +347,7 @@ export default function InlineCadViewer({
|
||||
}
|
||||
})
|
||||
}
|
||||
}, [modelReady, showUnassigned, partMaterials, resolvePartKey])
|
||||
}, [modelReady, showUnassigned, partMaterials])
|
||||
|
||||
// Reset isolateMode when no part is pinned
|
||||
useEffect(() => {
|
||||
@@ -334,7 +368,7 @@ export default function InlineCadViewer({
|
||||
|
||||
// Default: fully visible + raycasting enabled
|
||||
mesh.visible = true
|
||||
mesh.raycast = THREE.Mesh.prototype.raycast
|
||||
mesh.raycast = Mesh.prototype.raycast
|
||||
forEachMeshMaterial(mesh, (mat) => {
|
||||
if ('opacity' in mat) { mat.opacity = 1; mat.transparent = false; mat.depthWrite = true; mat.needsUpdate = true }
|
||||
})
|
||||
@@ -358,7 +392,7 @@ export default function InlineCadViewer({
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [modelReady, pinnedPart, isolateMode, hideAssigned, partMaterials, resolvePartKey])
|
||||
}, [modelReady, pinnedPart, isolateMode, hideAssigned, partMaterials])
|
||||
|
||||
// Dev-only: log normalized GLB mesh names vs stored keys to diagnose mismatches
|
||||
useEffect(() => {
|
||||
@@ -399,16 +433,16 @@ export default function InlineCadViewer({
|
||||
// Hover highlight
|
||||
const handlePointerOver = useCallback((e: any) => {
|
||||
e.stopPropagation()
|
||||
const mesh = e.object as THREE.Mesh
|
||||
const mesh = e.object as Mesh
|
||||
// Restore previous hovered mesh (correctly preserve unassigned glow)
|
||||
if (hoveredMeshRef.current && hoveredMeshRef.current !== mesh) {
|
||||
const prev = hoveredMeshRef.current
|
||||
const prevMats = Array.isArray(prev.material) ? prev.material : [prev.material]
|
||||
const hasAny = Object.keys(partMaterials).length > 0
|
||||
prevMats.forEach((m) => {
|
||||
const mat = m as THREE.MeshStandardMaterial
|
||||
const mat = m as MeshStandardMaterial
|
||||
if (!mat || !('emissive' in mat)) return
|
||||
const prevPk = (prev.userData?.partKey as string) || resolvePartKey(normalizeMeshName((prev.userData?.name as string) || prev.name))
|
||||
const prevPk = resolveObjectPartKey(prev, partKeyMap)
|
||||
if (showUnassigned && hasAny && !resolvePartMaterial(prevPk, partMaterials as PartMaterialMap)) {
|
||||
mat.emissive.set(0xff4400); mat.emissiveIntensity = 0.8
|
||||
} else {
|
||||
@@ -419,10 +453,10 @@ export default function InlineCadViewer({
|
||||
hoveredMeshRef.current = mesh
|
||||
const mats = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
|
||||
mats.forEach((m) => {
|
||||
const mat = m as THREE.MeshStandardMaterial
|
||||
const mat = m as MeshStandardMaterial
|
||||
if (mat && 'emissive' in mat) { mat.emissive.set(0x333333); mat.emissiveIntensity = 0.5 }
|
||||
})
|
||||
}, [showUnassigned, partMaterials, resolvePartKey])
|
||||
}, [showUnassigned, partMaterials, partKeyMap])
|
||||
|
||||
const handlePointerOut = useCallback(() => {
|
||||
if (hoveredMeshRef.current) {
|
||||
@@ -430,9 +464,9 @@ export default function InlineCadViewer({
|
||||
const mats = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
|
||||
const hasAnyAssignment = Object.keys(partMaterials).length > 0
|
||||
mats.forEach((m) => {
|
||||
const mat = m as THREE.MeshStandardMaterial
|
||||
const mat = m as MeshStandardMaterial
|
||||
if (!mat || !('emissive' in mat)) return
|
||||
const pk = (mesh.userData?.partKey as string) || resolvePartKey(normalizeMeshName((mesh.userData?.name as string) || mesh.name))
|
||||
const pk = resolveObjectPartKey(mesh, partKeyMap)
|
||||
if (showUnassigned && hasAnyAssignment && !resolvePartMaterial(pk, partMaterials as PartMaterialMap)) {
|
||||
mat.emissive.set(0xff4400); mat.emissiveIntensity = 0.8
|
||||
} else {
|
||||
@@ -441,14 +475,14 @@ export default function InlineCadViewer({
|
||||
})
|
||||
hoveredMeshRef.current = null
|
||||
}
|
||||
}, [showUnassigned, partMaterials, resolvePartKey])
|
||||
}, [showUnassigned, partMaterials, partKeyMap])
|
||||
|
||||
const handleClick = useCallback((e: any) => {
|
||||
e.stopPropagation()
|
||||
const meshObj = e.object as THREE.Mesh
|
||||
const pk = (meshObj?.userData?.partKey as string) || resolvePartKey(normalizeMeshName((meshObj?.userData?.name as string) || meshObj?.name || ''))
|
||||
const meshObj = e.object as Mesh
|
||||
const pk = resolveObjectPartKey(meshObj, partKeyMap)
|
||||
if (pk) setPinnedPart(pk)
|
||||
}, [resolvePartKey])
|
||||
}, [partKeyMap])
|
||||
|
||||
// ── Render: model loaded ──────────────────────────────────────────────────
|
||||
|
||||
@@ -498,10 +532,15 @@ export default function InlineCadViewer({
|
||||
<ToolbarBtn
|
||||
active={showUnassigned}
|
||||
onClick={() => setShowUnassigned(v => !v)}
|
||||
title={`Highlight unassigned parts (${assignedCount}/${totalMeshCount} assigned)`}
|
||||
title={`Highlight unassigned parts (${assignedCount}/${resolvedMeshCount} resolved${unresolvedMeshCount > 0 ? `, ${unresolvedMeshCount} unresolved` : ''})`}
|
||||
>
|
||||
<AlertCircle size={11} />
|
||||
<span className="tabular-nums text-[10px]">{assignedCount}/{totalMeshCount}</span>
|
||||
<span className="tabular-nums text-[10px]">{assignedCount}/{resolvedMeshCount}</span>
|
||||
{unresolvedMeshCount > 0 && (
|
||||
<span className="text-[10px] text-amber-300 tabular-nums" title={`${unresolvedMeshCount} unresolved meshes without authoritative part mapping`}>
|
||||
?{unresolvedMeshCount}
|
||||
</span>
|
||||
)}
|
||||
</ToolbarBtn>
|
||||
{assignedCount > 0 && (
|
||||
<ToolbarBtn
|
||||
@@ -536,32 +575,12 @@ export default function InlineCadViewer({
|
||||
// Extract partKeyMap from GLB extras
|
||||
const glbExtras = (sceneRef.current as any)?.userData ?? {}
|
||||
const map = glbExtras.partKeyMap as Record<string, string> | undefined
|
||||
if (map && Object.keys(map).length > 0) {
|
||||
setPartKeyMap(map)
|
||||
}
|
||||
// Single traverse: stamp partKey, build registry, count unique parts
|
||||
const registry: MeshRegistryEntry[] = []
|
||||
const names = new Set<string>()
|
||||
sceneRef.current?.traverse((obj) => {
|
||||
if (!(obj instanceof THREE.Mesh)) return
|
||||
// Stamp partKey from parent Group or partKeyMap
|
||||
if (!obj.userData.partKey) {
|
||||
const parentPk = obj.parent?.userData?.partKey as string | undefined
|
||||
if (parentPk) {
|
||||
obj.userData.partKey = parentPk
|
||||
} else if (map) {
|
||||
const normalized = normalizeMeshName((obj.userData?.name as string) || obj.name)
|
||||
obj.userData.partKey = map[normalized] ?? normalized
|
||||
}
|
||||
}
|
||||
const pk = (obj.userData?.partKey as string) ||
|
||||
normalizeMeshName((obj.userData?.name as string) || obj.name)
|
||||
registry.push({ mesh: obj, partKey: pk })
|
||||
if (pk) names.add(pk)
|
||||
})
|
||||
meshRegistryRef.current = registry
|
||||
setTotalMeshCount(names.size)
|
||||
setGlbMeshNames(new Set(names))
|
||||
const resolvedMap = map ?? {}
|
||||
if (Object.keys(resolvedMap).length > 0) setPartKeyMap(resolvedMap)
|
||||
const { meshRegistry, logicalPartKeys, unresolvedMeshNames } = buildScenePartRegistry(sceneRef.current, resolvedMap)
|
||||
meshRegistryRef.current = meshRegistry
|
||||
setLogicalPartKeys(new Set(logicalPartKeys))
|
||||
setUnresolvedMeshNames(new Set(unresolvedMeshNames))
|
||||
setModelReady(true)
|
||||
setFitTrigger(t => t + 1)
|
||||
}}
|
||||
|
||||
@@ -21,7 +21,17 @@ import {
|
||||
GizmoHelper,
|
||||
GizmoViewport,
|
||||
} from '@react-three/drei'
|
||||
import * as THREE from 'three'
|
||||
import {
|
||||
Box3,
|
||||
Color,
|
||||
Mesh,
|
||||
MeshStandardMaterial,
|
||||
Object3D,
|
||||
OrthographicCamera as ThreeOrthographicCamera,
|
||||
PerspectiveCamera as ThreePerspectiveCamera,
|
||||
Vector3,
|
||||
type Material,
|
||||
} from 'three'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
X, Camera, Loader2, AlertTriangle, Box, Download, ChevronDown,
|
||||
@@ -32,7 +42,7 @@ import { getParsedObjects, getPartMaterials, getManualOverrides, type PartMateri
|
||||
import { fetchSceneManifest } from '../../api/sceneManifest'
|
||||
import { useAuthStore } from '../../store/auth'
|
||||
import MaterialPanel, { type IsolateMode } from './MaterialPanel'
|
||||
import { normalizeMeshName, resolvePartMaterial, remapToPartKeys, applyPBRToMaterial, previewColorForEntry, forEachMeshMaterial, type MeshRegistryEntry } from './cadUtils'
|
||||
import { resolvePartMaterial, remapToPartKeys, applyPBRToMaterial, previewColorForEntry, forEachMeshMaterial, convertSceneManifestMaterials, alignSceneManifestToLogicalPartKeys, buildScenePartRegistry, resolveObjectPartKey, buildEffectiveViewerMaterials, type MeshRegistryEntry } from './cadUtils'
|
||||
import { fetchMaterialPBR, type MaterialPBRMap } from '../../api/assetLibraries'
|
||||
import { useGeometryMerge } from './useGeometryMerge'
|
||||
import WebGLErrorBoundary from './WebGLErrorBoundary'
|
||||
@@ -68,7 +78,7 @@ const BG_COLORS = [
|
||||
]
|
||||
|
||||
interface SceneInfo {
|
||||
center: THREE.Vector3
|
||||
center: Vector3
|
||||
maxDim: number
|
||||
groundY: number
|
||||
}
|
||||
@@ -84,7 +94,7 @@ function CameraFit({
|
||||
isOrtho,
|
||||
onFitted,
|
||||
}: {
|
||||
sceneRef: React.MutableRefObject<THREE.Object3D | null>
|
||||
sceneRef: React.MutableRefObject<Object3D | null>
|
||||
controlsRef: React.RefObject<any>
|
||||
fitTrigger: number
|
||||
isOrtho: boolean
|
||||
@@ -96,19 +106,19 @@ function CameraFit({
|
||||
if (fitTrigger === 0 || !sceneRef.current) return
|
||||
|
||||
// Compute bbox from meshes only (ignore lights / empty groups)
|
||||
const box = new THREE.Box3()
|
||||
const box = new Box3()
|
||||
sceneRef.current.traverse((obj) => {
|
||||
if ((obj as THREE.Mesh).isMesh) box.expandByObject(obj)
|
||||
if ((obj as Mesh).isMesh) box.expandByObject(obj)
|
||||
})
|
||||
if (box.isEmpty()) return
|
||||
|
||||
const center = box.getCenter(new THREE.Vector3())
|
||||
const sizeVec = box.getSize(new THREE.Vector3())
|
||||
const center = box.getCenter(new Vector3())
|
||||
const sizeVec = box.getSize(new Vector3())
|
||||
const maxDim = Math.max(sizeVec.x, sizeVec.y, sizeVec.z)
|
||||
const groundY = box.min.y
|
||||
|
||||
if (isOrtho) {
|
||||
const oc = camera as THREE.OrthographicCamera
|
||||
const oc = camera as ThreeOrthographicCamera
|
||||
const aspect = size.width / size.height
|
||||
const halfH = maxDim * 0.8
|
||||
oc.left = -halfH * aspect
|
||||
@@ -121,7 +131,7 @@ function CameraFit({
|
||||
oc.updateProjectionMatrix()
|
||||
camera.position.set(center.x, center.y, center.z + maxDim * 5)
|
||||
} else {
|
||||
const pc = camera as THREE.PerspectiveCamera
|
||||
const pc = camera as ThreePerspectiveCamera
|
||||
const fovRad = (pc.fov * Math.PI) / 180
|
||||
const aspect = size.width / size.height
|
||||
const fovH = 2 * Math.atan(Math.tan(fovRad / 2) * aspect)
|
||||
@@ -156,7 +166,7 @@ function CameraFit({
|
||||
function SceneBackground({ color }: { color: string }) {
|
||||
const { scene } = useThree()
|
||||
useEffect(() => {
|
||||
scene.background = new THREE.Color(color)
|
||||
scene.background = new Color(color)
|
||||
return () => { scene.background = null }
|
||||
}, [color, scene])
|
||||
return null
|
||||
@@ -182,7 +192,7 @@ interface ModelWithReadyProps {
|
||||
url: string
|
||||
wireframe: boolean
|
||||
onReady: () => void
|
||||
sceneRef: React.MutableRefObject<THREE.Object3D | null>
|
||||
sceneRef: React.MutableRefObject<Object3D | null>
|
||||
onPointerOver?: (e: any) => void
|
||||
onPointerOut?: () => void
|
||||
onClick?: (e: any) => void
|
||||
@@ -385,7 +395,7 @@ export default function ThreeDViewer({
|
||||
const [hoverInfo, setHoverInfo] = useState<{ name: string; x: number; y: number } | null>(null)
|
||||
|
||||
// Task 5 — hovered mesh ref for emissive highlight
|
||||
const hoveredMeshRef = useRef<THREE.Mesh | null>(null)
|
||||
const hoveredMeshRef = useRef<Mesh | null>(null)
|
||||
|
||||
// partKey map from GLB extras: normalizedMeshName → partKey slug
|
||||
const [partKeyMap, setPartKeyMap] = useState<Record<string, string>>({})
|
||||
@@ -409,7 +419,7 @@ export default function ThreeDViewer({
|
||||
const [perfMode, setPerfMode] = useState(false)
|
||||
|
||||
// Refs
|
||||
const sceneRef = useRef<THREE.Object3D | null>(null)
|
||||
const sceneRef = useRef<Object3D | null>(null)
|
||||
const controlsRef = useRef<any>(null)
|
||||
const camPosRef = useRef<[number, number, number]>([0, 0.1, 0.3])
|
||||
|
||||
@@ -443,8 +453,8 @@ export default function ThreeDViewer({
|
||||
})
|
||||
|
||||
// Total unique normalized mesh count (set once when model is ready)
|
||||
const [totalMeshCount, setTotalMeshCount] = useState(0)
|
||||
const [glbMeshNames, setGlbMeshNames] = useState<Set<string>>(new Set())
|
||||
const [logicalPartKeys, setLogicalPartKeys] = useState<Set<string>>(new Set())
|
||||
const [unresolvedMeshNames, setUnresolvedMeshNames] = useState<Set<string>>(new Set())
|
||||
|
||||
// Task 6 — load saved part-material assignments (manual overrides from the viewer)
|
||||
const { data: savedPartMaterials = {} } = useQuery({
|
||||
@@ -461,37 +471,33 @@ export default function ThreeDViewer({
|
||||
staleTime: 300_000,
|
||||
})
|
||||
|
||||
// Merge: initialPartMaterials (Product Excel data) as base; savedPartMaterials overrides
|
||||
const partMaterials = useMemo(
|
||||
() => ({ ...initialPartMaterials, ...savedPartMaterials } as PartMaterialMap),
|
||||
[initialPartMaterials, savedPartMaterials],
|
||||
const manifestMaterials = useMemo(
|
||||
() => alignSceneManifestToLogicalPartKeys(
|
||||
convertSceneManifestMaterials(sceneManifest?.parts ?? []),
|
||||
logicalPartKeys,
|
||||
),
|
||||
[sceneManifest, logicalPartKeys],
|
||||
)
|
||||
|
||||
// Effective materials: remap Excel-imported keys to partKey slugs (when
|
||||
// partKeyMap is available), then layer manual overrides on top.
|
||||
const effectiveMaterials = useMemo(() => {
|
||||
// Remap normalized OCC name keys → partKey slugs so they match mesh resolution
|
||||
const remapped = remapToPartKeys(partMaterials, partKeyMap)
|
||||
// Manual overrides are already keyed by partKey slug
|
||||
const fromManual: PartMaterialMap = Object.fromEntries(
|
||||
Object.entries(manualOverrides).map(([k, v]) => [
|
||||
k,
|
||||
{ type: (v.startsWith('#') ? 'hex' : 'library') as 'hex' | 'library', value: v },
|
||||
])
|
||||
)
|
||||
return { ...remapped, ...fromManual }
|
||||
}, [partMaterials, manualOverrides, partKeyMap])
|
||||
|
||||
// Resolve partKey from normalized mesh name (identity fallback when no map loaded)
|
||||
const resolvePartKey = useCallback(
|
||||
(normalizedName: string): string => partKeyMap[normalizedName] ?? normalizedName,
|
||||
[partKeyMap],
|
||||
const fallbackMaterials = useMemo(
|
||||
() => remapToPartKeys({ ...initialPartMaterials, ...savedPartMaterials } as PartMaterialMap, partKeyMap),
|
||||
[initialPartMaterials, savedPartMaterials, partKeyMap],
|
||||
)
|
||||
|
||||
// Merge scene-manifest and legacy/product sources so richer CAD mappings stay
|
||||
// authoritative while graph/USD manifest keys still fill gaps.
|
||||
const effectiveMaterials = useMemo(
|
||||
() => buildEffectiveViewerMaterials(manifestMaterials, fallbackMaterials, manualOverrides),
|
||||
[manifestMaterials, fallbackMaterials, manualOverrides],
|
||||
)
|
||||
|
||||
const resolvedMeshCount = logicalPartKeys.size
|
||||
const unresolvedMeshCount = unresolvedMeshNames.size
|
||||
|
||||
// Count how many unique GLB mesh types have a resolved material assignment
|
||||
const assignedCount = useMemo(
|
||||
() => [...glbMeshNames].filter(n => !!resolvePartMaterial(n, effectiveMaterials)).length,
|
||||
[glbMeshNames, effectiveMaterials],
|
||||
() => [...logicalPartKeys].filter(n => !!resolvePartMaterial(n, effectiveMaterials)).length,
|
||||
[logicalPartKeys, effectiveMaterials],
|
||||
)
|
||||
|
||||
// Raw URL (used as stable key before blob fetch)
|
||||
@@ -547,33 +553,10 @@ export default function ThreeDViewer({
|
||||
setPartKeyMap(map)
|
||||
}
|
||||
|
||||
// Single traverse: stamp partKey, build registry, count unique parts
|
||||
const registry: MeshRegistryEntry[] = []
|
||||
const names = new Set<string>()
|
||||
sceneRef.current.traverse((obj) => {
|
||||
if (!(obj instanceof THREE.Mesh)) return
|
||||
|
||||
// Stamp userData.partKey (propagate from parent Group for multi-primitive GLB nodes)
|
||||
if (!obj.userData.partKey) {
|
||||
const parentPk = obj.parent?.userData?.partKey as string | undefined
|
||||
if (parentPk) {
|
||||
obj.userData.partKey = parentPk
|
||||
} else if (map) {
|
||||
const normalized = normalizeMeshName((obj.userData?.name as string) || obj.name)
|
||||
obj.userData.partKey = map[normalized] ?? normalized
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve partKey for this mesh
|
||||
const pk = (obj.userData?.partKey as string) ||
|
||||
normalizeMeshName((obj.userData?.name as string) || obj.name)
|
||||
|
||||
registry.push({ mesh: obj, partKey: pk })
|
||||
if (pk) names.add(pk)
|
||||
})
|
||||
meshRegistryRef.current = registry
|
||||
setTotalMeshCount(names.size)
|
||||
setGlbMeshNames(new Set(names))
|
||||
const { meshRegistry, logicalPartKeys, unresolvedMeshNames } = buildScenePartRegistry(sceneRef.current, map ?? {})
|
||||
meshRegistryRef.current = meshRegistry
|
||||
setLogicalPartKeys(new Set(logicalPartKeys))
|
||||
setUnresolvedMeshNames(new Set(unresolvedMeshNames))
|
||||
}, [modelReady])
|
||||
|
||||
// Re-fit when switching projection mode
|
||||
@@ -592,7 +575,7 @@ export default function ThreeDViewer({
|
||||
// Clone materials on first PBR application (GLB loader shares instances)
|
||||
if (!mesh.userData._pbrApplied) {
|
||||
mesh.material = Array.isArray(mesh.material)
|
||||
? mesh.material.map((m: THREE.Material) => m.clone())
|
||||
? mesh.material.map((m: Material) => m.clone())
|
||||
: mesh.material.clone()
|
||||
mesh.userData._pbrApplied = true
|
||||
}
|
||||
@@ -604,7 +587,7 @@ export default function ThreeDViewer({
|
||||
}
|
||||
})
|
||||
}
|
||||
}, [modelReady, effectiveMaterials, resolvePartKey, pbrMap])
|
||||
}, [modelReady, effectiveMaterials, pbrMap])
|
||||
|
||||
// Apply/remove unassigned highlight — uses MeshRegistry instead of traverse
|
||||
useEffect(() => {
|
||||
@@ -623,7 +606,7 @@ export default function ThreeDViewer({
|
||||
}
|
||||
})
|
||||
}
|
||||
}, [modelReady, showUnassigned, effectiveMaterials, resolvePartKey])
|
||||
}, [modelReady, showUnassigned, effectiveMaterials])
|
||||
|
||||
// Reset isolateMode when no part is pinned
|
||||
useEffect(() => {
|
||||
@@ -644,7 +627,7 @@ export default function ThreeDViewer({
|
||||
|
||||
// Default: fully visible + raycasting enabled
|
||||
mesh.visible = true
|
||||
mesh.raycast = THREE.Mesh.prototype.raycast
|
||||
mesh.raycast = Mesh.prototype.raycast
|
||||
forEachMeshMaterial(mesh, (mat) => {
|
||||
if ('opacity' in mat) { mat.opacity = 1; mat.transparent = false; mat.depthWrite = true; mat.needsUpdate = true }
|
||||
})
|
||||
@@ -668,7 +651,7 @@ export default function ThreeDViewer({
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [modelReady, pinnedPart, isolateMode, hideAssigned, effectiveMaterials, resolvePartKey])
|
||||
}, [modelReady, pinnedPart, isolateMode, hideAssigned, effectiveMaterials])
|
||||
|
||||
// Keyboard shortcuts
|
||||
useEffect(() => {
|
||||
@@ -706,12 +689,8 @@ export default function ThreeDViewer({
|
||||
// Task 5 — hover: highlight mesh with emissive, restore on out
|
||||
const handlePointerOver = useCallback((e: any) => {
|
||||
e.stopPropagation()
|
||||
const mesh = e.object as THREE.Mesh
|
||||
const raw = (mesh?.userData?.name as string) || mesh?.name || (mesh?.parent?.userData?.name as string) || mesh?.parent?.name || ''
|
||||
const normalized = normalizeMeshName(raw) || 'Part'
|
||||
// Task 3: prefer userData.partKey (set by GLB node extras or Task 2 stamp) over
|
||||
// raw normalized name so tooltip shows canonical slug (e.g. "ring_outer") not OCC name
|
||||
const displayName = (mesh?.userData?.partKey as string | undefined) ?? resolvePartKey(normalized)
|
||||
const mesh = e.object as Mesh
|
||||
const displayName = resolveObjectPartKey(mesh, partKeyMap) || 'Part'
|
||||
setHoverInfo({ name: displayName, x: e.nativeEvent.clientX, y: e.nativeEvent.clientY })
|
||||
|
||||
// Restore previous hovered mesh (array-safe)
|
||||
@@ -720,7 +699,7 @@ export default function ThreeDViewer({
|
||||
if (!showUnassigned) {
|
||||
const prevMats = Array.isArray(prev.material) ? prev.material : [prev.material]
|
||||
prevMats.forEach((m) => {
|
||||
const mat = m as THREE.MeshStandardMaterial
|
||||
const mat = m as MeshStandardMaterial
|
||||
if (mat && 'emissive' in mat) { mat.emissive.set(0x000000); mat.emissiveIntensity = 0 }
|
||||
})
|
||||
}
|
||||
@@ -732,10 +711,10 @@ export default function ThreeDViewer({
|
||||
? (Array.isArray(mesh.material) ? mesh.material : [mesh.material])
|
||||
: []
|
||||
mats.forEach((m) => {
|
||||
const mat = m as THREE.MeshStandardMaterial
|
||||
const mat = m as MeshStandardMaterial
|
||||
if (mat && 'emissive' in mat) { mat.emissive.set(0x333333); mat.emissiveIntensity = 0.5 }
|
||||
})
|
||||
}, [showUnassigned, resolvePartKey])
|
||||
}, [showUnassigned, partKeyMap])
|
||||
|
||||
const handlePointerOut = useCallback(() => {
|
||||
setHoverInfo(null)
|
||||
@@ -744,10 +723,10 @@ export default function ThreeDViewer({
|
||||
const mats = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
|
||||
const hasAnyAssignment = Object.keys(effectiveMaterials).length > 0
|
||||
mats.forEach((m) => {
|
||||
const mat = m as THREE.MeshStandardMaterial
|
||||
const mat = m as MeshStandardMaterial
|
||||
if (!mat || !('emissive' in mat)) return
|
||||
const normalized = normalizeMeshName((mesh.userData?.name as string) || mesh.name)
|
||||
if (showUnassigned && hasAnyAssignment && !resolvePartMaterial(resolvePartKey(normalized), effectiveMaterials)) {
|
||||
const partKey = resolveObjectPartKey(mesh, partKeyMap)
|
||||
if (showUnassigned && hasAnyAssignment && !resolvePartMaterial(partKey, effectiveMaterials)) {
|
||||
mat.emissive.set(0xff4400); mat.emissiveIntensity = 0.8
|
||||
} else {
|
||||
mat.emissive.set(0x000000); mat.emissiveIntensity = 0
|
||||
@@ -755,7 +734,7 @@ export default function ThreeDViewer({
|
||||
})
|
||||
hoveredMeshRef.current = null
|
||||
}
|
||||
}, [showUnassigned, effectiveMaterials, resolvePartKey])
|
||||
}, [showUnassigned, effectiveMaterials, partKeyMap])
|
||||
|
||||
const handlePointerMove = useCallback((e: React.PointerEvent) => {
|
||||
setHoverInfo(prev => prev ? { ...prev, x: e.clientX, y: e.clientY } : null)
|
||||
@@ -764,10 +743,10 @@ export default function ThreeDViewer({
|
||||
// Task 7 — click to pin material panel (resolves to partKey slug when available)
|
||||
const handleClick = useCallback((e: any) => {
|
||||
e.stopPropagation()
|
||||
const mesh = e.object as THREE.Mesh
|
||||
const normalized = normalizeMeshName((mesh?.userData?.name as string) || mesh?.name || (mesh?.parent?.userData?.name as string) || mesh?.parent?.name || '')
|
||||
if (normalized) setPinnedPart(resolvePartKey(normalized))
|
||||
}, [resolvePartKey])
|
||||
const mesh = e.object as Mesh
|
||||
const partKey = resolveObjectPartKey(mesh, partKeyMap)
|
||||
if (partKey) setPinnedPart(partKey)
|
||||
}, [partKeyMap])
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex flex-col bg-gray-950" onClick={() => setPinnedPart(null)}>
|
||||
@@ -831,10 +810,15 @@ export default function ThreeDViewer({
|
||||
<TBtn
|
||||
active={showUnassigned}
|
||||
onClick={() => setShowUnassigned(v => !v)}
|
||||
title={`Highlight unassigned parts (${assignedCount}/${totalMeshCount} assigned)`}
|
||||
title={`Highlight unassigned parts (${assignedCount}/${resolvedMeshCount} resolved${unresolvedMeshCount > 0 ? `, ${unresolvedMeshCount} unresolved` : ''})`}
|
||||
>
|
||||
<AlertCircle size={11} />
|
||||
<span className="text-[10px] tabular-nums">{assignedCount}/{totalMeshCount}</span>
|
||||
<span className="text-[10px] tabular-nums">{assignedCount}/{resolvedMeshCount}</span>
|
||||
{unresolvedMeshCount > 0 && (
|
||||
<span className="text-[10px] text-amber-300 tabular-nums" title={`${unresolvedMeshCount} unresolved meshes without authoritative part mapping`}>
|
||||
?{unresolvedMeshCount}
|
||||
</span>
|
||||
)}
|
||||
</TBtn>
|
||||
)}
|
||||
|
||||
@@ -851,18 +835,18 @@ export default function ThreeDViewer({
|
||||
)}
|
||||
|
||||
{/* Reconciliation button — shown when manifest has unmatched/unassigned items */}
|
||||
{sceneManifest && (sceneManifest.unmatched_source_rows.length > 0 || sceneManifest.unassigned_parts.length > 0) && (
|
||||
{(sceneManifest && (sceneManifest.unmatched_source_rows.length > 0 || sceneManifest.unassigned_parts.length > 0)) || unresolvedMeshCount > 0 ? (
|
||||
<TBtn
|
||||
active={showReconcile}
|
||||
onClick={() => setShowReconcile(v => !v)}
|
||||
title={`${sceneManifest.unmatched_source_rows.length} unmatched source rows · ${sceneManifest.unassigned_parts.length} unassigned parts`}
|
||||
title={`${sceneManifest?.unmatched_source_rows.length ?? 0} unmatched source rows · ${sceneManifest?.unassigned_parts.length ?? 0} unassigned parts · ${unresolvedMeshCount} unresolved meshes`}
|
||||
>
|
||||
<AlertTriangle size={11} />
|
||||
<span className="text-[10px] tabular-nums">
|
||||
{sceneManifest.unmatched_source_rows.length + sceneManifest.unassigned_parts.length}
|
||||
{(sceneManifest?.unmatched_source_rows.length ?? 0) + (sceneManifest?.unassigned_parts.length ?? 0) + unresolvedMeshCount}
|
||||
</span>
|
||||
</TBtn>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
{/* Environment */}
|
||||
<EnvDropdown value={envPreset} onChange={setEnvPreset} />
|
||||
@@ -978,7 +962,7 @@ export default function ThreeDViewer({
|
||||
)}
|
||||
|
||||
{/* Reconciliation panel */}
|
||||
{showReconcile && sceneManifest && (
|
||||
{showReconcile && (sceneManifest || unresolvedMeshCount > 0) && (
|
||||
<div
|
||||
className="absolute top-2 right-2 z-30 w-64 bg-gray-900 border border-gray-700 rounded-lg shadow-2xl max-h-[70vh] overflow-y-auto"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
@@ -992,7 +976,23 @@ export default function ThreeDViewer({
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-3 space-y-3">
|
||||
{sceneManifest.unassigned_parts.length > 0 && (
|
||||
{unresolvedMeshCount > 0 && (
|
||||
<div>
|
||||
<p className="text-gray-400 text-[10px] font-medium mb-1.5 uppercase tracking-wider">
|
||||
Unresolved meshes ({unresolvedMeshCount})
|
||||
</p>
|
||||
{[...unresolvedMeshNames].sort().map((meshName) => (
|
||||
<div
|
||||
key={meshName}
|
||||
className="px-2 py-1 text-xs text-amber-300 truncate"
|
||||
title={meshName}
|
||||
>
|
||||
{meshName}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{sceneManifest?.unassigned_parts.length ? (
|
||||
<div>
|
||||
<p className="text-gray-400 text-[10px] font-medium mb-1.5 uppercase tracking-wider">
|
||||
Unassigned parts ({sceneManifest.unassigned_parts.length})
|
||||
@@ -1008,8 +1008,8 @@ export default function ThreeDViewer({
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{sceneManifest.unmatched_source_rows.length > 0 && (
|
||||
) : null}
|
||||
{sceneManifest?.unmatched_source_rows.length ? (
|
||||
<div>
|
||||
<p className="text-gray-400 text-[10px] font-medium mb-1.5 uppercase tracking-wider">
|
||||
Unmatched source rows ({sceneManifest.unmatched_source_rows.length})
|
||||
@@ -1024,7 +1024,7 @@ export default function ThreeDViewer({
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -1082,8 +1082,8 @@ export default function ThreeDViewer({
|
||||
args={[
|
||||
sceneInfo.maxDim * 6,
|
||||
Math.round(sceneInfo.maxDim * 6 / (sceneInfo.maxDim / 10)),
|
||||
new THREE.Color('#3a3a3a'),
|
||||
new THREE.Color('#222222'),
|
||||
new Color('#3a3a3a'),
|
||||
new Color('#222222'),
|
||||
]}
|
||||
position={[sceneInfo.center.x, sceneInfo.groundY - 0.0001, sceneInfo.center.z]}
|
||||
/>
|
||||
|
||||
@@ -27,6 +27,144 @@ export function normalizeMeshName(name: string): string {
|
||||
return n
|
||||
}
|
||||
|
||||
function slugifyMaterialKey(value: string): string {
|
||||
return value.replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '')
|
||||
}
|
||||
|
||||
function buildLegacyMaterialLookup(
|
||||
materialMap: PartMaterialMap,
|
||||
): PartMaterialMap {
|
||||
const lookup: PartMaterialMap = {}
|
||||
for (const [rawKey, entry] of Object.entries(materialMap)) {
|
||||
const normalized = rawKey.toLowerCase().trim()
|
||||
if (!normalized) continue
|
||||
|
||||
lookup[normalized] = entry
|
||||
|
||||
const slugKey = slugifyMaterialKey(normalized)
|
||||
if (slugKey && !lookup[slugKey]) lookup[slugKey] = entry
|
||||
|
||||
const stripped = normalized.replace(/(_af\d+(_\d+)?)+$/gi, '')
|
||||
if (stripped !== normalized) {
|
||||
if (!lookup[stripped]) lookup[stripped] = entry
|
||||
const slugStripped = slugifyMaterialKey(stripped)
|
||||
if (slugStripped && !lookup[slugStripped]) lookup[slugStripped] = entry
|
||||
}
|
||||
}
|
||||
return lookup
|
||||
}
|
||||
|
||||
function commonPrefixLength(left: string, right: string): number {
|
||||
const limit = Math.min(left.length, right.length)
|
||||
let idx = 0
|
||||
while (idx < limit && left[idx] === right[idx]) idx += 1
|
||||
return idx
|
||||
}
|
||||
|
||||
function lookupByPrefix(
|
||||
query: string,
|
||||
materialLookup: PartMaterialMap,
|
||||
): PartMaterialEntry | undefined {
|
||||
if (!query) return undefined
|
||||
|
||||
const contenders: Array<{ keyLength: number; entry: PartMaterialEntry }> = []
|
||||
for (const [key, entry] of Object.entries(materialLookup)) {
|
||||
if (key.length >= 5 && query.length >= 5 && (query.startsWith(key) || key.startsWith(query))) {
|
||||
contenders.push({ keyLength: key.length, entry })
|
||||
}
|
||||
}
|
||||
|
||||
if (contenders.length === 0) return undefined
|
||||
|
||||
contenders.sort((a, b) => b.keyLength - a.keyLength)
|
||||
const topLength = contenders[0].keyLength
|
||||
const closeValues = new Set(
|
||||
contenders
|
||||
.filter((item) => item.keyLength >= topLength - 2)
|
||||
.map((item) => `${item.entry.type}:${item.entry.value}`),
|
||||
)
|
||||
return closeValues.size === 1 ? contenders[0].entry : undefined
|
||||
}
|
||||
|
||||
function lookupByCommonPrefix(
|
||||
query: string,
|
||||
materialLookup: PartMaterialMap,
|
||||
): PartMaterialEntry | undefined {
|
||||
if (!query) return undefined
|
||||
|
||||
const scored: Array<{ ratio: number; prefixLength: number; keyLength: number; entry: PartMaterialEntry }> = []
|
||||
for (const [key, entry] of Object.entries(materialLookup)) {
|
||||
const prefixLength = commonPrefixLength(query, key)
|
||||
if (prefixLength < 12) continue
|
||||
const ratio = prefixLength / Math.max(query.length, key.length)
|
||||
if (ratio < 0.68) continue
|
||||
scored.push({ ratio, prefixLength, keyLength: key.length, entry })
|
||||
}
|
||||
|
||||
if (scored.length === 0) return undefined
|
||||
|
||||
scored.sort((a, b) =>
|
||||
b.ratio - a.ratio ||
|
||||
b.prefixLength - a.prefixLength ||
|
||||
b.keyLength - a.keyLength,
|
||||
)
|
||||
|
||||
const top = scored[0]
|
||||
const closeValues = new Set(
|
||||
scored
|
||||
.filter((item) => item.ratio >= top.ratio - 0.02 && item.prefixLength >= top.prefixLength - 2)
|
||||
.map((item) => `${item.entry.type}:${item.entry.value}`),
|
||||
)
|
||||
return closeValues.size === 1 ? top.entry : undefined
|
||||
}
|
||||
|
||||
function lookupLegacyPartMaterial(
|
||||
rawName: string,
|
||||
materialLookup: PartMaterialMap,
|
||||
...fallbackNames: string[]
|
||||
): PartMaterialEntry | undefined {
|
||||
const candidates = [rawName, ...fallbackNames]
|
||||
const seen = new Set<string>()
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (!candidate) continue
|
||||
|
||||
const normalized = candidate.toLowerCase().trim()
|
||||
const variants = [normalized]
|
||||
|
||||
const stripped = normalized.replace(/(_af\d+(_\d+)?)+$/gi, '')
|
||||
if (stripped !== normalized) variants.push(stripped)
|
||||
|
||||
const noInstance = stripped.replace(/_\d+$/, '')
|
||||
if (noInstance && !variants.includes(noInstance)) variants.push(noInstance)
|
||||
|
||||
for (const variant of [...variants]) {
|
||||
const slug = slugifyMaterialKey(variant)
|
||||
if (slug && !variants.includes(slug)) variants.push(slug)
|
||||
}
|
||||
|
||||
const deduped = variants.filter((variant) => {
|
||||
if (!variant || seen.has(variant)) return false
|
||||
seen.add(variant)
|
||||
return true
|
||||
})
|
||||
|
||||
for (const variant of deduped) {
|
||||
if (materialLookup[variant]) return materialLookup[variant]
|
||||
}
|
||||
for (const variant of deduped) {
|
||||
const matched = lookupByPrefix(variant, materialLookup)
|
||||
if (matched) return matched
|
||||
}
|
||||
for (const variant of deduped) {
|
||||
const matched = lookupByCommonPrefix(variant, materialLookup)
|
||||
if (matched) return matched
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// resolvePartMaterial
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -80,24 +218,13 @@ export function remapToPartKeys(
|
||||
partKeyMap: Record<string, string>,
|
||||
): PartMaterialMap {
|
||||
if (!partKeyMap || Object.keys(partKeyMap).length === 0) return materials
|
||||
const mapKeys = Object.keys(partKeyMap)
|
||||
const lookup = buildLegacyMaterialLookup(materials)
|
||||
const result: PartMaterialMap = {}
|
||||
for (const [key, entry] of Object.entries(materials)) {
|
||||
// 1. Exact match
|
||||
if (partKeyMap[key]) { result[partKeyMap[key]] = entry; continue }
|
||||
// 2. Prefix match: cad_part_materials may have extra _1 instance suffixes
|
||||
// that partKeyMap doesn't (e.g. "PART_04_1" vs partKeyMap "PART_04")
|
||||
let matched = false
|
||||
for (const mk of mapKeys) {
|
||||
if (key.startsWith(mk + '_') || key === mk) {
|
||||
result[partKeyMap[mk]] = entry
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!matched) result[key] = entry // preserve unmapped
|
||||
for (const [sourceName, partKey] of Object.entries(partKeyMap)) {
|
||||
const entry = lookupLegacyPartMaterial(sourceName, lookup, partKey)
|
||||
if (entry) result[partKey] = entry
|
||||
}
|
||||
return result
|
||||
return Object.keys(result).length > 0 ? result : materials
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -125,6 +252,94 @@ export function convertCadPartMaterials(
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert scene manifest parts into the PartMaterialMap format used by the viewers.
|
||||
*
|
||||
* Scene manifest materials are already authoritative and keyed by part_key.
|
||||
*/
|
||||
export function convertSceneManifestMaterials(
|
||||
parts: Array<{ part_key: string; effective_material: string | null }>,
|
||||
): PartMaterialMap {
|
||||
const result: PartMaterialMap = {}
|
||||
for (const part of parts) {
|
||||
const key = part.part_key?.trim()
|
||||
const value = part.effective_material?.trim()
|
||||
if (!key || !value) continue
|
||||
result[key] = { type: value.startsWith('#') ? 'hex' : 'library', value }
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Add viewer-logical keys for scene-manifest entries when GLB semantics collapse
|
||||
* repeated leaf part keys onto a shared instance key.
|
||||
*/
|
||||
export function alignSceneManifestToLogicalPartKeys(
|
||||
partMaterials: PartMaterialMap,
|
||||
logicalPartKeys: Iterable<string>,
|
||||
): PartMaterialMap {
|
||||
if (!partMaterials || Object.keys(partMaterials).length === 0) return partMaterials
|
||||
|
||||
const legacyLookup = buildLegacyMaterialLookup(partMaterials)
|
||||
let changed = false
|
||||
const result: PartMaterialMap = { ...partMaterials }
|
||||
for (const rawKey of logicalPartKeys) {
|
||||
const logicalKey = rawKey.trim()
|
||||
if (!logicalKey || result[logicalKey]) continue
|
||||
|
||||
const entry =
|
||||
resolvePartMaterial(logicalKey, partMaterials) ??
|
||||
lookupLegacyPartMaterial(logicalKey, legacyLookup)
|
||||
if (!entry) continue
|
||||
|
||||
result[logicalKey] = entry
|
||||
changed = true
|
||||
}
|
||||
|
||||
return changed ? result : partMaterials
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge scene-manifest and legacy/fallback viewer material maps.
|
||||
*
|
||||
* Legacy CAD/product assignments stay authoritative for overlapping keys so the
|
||||
* viewer keeps parity with the pre-USD material mapping behavior. The scene
|
||||
* manifest still fills gaps for graph/USD-only logical keys that do not exist
|
||||
* in the fallback map yet.
|
||||
*/
|
||||
export function mergeViewerMaterialSources(
|
||||
manifestMaterials: PartMaterialMap,
|
||||
fallbackMaterials: PartMaterialMap,
|
||||
): PartMaterialMap {
|
||||
if (!Object.keys(manifestMaterials).length) return fallbackMaterials
|
||||
if (!Object.keys(fallbackMaterials).length) return manifestMaterials
|
||||
return { ...manifestMaterials, ...fallbackMaterials }
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the effective viewer material map shared by inline and fullscreen CAD viewers.
|
||||
*
|
||||
* - fallback materials stay authoritative for overlapping keys
|
||||
* - manifest materials fill graph/USD-only gaps
|
||||
* - manual overrides win last because they are explicit user actions
|
||||
*/
|
||||
export function buildEffectiveViewerMaterials(
|
||||
manifestMaterials: PartMaterialMap,
|
||||
fallbackMaterials: PartMaterialMap,
|
||||
manualOverrides: Record<string, string>,
|
||||
): PartMaterialMap {
|
||||
const merged = mergeViewerMaterialSources(manifestMaterials, fallbackMaterials)
|
||||
if (!manualOverrides || Object.keys(manualOverrides).length === 0) return merged
|
||||
|
||||
const overrideEntries: PartMaterialMap = Object.fromEntries(
|
||||
Object.entries(manualOverrides).map(([key, value]) => [
|
||||
key,
|
||||
{ type: (value.startsWith('#') ? 'hex' : 'library') as 'hex' | 'library', value },
|
||||
]),
|
||||
)
|
||||
return { ...merged, ...overrideEntries }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PBR material helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -184,6 +399,166 @@ export interface MeshRegistryEntry {
|
||||
partKey: string
|
||||
}
|
||||
|
||||
export interface ScenePartRegistry {
|
||||
meshRegistry: MeshRegistryEntry[]
|
||||
logicalPartKeys: Set<string>
|
||||
unresolvedMeshNames: Set<string>
|
||||
}
|
||||
|
||||
function readPartKey(value: unknown): string | undefined {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : undefined
|
||||
}
|
||||
|
||||
function readFiniteNumber(value: unknown, fallback = 0): number {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : fallback
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function readObjectName(object: any): string {
|
||||
const raw = object?.userData?.name || object?.name || ''
|
||||
return typeof raw === 'string' ? raw.trim() : ''
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function findAncestorPartKey(object: any): string | undefined {
|
||||
let current = object
|
||||
while (current) {
|
||||
const partKey = readPartKey(current?.userData?.partKey)
|
||||
if (partKey) return partKey
|
||||
current = current.parent
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function resolveOwnPartKey(object: any, partKeyMap: Record<string, string>): string | undefined {
|
||||
const explicitPartKey = readPartKey(object?.userData?.partKey)
|
||||
if (explicitPartKey) return explicitPartKey
|
||||
|
||||
const normalized = normalizeMeshName(readObjectName(object))
|
||||
return normalized ? partKeyMap[normalized] : undefined
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function haveMatchingLocalTransforms(left: any, right: any): boolean {
|
||||
const epsilon = 1e-5
|
||||
|
||||
const samePosition =
|
||||
Math.abs(readFiniteNumber(left?.position?.x) - readFiniteNumber(right?.position?.x)) <= epsilon &&
|
||||
Math.abs(readFiniteNumber(left?.position?.y) - readFiniteNumber(right?.position?.y)) <= epsilon &&
|
||||
Math.abs(readFiniteNumber(left?.position?.z) - readFiniteNumber(right?.position?.z)) <= epsilon
|
||||
if (!samePosition) return false
|
||||
|
||||
const sameScale =
|
||||
Math.abs(readFiniteNumber(left?.scale?.x, 1) - readFiniteNumber(right?.scale?.x, 1)) <= epsilon &&
|
||||
Math.abs(readFiniteNumber(left?.scale?.y, 1) - readFiniteNumber(right?.scale?.y, 1)) <= epsilon &&
|
||||
Math.abs(readFiniteNumber(left?.scale?.z, 1) - readFiniteNumber(right?.scale?.z, 1)) <= epsilon
|
||||
if (!sameScale) return false
|
||||
|
||||
const dot =
|
||||
readFiniteNumber(left?.quaternion?.x) * readFiniteNumber(right?.quaternion?.x) +
|
||||
readFiniteNumber(left?.quaternion?.y) * readFiniteNumber(right?.quaternion?.y) +
|
||||
readFiniteNumber(left?.quaternion?.z) * readFiniteNumber(right?.quaternion?.z) +
|
||||
readFiniteNumber(left?.quaternion?.w, 1) * readFiniteNumber(right?.quaternion?.w, 1)
|
||||
|
||||
return Math.abs(1 - Math.abs(dot)) <= 1e-4
|
||||
}
|
||||
|
||||
function isSemanticSiblingMatch(meshName: string, siblingName: string): boolean {
|
||||
if (!meshName || !siblingName) return false
|
||||
if (meshName === siblingName) return true
|
||||
if (meshName.startsWith(`${siblingName}_`)) return true
|
||||
return meshName.replace(/_\d+$/, '') === siblingName
|
||||
}
|
||||
|
||||
function scoreSiblingCandidate(meshName: string, siblingName: string, siblingPartKey: string): number {
|
||||
const canonicalBonus = /_af\d+$/i.test(siblingPartKey) ? 0 : 1000
|
||||
const baseBonus = meshName.replace(/_\d+$/, '') === siblingName ? 100 : 0
|
||||
return canonicalBonus + baseBonus + siblingName.length
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function findSiblingSemanticPartKey(object: any, partKeyMap: Record<string, string>): string | undefined {
|
||||
if (!object?.isMesh || !object?.parent?.children) return undefined
|
||||
|
||||
const meshName = normalizeMeshName(readObjectName(object))
|
||||
if (!meshName) return undefined
|
||||
|
||||
let bestMatch: { partKey: string; score: number } | undefined
|
||||
for (const sibling of object.parent.children) {
|
||||
if (!sibling || sibling === object || sibling.isMesh) continue
|
||||
|
||||
const siblingPartKey = resolveOwnPartKey(sibling, partKeyMap)
|
||||
const siblingName = normalizeMeshName(readObjectName(sibling))
|
||||
if (!siblingPartKey || !siblingName) continue
|
||||
if (!isSemanticSiblingMatch(meshName, siblingName)) continue
|
||||
|
||||
// Real OCC exports can place the semantic helper node and the mesh instance
|
||||
// under the same parent with different local transforms. Matching transforms
|
||||
// is still a strong hint, but it must not be mandatory.
|
||||
const score =
|
||||
scoreSiblingCandidate(meshName, siblingName, siblingPartKey) +
|
||||
(haveMatchingLocalTransforms(object, sibling) ? 10_000 : 0)
|
||||
if (!bestMatch || score > bestMatch.score) {
|
||||
bestMatch = { partKey: siblingPartKey, score }
|
||||
}
|
||||
}
|
||||
|
||||
return bestMatch?.partKey
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function resolveSceneNodePartKey(object: any, partKeyMap: Record<string, string>): string {
|
||||
const ownPartKey = resolveOwnPartKey(object, partKeyMap)
|
||||
if (ownPartKey) return ownPartKey
|
||||
|
||||
const siblingPartKey = findSiblingSemanticPartKey(object, partKeyMap)
|
||||
if (siblingPartKey) return siblingPartKey
|
||||
|
||||
const inheritedPartKey = findAncestorPartKey(object?.parent)
|
||||
if (inheritedPartKey) return inheritedPartKey
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function resolveObjectPartKey(object: any, partKeyMap: Record<string, string>): string {
|
||||
return resolveSceneNodePartKey(object, partKeyMap)
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function buildScenePartRegistry(object: any, partKeyMap: Record<string, string>): ScenePartRegistry {
|
||||
const meshRegistry: MeshRegistryEntry[] = []
|
||||
const logicalPartKeys = new Set<string>()
|
||||
const unresolvedMeshNames = new Set<string>()
|
||||
|
||||
object?.traverse((node: any) => {
|
||||
const resolvedPartKey = resolveSceneNodePartKey(node, partKeyMap)
|
||||
|
||||
if (resolvedPartKey && !readPartKey(node?.userData?.partKey)) {
|
||||
node.userData = node.userData ?? {}
|
||||
node.userData.partKey = resolvedPartKey
|
||||
}
|
||||
|
||||
if (!node?.isMesh) return
|
||||
|
||||
const nodePartKey = readPartKey(node?.userData?.partKey)
|
||||
const meshPartKey = resolvedPartKey || nodePartKey
|
||||
if (!meshPartKey) {
|
||||
const unresolvedName = normalizeMeshName(readObjectName(node)) || readObjectName(node)
|
||||
if (unresolvedName) unresolvedMeshNames.add(unresolvedName)
|
||||
return
|
||||
}
|
||||
|
||||
node.userData = node.userData ?? {}
|
||||
node.userData.partKey = meshPartKey
|
||||
logicalPartKeys.add(meshPartKey)
|
||||
meshRegistry.push({ mesh: node, partKey: meshPartKey })
|
||||
})
|
||||
|
||||
return { meshRegistry, logicalPartKeys, unresolvedMeshNames }
|
||||
}
|
||||
|
||||
/**
|
||||
* Iterate all materials on a mesh, calling `fn` for each MeshStandardMaterial.
|
||||
* Handles both single and array materials safely.
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import * as THREE from 'three'
|
||||
import {
|
||||
BufferGeometry,
|
||||
Mesh,
|
||||
MeshStandardMaterial,
|
||||
Object3D,
|
||||
Scene,
|
||||
} from 'three'
|
||||
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
|
||||
import type { MeshRegistryEntry } from './cadUtils'
|
||||
import type { PartMaterialMap } from '../../api/cad'
|
||||
@@ -17,8 +23,8 @@ interface UseGeometryMergeOpts {
|
||||
}
|
||||
|
||||
interface MergedState {
|
||||
mergedMeshes: THREE.Mesh[]
|
||||
hiddenOriginals: THREE.Object3D[]
|
||||
mergedMeshes: Mesh[]
|
||||
hiddenOriginals: Object3D[]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -57,15 +63,15 @@ export function useGeometryMerge({
|
||||
const groups = groupRegistryByMaterial(registry, partMaterials)
|
||||
if (groups.size === 0) return
|
||||
|
||||
const mergedMeshes: THREE.Mesh[] = []
|
||||
const hiddenOriginals: THREE.Object3D[] = []
|
||||
const mergedMeshes: Mesh[] = []
|
||||
const hiddenOriginals: Object3D[] = []
|
||||
let meshesReplaced = 0
|
||||
|
||||
for (const [materialKey, entries] of groups) {
|
||||
// Collect geometries with world transforms baked in
|
||||
const geometries: THREE.BufferGeometry[] = []
|
||||
const geometries: BufferGeometry[] = []
|
||||
for (const entry of entries) {
|
||||
const mesh = entry.mesh as THREE.Mesh
|
||||
const mesh = entry.mesh as Mesh
|
||||
if (!mesh.geometry) continue
|
||||
// Ensure world matrix is up to date
|
||||
mesh.updateWorldMatrix(true, false)
|
||||
@@ -96,10 +102,10 @@ export function useGeometryMerge({
|
||||
}
|
||||
|
||||
// Create merged mesh with material from the first entry
|
||||
const sourceMesh = entries[0].mesh as THREE.Mesh
|
||||
const sourceMesh = entries[0].mesh as Mesh
|
||||
const mat = (Array.isArray(sourceMesh.material)
|
||||
? sourceMesh.material[0]
|
||||
: sourceMesh.material) as THREE.MeshStandardMaterial
|
||||
: sourceMesh.material) as MeshStandardMaterial
|
||||
const mergedMat = mat.clone()
|
||||
|
||||
// Apply PBR properties to the merged material
|
||||
@@ -112,7 +118,7 @@ export function useGeometryMerge({
|
||||
}
|
||||
}
|
||||
|
||||
const mergedMesh = new THREE.Mesh(merged, mergedMat)
|
||||
const mergedMesh = new Mesh(merged, mergedMat)
|
||||
mergedMesh.name = `__merged_${materialKey}`
|
||||
mergedMesh.userData._isMerged = true
|
||||
scene.add(mergedMesh)
|
||||
@@ -120,7 +126,7 @@ export function useGeometryMerge({
|
||||
|
||||
// Hide originals
|
||||
for (const entry of entries) {
|
||||
const mesh = entry.mesh as THREE.Object3D
|
||||
const mesh = entry.mesh as Object3D
|
||||
mesh.visible = false
|
||||
mesh.raycast = () => {} // disable raycasting
|
||||
hiddenOriginals.push(mesh)
|
||||
@@ -150,7 +156,7 @@ export function useGeometryMerge({
|
||||
return { drawCallReduction: reductionRef.current }
|
||||
}
|
||||
|
||||
function _restore(state: MergedState, scene: THREE.Scene): void {
|
||||
function _restore(state: MergedState, scene: Scene): void {
|
||||
// Remove merged meshes
|
||||
for (const mesh of state.mergedMeshes) {
|
||||
scene.remove(mesh)
|
||||
@@ -164,6 +170,6 @@ function _restore(state: MergedState, scene: THREE.Scene): void {
|
||||
// Restore originals
|
||||
for (const obj of state.hiddenOriginals) {
|
||||
obj.visible = true
|
||||
obj.raycast = THREE.Mesh.prototype.raycast
|
||||
obj.raycast = Mesh.prototype.raycast
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,23 @@
|
||||
import { useEffect, type ReactNode } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
import { Boxes, Milestone, X } from 'lucide-react'
|
||||
|
||||
import type { WorkflowNodeDefinition } from '../../api/workflows'
|
||||
import type { WorkflowGraphFamily } from './workflowNodeLibrary'
|
||||
import { WorkflowNodeCatalogBrowser } from './WorkflowNodeCatalogBrowser'
|
||||
import { WorkflowAuthoringSectionContent } from './WorkflowAuthoringSectionContent'
|
||||
import {
|
||||
type WorkflowAuthoringActions,
|
||||
type WorkflowAuthoringPosition,
|
||||
} from './workflowAuthoringActions'
|
||||
import { useWorkflowAuthoringSurface } from './workflowAuthoringSurface'
|
||||
|
||||
export const NODE_COMMAND_MENU_WIDTH = 360
|
||||
|
||||
interface NodeCommandMenuProps {
|
||||
definitions: WorkflowNodeDefinition[]
|
||||
graphFamily: WorkflowGraphFamily
|
||||
onSelectStep: (step: string) => void
|
||||
activeSteps?: string[]
|
||||
actions: WorkflowAuthoringActions
|
||||
preferredPosition?: WorkflowAuthoringPosition
|
||||
onClose: () => void
|
||||
renderIcon: (iconName?: string, size?: number) => ReactNode
|
||||
}
|
||||
@@ -16,10 +25,22 @@ interface NodeCommandMenuProps {
|
||||
export function NodeCommandMenu({
|
||||
definitions,
|
||||
graphFamily,
|
||||
onSelectStep,
|
||||
activeSteps = [],
|
||||
actions,
|
||||
preferredPosition,
|
||||
onClose,
|
||||
renderIcon,
|
||||
}: NodeCommandMenuProps) {
|
||||
const { activeSection, insertBindings, plan, sections, setActiveSection } =
|
||||
useWorkflowAuthoringSurface({
|
||||
definitions,
|
||||
graphFamily,
|
||||
activeSteps,
|
||||
actions,
|
||||
preferredPosition,
|
||||
onAfterInsert: onClose,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
@@ -32,14 +53,34 @@ export function NodeCommandMenu({
|
||||
}, [onClose])
|
||||
|
||||
return (
|
||||
<div className="flex max-h-[70vh] w-[380px] flex-col overflow-hidden rounded-2xl border border-border-default bg-surface shadow-2xl">
|
||||
<div
|
||||
className="flex max-h-[calc(100vh-2rem)] flex-col overflow-hidden rounded-2xl border border-border-default bg-surface shadow-2xl"
|
||||
style={{ width: NODE_COMMAND_MENU_WIDTH }}
|
||||
>
|
||||
<div className="border-b border-border-default px-4 py-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-content">Add Workflow Node</p>
|
||||
<p className="text-sm font-semibold text-content">Workflow Authoring</p>
|
||||
<p className="text-xs text-content-muted">
|
||||
Search by label, step, family, or execution mode.
|
||||
Use complete reference paths, modules, starter steps, or the raw node library directly on the canvas.
|
||||
</p>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-1.5 text-[11px] text-content-muted">
|
||||
<span className="rounded-full border border-border-default bg-surface-hover px-2 py-0.5">
|
||||
{activeSteps.length} on canvas
|
||||
</span>
|
||||
{plan.referenceBundles.length > 0 && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full border border-border-default bg-surface-hover px-2 py-0.5">
|
||||
<Milestone size={11} />
|
||||
{plan.referenceBundles.length} paths
|
||||
</span>
|
||||
)}
|
||||
{plan.moduleBundles.length > 0 && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full border border-border-default bg-surface-hover px-2 py-0.5">
|
||||
<Boxes size={11} />
|
||||
{plan.moduleBundles.length} modules
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -53,15 +94,42 @@ export function NodeCommandMenu({
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-3 py-3">
|
||||
<WorkflowNodeCatalogBrowser
|
||||
definitions={definitions}
|
||||
graphFamily={graphFamily}
|
||||
variant="menu"
|
||||
onSelectStep={onSelectStep}
|
||||
renderIcon={renderIcon}
|
||||
searchPlaceholder="Search nodes"
|
||||
autoFocusSearch
|
||||
/>
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{sections.map(section => {
|
||||
const Icon = section.icon
|
||||
const isActive = activeSection === section.key
|
||||
return (
|
||||
<button
|
||||
key={section.key}
|
||||
type="button"
|
||||
onClick={() => setActiveSection(section.key)}
|
||||
className={`inline-flex items-center gap-1 rounded-full px-3 py-1.5 text-xs font-medium transition-colors ${
|
||||
isActive
|
||||
? 'bg-accent text-white'
|
||||
: 'border border-border-default bg-surface text-content-secondary hover:bg-surface-hover'
|
||||
}`}
|
||||
title={section.helper}
|
||||
>
|
||||
<Icon size={12} />
|
||||
{section.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<WorkflowAuthoringSectionContent
|
||||
activeSection={activeSection}
|
||||
definitions={definitions}
|
||||
graphFamily={graphFamily}
|
||||
activeSteps={activeSteps}
|
||||
actions={insertBindings}
|
||||
renderIcon={renderIcon}
|
||||
nodesVariant="menu"
|
||||
searchPlaceholder="Search nodes"
|
||||
autoFocusSearch
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,39 +1,195 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { ArrowRight } from 'lucide-react'
|
||||
|
||||
import type { WorkflowNodeDefinition } from '../../api/workflows'
|
||||
import { WorkflowAuthoringSectionContent } from './WorkflowAuthoringSectionContent'
|
||||
import { type WorkflowAuthoringActions } from './workflowAuthoringActions'
|
||||
import type { WorkflowGraphFamily } from './workflowNodeLibrary'
|
||||
import { WorkflowNodeCatalogBrowser } from './WorkflowNodeCatalogBrowser'
|
||||
import { useWorkflowAuthoringSurface } from './workflowAuthoringSurface'
|
||||
|
||||
interface NodeDefinitionsPanelProps {
|
||||
definitions: WorkflowNodeDefinition[]
|
||||
graphFamily: WorkflowGraphFamily
|
||||
onSelectStep?: (step: string) => void
|
||||
activeSteps?: string[]
|
||||
actions?: WorkflowAuthoringActions
|
||||
renderIcon?: (iconName?: string, size?: number) => ReactNode
|
||||
}
|
||||
|
||||
export function NodeDefinitionsPanel({ definitions, graphFamily, onSelectStep, renderIcon }: NodeDefinitionsPanelProps) {
|
||||
type AuthoringFlowStep = {
|
||||
index: number
|
||||
title: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export function NodeDefinitionsPanel({
|
||||
definitions,
|
||||
graphFamily,
|
||||
activeSteps = [],
|
||||
actions = {},
|
||||
renderIcon,
|
||||
}: NodeDefinitionsPanelProps) {
|
||||
const {
|
||||
activeSection,
|
||||
activeSectionMeta,
|
||||
defaultSection,
|
||||
insertBindings,
|
||||
plan: authoringPlan,
|
||||
sections,
|
||||
setActiveSection,
|
||||
} = useWorkflowAuthoringSurface({
|
||||
definitions,
|
||||
graphFamily,
|
||||
activeSteps,
|
||||
actions,
|
||||
})
|
||||
const authoringFlow: AuthoringFlowStep[] = authoringPlan.authoringFlow
|
||||
|
||||
const presentStepCount = activeSteps.length
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2 rounded-2xl border border-border-default bg-surface-hover/30 p-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-content-secondary">
|
||||
Node Library
|
||||
</p>
|
||||
<span className="text-[11px] text-content-muted">
|
||||
{onSelectStep ? 'Click insert to add to canvas' : `${definitions.length} definitions`}
|
||||
<div className="space-y-3 rounded-2xl border border-border-default bg-surface-hover/25 p-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-content-secondary">
|
||||
Node Library
|
||||
</p>
|
||||
<div className="mt-1 flex min-w-0 flex-wrap items-center gap-2">
|
||||
<p className="text-sm font-semibold text-content">Authoring Browser</p>
|
||||
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5 text-[11px] text-content-muted">
|
||||
{definitions.length} definitions
|
||||
</span>
|
||||
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5 text-[11px] text-content-muted">
|
||||
{presentStepCount} on canvas
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-content-muted">
|
||||
Start from reference paths or production modules, then drop to starter steps and raw nodes only when needed.
|
||||
</p>
|
||||
</div>
|
||||
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5 text-[11px] text-content-muted">
|
||||
{insertBindings.onSelectStep ? 'Insert enabled' : 'Browse only'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-content-muted">
|
||||
Browse by runtime family and module contract, then insert nodes directly from the sidebar.
|
||||
</p>
|
||||
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{sections.map(section => {
|
||||
const Icon = section.icon
|
||||
const isActive = activeSection === section.key
|
||||
|
||||
return (
|
||||
<button
|
||||
key={section.key}
|
||||
type="button"
|
||||
onClick={() => setActiveSection(section.key)}
|
||||
aria-label={section.label}
|
||||
className={`rounded-2xl border px-3 py-3 text-left transition-colors ${
|
||||
isActive
|
||||
? 'border-accent/40 bg-accent-light'
|
||||
: 'border-border-default bg-surface hover:bg-surface-hover'
|
||||
}`}
|
||||
title={section.helper}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="inline-flex items-center gap-2 text-sm font-medium text-content">
|
||||
<Icon size={14} />
|
||||
{section.label}
|
||||
</span>
|
||||
{isActive && <ArrowRight size={14} className="text-content-secondary" />}
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-content-muted">{section.helper}</p>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<WorkflowNodeCatalogBrowser
|
||||
definitions={definitions}
|
||||
graphFamily={graphFamily}
|
||||
variant="panel"
|
||||
onSelectStep={onSelectStep}
|
||||
renderIcon={renderIcon}
|
||||
/>
|
||||
|
||||
{activeSectionMeta && (
|
||||
<div className="rounded-2xl border border-border-default bg-surface-hover/20 p-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-content-secondary">
|
||||
Current Section
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-content-muted">
|
||||
{activeSectionMeta.label}: {activeSectionMeta.helper}
|
||||
</p>
|
||||
</div>
|
||||
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5 text-[11px] text-content-muted">
|
||||
Active
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeSection === defaultSection && (
|
||||
<div className="rounded-2xl border border-border-default bg-surface-hover/20 p-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-content-secondary">
|
||||
Authoring Flow
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-content-muted">
|
||||
Keep the legacy workflow safe by starting from graph-safe assemblies, then drilling down only when the module-level path is in place.
|
||||
</p>
|
||||
</div>
|
||||
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5 text-[11px] text-content-muted">
|
||||
Guided
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3 grid gap-2 md:grid-cols-2">
|
||||
{authoringFlow.map(step => (
|
||||
<div
|
||||
key={step.title}
|
||||
className="rounded-xl border border-border-default bg-surface px-3 py-2"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="rounded-full border border-border-default bg-surface-hover/70 px-1.5 py-0.5 text-[10px] font-semibold text-content-secondary">
|
||||
{step.index}
|
||||
</span>
|
||||
<p className="text-sm font-medium text-content">{step.title}</p>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-content-muted">{step.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeSection === 'nodes' ? (
|
||||
<div className="space-y-2 rounded-2xl border border-border-default bg-surface-hover/20 p-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-content-secondary">
|
||||
Raw Node Catalog
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-content-muted">
|
||||
Advanced mode for inserting individual legacy, bridge, or graph nodes after the higher-level path is established.
|
||||
</p>
|
||||
</div>
|
||||
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5 text-[11px] text-content-muted">
|
||||
Escape Hatch
|
||||
</span>
|
||||
</div>
|
||||
<WorkflowAuthoringSectionContent
|
||||
activeSection={activeSection}
|
||||
definitions={definitions}
|
||||
graphFamily={graphFamily}
|
||||
activeSteps={activeSteps}
|
||||
actions={insertBindings}
|
||||
renderIcon={renderIcon}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<WorkflowAuthoringSectionContent
|
||||
activeSection={activeSection}
|
||||
definitions={definitions}
|
||||
graphFamily={graphFamily}
|
||||
activeSteps={activeSteps}
|
||||
actions={insertBindings}
|
||||
renderIcon={renderIcon}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
import { ArrowRight, Boxes, CheckCircle2, CircleDashed, Library, Milestone, Sparkles } from 'lucide-react'
|
||||
|
||||
import type { WorkflowNodeDefinition } from '../../api/workflows'
|
||||
import type { WorkflowGraphFamily } from './workflowNodeLibrary'
|
||||
import { getWorkflowAuthoringPlan } from './workflowAuthoringGuidance'
|
||||
import type { WorkflowModuleBundleId } from './workflowModuleBundles'
|
||||
import type { WorkflowReferenceBundleId } from './workflowReferenceBundles'
|
||||
|
||||
type WorkflowAuthoringOverviewProps = {
|
||||
definitions: WorkflowNodeDefinition[]
|
||||
graphFamily: WorkflowGraphFamily
|
||||
activeSteps: string[]
|
||||
onInsertModule?: (bundleId: WorkflowModuleBundleId) => void
|
||||
onInsertReferencePath?: (bundleId: WorkflowReferenceBundleId) => void
|
||||
onSelectStep?: (step: string) => void
|
||||
}
|
||||
|
||||
export function WorkflowAuthoringOverview({
|
||||
definitions,
|
||||
graphFamily,
|
||||
activeSteps,
|
||||
onInsertModule,
|
||||
onInsertReferencePath,
|
||||
onSelectStep,
|
||||
}: WorkflowAuthoringOverviewProps) {
|
||||
const {
|
||||
description,
|
||||
gapFillDefinitions,
|
||||
moduleBundles,
|
||||
priorities,
|
||||
referenceBundles,
|
||||
stageProgress,
|
||||
title,
|
||||
} = getWorkflowAuthoringPlan(definitions, graphFamily, activeSteps)
|
||||
const priorityIcons = [Milestone, Boxes, Library] as const
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{graphFamily !== 'mixed' && stageProgress.length > 0 && (
|
||||
<div className="rounded-2xl border border-border-default bg-surface-hover/20 p-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-content-secondary">
|
||||
Stage Status
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-content-muted">
|
||||
Track the canonical authoring path stage by stage and fill only the missing parts.
|
||||
</p>
|
||||
</div>
|
||||
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5 text-[11px] text-content-muted">
|
||||
Operational
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid gap-2">
|
||||
{stageProgress.map(stage => {
|
||||
const isComplete = stage.present >= stage.total
|
||||
const Icon = isComplete ? CheckCircle2 : CircleDashed
|
||||
const progressLabel = `${stage.present}/${stage.total} present`
|
||||
|
||||
return (
|
||||
<div
|
||||
key={stage.id}
|
||||
className="rounded-xl border border-border-default bg-surface px-3 py-3"
|
||||
>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className={`inline-flex items-center gap-1 text-sm font-medium ${isComplete ? 'text-emerald-700 dark:text-emerald-300' : 'text-content'}`}>
|
||||
<Icon size={14} />
|
||||
{stage.title}
|
||||
</span>
|
||||
<span className="rounded-full border border-border-default bg-surface-hover/70 px-2 py-0.5 text-[11px] text-content-muted">
|
||||
{progressLabel}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-content-muted">{stage.description}</p>
|
||||
</div>
|
||||
|
||||
{stage.actionKind === 'reference' && stage.bundleId && onInsertReferencePath && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onInsertReferencePath(stage.bundleId as WorkflowReferenceBundleId)}
|
||||
className="inline-flex items-center gap-1 rounded-xl bg-accent px-3 py-1.5 text-xs font-semibold text-white transition-colors hover:bg-accent-hover"
|
||||
>
|
||||
<Milestone size={12} />
|
||||
{stage.actionLabel}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{stage.actionKind === 'module' && stage.bundleId && onInsertModule && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onInsertModule(stage.bundleId as WorkflowModuleBundleId)}
|
||||
className="inline-flex items-center gap-1 rounded-xl border border-border-default bg-surface px-3 py-1.5 text-xs font-semibold text-content transition-colors hover:bg-surface-hover"
|
||||
>
|
||||
<Boxes size={12} />
|
||||
{stage.actionLabel}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{stage.actionKind === 'step' && stage.step && onSelectStep && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelectStep(stage.step as string)}
|
||||
className="inline-flex items-center gap-1 rounded-xl border border-border-default bg-surface px-3 py-1.5 text-xs font-medium text-content transition-colors hover:bg-surface-hover"
|
||||
>
|
||||
<ArrowRight size={12} />
|
||||
{stage.actionLabel}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-2xl border border-accent/20 bg-accent-light p-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles size={14} className="text-accent" />
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-content-secondary">
|
||||
Recommended Path
|
||||
</p>
|
||||
</div>
|
||||
<p className="mt-1 text-sm font-semibold text-content">{title}</p>
|
||||
<p className="mt-1 text-xs text-content-muted">{description}</p>
|
||||
</div>
|
||||
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5 text-[11px] text-content-muted">
|
||||
{activeSteps.length} on canvas
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 space-y-1.5">
|
||||
{priorities.map((priority, index) => {
|
||||
const Icon = priorityIcons[index] ?? Library
|
||||
return (
|
||||
<div
|
||||
key={priority.title}
|
||||
className="rounded-xl border border-border-default bg-surface px-3 py-2"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="mt-0.5 rounded-full border border-border-default bg-surface-hover/70 px-1.5 py-0.5 text-[10px] font-semibold text-content-secondary">
|
||||
{index + 1}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<span className="inline-flex items-center gap-1 text-sm font-medium text-content">
|
||||
<Icon size={13} />
|
||||
{priority.title}
|
||||
</span>
|
||||
<p className="mt-0.5 text-xs text-content-muted">{priority.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(referenceBundles.length > 0 || moduleBundles.length > 0) && (
|
||||
<div className="rounded-2xl border border-border-default bg-surface-hover/20 p-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-content-secondary">
|
||||
Quick Start
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-content-muted">
|
||||
Insert the recommended baseline first, then add stage bundles only where the path should diverge.
|
||||
</p>
|
||||
</div>
|
||||
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5 text-[11px] text-content-muted">
|
||||
Guided
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{referenceBundles.map(bundle => (
|
||||
<button
|
||||
key={bundle.id}
|
||||
type="button"
|
||||
onClick={() => onInsertReferencePath?.(bundle.id)}
|
||||
disabled={!onInsertReferencePath}
|
||||
className="inline-flex items-center gap-1 rounded-xl bg-accent px-3 py-1.5 text-xs font-semibold text-white transition-colors hover:bg-accent-hover disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<Milestone size={12} />
|
||||
Insert {bundle.shortLabel}
|
||||
</button>
|
||||
))}
|
||||
|
||||
{moduleBundles.slice(0, 2).map(bundle => (
|
||||
<button
|
||||
key={bundle.id}
|
||||
type="button"
|
||||
onClick={() => onInsertModule?.(bundle.id)}
|
||||
disabled={!onInsertModule}
|
||||
className="inline-flex items-center gap-1 rounded-xl border border-border-default bg-surface px-3 py-1.5 text-xs font-semibold text-content transition-colors hover:bg-surface-hover disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<Boxes size={12} />
|
||||
Insert {bundle.shortLabel}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{graphFamily !== 'mixed' && onSelectStep && (
|
||||
<div className="rounded-2xl border border-border-default bg-surface-hover/20 p-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-content-secondary">
|
||||
Gap Fill
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-content-muted">
|
||||
Use starter-safe inserts only for missing links in the recommended chain.
|
||||
</p>
|
||||
</div>
|
||||
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5 text-[11px] text-content-muted">
|
||||
Minimal
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{gapFillDefinitions.map(definition => (
|
||||
<button
|
||||
key={definition.step}
|
||||
type="button"
|
||||
onClick={() => onSelectStep(definition.step)}
|
||||
className="inline-flex items-center gap-1 rounded-xl border border-border-default bg-surface px-3 py-1.5 text-xs font-medium text-content transition-colors hover:bg-surface-hover"
|
||||
>
|
||||
<ArrowRight size={12} />
|
||||
Add {definition.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import type { WorkflowNodeDefinition } from '../../api/workflows'
|
||||
import { WorkflowAuthoringOverview } from './WorkflowAuthoringOverview'
|
||||
import { WorkflowModuleBundlePanel } from './WorkflowModuleBundlePanel'
|
||||
import { WorkflowNodeCatalogBrowser } from './WorkflowNodeCatalogBrowser'
|
||||
import { WorkflowReferenceBundlePanel } from './WorkflowReferenceBundlePanel'
|
||||
import { WorkflowStarterPathPanel } from './WorkflowStarterPathPanel'
|
||||
import type { WorkflowAuthoringInsertHandlers } from './workflowAuthoringActions'
|
||||
import type { WorkflowGraphFamily } from './workflowNodeLibrary'
|
||||
import type { WorkflowAuthoringSection } from './workflowAuthoringSections'
|
||||
|
||||
type WorkflowAuthoringSectionContentProps = {
|
||||
activeSection: WorkflowAuthoringSection
|
||||
definitions: WorkflowNodeDefinition[]
|
||||
graphFamily: WorkflowGraphFamily
|
||||
activeSteps: string[]
|
||||
actions?: WorkflowAuthoringInsertHandlers
|
||||
renderIcon?: (iconName?: string, size?: number) => ReactNode
|
||||
nodesVariant?: 'panel' | 'menu'
|
||||
searchPlaceholder?: string
|
||||
autoFocusSearch?: boolean
|
||||
}
|
||||
|
||||
export function WorkflowAuthoringSectionContent({
|
||||
activeSection,
|
||||
definitions,
|
||||
graphFamily,
|
||||
activeSteps,
|
||||
actions,
|
||||
renderIcon,
|
||||
nodesVariant = 'panel',
|
||||
searchPlaceholder,
|
||||
autoFocusSearch = false,
|
||||
}: WorkflowAuthoringSectionContentProps) {
|
||||
if (activeSection === 'overview') {
|
||||
return (
|
||||
<WorkflowAuthoringOverview
|
||||
definitions={definitions}
|
||||
graphFamily={graphFamily}
|
||||
activeSteps={activeSteps}
|
||||
onInsertModule={actions?.onInsertModule}
|
||||
onInsertReferencePath={actions?.onInsertReferencePath}
|
||||
onSelectStep={actions?.onSelectStep}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (activeSection === 'paths') {
|
||||
return (
|
||||
<WorkflowReferenceBundlePanel
|
||||
definitions={definitions}
|
||||
graphFamily={graphFamily}
|
||||
activeSteps={activeSteps}
|
||||
onInsertReferencePath={actions?.onInsertReferencePath}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (activeSection === 'modules') {
|
||||
return (
|
||||
<WorkflowModuleBundlePanel
|
||||
definitions={definitions}
|
||||
graphFamily={graphFamily}
|
||||
activeSteps={activeSteps}
|
||||
onInsertModule={actions?.onInsertModule}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (activeSection === 'starter') {
|
||||
return (
|
||||
<WorkflowStarterPathPanel
|
||||
definitions={definitions}
|
||||
graphFamily={graphFamily}
|
||||
activeSteps={activeSteps}
|
||||
onSelectStep={actions?.onSelectStep}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (activeSection === 'nodes') {
|
||||
return (
|
||||
<WorkflowNodeCatalogBrowser
|
||||
definitions={definitions}
|
||||
graphFamily={graphFamily}
|
||||
variant={nodesVariant}
|
||||
onSelectStep={actions?.onSelectStep}
|
||||
renderIcon={renderIcon}
|
||||
searchPlaceholder={searchPlaceholder}
|
||||
autoFocusSearch={autoFocusSearch}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -1,210 +1,448 @@
|
||||
import {
|
||||
BadgeInfo,
|
||||
GitBranch,
|
||||
LayoutGrid,
|
||||
Loader2,
|
||||
MousePointer2,
|
||||
Play,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Save,
|
||||
Trash2,
|
||||
} from 'lucide-react'
|
||||
import { type ReactNode } from 'react'
|
||||
import { GitBranch, LayoutGrid, Loader2, MousePointer2, Play, RefreshCw, Save, Trash2 } from 'lucide-react'
|
||||
|
||||
import type { WorkflowRolloutLinkedOutputType } from '../../api/workflows'
|
||||
import { getOutputTypeRolloutPresentation } from '../admin/outputTypeRolloutPresentation'
|
||||
import type { WorkflowOrderLineContextGroup } from './useWorkflowCanvasController'
|
||||
import type { WorkflowAuthoringActions, WorkflowAuthoringEntryAction } from './workflowAuthoringActions'
|
||||
|
||||
type WorkflowExecutionModeOption = {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
function ToolbarBadge({
|
||||
children,
|
||||
className = '',
|
||||
title,
|
||||
}: {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
title?: string
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 rounded-full border border-border-default bg-surface px-2 py-0.5 text-[11px] font-medium ${className}`}
|
||||
title={title}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function ToolbarField({
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
label: string
|
||||
children: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<label className="flex min-w-0 items-center gap-2 rounded-lg border border-border-default bg-surface px-2 py-1 text-[11px] text-content-secondary">
|
||||
<span className="whitespace-nowrap font-medium">{label}</span>
|
||||
{children}
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
function ToolbarActionButton({
|
||||
children,
|
||||
title,
|
||||
disabled = false,
|
||||
onClick,
|
||||
tone = 'default',
|
||||
}: {
|
||||
children: ReactNode
|
||||
title?: string
|
||||
disabled?: boolean
|
||||
onClick?: () => void
|
||||
tone?: 'default' | 'primary'
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
title={title}
|
||||
className={`flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-sm font-medium disabled:opacity-50 ${
|
||||
tone === 'primary'
|
||||
? 'bg-accent text-white hover:bg-accent-hover'
|
||||
: 'border border-border-default text-content hover:bg-surface-hover'
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
interface WorkflowCanvasToolbarProps {
|
||||
workflowName: string
|
||||
blueprintLabel?: string | null
|
||||
blueprintDescription?: string | null
|
||||
authoringFamilyLabel: string
|
||||
authoringFamilyClassName: string
|
||||
graphFamilyLabel: string
|
||||
graphFamilyClassName: string
|
||||
executionMode: string
|
||||
executionModeLabel: string
|
||||
executionModeClassName: string
|
||||
executionModeHint: string
|
||||
rolloutBadgeLabel: string
|
||||
rolloutBadgeClassName: string
|
||||
rolloutStatusLabel: string
|
||||
rolloutStatusClassName: string
|
||||
rolloutSummary: string
|
||||
linkedOutputTypeCount: number
|
||||
linkedOutputTypes: WorkflowRolloutLinkedOutputType[]
|
||||
dispatchContextKind: 'order_line' | 'cad_file' | null
|
||||
dispatchContextLabel: string
|
||||
dispatchContextId: string
|
||||
dispatchContextSummary?: string | null
|
||||
dispatchContextMeta?: string | null
|
||||
orderLineContextGroups: WorkflowOrderLineContextGroup[]
|
||||
executionModes: WorkflowExecutionModeOption[]
|
||||
selectedEdgeCount: number
|
||||
canAutoLayout: boolean
|
||||
canPreflight: boolean
|
||||
canDispatch: boolean
|
||||
hasValidationErrors: boolean
|
||||
isPreflightPending: boolean
|
||||
isDispatchPending: boolean
|
||||
isContextOptionsLoading: boolean
|
||||
isSaving: boolean
|
||||
rollbackPendingOutputTypeId?: string | null
|
||||
preflightState: 'ready' | 'required' | 'stale' | 'blocked'
|
||||
authoringActions: WorkflowAuthoringActions
|
||||
authoringEntryAction: WorkflowAuthoringEntryAction
|
||||
onDispatchContextIdChange: (value: string) => void
|
||||
onExecutionModeChange: (value: string) => void
|
||||
onOpenNodeMenu: () => void
|
||||
onAutoLayout: () => void
|
||||
onDeleteSelectedEdges: () => void
|
||||
onPreflight: () => void
|
||||
onDispatch: () => void
|
||||
onSave: () => void
|
||||
onRollbackOutputType: (outputTypeId: string) => void
|
||||
}
|
||||
|
||||
export function WorkflowCanvasToolbar({
|
||||
workflowName,
|
||||
blueprintLabel,
|
||||
blueprintDescription,
|
||||
authoringFamilyLabel,
|
||||
authoringFamilyClassName,
|
||||
graphFamilyLabel,
|
||||
graphFamilyClassName,
|
||||
executionMode,
|
||||
executionModeLabel,
|
||||
executionModeClassName,
|
||||
executionModeHint,
|
||||
rolloutBadgeLabel,
|
||||
rolloutBadgeClassName,
|
||||
rolloutStatusLabel,
|
||||
rolloutStatusClassName,
|
||||
rolloutSummary,
|
||||
linkedOutputTypeCount,
|
||||
linkedOutputTypes,
|
||||
dispatchContextKind,
|
||||
dispatchContextLabel,
|
||||
dispatchContextId,
|
||||
dispatchContextSummary,
|
||||
dispatchContextMeta,
|
||||
orderLineContextGroups,
|
||||
executionModes,
|
||||
selectedEdgeCount,
|
||||
canAutoLayout,
|
||||
canPreflight,
|
||||
canDispatch,
|
||||
hasValidationErrors,
|
||||
isPreflightPending,
|
||||
isDispatchPending,
|
||||
isContextOptionsLoading,
|
||||
isSaving,
|
||||
rollbackPendingOutputTypeId,
|
||||
preflightState,
|
||||
authoringActions,
|
||||
authoringEntryAction,
|
||||
onDispatchContextIdChange,
|
||||
onExecutionModeChange,
|
||||
onOpenNodeMenu,
|
||||
onAutoLayout,
|
||||
onDeleteSelectedEdges,
|
||||
onPreflight,
|
||||
onDispatch,
|
||||
onSave,
|
||||
onRollbackOutputType,
|
||||
}: WorkflowCanvasToolbarProps) {
|
||||
const AuthoringEntryIcon = authoringEntryAction.icon
|
||||
const preflightBadgeClassName = {
|
||||
ready: 'border-emerald-200 bg-emerald-50 text-emerald-700',
|
||||
required: 'border-slate-200 bg-slate-100 text-slate-600',
|
||||
stale: 'border-amber-200 bg-amber-50 text-amber-700',
|
||||
blocked: 'border-red-200 bg-red-50 text-red-700',
|
||||
}[preflightState]
|
||||
const preflightBadgeLabel = {
|
||||
ready: 'Preflight ready',
|
||||
required: 'Preflight required',
|
||||
stale: 'Preflight stale',
|
||||
blocked: 'Preflight blocked',
|
||||
}[preflightState]
|
||||
const showSplitFamilyBadges = authoringFamilyLabel !== graphFamilyLabel || authoringFamilyClassName !== graphFamilyClassName
|
||||
const selectedEdgeLabel = selectedEdgeCount > 1 ? `Delete (${selectedEdgeCount})` : 'Delete'
|
||||
const hasRolloutControls = linkedOutputTypes.length > 0
|
||||
|
||||
return (
|
||||
<div className="border-b border-border-default bg-surface px-3 py-2">
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<div className="flex items-center gap-2 rounded-full border border-border-default bg-surface-hover/60 px-2 py-0.5 text-[11px] font-medium text-content-secondary">
|
||||
<GitBranch size={13} />
|
||||
Workflow Canvas
|
||||
</div>
|
||||
<h1 className="truncate text-sm font-semibold text-content">{workflowName}</h1>
|
||||
<span className={`rounded-full px-2 py-0.5 text-[11px] font-medium ${graphFamilyClassName}`}>
|
||||
{graphFamilyLabel}
|
||||
</span>
|
||||
<span className={`rounded-full px-2 py-0.5 text-[11px] font-medium ${executionModeClassName}`}>
|
||||
{executionModeLabel}
|
||||
</span>
|
||||
{blueprintLabel && (
|
||||
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-[11px] font-medium text-slate-700 dark:bg-slate-900/40 dark:text-slate-300">
|
||||
{blueprintLabel}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="min-w-0 flex flex-1 flex-col gap-1">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
|
||||
<ToolbarBadge className="bg-surface-hover/60 text-content-secondary">
|
||||
<GitBranch size={13} />
|
||||
Workflow Canvas
|
||||
</ToolbarBadge>
|
||||
<h1 className="max-w-[20rem] truncate text-sm font-semibold text-content">{workflowName}</h1>
|
||||
{blueprintLabel && (
|
||||
<ToolbarBadge className="border-transparent bg-slate-100 text-slate-700 dark:bg-slate-900/40 dark:text-slate-300">
|
||||
{blueprintLabel}
|
||||
</ToolbarBadge>
|
||||
)}
|
||||
{showSplitFamilyBadges ? (
|
||||
<>
|
||||
<span className={`rounded-full px-2 py-0.5 text-[11px] font-medium ${authoringFamilyClassName}`}>
|
||||
Authoring: {authoringFamilyLabel}
|
||||
</span>
|
||||
<span className={`rounded-full px-2 py-0.5 text-[11px] font-medium ${graphFamilyClassName}`}>
|
||||
Graph: {graphFamilyLabel}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className={`rounded-full px-2 py-0.5 text-[11px] font-medium ${authoringFamilyClassName}`}>
|
||||
Family: {authoringFamilyLabel}
|
||||
</span>
|
||||
)}
|
||||
<span className={`rounded-full px-2 py-0.5 text-[11px] font-medium ${executionModeClassName}`}>
|
||||
{executionModeLabel}
|
||||
</span>
|
||||
<span className={`rounded-full px-2 py-0.5 text-[11px] font-medium ${rolloutBadgeClassName}`}>
|
||||
{rolloutBadgeLabel}
|
||||
</span>
|
||||
<span className={`rounded-full px-2 py-0.5 text-[11px] font-medium ${rolloutStatusClassName}`}>
|
||||
{rolloutStatusLabel}
|
||||
</span>
|
||||
<span className={`inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] font-medium ${preflightBadgeClassName}`}>
|
||||
{preflightBadgeLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-[11px] text-content-muted">
|
||||
<p className="text-xs text-content-muted">
|
||||
{linkedOutputTypeCount} linked output type{linkedOutputTypeCount === 1 ? '' : 's'} · {rolloutSummary}
|
||||
</p>
|
||||
<span className="hidden text-content-muted lg:inline">|</span>
|
||||
<span className="text-xs text-content-muted">{executionModeHint}</span>
|
||||
</div>
|
||||
{blueprintDescription && <p className="text-[11px] text-content-muted">{blueprintDescription}</p>}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-1.5 self-start">
|
||||
<ToolbarActionButton
|
||||
onClick={authoringActions.openNodeMenu}
|
||||
disabled={!authoringActions.openNodeMenu}
|
||||
title={authoringEntryAction.title}
|
||||
>
|
||||
<AuthoringEntryIcon size={14} />
|
||||
{authoringEntryAction.label}
|
||||
</ToolbarActionButton>
|
||||
<ToolbarActionButton
|
||||
onClick={onAutoLayout}
|
||||
disabled={!canAutoLayout}
|
||||
title="Automatically align nodes into a readable graph layout"
|
||||
>
|
||||
<LayoutGrid size={14} />
|
||||
Align
|
||||
</ToolbarActionButton>
|
||||
<ToolbarActionButton
|
||||
onClick={onDeleteSelectedEdges}
|
||||
disabled={selectedEdgeCount === 0}
|
||||
title="Delete the currently selected connection(s)"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
{selectedEdgeLabel}
|
||||
</ToolbarActionButton>
|
||||
<ToolbarActionButton
|
||||
onClick={onPreflight}
|
||||
disabled={!canPreflight || isPreflightPending || hasValidationErrors}
|
||||
title="Validate graph runtime readiness without dispatching tasks"
|
||||
>
|
||||
{isPreflightPending ? <Loader2 size={14} className="animate-spin" /> : <RefreshCw size={14} />}
|
||||
{isPreflightPending ? 'Checking…' : 'Dry Run'}
|
||||
</ToolbarActionButton>
|
||||
<ToolbarActionButton
|
||||
onClick={onDispatch}
|
||||
disabled={!canDispatch || isDispatchPending || hasValidationErrors}
|
||||
title="Manual graph runtime dispatch for workflow debugging"
|
||||
>
|
||||
{isDispatchPending ? <Loader2 size={14} className="animate-spin" /> : <Play size={14} />}
|
||||
{isDispatchPending ? 'Dispatching…' : 'Run'}
|
||||
</ToolbarActionButton>
|
||||
<ToolbarActionButton
|
||||
onClick={onSave}
|
||||
disabled={isSaving || hasValidationErrors}
|
||||
tone="primary"
|
||||
>
|
||||
<Save size={14} />
|
||||
{isSaving ? 'Saving…' : 'Save'}
|
||||
</ToolbarActionButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-border-default/70 pt-1.5">
|
||||
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-1.5">
|
||||
{dispatchContextKind === 'order_line' ? (
|
||||
<ToolbarField label={dispatchContextLabel}>
|
||||
<select
|
||||
value={dispatchContextId}
|
||||
onChange={event => onDispatchContextIdChange(event.target.value)}
|
||||
className="max-w-[18rem] bg-transparent text-sm text-content focus:outline-none"
|
||||
aria-label="Order line context"
|
||||
disabled={isContextOptionsLoading || orderLineContextGroups.length === 0}
|
||||
>
|
||||
{orderLineContextGroups.length === 0 ? (
|
||||
<option value="">
|
||||
{isContextOptionsLoading ? 'Loading order lines…' : 'No order lines available'}
|
||||
</option>
|
||||
) : (
|
||||
orderLineContextGroups.map(group => (
|
||||
<optgroup key={group.orderId} label={group.orderLabel}>
|
||||
{group.options.map(option => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
))
|
||||
)}
|
||||
</select>
|
||||
</ToolbarField>
|
||||
) : (
|
||||
<ToolbarField label={dispatchContextLabel}>
|
||||
<input
|
||||
value={dispatchContextId}
|
||||
onChange={event => onDispatchContextIdChange(event.target.value)}
|
||||
placeholder={dispatchContextKind === 'cad_file' ? 'cad file uuid' : 'context id'}
|
||||
className="w-32 bg-transparent text-sm text-content focus:outline-none lg:w-40"
|
||||
/>
|
||||
</ToolbarField>
|
||||
)}
|
||||
<ToolbarField label="Mode">
|
||||
<select
|
||||
value={executionMode}
|
||||
onChange={event => onExecutionModeChange(event.target.value)}
|
||||
className="bg-transparent text-sm text-content focus:outline-none"
|
||||
aria-label="Mode"
|
||||
title={executionModeHint}
|
||||
>
|
||||
{executionModes.map(mode => (
|
||||
<option key={mode.value} value={mode.value}>
|
||||
{mode.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</ToolbarField>
|
||||
{dispatchContextSummary && (
|
||||
<ToolbarBadge
|
||||
className="max-w-[28rem] bg-surface-hover/60 text-content-secondary"
|
||||
title={dispatchContextMeta ? `${dispatchContextSummary} · ${dispatchContextMeta}` : dispatchContextSummary}
|
||||
>
|
||||
<span className="font-medium text-content">{dispatchContextLabel}</span>
|
||||
<span className="truncate">{dispatchContextSummary}</span>
|
||||
{dispatchContextMeta && <span className="truncate text-content-muted">· {dispatchContextMeta}</span>}
|
||||
</ToolbarBadge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 text-[11px] text-content-muted">
|
||||
{(blueprintDescription || executionModeHint) && (
|
||||
<span className="inline-flex max-w-3xl items-center gap-1 rounded-full border border-border-default bg-surface px-2 py-0.5">
|
||||
<BadgeInfo size={11} />
|
||||
{blueprintDescription ?? executionModeHint}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className="inline-flex items-center gap-1 rounded-full border border-border-default bg-surface px-2 py-0.5"
|
||||
title="Right-click anywhere on the canvas to open the searchable node picker."
|
||||
|
||||
<div className="flex flex-wrap items-center gap-1.5 text-[11px] text-content-muted">
|
||||
<ToolbarBadge
|
||||
className="text-content-muted"
|
||||
title={blueprintDescription ?? 'Right-click anywhere on the canvas to open the searchable node picker.'}
|
||||
>
|
||||
<MousePointer2 size={11} />
|
||||
Right-click to add
|
||||
</span>
|
||||
<span
|
||||
className="inline-flex items-center gap-1 rounded-full border border-border-default bg-surface px-2 py-0.5"
|
||||
</ToolbarBadge>
|
||||
<ToolbarBadge
|
||||
className="text-content-muted"
|
||||
title="Select an edge and press Delete, or use right-click / double-click to remove it."
|
||||
>
|
||||
<Trash2 size={11} />
|
||||
Delete removes connections
|
||||
</span>
|
||||
</ToolbarBadge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenNodeMenu}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-border-default px-2.5 py-1.5 text-sm text-content hover:bg-surface-hover"
|
||||
title="Open searchable node picker"
|
||||
>
|
||||
<Plus size={14} />
|
||||
Node
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAutoLayout}
|
||||
disabled={!canAutoLayout}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-border-default px-2.5 py-1.5 text-sm text-content hover:bg-surface-hover disabled:opacity-50"
|
||||
title="Automatically align nodes into a readable graph layout"
|
||||
>
|
||||
<LayoutGrid size={14} />
|
||||
Align
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDeleteSelectedEdges}
|
||||
disabled={selectedEdgeCount === 0}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-border-default px-2.5 py-1.5 text-sm text-content hover:bg-surface-hover disabled:opacity-50"
|
||||
title="Delete the currently selected connection(s)"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
{selectedEdgeCount > 1 ? `Delete (${selectedEdgeCount})` : 'Delete'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{hasRolloutControls && (
|
||||
<details className="rounded-xl border border-border-default bg-surface-hover/40">
|
||||
<summary className="flex cursor-pointer list-none flex-wrap items-center justify-between gap-2 px-3 py-2">
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.14em] text-content-muted">
|
||||
Rollout Controls
|
||||
</span>
|
||||
<span className="text-xs text-content-muted">
|
||||
Linked output types can be forced back to legacy directly from the workflow editor.
|
||||
</span>
|
||||
</div>
|
||||
<ToolbarBadge className="text-content-muted">
|
||||
{linkedOutputTypes.length} output type{linkedOutputTypes.length === 1 ? '' : 's'}
|
||||
</ToolbarBadge>
|
||||
</summary>
|
||||
|
||||
<div className="mt-2 flex flex-wrap items-center justify-between gap-2 border-t border-border-default/70 pt-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<label className="flex items-center gap-2 rounded-lg border border-border-default bg-surface px-2 py-1.5 text-xs text-content-secondary">
|
||||
<span className="whitespace-nowrap">Context</span>
|
||||
<input
|
||||
value={dispatchContextId}
|
||||
onChange={event => onDispatchContextIdChange(event.target.value)}
|
||||
placeholder="context id"
|
||||
className="w-40 bg-transparent text-sm text-content focus:outline-none lg:w-52"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 rounded-lg border border-border-default bg-surface px-2 py-1.5 text-xs text-content-secondary">
|
||||
<span className="whitespace-nowrap">Mode</span>
|
||||
<select
|
||||
value={executionMode}
|
||||
onChange={event => onExecutionModeChange(event.target.value)}
|
||||
className="bg-transparent text-sm text-content focus:outline-none"
|
||||
>
|
||||
{executionModes.map(mode => (
|
||||
<option key={mode.value} value={mode.value}>
|
||||
{mode.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<button
|
||||
onClick={onPreflight}
|
||||
disabled={isPreflightPending || hasValidationErrors}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-border-default px-2.5 py-1.5 text-sm text-content hover:bg-surface-hover disabled:opacity-50"
|
||||
title="Validate graph runtime readiness without dispatching tasks"
|
||||
>
|
||||
{isPreflightPending ? <Loader2 size={14} className="animate-spin" /> : <RefreshCw size={14} />}
|
||||
{isPreflightPending ? 'Checking…' : 'Dry Run'}
|
||||
</button>
|
||||
<button
|
||||
onClick={onDispatch}
|
||||
disabled={isDispatchPending || hasValidationErrors}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-border-default px-2.5 py-1.5 text-sm text-content hover:bg-surface-hover disabled:opacity-50"
|
||||
title="Manual graph runtime dispatch for workflow debugging"
|
||||
>
|
||||
{isDispatchPending ? <Loader2 size={14} className="animate-spin" /> : <Play size={14} />}
|
||||
{isDispatchPending ? 'Dispatching…' : 'Run'}
|
||||
</button>
|
||||
<button
|
||||
onClick={onSave}
|
||||
disabled={isSaving || hasValidationErrors}
|
||||
className="flex items-center gap-1.5 rounded-lg bg-accent px-2.5 py-1.5 text-sm text-white hover:bg-accent-hover disabled:opacity-50"
|
||||
>
|
||||
<Save size={14} />
|
||||
{isSaving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[11px] text-content-muted">
|
||||
{executionModeHint}
|
||||
</p>
|
||||
<div className="flex flex-col gap-2 border-t border-border-default px-3 py-2">
|
||||
{linkedOutputTypes.map(outputType => {
|
||||
const rolloutPresentation = getOutputTypeRolloutPresentation({
|
||||
hasWorkflowLink: true,
|
||||
workflowRolloutMode: outputType.workflow_rollout_mode,
|
||||
})
|
||||
const isRollbackPending = rollbackPendingOutputTypeId === outputType.id
|
||||
const isAlreadyLegacy = outputType.workflow_rollout_mode === 'legacy_only'
|
||||
|
||||
return (
|
||||
<div
|
||||
key={outputType.id}
|
||||
className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-border-default bg-surface px-2.5 py-2"
|
||||
>
|
||||
<div className="min-w-0 flex flex-1 flex-col gap-1">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span className="truncate text-sm font-medium text-content">{outputType.name}</span>
|
||||
<span className={`rounded-full px-2 py-0.5 text-[11px] font-medium ${rolloutPresentation.badgeClassName}`}>
|
||||
{rolloutPresentation.badgeLabel}
|
||||
</span>
|
||||
<span className="rounded-full border border-border-default px-2 py-0.5 text-[11px] font-medium text-content-secondary">
|
||||
{outputType.artifact_kind}
|
||||
</span>
|
||||
{!outputType.is_active && (
|
||||
<span className="rounded-full bg-amber-100 px-2 py-0.5 text-[11px] font-medium text-amber-700">
|
||||
Inactive
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-content-muted">{rolloutPresentation.rowSummary}</p>
|
||||
</div>
|
||||
|
||||
<ToolbarActionButton
|
||||
onClick={() => onRollbackOutputType(outputType.id)}
|
||||
disabled={isRollbackPending || isAlreadyLegacy}
|
||||
title={
|
||||
isAlreadyLegacy
|
||||
? `${outputType.name} is already legacy-authoritative.`
|
||||
: `Set ${outputType.name} rollout mode back to legacy_only.`
|
||||
}
|
||||
>
|
||||
{isRollbackPending ? <Loader2 size={14} className="animate-spin" /> : <RefreshCw size={14} />}
|
||||
{isRollbackPending ? 'Reverting…' : 'Set Legacy'}
|
||||
</ToolbarActionButton>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Activity, Library, ShieldCheck, SlidersHorizontal } from 'lucide-react'
|
||||
import { useMemo, type ReactNode } from 'react'
|
||||
import { Activity, Library, ShieldCheck, SlidersHorizontal, type LucideIcon } from 'lucide-react'
|
||||
import type {
|
||||
WorkflowNodeDefinition,
|
||||
WorkflowParams,
|
||||
@@ -12,8 +12,10 @@ import { WorkflowNodeInspector } from './WorkflowNodeInspector'
|
||||
import { WorkflowPreflightPanel } from './WorkflowPreflightPanel'
|
||||
import { WorkflowRunsPanel } from './WorkflowRunsPanel'
|
||||
import { WorkflowUtilityRail } from './WorkflowUtilityRail'
|
||||
import { getWorkflowAuthoringEntryAction, type WorkflowAuthoringActions } from './workflowAuthoringActions'
|
||||
import type { WorkflowCanvasNodeData } from './workflowGraphDraft'
|
||||
import type { WorkflowGraphFamily } from './workflowNodeLibrary'
|
||||
import { getWorkflowAuthoringSurfaceModel } from './workflowAuthoringSurface'
|
||||
|
||||
export type WorkflowUtilityTab = 'inspector' | 'library' | 'runs' | 'preflight'
|
||||
|
||||
@@ -28,7 +30,8 @@ type WorkflowCanvasUtilitySidebarProps = {
|
||||
nodeDefinitions: WorkflowNodeDefinition[]
|
||||
nodeDefinitionsByStep: Record<string, WorkflowNodeDefinition>
|
||||
graphFamily: WorkflowGraphFamily
|
||||
onInsertNode: (step: string) => void
|
||||
activeSteps: string[]
|
||||
authoringActions: WorkflowAuthoringActions
|
||||
renderNodeIcon: (iconName?: string, size?: number) => ReactNode
|
||||
workflowRuns: WorkflowRun[]
|
||||
selectedRunId: string | null
|
||||
@@ -48,7 +51,8 @@ export function WorkflowCanvasUtilitySidebar({
|
||||
nodeDefinitions,
|
||||
nodeDefinitionsByStep,
|
||||
graphFamily,
|
||||
onInsertNode,
|
||||
activeSteps,
|
||||
authoringActions,
|
||||
renderNodeIcon,
|
||||
workflowRuns,
|
||||
selectedRunId,
|
||||
@@ -58,12 +62,20 @@ export function WorkflowCanvasUtilitySidebar({
|
||||
preflightResult,
|
||||
isPreflightPending,
|
||||
}: WorkflowCanvasUtilitySidebarProps) {
|
||||
const surfaceModel = useMemo(
|
||||
() => getWorkflowAuthoringSurfaceModel({ definitions: nodeDefinitions, graphFamily, activeSteps }),
|
||||
[activeSteps, graphFamily, nodeDefinitions],
|
||||
)
|
||||
const authoringEntryAction = getWorkflowAuthoringEntryAction(surfaceModel)
|
||||
const authoringTabLabel = authoringEntryAction.label === 'Author' ? 'Authoring' : 'Library'
|
||||
const AuthoringTabIcon = authoringEntryAction.icon
|
||||
|
||||
if (nodeDefinitions.length === 0) return null
|
||||
|
||||
const utilityTabs: {
|
||||
key: WorkflowUtilityTab
|
||||
label: string
|
||||
icon: typeof SlidersHorizontal
|
||||
icon: LucideIcon
|
||||
count?: number | null
|
||||
disabled?: boolean
|
||||
}[] = [
|
||||
@@ -75,8 +87,8 @@ export function WorkflowCanvasUtilitySidebar({
|
||||
},
|
||||
{
|
||||
key: 'library',
|
||||
label: 'Library',
|
||||
icon: Library,
|
||||
label: authoringTabLabel,
|
||||
icon: AuthoringTabIcon,
|
||||
count: nodeDefinitions.length,
|
||||
},
|
||||
{
|
||||
@@ -124,7 +136,8 @@ export function WorkflowCanvasUtilitySidebar({
|
||||
<NodeDefinitionsPanel
|
||||
definitions={nodeDefinitions}
|
||||
graphFamily={graphFamily}
|
||||
onSelectStep={onInsertNode}
|
||||
activeSteps={activeSteps}
|
||||
actions={authoringActions}
|
||||
renderIcon={renderNodeIcon}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -10,6 +10,12 @@ interface WorkflowListItem {
|
||||
familyClassName: string
|
||||
executionModeLabel: string
|
||||
executionModeClassName: string
|
||||
rolloutBadgeLabel: string
|
||||
rolloutBadgeClassName: string
|
||||
rolloutStatusLabel: string
|
||||
rolloutStatusClassName: string
|
||||
rolloutSummary: string
|
||||
linkedOutputTypeCount: number
|
||||
blueprintLabel?: string | null
|
||||
isReference?: boolean
|
||||
}
|
||||
@@ -133,6 +139,18 @@ export function WorkflowListSidebar({
|
||||
<span className={`ml-1 mt-1 inline-block rounded-full px-1.5 py-0.5 text-xs font-medium ${item.executionModeClassName}`}>
|
||||
{item.executionModeLabel}
|
||||
</span>
|
||||
<span className={`ml-1 mt-1 inline-block rounded-full px-1.5 py-0.5 text-xs font-medium ${item.rolloutBadgeClassName}`}>
|
||||
{item.rolloutBadgeLabel}
|
||||
</span>
|
||||
<span className={`ml-1 mt-1 inline-block rounded-full px-1.5 py-0.5 text-xs font-medium ${item.rolloutStatusClassName}`}>
|
||||
{item.rolloutStatusLabel}
|
||||
</span>
|
||||
<p className="mt-1 text-xs text-content-muted">
|
||||
{item.linkedOutputTypeCount} linked output type{item.linkedOutputTypeCount === 1 ? '' : 's'}.
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-content-muted">
|
||||
{item.rolloutSummary}
|
||||
</p>
|
||||
{item.isReference && (
|
||||
<p className="mt-1 text-xs text-content-muted">
|
||||
Canonical reference workflow for parity work.
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { Boxes, Wand2 } from 'lucide-react'
|
||||
|
||||
import type { WorkflowNodeDefinition } from '../../api/workflows'
|
||||
import type { WorkflowGraphFamily } from './workflowNodeLibrary'
|
||||
import { getWorkflowAuthoringPlan } from './workflowAuthoringGuidance'
|
||||
import type { WorkflowModuleBundleId } from './workflowModuleBundles'
|
||||
|
||||
type WorkflowModuleBundlePanelProps = {
|
||||
definitions: WorkflowNodeDefinition[]
|
||||
graphFamily: WorkflowGraphFamily
|
||||
activeSteps: string[]
|
||||
onInsertModule?: (bundleId: WorkflowModuleBundleId) => void
|
||||
}
|
||||
|
||||
export function WorkflowModuleBundlePanel({
|
||||
definitions,
|
||||
graphFamily,
|
||||
activeSteps,
|
||||
onInsertModule,
|
||||
}: WorkflowModuleBundlePanelProps) {
|
||||
const { moduleBundles } = getWorkflowAuthoringPlan(definitions, graphFamily, activeSteps)
|
||||
|
||||
if (moduleBundles.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="space-y-2 rounded-2xl border border-border-default bg-surface-hover/30 p-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Boxes size={14} className="text-accent" />
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-content-secondary">
|
||||
Production Modules
|
||||
</p>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-content-muted">
|
||||
Insert reusable subgraphs for core production stages instead of assembling every node from scratch.
|
||||
</p>
|
||||
</div>
|
||||
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5 text-[11px] text-content-muted">
|
||||
{moduleBundles.length} bundles
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{moduleBundles.map(bundle => (
|
||||
<div
|
||||
key={bundle.id}
|
||||
className="rounded-2xl border border-border-default bg-surface px-3 py-3"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="text-sm font-semibold text-content">{bundle.label}</p>
|
||||
<span className="rounded-full bg-surface-hover px-2 py-0.5 text-[11px] font-medium text-content-secondary">
|
||||
{bundle.stage}
|
||||
</span>
|
||||
<span className="rounded-full bg-surface-hover px-2 py-0.5 text-[11px] text-content-muted">
|
||||
{bundle.presentCount}/{bundle.totalCount} present
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-content-muted">{bundle.description}</p>
|
||||
<p className="text-[11px] text-content-muted">
|
||||
{bundle.stepIds.join(' -> ')}
|
||||
</p>
|
||||
</div>
|
||||
{onInsertModule ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onInsertModule(bundle.id)}
|
||||
aria-label={`Insert ${bundle.label}`}
|
||||
className="inline-flex items-center gap-1 rounded-xl bg-accent px-3 py-1.5 text-xs font-semibold text-white transition-colors hover:bg-accent-hover"
|
||||
>
|
||||
<Wand2 size={12} />
|
||||
Insert
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,18 +1,22 @@
|
||||
import { useEffect, useMemo, useState, type ReactNode } from 'react'
|
||||
import { ArrowRight, Plus, Search } from 'lucide-react'
|
||||
|
||||
import type { WorkflowNodeDefinition } from '../../api/workflows'
|
||||
import type { StepCategory, WorkflowNodeDefinition } from '../../api/workflows'
|
||||
import {
|
||||
AUTHORING_STAGE_DESCRIPTIONS,
|
||||
AUTHORING_STAGE_LABELS,
|
||||
AUTHORING_STAGE_STYLES,
|
||||
CATEGORY_COLORS,
|
||||
CATEGORY_LABELS,
|
||||
FAMILY_FILTER_DESCRIPTIONS,
|
||||
FAMILY_FILTER_LABELS,
|
||||
FAMILY_FILTER_STYLES,
|
||||
NODE_KIND_FILTER_LABELS,
|
||||
NODE_LIBRARY_GROUP_DESCRIPTIONS,
|
||||
NODE_LIBRARY_GROUP_LABELS,
|
||||
NODE_LIBRARY_GROUP_STYLES,
|
||||
getDefinitionBadges,
|
||||
getDefinitionFamily,
|
||||
getDefinitionModuleLabel,
|
||||
getDefinitionModuleNamespace,
|
||||
type WorkflowGraphFamily,
|
||||
type WorkflowNodeFamilyFilter,
|
||||
@@ -20,10 +24,11 @@ import {
|
||||
type WorkflowNodeLibraryGroup,
|
||||
} from './workflowNodeLibrary'
|
||||
import {
|
||||
buildWorkflowNodeCatalog,
|
||||
buildWorkflowNodeCatalogModel,
|
||||
filterWorkflowNodeDefinitions,
|
||||
getAvailableFamilyFilters,
|
||||
} from './workflowNodeCatalog'
|
||||
import { STARTER_NODE_STEP_ORDER, STARTER_PATH_TITLES } from './workflowAuthoringGuidance'
|
||||
|
||||
type WorkflowNodeCatalogBrowserProps = {
|
||||
definitions: WorkflowNodeDefinition[]
|
||||
@@ -37,10 +42,21 @@ type WorkflowNodeCatalogBrowserProps = {
|
||||
autoFocusSearch?: boolean
|
||||
}
|
||||
|
||||
type WorkflowNodeCatalogModuleFilter = {
|
||||
namespace: string
|
||||
type WorkflowNodeCategoryFilter = 'all' | StepCategory
|
||||
|
||||
const CATEGORY_FILTERS: WorkflowNodeCategoryFilter[] = ['all', 'input', 'processing', 'rendering', 'output']
|
||||
|
||||
const CATEGORY_FILTER_LABELS: Record<WorkflowNodeCategoryFilter, string> = {
|
||||
all: 'All Categories',
|
||||
input: 'Input',
|
||||
processing: 'Processing',
|
||||
rendering: 'Rendering',
|
||||
output: 'Output',
|
||||
}
|
||||
|
||||
type FilterPillOption<T extends string> = {
|
||||
value: T
|
||||
label: string
|
||||
count: number
|
||||
}
|
||||
|
||||
function readContractList(contract: Record<string, unknown>, key: string) {
|
||||
@@ -60,6 +76,42 @@ function formatContractLabel(value: string) {
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
function FilterPillGroup<T extends string>({
|
||||
title,
|
||||
options,
|
||||
activeValue,
|
||||
onChange,
|
||||
}: {
|
||||
title: string
|
||||
options: FilterPillOption<T>[]
|
||||
activeValue: T
|
||||
onChange: (value: T) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wide text-content-secondary">
|
||||
{title}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{options.map(option => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => onChange(option.value)}
|
||||
className={`rounded-full px-2.5 py-1 text-[11px] font-medium transition-colors ${
|
||||
activeValue === option.value
|
||||
? 'bg-accent text-white'
|
||||
: 'border border-border-default bg-surface text-content-secondary hover:bg-surface-hover'
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function WorkflowNodeCatalogBrowser({
|
||||
definitions,
|
||||
graphFamily,
|
||||
@@ -76,11 +128,15 @@ export function WorkflowNodeCatalogBrowser({
|
||||
graphFamily === 'mixed' ? 'all' : graphFamily,
|
||||
)
|
||||
const [kindFilter, setKindFilter] = useState<WorkflowNodeKindFilter>('all')
|
||||
const [categoryFilter, setCategoryFilter] = useState<WorkflowNodeCategoryFilter>('all')
|
||||
const [moduleFilter, setModuleFilter] = useState<string>('all')
|
||||
const [moduleQuery, setModuleQuery] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
setFamilyFilter(graphFamily === 'mixed' ? 'all' : graphFamily)
|
||||
setCategoryFilter('all')
|
||||
setModuleFilter('all')
|
||||
setModuleQuery('')
|
||||
}, [graphFamily])
|
||||
|
||||
const availableFamilyFilters = useMemo(
|
||||
@@ -97,25 +153,32 @@ export function WorkflowNodeCatalogBrowser({
|
||||
})
|
||||
}, [definitions, familyFilter, graphFamily, kindFilter, query])
|
||||
|
||||
const moduleFilters = useMemo<WorkflowNodeCatalogModuleFilter[]>(() => {
|
||||
const modules = new Map<string, WorkflowNodeCatalogModuleFilter>()
|
||||
const normalizedModuleQuery = moduleQuery.trim().toLowerCase()
|
||||
|
||||
for (const definition of filteredDefinitions) {
|
||||
const namespace = getDefinitionModuleNamespace(definition)
|
||||
const current = modules.get(namespace)
|
||||
if (current) {
|
||||
current.count += 1
|
||||
continue
|
||||
}
|
||||
modules.set(namespace, {
|
||||
namespace,
|
||||
label: definition.module_key,
|
||||
count: 1,
|
||||
})
|
||||
}
|
||||
const visibleDefinitions = useMemo(() => {
|
||||
const scopedByModule =
|
||||
moduleFilter === 'all'
|
||||
? filteredDefinitions
|
||||
: filteredDefinitions.filter(definition => getDefinitionModuleNamespace(definition) === moduleFilter)
|
||||
|
||||
return Array.from(modules.values()).sort((a, b) => a.label.localeCompare(b.label))
|
||||
}, [filteredDefinitions])
|
||||
const scopedByModuleQuery = !normalizedModuleQuery
|
||||
? scopedByModule
|
||||
: scopedByModule.filter(definition => {
|
||||
const namespace = getDefinitionModuleNamespace(definition).toLowerCase()
|
||||
return (
|
||||
namespace.includes(normalizedModuleQuery) ||
|
||||
getDefinitionModuleLabel(definition).toLowerCase().includes(normalizedModuleQuery) ||
|
||||
definition.module_key.toLowerCase().includes(normalizedModuleQuery) ||
|
||||
definition.label.toLowerCase().includes(normalizedModuleQuery)
|
||||
)
|
||||
})
|
||||
|
||||
if (categoryFilter === 'all') return scopedByModuleQuery
|
||||
return scopedByModuleQuery.filter(definition => definition.category === categoryFilter)
|
||||
}, [categoryFilter, filteredDefinitions, moduleFilter, normalizedModuleQuery])
|
||||
|
||||
const catalogModel = useMemo(() => buildWorkflowNodeCatalogModel(visibleDefinitions), [visibleDefinitions])
|
||||
const moduleFilters = catalogModel.moduleFilters
|
||||
|
||||
useEffect(() => {
|
||||
if (moduleFilter !== 'all' && !moduleFilters.some(module => module.namespace === moduleFilter)) {
|
||||
@@ -123,14 +186,36 @@ export function WorkflowNodeCatalogBrowser({
|
||||
}
|
||||
}, [moduleFilter, moduleFilters])
|
||||
|
||||
const visibleDefinitions = useMemo(() => {
|
||||
if (moduleFilter === 'all') return filteredDefinitions
|
||||
return filteredDefinitions.filter(definition => getDefinitionModuleNamespace(definition) === moduleFilter)
|
||||
}, [filteredDefinitions, moduleFilter])
|
||||
|
||||
const catalogSections = useMemo(() => buildWorkflowNodeCatalog(visibleDefinitions), [visibleDefinitions])
|
||||
const familySections = catalogModel.familySections
|
||||
const firstVisibleDefinition = visibleDefinitions[0]
|
||||
const totalModuleCount = moduleFilters.length
|
||||
const quickInsertDefinitions = useMemo(() => {
|
||||
const prioritizedSteps = STARTER_NODE_STEP_ORDER[graphFamily]
|
||||
if (prioritizedSteps.length === 0) return visibleDefinitions.slice(0, 4)
|
||||
|
||||
const byStep = new Map(visibleDefinitions.map(definition => [definition.step, definition]))
|
||||
return prioritizedSteps
|
||||
.map(step => byStep.get(step))
|
||||
.filter((definition): definition is WorkflowNodeDefinition => Boolean(definition))
|
||||
.slice(0, variant === 'menu' ? 5 : 6)
|
||||
}, [graphFamily, variant, visibleDefinitions])
|
||||
const quickInsertTitle = graphFamily === 'mixed' ? 'Suggested nodes' : STARTER_PATH_TITLES[graphFamily]
|
||||
const runtimeFilterOptions: FilterPillOption<WorkflowNodeKindFilter>[] = [
|
||||
{ value: 'all', label: NODE_KIND_FILTER_LABELS.all },
|
||||
{ value: 'legacy', label: NODE_KIND_FILTER_LABELS.legacy },
|
||||
{ value: 'bridge', label: NODE_KIND_FILTER_LABELS.bridge },
|
||||
{ value: 'graph', label: NODE_KIND_FILTER_LABELS.graph },
|
||||
]
|
||||
const familyFilterOptions = availableFamilyFilters.map(filter => ({
|
||||
value: filter,
|
||||
label: FAMILY_FILTER_LABELS[filter],
|
||||
}))
|
||||
const categoryFilterOptions: FilterPillOption<WorkflowNodeCategoryFilter>[] = CATEGORY_FILTERS.map(
|
||||
filter => ({
|
||||
value: filter,
|
||||
label: CATEGORY_FILTER_LABELS[filter],
|
||||
}),
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
@@ -149,10 +234,13 @@ export function WorkflowNodeCatalogBrowser({
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{moduleFilter !== 'all' && (
|
||||
{(moduleFilter !== 'all' || moduleQuery) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setModuleFilter('all')}
|
||||
onClick={() => {
|
||||
setModuleFilter('all')
|
||||
setModuleQuery('')
|
||||
}}
|
||||
className="text-[11px] font-medium text-accent hover:text-accent-hover"
|
||||
>
|
||||
Show all modules
|
||||
@@ -178,39 +266,26 @@ export function WorkflowNodeCatalogBrowser({
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(['all', 'legacy', 'bridge', 'graph'] as WorkflowNodeKindFilter[]).map(filter => (
|
||||
<button
|
||||
key={filter}
|
||||
type="button"
|
||||
onClick={() => setKindFilter(filter)}
|
||||
className={`rounded-full px-2.5 py-1 text-[11px] font-medium transition-colors ${
|
||||
kindFilter === filter
|
||||
? 'bg-accent text-white'
|
||||
: 'border border-border-default bg-surface text-content-secondary hover:bg-surface-hover'
|
||||
}`}
|
||||
>
|
||||
{NODE_KIND_FILTER_LABELS[filter]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<FilterPillGroup
|
||||
title="Runtime"
|
||||
options={runtimeFilterOptions}
|
||||
activeValue={kindFilter}
|
||||
onChange={setKindFilter}
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{availableFamilyFilters.map(filter => (
|
||||
<button
|
||||
key={filter}
|
||||
type="button"
|
||||
onClick={() => setFamilyFilter(filter)}
|
||||
className={`rounded-full px-2.5 py-1 text-[11px] font-medium transition-colors ${
|
||||
familyFilter === filter
|
||||
? 'bg-accent text-white'
|
||||
: 'border border-border-default bg-surface text-content-secondary hover:bg-surface-hover'
|
||||
}`}
|
||||
>
|
||||
{FAMILY_FILTER_LABELS[filter]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<FilterPillGroup
|
||||
title="Family"
|
||||
options={familyFilterOptions}
|
||||
activeValue={familyFilter}
|
||||
onChange={setFamilyFilter}
|
||||
/>
|
||||
|
||||
<FilterPillGroup
|
||||
title="Category"
|
||||
options={categoryFilterOptions}
|
||||
activeValue={categoryFilter}
|
||||
onChange={setCategoryFilter}
|
||||
/>
|
||||
|
||||
{moduleFilters.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
@@ -222,6 +297,15 @@ export function WorkflowNodeCatalogBrowser({
|
||||
family + runtime scoped
|
||||
</span>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Search size={12} className="absolute left-3 top-1/2 -translate-y-1/2 text-content-muted" />
|
||||
<input
|
||||
value={moduleQuery}
|
||||
onChange={event => setModuleQuery(event.target.value)}
|
||||
placeholder="Search modules"
|
||||
className="w-full rounded-xl border border-border-default bg-surface px-8 py-2 text-xs text-content focus:outline-none focus:ring-2 focus:ring-accent"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
@@ -244,9 +328,9 @@ export function WorkflowNodeCatalogBrowser({
|
||||
? 'bg-accent text-white'
|
||||
: 'border border-border-default bg-surface text-content-secondary hover:bg-surface-hover'
|
||||
}`}
|
||||
title={module.label}
|
||||
title={`${module.label} · ${module.stages.map(stage => AUTHORING_STAGE_LABELS[stage]).join(' / ')}`}
|
||||
>
|
||||
{module.namespace}
|
||||
{module.label}
|
||||
<span className="ml-1 opacity-70">{module.count}</span>
|
||||
</button>
|
||||
))}
|
||||
@@ -258,21 +342,51 @@ export function WorkflowNodeCatalogBrowser({
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(['legacy', 'bridge', 'graph'] as WorkflowNodeLibraryGroup[]).map(group => {
|
||||
const count = catalogSections.find(section => section.group === group)?.definitions.length ?? 0
|
||||
const count = catalogModel.runtimeCounts[group] ?? 0
|
||||
if (count === 0) return null
|
||||
return (
|
||||
<span
|
||||
key={group}
|
||||
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-medium ${NODE_LIBRARY_GROUP_STYLES[group]}`}
|
||||
title={NODE_LIBRARY_GROUP_DESCRIPTIONS[group]}
|
||||
title={NODE_LIBRARY_GROUP_LABELS[group]}
|
||||
>
|
||||
<span>{NODE_KIND_FILTER_LABELS[group]}</span>
|
||||
<span>{NODE_LIBRARY_GROUP_LABELS[group]}</span>
|
||||
<span>{count}</span>
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{quickInsertDefinitions.length > 0 && (
|
||||
<div className="rounded-xl border border-border-default bg-surface-hover/35 p-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-content-secondary">
|
||||
Quick Insert
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-content-muted">{quickInsertTitle}</p>
|
||||
</div>
|
||||
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5 text-[11px] text-content-muted">
|
||||
{quickInsertDefinitions.length} picks
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{quickInsertDefinitions.map(definition => (
|
||||
<button
|
||||
key={`quick-${definition.step}`}
|
||||
type="button"
|
||||
onClick={() => onSelectStep?.(definition.step)}
|
||||
disabled={!onSelectStep}
|
||||
className="rounded-full border border-border-default bg-surface px-3 py-1.5 text-xs font-medium text-content transition-colors hover:bg-surface-hover disabled:cursor-default disabled:opacity-60"
|
||||
title={definition.description}
|
||||
>
|
||||
{definition.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{visibleDefinitions.length === 0 && (
|
||||
<div className="rounded-2xl border border-dashed border-border-default bg-surface-hover/40 px-4 py-8 text-center">
|
||||
<p className="text-sm font-medium text-content">No matching nodes</p>
|
||||
@@ -292,172 +406,258 @@ export function WorkflowNodeCatalogBrowser({
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
{catalogSections.map(section => {
|
||||
const group = section.group as WorkflowNodeLibraryGroup
|
||||
{familySections.map(familySection => {
|
||||
const familyLabel = FAMILY_FILTER_LABELS[familySection.family]
|
||||
const familyDescription = FAMILY_FILTER_DESCRIPTIONS[familySection.family]
|
||||
const familyStyle = FAMILY_FILTER_STYLES[familySection.family]
|
||||
|
||||
return (
|
||||
<div key={group} className="rounded-lg border border-border-default bg-surface-hover/40 p-2">
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<span className={`rounded-full px-2 py-0.5 text-[11px] font-medium ${NODE_LIBRARY_GROUP_STYLES[group]}`}>
|
||||
{NODE_LIBRARY_GROUP_LABELS[group]}
|
||||
</span>
|
||||
<span className="text-xs text-content-muted">{section.definitions.length}</span>
|
||||
<div
|
||||
key={familySection.family}
|
||||
className="rounded-xl border border-border-default bg-surface-hover/35 p-3"
|
||||
>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className={`rounded-full px-2 py-0.5 text-[11px] font-medium ${familyStyle}`}>
|
||||
{familyLabel}
|
||||
</span>
|
||||
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5 text-[11px] text-content-muted">
|
||||
{familySection.modules.length} modules
|
||||
</span>
|
||||
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5 text-[11px] text-content-muted">
|
||||
{familySection.definitions.length} nodes
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-content-muted">{familyDescription}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(['legacy', 'bridge', 'graph'] as WorkflowNodeLibraryGroup[]).map(group => {
|
||||
const count = familySection.runtimeCounts[group]
|
||||
if (count === 0) return null
|
||||
return (
|
||||
<span
|
||||
key={`${familySection.family}-${group}`}
|
||||
className={`rounded-full px-1.5 py-0.5 text-[10px] font-medium ${NODE_LIBRARY_GROUP_STYLES[group]}`}
|
||||
>
|
||||
{NODE_KIND_FILTER_LABELS[group]} {count}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<p className="mb-2 text-xs text-content-muted">{NODE_LIBRARY_GROUP_DESCRIPTIONS[group]}</p>
|
||||
<div className="space-y-2">
|
||||
{section.modules.map(moduleGroup => (
|
||||
|
||||
<div className="mt-3 space-y-3">
|
||||
{familySection.modules.map(moduleGroup => (
|
||||
<div
|
||||
key={`${group}:${moduleGroup.namespace}`}
|
||||
className="rounded-md border border-border-default bg-surface/70 px-2 py-2"
|
||||
key={`${familySection.family}:${moduleGroup.namespace}`}
|
||||
className="rounded-lg border border-border-default bg-surface/80 p-3"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 py-1 text-xs text-content-secondary">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
|
||||
<span className="rounded-full border border-border-default bg-surface px-1.5 py-0.5 font-medium">
|
||||
{moduleGroup.label}
|
||||
</span>
|
||||
<span className="truncate rounded-full border border-border-default bg-surface px-1.5 py-0.5 font-mono text-[10px]">
|
||||
{moduleGroup.namespace}
|
||||
</span>
|
||||
{moduleGroup.familyCounts.cad_file > 0 && (
|
||||
<span className={`rounded-full px-1.5 py-0.5 text-[10px] font-medium ${FAMILY_FILTER_STYLES.cad_file}`}>
|
||||
{FAMILY_FILTER_LABELS.cad_file}
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
|
||||
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5 text-xs font-medium text-content">
|
||||
{moduleGroup.label}
|
||||
</span>
|
||||
)}
|
||||
{moduleGroup.familyCounts.order_line > 0 && (
|
||||
<span className={`rounded-full px-1.5 py-0.5 text-[10px] font-medium ${FAMILY_FILTER_STYLES.order_line}`}>
|
||||
{FAMILY_FILTER_LABELS.order_line}
|
||||
<span className="truncate rounded-full border border-border-default bg-surface px-2 py-0.5 font-mono text-[10px] text-content-muted">
|
||||
{moduleGroup.namespace}
|
||||
</span>
|
||||
)}
|
||||
{moduleGroup.stages.map(stage => (
|
||||
<span
|
||||
key={`${moduleGroup.namespace}-${stage}`}
|
||||
className={`rounded-full px-1.5 py-0.5 text-[10px] font-medium ${AUTHORING_STAGE_STYLES[stage]}`}
|
||||
title={AUTHORING_STAGE_DESCRIPTIONS[stage]}
|
||||
>
|
||||
{AUTHORING_STAGE_LABELS[stage]}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{(['legacy', 'bridge', 'graph'] as WorkflowNodeLibraryGroup[]).map(group => {
|
||||
const count = moduleGroup.runtimeCounts[group]
|
||||
if (count === 0) return null
|
||||
return (
|
||||
<span
|
||||
key={`${moduleGroup.namespace}-${group}`}
|
||||
className={`rounded-full px-1.5 py-0.5 text-[10px] font-medium ${NODE_LIBRARY_GROUP_STYLES[group]}`}
|
||||
>
|
||||
{NODE_KIND_FILTER_LABELS[group]} {count}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-content-muted">{moduleGroup.definitions.length}</span>
|
||||
<span className="text-xs text-content-muted">{moduleGroup.definitions.length}</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-1 space-y-2">
|
||||
{moduleGroup.categories.map(categorySection => {
|
||||
const { category, definitions: categoryDefinitions } = categorySection
|
||||
<div className="mt-3 space-y-3">
|
||||
{moduleGroup.stageSections.map(stageSection => {
|
||||
const stageLabel = AUTHORING_STAGE_LABELS[stageSection.stage]
|
||||
const stageDescription = AUTHORING_STAGE_DESCRIPTIONS[stageSection.stage]
|
||||
const stageStyle = AUTHORING_STAGE_STYLES[stageSection.stage]
|
||||
|
||||
return (
|
||||
<div key={`${group}:${moduleGroup.namespace}:${category}`}>
|
||||
<div className="mb-1 flex items-center justify-between gap-2">
|
||||
<span className={`rounded-full px-1.5 py-0.5 text-[10px] font-medium ${CATEGORY_COLORS[category]}`}>
|
||||
{CATEGORY_LABELS[category]}
|
||||
<div
|
||||
key={`${familySection.family}:${moduleGroup.namespace}:${stageSection.stage}`}
|
||||
className="rounded-md border border-border-default bg-surface-hover/35 p-2"
|
||||
>
|
||||
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<span className={`rounded-full px-1.5 py-0.5 text-[10px] font-medium ${stageStyle}`}>
|
||||
{stageLabel}
|
||||
</span>
|
||||
<p className="text-xs text-content-muted">{stageDescription}</p>
|
||||
</div>
|
||||
<span className="text-[10px] text-content-muted">
|
||||
{stageSection.definitions.length}
|
||||
</span>
|
||||
<span className="text-[10px] text-content-muted">{categoryDefinitions.length}</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
{categoryDefinitions.map(definition => {
|
||||
const family = getDefinitionFamily(definition)
|
||||
const requiredInputs = readContractList(definition.input_contract, 'requires')
|
||||
const providedOutputs = readContractList(definition.output_contract, 'provides')
|
||||
const inputContext = readContractContext(definition.input_contract)
|
||||
const outputContext = readContractContext(definition.output_contract)
|
||||
const isActionable = Boolean(onSelectStep)
|
||||
|
||||
<div className="space-y-2">
|
||||
{stageSection.categories.map(categorySection => {
|
||||
const { category, definitions: categoryDefinitions } = categorySection
|
||||
return (
|
||||
<div
|
||||
key={definition.step}
|
||||
className={`rounded-lg border border-border-default bg-surface px-3 py-2 ${
|
||||
isActionable ? 'transition-colors hover:bg-surface-hover' : ''
|
||||
}`}
|
||||
title={definition.description}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
{renderIcon && (
|
||||
<span className="mt-0.5 text-content-secondary">
|
||||
{renderIcon(definition.icon, variant === 'menu' ? 14 : 13)}
|
||||
</span>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<p className="truncate text-sm font-medium text-content">{definition.label}</p>
|
||||
<span className={`rounded-full px-1.5 py-0.5 text-[10px] font-medium ${FAMILY_FILTER_STYLES[family]}`}>
|
||||
{FAMILY_FILTER_LABELS[family]}
|
||||
</span>
|
||||
{getDefinitionBadges(definition).map(badge => (
|
||||
<span
|
||||
key={`${definition.step}-${badge.label}`}
|
||||
className={`rounded-full px-1.5 py-0.5 text-[10px] font-medium ${badge.className}`}
|
||||
>
|
||||
{badge.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-0.5 truncate font-mono text-[11px] text-content-muted">{definition.step}</p>
|
||||
</div>
|
||||
<div key={`${moduleGroup.namespace}:${stageSection.stage}:${category}`}>
|
||||
<div className="mb-1 flex items-center justify-between gap-2">
|
||||
<span className={`rounded-full px-1.5 py-0.5 text-[10px] font-medium ${CATEGORY_COLORS[category]}`}>
|
||||
{CATEGORY_LABELS[category]}
|
||||
</span>
|
||||
<span className="text-[10px] text-content-muted">{categoryDefinitions.length}</span>
|
||||
</div>
|
||||
|
||||
{isActionable && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelectStep?.(definition.step)}
|
||||
className={`shrink-0 rounded-lg px-2 py-1 text-xs font-medium ${
|
||||
variant === 'panel'
|
||||
? 'border border-border-default text-content hover:bg-surface-hover'
|
||||
: 'bg-accent text-white hover:bg-accent-hover'
|
||||
}`}
|
||||
>
|
||||
{variant === 'panel' ? (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Plus size={12} />
|
||||
Insert
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
Use
|
||||
<ArrowRight size={12} />
|
||||
<div className="space-y-1">
|
||||
{categoryDefinitions.map(definition => {
|
||||
const family = getDefinitionFamily(definition)
|
||||
const requiredInputs = readContractList(definition.input_contract, 'requires')
|
||||
const providedOutputs = readContractList(definition.output_contract, 'provides')
|
||||
const inputContext = readContractContext(definition.input_contract)
|
||||
const outputContext = readContractContext(definition.output_contract)
|
||||
const isActionable = Boolean(onSelectStep)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={definition.step}
|
||||
className={`rounded-lg border border-border-default bg-surface px-3 py-2 ${
|
||||
isActionable ? 'transition-colors hover:bg-surface-hover' : ''
|
||||
}`}
|
||||
title={definition.description}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
{renderIcon && (
|
||||
<span className="mt-0.5 text-content-secondary">
|
||||
{renderIcon(definition.icon, variant === 'menu' ? 14 : 13)}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<p className="truncate text-sm font-medium text-content">
|
||||
{definition.label}
|
||||
</p>
|
||||
<span
|
||||
className={`rounded-full px-1.5 py-0.5 text-[10px] font-medium ${FAMILY_FILTER_STYLES[family]}`}
|
||||
>
|
||||
{FAMILY_FILTER_LABELS[family]}
|
||||
</span>
|
||||
{getDefinitionBadges(definition).map(badge => (
|
||||
<span
|
||||
key={`${definition.step}-${badge.label}`}
|
||||
className={`rounded-full px-1.5 py-0.5 text-[10px] font-medium ${badge.className}`}
|
||||
>
|
||||
{badge.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-0.5 truncate font-mono text-[11px] text-content-muted">
|
||||
{definition.step}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className="mt-1 line-clamp-2 text-xs text-content-muted">{definition.description}</p>
|
||||
{isActionable && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelectStep?.(definition.step)}
|
||||
aria-label={
|
||||
variant === 'panel'
|
||||
? `Insert ${definition.label}`
|
||||
: `Use ${definition.label}`
|
||||
}
|
||||
className={`shrink-0 rounded-lg px-2 py-1 text-xs font-medium ${
|
||||
variant === 'panel'
|
||||
? 'border border-border-default text-content hover:bg-surface-hover'
|
||||
: 'bg-accent text-white hover:bg-accent-hover'
|
||||
}`}
|
||||
>
|
||||
{variant === 'panel' ? (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Plus size={12} />
|
||||
Insert
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
Use
|
||||
<ArrowRight size={12} />
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex flex-wrap gap-1.5 text-[10px]">
|
||||
{inputContext && (
|
||||
<span className="rounded-full border border-border-default bg-surface-hover/80 px-1.5 py-0.5 text-content-secondary">
|
||||
In {formatContractLabel(inputContext)}
|
||||
</span>
|
||||
)}
|
||||
{outputContext && (
|
||||
<span className="rounded-full border border-border-default bg-surface-hover/80 px-1.5 py-0.5 text-content-secondary">
|
||||
Out {formatContractLabel(outputContext)}
|
||||
</span>
|
||||
)}
|
||||
{requiredInputs.slice(0, 2).map(input => (
|
||||
<span
|
||||
key={`${definition.step}-requires-${input}`}
|
||||
className="rounded-full border border-border-default bg-surface-hover/80 px-1.5 py-0.5 text-content-secondary"
|
||||
>
|
||||
Requires {formatContractLabel(input)}
|
||||
</span>
|
||||
))}
|
||||
{providedOutputs.slice(0, 2).map(output => (
|
||||
<span
|
||||
key={`${definition.step}-provides-${output}`}
|
||||
className="rounded-full border border-border-default bg-surface-hover/80 px-1.5 py-0.5 text-content-secondary"
|
||||
>
|
||||
Provides {formatContractLabel(output)}
|
||||
</span>
|
||||
))}
|
||||
{definition.artifact_roles_consumed.slice(0, 1).map(artifact => (
|
||||
<span
|
||||
key={`${definition.step}-consumes-${artifact}`}
|
||||
className="rounded-full border border-border-default bg-surface-hover/80 px-1.5 py-0.5 text-content-secondary"
|
||||
>
|
||||
Consumes {formatContractLabel(artifact)}
|
||||
</span>
|
||||
))}
|
||||
{definition.artifact_roles_produced.slice(0, 1).map(artifact => (
|
||||
<span
|
||||
key={`${definition.step}-produces-${artifact}`}
|
||||
className="rounded-full border border-border-default bg-surface-hover/80 px-1.5 py-0.5 text-content-secondary"
|
||||
>
|
||||
Produces {formatContractLabel(artifact)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-1 line-clamp-2 text-xs text-content-muted">
|
||||
{definition.description}
|
||||
</p>
|
||||
|
||||
<div className="mt-2 flex flex-wrap gap-1.5 text-[10px]">
|
||||
{inputContext && (
|
||||
<span className="rounded-full border border-border-default bg-surface-hover/80 px-1.5 py-0.5 text-content-secondary">
|
||||
In {formatContractLabel(inputContext)}
|
||||
</span>
|
||||
)}
|
||||
{outputContext && (
|
||||
<span className="rounded-full border border-border-default bg-surface-hover/80 px-1.5 py-0.5 text-content-secondary">
|
||||
Out {formatContractLabel(outputContext)}
|
||||
</span>
|
||||
)}
|
||||
{requiredInputs.slice(0, 2).map(input => (
|
||||
<span
|
||||
key={`${definition.step}-requires-${input}`}
|
||||
className="rounded-full border border-border-default bg-surface-hover/80 px-1.5 py-0.5 text-content-secondary"
|
||||
>
|
||||
Requires {formatContractLabel(input)}
|
||||
</span>
|
||||
))}
|
||||
{providedOutputs.slice(0, 2).map(output => (
|
||||
<span
|
||||
key={`${definition.step}-provides-${output}`}
|
||||
className="rounded-full border border-border-default bg-surface-hover/80 px-1.5 py-0.5 text-content-secondary"
|
||||
>
|
||||
Provides {formatContractLabel(output)}
|
||||
</span>
|
||||
))}
|
||||
{definition.artifact_roles_consumed.slice(0, 1).map(artifact => (
|
||||
<span
|
||||
key={`${definition.step}-consumes-${artifact}`}
|
||||
className="rounded-full border border-border-default bg-surface-hover/80 px-1.5 py-0.5 text-content-secondary"
|
||||
>
|
||||
Consumes {formatContractLabel(artifact)}
|
||||
</span>
|
||||
))}
|
||||
{definition.artifact_roles_produced.slice(0, 1).map(artifact => (
|
||||
<span
|
||||
key={`${definition.step}-produces-${artifact}`}
|
||||
className="rounded-full border border-border-default bg-surface-hover/80 px-1.5 py-0.5 text-content-secondary"
|
||||
>
|
||||
Produces {formatContractLabel(artifact)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { formatContractValue } from './workflowGraphDraft'
|
||||
|
||||
interface WorkflowNodeContractCardProps {
|
||||
moduleLabel: string
|
||||
moduleKey: string
|
||||
@@ -10,16 +12,14 @@ interface WorkflowNodeContractCardProps {
|
||||
inputContextLabel?: string | null
|
||||
outputContextLabel?: string | null
|
||||
requiredInputs: string[]
|
||||
requiredAnyInputs: string[][]
|
||||
consumedArtifacts: string[]
|
||||
providedOutputs: string[]
|
||||
producedArtifacts: string[]
|
||||
}
|
||||
|
||||
function formatContractRole(role: string): string {
|
||||
return role
|
||||
.split('_')
|
||||
.map(part => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(' ')
|
||||
return formatContractValue(role)
|
||||
}
|
||||
|
||||
function ContractRolePills({
|
||||
@@ -43,6 +43,27 @@ function ContractRolePills({
|
||||
)
|
||||
}
|
||||
|
||||
function ContractAlternativeGroups({
|
||||
groups,
|
||||
}: {
|
||||
groups: string[][]
|
||||
}) {
|
||||
if (groups.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
{groups.map(group => (
|
||||
<div
|
||||
key={group.join('|')}
|
||||
className="rounded-lg border border-dashed border-border-default bg-surface px-2 py-1 text-[11px] text-content-secondary"
|
||||
>
|
||||
Any of: {group.map(formatContractRole).join(' / ')}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function WorkflowNodeContractCard({
|
||||
moduleLabel,
|
||||
moduleKey,
|
||||
@@ -55,6 +76,7 @@ export function WorkflowNodeContractCard({
|
||||
inputContextLabel,
|
||||
outputContextLabel,
|
||||
requiredInputs,
|
||||
requiredAnyInputs,
|
||||
consumedArtifacts,
|
||||
providedOutputs,
|
||||
producedArtifacts,
|
||||
@@ -99,6 +121,12 @@ export function WorkflowNodeContractCard({
|
||||
) : (
|
||||
<p className="text-xs text-content-muted">No declared upstream requirements.</p>
|
||||
)}
|
||||
{requiredAnyInputs.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<p className="text-[11px] font-medium uppercase tracking-wide text-content-secondary">Alternative Inputs</p>
|
||||
<ContractAlternativeGroups groups={requiredAnyInputs} />
|
||||
</div>
|
||||
)}
|
||||
{consumedArtifacts.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<p className="text-[11px] font-medium uppercase tracking-wide text-content-secondary">Artifacts Consumed</p>
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { useMemo, type ChangeEvent } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
import { listRenderTemplates } from '../../api/renderTemplates'
|
||||
import type { WorkflowNodeDefinition, WorkflowNodeFieldDefinition, WorkflowParams } from '../../api/workflows'
|
||||
import {
|
||||
FAMILY_FILTER_LABELS,
|
||||
@@ -10,6 +13,11 @@ import {
|
||||
type WorkflowGraphFamily,
|
||||
} from './workflowNodeLibrary'
|
||||
import { WorkflowNodeContractCard } from './WorkflowNodeContractCard'
|
||||
import { formatContractValue } from './workflowGraphDraft'
|
||||
|
||||
const TEMPLATE_INPUT_PARAM_PREFIX = 'template_input__'
|
||||
const OUTPUT_SAVE_ALTERNATIVE_INPUTS = ['rendered_image', 'rendered_frames', 'rendered_video']
|
||||
const NOTIFY_ALTERNATIVE_INPUTS = ['rendered_image', 'rendered_frames', 'rendered_video', 'workflow_result', 'blend_asset']
|
||||
|
||||
function groupFieldsBySection(fields: WorkflowNodeFieldDefinition[]) {
|
||||
return fields.reduce<Record<string, WorkflowNodeFieldDefinition[]>>((sections, field) => {
|
||||
@@ -25,12 +33,104 @@ function getContractValues(contract: Record<string, unknown> | undefined, key: s
|
||||
return value.filter((entry): entry is string => typeof entry === 'string' && entry.trim().length > 0)
|
||||
}
|
||||
|
||||
function getContractAlternativeGroups(contract: Record<string, unknown> | undefined, key: string): string[][] {
|
||||
const value = contract?.[key]
|
||||
if (!Array.isArray(value)) return []
|
||||
|
||||
if (value.every(entry => typeof entry === 'string' && entry.trim().length > 0)) {
|
||||
return [value as string[]]
|
||||
}
|
||||
|
||||
return value
|
||||
.filter((entry): entry is string[] => Array.isArray(entry))
|
||||
.map(group => group.filter((entry): entry is string => typeof entry === 'string' && entry.trim().length > 0))
|
||||
.filter(group => group.length > 0)
|
||||
}
|
||||
|
||||
function getContractContextLabel(contract: Record<string, unknown> | undefined): string | null {
|
||||
const value = contract?.context
|
||||
if (value !== 'cad_file' && value !== 'order_line') return null
|
||||
return value === 'cad_file' ? 'CAD File' : 'Order Line'
|
||||
}
|
||||
|
||||
function getAlternativeInputGroupsForNode(step: string | undefined, contract: Record<string, unknown> | undefined): string[][] {
|
||||
const groups = getContractAlternativeGroups(contract, 'requires_any')
|
||||
|
||||
if (step === 'output_save' && groups.length === 0) {
|
||||
return [OUTPUT_SAVE_ALTERNATIVE_INPUTS]
|
||||
}
|
||||
|
||||
if (step === 'notify') {
|
||||
if (groups.length === 0) {
|
||||
return [NOTIFY_ALTERNATIVE_INPUTS]
|
||||
}
|
||||
return [Array.from(new Set([...groups[0], 'blend_asset'])), ...groups.slice(1)]
|
||||
}
|
||||
|
||||
return groups
|
||||
}
|
||||
|
||||
function parseSelectValue(field: WorkflowNodeFieldDefinition, rawValue: string): unknown {
|
||||
if (rawValue === '') return ''
|
||||
const matchedOption = field.options.find(option => String(option.value) === rawValue)
|
||||
return matchedOption ? matchedOption.value : rawValue
|
||||
}
|
||||
|
||||
function clearDynamicTemplateInputParams(params: WorkflowParams): WorkflowParams {
|
||||
return Object.fromEntries(
|
||||
Object.entries(params).filter(([key]) => !key.startsWith(TEMPLATE_INPUT_PARAM_PREFIX)),
|
||||
)
|
||||
}
|
||||
|
||||
function getVariableLabels(fields: WorkflowNodeFieldDefinition[]): string[] {
|
||||
return Array.from(
|
||||
new Set(
|
||||
fields
|
||||
.map(field => field.label?.trim())
|
||||
.filter((label): label is string => Boolean(label)),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function formatContractRole(value: string): string {
|
||||
return formatContractValue(value)
|
||||
}
|
||||
|
||||
function describeRequiredInputs(requiredInputs: string[], requiredAnyInputs: string[][]): string[] {
|
||||
return [
|
||||
...requiredInputs.map(role => formatContractRole(role)),
|
||||
...requiredAnyInputs.map(group => `Any of: ${group.map(formatContractRole).join(' / ')}`),
|
||||
]
|
||||
}
|
||||
|
||||
type InputSocketDescriptor = {
|
||||
id: string
|
||||
label: string
|
||||
tone: 'required' | 'alternative'
|
||||
}
|
||||
|
||||
function buildInputSocketDescriptors(
|
||||
requiredInputs: string[],
|
||||
requiredAnyInputs: string[][],
|
||||
): InputSocketDescriptor[] {
|
||||
return [
|
||||
...requiredInputs.map(role => ({
|
||||
id: `required:${role}`,
|
||||
label: formatContractRole(role),
|
||||
tone: 'required' as const,
|
||||
})),
|
||||
...requiredAnyInputs.map(group => ({
|
||||
id: `alternative:${group.join('|')}`,
|
||||
label: `Any of: ${group.map(formatContractRole).join(' / ')}`,
|
||||
tone: 'alternative' as const,
|
||||
})),
|
||||
]
|
||||
}
|
||||
|
||||
function formatCountNoun(count: number, singular: string, plural: string): string {
|
||||
return `${count} ${count === 1 ? singular : plural}`
|
||||
}
|
||||
|
||||
type WorkflowNodeInspectorProps = {
|
||||
params: WorkflowParams
|
||||
onChange: (params: WorkflowParams) => void
|
||||
@@ -59,8 +159,80 @@ export function WorkflowNodeInspector({
|
||||
[graphFamily, nodeDefinitions],
|
||||
)
|
||||
const nodeSelectionGroups = groupDefinitionsForStepSelect(selectableNodeDefinitions)
|
||||
const isResolveTemplateNode = step === 'resolve_template'
|
||||
const { data: renderTemplates = [] } = useQuery({
|
||||
queryKey: ['render-templates'],
|
||||
queryFn: listRenderTemplates,
|
||||
enabled: isResolveTemplateNode,
|
||||
staleTime: 30_000,
|
||||
})
|
||||
|
||||
const selectedTemplateId =
|
||||
typeof params.template_id_override === 'string' ? params.template_id_override : ''
|
||||
const selectedTemplate = useMemo(
|
||||
() => renderTemplates.find(template => template.id === selectedTemplateId) ?? null,
|
||||
[renderTemplates, selectedTemplateId],
|
||||
)
|
||||
|
||||
const effectiveFields = useMemo(() => {
|
||||
const baseFields = [...(nodeDefinition?.fields ?? [])]
|
||||
if (!isResolveTemplateNode) return baseFields
|
||||
|
||||
const templateOptions = renderTemplates
|
||||
.filter(template => template.is_active)
|
||||
.sort((left, right) => left.name.localeCompare(right.name))
|
||||
.map(template => ({
|
||||
value: template.id,
|
||||
label: template.output_type_names?.length
|
||||
? `${template.name} (${template.output_type_names.join(', ')})`
|
||||
: template.name,
|
||||
}))
|
||||
|
||||
const selectedTemplateMissing =
|
||||
selectedTemplateId.length > 0 && !templateOptions.some(option => option.value === selectedTemplateId)
|
||||
const resolvedBaseFields = baseFields.map(field => {
|
||||
if (field.key !== 'template_id_override') return field
|
||||
return {
|
||||
...field,
|
||||
label: 'Template Override',
|
||||
description:
|
||||
'Select a specific render template to expose its workflow inputs and bypass category/output-type auto resolution.',
|
||||
type: 'select' as const,
|
||||
allow_blank: true,
|
||||
options: selectedTemplateMissing
|
||||
? [{ value: selectedTemplateId, label: `${selectedTemplateId} (manual UUID)` }, ...templateOptions]
|
||||
: templateOptions,
|
||||
}
|
||||
})
|
||||
|
||||
const dynamicTemplateFields = (selectedTemplate?.workflow_input_schema ?? []).map(field => ({
|
||||
...field,
|
||||
key: `${TEMPLATE_INPUT_PARAM_PREFIX}${field.key}`,
|
||||
section: field.section || 'Template Inputs',
|
||||
}))
|
||||
|
||||
return [...resolvedBaseFields, ...dynamicTemplateFields]
|
||||
}, [isResolveTemplateNode, nodeDefinition?.fields, renderTemplates, selectedTemplate, selectedTemplateId])
|
||||
|
||||
const updateField = (field: WorkflowNodeFieldDefinition, value: unknown) => {
|
||||
if (field.key === 'template_id_override') {
|
||||
const nextParams = clearDynamicTemplateInputParams(params)
|
||||
if (value !== '') {
|
||||
nextParams[field.key] = value
|
||||
} else {
|
||||
delete nextParams[field.key]
|
||||
}
|
||||
onChange(nextParams)
|
||||
return
|
||||
}
|
||||
|
||||
if (value === '' && field.allow_blank !== false) {
|
||||
const nextParams = { ...params }
|
||||
delete nextParams[field.key]
|
||||
onChange(nextParams)
|
||||
return
|
||||
}
|
||||
|
||||
onChange({
|
||||
...params,
|
||||
[field.key]: value,
|
||||
@@ -78,13 +250,24 @@ export function WorkflowNodeInspector({
|
||||
updateField(field, Number(rawValue))
|
||||
}
|
||||
|
||||
const fieldsBySection = groupFieldsBySection(nodeDefinition?.fields ?? [])
|
||||
const fieldsBySection = groupFieldsBySection(effectiveFields)
|
||||
const inputContextLabel = getContractContextLabel(nodeDefinition?.input_contract as Record<string, unknown> | undefined)
|
||||
const outputContextLabel = getContractContextLabel(nodeDefinition?.output_contract as Record<string, unknown> | undefined)
|
||||
const requiredAnyInputs = getAlternativeInputGroupsForNode(
|
||||
step,
|
||||
nodeDefinition?.input_contract as Record<string, unknown> | undefined,
|
||||
)
|
||||
const alternativeRoleSet = new Set(requiredAnyInputs.flat())
|
||||
const requiredInputs = getContractValues(nodeDefinition?.input_contract as Record<string, unknown> | undefined, 'requires')
|
||||
.filter(role => !alternativeRoleSet.has(role))
|
||||
const providedOutputs = getContractValues(nodeDefinition?.output_contract as Record<string, unknown> | undefined, 'provides')
|
||||
const consumedArtifacts = nodeDefinition?.artifact_roles_consumed ?? []
|
||||
const producedArtifacts = nodeDefinition?.artifact_roles_produced ?? []
|
||||
const staticVariableLabels = getVariableLabels(nodeDefinition?.fields ?? [])
|
||||
const dynamicTemplateVariableLabels = getVariableLabels(selectedTemplate?.workflow_input_schema ?? [])
|
||||
const inputDescriptions = describeRequiredInputs(requiredInputs, requiredAnyInputs)
|
||||
const inputSocketDescriptors = buildInputSocketDescriptors(requiredInputs, requiredAnyInputs)
|
||||
const totalVariableCount = staticVariableLabels.length + dynamicTemplateVariableLabels.length
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
@@ -147,16 +330,139 @@ export function WorkflowNodeInspector({
|
||||
inputContextLabel={inputContextLabel}
|
||||
outputContextLabel={outputContextLabel}
|
||||
requiredInputs={requiredInputs}
|
||||
requiredAnyInputs={requiredAnyInputs}
|
||||
consumedArtifacts={consumedArtifacts}
|
||||
providedOutputs={providedOutputs}
|
||||
producedArtifacts={producedArtifacts}
|
||||
/>
|
||||
)}
|
||||
|
||||
{nodeDefinition && (
|
||||
<div className="space-y-3 rounded-xl border border-border-default bg-surface-hover/40 p-3">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-content-secondary">
|
||||
Authoring Model
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-content">
|
||||
Canvas connections define upstream artifacts, inspector fields define local node variables.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="rounded-lg border border-border-default bg-surface px-3 py-2">
|
||||
<p className="text-[11px] font-medium uppercase tracking-wide text-content-secondary">
|
||||
Wired Inputs
|
||||
</p>
|
||||
{inputDescriptions.length > 0 ? (
|
||||
<>
|
||||
<p className="mt-1 text-xs text-content-muted">
|
||||
{formatCountNoun(inputDescriptions.length, 'canvas socket', 'canvas sockets')} {inputDescriptions.length === 1 ? 'is' : 'are'} required. Each entry below maps to one input handle on the node.
|
||||
</p>
|
||||
<div className="mt-2 space-y-1.5">
|
||||
{inputSocketDescriptors.map((descriptor, index) => (
|
||||
<div
|
||||
key={descriptor.id}
|
||||
className="flex items-start gap-2 rounded-md border border-border-default bg-surface-hover/50 px-2 py-1"
|
||||
>
|
||||
<span
|
||||
className={`inline-flex rounded-full px-1.5 py-0.5 text-[10px] font-medium ${
|
||||
descriptor.tone === 'alternative'
|
||||
? 'bg-sky-100 text-sky-700 dark:bg-sky-900/40 dark:text-sky-300'
|
||||
: 'bg-slate-100 text-slate-700 dark:bg-slate-900/40 dark:text-slate-300'
|
||||
}`}
|
||||
>
|
||||
Socket {index + 1}
|
||||
</span>
|
||||
<span className="min-w-0 text-xs text-content">{descriptor.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="mt-1 text-xs text-content-muted">
|
||||
This entry node does not declare additional upstream sockets.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="rounded-lg border border-border-default bg-surface px-3 py-2">
|
||||
<p className="text-[11px] font-medium uppercase tracking-wide text-content-secondary">
|
||||
Node Variables
|
||||
</p>
|
||||
{totalVariableCount > 0 ? (
|
||||
<>
|
||||
<p className="mt-1 text-xs text-content-muted">
|
||||
{formatCountNoun(totalVariableCount, 'local variable', 'local variables')} {totalVariableCount === 1 ? 'is' : 'are'} edited in the inspector.
|
||||
</p>
|
||||
{staticVariableLabels.length > 0 && (
|
||||
<p className="mt-2 text-xs text-content">
|
||||
Static: {staticVariableLabels.join(', ')}
|
||||
</p>
|
||||
)}
|
||||
{dynamicTemplateVariableLabels.length > 0 && (
|
||||
<p className="mt-2 text-xs text-content">
|
||||
Template-driven: {dynamicTemplateVariableLabels.join(', ')}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="mt-1 text-xs text-content-muted">
|
||||
This node has 0 local variables by design. Its behavior is driven entirely by connections and runtime context.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isResolveTemplateNode && (
|
||||
<div className="rounded-lg border border-dashed border-border-default bg-surface px-3 py-2">
|
||||
<p className="text-[11px] font-medium uppercase tracking-wide text-content-secondary">
|
||||
Dynamic Template Variables
|
||||
</p>
|
||||
{dynamicTemplateVariableLabels.length > 0 ? (
|
||||
<>
|
||||
<p className="mt-1 text-xs text-content-muted">
|
||||
The selected template adds {dynamicTemplateVariableLabels.length} extra variable
|
||||
{dynamicTemplateVariableLabels.length === 1 ? '' : 's'} to this node.
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-content">
|
||||
{dynamicTemplateVariableLabels.join(', ')}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="mt-1 text-xs text-content-muted">
|
||||
Select a template override to expose template-defined variables for this node.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isResolveTemplateNode && !selectedTemplate && renderTemplates.length > 0 && (
|
||||
<div className="rounded-xl border border-dashed border-border-default bg-surface-hover/50 px-3 py-3">
|
||||
<p className="text-sm text-content">Template-specific inputs appear after selecting a template override.</p>
|
||||
<p className="mt-1 text-xs text-content-muted">
|
||||
Leave the field empty to keep legacy category/output-type resolution.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedTemplate && (selectedTemplate.workflow_input_schema?.length ?? 0) > 0 && (
|
||||
<div className="rounded-xl border border-border-default bg-surface-hover/50 px-3 py-3">
|
||||
<p className="text-sm font-medium text-content">{selectedTemplate.name}</p>
|
||||
<p className="mt-1 text-xs text-content-muted">
|
||||
{selectedTemplate.workflow_input_schema.length} template input
|
||||
{selectedTemplate.workflow_input_schema.length === 1 ? '' : 's'} exposed for this node.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{Object.keys(fieldsBySection).length === 0 && (
|
||||
<p className="text-sm text-content-muted">
|
||||
This node currently has no configurable settings in the editor.
|
||||
</p>
|
||||
<div className="rounded-xl border border-dashed border-border-default bg-surface-hover/50 px-3 py-3">
|
||||
<p className="text-sm text-content">This node has no editor settings.</p>
|
||||
<p className="mt-1 text-xs text-content-muted">
|
||||
Configure it by wiring its declared inputs on the canvas. Each required upstream input gets its own socket on the node itself.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{Object.entries(fieldsBySection).map(([section, fields]) => (
|
||||
@@ -167,6 +473,8 @@ export function WorkflowNodeInspector({
|
||||
{fields.map(field => {
|
||||
const rawValue = params[field.key]
|
||||
const value = rawValue ?? field.default
|
||||
const fieldId = `workflow-node-field-${field.key}`
|
||||
const fieldOptions = field.options ?? []
|
||||
const disableRenderOverrideField =
|
||||
(step === 'blender_still' || step === 'blender_turntable') &&
|
||||
!customRenderSettingsEnabled &&
|
||||
@@ -175,18 +483,24 @@ export function WorkflowNodeInspector({
|
||||
|
||||
return (
|
||||
<div key={field.key}>
|
||||
<label className="text-sm text-content-secondary mb-1 block">
|
||||
<label htmlFor={fieldId} 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)}
|
||||
id={fieldId}
|
||||
value={value == null ? '' : String(value)}
|
||||
onChange={event => updateField(field, parseSelectValue(field, event.target.value))}
|
||||
disabled={disableRenderOverrideField}
|
||||
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 => (
|
||||
{field.allow_blank !== false && (
|
||||
<option value="">
|
||||
{field.key === 'template_id_override' ? 'Automatic resolution' : 'None'}
|
||||
</option>
|
||||
)}
|
||||
{fieldOptions.map(option => (
|
||||
<option key={String(option.value)} value={String(option.value)}>
|
||||
{option.label}
|
||||
</option>
|
||||
@@ -195,6 +509,7 @@ export function WorkflowNodeInspector({
|
||||
)}
|
||||
{field.type === 'number' && (
|
||||
<input
|
||||
id={fieldId}
|
||||
type="number"
|
||||
min={field.min ?? undefined}
|
||||
max={field.max ?? undefined}
|
||||
@@ -206,8 +521,12 @@ export function WorkflowNodeInspector({
|
||||
/>
|
||||
)}
|
||||
{field.type === 'boolean' && (
|
||||
<label className="flex items-center gap-2 rounded-lg border border-border-default px-3 py-2 text-sm text-content">
|
||||
<label
|
||||
htmlFor={fieldId}
|
||||
className="flex items-center gap-2 rounded-lg border border-border-default px-3 py-2 text-sm text-content"
|
||||
>
|
||||
<input
|
||||
id={fieldId}
|
||||
type="checkbox"
|
||||
checked={Boolean(value)}
|
||||
onChange={event => updateField(field, event.target.checked)}
|
||||
@@ -219,8 +538,10 @@ export function WorkflowNodeInspector({
|
||||
)}
|
||||
{field.type === 'text' && (
|
||||
<input
|
||||
id={fieldId}
|
||||
type="text"
|
||||
value={value == null ? '' : String(value)}
|
||||
maxLength={field.max_length ?? undefined}
|
||||
onChange={event => updateField(field, event.target.value)}
|
||||
disabled={disableRenderOverrideField}
|
||||
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"
|
||||
|
||||
@@ -37,6 +37,23 @@ export function WorkflowPreflightPanel({
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2 text-[11px] text-content-muted">
|
||||
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5">
|
||||
Mode: {preflight.execution_mode}
|
||||
</span>
|
||||
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5">
|
||||
Global issues: {preflight.issues.length}
|
||||
</span>
|
||||
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5">
|
||||
Node checks: {preflight.nodes.length}
|
||||
</span>
|
||||
{preflight.unsupported_node_ids.length > 0 && (
|
||||
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5">
|
||||
Unsupported nodes: {preflight.unsupported_node_ids.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(preflight.resolved_order_line_id || preflight.resolved_cad_file_id) && (
|
||||
<div className="space-y-1 text-xs text-content-muted">
|
||||
{preflight.resolved_order_line_id && <p>Order Line: {preflight.resolved_order_line_id}</p>}
|
||||
@@ -44,6 +61,13 @@ export function WorkflowPreflightPanel({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{preflight.unsupported_node_ids.length > 0 && (
|
||||
<div className="space-y-1 rounded-md border border-border-default bg-surface px-2.5 py-2 text-xs text-content-muted">
|
||||
<p className="font-medium text-content">Unsupported Node IDs</p>
|
||||
<p className="break-words">{preflight.unsupported_node_ids.join(', ')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{preflight.issues.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-content-secondary">
|
||||
@@ -57,6 +81,11 @@ export function WorkflowPreflightPanel({
|
||||
{issue.severity}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap gap-2 text-[11px] text-content-muted">
|
||||
<span>Code: {issue.code}</span>
|
||||
{issue.step && <span>Step: {issue.step}</span>}
|
||||
{issue.node_id && <span>Node: {issue.node_id}</span>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -77,6 +106,14 @@ export function WorkflowPreflightPanel({
|
||||
{node.status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap gap-2 text-[11px] text-content-muted">
|
||||
<span className="rounded-full border border-border-default bg-surface-hover/70 px-2 py-0.5">
|
||||
Runtime: {node.execution_kind}
|
||||
</span>
|
||||
<span className="rounded-full border border-border-default bg-surface-hover/70 px-2 py-0.5">
|
||||
Supported: {node.supported ? 'yes' : 'no'}
|
||||
</span>
|
||||
</div>
|
||||
{node.issues.length > 0 && (
|
||||
<div className="mt-2 space-y-1">
|
||||
{node.issues.map(issue => (
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { Milestone, Wand2 } from 'lucide-react'
|
||||
|
||||
import type { WorkflowNodeDefinition } from '../../api/workflows'
|
||||
import type { WorkflowGraphFamily } from './workflowNodeLibrary'
|
||||
import { getWorkflowAuthoringPlan } from './workflowAuthoringGuidance'
|
||||
import type { WorkflowReferenceBundleId } from './workflowReferenceBundles'
|
||||
|
||||
type WorkflowReferenceBundlePanelProps = {
|
||||
definitions: WorkflowNodeDefinition[]
|
||||
graphFamily: WorkflowGraphFamily
|
||||
activeSteps: string[]
|
||||
onInsertReferencePath?: (bundleId: WorkflowReferenceBundleId) => void
|
||||
}
|
||||
|
||||
export function WorkflowReferenceBundlePanel({
|
||||
definitions,
|
||||
graphFamily,
|
||||
activeSteps,
|
||||
onInsertReferencePath,
|
||||
}: WorkflowReferenceBundlePanelProps) {
|
||||
const { referenceBundles } = getWorkflowAuthoringPlan(definitions, graphFamily, activeSteps)
|
||||
|
||||
if (referenceBundles.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="space-y-2 rounded-2xl border border-border-default bg-surface-hover/30 p-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Milestone size={14} className="text-accent" />
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-content-secondary">
|
||||
Reference Paths
|
||||
</p>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-content-muted">
|
||||
Insert complete canonical production routes when you want a full non-legacy baseline instead of assembling modules piecemeal.
|
||||
</p>
|
||||
</div>
|
||||
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5 text-[11px] text-content-muted">
|
||||
{referenceBundles.length} paths
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{referenceBundles.map(bundle => (
|
||||
<div
|
||||
key={bundle.id}
|
||||
className="rounded-2xl border border-border-default bg-surface px-3 py-3"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="text-sm font-semibold text-content">{bundle.label}</p>
|
||||
<span className="rounded-full bg-surface-hover px-2 py-0.5 text-[11px] font-medium text-content-secondary">
|
||||
{bundle.stage}
|
||||
</span>
|
||||
<span className="rounded-full bg-surface-hover px-2 py-0.5 text-[11px] text-content-muted">
|
||||
{bundle.presentCount}/{bundle.totalCount} present
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-content-muted">{bundle.description}</p>
|
||||
<p className="text-[11px] text-content-muted">
|
||||
{bundle.stepIds.join(' -> ')}
|
||||
</p>
|
||||
</div>
|
||||
{onInsertReferencePath ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onInsertReferencePath(bundle.id)}
|
||||
aria-label={`Insert ${bundle.label}`}
|
||||
className="inline-flex items-center gap-1 rounded-xl bg-accent px-3 py-1.5 text-xs font-semibold text-white transition-colors hover:bg-accent-hover"
|
||||
>
|
||||
<Wand2 size={12} />
|
||||
Insert
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -8,6 +8,27 @@ import {
|
||||
getRunStatusClassName,
|
||||
} from './workflowRunPresentation'
|
||||
|
||||
function formatDuration(durationS: number | null) {
|
||||
if (durationS == null) return null
|
||||
if (durationS < 1) return `${Math.round(durationS * 1000)} ms`
|
||||
return `${durationS.toFixed(durationS >= 10 ? 0 : 1)} s`
|
||||
}
|
||||
|
||||
function formatOutputPreview(output: Record<string, unknown> | null) {
|
||||
if (!output) return null
|
||||
return JSON.stringify(output, null, 2)
|
||||
}
|
||||
|
||||
function getRolloutGateClassName(verdict: WorkflowRunComparison['rollout_gate_verdict']) {
|
||||
if (verdict === 'pass') return 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300'
|
||||
if (verdict === 'warn') return 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300'
|
||||
return 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300'
|
||||
}
|
||||
|
||||
function formatRolloutStatus(status: WorkflowRunComparison['workflow_rollout_status']) {
|
||||
return status === 'ready_for_rollout' ? 'Ready For Rollout' : 'Hold Legacy Authoritative'
|
||||
}
|
||||
|
||||
interface WorkflowRunsPanelProps {
|
||||
runs: WorkflowRun[]
|
||||
selectedRunId: string | null
|
||||
@@ -82,6 +103,29 @@ export function WorkflowRunsPanel({
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 text-xs text-content-muted sm:grid-cols-2">
|
||||
<div className="rounded-md border border-border-default bg-surface px-2.5 py-2">
|
||||
<p className="font-medium text-content">Execution Mode</p>
|
||||
<p>{EXECUTION_MODE_LABELS[selectedRun.execution_mode]}</p>
|
||||
</div>
|
||||
<div className="rounded-md border border-border-default bg-surface px-2.5 py-2">
|
||||
<p className="font-medium text-content">Completed</p>
|
||||
<p>{formatDateTime(selectedRun.completed_at)}</p>
|
||||
</div>
|
||||
{selectedRun.order_line_id && (
|
||||
<div className="rounded-md border border-border-default bg-surface px-2.5 py-2">
|
||||
<p className="font-medium text-content">Order Line</p>
|
||||
<p className="break-all">{selectedRun.order_line_id}</p>
|
||||
</div>
|
||||
)}
|
||||
{selectedRun.celery_task_id && (
|
||||
<div className="rounded-md border border-border-default bg-surface px-2.5 py-2">
|
||||
<p className="font-medium text-content">Celery Task</p>
|
||||
<p className="break-all">{selectedRun.celery_task_id}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedRun.error_message && (
|
||||
<p className="text-xs text-red-600 dark:text-red-300">{selectedRun.error_message}</p>
|
||||
)}
|
||||
@@ -90,6 +134,11 @@ export function WorkflowRunsPanel({
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-content-secondary">
|
||||
Node Results
|
||||
</p>
|
||||
{selectedRun.node_results.length === 0 && (
|
||||
<p className="rounded-md border border-dashed border-border-default bg-surface px-2.5 py-2 text-xs text-content-muted">
|
||||
No node results recorded yet for this run.
|
||||
</p>
|
||||
)}
|
||||
{selectedRun.node_results.map(result => (
|
||||
<div key={result.id} className="rounded-md border border-border-default bg-surface px-2.5 py-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
@@ -98,9 +147,29 @@ export function WorkflowRunsPanel({
|
||||
{result.status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2 text-[11px] text-content-muted">
|
||||
{formatDuration(result.duration_s) && (
|
||||
<span className="rounded-full border border-border-default bg-surface-hover/70 px-2 py-0.5">
|
||||
Duration: {formatDuration(result.duration_s)}
|
||||
</span>
|
||||
)}
|
||||
<span className="rounded-full border border-border-default bg-surface-hover/70 px-2 py-0.5">
|
||||
Recorded {formatDateTime(result.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
{result.log && (
|
||||
<p className="mt-1 line-clamp-3 text-xs text-content-muted">{result.log}</p>
|
||||
)}
|
||||
{result.output && (
|
||||
<details className="mt-2 rounded-md border border-border-default bg-surface-hover/40 px-2 py-2">
|
||||
<summary className="cursor-pointer text-xs font-medium text-content">
|
||||
Node output
|
||||
</summary>
|
||||
<pre className="mt-2 overflow-x-auto whitespace-pre-wrap break-words text-[11px] text-content-muted">
|
||||
{formatOutputPreview(result.output)}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -117,6 +186,26 @@ export function WorkflowRunsPanel({
|
||||
<div className="space-y-1.5 rounded-md border border-border-default bg-surface px-2.5 py-2 text-xs text-content-muted">
|
||||
<p className="text-sm text-content">{comparison.summary}</p>
|
||||
<p>Status: {comparison.status}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className={`rounded-full px-2 py-0.5 font-medium ${getRolloutGateClassName(comparison.rollout_gate_verdict)}`}>
|
||||
Rollout Gate: {comparison.rollout_gate_verdict}
|
||||
</span>
|
||||
<span className={`rounded-full px-2 py-0.5 font-medium ${comparison.workflow_rollout_ready ? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300' : 'bg-slate-200 text-slate-700 dark:bg-slate-800 dark:text-slate-200'}`}>
|
||||
{formatRolloutStatus(comparison.workflow_rollout_status)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{comparison.exact_match != null && (
|
||||
<span className={`rounded-full px-2 py-0.5 font-medium ${comparison.exact_match ? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300' : 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300'}`}>
|
||||
Exact Match: {comparison.exact_match ? 'yes' : 'no'}
|
||||
</span>
|
||||
)}
|
||||
{comparison.dimensions_match != null && (
|
||||
<span className={`rounded-full px-2 py-0.5 font-medium ${comparison.dimensions_match ? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300' : 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300'}`}>
|
||||
Dimensions: {comparison.dimensions_match ? 'match' : 'mismatch'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p>
|
||||
Authoritative: {comparison.authoritative_output.image_width ?? '?'} x {comparison.authoritative_output.image_height ?? '?'}
|
||||
</p>
|
||||
@@ -126,6 +215,16 @@ export function WorkflowRunsPanel({
|
||||
{comparison.mean_pixel_delta != null && (
|
||||
<p>Mean Pixel Delta: {comparison.mean_pixel_delta.toFixed(6)}</p>
|
||||
)}
|
||||
{comparison.rollout_reasons.length > 0 && (
|
||||
<div className="rounded-md border border-border-default bg-surface-hover/40 px-2 py-2">
|
||||
<p className="font-medium text-content">Operator Decision</p>
|
||||
<ul className="mt-1 space-y-1">
|
||||
{comparison.rollout_reasons.map(reason => (
|
||||
<li key={reason}>{reason}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { CheckCircle2, CircleDashed, Plus } from 'lucide-react'
|
||||
|
||||
import type { WorkflowNodeDefinition } from '../../api/workflows'
|
||||
import type { WorkflowGraphFamily } from './workflowNodeLibrary'
|
||||
import { getWorkflowAuthoringPlan } from './workflowAuthoringGuidance'
|
||||
|
||||
type WorkflowStarterPathPanelProps = {
|
||||
definitions: WorkflowNodeDefinition[]
|
||||
graphFamily: WorkflowGraphFamily
|
||||
activeSteps: string[]
|
||||
onSelectStep?: (step: string) => void
|
||||
}
|
||||
|
||||
export function WorkflowStarterPathPanel({
|
||||
definitions,
|
||||
graphFamily,
|
||||
activeSteps,
|
||||
onSelectStep,
|
||||
}: WorkflowStarterPathPanelProps) {
|
||||
const plan = getWorkflowAuthoringPlan(definitions, graphFamily, activeSteps)
|
||||
|
||||
if (graphFamily === 'mixed' || plan.starterItems.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="space-y-3 rounded-2xl border border-border-default bg-surface-hover/30 p-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-content-secondary">
|
||||
Starter Path
|
||||
</p>
|
||||
<p className="mt-1 text-sm font-medium text-content">{plan.starterTitle}</p>
|
||||
<p className="mt-1 text-xs text-content-muted">{plan.starterDescription}</p>
|
||||
</div>
|
||||
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5 text-[11px] text-content-muted">
|
||||
{plan.starterCompletedCount}/{plan.starterItems.length} present
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{plan.starterItems.map(item => {
|
||||
const { definition, index, isPresent } = item
|
||||
return (
|
||||
<div
|
||||
key={definition.step}
|
||||
className="flex items-center justify-between gap-2 rounded-xl border border-border-default bg-surface px-3 py-2"
|
||||
>
|
||||
<div className="min-w-0 flex items-start gap-2">
|
||||
<span className="mt-0.5 text-content-secondary">
|
||||
{isPresent ? (
|
||||
<CheckCircle2 size={15} className="text-emerald-600" />
|
||||
) : (
|
||||
<CircleDashed size={15} className="text-amber-600" />
|
||||
)}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span className="rounded-full border border-border-default bg-surface-hover/70 px-1.5 py-0.5 text-[10px] font-medium text-content-secondary">
|
||||
{index}
|
||||
</span>
|
||||
<p className="truncate text-sm font-medium text-content">{definition.label}</p>
|
||||
<span className="rounded-full px-1.5 py-0.5 text-[10px] font-medium text-content-muted ring-1 ring-border-default">
|
||||
{definition.category}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-0.5 line-clamp-2 text-xs text-content-muted">{definition.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isPresent && onSelectStep && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelectStep(definition.step)}
|
||||
className="shrink-0 rounded-lg border border-border-default px-2 py-1 text-xs font-medium text-content hover:bg-surface-hover"
|
||||
>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Plus size={12} />
|
||||
Add
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isPresent && (
|
||||
<span className="shrink-0 rounded-full bg-emerald-100 px-2 py-0.5 text-[11px] font-medium text-emerald-700">
|
||||
Present
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -23,7 +23,7 @@ export function WorkflowUtilityRail<T extends string>({
|
||||
children,
|
||||
}: WorkflowUtilityRailProps<T>) {
|
||||
return (
|
||||
<div className="flex w-[22rem] flex-col border-l border-border-default bg-surface">
|
||||
<div className="flex w-full flex-col border-t border-border-default bg-surface xl:w-[22rem] xl:flex-shrink-0 xl:border-l xl:border-t-0">
|
||||
<div className="border-b border-border-default px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-xl border border-border-default bg-surface-hover text-content-secondary">
|
||||
@@ -67,7 +67,7 @@ export function WorkflowUtilityRail<T extends string>({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-4">{children}</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-4 xl:max-h-none">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,21 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent as ReactMouseEvent } from 'react'
|
||||
import { addEdge, useEdgesState, useNodesState, type Connection, type Edge, type Node, type ReactFlowInstance } from '@xyflow/react'
|
||||
import {
|
||||
addEdge,
|
||||
applyNodeChanges,
|
||||
useEdgesState,
|
||||
useNodesState,
|
||||
type Connection,
|
||||
type Edge,
|
||||
type Node,
|
||||
type NodeChange,
|
||||
type ReactFlowInstance,
|
||||
} from '@xyflow/react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import {
|
||||
dispatchWorkflowDraft,
|
||||
getWorkflowOrderLineContexts,
|
||||
getNodeDefinitions,
|
||||
getWorkflowRunComparison,
|
||||
getWorkflowRuns,
|
||||
@@ -13,17 +24,25 @@ import {
|
||||
type WorkflowDefinition,
|
||||
type WorkflowExecutionMode,
|
||||
type WorkflowNodeDefinition,
|
||||
type WorkflowOrderLineContextGroup as WorkflowOrderLineContextGroupApi,
|
||||
type WorkflowOrderLineContextOption as WorkflowOrderLineContextOptionApi,
|
||||
type WorkflowParams,
|
||||
type WorkflowPreflightResponse,
|
||||
} from '../../api/workflows'
|
||||
import {
|
||||
applyAutoLayout,
|
||||
buildWorkflowCanvasNodeData,
|
||||
buildCurrentWorkflowConfig,
|
||||
deriveWorkflowAuthoringFamily,
|
||||
findOpenNodePosition,
|
||||
graphNeedsAutoLayout,
|
||||
inferNodeLabel,
|
||||
inferNodeType,
|
||||
inferStepFromNodeType,
|
||||
normalizeWorkflowParams,
|
||||
resolveParamsForStepChange,
|
||||
resolveNodeCollisions,
|
||||
shouldAutoLayoutAfterInsert,
|
||||
type WorkflowCanvasNodeData,
|
||||
validateWorkflowDraft,
|
||||
workflowToGraph,
|
||||
@@ -35,6 +54,11 @@ import {
|
||||
GRAPH_FAMILY_LABELS,
|
||||
isDefinitionAllowedForGraphFamily,
|
||||
} from './workflowNodeLibrary'
|
||||
import { createWorkflowModuleBundleInsertion, type WorkflowModuleBundleId } from './workflowModuleBundles'
|
||||
import {
|
||||
createWorkflowReferenceBundleInsertion,
|
||||
type WorkflowReferenceBundleId,
|
||||
} from './workflowReferenceBundles'
|
||||
import type { WorkflowUtilityTab } from './WorkflowCanvasUtilitySidebar'
|
||||
|
||||
export type NodeMenuAnchor = {
|
||||
@@ -43,20 +67,30 @@ export type NodeMenuAnchor = {
|
||||
flowPosition: { x: number; y: number }
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
export type WorkflowOrderLineContextOption = {
|
||||
value: string
|
||||
label: string
|
||||
meta: string
|
||||
}
|
||||
|
||||
export type WorkflowOrderLineContextGroup = {
|
||||
orderId: string
|
||||
orderLabel: string
|
||||
options: WorkflowOrderLineContextOption[]
|
||||
}
|
||||
|
||||
function normalizeOrderLineContextGroups(
|
||||
groups: WorkflowOrderLineContextGroupApi[],
|
||||
): WorkflowOrderLineContextGroup[] {
|
||||
return groups.map(group => ({
|
||||
orderId: group.order_id,
|
||||
orderLabel: group.order_label,
|
||||
options: group.options.map((option: WorkflowOrderLineContextOptionApi) => ({
|
||||
value: option.value,
|
||||
label: option.label,
|
||||
meta: option.meta,
|
||||
})),
|
||||
}))
|
||||
}
|
||||
|
||||
type UseWorkflowCanvasControllerArgs = {
|
||||
@@ -73,14 +107,17 @@ export function useWorkflowCanvasController({ workflow, onSave }: UseWorkflowCan
|
||||
})
|
||||
const nodeDefinitions = nodeDefinitionsData?.definitions ?? []
|
||||
const nodeDefinitionsByStep = Object.fromEntries(nodeDefinitions.map(definition => [definition.step, definition]))
|
||||
const definitionsLoaded = nodeDefinitions.length > 0
|
||||
|
||||
const { nodes: initNodes, edges: initEdges } = workflowToGraph(workflow.config, nodeDefinitionsByStep)
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState(initNodes)
|
||||
const [nodes, setNodes] = useNodesState(initNodes)
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState(initEdges)
|
||||
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null)
|
||||
const [selectedEdgeIds, setSelectedEdgeIds] = useState<string[]>([])
|
||||
const [selectedRunId, setSelectedRunId] = useState<string | null>(null)
|
||||
const [dispatchContextId, setDispatchContextId] = useState('')
|
||||
const [preflightResult, setPreflightResult] = useState<WorkflowPreflightResponse | null>(null)
|
||||
const [lastSuccessfulPreflightFingerprint, setLastSuccessfulPreflightFingerprint] = useState<string | null>(null)
|
||||
const [executionMode, setExecutionMode] = useState<WorkflowExecutionMode>(workflow.config.ui?.execution_mode ?? 'legacy')
|
||||
const [nodeMenuAnchor, setNodeMenuAnchor] = useState<NodeMenuAnchor | null>(null)
|
||||
const [activeUtilityTab, setActiveUtilityTab] = useState<WorkflowUtilityTab>('library')
|
||||
@@ -88,18 +125,66 @@ export function useWorkflowCanvasController({ workflow, onSave }: UseWorkflowCan
|
||||
const [reactFlowInstance, setReactFlowInstance] = useState<ReactFlowInstance<Node, Edge> | null>(null)
|
||||
|
||||
const validation = validateWorkflowDraft(nodes, edges, nodeDefinitionsByStep, nodeDefinitions.length > 0)
|
||||
const selectedEdgeIds = useMemo(
|
||||
() => edges.filter(edge => Boolean((edge as Edge & { selected?: boolean }).selected)).map(edge => edge.id),
|
||||
[edges],
|
||||
const authoringFamily = useMemo(
|
||||
() => deriveWorkflowAuthoringFamily(workflow, nodes, nodeDefinitionsByStep, definitionsLoaded),
|
||||
[definitionsLoaded, nodeDefinitionsByStep, nodes, workflow],
|
||||
)
|
||||
const currentWorkflowConfig = useMemo(
|
||||
() => buildCurrentWorkflowConfig(workflow, nodes, edges, executionMode, authoringFamily),
|
||||
[authoringFamily, edges, executionMode, nodes, workflow],
|
||||
)
|
||||
const graphFamily = useMemo(
|
||||
() =>
|
||||
inferWorkflowFamily(
|
||||
buildCurrentWorkflowConfig(workflow, nodes, edges, executionMode),
|
||||
nodeDefinitionsByStep,
|
||||
),
|
||||
[edges, executionMode, nodeDefinitionsByStep, nodes, workflow],
|
||||
() => inferWorkflowFamily(currentWorkflowConfig, nodeDefinitionsByStep),
|
||||
[currentWorkflowConfig, nodeDefinitionsByStep],
|
||||
)
|
||||
const isOrderLineGraph = graphFamily === 'order_line'
|
||||
|
||||
const { data: workflowOrderLineContexts = [], isFetching: isOrderLineContextsLoading } = useQuery({
|
||||
queryKey: ['workflow-order-line-contexts'],
|
||||
queryFn: () => getWorkflowOrderLineContexts(50),
|
||||
enabled: isOrderLineGraph,
|
||||
staleTime: 30_000,
|
||||
})
|
||||
|
||||
const orderLineContextGroups = useMemo<WorkflowOrderLineContextGroup[]>(
|
||||
() => normalizeOrderLineContextGroups(workflowOrderLineContexts).filter(group => group.options.length > 0),
|
||||
[workflowOrderLineContexts],
|
||||
)
|
||||
const selectedOrderLineContext = useMemo(
|
||||
() =>
|
||||
orderLineContextGroups
|
||||
.flatMap(group => group.options)
|
||||
.find(option => option.value === dispatchContextId) ?? null,
|
||||
[dispatchContextId, orderLineContextGroups],
|
||||
)
|
||||
const dispatchContextLabel = useMemo(() => {
|
||||
if (isOrderLineGraph) return 'Order Line'
|
||||
if (graphFamily === 'cad_file') return 'CAD File'
|
||||
return 'Context'
|
||||
}, [graphFamily, isOrderLineGraph])
|
||||
const dispatchContextSummary = useMemo(() => {
|
||||
if (isOrderLineGraph) return selectedOrderLineContext?.label ?? null
|
||||
const trimmed = dispatchContextId.trim()
|
||||
if (graphFamily === 'cad_file' && trimmed.length > 0) return 'CAD File'
|
||||
return trimmed.length > 0 ? trimmed : null
|
||||
}, [dispatchContextId, graphFamily, isOrderLineGraph, selectedOrderLineContext])
|
||||
const dispatchContextMeta = useMemo(() => {
|
||||
if (isOrderLineGraph) return selectedOrderLineContext?.meta ?? null
|
||||
if (graphFamily !== 'cad_file') return null
|
||||
const trimmed = dispatchContextId.trim()
|
||||
if (!trimmed) return null
|
||||
if (preflightResult?.context_id === trimmed && preflightResult.resolved_cad_file_id) {
|
||||
return `${preflightResult.resolved_cad_file_id} · validated`
|
||||
}
|
||||
return trimmed
|
||||
}, [dispatchContextId, graphFamily, isOrderLineGraph, preflightResult, selectedOrderLineContext])
|
||||
const currentDispatchFingerprint = useMemo(
|
||||
() => JSON.stringify({ contextId: dispatchContextId.trim(), config: currentWorkflowConfig }),
|
||||
[currentWorkflowConfig, dispatchContextId],
|
||||
)
|
||||
const hasFreshSuccessfulPreflight =
|
||||
preflightResult?.graph_dispatch_allowed === true &&
|
||||
lastSuccessfulPreflightFingerprint === currentDispatchFingerprint
|
||||
|
||||
const { data: workflowRuns = [] } = useQuery({
|
||||
queryKey: ['workflow-runs', workflow.id],
|
||||
@@ -140,32 +225,47 @@ export function useWorkflowCanvasController({ workflow, onSave }: UseWorkflowCan
|
||||
context_id: contextId,
|
||||
config,
|
||||
}),
|
||||
onSuccess: result => {
|
||||
onSuccess: (result, variables) => {
|
||||
setPreflightResult(result)
|
||||
if (result.graph_dispatch_allowed) {
|
||||
setLastSuccessfulPreflightFingerprint(JSON.stringify({ contextId: variables.contextId, config: variables.config }))
|
||||
toast.success(result.summary)
|
||||
} else {
|
||||
setLastSuccessfulPreflightFingerprint(null)
|
||||
toast.error(result.summary)
|
||||
}
|
||||
},
|
||||
onError: (error: any) => {
|
||||
setPreflightResult(null)
|
||||
setLastSuccessfulPreflightFingerprint(null)
|
||||
toast.error(error?.response?.data?.detail || 'Failed to preflight workflow')
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const graph = workflowToGraph(workflow.config, nodeDefinitionsByStep)
|
||||
setNodes(graph.nodes)
|
||||
const nextNodes = graphNeedsAutoLayout(graph.nodes) ? applyAutoLayout(graph.nodes, graph.edges) : graph.nodes
|
||||
setNodes(nextNodes)
|
||||
setEdges(graph.edges)
|
||||
setSelectedNodeId(null)
|
||||
setSelectedEdgeIds([])
|
||||
setSelectedRunId(null)
|
||||
setNodeMenuAnchor(null)
|
||||
setPreflightResult(null)
|
||||
setLastSuccessfulPreflightFingerprint(null)
|
||||
setExecutionMode(workflow.config.ui?.execution_mode ?? 'legacy')
|
||||
setActiveUtilityTab('library')
|
||||
}, [nodeDefinitionsData, setEdges, setNodes, workflow.config])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOrderLineGraph) return
|
||||
if (dispatchContextId.trim()) return
|
||||
const firstOption = orderLineContextGroups[0]?.options[0]
|
||||
if (firstOption) {
|
||||
setDispatchContextId(firstOption.value)
|
||||
}
|
||||
}, [dispatchContextId, isOrderLineGraph, orderLineContextGroups])
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedRunId && workflowRuns.length > 0) {
|
||||
setSelectedRunId(workflowRuns[0].id)
|
||||
@@ -177,18 +277,110 @@ export function useWorkflowCanvasController({ workflow, onSave }: UseWorkflowCan
|
||||
}, [selectedRunId, workflowRuns])
|
||||
|
||||
const onConnect = useCallback(
|
||||
(connection: Connection) => setEdges(currentEdges => addEdge(connection, currentEdges)),
|
||||
[setEdges],
|
||||
(connection: Connection) => {
|
||||
if (!connection.source || !connection.target) return
|
||||
|
||||
const sourceNode = nodes.find(node => node.id === connection.source)
|
||||
const targetNode = nodes.find(node => node.id === connection.target)
|
||||
const sourceData = sourceNode?.data as WorkflowCanvasNodeData | undefined
|
||||
const targetData = targetNode?.data as WorkflowCanvasNodeData | undefined
|
||||
const sourcePorts = sourceData?.outputPorts ?? []
|
||||
const targetPorts = targetData?.inputPorts ?? []
|
||||
|
||||
if (sourcePorts.length === 0) {
|
||||
toast.error('Selected source node does not expose any downstream outputs.')
|
||||
return
|
||||
}
|
||||
|
||||
if (targetPorts.length === 0) {
|
||||
toast.error('Selected target node does not declare any upstream inputs.')
|
||||
return
|
||||
}
|
||||
|
||||
const requestedTargetPort = connection.targetHandle
|
||||
? targetPorts.find(port => port.id === connection.targetHandle)
|
||||
: undefined
|
||||
const requestedSourcePort = connection.sourceHandle
|
||||
? sourcePorts.find(port => port.id === connection.sourceHandle)
|
||||
: undefined
|
||||
|
||||
const matchingTargetPort =
|
||||
requestedTargetPort &&
|
||||
sourcePorts.some(sourcePort =>
|
||||
requestedTargetPort.roles.some(role => sourcePort.roles.includes(role)),
|
||||
)
|
||||
? requestedTargetPort
|
||||
: targetPorts.find(port =>
|
||||
sourcePorts.some(sourcePort => port.roles.some(role => sourcePort.roles.includes(role))),
|
||||
)
|
||||
|
||||
if (!matchingTargetPort) {
|
||||
toast.error('These nodes do not share a compatible input/output contract.')
|
||||
return
|
||||
}
|
||||
|
||||
const matchingSourcePort =
|
||||
requestedSourcePort &&
|
||||
matchingTargetPort.roles.some(role => requestedSourcePort.roles.includes(role))
|
||||
? requestedSourcePort
|
||||
: sourcePorts.find(port => matchingTargetPort.roles.some(role => port.roles.includes(role)))
|
||||
|
||||
if (!matchingSourcePort) {
|
||||
toast.error('The selected source handle does not satisfy the target input contract.')
|
||||
return
|
||||
}
|
||||
|
||||
setEdges(currentEdges => {
|
||||
const duplicateEdge = currentEdges.some(
|
||||
edge => edge.source === connection.source && edge.target === connection.target,
|
||||
)
|
||||
if (duplicateEdge) {
|
||||
toast.error('A connection between these nodes already exists.')
|
||||
return currentEdges
|
||||
}
|
||||
|
||||
return addEdge(
|
||||
{
|
||||
...connection,
|
||||
sourceHandle: matchingSourcePort.id,
|
||||
targetHandle: matchingTargetPort.id,
|
||||
},
|
||||
currentEdges,
|
||||
)
|
||||
})
|
||||
},
|
||||
[nodes, setEdges],
|
||||
)
|
||||
|
||||
const onNodesChange = useCallback(
|
||||
(changes: NodeChange[]) => {
|
||||
setNodes(currentNodes => {
|
||||
const nextNodes = applyNodeChanges(changes, currentNodes)
|
||||
const settledNodeIds = changes
|
||||
.filter((change): change is Extract<NodeChange, { type: 'position' }> => change.type === 'position')
|
||||
.filter(change => change.dragging !== true)
|
||||
.map(change => change.id)
|
||||
|
||||
if (settledNodeIds.length === 0) {
|
||||
return nextNodes
|
||||
}
|
||||
|
||||
return resolveNodeCollisions(nextNodes, settledNodeIds)
|
||||
})
|
||||
},
|
||||
[setNodes],
|
||||
)
|
||||
|
||||
const onNodeClick = useCallback((_: ReactMouseEvent, node: Node) => {
|
||||
setNodeMenuAnchor(null)
|
||||
setSelectedEdgeIds([])
|
||||
setSelectedNodeId(node.id)
|
||||
setActiveUtilityTab('inspector')
|
||||
}, [])
|
||||
|
||||
const onEdgeClick = useCallback((_: ReactMouseEvent, edge: Edge) => {
|
||||
setNodeMenuAnchor(null)
|
||||
setSelectedEdgeIds([edge.id])
|
||||
setSelectedNodeId(null)
|
||||
setEdges(currentEdges =>
|
||||
currentEdges.map(currentEdge => ({
|
||||
@@ -200,6 +392,7 @@ export function useWorkflowCanvasController({ workflow, onSave }: UseWorkflowCan
|
||||
|
||||
const onPaneClick = useCallback(() => {
|
||||
setNodeMenuAnchor(null)
|
||||
setSelectedEdgeIds([])
|
||||
setSelectedNodeId(null)
|
||||
setEdges(currentEdges =>
|
||||
currentEdges.map(edge => ({
|
||||
@@ -226,8 +419,8 @@ export function useWorkflowCanvasController({ workflow, onSave }: UseWorkflowCan
|
||||
const handlePipelineStepChange = useCallback(
|
||||
(stepName: string) => {
|
||||
const definition = nodeDefinitionsByStep[stepName]
|
||||
if (definition && !isDefinitionAllowedForGraphFamily(definition, graphFamily)) {
|
||||
toast.error(`${definition.label} does not belong to the ${GRAPH_FAMILY_LABELS[graphFamily]} family.`)
|
||||
if (definition && !isDefinitionAllowedForGraphFamily(definition, authoringFamily)) {
|
||||
toast.error(`${definition.label} does not belong to the ${GRAPH_FAMILY_LABELS[authoringFamily]} authoring family.`)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -235,12 +428,12 @@ export function useWorkflowCanvasController({ workflow, onSave }: UseWorkflowCan
|
||||
currentNodes.map(node => {
|
||||
if (node.id !== selectedNodeId) return node
|
||||
|
||||
const currentData = (node.data as WorkflowCanvasNodeData | undefined) ?? buildNodeData(stepName)
|
||||
const currentData = (node.data as WorkflowCanvasNodeData | undefined) ?? buildWorkflowCanvasNodeData(stepName)
|
||||
return {
|
||||
...node,
|
||||
type: definition?.node_type ?? inferNodeType(stepName),
|
||||
data: {
|
||||
...buildNodeData(
|
||||
...buildWorkflowCanvasNodeData(
|
||||
stepName || inferStepFromNodeType(node.type),
|
||||
resolveParamsForStepChange(definition, currentData.params),
|
||||
definition,
|
||||
@@ -251,7 +444,7 @@ export function useWorkflowCanvasController({ workflow, onSave }: UseWorkflowCan
|
||||
}),
|
||||
)
|
||||
},
|
||||
[graphFamily, nodeDefinitionsByStep, selectedNodeId, setNodes],
|
||||
[authoringFamily, nodeDefinitionsByStep, selectedNodeId, setNodes],
|
||||
)
|
||||
|
||||
const openNodeMenu = useCallback(
|
||||
@@ -287,27 +480,91 @@ export function useWorkflowCanvasController({ workflow, onSave }: UseWorkflowCan
|
||||
const insertNode = useCallback(
|
||||
(step: string, preferredPosition?: { x: number; y: number }) => {
|
||||
const definition = nodeDefinitionsByStep[step]
|
||||
if (definition && !isDefinitionAllowedForGraphFamily(definition, graphFamily)) {
|
||||
toast.error(`${definition.label} cannot be added to a ${GRAPH_FAMILY_LABELS[graphFamily]} workflow.`)
|
||||
if (definition && !isDefinitionAllowedForGraphFamily(definition, authoringFamily)) {
|
||||
toast.error(`${definition.label} cannot be added to a ${GRAPH_FAMILY_LABELS[authoringFamily]} workflow.`)
|
||||
return
|
||||
}
|
||||
|
||||
const type = definition?.node_type ?? inferNodeType(step)
|
||||
const fallbackX = nodes.length > 0 ? Math.max(...nodes.map(node => node.position.x)) + 220 : 120
|
||||
const fallbackY = nodes.length > 0 ? Math.max(...nodes.map(node => node.position.y)) + 40 : 120
|
||||
const fallbackX = nodes.length > 0 ? Math.max(...nodes.map(node => node.position.x)) + 312 : 120
|
||||
const fallbackY = nodes.length > 0 ? Math.max(...nodes.map(node => node.position.y)) + 140 : 120
|
||||
const position = findOpenNodePosition(nodes, preferredPosition ?? { x: fallbackX, y: fallbackY })
|
||||
const newNode: Node = {
|
||||
id: `${step}_${Date.now()}`,
|
||||
type,
|
||||
position: preferredPosition ?? { x: fallbackX, y: fallbackY },
|
||||
data: buildNodeData(step, definition?.defaults ?? {}, definition),
|
||||
position,
|
||||
data: buildWorkflowCanvasNodeData(step, definition?.defaults ?? {}, definition),
|
||||
}
|
||||
const nextNodes = [...nodes, newNode]
|
||||
const shouldAutoLayout = shouldAutoLayoutAfterInsert(nextNodes, newNode, preferredPosition ?? null)
|
||||
const laidOutNodes = shouldAutoLayout ? applyAutoLayout(nextNodes, edges) : nextNodes
|
||||
|
||||
setNodes(currentNodes => [...currentNodes, newNode])
|
||||
setNodes(laidOutNodes)
|
||||
setSelectedNodeId(newNode.id)
|
||||
setNodeMenuAnchor(null)
|
||||
setActiveUtilityTab('inspector')
|
||||
|
||||
if (shouldAutoLayout) {
|
||||
window.requestAnimationFrame(() => {
|
||||
reactFlowInstance?.fitView({ padding: 0.2, duration: 220 })
|
||||
})
|
||||
}
|
||||
},
|
||||
[graphFamily, nodeDefinitionsByStep, nodes, setNodes],
|
||||
[authoringFamily, edges, nodeDefinitionsByStep, nodes, reactFlowInstance, setNodes],
|
||||
)
|
||||
|
||||
const insertModuleBundle = useCallback(
|
||||
(bundleId: WorkflowModuleBundleId, preferredPosition?: { x: number; y: number }) => {
|
||||
const insertion = createWorkflowModuleBundleInsertion({
|
||||
bundleId,
|
||||
graphFamily: authoringFamily,
|
||||
nodeDefinitionsByStep,
|
||||
existingNodes: nodes,
|
||||
preferredPosition,
|
||||
})
|
||||
|
||||
if (!insertion.ok) {
|
||||
toast.error(insertion.reason)
|
||||
return
|
||||
}
|
||||
|
||||
const combinedNodes = [...nodes, ...insertion.nodes]
|
||||
const combinedEdges = [...edges, ...insertion.edges]
|
||||
setNodes(graphNeedsAutoLayout(combinedNodes) ? applyAutoLayout(combinedNodes, combinedEdges) : combinedNodes)
|
||||
setEdges(combinedEdges)
|
||||
setSelectedNodeId(insertion.nodes[0]?.id ?? null)
|
||||
setNodeMenuAnchor(null)
|
||||
setActiveUtilityTab('inspector')
|
||||
toast.success(`${insertion.bundle.label} inserted`)
|
||||
},
|
||||
[authoringFamily, edges, nodeDefinitionsByStep, nodes, setEdges, setNodes],
|
||||
)
|
||||
|
||||
const insertReferenceBundle = useCallback(
|
||||
(bundleId: WorkflowReferenceBundleId, preferredPosition?: { x: number; y: number }) => {
|
||||
const insertion = createWorkflowReferenceBundleInsertion({
|
||||
bundleId,
|
||||
graphFamily: authoringFamily,
|
||||
nodeDefinitionsByStep,
|
||||
existingNodes: nodes,
|
||||
preferredPosition,
|
||||
})
|
||||
|
||||
if (!insertion.ok) {
|
||||
toast.error(insertion.reason)
|
||||
return
|
||||
}
|
||||
|
||||
const combinedNodes = [...nodes, ...insertion.nodes]
|
||||
const combinedEdges = [...edges, ...insertion.edges]
|
||||
setNodes(graphNeedsAutoLayout(combinedNodes) ? applyAutoLayout(combinedNodes, combinedEdges) : combinedNodes)
|
||||
setEdges(combinedEdges)
|
||||
setSelectedNodeId(insertion.nodes[0]?.id ?? null)
|
||||
setNodeMenuAnchor(null)
|
||||
setActiveUtilityTab('inspector')
|
||||
toast.success(`${insertion.bundle.label} inserted`)
|
||||
},
|
||||
[authoringFamily, edges, nodeDefinitionsByStep, nodes, setEdges, setNodes],
|
||||
)
|
||||
|
||||
const handleOpenToolbarNodeMenu = useCallback(() => {
|
||||
@@ -327,6 +584,7 @@ export function useWorkflowCanvasController({ workflow, onSave }: UseWorkflowCan
|
||||
const deleteEdgesById = useCallback((edgeIds: string[]) => {
|
||||
if (edgeIds.length === 0) return
|
||||
setEdges(currentEdges => currentEdges.filter(edge => !edgeIds.includes(edge.id)))
|
||||
setSelectedEdgeIds(currentIds => currentIds.filter(edgeId => !edgeIds.includes(edgeId)))
|
||||
setSelectedNodeId(null)
|
||||
setNodeMenuAnchor(null)
|
||||
toast.success(edgeIds.length === 1 ? 'Connection deleted' : `${edgeIds.length} connections deleted`)
|
||||
@@ -348,6 +606,27 @@ export function useWorkflowCanvasController({ workflow, onSave }: UseWorkflowCan
|
||||
deleteEdgesById([edge.id])
|
||||
}, [deleteEdgesById])
|
||||
|
||||
const handleSelectionChange = useCallback(
|
||||
({ nodes: selectedNodes, edges: selectedEdges }: { nodes: Node[]; edges: Edge[] }) => {
|
||||
if (selectedNodes.length > 0) {
|
||||
setSelectedNodeId(selectedNodes[0].id)
|
||||
setSelectedEdgeIds(selectedEdges.map(edge => edge.id))
|
||||
setActiveUtilityTab('inspector')
|
||||
return
|
||||
}
|
||||
|
||||
if (selectedEdges.length > 0) {
|
||||
setSelectedNodeId(null)
|
||||
setSelectedEdgeIds(selectedEdges.map(edge => edge.id))
|
||||
return
|
||||
}
|
||||
|
||||
setSelectedNodeId(null)
|
||||
setSelectedEdgeIds([])
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
const target = event.target as HTMLElement | null
|
||||
@@ -383,8 +662,8 @@ export function useWorkflowCanvasController({ workflow, onSave }: UseWorkflowCan
|
||||
toast.error('Resolve workflow validation errors before saving.')
|
||||
return
|
||||
}
|
||||
onSave(buildCurrentWorkflowConfig(workflow, nodes, edges, executionMode))
|
||||
}, [edges, executionMode, nodes, onSave, validation.errors.length, workflow])
|
||||
onSave(currentWorkflowConfig)
|
||||
}, [currentWorkflowConfig, onSave, validation.errors.length])
|
||||
|
||||
const handleDispatch = useCallback(() => {
|
||||
if (!dispatchContextId.trim()) {
|
||||
@@ -395,11 +674,15 @@ export function useWorkflowCanvasController({ workflow, onSave }: UseWorkflowCan
|
||||
toast.error('Resolve workflow validation errors before dispatching.')
|
||||
return
|
||||
}
|
||||
if (!hasFreshSuccessfulPreflight) {
|
||||
toast.error('Run a fresh Dry Run for the current graph and context before dispatching.')
|
||||
return
|
||||
}
|
||||
dispatchMutation.mutate({
|
||||
contextId: dispatchContextId.trim(),
|
||||
config: buildCurrentWorkflowConfig(workflow, nodes, edges, executionMode),
|
||||
config: currentWorkflowConfig,
|
||||
})
|
||||
}, [dispatchContextId, dispatchMutation, edges, executionMode, nodes, validation.errors.length, workflow])
|
||||
}, [currentWorkflowConfig, dispatchContextId, dispatchMutation, hasFreshSuccessfulPreflight, validation.errors.length])
|
||||
|
||||
const handlePreflight = useCallback(() => {
|
||||
if (!dispatchContextId.trim()) {
|
||||
@@ -412,14 +695,21 @@ export function useWorkflowCanvasController({ workflow, onSave }: UseWorkflowCan
|
||||
}
|
||||
preflightMutation.mutate({
|
||||
contextId: dispatchContextId.trim(),
|
||||
config: buildCurrentWorkflowConfig(workflow, nodes, edges, executionMode),
|
||||
config: currentWorkflowConfig,
|
||||
})
|
||||
}, [dispatchContextId, edges, executionMode, nodes, preflightMutation, validation.errors.length, workflow])
|
||||
}, [currentWorkflowConfig, dispatchContextId, preflightMutation, validation.errors.length])
|
||||
|
||||
const selectedNode = useMemo(
|
||||
() => nodes.find(node => node.id === selectedNodeId),
|
||||
[nodes, selectedNodeId],
|
||||
)
|
||||
const preflightState = useMemo<'ready' | 'required' | 'stale' | 'blocked'>(() => {
|
||||
if (!dispatchContextId.trim()) return 'required'
|
||||
if (preflightResult && !preflightResult.graph_dispatch_allowed) return 'blocked'
|
||||
if (hasFreshSuccessfulPreflight) return 'ready'
|
||||
if (preflightResult) return 'stale'
|
||||
return 'required'
|
||||
}, [dispatchContextId, hasFreshSuccessfulPreflight, preflightResult])
|
||||
|
||||
return {
|
||||
reactFlowWrapper,
|
||||
@@ -439,7 +729,15 @@ export function useWorkflowCanvasController({ workflow, onSave }: UseWorkflowCan
|
||||
preflightMutation,
|
||||
dispatchContextId,
|
||||
setDispatchContextId,
|
||||
isOrderLineGraph,
|
||||
isOrderLineContextsLoading,
|
||||
orderLineContextGroups,
|
||||
dispatchContextLabel,
|
||||
dispatchContextSummary,
|
||||
dispatchContextMeta,
|
||||
preflightResult,
|
||||
preflightState,
|
||||
hasFreshSuccessfulPreflight,
|
||||
executionMode,
|
||||
setExecutionMode,
|
||||
nodeMenuAnchor,
|
||||
@@ -447,6 +745,7 @@ export function useWorkflowCanvasController({ workflow, onSave }: UseWorkflowCan
|
||||
activeUtilityTab,
|
||||
setActiveUtilityTab,
|
||||
validation,
|
||||
authoringFamily,
|
||||
graphFamily,
|
||||
onConnect,
|
||||
onNodeClick,
|
||||
@@ -457,11 +756,14 @@ export function useWorkflowCanvasController({ workflow, onSave }: UseWorkflowCan
|
||||
handlePaneContextMenu,
|
||||
handleNodeContextMenu,
|
||||
insertNode,
|
||||
insertModuleBundle,
|
||||
insertReferenceBundle,
|
||||
handleOpenToolbarNodeMenu,
|
||||
handleAutoLayout,
|
||||
handleDeleteSelectedEdges,
|
||||
onEdgeContextMenu,
|
||||
onEdgeDoubleClick,
|
||||
handleSelectionChange,
|
||||
handleSave,
|
||||
handleDispatch,
|
||||
handlePreflight,
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { Library, Sparkles, type LucideIcon } from 'lucide-react'
|
||||
|
||||
import type { WorkflowModuleBundleId } from './workflowModuleBundles'
|
||||
import type { WorkflowReferenceBundleId } from './workflowReferenceBundles'
|
||||
import type { WorkflowAuthoringSurfaceModel } from './workflowAuthoringSurface'
|
||||
|
||||
export type WorkflowAuthoringPosition = {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
export type WorkflowAuthoringActions = {
|
||||
openNodeMenu?: () => void
|
||||
insertNode?: (step: string, preferredPosition?: WorkflowAuthoringPosition) => void
|
||||
insertModule?: (bundleId: WorkflowModuleBundleId, preferredPosition?: WorkflowAuthoringPosition) => void
|
||||
insertReferencePath?: (bundleId: WorkflowReferenceBundleId, preferredPosition?: WorkflowAuthoringPosition) => void
|
||||
}
|
||||
|
||||
export type WorkflowAuthoringInsertHandlers = {
|
||||
onSelectStep?: (step: string) => void
|
||||
onInsertModule?: (bundleId: WorkflowModuleBundleId) => void
|
||||
onInsertReferencePath?: (bundleId: WorkflowReferenceBundleId) => void
|
||||
}
|
||||
|
||||
type BindWorkflowAuthoringInsertActionsOptions = {
|
||||
preferredPosition?: WorkflowAuthoringPosition
|
||||
onAfterInsert?: () => void
|
||||
}
|
||||
|
||||
export type WorkflowAuthoringEntryAction = {
|
||||
label: string
|
||||
title: string
|
||||
helper: string
|
||||
icon: LucideIcon
|
||||
}
|
||||
|
||||
function wrapInsertAction<TArg>(
|
||||
action: ((arg: TArg, preferredPosition?: WorkflowAuthoringPosition) => void) | undefined,
|
||||
preferredPosition?: WorkflowAuthoringPosition,
|
||||
onAfterInsert?: () => void,
|
||||
) {
|
||||
if (!action) return undefined
|
||||
|
||||
return (arg: TArg) => {
|
||||
action(arg, preferredPosition)
|
||||
onAfterInsert?.()
|
||||
}
|
||||
}
|
||||
|
||||
export function bindWorkflowAuthoringInsertActions(
|
||||
actions: WorkflowAuthoringActions | undefined,
|
||||
{ preferredPosition, onAfterInsert }: BindWorkflowAuthoringInsertActionsOptions = {},
|
||||
): WorkflowAuthoringInsertHandlers {
|
||||
return {
|
||||
onSelectStep: wrapInsertAction(actions?.insertNode, preferredPosition, onAfterInsert),
|
||||
onInsertModule: wrapInsertAction(actions?.insertModule, preferredPosition, onAfterInsert),
|
||||
onInsertReferencePath: wrapInsertAction(actions?.insertReferencePath, preferredPosition, onAfterInsert),
|
||||
}
|
||||
}
|
||||
|
||||
export function getWorkflowAuthoringEntryAction(
|
||||
surfaceModel: WorkflowAuthoringSurfaceModel,
|
||||
): WorkflowAuthoringEntryAction {
|
||||
if (surfaceModel.defaultSection === 'overview') {
|
||||
return {
|
||||
label: 'Author',
|
||||
title: 'Open guided workflow authoring browser',
|
||||
helper: 'Open reference paths, production modules, starter steps, and raw nodes.',
|
||||
icon: Sparkles,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
label: 'Node',
|
||||
title: 'Open raw node browser',
|
||||
helper: 'Open the searchable node catalog directly on the canvas.',
|
||||
icon: Library,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
import type { WorkflowNodeDefinition } from '../../api/workflows'
|
||||
import type { WorkflowGraphFamily } from './workflowNodeLibrary'
|
||||
import { getWorkflowModuleBundles, type WorkflowModuleBundleDefinition, type WorkflowModuleBundleId } from './workflowModuleBundles'
|
||||
import {
|
||||
getWorkflowReferenceBundles,
|
||||
type WorkflowReferenceBundleDefinition,
|
||||
type WorkflowReferenceBundleId,
|
||||
} from './workflowReferenceBundles'
|
||||
|
||||
export const STARTER_NODE_STEP_ORDER: Record<WorkflowGraphFamily, string[]> = {
|
||||
cad_file: [
|
||||
'resolve_step_path',
|
||||
'occ_object_extract',
|
||||
'occ_glb_export',
|
||||
'stl_cache_generate',
|
||||
'thumbnail_save',
|
||||
],
|
||||
order_line: [
|
||||
'order_line_setup',
|
||||
'resolve_template',
|
||||
'auto_populate_materials',
|
||||
'glb_bbox',
|
||||
'material_map_resolve',
|
||||
'blender_still',
|
||||
'output_save',
|
||||
'notify',
|
||||
],
|
||||
mixed: [],
|
||||
}
|
||||
|
||||
export const STARTER_PATH_TITLES: Record<WorkflowGraphFamily, string> = {
|
||||
cad_file: 'CAD intake assembly',
|
||||
order_line: 'Still-render assembly',
|
||||
mixed: 'Starter path',
|
||||
}
|
||||
|
||||
export const STARTER_PATH_DESCRIPTIONS: Record<WorkflowGraphFamily, string> = {
|
||||
cad_file: 'Use this module sequence to take a CAD file through extraction, preview generation, and published output.',
|
||||
order_line: 'Use this module sequence to keep the non-legacy still graph parallel to the legacy render path.',
|
||||
mixed: 'Reference sequence for assembling a workflow graph.',
|
||||
}
|
||||
|
||||
export type WorkflowAuthoringPriority = {
|
||||
title: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export type WorkflowAuthoringFlowStep = {
|
||||
index: number
|
||||
title: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export type WorkflowAuthoringBundleStatus<TBundle> = TBundle & {
|
||||
presentCount: number
|
||||
totalCount: number
|
||||
}
|
||||
|
||||
export type WorkflowStarterPathItem = {
|
||||
index: number
|
||||
definition: WorkflowNodeDefinition
|
||||
isPresent: boolean
|
||||
}
|
||||
|
||||
export type WorkflowAuthoringStageProgress = {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
present: number
|
||||
total: number
|
||||
actionLabel: string
|
||||
actionKind: 'reference' | 'module' | 'step'
|
||||
bundleId?: WorkflowReferenceBundleId | WorkflowModuleBundleId
|
||||
step?: string
|
||||
}
|
||||
|
||||
export type WorkflowAuthoringPlan = {
|
||||
title: string
|
||||
description: string
|
||||
priorities: WorkflowAuthoringPriority[]
|
||||
authoringFlow: WorkflowAuthoringFlowStep[]
|
||||
referenceBundles: WorkflowAuthoringBundleStatus<WorkflowReferenceBundleDefinition>[]
|
||||
moduleBundles: WorkflowAuthoringBundleStatus<WorkflowModuleBundleDefinition>[]
|
||||
starterTitle: string
|
||||
starterDescription: string
|
||||
starterItems: WorkflowStarterPathItem[]
|
||||
starterCompletedCount: number
|
||||
stageProgress: WorkflowAuthoringStageProgress[]
|
||||
gapFillDefinitions: WorkflowNodeDefinition[]
|
||||
}
|
||||
|
||||
function haveSameStepSet(left: string[], right: string[]) {
|
||||
if (left.length !== right.length) return false
|
||||
const rightSet = new Set(right)
|
||||
return left.every(step => rightSet.has(step))
|
||||
}
|
||||
|
||||
function getAuthoringPriorities(graphFamily: WorkflowGraphFamily): WorkflowAuthoringPriority[] {
|
||||
if (graphFamily === 'order_line') {
|
||||
return [
|
||||
{
|
||||
title: 'Start with the Still Render Reference path',
|
||||
description: 'Insert the full non-legacy still production baseline first, then tune modules or individual nodes.',
|
||||
},
|
||||
{
|
||||
title: 'Swap stages with production modules',
|
||||
description: 'Use render and publish bundles to change whole stages without breaking graph-safe sequencing.',
|
||||
},
|
||||
{
|
||||
title: 'Use raw nodes last',
|
||||
description: 'Drop to legacy, bridge, or native graph nodes only after the production path already exists on canvas.',
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
if (graphFamily === 'cad_file') {
|
||||
return [
|
||||
{
|
||||
title: 'Start with the CAD intake assembly',
|
||||
description: 'Build the import and preview chain first so downstream consumers always receive consistent artifacts.',
|
||||
},
|
||||
{
|
||||
title: 'Extend with stage bundles',
|
||||
description: 'Use reusable intake modules before inserting isolated conversion or utility nodes.',
|
||||
},
|
||||
{
|
||||
title: 'Use raw nodes last',
|
||||
description: 'Only drop to individual nodes for edge cases or targeted debugging once the intake baseline is present.',
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
title: 'Split the graph by responsibility',
|
||||
description: 'Keep mixed graphs organized by building complete stages first, then add isolated nodes only for orchestration glue.',
|
||||
},
|
||||
{
|
||||
title: 'Prefer reusable modules over hand-wiring',
|
||||
description: 'Use grouped bundles whenever a stage can be expressed as a reusable production slice.',
|
||||
},
|
||||
{
|
||||
title: 'Use raw nodes last',
|
||||
description: 'Reach for the full catalog only after the baseline path is readable and stable.',
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
export function getWorkflowAuthoringFlow(graphFamily: WorkflowGraphFamily): WorkflowAuthoringFlowStep[] {
|
||||
const starterPathLabel = graphFamily === 'mixed' ? 'Starter Path' : STARTER_PATH_TITLES[graphFamily]
|
||||
|
||||
return [
|
||||
{
|
||||
index: 1,
|
||||
title: 'Reference Path',
|
||||
description:
|
||||
graphFamily === 'order_line'
|
||||
? 'Start from the canonical non-legacy still graph before changing individual stages.'
|
||||
: 'Start from the canonical path when you want a full baseline instead of assembling from scratch.',
|
||||
},
|
||||
{
|
||||
index: 2,
|
||||
title: 'Production Modules',
|
||||
description: 'Swap or extend whole production stages with reusable graph-safe bundles.',
|
||||
},
|
||||
{
|
||||
index: 3,
|
||||
title: starterPathLabel,
|
||||
description: 'Fill gaps one required step at a time and verify the minimum viable chain stays intact.',
|
||||
},
|
||||
{
|
||||
index: 4,
|
||||
title: 'Raw Node Catalog',
|
||||
description: 'Use individual nodes only for advanced authoring, experiments, or edge-case overrides.',
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
export function getWorkflowAuthoringPlan(
|
||||
definitions: WorkflowNodeDefinition[],
|
||||
graphFamily: WorkflowGraphFamily,
|
||||
activeSteps: string[],
|
||||
): WorkflowAuthoringPlan {
|
||||
const activeStepSet = new Set(activeSteps)
|
||||
const definitionsByStep = new Map(definitions.map(definition => [definition.step, definition]))
|
||||
const title = graphFamily === 'mixed' ? 'Guided Authoring' : STARTER_PATH_TITLES[graphFamily]
|
||||
const description =
|
||||
graphFamily === 'mixed'
|
||||
? 'Keep the graph readable by preferring complete paths and bundles before raw node-level edits.'
|
||||
: STARTER_PATH_DESCRIPTIONS[graphFamily]
|
||||
const referenceBundles = getWorkflowReferenceBundles(definitions, graphFamily)
|
||||
.map(bundle => ({
|
||||
...bundle,
|
||||
presentCount: bundle.stepIds.filter(step => activeStepSet.has(step)).length,
|
||||
totalCount: bundle.stepIds.length,
|
||||
}))
|
||||
const moduleBundles = getWorkflowModuleBundles(definitions, graphFamily)
|
||||
.map(bundle => ({
|
||||
...bundle,
|
||||
presentCount: bundle.stepIds.filter(step => activeStepSet.has(step)).length,
|
||||
totalCount: bundle.stepIds.length,
|
||||
}))
|
||||
|
||||
const starterDefinitions =
|
||||
graphFamily === 'mixed'
|
||||
? []
|
||||
: STARTER_NODE_STEP_ORDER[graphFamily]
|
||||
.map(step => definitionsByStep.get(step))
|
||||
.filter((definition): definition is WorkflowNodeDefinition => Boolean(definition))
|
||||
|
||||
const starterItems = starterDefinitions.map((definition, index) => ({
|
||||
index: index + 1,
|
||||
definition,
|
||||
isPresent: activeStepSet.has(definition.step),
|
||||
}))
|
||||
|
||||
const starterCompletedCount = starterItems.filter(item => item.isPresent).length
|
||||
const gapFillDefinitions = starterItems
|
||||
.filter(item => !item.isPresent)
|
||||
.map(item => item.definition)
|
||||
.slice(0, 3)
|
||||
|
||||
const stageProgress = (() => {
|
||||
if (graphFamily === 'mixed') return [] as WorkflowAuthoringStageProgress[]
|
||||
|
||||
const stages: WorkflowAuthoringStageProgress[] = []
|
||||
const referenceBundle = referenceBundles[0]
|
||||
if (referenceBundle) {
|
||||
stages.push({
|
||||
id: referenceBundle.id,
|
||||
title: referenceBundle.label,
|
||||
description: referenceBundle.description,
|
||||
present: referenceBundle.presentCount,
|
||||
total: referenceBundle.totalCount,
|
||||
actionLabel:
|
||||
referenceBundle.presentCount === 0
|
||||
? `Insert ${referenceBundle.shortLabel}`
|
||||
: `Reapply ${referenceBundle.shortLabel}`,
|
||||
actionKind: 'reference',
|
||||
bundleId: referenceBundle.id,
|
||||
})
|
||||
}
|
||||
|
||||
for (const bundle of moduleBundles) {
|
||||
if (referenceBundle && haveSameStepSet(bundle.stepIds, referenceBundle.stepIds)) {
|
||||
continue
|
||||
}
|
||||
if (bundle.presentCount === bundle.totalCount) continue
|
||||
|
||||
stages.push({
|
||||
id: bundle.id,
|
||||
title: bundle.label,
|
||||
description: bundle.description,
|
||||
present: bundle.presentCount,
|
||||
total: bundle.totalCount,
|
||||
actionLabel:
|
||||
bundle.presentCount === 0
|
||||
? `Insert ${bundle.shortLabel}`
|
||||
: `Complete ${bundle.shortLabel}`,
|
||||
actionKind: 'module',
|
||||
bundleId: bundle.id,
|
||||
})
|
||||
}
|
||||
|
||||
const nextMissingStarterDefinition = starterItems.find(item => !item.isPresent)?.definition
|
||||
if (nextMissingStarterDefinition) {
|
||||
stages.push({
|
||||
id: nextMissingStarterDefinition.step,
|
||||
title: `Next missing step: ${nextMissingStarterDefinition.label}`,
|
||||
description: 'Use a single-step insert only when you need to patch a gap without dropping an entire stage bundle.',
|
||||
present: 0,
|
||||
total: 1,
|
||||
actionLabel: `Add ${nextMissingStarterDefinition.label}`,
|
||||
actionKind: 'step',
|
||||
step: nextMissingStarterDefinition.step,
|
||||
})
|
||||
}
|
||||
|
||||
return stages
|
||||
})()
|
||||
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
priorities: getAuthoringPriorities(graphFamily),
|
||||
authoringFlow: getWorkflowAuthoringFlow(graphFamily),
|
||||
referenceBundles,
|
||||
moduleBundles,
|
||||
starterTitle: STARTER_PATH_TITLES[graphFamily],
|
||||
starterDescription: STARTER_PATH_DESCRIPTIONS[graphFamily],
|
||||
starterItems,
|
||||
starterCompletedCount,
|
||||
stageProgress,
|
||||
gapFillDefinitions,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import {
|
||||
Boxes,
|
||||
Compass,
|
||||
Library,
|
||||
Milestone,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react'
|
||||
|
||||
import type { WorkflowGraphFamily } from './workflowNodeLibrary'
|
||||
|
||||
export type WorkflowAuthoringSection = 'overview' | 'paths' | 'modules' | 'starter' | 'nodes'
|
||||
|
||||
type WorkflowAuthoringSectionConfig = {
|
||||
key: WorkflowAuthoringSection
|
||||
label: string
|
||||
helper: string
|
||||
icon: LucideIcon
|
||||
}
|
||||
|
||||
type WorkflowAuthoringSectionOptions = {
|
||||
graphFamily: WorkflowGraphFamily
|
||||
includeOverview?: boolean
|
||||
hasReferencePaths?: boolean
|
||||
hasModules?: boolean
|
||||
hasStarter?: boolean
|
||||
}
|
||||
|
||||
export function getWorkflowAuthoringSections({
|
||||
graphFamily,
|
||||
includeOverview = false,
|
||||
hasReferencePaths = false,
|
||||
hasModules = false,
|
||||
hasStarter = false,
|
||||
}: WorkflowAuthoringSectionOptions): WorkflowAuthoringSectionConfig[] {
|
||||
const sections: WorkflowAuthoringSectionConfig[] = []
|
||||
|
||||
if (includeOverview) {
|
||||
sections.push({
|
||||
key: 'overview',
|
||||
label: 'Overview',
|
||||
helper: 'Start with guided authoring modes before dropping to raw nodes.',
|
||||
icon: Compass,
|
||||
})
|
||||
}
|
||||
|
||||
if (hasReferencePaths) {
|
||||
sections.push({
|
||||
key: 'paths',
|
||||
label: 'Paths',
|
||||
helper: 'Insert complete canonical production paths.',
|
||||
icon: Milestone,
|
||||
})
|
||||
}
|
||||
|
||||
if (hasModules) {
|
||||
sections.push({
|
||||
key: 'modules',
|
||||
label: 'Modules',
|
||||
helper: 'Insert reusable production bundles.',
|
||||
icon: Boxes,
|
||||
})
|
||||
}
|
||||
|
||||
if (hasStarter || graphFamily !== 'mixed') {
|
||||
sections.push({
|
||||
key: 'starter',
|
||||
label: 'Starter',
|
||||
helper: 'Follow the canonical family-safe assembly path.',
|
||||
icon: Milestone,
|
||||
})
|
||||
}
|
||||
|
||||
sections.push({
|
||||
key: 'nodes',
|
||||
label: 'Nodes',
|
||||
helper: 'Browse the full node catalog.',
|
||||
icon: Library,
|
||||
})
|
||||
|
||||
return sections
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import type { WorkflowNodeDefinition } from '../../api/workflows'
|
||||
import {
|
||||
bindWorkflowAuthoringInsertActions,
|
||||
type WorkflowAuthoringActions,
|
||||
type WorkflowAuthoringInsertHandlers,
|
||||
type WorkflowAuthoringPosition,
|
||||
} from './workflowAuthoringActions'
|
||||
import { getWorkflowAuthoringPlan, type WorkflowAuthoringPlan } from './workflowAuthoringGuidance'
|
||||
import type { WorkflowGraphFamily } from './workflowNodeLibrary'
|
||||
import {
|
||||
getWorkflowAuthoringSections,
|
||||
type WorkflowAuthoringSection,
|
||||
} from './workflowAuthoringSections'
|
||||
|
||||
type WorkflowAuthoringSurfaceModelOptions = {
|
||||
definitions: WorkflowNodeDefinition[]
|
||||
graphFamily: WorkflowGraphFamily
|
||||
activeSteps: string[]
|
||||
}
|
||||
|
||||
export type WorkflowAuthoringSurfaceModel = {
|
||||
defaultSection: WorkflowAuthoringSection
|
||||
sections: ReturnType<typeof getWorkflowAuthoringSections>
|
||||
plan: WorkflowAuthoringPlan
|
||||
}
|
||||
|
||||
type UseWorkflowAuthoringSurfaceOptions = WorkflowAuthoringSurfaceModelOptions & {
|
||||
actions?: WorkflowAuthoringActions
|
||||
preferredPosition?: WorkflowAuthoringPosition
|
||||
onAfterInsert?: () => void
|
||||
}
|
||||
|
||||
export type WorkflowAuthoringSurfaceController = WorkflowAuthoringSurfaceModel & {
|
||||
activeSection: WorkflowAuthoringSection
|
||||
activeSectionMeta: WorkflowAuthoringSurfaceModel['sections'][number] | null
|
||||
insertBindings: WorkflowAuthoringInsertHandlers
|
||||
setActiveSection: (section: WorkflowAuthoringSection) => void
|
||||
}
|
||||
|
||||
export function getDefaultWorkflowAuthoringSection(
|
||||
graphFamily: WorkflowGraphFamily,
|
||||
): WorkflowAuthoringSection {
|
||||
return graphFamily === 'mixed' ? 'nodes' : 'overview'
|
||||
}
|
||||
|
||||
export function getWorkflowAuthoringSurfaceModel({
|
||||
definitions,
|
||||
graphFamily,
|
||||
activeSteps,
|
||||
}: WorkflowAuthoringSurfaceModelOptions): WorkflowAuthoringSurfaceModel {
|
||||
const plan = getWorkflowAuthoringPlan(definitions, graphFamily, activeSteps)
|
||||
const defaultSection = getDefaultWorkflowAuthoringSection(graphFamily)
|
||||
const sections = getWorkflowAuthoringSections({
|
||||
graphFamily,
|
||||
includeOverview: graphFamily !== 'mixed',
|
||||
hasReferencePaths: plan.referenceBundles.length > 0,
|
||||
hasModules: plan.moduleBundles.length > 0,
|
||||
hasStarter: graphFamily !== 'mixed' && plan.starterItems.length > 0,
|
||||
})
|
||||
|
||||
return {
|
||||
defaultSection,
|
||||
sections,
|
||||
plan,
|
||||
}
|
||||
}
|
||||
|
||||
export function getWorkflowAuthoringSurfaceSections({
|
||||
definitions,
|
||||
graphFamily,
|
||||
activeSteps,
|
||||
}: WorkflowAuthoringSurfaceModelOptions) {
|
||||
return getWorkflowAuthoringSurfaceModel({
|
||||
definitions,
|
||||
graphFamily,
|
||||
activeSteps,
|
||||
}).sections
|
||||
}
|
||||
|
||||
export function resolveWorkflowAuthoringSection(
|
||||
activeSection: WorkflowAuthoringSection,
|
||||
sections: WorkflowAuthoringSurfaceModel['sections'],
|
||||
defaultSection: WorkflowAuthoringSection,
|
||||
): WorkflowAuthoringSection {
|
||||
if (sections.some(section => section.key === activeSection)) {
|
||||
return activeSection
|
||||
}
|
||||
|
||||
if (sections.some(section => section.key === defaultSection)) {
|
||||
return defaultSection
|
||||
}
|
||||
|
||||
return sections[0]?.key ?? 'nodes'
|
||||
}
|
||||
|
||||
export function useWorkflowAuthoringSurface({
|
||||
definitions,
|
||||
graphFamily,
|
||||
activeSteps,
|
||||
actions,
|
||||
preferredPosition,
|
||||
onAfterInsert,
|
||||
}: UseWorkflowAuthoringSurfaceOptions): WorkflowAuthoringSurfaceController {
|
||||
const surfaceModel = useMemo(
|
||||
() => getWorkflowAuthoringSurfaceModel({ definitions, graphFamily, activeSteps }),
|
||||
[activeSteps, definitions, graphFamily],
|
||||
)
|
||||
const { defaultSection, plan, sections } = surfaceModel
|
||||
const insertBindings = useMemo(
|
||||
() =>
|
||||
bindWorkflowAuthoringInsertActions(actions, {
|
||||
preferredPosition,
|
||||
onAfterInsert,
|
||||
}),
|
||||
[actions, onAfterInsert, preferredPosition],
|
||||
)
|
||||
const [activeSection, setActiveSection] = useState<WorkflowAuthoringSection>(defaultSection)
|
||||
|
||||
useEffect(() => {
|
||||
setActiveSection(defaultSection)
|
||||
}, [defaultSection])
|
||||
|
||||
useEffect(() => {
|
||||
setActiveSection(currentSection =>
|
||||
resolveWorkflowAuthoringSection(currentSection, sections, defaultSection),
|
||||
)
|
||||
}, [defaultSection, sections])
|
||||
|
||||
const activeSectionMeta = useMemo(
|
||||
() => sections.find(section => section.key === activeSection) ?? null,
|
||||
[activeSection, sections],
|
||||
)
|
||||
|
||||
return {
|
||||
defaultSection,
|
||||
sections,
|
||||
plan,
|
||||
activeSection,
|
||||
activeSectionMeta,
|
||||
insertBindings,
|
||||
setActiveSection,
|
||||
}
|
||||
}
|
||||
@@ -24,11 +24,22 @@ export function inferWorkflowFamily(
|
||||
): WorkflowGraphFamily {
|
||||
const nodes = Array.isArray(config.nodes) ? config.nodes : []
|
||||
if (nodes.length > 0) {
|
||||
const families = new Set(nodes.map(node => getNodeFamily(node.step, nodeDefinitionsByStep)))
|
||||
const families = new Set(
|
||||
nodes
|
||||
.map(node => getNodeFamily(node.step, nodeDefinitionsByStep))
|
||||
.filter((family): family is Exclude<ReturnType<typeof getNodeFamily>, 'shared'> => family !== 'shared'),
|
||||
)
|
||||
if (families.size === 1) {
|
||||
return Array.from(families)[0]
|
||||
}
|
||||
return 'mixed'
|
||||
if (families.size > 1) {
|
||||
return 'mixed'
|
||||
}
|
||||
}
|
||||
|
||||
const configuredFamily = config.ui?.family
|
||||
if (configuredFamily === 'cad_file' || configuredFamily === 'order_line' || configuredFamily === 'mixed') {
|
||||
return configuredFamily
|
||||
}
|
||||
|
||||
const presetType = config.ui?.preset ?? 'custom'
|
||||
|
||||
@@ -8,7 +8,7 @@ import type {
|
||||
WorkflowNodeDefinition,
|
||||
WorkflowParams,
|
||||
} from '../../api/workflows'
|
||||
import { getNodeFamily, type WorkflowNodeDefinitionMap } from './workflowNodeLibrary'
|
||||
import { getNodeFamily, type WorkflowGraphFamily, type WorkflowNodeDefinitionMap } from './workflowNodeLibrary'
|
||||
|
||||
export type WorkflowCanvasNodeData = {
|
||||
label: string
|
||||
@@ -17,6 +17,21 @@ export type WorkflowCanvasNodeData = {
|
||||
description?: string
|
||||
icon?: string
|
||||
category?: StepCategory
|
||||
inputContextLabel?: string | null
|
||||
outputContextLabel?: string | null
|
||||
inputPorts?: WorkflowCanvasPort[]
|
||||
outputPorts?: WorkflowCanvasPort[]
|
||||
requiredAnyInputs?: string[][]
|
||||
editableFieldCount?: number
|
||||
editableFieldLabels?: string[]
|
||||
dynamicVariableHint?: string | null
|
||||
}
|
||||
|
||||
export type WorkflowCanvasPort = {
|
||||
id: string
|
||||
label: string
|
||||
roles: string[]
|
||||
kind: 'required' | 'alternative' | 'provided'
|
||||
}
|
||||
|
||||
export type WorkflowValidationResult = {
|
||||
@@ -24,12 +39,21 @@ export type WorkflowValidationResult = {
|
||||
warnings: string[]
|
||||
}
|
||||
|
||||
export const WORKFLOW_NODE_WIDTH = 240
|
||||
export const WORKFLOW_NODE_MIN_HEIGHT = 188
|
||||
export const WORKFLOW_NODE_HORIZONTAL_GAP = 72
|
||||
export const WORKFLOW_NODE_VERTICAL_GAP = 44
|
||||
export const WORKFLOW_LAYOUT_PADDING_X = 56
|
||||
export const WORKFLOW_LAYOUT_PADDING_Y = 48
|
||||
|
||||
type WorkflowNodeContractContext = 'cad_file' | 'order_line'
|
||||
type WorkflowSemanticState = {
|
||||
availableValues: Set<string>
|
||||
executedSteps: Set<string>
|
||||
}
|
||||
|
||||
const TEMPLATE_INPUT_PARAM_PREFIX = 'template_input__'
|
||||
|
||||
const CAD_FILE_ENTRY_STEPS = new Set([
|
||||
'resolve_step_path',
|
||||
'occ_object_extract',
|
||||
@@ -44,7 +68,6 @@ const ORDER_LINE_SETUP_REQUIRED_STEPS = new Set([
|
||||
'resolve_template',
|
||||
'material_map_resolve',
|
||||
'auto_populate_materials',
|
||||
'glb_bbox',
|
||||
'blender_still',
|
||||
'blender_turntable',
|
||||
'output_save',
|
||||
@@ -67,6 +90,20 @@ const ROOT_CONTEXT_VALUES: Record<WorkflowNodeContractContext, string[]> = {
|
||||
const ORDER_LINE_SETUP_COMPATIBILITY_VALUES = ['cad_materials', 'glb_preview', 'bbox']
|
||||
|
||||
const OUTPUT_SAVE_ALTERNATIVE_INPUTS = ['rendered_image', 'rendered_frames', 'rendered_video']
|
||||
const NOTIFY_ALTERNATIVE_INPUTS = ['rendered_image', 'rendered_frames', 'rendered_video', 'workflow_result', 'blend_asset']
|
||||
const CONTRACT_TOKEN_LABELS: Record<string, string> = {
|
||||
api: 'API',
|
||||
bbox: 'Bounding Box',
|
||||
cad: 'CAD',
|
||||
fps: 'FPS',
|
||||
glb: 'GLB',
|
||||
gpu: 'GPU',
|
||||
id: 'ID',
|
||||
occ: 'OCC',
|
||||
step: 'STEP',
|
||||
stl: 'STL',
|
||||
usd: 'USD',
|
||||
}
|
||||
|
||||
function getContractContext(
|
||||
contract: Record<string, unknown> | undefined,
|
||||
@@ -75,6 +112,14 @@ function getContractContext(
|
||||
return value === 'cad_file' || value === 'order_line' ? value : null
|
||||
}
|
||||
|
||||
export function getContractContextLabel(
|
||||
contract: Record<string, unknown> | undefined,
|
||||
): string | null {
|
||||
const context = getContractContext(contract)
|
||||
if (!context) return null
|
||||
return context === 'cad_file' ? 'CAD File' : 'Order Line'
|
||||
}
|
||||
|
||||
function getContractValues(
|
||||
contract: Record<string, unknown> | undefined,
|
||||
key: string,
|
||||
@@ -101,13 +146,222 @@ function getContractAlternativeValues(
|
||||
.filter(group => group.length > 0)
|
||||
}
|
||||
|
||||
function formatContractValue(value: string): string {
|
||||
export function formatContractValue(value: string): string {
|
||||
return value
|
||||
.split('_')
|
||||
.map(part => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.map(part => {
|
||||
const normalized = part.trim().toLowerCase()
|
||||
if (!normalized) return ''
|
||||
return CONTRACT_TOKEN_LABELS[normalized] ?? (normalized.charAt(0).toUpperCase() + normalized.slice(1))
|
||||
})
|
||||
.filter(part => part.length > 0)
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
function dedupeRoles(values: string[]): string[] {
|
||||
return Array.from(new Set(values.filter(value => value.trim().length > 0)))
|
||||
}
|
||||
|
||||
function createPortId(prefix: string, roles: string[]): string {
|
||||
return `${prefix}:${roles.join('|')}`
|
||||
}
|
||||
|
||||
function formatAlternativePortLabel(roles: string[]): string {
|
||||
if (roles.length === 1) return formatContractValue(roles[0])
|
||||
return `Any of ${roles.map(formatContractValue).join(' / ')}`
|
||||
}
|
||||
|
||||
function getNodeAlternativeInputGroups(definition: WorkflowNodeDefinition | undefined): string[][] {
|
||||
if (!definition) return []
|
||||
|
||||
const alternativeGroups = getContractAlternativeValues(
|
||||
definition.input_contract as Record<string, unknown> | undefined,
|
||||
'requires_any',
|
||||
)
|
||||
|
||||
if (definition.step === 'output_save' && alternativeGroups.length === 0) {
|
||||
alternativeGroups.push(OUTPUT_SAVE_ALTERNATIVE_INPUTS)
|
||||
}
|
||||
|
||||
if (definition.step === 'notify') {
|
||||
if (alternativeGroups.length === 0) {
|
||||
alternativeGroups.push(NOTIFY_ALTERNATIVE_INPUTS)
|
||||
} else {
|
||||
alternativeGroups[0] = Array.from(new Set([...alternativeGroups[0], 'blend_asset']))
|
||||
}
|
||||
}
|
||||
|
||||
return alternativeGroups.map(group => dedupeRoles(group))
|
||||
}
|
||||
|
||||
function getNodeRequiredInputRoles(definition: WorkflowNodeDefinition | undefined): string[] {
|
||||
if (!definition) return []
|
||||
|
||||
const alternativeRoles = new Set(getNodeAlternativeInputGroups(definition).flat())
|
||||
const directRoles = dedupeRoles([
|
||||
...getContractValues(definition.input_contract as Record<string, unknown> | undefined, 'requires'),
|
||||
...(definition.artifact_roles_consumed ?? []),
|
||||
])
|
||||
|
||||
return directRoles.filter(role => !alternativeRoles.has(role))
|
||||
}
|
||||
|
||||
function getNodeProvidedOutputRoles(definition: WorkflowNodeDefinition | undefined): string[] {
|
||||
if (!definition) return []
|
||||
|
||||
return dedupeRoles([
|
||||
...getContractValues(definition.output_contract as Record<string, unknown> | undefined, 'provides'),
|
||||
...(definition.artifact_roles_produced ?? []),
|
||||
])
|
||||
}
|
||||
|
||||
function getNodeEditableFieldLabels(definition: WorkflowNodeDefinition | undefined): string[] {
|
||||
if (!definition) return []
|
||||
|
||||
return Array.from(
|
||||
new Set(
|
||||
definition.fields
|
||||
.map(field => field.label?.trim())
|
||||
.filter((label): label is string => Boolean(label)),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function getDynamicVariableHint(definition: WorkflowNodeDefinition | undefined): string | null {
|
||||
if (!definition) return null
|
||||
|
||||
const providedRoles = new Set(getNodeProvidedOutputRoles(definition))
|
||||
if (providedRoles.has('workflow_input_schema') || providedRoles.has('template_inputs')) {
|
||||
return 'Template-selected variables appear after choosing a template.'
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function buildWorkflowCanvasNodeData(
|
||||
step: string,
|
||||
params: WorkflowParams = {},
|
||||
definition?: WorkflowNodeDefinition,
|
||||
overrides?: Partial<WorkflowCanvasNodeData>,
|
||||
): WorkflowCanvasNodeData {
|
||||
const requiredInputRoles = getNodeRequiredInputRoles(definition)
|
||||
const alternativeInputGroups = getNodeAlternativeInputGroups(definition)
|
||||
const providedOutputRoles = getNodeProvidedOutputRoles(definition)
|
||||
|
||||
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,
|
||||
inputContextLabel: getContractContextLabel(definition?.input_contract as Record<string, unknown> | undefined),
|
||||
outputContextLabel: getContractContextLabel(definition?.output_contract as Record<string, unknown> | undefined),
|
||||
inputPorts: [
|
||||
...requiredInputRoles.map(role => ({
|
||||
id: createPortId('input', [role]),
|
||||
label: formatContractValue(role),
|
||||
roles: [role],
|
||||
kind: 'required' as const,
|
||||
})),
|
||||
...alternativeInputGroups.map(group => ({
|
||||
id: createPortId('input-any', group),
|
||||
label: formatAlternativePortLabel(group),
|
||||
roles: group,
|
||||
kind: 'alternative' as const,
|
||||
})),
|
||||
],
|
||||
outputPorts: providedOutputRoles.map(role => ({
|
||||
id: createPortId('output', [role]),
|
||||
label: formatContractValue(role),
|
||||
roles: [role],
|
||||
kind: 'provided' as const,
|
||||
})),
|
||||
requiredAnyInputs: alternativeInputGroups,
|
||||
editableFieldCount: definition?.fields.length ?? 0,
|
||||
editableFieldLabels: getNodeEditableFieldLabels(definition),
|
||||
dynamicVariableHint: getDynamicVariableHint(definition),
|
||||
}
|
||||
}
|
||||
|
||||
function getCompatiblePortAssignments(
|
||||
sourceData: WorkflowCanvasNodeData | undefined,
|
||||
targetData: WorkflowCanvasNodeData | undefined,
|
||||
preferred?: {
|
||||
sourceHandle?: string | null
|
||||
targetHandle?: string | null
|
||||
},
|
||||
usedTargetHandleIds?: Set<string>,
|
||||
): {
|
||||
sourceHandle?: string
|
||||
targetHandle?: string
|
||||
} {
|
||||
const sourcePorts = sourceData?.outputPorts ?? []
|
||||
const targetPorts = targetData?.inputPorts ?? []
|
||||
|
||||
if (sourcePorts.length === 0 || targetPorts.length === 0) {
|
||||
return {}
|
||||
}
|
||||
|
||||
const preferredTarget = preferred?.targetHandle
|
||||
? targetPorts.find(port => port.id === preferred.targetHandle)
|
||||
: undefined
|
||||
const preferredSource = preferred?.sourceHandle
|
||||
? sourcePorts.find(port => port.id === preferred.sourceHandle)
|
||||
: undefined
|
||||
|
||||
if (preferredTarget && preferredSource) {
|
||||
const matches = preferredTarget.roles.some(role => preferredSource.roles.includes(role))
|
||||
if (matches) {
|
||||
return {
|
||||
sourceHandle: preferredSource.id,
|
||||
targetHandle: preferredTarget.id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (preferredTarget) {
|
||||
const matchingSource = sourcePorts.find(port =>
|
||||
preferredTarget.roles.some(role => port.roles.includes(role)),
|
||||
)
|
||||
if (matchingSource) {
|
||||
return {
|
||||
sourceHandle: matchingSource.id,
|
||||
targetHandle: preferredTarget.id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const compatibleTargets = targetPorts
|
||||
.filter(port =>
|
||||
sourcePorts.some(sourcePort => port.roles.some(role => sourcePort.roles.includes(role))),
|
||||
)
|
||||
.sort((left, right) => {
|
||||
const leftUsed = usedTargetHandleIds?.has(left.id) ? 1 : 0
|
||||
const rightUsed = usedTargetHandleIds?.has(right.id) ? 1 : 0
|
||||
return leftUsed - rightUsed
|
||||
})
|
||||
|
||||
const resolvedTarget = compatibleTargets[0]
|
||||
if (!resolvedTarget) {
|
||||
return {}
|
||||
}
|
||||
|
||||
const resolvedSource =
|
||||
preferredSource && resolvedTarget.roles.some(role => preferredSource.roles.includes(role))
|
||||
? preferredSource
|
||||
: sourcePorts.find(port => resolvedTarget.roles.some(role => port.roles.includes(role)))
|
||||
|
||||
if (!resolvedSource) {
|
||||
return {}
|
||||
}
|
||||
|
||||
return {
|
||||
sourceHandle: resolvedSource.id,
|
||||
targetHandle: resolvedTarget.id,
|
||||
}
|
||||
}
|
||||
|
||||
function inferWorkflowContextKind(
|
||||
nodes: Node[],
|
||||
nodeDefinitionsByStep: WorkflowNodeDefinitionMap,
|
||||
@@ -119,12 +373,17 @@ function inferWorkflowContextKind(
|
||||
const step = ((node.data as WorkflowCanvasNodeData | undefined)?.step as string | undefined) ?? inferStepFromNodeType(node.type)
|
||||
const definition = nodeDefinitionsByStep[step]
|
||||
if (definition) {
|
||||
families.add(definition.family)
|
||||
if (definition.family === 'cad_file' || definition.family === 'order_line') {
|
||||
families.add(definition.family)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (!definitionsLoaded) {
|
||||
families.add(getNodeFamily(step, nodeDefinitionsByStep))
|
||||
const fallbackFamily = getNodeFamily(step, nodeDefinitionsByStep)
|
||||
if (fallbackFamily === 'cad_file' || fallbackFamily === 'order_line') {
|
||||
families.add(fallbackFamily)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,6 +391,54 @@ function inferWorkflowContextKind(
|
||||
return Array.from(families)[0]
|
||||
}
|
||||
|
||||
function readConfiguredWorkflowFamily(config: WorkflowConfig): WorkflowGraphFamily | null {
|
||||
const family = config.ui?.family
|
||||
return family === 'cad_file' || family === 'order_line' || family === 'mixed' ? family : null
|
||||
}
|
||||
|
||||
function inferFallbackWorkflowFamily(
|
||||
config: WorkflowConfig,
|
||||
nodeDefinitionsByStep: WorkflowNodeDefinitionMap,
|
||||
): WorkflowGraphFamily {
|
||||
const configuredFamily = readConfiguredWorkflowFamily(config)
|
||||
if (configuredFamily) return configuredFamily
|
||||
|
||||
const nodes = Array.isArray(config.nodes) ? config.nodes : []
|
||||
if (nodes.length > 0) {
|
||||
const families = new Set(
|
||||
nodes
|
||||
.map(node => getNodeFamily(node.step, nodeDefinitionsByStep))
|
||||
.filter((family): family is WorkflowNodeContractContext => family === 'cad_file' || family === 'order_line'),
|
||||
)
|
||||
if (families.size === 1) {
|
||||
return Array.from(families)[0]
|
||||
}
|
||||
if (families.size > 1) {
|
||||
return 'mixed'
|
||||
}
|
||||
}
|
||||
|
||||
return config.ui?.preset === 'custom' ? 'mixed' : 'order_line'
|
||||
}
|
||||
|
||||
export function deriveWorkflowAuthoringFamily(
|
||||
workflow: WorkflowDefinition,
|
||||
nodes: Node[],
|
||||
nodeDefinitionsByStep: WorkflowNodeDefinitionMap,
|
||||
definitionsLoaded: boolean,
|
||||
): WorkflowGraphFamily {
|
||||
const configuredFamily = readConfiguredWorkflowFamily(workflow.config)
|
||||
if (configuredFamily === 'cad_file' || configuredFamily === 'order_line') {
|
||||
return configuredFamily
|
||||
}
|
||||
|
||||
const inferredFamily = inferWorkflowContextKind(nodes, nodeDefinitionsByStep, definitionsLoaded)
|
||||
if (inferredFamily) return inferredFamily
|
||||
|
||||
if (configuredFamily === 'mixed') return 'mixed'
|
||||
return inferFallbackWorkflowFamily(workflow.config, nodeDefinitionsByStep)
|
||||
}
|
||||
|
||||
function getFieldKeys(definition: WorkflowNodeDefinition | undefined): Set<string> {
|
||||
return new Set((definition?.fields ?? []).map(field => field.key))
|
||||
}
|
||||
@@ -158,6 +465,14 @@ export function resolveParamsForStepChange(
|
||||
}
|
||||
}
|
||||
|
||||
if (definition.step === 'resolve_template') {
|
||||
for (const [key, value] of Object.entries(currentParams)) {
|
||||
if (key.startsWith(TEMPLATE_INPUT_PARAM_PREFIX) && !isFieldValueEmpty(value)) {
|
||||
nextParams[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return normalizeWorkflowParams(nextParams)
|
||||
}
|
||||
|
||||
@@ -384,6 +699,12 @@ export function validateWorkflowDraft(
|
||||
|
||||
if (step === 'notify') {
|
||||
requiredValues.delete('workflow_result')
|
||||
requiredValues.delete('blend_asset')
|
||||
if (alternativeRequiredGroups.length === 0) {
|
||||
alternativeRequiredGroups.push(NOTIFY_ALTERNATIVE_INPUTS)
|
||||
} else if (alternativeRequiredGroups.length > 0) {
|
||||
alternativeRequiredGroups[0] = Array.from(new Set([...alternativeRequiredGroups[0], 'blend_asset']))
|
||||
}
|
||||
}
|
||||
|
||||
const compatibilityValues = new Set<string>()
|
||||
@@ -391,6 +712,9 @@ export function validateWorkflowDraft(
|
||||
for (const value of ORDER_LINE_SETUP_COMPATIBILITY_VALUES) {
|
||||
compatibilityValues.add(value)
|
||||
}
|
||||
if (definition.legacy_compatible) {
|
||||
compatibilityValues.add('material_assignments')
|
||||
}
|
||||
}
|
||||
|
||||
for (const group of alternativeRequiredGroups) {
|
||||
@@ -437,24 +761,51 @@ export function workflowToGraph(
|
||||
config: WorkflowConfig,
|
||||
nodeDefinitionsByStep: Record<string, WorkflowNodeDefinition>,
|
||||
): { nodes: Node[]; edges: Edge[] } {
|
||||
const nodes = config.nodes.map(node => ({
|
||||
id: node.id,
|
||||
type: node.ui?.type ?? nodeDefinitionsByStep[node.step]?.node_type ?? inferNodeType(node.step),
|
||||
position: node.ui?.position ?? { x: 0, y: 0 },
|
||||
data: buildWorkflowCanvasNodeData(
|
||||
node.step,
|
||||
node.params ?? {},
|
||||
nodeDefinitionsByStep[node.step],
|
||||
{ label: node.ui?.label },
|
||||
) satisfies WorkflowCanvasNodeData,
|
||||
}))
|
||||
const nodeById = new Map(nodes.map(node => [node.id, node]))
|
||||
const usedTargetHandlesByNodeId = new Map<string, Set<string>>()
|
||||
|
||||
return {
|
||||
nodes: config.nodes.map(node => ({
|
||||
id: node.id,
|
||||
type: node.ui?.type ?? nodeDefinitionsByStep[node.step]?.node_type ?? inferNodeType(node.step),
|
||||
position: node.ui?.position ?? { x: 0, y: 0 },
|
||||
data: {
|
||||
label: node.ui?.label ?? nodeDefinitionsByStep[node.step]?.label ?? inferNodeLabel(node.step),
|
||||
params: normalizeWorkflowParams(node.params ?? {}),
|
||||
step: node.step,
|
||||
description: nodeDefinitionsByStep[node.step]?.description,
|
||||
icon: nodeDefinitionsByStep[node.step]?.icon,
|
||||
category: nodeDefinitionsByStep[node.step]?.category,
|
||||
} satisfies WorkflowCanvasNodeData,
|
||||
})),
|
||||
nodes,
|
||||
edges: config.edges.map((edge, index) => ({
|
||||
id: `e_${edge.from}_${edge.to}_${index}`,
|
||||
source: edge.from,
|
||||
target: edge.to,
|
||||
...(() => {
|
||||
const sourceNode = nodeById.get(edge.from)
|
||||
const targetNode = nodeById.get(edge.to)
|
||||
const usedTargetHandles =
|
||||
usedTargetHandlesByNodeId.get(edge.to) ??
|
||||
(() => {
|
||||
const nextSet = new Set<string>()
|
||||
usedTargetHandlesByNodeId.set(edge.to, nextSet)
|
||||
return nextSet
|
||||
})()
|
||||
const handles = getCompatiblePortAssignments(
|
||||
sourceNode?.data as WorkflowCanvasNodeData | undefined,
|
||||
targetNode?.data as WorkflowCanvasNodeData | undefined,
|
||||
undefined,
|
||||
usedTargetHandles,
|
||||
)
|
||||
if (handles.targetHandle) {
|
||||
usedTargetHandles.add(handles.targetHandle)
|
||||
}
|
||||
|
||||
return {
|
||||
id: `e_${edge.from}_${edge.to}_${index}`,
|
||||
source: edge.from,
|
||||
target: edge.to,
|
||||
sourceHandle: handles.sourceHandle,
|
||||
targetHandle: handles.targetHandle,
|
||||
}
|
||||
})(),
|
||||
})),
|
||||
}
|
||||
}
|
||||
@@ -464,12 +815,19 @@ export function buildCurrentWorkflowConfig(
|
||||
nodes: Node[],
|
||||
edges: Edge[],
|
||||
executionMode: WorkflowExecutionMode,
|
||||
authoringFamily?: WorkflowGraphFamily,
|
||||
): WorkflowConfig {
|
||||
const nextFamily =
|
||||
authoringFamily ??
|
||||
readConfiguredWorkflowFamily(workflow.config) ??
|
||||
inferFallbackWorkflowFamily(workflow.config, {})
|
||||
|
||||
return {
|
||||
version: workflow.config.version ?? 1,
|
||||
ui: {
|
||||
...(workflow.config.ui ?? {}),
|
||||
execution_mode: executionMode,
|
||||
family: nextFamily,
|
||||
},
|
||||
nodes: nodes.map(node => {
|
||||
const step =
|
||||
@@ -499,28 +857,238 @@ export function buildCurrentWorkflowConfig(
|
||||
}
|
||||
}
|
||||
|
||||
type WorkflowNodeBounds = {
|
||||
left: number
|
||||
right: number
|
||||
top: number
|
||||
bottom: number
|
||||
}
|
||||
|
||||
function getNodeBounds(position: { x: number; y: number }): WorkflowNodeBounds {
|
||||
return {
|
||||
left: position.x,
|
||||
right: position.x + WORKFLOW_NODE_WIDTH,
|
||||
top: position.y,
|
||||
bottom: position.y + WORKFLOW_NODE_MIN_HEIGHT,
|
||||
}
|
||||
}
|
||||
|
||||
function nodesOverlapAtPosition(
|
||||
position: { x: number; y: number },
|
||||
otherPosition: { x: number; y: number },
|
||||
): boolean {
|
||||
const a = getNodeBounds(position)
|
||||
const b = getNodeBounds(otherPosition)
|
||||
|
||||
return !(
|
||||
a.right + WORKFLOW_NODE_HORIZONTAL_GAP <= b.left ||
|
||||
b.right + WORKFLOW_NODE_HORIZONTAL_GAP <= a.left ||
|
||||
a.bottom + WORKFLOW_NODE_VERTICAL_GAP <= b.top ||
|
||||
b.bottom + WORKFLOW_NODE_VERTICAL_GAP <= a.top
|
||||
)
|
||||
}
|
||||
|
||||
export function graphNeedsAutoLayout(nodes: Node[]): boolean {
|
||||
if (nodes.length <= 1) return false
|
||||
|
||||
const invalidPosition = nodes.some(
|
||||
node =>
|
||||
!Number.isFinite(node.position.x) ||
|
||||
!Number.isFinite(node.position.y),
|
||||
)
|
||||
if (invalidPosition) return true
|
||||
|
||||
const uniquePositions = new Set(nodes.map(node => `${Math.round(node.position.x)}:${Math.round(node.position.y)}`))
|
||||
if (uniquePositions.size <= Math.ceil(nodes.length / 2)) return true
|
||||
|
||||
for (let index = 0; index < nodes.length; index += 1) {
|
||||
for (let otherIndex = index + 1; otherIndex < nodes.length; otherIndex += 1) {
|
||||
if (nodesOverlapAtPosition(nodes[index].position, nodes[otherIndex].position)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export function findOpenNodePosition(
|
||||
nodes: Node[],
|
||||
preferredPosition: { x: number; y: number },
|
||||
): { x: number; y: number } {
|
||||
const horizontalStep = WORKFLOW_NODE_WIDTH + WORKFLOW_NODE_HORIZONTAL_GAP
|
||||
const verticalStep = WORKFLOW_NODE_MIN_HEIGHT + WORKFLOW_NODE_VERTICAL_GAP
|
||||
const normalized = {
|
||||
x: Math.max(WORKFLOW_LAYOUT_PADDING_X, preferredPosition.x),
|
||||
y: Math.max(WORKFLOW_LAYOUT_PADDING_Y, preferredPosition.y),
|
||||
}
|
||||
|
||||
const isPositionFree = (candidate: { x: number; y: number }) =>
|
||||
nodes.every(node => !nodesOverlapAtPosition(candidate, node.position))
|
||||
|
||||
if (isPositionFree(normalized)) {
|
||||
return normalized
|
||||
}
|
||||
|
||||
for (let radius = 0; radius < 12; radius += 1) {
|
||||
const horizontalRange = radius + 1
|
||||
const verticalRange = radius + 1
|
||||
for (let row = -verticalRange; row <= verticalRange; row += 1) {
|
||||
for (let column = -horizontalRange; column <= horizontalRange; column += 1) {
|
||||
const candidate = {
|
||||
x: Math.max(WORKFLOW_LAYOUT_PADDING_X, normalized.x + column * horizontalStep),
|
||||
y: Math.max(WORKFLOW_LAYOUT_PADDING_Y, normalized.y + row * verticalStep),
|
||||
}
|
||||
if (isPositionFree(candidate)) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
x: normalized.x + horizontalStep,
|
||||
y: normalized.y + verticalStep,
|
||||
}
|
||||
}
|
||||
|
||||
export function shouldAutoLayoutAfterInsert(
|
||||
nodes: Node[],
|
||||
insertedNode: Node,
|
||||
preferredPosition?: { x: number; y: number } | null,
|
||||
): boolean {
|
||||
if (graphNeedsAutoLayout(nodes)) return true
|
||||
if (!preferredPosition) return false
|
||||
|
||||
const horizontalThreshold = Math.max(WORKFLOW_NODE_HORIZONTAL_GAP, WORKFLOW_NODE_WIDTH * 0.55)
|
||||
const verticalThreshold = Math.max(WORKFLOW_NODE_VERTICAL_GAP, WORKFLOW_NODE_MIN_HEIGHT * 0.55)
|
||||
|
||||
return (
|
||||
Math.abs(insertedNode.position.x - preferredPosition.x) > horizontalThreshold ||
|
||||
Math.abs(insertedNode.position.y - preferredPosition.y) > verticalThreshold
|
||||
)
|
||||
}
|
||||
|
||||
type WorkflowCollisionDelta = {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
function getCollisionResolutionDelta(
|
||||
anchorPosition: { x: number; y: number },
|
||||
overlappingPosition: { x: number; y: number },
|
||||
): WorkflowCollisionDelta {
|
||||
const anchor = getNodeBounds(anchorPosition)
|
||||
const overlapping = getNodeBounds(overlappingPosition)
|
||||
const anchorCenterX = (anchor.left + anchor.right) / 2
|
||||
const anchorCenterY = (anchor.top + anchor.bottom) / 2
|
||||
const overlappingCenterX = (overlapping.left + overlapping.right) / 2
|
||||
const overlappingCenterY = (overlapping.top + overlapping.bottom) / 2
|
||||
const sameColumnThreshold = (WORKFLOW_NODE_WIDTH + WORKFLOW_NODE_HORIZONTAL_GAP) * 0.45
|
||||
const sameColumn = Math.abs(anchorCenterX - overlappingCenterX) <= sameColumnThreshold
|
||||
|
||||
const pushRight = anchor.right + WORKFLOW_NODE_HORIZONTAL_GAP - overlapping.left
|
||||
const pushLeft = anchor.left - WORKFLOW_NODE_HORIZONTAL_GAP - overlapping.right
|
||||
const pushDown = anchor.bottom + WORKFLOW_NODE_VERTICAL_GAP - overlapping.top
|
||||
const pushUp = anchor.top - WORKFLOW_NODE_VERTICAL_GAP - overlapping.bottom
|
||||
|
||||
const horizontalDelta = overlappingCenterX >= anchorCenterX ? pushRight : pushLeft
|
||||
const verticalDelta = overlappingCenterY >= anchorCenterY ? pushDown : pushUp
|
||||
const horizontalMagnitude = Math.abs(horizontalDelta)
|
||||
const verticalMagnitude = Math.abs(verticalDelta)
|
||||
|
||||
if (sameColumn && verticalMagnitude <= horizontalMagnitude * 1.25) {
|
||||
return { x: 0, y: verticalDelta }
|
||||
}
|
||||
|
||||
if (horizontalMagnitude < verticalMagnitude) {
|
||||
return { x: horizontalDelta, y: 0 }
|
||||
}
|
||||
|
||||
return { x: 0, y: verticalDelta }
|
||||
}
|
||||
|
||||
export function resolveNodeCollisions(nodes: Node[], anchorNodeIds: string[]): Node[] {
|
||||
if (nodes.length <= 1 || anchorNodeIds.length === 0) return nodes
|
||||
|
||||
const nextNodes = nodes.map(node => ({ ...node, position: { ...node.position } }))
|
||||
const nodeIndexById = new Map(nextNodes.map((node, index) => [node.id, index]))
|
||||
const lockedNodeIds = new Set(anchorNodeIds.filter(nodeId => nodeIndexById.has(nodeId)))
|
||||
if (lockedNodeIds.size === 0) return nextNodes
|
||||
|
||||
const queue = [...lockedNodeIds]
|
||||
const maxIterations = nextNodes.length * nextNodes.length * 4
|
||||
let iterations = 0
|
||||
|
||||
while (queue.length > 0 && iterations < maxIterations) {
|
||||
const anchorNodeId = queue.shift()!
|
||||
const anchorIndex = nodeIndexById.get(anchorNodeId)
|
||||
if (anchorIndex === undefined) continue
|
||||
|
||||
const anchorNode = nextNodes[anchorIndex]
|
||||
for (let index = 0; index < nextNodes.length; index += 1) {
|
||||
const candidate = nextNodes[index]
|
||||
if (candidate.id === anchorNode.id || lockedNodeIds.has(candidate.id)) continue
|
||||
if (!nodesOverlapAtPosition(anchorNode.position, candidate.position)) continue
|
||||
|
||||
const delta = getCollisionResolutionDelta(anchorNode.position, candidate.position)
|
||||
let candidatePosition = {
|
||||
x: Math.max(WORKFLOW_LAYOUT_PADDING_X, candidate.position.x + delta.x),
|
||||
y: Math.max(WORKFLOW_LAYOUT_PADDING_Y, candidate.position.y + delta.y),
|
||||
}
|
||||
const nodesWithoutCandidate = nextNodes.filter(node => node.id !== candidate.id)
|
||||
let attempts = 0
|
||||
|
||||
while (
|
||||
nodesWithoutCandidate.some(node => nodesOverlapAtPosition(candidatePosition, node.position)) &&
|
||||
attempts < 12
|
||||
) {
|
||||
candidatePosition = {
|
||||
x: Math.max(WORKFLOW_LAYOUT_PADDING_X, candidatePosition.x + delta.x),
|
||||
y: Math.max(WORKFLOW_LAYOUT_PADDING_Y, candidatePosition.y + delta.y),
|
||||
}
|
||||
attempts += 1
|
||||
}
|
||||
|
||||
if (nodesWithoutCandidate.some(node => nodesOverlapAtPosition(candidatePosition, node.position))) {
|
||||
candidatePosition = findOpenNodePosition(nodesWithoutCandidate, candidatePosition)
|
||||
}
|
||||
|
||||
nextNodes[index] = {
|
||||
...candidate,
|
||||
position: candidatePosition,
|
||||
}
|
||||
queue.push(candidate.id)
|
||||
iterations += 1
|
||||
}
|
||||
}
|
||||
|
||||
return nextNodes
|
||||
}
|
||||
|
||||
export function applyAutoLayout(nodes: Node[], edges: Edge[]) {
|
||||
if (nodes.length === 0) return nodes
|
||||
|
||||
const HORIZONTAL_SPACING = 280
|
||||
const VERTICAL_SPACING = 140
|
||||
const PADDING_X = 48
|
||||
const PADDING_Y = 48
|
||||
const horizontalSpacing = WORKFLOW_NODE_WIDTH + WORKFLOW_NODE_HORIZONTAL_GAP
|
||||
const verticalSpacing = WORKFLOW_NODE_MIN_HEIGHT + WORKFLOW_NODE_VERTICAL_GAP
|
||||
|
||||
const nodeById = new Map(nodes.map(node => [node.id, node]))
|
||||
const inDegree = new Map<string, number>()
|
||||
const adjacency = new Map<string, string[]>()
|
||||
const parentsByNodeId = new Map<string, string[]>()
|
||||
const layerByNodeId = new Map<string, number>()
|
||||
|
||||
for (const node of nodes) {
|
||||
inDegree.set(node.id, 0)
|
||||
adjacency.set(node.id, [])
|
||||
parentsByNodeId.set(node.id, [])
|
||||
layerByNodeId.set(node.id, 0)
|
||||
}
|
||||
|
||||
for (const edge of edges) {
|
||||
if (!nodeById.has(edge.source) || !nodeById.has(edge.target)) continue
|
||||
adjacency.set(edge.source, [...(adjacency.get(edge.source) ?? []), edge.target])
|
||||
parentsByNodeId.set(edge.target, [...(parentsByNodeId.get(edge.target) ?? []), edge.source])
|
||||
inDegree.set(edge.target, (inDegree.get(edge.target) ?? 0) + 1)
|
||||
}
|
||||
|
||||
@@ -561,7 +1129,62 @@ export function applyAutoLayout(nodes: Node[], edges: Edge[]) {
|
||||
layers.set(layer, [...(layers.get(layer) ?? []), node])
|
||||
}
|
||||
|
||||
const positioned = new Map<string, Node>()
|
||||
const sortedLayers = [...layers.entries()].sort(([a], [b]) => a - b)
|
||||
|
||||
for (const [layer, layerNodes] of sortedLayers) {
|
||||
const sortedNodes = [...layerNodes].sort((a, b) => {
|
||||
const aParents = parentsByNodeId.get(a.id) ?? []
|
||||
const bParents = parentsByNodeId.get(b.id) ?? []
|
||||
const averageParentY = (parentIds: string[], fallbackNode: Node) => {
|
||||
if (parentIds.length === 0) return fallbackNode.position.y
|
||||
const total = parentIds.reduce((sum, parentId) => {
|
||||
const parentNode = positioned.get(parentId) ?? nodeById.get(parentId)
|
||||
return sum + (parentNode?.position.y ?? fallbackNode.position.y)
|
||||
}, 0)
|
||||
return total / parentIds.length
|
||||
}
|
||||
|
||||
return (
|
||||
averageParentY(aParents, a) - averageParentY(bParents, b) ||
|
||||
a.position.y - b.position.y ||
|
||||
a.position.x - b.position.x
|
||||
)
|
||||
})
|
||||
|
||||
let nextY = WORKFLOW_LAYOUT_PADDING_Y
|
||||
for (const node of sortedNodes) {
|
||||
const parentIds = parentsByNodeId.get(node.id) ?? []
|
||||
const parentCenterY =
|
||||
parentIds.length === 0
|
||||
? WORKFLOW_LAYOUT_PADDING_Y + WORKFLOW_NODE_MIN_HEIGHT / 2
|
||||
: parentIds.reduce((sum, parentId) => {
|
||||
const parentNode = positioned.get(parentId) ?? nodeById.get(parentId)
|
||||
const parentY = parentNode?.position.y ?? WORKFLOW_LAYOUT_PADDING_Y
|
||||
return sum + parentY + WORKFLOW_NODE_MIN_HEIGHT / 2
|
||||
}, 0) / parentIds.length
|
||||
|
||||
const desiredTop = Math.max(
|
||||
WORKFLOW_LAYOUT_PADDING_Y,
|
||||
Math.round(parentCenterY - WORKFLOW_NODE_MIN_HEIGHT / 2),
|
||||
)
|
||||
const resolvedTop = Math.max(desiredTop, nextY)
|
||||
|
||||
positioned.set(node.id, {
|
||||
...node,
|
||||
position: {
|
||||
x: WORKFLOW_LAYOUT_PADDING_X + layer * horizontalSpacing,
|
||||
y: resolvedTop,
|
||||
},
|
||||
})
|
||||
nextY = resolvedTop + verticalSpacing
|
||||
}
|
||||
}
|
||||
|
||||
return nodes.map(node => {
|
||||
const laidOutNode = positioned.get(node.id)
|
||||
if (laidOutNode) return laidOutNode
|
||||
|
||||
const layer = layerByNodeId.get(node.id) ?? 0
|
||||
const layerNodes = [...(layers.get(layer) ?? [])].sort((a, b) => {
|
||||
const aLabel = ((a.data as WorkflowCanvasNodeData | undefined)?.label as string | undefined) ?? a.id
|
||||
@@ -573,8 +1196,8 @@ export function applyAutoLayout(nodes: Node[], edges: Edge[]) {
|
||||
return {
|
||||
...node,
|
||||
position: {
|
||||
x: PADDING_X + layer * HORIZONTAL_SPACING,
|
||||
y: PADDING_Y + Math.max(index, 0) * VERTICAL_SPACING,
|
||||
x: WORKFLOW_LAYOUT_PADDING_X + layer * horizontalSpacing,
|
||||
y: WORKFLOW_LAYOUT_PADDING_Y + Math.max(index, 0) * verticalSpacing,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import type { Edge, Node } from '@xyflow/react'
|
||||
|
||||
import type { WorkflowNodeDefinition, WorkflowParams } from '../../api/workflows'
|
||||
import { inferNodeLabel, inferNodeType, normalizeWorkflowParams, type WorkflowCanvasNodeData } from './workflowGraphDraft'
|
||||
import {
|
||||
AUTHORING_STAGE_LABELS,
|
||||
type WorkflowAuthoringStage,
|
||||
type WorkflowGraphFamily,
|
||||
} from './workflowNodeLibrary'
|
||||
|
||||
export type WorkflowModuleBundleId =
|
||||
| 'cad_intake_core'
|
||||
| 'still_render_core'
|
||||
| 'output_publish_notify'
|
||||
|
||||
export type WorkflowModuleBundleDefinition = {
|
||||
id: WorkflowModuleBundleId
|
||||
label: string
|
||||
shortLabel: string
|
||||
description: string
|
||||
family: Exclude<WorkflowGraphFamily, 'mixed'>
|
||||
stepIds: string[]
|
||||
stage: string
|
||||
stageId: WorkflowAuthoringStage
|
||||
}
|
||||
|
||||
type WorkflowModuleBundleInsertionResult =
|
||||
| {
|
||||
ok: true
|
||||
bundle: WorkflowModuleBundleDefinition
|
||||
nodes: Node[]
|
||||
edges: Edge[]
|
||||
}
|
||||
| {
|
||||
ok: false
|
||||
reason: string
|
||||
}
|
||||
|
||||
const WORKFLOW_MODULE_BUNDLE_REGISTRY: WorkflowModuleBundleDefinition[] = [
|
||||
{
|
||||
id: 'cad_intake_core',
|
||||
label: 'CAD Intake Core',
|
||||
shortLabel: 'CAD Intake',
|
||||
description: 'Resolve STEP, extract CAD structure, generate GLB, cache STL, and publish the preview thumbnail.',
|
||||
family: 'cad_file',
|
||||
stepIds: ['resolve_step_path', 'occ_object_extract', 'occ_glb_export', 'stl_cache_generate', 'thumbnail_save'],
|
||||
stage: AUTHORING_STAGE_LABELS.cad_intake,
|
||||
stageId: 'cad_intake',
|
||||
},
|
||||
{
|
||||
id: 'still_render_core',
|
||||
label: 'Still Render Core',
|
||||
shortLabel: 'Still Render',
|
||||
description: 'Prepare order-line context, resolve template and materials, compute geometry, and run the still render.',
|
||||
family: 'order_line',
|
||||
stepIds: ['order_line_setup', 'resolve_template', 'auto_populate_materials', 'glb_bbox', 'material_map_resolve', 'blender_still'],
|
||||
stage: AUTHORING_STAGE_LABELS.render,
|
||||
stageId: 'render',
|
||||
},
|
||||
{
|
||||
id: 'output_publish_notify',
|
||||
label: 'Publish And Notify',
|
||||
shortLabel: 'Publish',
|
||||
description: 'Persist the rendered output and emit downstream completion signals.',
|
||||
family: 'order_line',
|
||||
stepIds: ['output_save', 'notify'],
|
||||
stage: AUTHORING_STAGE_LABELS.publish,
|
||||
stageId: 'publish',
|
||||
},
|
||||
]
|
||||
|
||||
function buildNodeData(
|
||||
step: string,
|
||||
params: WorkflowParams = {},
|
||||
definition?: WorkflowNodeDefinition,
|
||||
): WorkflowCanvasNodeData {
|
||||
return {
|
||||
label: definition?.label ?? inferNodeLabel(step),
|
||||
params: normalizeWorkflowParams(params),
|
||||
step,
|
||||
description: definition?.description,
|
||||
icon: definition?.icon,
|
||||
category: definition?.category,
|
||||
}
|
||||
}
|
||||
|
||||
export function getWorkflowModuleBundle(bundleId: WorkflowModuleBundleId) {
|
||||
return WORKFLOW_MODULE_BUNDLE_REGISTRY.find(bundle => bundle.id === bundleId) ?? null
|
||||
}
|
||||
|
||||
export function getWorkflowModuleBundles(
|
||||
nodeDefinitions: WorkflowNodeDefinition[],
|
||||
graphFamily: WorkflowGraphFamily,
|
||||
): WorkflowModuleBundleDefinition[] {
|
||||
const availableSteps = new Set(nodeDefinitions.map(definition => definition.step))
|
||||
|
||||
return WORKFLOW_MODULE_BUNDLE_REGISTRY.filter(bundle => {
|
||||
if (graphFamily !== 'mixed' && bundle.family !== graphFamily) return false
|
||||
return bundle.stepIds.every(step => availableSteps.has(step))
|
||||
})
|
||||
}
|
||||
|
||||
export function createWorkflowModuleBundleInsertion(args: {
|
||||
bundleId: WorkflowModuleBundleId
|
||||
graphFamily: WorkflowGraphFamily
|
||||
nodeDefinitionsByStep: Record<string, WorkflowNodeDefinition>
|
||||
existingNodes: Node[]
|
||||
preferredPosition?: { x: number; y: number }
|
||||
}): WorkflowModuleBundleInsertionResult {
|
||||
const { bundleId, graphFamily, nodeDefinitionsByStep, existingNodes, preferredPosition } = args
|
||||
const bundle = getWorkflowModuleBundle(bundleId)
|
||||
|
||||
if (!bundle) {
|
||||
return { ok: false, reason: 'Unknown workflow module.' }
|
||||
}
|
||||
|
||||
if (graphFamily !== 'mixed' && bundle.family !== graphFamily) {
|
||||
return { ok: false, reason: `${bundle.label} does not belong to the active authoring family.` }
|
||||
}
|
||||
|
||||
const missingStep = bundle.stepIds.find(step => !nodeDefinitionsByStep[step])
|
||||
if (missingStep) {
|
||||
return { ok: false, reason: `Workflow module is missing definition for ${missingStep}.` }
|
||||
}
|
||||
|
||||
const anchorX = preferredPosition?.x ?? (existingNodes.length > 0 ? Math.max(...existingNodes.map(node => node.position.x)) + 220 : 120)
|
||||
const anchorY = preferredPosition?.y ?? (existingNodes.length > 0 ? Math.max(...existingNodes.map(node => node.position.y)) + 60 : 140)
|
||||
const timestamp = Date.now()
|
||||
const spacingX = 220
|
||||
|
||||
const nodes = bundle.stepIds.map((step, index) => {
|
||||
const definition = nodeDefinitionsByStep[step]
|
||||
return {
|
||||
id: `${bundle.id}_${step}_${timestamp}_${index}`,
|
||||
type: definition?.node_type ?? inferNodeType(step),
|
||||
position: {
|
||||
x: anchorX + index * spacingX,
|
||||
y: anchorY,
|
||||
},
|
||||
data: buildNodeData(step, definition?.defaults ?? {}, definition),
|
||||
} satisfies Node
|
||||
})
|
||||
|
||||
const edges = nodes.slice(1).map((node, index) => ({
|
||||
id: `${nodes[index].id}->${node.id}`,
|
||||
source: nodes[index].id,
|
||||
target: node.id,
|
||||
}) satisfies Edge)
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
bundle,
|
||||
nodes,
|
||||
edges,
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,17 @@
|
||||
import type { StepCategory, WorkflowNodeDefinition, WorkflowNodeFamily } from '../../api/workflows'
|
||||
import {
|
||||
AUTHORING_STAGE_ORDER,
|
||||
compareNodeDefinitions,
|
||||
getDefinitionAuthoringStage,
|
||||
getDefinitionFamily,
|
||||
getDefinitionModuleLabel,
|
||||
getDefinitionModuleNamespace,
|
||||
getDefinitionSearchText,
|
||||
getPrimaryLibraryGroup,
|
||||
getDefinitionModuleNamespace,
|
||||
getDefinitionModuleLabel,
|
||||
isDefinitionAllowedForGraphFamily,
|
||||
matchesNodeKindFilter,
|
||||
NODE_CATEGORY_ORDER,
|
||||
type WorkflowAuthoringStage,
|
||||
type WorkflowGraphFamily,
|
||||
type WorkflowNodeFamilyFilter,
|
||||
type WorkflowNodeKindFilter,
|
||||
@@ -30,23 +33,92 @@ export type WorkflowNodeCatalogCategorySection = {
|
||||
export type WorkflowNodeCatalogModuleSection = {
|
||||
namespace: string
|
||||
label: string
|
||||
stage: WorkflowAuthoringStage
|
||||
definitions: WorkflowNodeDefinition[]
|
||||
categories: WorkflowNodeCatalogCategorySection[]
|
||||
familyCounts: Record<WorkflowNodeFamily, number>
|
||||
runtimeCounts: Record<WorkflowNodeLibraryGroup, number>
|
||||
}
|
||||
|
||||
export type WorkflowNodeCatalogGroupSection = {
|
||||
group: WorkflowNodeLibraryGroup
|
||||
export type WorkflowNodeCatalogStageSection = {
|
||||
stage: WorkflowAuthoringStage
|
||||
definitions: WorkflowNodeDefinition[]
|
||||
modules: WorkflowNodeCatalogModuleSection[]
|
||||
runtimeCounts: Record<WorkflowNodeLibraryGroup, number>
|
||||
}
|
||||
|
||||
export type WorkflowNodeCatalogModuleStageSection = {
|
||||
stage: WorkflowAuthoringStage
|
||||
definitions: WorkflowNodeDefinition[]
|
||||
categories: WorkflowNodeCatalogCategorySection[]
|
||||
}
|
||||
|
||||
export type WorkflowNodeCatalogFamilyModuleSection = {
|
||||
namespace: string
|
||||
label: string
|
||||
definitions: WorkflowNodeDefinition[]
|
||||
stages: WorkflowAuthoringStage[]
|
||||
stageSections: WorkflowNodeCatalogModuleStageSection[]
|
||||
runtimeCounts: Record<WorkflowNodeLibraryGroup, number>
|
||||
}
|
||||
|
||||
export type WorkflowNodeCatalogFamilySection = {
|
||||
family: WorkflowNodeFamily
|
||||
definitions: WorkflowNodeDefinition[]
|
||||
modules: WorkflowNodeCatalogFamilyModuleSection[]
|
||||
stageSections: WorkflowNodeCatalogStageSection[]
|
||||
runtimeCounts: Record<WorkflowNodeLibraryGroup, number>
|
||||
}
|
||||
|
||||
export type WorkflowNodeCatalogModuleFilter = {
|
||||
namespace: string
|
||||
label: string
|
||||
count: number
|
||||
stages: WorkflowAuthoringStage[]
|
||||
}
|
||||
|
||||
export type WorkflowNodeCatalogModel = {
|
||||
definitions: WorkflowNodeDefinition[]
|
||||
moduleFilters: WorkflowNodeCatalogModuleFilter[]
|
||||
familySections: WorkflowNodeCatalogFamilySection[]
|
||||
stageSections: WorkflowNodeCatalogStageSection[]
|
||||
runtimeCounts: Record<WorkflowNodeLibraryGroup, number>
|
||||
}
|
||||
|
||||
function buildModuleSection(
|
||||
stage: WorkflowAuthoringStage,
|
||||
moduleDefinitions: WorkflowNodeDefinition[],
|
||||
): WorkflowNodeCatalogModuleSection {
|
||||
const definitions = [...moduleDefinitions].sort(compareNodeDefinitions)
|
||||
|
||||
return {
|
||||
namespace: getDefinitionModuleNamespace(definitions[0]),
|
||||
label: getDefinitionModuleLabel(definitions[0]),
|
||||
stage,
|
||||
definitions,
|
||||
categories: NODE_CATEGORY_ORDER.map(category => ({
|
||||
category,
|
||||
definitions: definitions.filter(definition => definition.category === category),
|
||||
})).filter(section => section.definitions.length > 0),
|
||||
familyCounts: {
|
||||
cad_file: definitions.filter(definition => getDefinitionFamily(definition) === 'cad_file').length,
|
||||
shared: definitions.filter(definition => getDefinitionFamily(definition) === 'shared').length,
|
||||
order_line: definitions.filter(definition => getDefinitionFamily(definition) === 'order_line').length,
|
||||
},
|
||||
runtimeCounts: {
|
||||
legacy: definitions.filter(definition => getPrimaryLibraryGroup(definition) === 'legacy').length,
|
||||
bridge: definitions.filter(definition => getPrimaryLibraryGroup(definition) === 'bridge').length,
|
||||
graph: definitions.filter(definition => getPrimaryLibraryGroup(definition) === 'graph').length,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function getAvailableFamilyFilters(
|
||||
graphFamily: WorkflowGraphFamily,
|
||||
): WorkflowNodeFamilyFilter[] {
|
||||
return graphFamily === 'mixed'
|
||||
? ['all', 'cad_file', 'order_line']
|
||||
: [graphFamily]
|
||||
? ['all', 'cad_file', 'shared', 'order_line']
|
||||
: [graphFamily, 'shared']
|
||||
}
|
||||
|
||||
export function filterWorkflowNodeDefinitions(
|
||||
@@ -62,7 +134,12 @@ export function filterWorkflowNodeDefinitions(
|
||||
|
||||
return definitions
|
||||
.filter(definition => isDefinitionAllowedForGraphFamily(definition, graphFamily))
|
||||
.filter(definition => familyFilter === 'all' || getDefinitionFamily(definition) === familyFilter)
|
||||
.filter(definition => {
|
||||
if (familyFilter === 'all') return true
|
||||
const family = getDefinitionFamily(definition)
|
||||
if (familyFilter === 'shared') return family === 'shared'
|
||||
return family === familyFilter || family === 'shared'
|
||||
})
|
||||
.filter(definition => matchesNodeKindFilter(definition, kindFilter))
|
||||
.filter(definition => !normalizedQuery || getDefinitionSearchText(definition).includes(normalizedQuery))
|
||||
.sort(compareNodeDefinitions)
|
||||
@@ -70,48 +147,173 @@ export function filterWorkflowNodeDefinitions(
|
||||
|
||||
export function buildWorkflowNodeCatalog(
|
||||
definitions: WorkflowNodeDefinition[],
|
||||
): WorkflowNodeCatalogGroupSection[] {
|
||||
const groupEntries = new Map<
|
||||
WorkflowNodeLibraryGroup,
|
||||
Map<string, WorkflowNodeDefinition[]>
|
||||
>()
|
||||
): WorkflowNodeCatalogStageSection[] {
|
||||
const stageEntries = new Map<WorkflowAuthoringStage, Map<string, WorkflowNodeDefinition[]>>()
|
||||
|
||||
for (const definition of definitions) {
|
||||
const group = getPrimaryLibraryGroup(definition)
|
||||
const stage = getDefinitionAuthoringStage(definition)
|
||||
const namespace = getDefinitionModuleNamespace(definition)
|
||||
const modules = groupEntries.get(group) ?? new Map<string, WorkflowNodeDefinition[]>()
|
||||
const modules = stageEntries.get(stage) ?? new Map<string, WorkflowNodeDefinition[]>()
|
||||
modules.set(namespace, [...(modules.get(namespace) ?? []), definition])
|
||||
groupEntries.set(group, modules)
|
||||
stageEntries.set(stage, modules)
|
||||
}
|
||||
|
||||
return (['legacy', 'bridge', 'graph'] as WorkflowNodeLibraryGroup[])
|
||||
.map(group => {
|
||||
const moduleEntries = groupEntries.get(group) ?? new Map<string, WorkflowNodeDefinition[]>()
|
||||
const modules = Array.from(moduleEntries.entries())
|
||||
.map(([namespace, moduleDefinitions]) => {
|
||||
const definitionsForModule = [...moduleDefinitions].sort(compareNodeDefinitions)
|
||||
return {
|
||||
namespace,
|
||||
label: getDefinitionModuleLabel(definitionsForModule[0]),
|
||||
definitions: definitionsForModule,
|
||||
categories: NODE_CATEGORY_ORDER.map(category => ({
|
||||
category,
|
||||
definitions: definitionsForModule.filter(definition => definition.category === category),
|
||||
})).filter(section => section.definitions.length > 0),
|
||||
familyCounts: {
|
||||
cad_file: definitionsForModule.filter(definition => getDefinitionFamily(definition) === 'cad_file').length,
|
||||
order_line: definitionsForModule.filter(definition => getDefinitionFamily(definition) === 'order_line').length,
|
||||
},
|
||||
}
|
||||
})
|
||||
return AUTHORING_STAGE_ORDER
|
||||
.map(stage => {
|
||||
const moduleEntries = stageEntries.get(stage) ?? new Map<string, WorkflowNodeDefinition[]>()
|
||||
const modules = Array.from(moduleEntries.values())
|
||||
.map(moduleDefinitions => buildModuleSection(stage, moduleDefinitions))
|
||||
.sort((a, b) => a.label.localeCompare(b.label))
|
||||
|
||||
const groupDefinitions = modules.flatMap(module => module.definitions)
|
||||
const stageDefinitions = modules.flatMap(module => module.definitions)
|
||||
return {
|
||||
group,
|
||||
definitions: groupDefinitions,
|
||||
stage,
|
||||
definitions: stageDefinitions,
|
||||
modules,
|
||||
runtimeCounts: {
|
||||
legacy: stageDefinitions.filter(definition => getPrimaryLibraryGroup(definition) === 'legacy').length,
|
||||
bridge: stageDefinitions.filter(definition => getPrimaryLibraryGroup(definition) === 'bridge').length,
|
||||
graph: stageDefinitions.filter(definition => getPrimaryLibraryGroup(definition) === 'graph').length,
|
||||
},
|
||||
}
|
||||
})
|
||||
.filter(section => section.definitions.length > 0)
|
||||
}
|
||||
|
||||
function buildModuleStageSection(
|
||||
stage: WorkflowAuthoringStage,
|
||||
definitions: WorkflowNodeDefinition[],
|
||||
): WorkflowNodeCatalogModuleStageSection {
|
||||
const sortedDefinitions = [...definitions].sort(compareNodeDefinitions)
|
||||
|
||||
return {
|
||||
stage,
|
||||
definitions: sortedDefinitions,
|
||||
categories: NODE_CATEGORY_ORDER.map(category => ({
|
||||
category,
|
||||
definitions: sortedDefinitions.filter(definition => definition.category === category),
|
||||
})).filter(section => section.definitions.length > 0),
|
||||
}
|
||||
}
|
||||
|
||||
function buildFamilyModuleSections(
|
||||
definitions: WorkflowNodeDefinition[],
|
||||
): WorkflowNodeCatalogFamilyModuleSection[] {
|
||||
const moduleEntries = new Map<
|
||||
string,
|
||||
{
|
||||
label: string
|
||||
stages: Map<WorkflowAuthoringStage, WorkflowNodeDefinition[]>
|
||||
}
|
||||
>()
|
||||
|
||||
for (const definition of definitions) {
|
||||
const namespace = getDefinitionModuleNamespace(definition)
|
||||
const stage = getDefinitionAuthoringStage(definition)
|
||||
const existing = moduleEntries.get(namespace) ?? {
|
||||
label: getDefinitionModuleLabel(definition),
|
||||
stages: new Map<WorkflowAuthoringStage, WorkflowNodeDefinition[]>(),
|
||||
}
|
||||
existing.stages.set(stage, [...(existing.stages.get(stage) ?? []), definition])
|
||||
moduleEntries.set(namespace, existing)
|
||||
}
|
||||
|
||||
return Array.from(moduleEntries.entries())
|
||||
.map(([namespace, entry]) => {
|
||||
const stageSections = AUTHORING_STAGE_ORDER
|
||||
.map(stage => {
|
||||
const stageDefinitions = entry.stages.get(stage) ?? []
|
||||
if (stageDefinitions.length === 0) return null
|
||||
return buildModuleStageSection(stage, stageDefinitions)
|
||||
})
|
||||
.filter((section): section is WorkflowNodeCatalogModuleStageSection => Boolean(section))
|
||||
const moduleDefinitions = stageSections.flatMap(section => section.definitions)
|
||||
|
||||
return {
|
||||
namespace,
|
||||
label: entry.label,
|
||||
definitions: moduleDefinitions,
|
||||
stages: stageSections.map(section => section.stage),
|
||||
stageSections,
|
||||
runtimeCounts: {
|
||||
legacy: moduleDefinitions.filter(definition => getPrimaryLibraryGroup(definition) === 'legacy').length,
|
||||
bridge: moduleDefinitions.filter(definition => getPrimaryLibraryGroup(definition) === 'bridge').length,
|
||||
graph: moduleDefinitions.filter(definition => getPrimaryLibraryGroup(definition) === 'graph').length,
|
||||
},
|
||||
}
|
||||
})
|
||||
.sort((a, b) => a.label.localeCompare(b.label))
|
||||
}
|
||||
|
||||
function buildFamilySections(
|
||||
definitions: WorkflowNodeDefinition[],
|
||||
): WorkflowNodeCatalogFamilySection[] {
|
||||
const familyEntries = new Map<WorkflowNodeFamily, WorkflowNodeDefinition[]>()
|
||||
|
||||
for (const definition of definitions) {
|
||||
const family = getDefinitionFamily(definition)
|
||||
familyEntries.set(family, [...(familyEntries.get(family) ?? []), definition])
|
||||
}
|
||||
|
||||
return (['cad_file', 'shared', 'order_line'] as WorkflowNodeFamily[])
|
||||
.map(family => {
|
||||
const familyDefinitions = [...(familyEntries.get(family) ?? [])].sort(compareNodeDefinitions)
|
||||
if (familyDefinitions.length === 0) return null
|
||||
|
||||
return {
|
||||
family,
|
||||
definitions: familyDefinitions,
|
||||
modules: buildFamilyModuleSections(familyDefinitions),
|
||||
stageSections: buildWorkflowNodeCatalog(familyDefinitions),
|
||||
runtimeCounts: {
|
||||
legacy: familyDefinitions.filter(definition => getPrimaryLibraryGroup(definition) === 'legacy').length,
|
||||
bridge: familyDefinitions.filter(definition => getPrimaryLibraryGroup(definition) === 'bridge').length,
|
||||
graph: familyDefinitions.filter(definition => getPrimaryLibraryGroup(definition) === 'graph').length,
|
||||
},
|
||||
}
|
||||
})
|
||||
.filter((section): section is WorkflowNodeCatalogFamilySection => Boolean(section))
|
||||
}
|
||||
|
||||
export function buildWorkflowNodeCatalogModel(
|
||||
definitions: WorkflowNodeDefinition[],
|
||||
): WorkflowNodeCatalogModel {
|
||||
const stageSections = buildWorkflowNodeCatalog(definitions)
|
||||
const familySections = buildFamilySections(definitions)
|
||||
const moduleFilterEntries = new Map<string, WorkflowNodeCatalogModuleFilter>()
|
||||
|
||||
for (const section of stageSections) {
|
||||
for (const module of section.modules) {
|
||||
const existing = moduleFilterEntries.get(module.namespace)
|
||||
if (existing) {
|
||||
existing.count += module.definitions.length
|
||||
if (!existing.stages.includes(section.stage)) {
|
||||
existing.stages.push(section.stage)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
moduleFilterEntries.set(module.namespace, {
|
||||
namespace: module.namespace,
|
||||
label: module.label,
|
||||
count: module.definitions.length,
|
||||
stages: [section.stage],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const moduleFilters = Array.from(moduleFilterEntries.values())
|
||||
.sort((a, b) => a.label.localeCompare(b.label))
|
||||
|
||||
return {
|
||||
definitions,
|
||||
moduleFilters,
|
||||
familySections,
|
||||
stageSections,
|
||||
runtimeCounts: {
|
||||
legacy: definitions.filter(definition => getPrimaryLibraryGroup(definition) === 'legacy').length,
|
||||
bridge: definitions.filter(definition => getPrimaryLibraryGroup(definition) === 'bridge').length,
|
||||
graph: definitions.filter(definition => getPrimaryLibraryGroup(definition) === 'graph').length,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import type { StepCategory, WorkflowNodeDefinition, WorkflowNodeFamily } from '../../api/workflows'
|
||||
|
||||
export type WorkflowNodeFamilyFilter = 'all' | WorkflowNodeFamily
|
||||
export type WorkflowGraphFamily = WorkflowNodeFamily | 'mixed'
|
||||
export type WorkflowGraphFamily = Exclude<WorkflowNodeFamily, 'shared'> | 'mixed'
|
||||
export type WorkflowNodeKindFilter = 'all' | 'legacy' | 'bridge' | 'graph'
|
||||
export type WorkflowNodeLibraryGroup = 'legacy' | 'bridge' | 'graph'
|
||||
export type WorkflowAuthoringStage =
|
||||
| 'cad_intake'
|
||||
| 'scene_prep'
|
||||
| 'materials'
|
||||
| 'render'
|
||||
| 'publish'
|
||||
| 'orchestration'
|
||||
|
||||
export const CATEGORY_LABELS: Record<StepCategory, string> = {
|
||||
input: 'Input',
|
||||
@@ -24,6 +31,7 @@ export const NODE_CATEGORY_ORDER: StepCategory[] = ['input', 'processing', 'rend
|
||||
export const FAMILY_FILTER_LABELS: Record<WorkflowNodeFamilyFilter, string> = {
|
||||
all: 'All Nodes',
|
||||
cad_file: 'CAD Intake',
|
||||
shared: 'Shared',
|
||||
order_line: 'Order Rendering',
|
||||
}
|
||||
|
||||
@@ -52,13 +60,51 @@ export const NODE_LIBRARY_GROUP_DESCRIPTIONS: Record<WorkflowNodeLibraryGroup, s
|
||||
graph: 'Native graph runtime nodes for the non-legacy editor flow.',
|
||||
}
|
||||
|
||||
export const AUTHORING_STAGE_ORDER: WorkflowAuthoringStage[] = [
|
||||
'cad_intake',
|
||||
'scene_prep',
|
||||
'materials',
|
||||
'render',
|
||||
'publish',
|
||||
'orchestration',
|
||||
]
|
||||
|
||||
export const AUTHORING_STAGE_LABELS: Record<WorkflowAuthoringStage, string> = {
|
||||
cad_intake: 'CAD Intake',
|
||||
scene_prep: 'Scene Prep',
|
||||
materials: 'Materials',
|
||||
render: 'Render',
|
||||
publish: 'Publish',
|
||||
orchestration: 'Orchestration',
|
||||
}
|
||||
|
||||
export const AUTHORING_STAGE_DESCRIPTIONS: Record<WorkflowAuthoringStage, string> = {
|
||||
cad_intake: 'Import CAD sources, extract geometry, and prepare downstream preview assets.',
|
||||
scene_prep: 'Resolve context, templates, geometry metadata, and upstream render state.',
|
||||
materials: 'Map, normalize, or override materials before render execution.',
|
||||
render: 'Generate stills, thumbnails, or other rendered artifacts.',
|
||||
publish: 'Persist outputs and emit downstream completion signals.',
|
||||
orchestration: 'Support glue, control flow, or utility nodes that do not belong to a single production stage.',
|
||||
}
|
||||
|
||||
export const AUTHORING_STAGE_STYLES: Record<WorkflowAuthoringStage, string> = {
|
||||
cad_intake: 'bg-sky-100 text-sky-700 dark:bg-sky-900/40 dark:text-sky-300',
|
||||
scene_prep: 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900/40 dark:text-indigo-300',
|
||||
materials: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300',
|
||||
render: 'bg-rose-100 text-rose-700 dark:bg-rose-900/40 dark:text-rose-300',
|
||||
publish: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300',
|
||||
orchestration: 'bg-slate-100 text-slate-700 dark:bg-slate-900/40 dark:text-slate-300',
|
||||
}
|
||||
|
||||
export const FAMILY_FILTER_DESCRIPTIONS: Record<WorkflowNodeFamily, string> = {
|
||||
cad_file: 'Start with a CAD file context and produce previews, caches, or derived assets.',
|
||||
shared: 'Reusable nodes that can be dropped into either CAD-intake or order-rendering workflows.',
|
||||
order_line: 'Start with an order line context and run production rendering/output steps.',
|
||||
}
|
||||
|
||||
export const FAMILY_FILTER_STYLES: Record<WorkflowNodeFamily, string> = {
|
||||
cad_file: 'bg-sky-100 text-sky-700 dark:bg-sky-900/40 dark:text-sky-300',
|
||||
shared: 'bg-violet-100 text-violet-700 dark:bg-violet-900/40 dark:text-violet-300',
|
||||
order_line: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300',
|
||||
}
|
||||
|
||||
@@ -86,8 +132,16 @@ const CAD_FILE_NODE_STEPS = new Set([
|
||||
'thumbnail_save',
|
||||
])
|
||||
|
||||
const SHARED_NODE_STEPS = new Set([
|
||||
'glb_bbox',
|
||||
])
|
||||
|
||||
export function getNodeFamily(step: string, nodeDefinitionsByStep?: WorkflowNodeDefinitionMap): WorkflowNodeFamily {
|
||||
return nodeDefinitionsByStep?.[step]?.family ?? (CAD_FILE_NODE_STEPS.has(step) ? 'cad_file' : 'order_line')
|
||||
if (nodeDefinitionsByStep?.[step]?.family) {
|
||||
return nodeDefinitionsByStep[step].family
|
||||
}
|
||||
if (SHARED_NODE_STEPS.has(step)) return 'shared'
|
||||
return CAD_FILE_NODE_STEPS.has(step) ? 'cad_file' : 'order_line'
|
||||
}
|
||||
|
||||
export function getDefinitionFamily(
|
||||
@@ -103,7 +157,8 @@ export function isDefinitionAllowedForGraphFamily(
|
||||
nodeDefinitionsByStep?: WorkflowNodeDefinitionMap,
|
||||
): boolean {
|
||||
if (graphFamily === 'mixed') return true
|
||||
return getDefinitionFamily(definition, nodeDefinitionsByStep) === graphFamily
|
||||
const family = getDefinitionFamily(definition, nodeDefinitionsByStep)
|
||||
return family === 'shared' || family === graphFamily
|
||||
}
|
||||
|
||||
export function compareNodeDefinitions(a: WorkflowNodeDefinition, b: WorkflowNodeDefinition) {
|
||||
@@ -125,12 +180,47 @@ export function getDefinitionModuleLabel(definition: WorkflowNodeDefinition): st
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
export function getDefinitionAuthoringStage(definition: WorkflowNodeDefinition): WorkflowAuthoringStage {
|
||||
const moduleKey = definition.module_key.toLowerCase()
|
||||
|
||||
if (moduleKey.startsWith('cad.')) {
|
||||
if (definition.category === 'rendering') return 'render'
|
||||
if (definition.category === 'output') return 'publish'
|
||||
return 'cad_intake'
|
||||
}
|
||||
|
||||
if (
|
||||
moduleKey.startsWith('order_line.')
|
||||
|| moduleKey.startsWith('geometry.')
|
||||
|| moduleKey.startsWith('context.')
|
||||
) {
|
||||
return 'scene_prep'
|
||||
}
|
||||
|
||||
if (moduleKey.startsWith('materials.')) {
|
||||
return 'materials'
|
||||
}
|
||||
|
||||
if (moduleKey.startsWith('render.') || moduleKey.startsWith('rendering.')) {
|
||||
return 'render'
|
||||
}
|
||||
|
||||
if (moduleKey.startsWith('media.') || moduleKey.startsWith('notifications.')) {
|
||||
return 'publish'
|
||||
}
|
||||
|
||||
if (definition.category === 'rendering') return 'render'
|
||||
if (definition.category === 'output') return 'publish'
|
||||
if (definition.category === 'input' || definition.category === 'processing') return 'orchestration'
|
||||
return 'orchestration'
|
||||
}
|
||||
|
||||
export function groupDefinitionsForStepSelect(definitions: WorkflowNodeDefinition[]) {
|
||||
const groups = new Map<string, WorkflowNodeDefinition[]>()
|
||||
|
||||
for (const definition of [...definitions].sort(compareNodeDefinitions)) {
|
||||
const family = getDefinitionFamily(definition)
|
||||
const groupLabel = `${FAMILY_FILTER_LABELS[family]} · ${getDefinitionModuleLabel(definition)} · ${CATEGORY_LABELS[definition.category]}`
|
||||
const groupLabel = `${FAMILY_FILTER_LABELS[family]} · ${AUTHORING_STAGE_LABELS[getDefinitionAuthoringStage(definition)]} · ${getDefinitionModuleLabel(definition)}`
|
||||
groups.set(groupLabel, [...(groups.get(groupLabel) ?? []), definition])
|
||||
}
|
||||
|
||||
@@ -145,6 +235,9 @@ export function groupDefinitionsByFamily(
|
||||
cad_file: definitions
|
||||
.filter(definition => getDefinitionFamily(definition, nodeDefinitionsByStep) === 'cad_file')
|
||||
.sort(compareNodeDefinitions),
|
||||
shared: definitions
|
||||
.filter(definition => getDefinitionFamily(definition, nodeDefinitionsByStep) === 'shared')
|
||||
.sort(compareNodeDefinitions),
|
||||
order_line: definitions
|
||||
.filter(definition => getDefinitionFamily(definition, nodeDefinitionsByStep) === 'order_line')
|
||||
.sort(compareNodeDefinitions),
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { WorkflowCanvasPort } from './workflowGraphDraft'
|
||||
|
||||
const DIRECT_PORT_BADGE_LABELS: Record<string, string> = {
|
||||
bbox: 'Bounding Box',
|
||||
blend_asset: 'Blend Asset',
|
||||
cad_file_ref: 'CAD File Ref',
|
||||
cad_materials: 'CAD Materials',
|
||||
cad_file_record: 'CAD File',
|
||||
glb_preview: 'GLB Preview',
|
||||
glb_reuse_path: 'GLB Reuse Path',
|
||||
material_assignments: 'Material Assignments',
|
||||
material_catalog_updates: 'Catalog Updates',
|
||||
media_asset: 'Media Asset',
|
||||
notification_event: 'Notification Event',
|
||||
order_line_context: 'Order Context',
|
||||
order_line_record: 'Order Line',
|
||||
output_profile: 'Output Profile',
|
||||
render_template: 'Render Template',
|
||||
rendered_frames: 'Rendered Frames',
|
||||
rendered_image: 'Rendered Image',
|
||||
rendered_video: 'Rendered Video',
|
||||
step_path: 'STEP Path',
|
||||
template_inputs: 'Template Inputs',
|
||||
template_path: 'Template Path',
|
||||
usd_render_path: 'USD Render Path',
|
||||
workflow_input_schema: 'Input Schema',
|
||||
workflow_result: 'Workflow Result',
|
||||
}
|
||||
|
||||
const ALTERNATIVE_PORT_BADGE_LABELS: Record<string, string> = {
|
||||
blend_asset: 'Blend Asset',
|
||||
rendered_frames: 'Frames',
|
||||
rendered_image: 'Image',
|
||||
rendered_video: 'Video',
|
||||
workflow_result: 'Workflow Result',
|
||||
}
|
||||
|
||||
function getAlternativeRoleBadgeLabel(role: string): string {
|
||||
return ALTERNATIVE_PORT_BADGE_LABELS[role] ?? DIRECT_PORT_BADGE_LABELS[role] ?? role
|
||||
}
|
||||
|
||||
export function getWorkflowNodePortBadgeLabel(port: WorkflowCanvasPort): string {
|
||||
if (port.kind === 'alternative') {
|
||||
return `Any: ${port.roles.map(getAlternativeRoleBadgeLabel).join(' / ')}`
|
||||
}
|
||||
|
||||
if (port.roles.length === 1) {
|
||||
return DIRECT_PORT_BADGE_LABELS[port.roles[0]] ?? port.label
|
||||
}
|
||||
|
||||
return port.label
|
||||
}
|
||||
|
||||
export function getWorkflowNodePortTitle(port: WorkflowCanvasPort): string {
|
||||
if (port.kind === 'alternative') {
|
||||
return `Accepts any of: ${port.roles.map(role => DIRECT_PORT_BADGE_LABELS[role] ?? role).join(' / ')}`
|
||||
}
|
||||
|
||||
return port.label
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import type { Edge, Node } from '@xyflow/react'
|
||||
|
||||
import {
|
||||
buildWorkflowBlueprintConfig,
|
||||
type WorkflowNodeDefinition,
|
||||
type WorkflowConfig,
|
||||
type WorkflowParams,
|
||||
} from '../../api/workflows'
|
||||
import {
|
||||
inferNodeLabel,
|
||||
inferNodeType,
|
||||
normalizeWorkflowParams,
|
||||
type WorkflowCanvasNodeData,
|
||||
} from './workflowGraphDraft'
|
||||
import type { WorkflowGraphFamily } from './workflowNodeLibrary'
|
||||
|
||||
export type WorkflowReferenceBundleId = 'still_render_reference'
|
||||
| 'cad_intake_reference'
|
||||
|
||||
export type WorkflowReferenceBundleDefinition = {
|
||||
id: WorkflowReferenceBundleId
|
||||
label: string
|
||||
shortLabel: string
|
||||
description: string
|
||||
family: Exclude<WorkflowGraphFamily, 'mixed'>
|
||||
stepIds: string[]
|
||||
stage: string
|
||||
}
|
||||
|
||||
type WorkflowReferenceBundleInsertionResult =
|
||||
| {
|
||||
ok: true
|
||||
bundle: WorkflowReferenceBundleDefinition
|
||||
nodes: Node[]
|
||||
edges: Edge[]
|
||||
}
|
||||
| {
|
||||
ok: false
|
||||
reason: string
|
||||
}
|
||||
|
||||
const WORKFLOW_REFERENCE_BUNDLE_REGISTRY: WorkflowReferenceBundleDefinition[] = [
|
||||
{
|
||||
id: 'cad_intake_reference',
|
||||
label: 'CAD Intake Reference',
|
||||
shortLabel: 'CAD Intake Reference',
|
||||
description: 'Insert the canonical CAD intake path so extraction, geometry export, STL cache generation, and thumbnail publishing stay wired in a known-good order.',
|
||||
family: 'cad_file',
|
||||
stepIds: [
|
||||
'resolve_step_path',
|
||||
'occ_object_extract',
|
||||
'occ_glb_export',
|
||||
'stl_cache_generate',
|
||||
'thumbnail_save',
|
||||
],
|
||||
stage: 'Reference Path',
|
||||
},
|
||||
{
|
||||
id: 'still_render_reference',
|
||||
label: 'Still Render Reference',
|
||||
shortLabel: 'Still Reference',
|
||||
description: 'Insert the complete canonical non-legacy still-render path, including material resolution, render, output, and notification branches.',
|
||||
family: 'order_line',
|
||||
stepIds: [
|
||||
'order_line_setup',
|
||||
'resolve_template',
|
||||
'auto_populate_materials',
|
||||
'glb_bbox',
|
||||
'material_map_resolve',
|
||||
'blender_still',
|
||||
'output_save',
|
||||
'notify',
|
||||
],
|
||||
stage: 'Reference Path',
|
||||
},
|
||||
]
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
function getReferenceTemplate(bundleId: WorkflowReferenceBundleId) {
|
||||
const toTemplate = (config: WorkflowConfig) => ({
|
||||
nodes: config.nodes,
|
||||
edges: config.edges,
|
||||
})
|
||||
|
||||
switch (bundleId) {
|
||||
case 'cad_intake_reference':
|
||||
return toTemplate(buildWorkflowBlueprintConfig('cad_intake'))
|
||||
case 'still_render_reference':
|
||||
return toTemplate(buildWorkflowBlueprintConfig('still_graph_reference'))
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function getWorkflowReferenceBundle(bundleId: WorkflowReferenceBundleId) {
|
||||
return WORKFLOW_REFERENCE_BUNDLE_REGISTRY.find(bundle => bundle.id === bundleId) ?? null
|
||||
}
|
||||
|
||||
export function getWorkflowReferenceBundles(
|
||||
nodeDefinitions: WorkflowNodeDefinition[],
|
||||
graphFamily: WorkflowGraphFamily,
|
||||
): WorkflowReferenceBundleDefinition[] {
|
||||
const availableSteps = new Set(nodeDefinitions.map(definition => definition.step))
|
||||
|
||||
return WORKFLOW_REFERENCE_BUNDLE_REGISTRY.filter(bundle => {
|
||||
if (graphFamily !== 'mixed' && bundle.family !== graphFamily) return false
|
||||
return bundle.stepIds.every(step => availableSteps.has(step))
|
||||
})
|
||||
}
|
||||
|
||||
export function createWorkflowReferenceBundleInsertion(args: {
|
||||
bundleId: WorkflowReferenceBundleId
|
||||
graphFamily: WorkflowGraphFamily
|
||||
nodeDefinitionsByStep: Record<string, WorkflowNodeDefinition>
|
||||
existingNodes: Node[]
|
||||
preferredPosition?: { x: number; y: number }
|
||||
}): WorkflowReferenceBundleInsertionResult {
|
||||
const { bundleId, graphFamily, nodeDefinitionsByStep, existingNodes, preferredPosition } = args
|
||||
const bundle = getWorkflowReferenceBundle(bundleId)
|
||||
|
||||
if (!bundle) {
|
||||
return { ok: false, reason: 'Unknown workflow reference path.' }
|
||||
}
|
||||
|
||||
if (graphFamily !== 'mixed' && bundle.family !== graphFamily) {
|
||||
return { ok: false, reason: `${bundle.label} does not belong to the active authoring family.` }
|
||||
}
|
||||
|
||||
const missingStep = bundle.stepIds.find(step => !nodeDefinitionsByStep[step])
|
||||
if (missingStep) {
|
||||
return { ok: false, reason: `Workflow reference path is missing definition for ${missingStep}.` }
|
||||
}
|
||||
|
||||
const template = getReferenceTemplate(bundleId)
|
||||
if (!template) {
|
||||
return { ok: false, reason: 'Workflow reference path template is unavailable.' }
|
||||
}
|
||||
|
||||
const existingMaxX = existingNodes.length > 0 ? Math.max(...existingNodes.map(node => node.position.x)) : null
|
||||
const existingMaxY = existingNodes.length > 0 ? Math.max(...existingNodes.map(node => node.position.y)) : null
|
||||
const anchorX = preferredPosition?.x ?? (existingMaxX !== null ? existingMaxX + 260 : 120)
|
||||
const anchorY = preferredPosition?.y ?? (existingMaxY !== null ? existingMaxY + 80 : 120)
|
||||
const timestamp = Date.now()
|
||||
|
||||
const templatePositions = template.nodes.map(node => node.ui?.position ?? { x: 0, y: 0 })
|
||||
const minX = Math.min(...templatePositions.map(position => position.x))
|
||||
const minY = Math.min(...templatePositions.map(position => position.y))
|
||||
|
||||
const nodeIdMap = new Map<string, string>()
|
||||
const nodes = template.nodes.map(node => {
|
||||
const definition = nodeDefinitionsByStep[node.step]
|
||||
const templatePosition = node.ui?.position ?? { x: 0, y: 0 }
|
||||
const id = `${bundle.id}_${node.id}_${timestamp}`
|
||||
nodeIdMap.set(node.id, id)
|
||||
|
||||
return {
|
||||
id,
|
||||
type: definition?.node_type ?? node.ui?.type ?? inferNodeType(node.step),
|
||||
position: {
|
||||
x: anchorX + (templatePosition.x - minX),
|
||||
y: anchorY + (templatePosition.y - minY),
|
||||
},
|
||||
data: buildNodeData(
|
||||
node.step,
|
||||
node.params ?? definition?.defaults ?? {},
|
||||
definition,
|
||||
node.ui?.label
|
||||
? {
|
||||
label: node.ui.label,
|
||||
}
|
||||
: undefined,
|
||||
),
|
||||
} satisfies Node
|
||||
})
|
||||
|
||||
const edges = template.edges.map(edge => ({
|
||||
id: `${nodeIdMap.get(edge.from)}->${nodeIdMap.get(edge.to)}`,
|
||||
source: nodeIdMap.get(edge.from) ?? edge.from,
|
||||
target: nodeIdMap.get(edge.to) ?? edge.to,
|
||||
}) satisfies Edge)
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
bundle,
|
||||
nodes,
|
||||
edges,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { WorkflowRolloutSummary } from '../../api/workflows'
|
||||
|
||||
export interface WorkflowRolloutPresentation {
|
||||
badgeLabel: string
|
||||
badgeClassName: string
|
||||
statusLabel: string
|
||||
statusClassName: string
|
||||
summary: string
|
||||
}
|
||||
|
||||
export function getWorkflowRolloutPresentation(
|
||||
rollout: WorkflowRolloutSummary,
|
||||
): WorkflowRolloutPresentation {
|
||||
if (rollout.linked_output_type_count === 0) {
|
||||
return {
|
||||
badgeLabel: 'Unlinked',
|
||||
badgeClassName: 'bg-surface-muted text-content-muted',
|
||||
statusLabel: 'Legacy Only',
|
||||
statusClassName: 'bg-slate-100 text-slate-700',
|
||||
summary: 'No output types are linked to this workflow yet.',
|
||||
}
|
||||
}
|
||||
|
||||
if (rollout.has_blocking_contracts) {
|
||||
return {
|
||||
badgeLabel: 'Contract Blocked',
|
||||
badgeClassName: 'bg-red-100 text-red-700',
|
||||
statusLabel: 'Do Not Promote',
|
||||
statusClassName: 'bg-red-100 text-red-700',
|
||||
summary: 'One or more linked output types are contract-invalid for this workflow.',
|
||||
}
|
||||
}
|
||||
|
||||
const rolloutModes = new Set(rollout.rollout_modes)
|
||||
if (rolloutModes.has('graph')) {
|
||||
return {
|
||||
badgeLabel: rolloutModes.size > 1 ? 'Mixed Rollout' : 'Graph Authoritative',
|
||||
badgeClassName: 'bg-status-success-bg text-status-success-text',
|
||||
statusLabel: rollout.latest_rollout_ready ? 'Ready For Rollout' : 'Production: Graph',
|
||||
statusClassName: rollout.latest_rollout_ready
|
||||
? 'bg-emerald-100 text-emerald-700'
|
||||
: 'bg-green-100 text-green-700',
|
||||
summary:
|
||||
rolloutModes.size > 1
|
||||
? 'Some linked output types are already graph-authoritative while others still hold legacy or shadow.'
|
||||
: 'Linked output types dispatch through the graph runtime with legacy fallback armed.',
|
||||
}
|
||||
}
|
||||
|
||||
if (rolloutModes.has('shadow')) {
|
||||
return {
|
||||
badgeLabel: 'Shadow',
|
||||
badgeClassName: 'bg-status-info-bg text-status-info-text',
|
||||
statusLabel:
|
||||
rollout.latest_rollout_status === 'ready_for_rollout'
|
||||
? 'Ready For Rollout'
|
||||
: 'Legacy Authoritative',
|
||||
statusClassName:
|
||||
rollout.latest_rollout_status === 'ready_for_rollout'
|
||||
? 'bg-emerald-100 text-emerald-700'
|
||||
: 'bg-sky-100 text-sky-700',
|
||||
summary:
|
||||
rollout.latest_rollout_gate_verdict != null
|
||||
? `Latest shadow verdict: ${rollout.latest_rollout_gate_verdict}.`
|
||||
: 'Shadow runs can observe parity while legacy remains authoritative.',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
badgeLabel: 'Legacy Only',
|
||||
badgeClassName: 'bg-surface-muted text-content-muted',
|
||||
statusLabel: 'Production: Legacy',
|
||||
statusClassName: 'bg-slate-100 text-slate-700',
|
||||
summary: 'Linked output types keep this workflow attached for authoring, but production stays on legacy.',
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user