chore: snapshot workflow migration progress
This commit is contained in:
+119
-107
@@ -1,30 +1,40 @@
|
||||
import { Suspense, lazy } from 'react'
|
||||
import { BrowserRouter, Routes, Route, Navigate, useLocation } from 'react-router-dom'
|
||||
import { useAuthStore, isPrivileged as checkIsPrivileged } from './store/auth'
|
||||
import { WebSocketProvider } from './contexts/WebSocketContext'
|
||||
import Layout from './components/layout/Layout'
|
||||
import LoginPage from './pages/Login'
|
||||
import NotFoundPage from './pages/NotFound'
|
||||
import DashboardPage from './pages/Dashboard'
|
||||
import OrdersPage from './pages/Orders'
|
||||
import OrderDetailPage from './pages/OrderDetail'
|
||||
import NewOrderPage from './pages/NewOrder'
|
||||
import UploadPage from './pages/Upload'
|
||||
import AdminPage from './pages/Admin'
|
||||
import CadPreviewPage from './pages/CadPreview'
|
||||
import MaterialsPage from './pages/Materials'
|
||||
import WorkerActivityPage from './pages/WorkerActivity'
|
||||
import ProductLibraryPage from './pages/ProductLibrary'
|
||||
import ProductDetailPage from './pages/ProductDetail'
|
||||
import NewProductOrderPage from './pages/NewProductOrder'
|
||||
import NotificationsPage from './pages/Notifications'
|
||||
import NotificationSettingsPage from './pages/NotificationSettings'
|
||||
import PreferencesPage from './pages/Preferences'
|
||||
import TenantsPage from './pages/Tenants'
|
||||
import WorkflowEditorPage from './pages/WorkflowEditor'
|
||||
import MediaBrowserPage from './pages/MediaBrowser'
|
||||
import BillingPage from './pages/Billing'
|
||||
import WorkerManagementPage from './pages/WorkerManagement'
|
||||
import AssetLibraryPage from './pages/AssetLibrary'
|
||||
|
||||
const LoginPage = lazy(() => import('./pages/Login'))
|
||||
const NotFoundPage = lazy(() => import('./pages/NotFound'))
|
||||
const DashboardPage = lazy(() => import('./pages/Dashboard'))
|
||||
const OrdersPage = lazy(() => import('./pages/Orders'))
|
||||
const OrderDetailPage = lazy(() => import('./pages/OrderDetail'))
|
||||
const NewOrderPage = lazy(() => import('./pages/NewOrder'))
|
||||
const UploadPage = lazy(() => import('./pages/Upload'))
|
||||
const AdminPage = lazy(() => import('./pages/Admin'))
|
||||
const CadPreviewPage = lazy(() => import('./pages/CadPreview'))
|
||||
const MaterialsPage = lazy(() => import('./pages/Materials'))
|
||||
const WorkerActivityPage = lazy(() => import('./pages/WorkerActivity'))
|
||||
const ProductLibraryPage = lazy(() => import('./pages/ProductLibrary'))
|
||||
const ProductDetailPage = lazy(() => import('./pages/ProductDetail'))
|
||||
const NewProductOrderPage = lazy(() => import('./pages/NewProductOrder'))
|
||||
const NotificationsPage = lazy(() => import('./pages/Notifications'))
|
||||
const NotificationSettingsPage = lazy(() => import('./pages/NotificationSettings'))
|
||||
const PreferencesPage = lazy(() => import('./pages/Preferences'))
|
||||
const TenantsPage = lazy(() => import('./pages/Tenants'))
|
||||
const WorkflowEditorPage = lazy(() => import('./pages/WorkflowEditor'))
|
||||
const MediaBrowserPage = lazy(() => import('./pages/MediaBrowser'))
|
||||
const BillingPage = lazy(() => import('./pages/Billing'))
|
||||
const WorkerManagementPage = lazy(() => import('./pages/WorkerManagement'))
|
||||
const AssetLibraryPage = lazy(() => import('./pages/AssetLibrary'))
|
||||
|
||||
function RouteFallback() {
|
||||
return (
|
||||
<div className="flex min-h-[40vh] items-center justify-center text-sm text-content-muted">
|
||||
Loading...
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
const token = useAuthStore((s) => s.token)
|
||||
@@ -42,91 +52,93 @@ function AdminRoute({ children }: { children: React.ReactNode }) {
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
|
||||
<WebSocketProvider>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
>
|
||||
<Route index element={<DashboardPage />} />
|
||||
<Route path="orders" element={<OrdersPage />} />
|
||||
<Route path="orders/new" element={<NewOrderPage />} />
|
||||
<Route path="orders/new/product" element={<NewProductOrderPage />} />
|
||||
<Route path="orders/:id" element={<OrderDetailPage />} />
|
||||
<Route path="upload" element={<UploadPage />} />
|
||||
<Route
|
||||
path="admin"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<AdminPage />
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="tenants"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<TenantsPage />
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="workflows"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<WorkflowEditorPage />
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="materials" element={<MaterialsPage />} />
|
||||
<Route path="activity" element={<WorkerActivityPage />} />
|
||||
<Route path="products" element={<ProductLibraryPage />} />
|
||||
<Route path="products/:id" element={<ProductDetailPage />} />
|
||||
<Route path="notifications" element={<NotificationsPage />} />
|
||||
<Route path="notification-settings" element={<NotificationSettingsPage />} />
|
||||
<Route path="preferences" element={<PreferencesPage />} />
|
||||
<Route path="cad/:id" element={<CadPreviewPage />} />
|
||||
<Route
|
||||
path="media"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<MediaBrowserPage />
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="billing"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<BillingPage />
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="workers"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<WorkerManagementPage />
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="asset-libraries"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<AssetLibraryPage />
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
</Route>
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Routes>
|
||||
<Suspense fallback={<RouteFallback />}>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
>
|
||||
<Route index element={<DashboardPage />} />
|
||||
<Route path="orders" element={<OrdersPage />} />
|
||||
<Route path="orders/new" element={<NewOrderPage />} />
|
||||
<Route path="orders/new/product" element={<NewProductOrderPage />} />
|
||||
<Route path="orders/:id" element={<OrderDetailPage />} />
|
||||
<Route path="upload" element={<UploadPage />} />
|
||||
<Route
|
||||
path="admin"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<AdminPage />
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="tenants"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<TenantsPage />
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="workflows"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<WorkflowEditorPage />
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="materials" element={<MaterialsPage />} />
|
||||
<Route path="activity" element={<WorkerActivityPage />} />
|
||||
<Route path="products" element={<ProductLibraryPage />} />
|
||||
<Route path="products/:id" element={<ProductDetailPage />} />
|
||||
<Route path="notifications" element={<NotificationsPage />} />
|
||||
<Route path="notification-settings" element={<NotificationSettingsPage />} />
|
||||
<Route path="preferences" element={<PreferencesPage />} />
|
||||
<Route path="cad/:id" element={<CadPreviewPage />} />
|
||||
<Route
|
||||
path="media"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<MediaBrowserPage />
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="billing"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<BillingPage />
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="workers"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<WorkerManagementPage />
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="asset-libraries"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<AssetLibraryPage />
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
</Route>
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</WebSocketProvider>
|
||||
</BrowserRouter>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
import { describe, expect, test } from 'vitest'
|
||||
|
||||
import {
|
||||
getCachedOutputTypeContractCatalog,
|
||||
getCompatibleWorkflowsForOutputTypeContract,
|
||||
getOutputTypeInvocationOverrides,
|
||||
getOutputTypeWorkflowContractIssues,
|
||||
getDefaultOutputFormatForArtifactKind,
|
||||
inferArtifactKind,
|
||||
listAllowedInvocationOverrideKeysForArtifactKind,
|
||||
listAllowedOutputFormatsForFamily,
|
||||
type OutputType,
|
||||
} from '../../api/outputTypes'
|
||||
|
||||
describe('output type contract helpers', () => {
|
||||
test('lists family-safe output formats', () => {
|
||||
const contractCatalog = getCachedOutputTypeContractCatalog()
|
||||
|
||||
expect(listAllowedOutputFormatsForFamily('cad_file', contractCatalog)).toEqual([
|
||||
'png',
|
||||
'jpg',
|
||||
'jpeg',
|
||||
'webp',
|
||||
'gltf',
|
||||
'glb',
|
||||
'stl',
|
||||
'obj',
|
||||
'usd',
|
||||
'usdz',
|
||||
])
|
||||
|
||||
expect(listAllowedOutputFormatsForFamily('order_line', contractCatalog)).toEqual([
|
||||
'png',
|
||||
'jpg',
|
||||
'jpeg',
|
||||
'webp',
|
||||
'mp4',
|
||||
'webm',
|
||||
'mov',
|
||||
'blend',
|
||||
])
|
||||
})
|
||||
|
||||
test('infers artifact kinds for family-safe formats', () => {
|
||||
expect(inferArtifactKind('order_line', 'blend', false)).toBe('blend_asset')
|
||||
expect(inferArtifactKind('order_line', 'mp4', true)).toBe('turntable_video')
|
||||
expect(inferArtifactKind('cad_file', 'gltf', false)).toBe('model_export')
|
||||
expect(inferArtifactKind('cad_file', 'png', false)).toBe('thumbnail_image')
|
||||
})
|
||||
|
||||
test('returns defaults that match artifact contracts', () => {
|
||||
const contractCatalog = getCachedOutputTypeContractCatalog()
|
||||
|
||||
expect(getDefaultOutputFormatForArtifactKind('still_image', contractCatalog)).toBe('png')
|
||||
expect(getDefaultOutputFormatForArtifactKind('thumbnail_image', contractCatalog)).toBe('png')
|
||||
expect(getDefaultOutputFormatForArtifactKind('turntable_video', contractCatalog)).toBe('mp4')
|
||||
expect(getDefaultOutputFormatForArtifactKind('model_export', contractCatalog)).toBe('gltf')
|
||||
expect(getDefaultOutputFormatForArtifactKind('blend_asset', contractCatalog)).toBe('blend')
|
||||
})
|
||||
|
||||
test('exposes invocation override keys by artifact contract', () => {
|
||||
const contractCatalog = getCachedOutputTypeContractCatalog()
|
||||
|
||||
expect(listAllowedInvocationOverrideKeysForArtifactKind('turntable_video', contractCatalog)).toEqual([
|
||||
'width',
|
||||
'height',
|
||||
'engine',
|
||||
'samples',
|
||||
'bg_color',
|
||||
'noise_threshold',
|
||||
'denoiser',
|
||||
'denoising_input_passes',
|
||||
'denoising_prefilter',
|
||||
'denoising_quality',
|
||||
'denoising_use_gpu',
|
||||
'frame_count',
|
||||
'fps',
|
||||
'turntable_axis',
|
||||
])
|
||||
expect(listAllowedInvocationOverrideKeysForArtifactKind('blend_asset', contractCatalog)).toEqual([])
|
||||
})
|
||||
|
||||
test('respects backend-authored contract catalog when provided', () => {
|
||||
const contractCatalog = {
|
||||
...getCachedOutputTypeContractCatalog(),
|
||||
allowed_output_formats_by_family: {
|
||||
order_line: ['png', 'heic'],
|
||||
cad_file: ['jpg'],
|
||||
},
|
||||
default_output_format_by_artifact_kind: {
|
||||
...getCachedOutputTypeContractCatalog().default_output_format_by_artifact_kind,
|
||||
still_image: 'heic',
|
||||
},
|
||||
}
|
||||
|
||||
expect(listAllowedOutputFormatsForFamily('order_line', contractCatalog)).toEqual(['png', 'heic'])
|
||||
expect(getDefaultOutputFormatForArtifactKind('still_image', contractCatalog)).toBe('heic')
|
||||
})
|
||||
|
||||
test('exposes parameter ownership boundaries in the contract catalog', () => {
|
||||
const contractCatalog = getCachedOutputTypeContractCatalog()
|
||||
|
||||
expect(contractCatalog.parameter_ownership.output_type_profile_keys).toEqual([
|
||||
'transparent_bg',
|
||||
'cycles_device',
|
||||
'material_override',
|
||||
])
|
||||
expect(contractCatalog.parameter_ownership.template_runtime_keys).toEqual([
|
||||
'target_collection',
|
||||
'lighting_only',
|
||||
'shadow_catcher',
|
||||
'camera_orbit',
|
||||
'template_inputs',
|
||||
])
|
||||
expect(contractCatalog.parameter_ownership.workflow_node_keys_by_step.resolve_template).toContain('target_collection')
|
||||
expect(contractCatalog.parameter_ownership.workflow_node_keys_by_step.blender_still).toContain('material_override')
|
||||
expect(contractCatalog.parameter_ownership.workflow_node_keys_by_step.blender_turntable).toContain('camera_orbit')
|
||||
})
|
||||
|
||||
test('prefers invocation_profile overrides over legacy render settings', () => {
|
||||
const outputType = {
|
||||
id: 'ot-1',
|
||||
name: 'Still',
|
||||
description: null,
|
||||
renderer: 'blender',
|
||||
render_settings: { width: 4096, frame_count: 180 },
|
||||
invocation_overrides: { width: 2048, frame_count: 120 },
|
||||
output_format: 'png',
|
||||
sort_order: 0,
|
||||
compatible_categories: [],
|
||||
render_backend: 'celery',
|
||||
is_animation: false,
|
||||
transparent_bg: false,
|
||||
workflow_family: 'order_line',
|
||||
artifact_kind: 'still_image',
|
||||
cycles_device: null,
|
||||
pricing_tier_id: null,
|
||||
pricing_tier_name: null,
|
||||
price_per_item: null,
|
||||
workflow_definition_id: null,
|
||||
workflow_rollout_mode: 'legacy_only',
|
||||
workflow_name: null,
|
||||
material_override: null,
|
||||
invocation_profile: {
|
||||
renderer: 'blender',
|
||||
render_backend: 'celery',
|
||||
workflow_family: 'order_line',
|
||||
artifact_kind: 'still_image',
|
||||
output_format: 'png',
|
||||
is_animation: false,
|
||||
workflow_definition_id: null,
|
||||
workflow_rollout_mode: 'legacy_only',
|
||||
transparent_bg: false,
|
||||
cycles_device: null,
|
||||
material_override: null,
|
||||
allowed_override_keys: ['width', 'height', 'engine', 'samples', 'bg_color', 'noise_threshold', 'denoiser', 'denoising_input_passes', 'denoising_prefilter', 'denoising_quality', 'denoising_use_gpu'],
|
||||
invocation_overrides: { width: 2048 },
|
||||
},
|
||||
is_active: true,
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
} satisfies OutputType
|
||||
|
||||
expect(getOutputTypeInvocationOverrides(outputType)).toEqual({ width: 2048 })
|
||||
})
|
||||
|
||||
test('flags rollout modes without a linked workflow', () => {
|
||||
expect(getOutputTypeWorkflowContractIssues({
|
||||
workflowFamily: 'order_line',
|
||||
artifactKind: 'still_image',
|
||||
outputFormat: 'png',
|
||||
isAnimation: false,
|
||||
workflowDefinitionId: '',
|
||||
workflowRolloutMode: 'graph',
|
||||
workflows: [],
|
||||
})).toEqual([
|
||||
expect.objectContaining({
|
||||
code: 'rollout_requires_workflow',
|
||||
severity: 'error',
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
test('flags workflow family and artifact mismatches', () => {
|
||||
const issues = getOutputTypeWorkflowContractIssues({
|
||||
workflowFamily: 'order_line',
|
||||
artifactKind: 'still_image',
|
||||
outputFormat: 'png',
|
||||
isAnimation: false,
|
||||
workflowDefinitionId: 'wf-cad',
|
||||
workflowRolloutMode: 'shadow',
|
||||
workflows: [
|
||||
{
|
||||
id: 'wf-cad',
|
||||
name: 'CAD Intake',
|
||||
family: 'cad_file',
|
||||
supported_artifact_kinds: ['thumbnail_image'],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(issues).toEqual([
|
||||
expect.objectContaining({ code: 'workflow_family_mismatch', severity: 'error' }),
|
||||
expect.objectContaining({ code: 'workflow_artifact_mismatch', severity: 'error' }),
|
||||
])
|
||||
})
|
||||
|
||||
test('flags output formats that are incompatible with the workflow family', () => {
|
||||
expect(getOutputTypeWorkflowContractIssues({
|
||||
workflowFamily: 'order_line',
|
||||
artifactKind: 'custom',
|
||||
outputFormat: 'gltf',
|
||||
isAnimation: false,
|
||||
workflowDefinitionId: '',
|
||||
workflowRolloutMode: 'legacy_only',
|
||||
workflows: [],
|
||||
})).toEqual([
|
||||
expect.objectContaining({
|
||||
code: 'format_family_mismatch',
|
||||
severity: 'error',
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
test('returns only workflows that satisfy family and artifact contract', () => {
|
||||
expect(getCompatibleWorkflowsForOutputTypeContract(
|
||||
[
|
||||
{
|
||||
id: 'wf-1',
|
||||
name: 'Still Graph',
|
||||
family: 'order_line',
|
||||
supported_artifact_kinds: ['still_image', 'turntable_video'],
|
||||
},
|
||||
{
|
||||
id: 'wf-2',
|
||||
name: 'CAD Intake',
|
||||
family: 'cad_file',
|
||||
supported_artifact_kinds: ['thumbnail_image'],
|
||||
},
|
||||
{
|
||||
id: 'wf-3',
|
||||
name: 'Mixed Graph',
|
||||
family: 'mixed',
|
||||
supported_artifact_kinds: ['still_image'],
|
||||
},
|
||||
],
|
||||
'order_line',
|
||||
'still_image',
|
||||
)).toEqual([
|
||||
expect.objectContaining({ id: 'wf-1' }),
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,18 @@
|
||||
import { describe, expect, test } from 'vitest'
|
||||
import { describe, expect, test, vi } from 'vitest'
|
||||
|
||||
import { createPresetWorkflowConfig, createStarterWorkflowConfig, normalizeWorkflowConfig } from '../../api/workflows'
|
||||
import {
|
||||
createPresetWorkflowConfig,
|
||||
createStarterWorkflowConfig,
|
||||
normalizeWorkflowConfig,
|
||||
getWorkflows,
|
||||
} from '../../api/workflows'
|
||||
import api from '../../api/client'
|
||||
|
||||
vi.mock('../../api/client', () => ({
|
||||
default: {
|
||||
get: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
describe('workflow preset config builders', () => {
|
||||
test('builds a non-legacy still graph preset', () => {
|
||||
@@ -24,7 +36,7 @@ describe('workflow preset config builders', () => {
|
||||
'notify',
|
||||
])
|
||||
expect(config.nodes.find(node => node.step === 'blender_still')?.params).toMatchObject({
|
||||
use_custom_render_settings: true,
|
||||
use_custom_render_settings: false,
|
||||
render_engine: 'cycles',
|
||||
samples: 128,
|
||||
width: 1600,
|
||||
@@ -62,4 +74,107 @@ describe('workflow preset config builders', () => {
|
||||
expect(config.ui?.family).toBe('order_line')
|
||||
expect(config.ui?.execution_mode).toBe('shadow')
|
||||
})
|
||||
|
||||
test('rebuilds canonical reference blueprints during normalization', () => {
|
||||
const config = normalizeWorkflowConfig({
|
||||
version: 1,
|
||||
ui: {
|
||||
preset: 'custom',
|
||||
execution_mode: 'legacy',
|
||||
blueprint: 'cad_intake',
|
||||
},
|
||||
nodes: [
|
||||
{ id: 'resolve_step', step: 'resolve_step_path', params: {} },
|
||||
],
|
||||
edges: [],
|
||||
})
|
||||
|
||||
expect(config.ui?.blueprint).toBe('cad_intake')
|
||||
expect(config.ui?.family).toBe('cad_file')
|
||||
expect(config.nodes.map(node => node.step)).toEqual([
|
||||
'resolve_step_path',
|
||||
'occ_object_extract',
|
||||
'occ_glb_export',
|
||||
'glb_bbox',
|
||||
'stl_cache_generate',
|
||||
'blender_render',
|
||||
'threejs_render',
|
||||
'thumbnail_save',
|
||||
'thumbnail_save',
|
||||
])
|
||||
expect(config.edges).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ from: 'export_glb', to: 'bbox' },
|
||||
{ from: 'bbox', to: 'threejs_thumb' },
|
||||
]),
|
||||
)
|
||||
})
|
||||
|
||||
test('rebuilds canonical starter blueprints during normalization', () => {
|
||||
const config = normalizeWorkflowConfig({
|
||||
version: 1,
|
||||
ui: {
|
||||
preset: 'custom',
|
||||
execution_mode: 'legacy',
|
||||
blueprint: 'starter_order_rendering',
|
||||
},
|
||||
nodes: [],
|
||||
edges: [],
|
||||
})
|
||||
|
||||
expect(config.ui?.blueprint).toBe('starter_order_rendering')
|
||||
expect(config.ui?.family).toBe('order_line')
|
||||
expect(config.nodes.map(node => node.step)).toEqual(['order_line_setup'])
|
||||
})
|
||||
|
||||
test('normalizes workflow rollout summary from the API payload', async () => {
|
||||
vi.mocked(api.get).mockResolvedValueOnce({
|
||||
data: [
|
||||
{
|
||||
id: 'wf-1',
|
||||
name: 'Still Graph',
|
||||
output_type_id: null,
|
||||
config: createPresetWorkflowConfig('still_graph'),
|
||||
family: 'order_line',
|
||||
supported_artifact_kinds: ['still_image'],
|
||||
rollout_summary: {
|
||||
linked_output_type_count: 2,
|
||||
active_output_type_count: 1,
|
||||
linked_output_type_names: ['Still Render', 'Still Render Shadow'],
|
||||
rollout_modes: ['shadow'],
|
||||
has_blocking_contracts: false,
|
||||
blocking_reasons: [],
|
||||
latest_run: {
|
||||
workflow_run_id: 'run-1',
|
||||
execution_mode: 'graph',
|
||||
status: 'completed',
|
||||
created_at: '2026-04-11T10:00:00Z',
|
||||
completed_at: '2026-04-11T10:01:00Z',
|
||||
},
|
||||
latest_shadow_run: {
|
||||
workflow_run_id: 'run-shadow-1',
|
||||
execution_mode: 'shadow',
|
||||
status: 'completed',
|
||||
created_at: '2026-04-11T09:00:00Z',
|
||||
completed_at: '2026-04-11T09:01:00Z',
|
||||
},
|
||||
latest_rollout_gate_verdict: 'pass',
|
||||
latest_rollout_ready: true,
|
||||
latest_rollout_status: 'ready_for_rollout',
|
||||
latest_rollout_reasons: ['Observer output matches the authoritative legacy output byte-for-byte.'],
|
||||
},
|
||||
is_active: true,
|
||||
created_at: '2026-04-11T08:00:00Z',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const [workflow] = await getWorkflows()
|
||||
|
||||
expect(workflow.rollout_summary.linked_output_type_count).toBe(2)
|
||||
expect(workflow.rollout_summary.rollout_modes).toEqual(['shadow'])
|
||||
expect(workflow.rollout_summary.latest_shadow_run?.execution_mode).toBe('shadow')
|
||||
expect(workflow.rollout_summary.latest_rollout_gate_verdict).toBe('pass')
|
||||
expect(workflow.rollout_summary.latest_rollout_ready).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
import { useState } from 'react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { render, screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest'
|
||||
|
||||
import type { RenderTemplate } from '../../api/renderTemplates'
|
||||
import type { WorkflowNodeDefinition, WorkflowParams } from '../../api/workflows'
|
||||
import { WorkflowNodeInspector } from '../../components/workflows/WorkflowNodeInspector'
|
||||
|
||||
const listRenderTemplates = vi.fn<() => Promise<RenderTemplate[]>>()
|
||||
|
||||
vi.mock('../../api/renderTemplates', () => ({
|
||||
listRenderTemplates: () => listRenderTemplates(),
|
||||
}))
|
||||
|
||||
const resolveTemplateDefinition: WorkflowNodeDefinition = {
|
||||
step: 'resolve_template',
|
||||
label: 'Resolve Template',
|
||||
family: 'order_line',
|
||||
module_key: 'rendering.resolve_template',
|
||||
category: 'processing',
|
||||
description: 'Resolve a template for order-line rendering.',
|
||||
node_type: 'processNode',
|
||||
icon: 'layers',
|
||||
defaults: {},
|
||||
fields: [
|
||||
{
|
||||
key: 'template_id_override',
|
||||
label: 'Template Override',
|
||||
type: 'text',
|
||||
description: 'Manual template override.',
|
||||
section: 'General',
|
||||
default: '',
|
||||
min: null,
|
||||
max: null,
|
||||
step: null,
|
||||
unit: null,
|
||||
options: [],
|
||||
allow_blank: true,
|
||||
max_length: null,
|
||||
text_format: 'uuid',
|
||||
},
|
||||
],
|
||||
execution_kind: 'bridge',
|
||||
legacy_compatible: true,
|
||||
input_contract: { context: 'order_line', requires: ['order_line_context'] },
|
||||
output_contract: { context: 'order_line', provides: ['render_template'] },
|
||||
artifact_roles_consumed: ['order_line_context'],
|
||||
artifact_roles_produced: ['render_template'],
|
||||
legacy_source: 'legacy.resolve_template',
|
||||
}
|
||||
|
||||
const notifyDefinition: WorkflowNodeDefinition = {
|
||||
step: 'notify',
|
||||
label: 'Notify',
|
||||
family: 'order_line',
|
||||
module_key: 'notifications.emit',
|
||||
category: 'output',
|
||||
description: 'Emit the workflow result.',
|
||||
node_type: 'outputNode',
|
||||
icon: 'bell',
|
||||
defaults: {},
|
||||
fields: [],
|
||||
execution_kind: 'bridge',
|
||||
legacy_compatible: true,
|
||||
input_contract: { context: 'order_line', requires: ['workflow_result'] },
|
||||
output_contract: { context: 'order_line', provides: ['notification_event'] },
|
||||
artifact_roles_consumed: ['workflow_result'],
|
||||
artifact_roles_produced: ['notification_event'],
|
||||
legacy_source: 'legacy.notify',
|
||||
}
|
||||
|
||||
function createRenderTemplate(overrides: Partial<RenderTemplate> = {}): RenderTemplate {
|
||||
return {
|
||||
id: '0d87b85f-c454-4d61-a124-d5b59e6a43a2',
|
||||
name: 'Bearing Studio',
|
||||
category_key: 'bearings',
|
||||
output_type_id: null,
|
||||
output_type_name: null,
|
||||
output_type_ids: [],
|
||||
output_type_names: ['Still'],
|
||||
blend_file_path: '/templates/bearing.blend',
|
||||
original_filename: 'bearing.blend',
|
||||
target_collection: 'Product',
|
||||
material_replace_enabled: true,
|
||||
lighting_only: false,
|
||||
shadow_catcher_enabled: false,
|
||||
camera_orbit: true,
|
||||
workflow_input_schema: [],
|
||||
is_active: true,
|
||||
created_at: '2026-04-11T00:00:00Z',
|
||||
updated_at: '2026-04-11T00:00:00Z',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function renderInspector(
|
||||
params: WorkflowParams,
|
||||
onChange = vi.fn(),
|
||||
options: { stateful?: boolean } = {},
|
||||
) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
function StatefulInspector() {
|
||||
const [currentParams, setCurrentParams] = useState(params)
|
||||
|
||||
return (
|
||||
<WorkflowNodeInspector
|
||||
params={currentParams}
|
||||
onChange={nextParams => {
|
||||
setCurrentParams(nextParams)
|
||||
onChange(nextParams)
|
||||
}}
|
||||
nodeDefinition={resolveTemplateDefinition}
|
||||
step="resolve_template"
|
||||
nodeDefinitions={[resolveTemplateDefinition]}
|
||||
graphFamily="order_line"
|
||||
onStepChange={vi.fn()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{options.stateful ? (
|
||||
<StatefulInspector />
|
||||
) : (
|
||||
<WorkflowNodeInspector
|
||||
params={params}
|
||||
onChange={onChange}
|
||||
nodeDefinition={resolveTemplateDefinition}
|
||||
step="resolve_template"
|
||||
nodeDefinitions={[resolveTemplateDefinition]}
|
||||
graphFamily="order_line"
|
||||
onStepChange={vi.fn()}
|
||||
/>
|
||||
)}
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
return { onChange, queryClient }
|
||||
}
|
||||
|
||||
describe('WorkflowNodeInspector', () => {
|
||||
beforeEach(() => {
|
||||
listRenderTemplates.mockReset()
|
||||
})
|
||||
|
||||
test('renders template-defined workflow input fields for resolve_template nodes', async () => {
|
||||
listRenderTemplates.mockResolvedValue([
|
||||
createRenderTemplate({
|
||||
workflow_input_schema: [
|
||||
{
|
||||
key: 'studio_variant',
|
||||
label: 'Studio Variant',
|
||||
type: 'select',
|
||||
section: 'Template Inputs',
|
||||
description: 'Choose the blend lighting preset.',
|
||||
default: 'default',
|
||||
min: null,
|
||||
max: null,
|
||||
step: null,
|
||||
unit: null,
|
||||
options: [
|
||||
{ value: 'default', label: 'Default' },
|
||||
{ value: 'warm', label: 'Warm' },
|
||||
],
|
||||
allow_blank: false,
|
||||
},
|
||||
],
|
||||
}),
|
||||
])
|
||||
|
||||
renderInspector({
|
||||
template_id_override: '0d87b85f-c454-4d61-a124-d5b59e6a43a2',
|
||||
template_input__studio_variant: 'warm',
|
||||
})
|
||||
|
||||
expect(await screen.findByLabelText('Template Override')).toHaveValue(
|
||||
'0d87b85f-c454-4d61-a124-d5b59e6a43a2',
|
||||
)
|
||||
expect(await screen.findByLabelText('Studio Variant')).toHaveValue('warm')
|
||||
expect(screen.getByText('Bearing Studio')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('clears dynamic template inputs when template override is removed', async () => {
|
||||
listRenderTemplates.mockResolvedValue([
|
||||
createRenderTemplate({
|
||||
workflow_input_schema: [
|
||||
{
|
||||
key: 'studio_variant',
|
||||
label: 'Studio Variant',
|
||||
type: 'select',
|
||||
section: 'Template Inputs',
|
||||
description: 'Choose the blend lighting preset.',
|
||||
default: 'default',
|
||||
min: null,
|
||||
max: null,
|
||||
step: null,
|
||||
unit: null,
|
||||
options: [
|
||||
{ value: 'default', label: 'Default' },
|
||||
{ value: 'warm', label: 'Warm' },
|
||||
],
|
||||
allow_blank: false,
|
||||
},
|
||||
],
|
||||
}),
|
||||
])
|
||||
|
||||
const user = userEvent.setup()
|
||||
const { onChange } = renderInspector({
|
||||
template_id_override: '0d87b85f-c454-4d61-a124-d5b59e6a43a2',
|
||||
template_input__studio_variant: 'warm',
|
||||
template_input__camera_profile: 'macro',
|
||||
})
|
||||
|
||||
const templateOverride = await screen.findByLabelText('Template Override')
|
||||
await user.selectOptions(templateOverride, '')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onChange).toHaveBeenCalledWith({})
|
||||
})
|
||||
})
|
||||
|
||||
test('explains connection-driven nodes when no editor fields are available', async () => {
|
||||
listRenderTemplates.mockResolvedValue([])
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<WorkflowNodeInspector
|
||||
params={{}}
|
||||
onChange={vi.fn()}
|
||||
nodeDefinition={notifyDefinition}
|
||||
step="notify"
|
||||
nodeDefinitions={[notifyDefinition]}
|
||||
graphFamily="order_line"
|
||||
onStepChange={vi.fn()}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('This node has no editor settings.')).toBeInTheDocument()
|
||||
expect(screen.getByText(/each required upstream input gets its own socket/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/0 local variables by design/i)).toBeInTheDocument()
|
||||
expect(screen.getByText('Socket 1')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getAllByText('Any of: Rendered Image / Rendered Frames / Rendered Video / Workflow Result / Blend Asset').length,
|
||||
).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('summarizes wired inputs and inspector variables separately', async () => {
|
||||
listRenderTemplates.mockResolvedValue([
|
||||
createRenderTemplate({
|
||||
workflow_input_schema: [
|
||||
{
|
||||
key: 'studio_variant',
|
||||
label: 'Studio Variant',
|
||||
type: 'select',
|
||||
section: 'Template Inputs',
|
||||
description: 'Choose the blend lighting preset.',
|
||||
default: 'default',
|
||||
min: null,
|
||||
max: null,
|
||||
step: null,
|
||||
unit: null,
|
||||
options: [
|
||||
{ value: 'default', label: 'Default' },
|
||||
{ value: 'warm', label: 'Warm' },
|
||||
],
|
||||
allow_blank: false,
|
||||
},
|
||||
],
|
||||
}),
|
||||
])
|
||||
|
||||
const user = userEvent.setup()
|
||||
renderInspector({}, vi.fn(), { stateful: true })
|
||||
|
||||
const templateOverride = await screen.findByLabelText('Template Override')
|
||||
await waitFor(() => {
|
||||
expect(within(templateOverride).getByRole('option', { name: /bearing studio/i })).toBeInTheDocument()
|
||||
})
|
||||
await user.selectOptions(templateOverride, '0d87b85f-c454-4d61-a124-d5b59e6a43a2')
|
||||
|
||||
expect(await screen.findByText(/1 canvas socket is required/i)).toBeInTheDocument()
|
||||
expect(screen.getByText('Socket 1')).toBeInTheDocument()
|
||||
expect(await screen.findByText(/2 local variables are edited in the inspector/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/Static: Template Override/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/Template-driven: Studio Variant/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, test } from 'vitest'
|
||||
import * as THREE from 'three'
|
||||
|
||||
import { buildScenePartRegistry, convertSceneManifestMaterials, remapToPartKeys, resolveObjectPartKey } from '../../components/cad/cadUtils'
|
||||
import { alignSceneManifestToLogicalPartKeys, buildEffectiveViewerMaterials, buildScenePartRegistry, convertSceneManifestMaterials, mergeViewerMaterialSources, remapToPartKeys, resolveObjectPartKey } from '../../components/cad/cadUtils'
|
||||
|
||||
describe('cadUtils scene manifest conversion', () => {
|
||||
test('uses scene manifest part keys as authoritative viewer material map', () => {
|
||||
@@ -31,6 +31,55 @@ describe('cadUtils scene manifest conversion', () => {
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test('adds viewer logical keys when manifest stores repeated leaf instances under deduplicated part keys', () => {
|
||||
const materials = alignSceneManifestToLogicalPartKeys(
|
||||
convertSceneManifestMaterials([
|
||||
{
|
||||
part_key: 'kero_z_575693_qp_drh_isb_1_1',
|
||||
effective_material: 'HARTOMAT_010101_Steel-Bare',
|
||||
},
|
||||
{
|
||||
part_key: 'kero_z_575693_qp_drh_isb_1_1_2',
|
||||
effective_material: 'HARTOMAT_010101_Steel-Bare',
|
||||
},
|
||||
]),
|
||||
new Set(['kero_z_575693_qp_drh_isb_1']),
|
||||
)
|
||||
|
||||
expect(materials.kero_z_575693_qp_drh_isb_1).toEqual({
|
||||
type: 'library',
|
||||
value: 'HARTOMAT_010101_Steel-Bare',
|
||||
})
|
||||
})
|
||||
|
||||
test('backfills helper and af-instance logical keys through legacy fuzzy lookup when manifest keys differ', () => {
|
||||
const materials = alignSceneManifestToLogicalPartKeys(
|
||||
convertSceneManifestMaterials([
|
||||
{
|
||||
part_key: 'kero_z_575693_qp_drh_isb',
|
||||
effective_material: 'HARTOMAT_010101_Steel-Bare',
|
||||
},
|
||||
{
|
||||
part_key: 'kero_z_575693_qp_drh_isb_1_1',
|
||||
effective_material: 'HARTOMAT_010101_Steel-Bare',
|
||||
},
|
||||
]),
|
||||
new Set([
|
||||
'kero_z_575693_qp_drh_isb_1',
|
||||
'kero_z_575693_qp_drh_isb_1_af0',
|
||||
]),
|
||||
)
|
||||
|
||||
expect(materials.kero_z_575693_qp_drh_isb_1).toEqual({
|
||||
type: 'library',
|
||||
value: 'HARTOMAT_010101_Steel-Bare',
|
||||
})
|
||||
expect(materials.kero_z_575693_qp_drh_isb_1_af0).toEqual({
|
||||
type: 'library',
|
||||
value: 'HARTOMAT_010101_Steel-Bare',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('cadUtils legacy fallback remapping', () => {
|
||||
@@ -113,8 +162,82 @@ describe('cadUtils legacy fallback remapping', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('cadUtils viewer material source merge', () => {
|
||||
test('keeps fallback assignments authoritative while filling manifest-only gaps', () => {
|
||||
const merged = mergeViewerMaterialSources(
|
||||
{
|
||||
rwdr_b_f_802044_tr4_h122bk: {
|
||||
type: 'library',
|
||||
value: 'HARTOMAT_010101_Steel-Bare',
|
||||
},
|
||||
usd_only_part: {
|
||||
type: 'library',
|
||||
value: 'HARTOMAT_050101_Elastomer-Black',
|
||||
},
|
||||
},
|
||||
{
|
||||
rwdr_b_f_802044_tr4_h122bk: {
|
||||
type: 'library',
|
||||
value: 'Steel--Stahl',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
expect(merged).toEqual({
|
||||
rwdr_b_f_802044_tr4_h122bk: {
|
||||
type: 'library',
|
||||
value: 'Steel--Stahl',
|
||||
},
|
||||
usd_only_part: {
|
||||
type: 'library',
|
||||
value: 'HARTOMAT_050101_Elastomer-Black',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test('applies manual overrides last on top of fallback-authoritative merged sources', () => {
|
||||
const merged = buildEffectiveViewerMaterials(
|
||||
{
|
||||
rwdr_b_f_802044_tr4_h122bk: {
|
||||
type: 'library',
|
||||
value: 'HARTOMAT_010101_Steel-Bare',
|
||||
},
|
||||
usd_only_part: {
|
||||
type: 'library',
|
||||
value: 'HARTOMAT_050101_Elastomer-Black',
|
||||
},
|
||||
},
|
||||
{
|
||||
rwdr_b_f_802044_tr4_h122bk: {
|
||||
type: 'library',
|
||||
value: 'Steel--Stahl',
|
||||
},
|
||||
},
|
||||
{
|
||||
rwdr_b_f_802044_tr4_h122bk: '#123456',
|
||||
manual_only_part: 'HARTOMAT_070707_Test-Material',
|
||||
},
|
||||
)
|
||||
|
||||
expect(merged).toEqual({
|
||||
rwdr_b_f_802044_tr4_h122bk: {
|
||||
type: 'hex',
|
||||
value: '#123456',
|
||||
},
|
||||
usd_only_part: {
|
||||
type: 'library',
|
||||
value: 'HARTOMAT_050101_Elastomer-Black',
|
||||
},
|
||||
manual_only_part: {
|
||||
type: 'library',
|
||||
value: 'HARTOMAT_070707_Test-Material',
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('cadUtils scene graph part-key registry', () => {
|
||||
test('inherits instance part keys from ancestor nodes and keeps logical keys from scene metadata', () => {
|
||||
test('inherits instance part keys from ancestor nodes while excluding helper-only logical keys from renderable counts', () => {
|
||||
const scene = new THREE.Group()
|
||||
|
||||
const instanceGroup = new THREE.Group()
|
||||
@@ -139,14 +262,10 @@ describe('cadUtils scene graph part-key registry', () => {
|
||||
expect(meshRegistry).toHaveLength(1)
|
||||
expect(meshRegistry[0].partKey).toBe('kero_z_575693_qp_drh_isb_1')
|
||||
expect(resolveObjectPartKey(mesh, {})).toBe('kero_z_575693_qp_drh_isb_1')
|
||||
expect(logicalPartKeys).toEqual(new Set([
|
||||
'kero_z_575693_qp_drh_isb_1',
|
||||
'rwdr_skel_f_802044_tr4_h122bk',
|
||||
'f_802044_tr4_h122bk_04',
|
||||
]))
|
||||
expect(logicalPartKeys).toEqual(new Set(['kero_z_575693_qp_drh_isb_1']))
|
||||
})
|
||||
|
||||
test('prefers sibling semantic instance nodes over mesh-local exporter keys when transforms match', () => {
|
||||
test('prefers explicit mesh-local exporter keys over sibling semantic instance nodes when transforms match', () => {
|
||||
const scene = new THREE.Group()
|
||||
const assembly = new THREE.Group()
|
||||
|
||||
@@ -169,12 +288,12 @@ describe('cadUtils scene graph part-key registry', () => {
|
||||
const { meshRegistry } = buildScenePartRegistry(scene, {})
|
||||
|
||||
expect(meshRegistry).toHaveLength(1)
|
||||
expect(meshRegistry[0].partKey).toBe('kero_z_575693_qp_drh_isb_1')
|
||||
expect(mesh.userData.partKey).toBe('kero_z_575693_qp_drh_isb_1')
|
||||
expect(resolveObjectPartKey(mesh, {})).toBe('kero_z_575693_qp_drh_isb_1')
|
||||
expect(meshRegistry[0].partKey).toBe('kero_z_575693_qp_drh_isb_1_1')
|
||||
expect(mesh.userData.partKey).toBe('kero_z_575693_qp_drh_isb_1_1')
|
||||
expect(resolveObjectPartKey(mesh, {})).toBe('kero_z_575693_qp_drh_isb_1_1')
|
||||
})
|
||||
|
||||
test('prefers sibling semantic instance nodes even when transforms do not match', () => {
|
||||
test('falls back to sibling semantic instance nodes when explicit mesh keys are absent, even when transforms do not match', () => {
|
||||
const scene = new THREE.Group()
|
||||
const assembly = new THREE.Group()
|
||||
|
||||
@@ -185,7 +304,6 @@ describe('cadUtils scene graph part-key registry', () => {
|
||||
|
||||
const mesh = new THREE.Mesh(new THREE.BufferGeometry(), new THREE.MeshStandardMaterial())
|
||||
mesh.name = 'KERO_Z-575693-QP-DRH_ISB_1_1'
|
||||
mesh.userData.partKey = 'kero_z_575693_qp_drh_isb_1_1'
|
||||
mesh.position.set(0.2422435981345, 0.06134441033723, 0.2109037401181)
|
||||
|
||||
assembly.add(semanticSibling)
|
||||
@@ -199,4 +317,19 @@ describe('cadUtils scene graph part-key registry', () => {
|
||||
expect(mesh.userData.partKey).toBe('kero_z_575693_qp_drh_isb_1')
|
||||
expect(resolveObjectPartKey(mesh, {})).toBe('kero_z_575693_qp_drh_isb_1')
|
||||
})
|
||||
|
||||
test('does not synthesize pseudo part keys from normalized mesh names when no authoritative mapping exists', () => {
|
||||
const scene = new THREE.Group()
|
||||
const mesh = new THREE.Mesh(new THREE.BufferGeometry(), new THREE.MeshStandardMaterial())
|
||||
mesh.name = 'RWDR_B_F-802044_TR4_H122BK_AF0'
|
||||
scene.add(mesh)
|
||||
|
||||
const { meshRegistry, logicalPartKeys, unresolvedMeshNames } = buildScenePartRegistry(scene, {})
|
||||
|
||||
expect(resolveObjectPartKey(mesh, {})).toBe('')
|
||||
expect(mesh.userData.partKey).toBeUndefined()
|
||||
expect(meshRegistry).toHaveLength(0)
|
||||
expect(logicalPartKeys).toEqual(new Set())
|
||||
expect(unresolvedMeshNames).toEqual(new Set(['RWDR_B_F-802044_TR4_H122BK']))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, test } from 'vitest'
|
||||
|
||||
import { getOutputTypeRolloutPresentation } from '../../components/admin/outputTypeRolloutPresentation'
|
||||
|
||||
describe('outputTypeRolloutPresentation', () => {
|
||||
test('describes unlinked output types as fully legacy', () => {
|
||||
expect(getOutputTypeRolloutPresentation({
|
||||
hasWorkflowLink: false,
|
||||
workflowRolloutMode: 'legacy_only',
|
||||
})).toEqual(expect.objectContaining({
|
||||
badgeLabel: 'Legacy Only',
|
||||
statusLabel: 'Production: Legacy',
|
||||
rowSummary: 'No linked graph workflow.',
|
||||
}))
|
||||
})
|
||||
|
||||
test('describes shadow rollout as legacy-authoritative observer mode', () => {
|
||||
expect(getOutputTypeRolloutPresentation({
|
||||
hasWorkflowLink: true,
|
||||
workflowRolloutMode: 'shadow',
|
||||
})).toEqual(expect.objectContaining({
|
||||
badgeLabel: 'Shadow',
|
||||
statusLabel: 'Production: Legacy',
|
||||
rowSummary: 'Graph observes only; legacy remains authoritative.',
|
||||
}))
|
||||
})
|
||||
|
||||
test('describes graph rollout as production-graph with legacy fallback', () => {
|
||||
expect(getOutputTypeRolloutPresentation({
|
||||
hasWorkflowLink: true,
|
||||
workflowRolloutMode: 'graph',
|
||||
})).toEqual(expect.objectContaining({
|
||||
badgeLabel: 'Graph Authoritative',
|
||||
statusLabel: 'Production: Graph',
|
||||
rowSummary: 'Graph drives production with legacy fallback armed.',
|
||||
}))
|
||||
})
|
||||
|
||||
test('elevates blocking issues above rollout mode', () => {
|
||||
expect(getOutputTypeRolloutPresentation({
|
||||
hasWorkflowLink: true,
|
||||
workflowRolloutMode: 'graph',
|
||||
hasBlockingIssues: true,
|
||||
})).toEqual(expect.objectContaining({
|
||||
badgeLabel: 'Contract Blocked',
|
||||
statusLabel: 'Do Not Promote',
|
||||
}))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, test, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
bindWorkflowAuthoringInsertActions,
|
||||
type WorkflowAuthoringActions,
|
||||
} from '../../components/workflows/workflowAuthoringActions'
|
||||
|
||||
describe('bindWorkflowAuthoringInsertActions', () => {
|
||||
test('binds preferred canvas position into insert actions', () => {
|
||||
const insertNode = vi.fn()
|
||||
const insertModule = vi.fn()
|
||||
const insertReferencePath = vi.fn()
|
||||
const actions: WorkflowAuthoringActions = {
|
||||
insertNode,
|
||||
insertModule,
|
||||
insertReferencePath,
|
||||
}
|
||||
|
||||
const bindings = bindWorkflowAuthoringInsertActions(actions, {
|
||||
preferredPosition: { x: 120, y: 240 },
|
||||
})
|
||||
|
||||
bindings.onSelectStep?.('blender_still')
|
||||
bindings.onInsertModule?.('still_render_core')
|
||||
bindings.onInsertReferencePath?.('still_render_reference')
|
||||
|
||||
expect(insertNode).toHaveBeenCalledWith('blender_still', { x: 120, y: 240 })
|
||||
expect(insertModule).toHaveBeenCalledWith('still_render_core', { x: 120, y: 240 })
|
||||
expect(insertReferencePath).toHaveBeenCalledWith('still_render_reference', { x: 120, y: 240 })
|
||||
})
|
||||
|
||||
test('runs the after-insert callback after successful bound actions', () => {
|
||||
const afterInsert = vi.fn()
|
||||
const insertNode = vi.fn()
|
||||
|
||||
const bindings = bindWorkflowAuthoringInsertActions(
|
||||
{
|
||||
insertNode,
|
||||
},
|
||||
{
|
||||
onAfterInsert: afterInsert,
|
||||
},
|
||||
)
|
||||
|
||||
bindings.onSelectStep?.('resolve_template')
|
||||
|
||||
expect(insertNode).toHaveBeenCalledWith('resolve_template', undefined)
|
||||
expect(afterInsert).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,227 @@
|
||||
import { describe, expect, test } from 'vitest'
|
||||
|
||||
import type { WorkflowNodeDefinition } from '../../api/workflows'
|
||||
import { getWorkflowAuthoringPlan } from '../../components/workflows/workflowAuthoringGuidance'
|
||||
import {
|
||||
getWorkflowAuthoringSurfaceModel,
|
||||
resolveWorkflowAuthoringSection,
|
||||
} from '../../components/workflows/workflowAuthoringSurface'
|
||||
|
||||
const definitions: WorkflowNodeDefinition[] = [
|
||||
{
|
||||
step: 'order_line_setup',
|
||||
label: 'Order Line Setup',
|
||||
family: 'order_line',
|
||||
module_key: 'order_line.prepare_render_context',
|
||||
category: 'input',
|
||||
description: 'Prepare order line.',
|
||||
node_type: 'inputNode',
|
||||
icon: 'refresh-cw',
|
||||
defaults: {},
|
||||
fields: [],
|
||||
execution_kind: 'bridge',
|
||||
legacy_compatible: true,
|
||||
input_contract: { context: 'order_line' },
|
||||
output_contract: { context: 'order_line', provides: ['order_line'] },
|
||||
artifact_roles_produced: [],
|
||||
artifact_roles_consumed: [],
|
||||
legacy_source: 'legacy.order_line_setup',
|
||||
},
|
||||
{
|
||||
step: 'resolve_template',
|
||||
label: 'Resolve Template',
|
||||
family: 'order_line',
|
||||
module_key: 'rendering.resolve_template',
|
||||
category: 'processing',
|
||||
description: 'Resolve template.',
|
||||
node_type: 'processNode',
|
||||
icon: 'layers',
|
||||
defaults: {},
|
||||
fields: [],
|
||||
execution_kind: 'bridge',
|
||||
legacy_compatible: true,
|
||||
input_contract: { context: 'order_line', requires: ['order_line'] },
|
||||
output_contract: { context: 'order_line', provides: ['render_template'] },
|
||||
artifact_roles_produced: [],
|
||||
artifact_roles_consumed: [],
|
||||
legacy_source: 'legacy.resolve_template',
|
||||
},
|
||||
{
|
||||
step: 'auto_populate_materials',
|
||||
label: 'Auto Populate Materials',
|
||||
family: 'order_line',
|
||||
module_key: 'materials.auto_populate',
|
||||
category: 'processing',
|
||||
description: 'Populate materials.',
|
||||
node_type: 'processNode',
|
||||
icon: 'layers',
|
||||
defaults: {},
|
||||
fields: [],
|
||||
execution_kind: 'bridge',
|
||||
legacy_compatible: true,
|
||||
input_contract: { context: 'order_line', requires: ['order_line'] },
|
||||
output_contract: { context: 'order_line', provides: ['cad_materials'] },
|
||||
artifact_roles_produced: [],
|
||||
artifact_roles_consumed: [],
|
||||
legacy_source: 'legacy.auto_populate_materials',
|
||||
},
|
||||
{
|
||||
step: 'glb_bbox',
|
||||
label: 'Compute Bounding Box',
|
||||
family: 'order_line',
|
||||
module_key: 'geometry.compute_bbox',
|
||||
category: 'processing',
|
||||
description: 'Compute bbox.',
|
||||
node_type: 'processNode',
|
||||
icon: 'box',
|
||||
defaults: {},
|
||||
fields: [],
|
||||
execution_kind: 'native',
|
||||
legacy_compatible: false,
|
||||
input_contract: { context: 'order_line', requires: ['order_line'] },
|
||||
output_contract: { context: 'order_line', provides: ['bbox'] },
|
||||
artifact_roles_produced: [],
|
||||
artifact_roles_consumed: [],
|
||||
legacy_source: null,
|
||||
},
|
||||
{
|
||||
step: 'material_map_resolve',
|
||||
label: 'Resolve Material Map',
|
||||
family: 'order_line',
|
||||
module_key: 'materials.resolve_map',
|
||||
category: 'processing',
|
||||
description: 'Resolve material map.',
|
||||
node_type: 'processNode',
|
||||
icon: 'layers',
|
||||
defaults: {},
|
||||
fields: [],
|
||||
execution_kind: 'native',
|
||||
legacy_compatible: false,
|
||||
input_contract: { context: 'order_line', requires: ['order_line'] },
|
||||
output_contract: { context: 'order_line', provides: ['material_map'] },
|
||||
artifact_roles_produced: [],
|
||||
artifact_roles_consumed: [],
|
||||
legacy_source: null,
|
||||
},
|
||||
{
|
||||
step: 'blender_still',
|
||||
label: 'Blender Still',
|
||||
family: 'order_line',
|
||||
module_key: 'render.production.still',
|
||||
category: 'rendering',
|
||||
description: 'Render still.',
|
||||
node_type: 'renderNode',
|
||||
icon: 'camera',
|
||||
defaults: {},
|
||||
fields: [],
|
||||
execution_kind: 'native',
|
||||
legacy_compatible: false,
|
||||
input_contract: { context: 'order_line', requires: ['order_line'] },
|
||||
output_contract: { context: 'order_line', provides: ['render_image'] },
|
||||
artifact_roles_produced: [],
|
||||
artifact_roles_consumed: [],
|
||||
legacy_source: null,
|
||||
},
|
||||
{
|
||||
step: 'output_save',
|
||||
label: 'Save Output',
|
||||
family: 'order_line',
|
||||
module_key: 'media.save_output',
|
||||
category: 'output',
|
||||
description: 'Persist output.',
|
||||
node_type: 'outputNode',
|
||||
icon: 'download',
|
||||
defaults: {},
|
||||
fields: [],
|
||||
execution_kind: 'bridge',
|
||||
legacy_compatible: true,
|
||||
input_contract: { context: 'order_line', requires: ['render_image'] },
|
||||
output_contract: { context: 'order_line', provides: ['saved_output'] },
|
||||
artifact_roles_produced: [],
|
||||
artifact_roles_consumed: [],
|
||||
legacy_source: 'legacy.output_save',
|
||||
},
|
||||
{
|
||||
step: 'notify',
|
||||
label: 'Notify Result',
|
||||
family: 'order_line',
|
||||
module_key: 'notifications.emit',
|
||||
category: 'output',
|
||||
description: 'Notify completion.',
|
||||
node_type: 'outputNode',
|
||||
icon: 'bell',
|
||||
defaults: {},
|
||||
fields: [],
|
||||
execution_kind: 'bridge',
|
||||
legacy_compatible: true,
|
||||
input_contract: { context: 'order_line', requires: ['saved_output'] },
|
||||
output_contract: { context: 'order_line', provides: ['notification'] },
|
||||
artifact_roles_produced: [],
|
||||
artifact_roles_consumed: [],
|
||||
legacy_source: 'legacy.notify',
|
||||
},
|
||||
]
|
||||
|
||||
describe('workflow authoring guidance', () => {
|
||||
test('derives a single shared order-line authoring plan', () => {
|
||||
const plan = getWorkflowAuthoringPlan(definitions, 'order_line', ['blender_still'])
|
||||
|
||||
expect(plan.referenceBundles).toHaveLength(1)
|
||||
expect(plan.moduleBundles).toHaveLength(2)
|
||||
expect(plan.referenceBundles[0]?.presentCount).toBe(1)
|
||||
expect(plan.moduleBundles.find(bundle => bundle.id === 'still_render_core')?.presentCount).toBe(1)
|
||||
expect(plan.stageProgress.map(stage => stage.id)).toEqual([
|
||||
'still_render_reference',
|
||||
'still_render_core',
|
||||
'output_publish_notify',
|
||||
'order_line_setup',
|
||||
])
|
||||
expect(plan.gapFillDefinitions.map(definition => definition.step)).toEqual([
|
||||
'order_line_setup',
|
||||
'resolve_template',
|
||||
'auto_populate_materials',
|
||||
])
|
||||
})
|
||||
|
||||
test('hides starter-only guidance for mixed graphs', () => {
|
||||
const plan = getWorkflowAuthoringPlan(definitions, 'mixed', ['blender_still'])
|
||||
|
||||
expect(plan.title).toBe('Guided Authoring')
|
||||
expect(plan.starterItems).toEqual([])
|
||||
expect(plan.stageProgress).toEqual([])
|
||||
expect(plan.authoringFlow[2]?.title).toBe('Starter Path')
|
||||
})
|
||||
|
||||
test('derives one shared authoring surface model from the same plan', () => {
|
||||
const surface = getWorkflowAuthoringSurfaceModel({
|
||||
definitions,
|
||||
graphFamily: 'order_line',
|
||||
activeSteps: ['order_line_setup'],
|
||||
})
|
||||
|
||||
expect(surface.defaultSection).toBe('overview')
|
||||
expect(surface.sections.map(section => section.key)).toEqual([
|
||||
'overview',
|
||||
'paths',
|
||||
'modules',
|
||||
'starter',
|
||||
'nodes',
|
||||
])
|
||||
expect(surface.plan.referenceBundles[0]?.id).toBe('still_render_reference')
|
||||
expect(surface.plan.moduleBundles.map(bundle => bundle.id)).toEqual([
|
||||
'still_render_core',
|
||||
'output_publish_notify',
|
||||
])
|
||||
})
|
||||
|
||||
test('falls back to a valid section when the requested section is unavailable', () => {
|
||||
const surface = getWorkflowAuthoringSurfaceModel({
|
||||
definitions,
|
||||
graphFamily: 'mixed',
|
||||
activeSteps: ['order_line_setup'],
|
||||
})
|
||||
|
||||
expect(resolveWorkflowAuthoringSection('overview', surface.sections, surface.defaultSection)).toBe('nodes')
|
||||
expect(resolveWorkflowAuthoringSection('nodes', surface.sections, surface.defaultSection)).toBe('nodes')
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { render, screen, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, test, vi } from 'vitest'
|
||||
|
||||
@@ -14,6 +14,11 @@ import { WorkflowCanvasToolbar } from '../../components/workflows/WorkflowCanvas
|
||||
import { WorkflowNodeContractCard } from '../../components/workflows/WorkflowNodeContractCard'
|
||||
import { WorkflowPreflightPanel } from '../../components/workflows/WorkflowPreflightPanel'
|
||||
import { WorkflowRunsPanel } from '../../components/workflows/WorkflowRunsPanel'
|
||||
import {
|
||||
bindWorkflowAuthoringInsertActions,
|
||||
getWorkflowAuthoringEntryAction,
|
||||
} from '../../components/workflows/workflowAuthoringActions'
|
||||
import { getWorkflowAuthoringSurfaceModel } from '../../components/workflows/workflowAuthoringSurface'
|
||||
|
||||
const nodeDefinitions: WorkflowNodeDefinition[] = [
|
||||
{
|
||||
@@ -35,6 +40,177 @@ const nodeDefinitions: WorkflowNodeDefinition[] = [
|
||||
artifact_roles_consumed: [],
|
||||
legacy_source: 'legacy.resolve_step_path',
|
||||
},
|
||||
{
|
||||
step: 'occ_object_extract',
|
||||
label: 'Extract OCC Objects',
|
||||
family: 'cad_file',
|
||||
module_key: 'cad.intake',
|
||||
category: 'processing',
|
||||
description: 'Extract assembly objects from CAD input.',
|
||||
node_type: 'processNode',
|
||||
icon: 'boxes',
|
||||
defaults: {},
|
||||
fields: [],
|
||||
execution_kind: 'native',
|
||||
legacy_compatible: false,
|
||||
input_contract: { context: 'cad_file', requires: ['step_path'] },
|
||||
output_contract: { context: 'cad_file', provides: ['cad_objects'] },
|
||||
artifact_roles_produced: [],
|
||||
artifact_roles_consumed: ['step_file'],
|
||||
legacy_source: null,
|
||||
},
|
||||
{
|
||||
step: 'occ_glb_export',
|
||||
label: 'Export GLB',
|
||||
family: 'cad_file',
|
||||
module_key: 'cad.intake',
|
||||
category: 'processing',
|
||||
description: 'Export preview GLB from CAD geometry.',
|
||||
node_type: 'processNode',
|
||||
icon: 'box',
|
||||
defaults: {},
|
||||
fields: [],
|
||||
execution_kind: 'native',
|
||||
legacy_compatible: false,
|
||||
input_contract: { context: 'cad_file', requires: ['cad_objects'] },
|
||||
output_contract: { context: 'cad_file', provides: ['cad_preview'] },
|
||||
artifact_roles_produced: ['cad_preview'],
|
||||
artifact_roles_consumed: [],
|
||||
legacy_source: null,
|
||||
},
|
||||
{
|
||||
step: 'stl_cache_generate',
|
||||
label: 'Generate STL Cache',
|
||||
family: 'cad_file',
|
||||
module_key: 'cad.intake',
|
||||
category: 'processing',
|
||||
description: 'Build STL cache for downstream consumers.',
|
||||
node_type: 'processNode',
|
||||
icon: 'database',
|
||||
defaults: {},
|
||||
fields: [],
|
||||
execution_kind: 'native',
|
||||
legacy_compatible: false,
|
||||
input_contract: { context: 'cad_file', requires: ['cad_objects'] },
|
||||
output_contract: { context: 'cad_file', provides: ['stl_cache'] },
|
||||
artifact_roles_produced: ['stl_cache'],
|
||||
artifact_roles_consumed: [],
|
||||
legacy_source: null,
|
||||
},
|
||||
{
|
||||
step: 'thumbnail_save',
|
||||
label: 'Publish Thumbnail',
|
||||
family: 'cad_file',
|
||||
module_key: 'cad.intake',
|
||||
category: 'output',
|
||||
description: 'Persist preview thumbnail output.',
|
||||
node_type: 'outputNode',
|
||||
icon: 'image',
|
||||
defaults: {},
|
||||
fields: [],
|
||||
execution_kind: 'bridge',
|
||||
legacy_compatible: true,
|
||||
input_contract: { context: 'cad_file', requires: ['cad_preview'] },
|
||||
output_contract: { context: 'cad_file', provides: ['thumbnail'] },
|
||||
artifact_roles_produced: ['thumbnail'],
|
||||
artifact_roles_consumed: ['cad_preview'],
|
||||
legacy_source: 'legacy.thumbnail_save',
|
||||
},
|
||||
{
|
||||
step: 'order_line_setup',
|
||||
label: 'Order Line Setup',
|
||||
family: 'order_line',
|
||||
module_key: 'order_line.prepare_render_context',
|
||||
category: 'input',
|
||||
description: 'Prepare order-line render context.',
|
||||
node_type: 'inputNode',
|
||||
icon: 'refresh-cw',
|
||||
defaults: {},
|
||||
fields: [],
|
||||
execution_kind: 'bridge',
|
||||
legacy_compatible: true,
|
||||
input_contract: { context: 'order_line' },
|
||||
output_contract: { context: 'order_line', provides: ['order_line'] },
|
||||
artifact_roles_produced: [],
|
||||
artifact_roles_consumed: [],
|
||||
legacy_source: 'legacy.order_line_setup',
|
||||
},
|
||||
{
|
||||
step: 'resolve_template',
|
||||
label: 'Resolve Template',
|
||||
family: 'order_line',
|
||||
module_key: 'rendering.resolve_template',
|
||||
category: 'processing',
|
||||
description: 'Resolve render template.',
|
||||
node_type: 'processNode',
|
||||
icon: 'layers',
|
||||
defaults: {},
|
||||
fields: [],
|
||||
execution_kind: 'bridge',
|
||||
legacy_compatible: true,
|
||||
input_contract: { context: 'order_line', requires: ['order_line'] },
|
||||
output_contract: { context: 'order_line', provides: ['render_template'] },
|
||||
artifact_roles_produced: [],
|
||||
artifact_roles_consumed: [],
|
||||
legacy_source: 'legacy.resolve_template',
|
||||
},
|
||||
{
|
||||
step: 'auto_populate_materials',
|
||||
label: 'Auto Populate Materials',
|
||||
family: 'order_line',
|
||||
module_key: 'materials.auto_populate',
|
||||
category: 'processing',
|
||||
description: 'Populate materials automatically.',
|
||||
node_type: 'processNode',
|
||||
icon: 'layers',
|
||||
defaults: {},
|
||||
fields: [],
|
||||
execution_kind: 'bridge',
|
||||
legacy_compatible: true,
|
||||
input_contract: { context: 'order_line', requires: ['order_line'] },
|
||||
output_contract: { context: 'order_line', provides: ['cad_materials'] },
|
||||
artifact_roles_produced: [],
|
||||
artifact_roles_consumed: [],
|
||||
legacy_source: 'legacy.auto_populate_materials',
|
||||
},
|
||||
{
|
||||
step: 'glb_bbox',
|
||||
label: 'Compute Bounding Box',
|
||||
family: 'shared',
|
||||
module_key: 'geometry.compute_bbox',
|
||||
category: 'processing',
|
||||
description: 'Compute GLB bounding box.',
|
||||
node_type: 'processNode',
|
||||
icon: 'layers',
|
||||
defaults: {},
|
||||
fields: [],
|
||||
execution_kind: 'native',
|
||||
legacy_compatible: false,
|
||||
input_contract: { requires: ['cad_preview'] },
|
||||
output_contract: { provides: ['bbox'] },
|
||||
artifact_roles_produced: [],
|
||||
artifact_roles_consumed: [],
|
||||
legacy_source: null,
|
||||
},
|
||||
{
|
||||
step: 'material_map_resolve',
|
||||
label: 'Resolve Material Map',
|
||||
family: 'order_line',
|
||||
module_key: 'materials.resolve_map',
|
||||
category: 'processing',
|
||||
description: 'Resolve material mapping.',
|
||||
node_type: 'processNode',
|
||||
icon: 'layers',
|
||||
defaults: {},
|
||||
fields: [],
|
||||
execution_kind: 'native',
|
||||
legacy_compatible: false,
|
||||
input_contract: { context: 'order_line', requires: ['order_line'] },
|
||||
output_contract: { context: 'order_line', provides: ['material_map'] },
|
||||
artifact_roles_produced: [],
|
||||
artifact_roles_consumed: [],
|
||||
legacy_source: null,
|
||||
},
|
||||
{
|
||||
step: 'blender_still',
|
||||
label: 'Blender Still',
|
||||
@@ -54,6 +230,44 @@ const nodeDefinitions: WorkflowNodeDefinition[] = [
|
||||
artifact_roles_consumed: ['cad_preview'],
|
||||
legacy_source: null,
|
||||
},
|
||||
{
|
||||
step: 'output_save',
|
||||
label: 'Save Output',
|
||||
family: 'order_line',
|
||||
module_key: 'media.save_output',
|
||||
category: 'output',
|
||||
description: 'Save rendered output.',
|
||||
node_type: 'outputNode',
|
||||
icon: 'download',
|
||||
defaults: {},
|
||||
fields: [],
|
||||
execution_kind: 'bridge',
|
||||
legacy_compatible: true,
|
||||
input_contract: { context: 'order_line', requires: ['rendered_image'] },
|
||||
output_contract: { context: 'order_line', provides: ['saved_output'] },
|
||||
artifact_roles_produced: [],
|
||||
artifact_roles_consumed: [],
|
||||
legacy_source: 'legacy.output_save',
|
||||
},
|
||||
{
|
||||
step: 'notify',
|
||||
label: 'Notify Result',
|
||||
family: 'order_line',
|
||||
module_key: 'notifications.emit',
|
||||
category: 'output',
|
||||
description: 'Emit completion notification.',
|
||||
node_type: 'outputNode',
|
||||
icon: 'bell',
|
||||
defaults: {},
|
||||
fields: [],
|
||||
execution_kind: 'bridge',
|
||||
legacy_compatible: true,
|
||||
input_contract: { context: 'order_line', requires: ['saved_output'] },
|
||||
output_contract: { context: 'order_line', provides: ['notification'] },
|
||||
artifact_roles_produced: [],
|
||||
artifact_roles_consumed: [],
|
||||
legacy_source: 'legacy.notify',
|
||||
},
|
||||
]
|
||||
|
||||
const shadowRun: WorkflowRun = {
|
||||
@@ -72,7 +286,10 @@ const shadowRun: WorkflowRun = {
|
||||
id: 'node-result-1',
|
||||
node_name: 'Blender Still',
|
||||
status: 'completed',
|
||||
output: null,
|
||||
output: {
|
||||
image_path: '/tmp/render.png',
|
||||
artifact_role: 'png_output',
|
||||
},
|
||||
log: 'Rendered successfully.',
|
||||
duration_s: 2.3,
|
||||
created_at: '2026-04-08T10:02:00Z',
|
||||
@@ -87,6 +304,16 @@ const shadowComparison: WorkflowRunComparison = {
|
||||
execution_mode: 'shadow',
|
||||
status: 'matched',
|
||||
summary: 'Observer output matches authoritative output.',
|
||||
rollout_gate_verdict: 'pass',
|
||||
workflow_rollout_ready: true,
|
||||
workflow_rollout_status: 'ready_for_rollout',
|
||||
rollout_reasons: [
|
||||
'Observer output matches the authoritative legacy output byte-for-byte.',
|
||||
],
|
||||
rollout_thresholds: {
|
||||
pass_max_mean_pixel_delta: 0.000001,
|
||||
warn_max_mean_pixel_delta: 0.02,
|
||||
},
|
||||
authoritative_output: {
|
||||
path: null,
|
||||
storage_key: null,
|
||||
@@ -122,7 +349,7 @@ const preflightResponse: WorkflowPreflightResponse = {
|
||||
summary: 'Graph requires one missing upstream artifact.',
|
||||
resolved_order_line_id: 'ol-1',
|
||||
resolved_cad_file_id: null,
|
||||
unsupported_node_ids: [],
|
||||
unsupported_node_ids: ['node-legacy-1'],
|
||||
issues: [
|
||||
{
|
||||
severity: 'warning',
|
||||
@@ -168,6 +395,7 @@ describe('WorkflowNodeContractCard', () => {
|
||||
inputContextLabel="Order Rendering"
|
||||
outputContextLabel="Order Rendering"
|
||||
requiredInputs={['order_line', 'render_template']}
|
||||
requiredAnyInputs={[['rendered_image', 'rendered_frames']]}
|
||||
consumedArtifacts={['cad_preview']}
|
||||
providedOutputs={['render_image']}
|
||||
producedArtifacts={['png_output']}
|
||||
@@ -180,7 +408,8 @@ describe('WorkflowNodeContractCard', () => {
|
||||
expect(screen.getByText('legacy.still_render')).toBeInTheDocument()
|
||||
expect(screen.getByText('Order Line')).toBeInTheDocument()
|
||||
expect(screen.getByText('Render Template')).toBeInTheDocument()
|
||||
expect(screen.getByText('Cad Preview')).toBeInTheDocument()
|
||||
expect(screen.getByText('Any of: Rendered Image / Rendered Frames')).toBeInTheDocument()
|
||||
expect(screen.getByText('CAD Preview')).toBeInTheDocument()
|
||||
expect(screen.getByText('Render Image')).toBeInTheDocument()
|
||||
expect(screen.getByText('Png Output')).toBeInTheDocument()
|
||||
})
|
||||
@@ -197,19 +426,58 @@ describe('WorkflowCanvasToolbar', () => {
|
||||
const onPreflight = vi.fn()
|
||||
const onDispatch = vi.fn()
|
||||
const onSave = vi.fn()
|
||||
const onRollbackOutputType = vi.fn()
|
||||
|
||||
render(
|
||||
<WorkflowCanvasToolbar
|
||||
workflowName="Still Image - Graph"
|
||||
blueprintLabel="Still Graph"
|
||||
blueprintDescription="Reference graph for the non-legacy still render path."
|
||||
authoringFamilyLabel="Order Rendering"
|
||||
authoringFamilyClassName="bg-emerald-100 text-emerald-700"
|
||||
graphFamilyLabel="Order Rendering"
|
||||
graphFamilyClassName="bg-emerald-100 text-emerald-700"
|
||||
executionMode="graph"
|
||||
executionModeLabel="Graph"
|
||||
executionModeClassName="bg-green-100 text-green-700"
|
||||
executionModeHint="Production dispatch uses graph runtime with fallback."
|
||||
dispatchContextId=""
|
||||
rolloutBadgeLabel="Shadow"
|
||||
rolloutBadgeClassName="bg-sky-100 text-sky-700"
|
||||
rolloutStatusLabel="Legacy Authoritative"
|
||||
rolloutStatusClassName="bg-sky-100 text-sky-700"
|
||||
rolloutSummary="Latest shadow verdict: pass."
|
||||
linkedOutputTypeCount={2}
|
||||
linkedOutputTypes={[
|
||||
{
|
||||
id: 'ot-1',
|
||||
name: 'Shadow Still Output',
|
||||
is_active: true,
|
||||
artifact_kind: 'still_image',
|
||||
workflow_rollout_mode: 'shadow',
|
||||
},
|
||||
{
|
||||
id: 'ot-2',
|
||||
name: 'Legacy Archive Output',
|
||||
is_active: false,
|
||||
artifact_kind: 'blend_asset',
|
||||
workflow_rollout_mode: 'legacy_only',
|
||||
},
|
||||
]}
|
||||
dispatchContextKind="order_line"
|
||||
dispatchContextLabel="Order Line"
|
||||
dispatchContextId="line-1"
|
||||
dispatchContextSummary="Product A · Still"
|
||||
dispatchContextMeta="ORD-1001 · pending"
|
||||
orderLineContextGroups={[
|
||||
{
|
||||
orderId: 'order-1',
|
||||
orderLabel: 'ORD-1001',
|
||||
options: [
|
||||
{ value: 'line-1', label: 'Product A · Still', meta: 'ORD-1001 · pending' },
|
||||
{ value: 'line-2', label: 'Product B · Still', meta: 'ORD-1001 · completed' },
|
||||
],
|
||||
},
|
||||
]}
|
||||
executionModes={[
|
||||
{ value: 'legacy', label: 'Legacy' },
|
||||
{ value: 'graph', label: 'Graph' },
|
||||
@@ -217,39 +485,66 @@ describe('WorkflowCanvasToolbar', () => {
|
||||
]}
|
||||
selectedEdgeCount={2}
|
||||
canAutoLayout
|
||||
canPreflight
|
||||
canDispatch
|
||||
hasValidationErrors={false}
|
||||
isPreflightPending={false}
|
||||
isDispatchPending={false}
|
||||
isContextOptionsLoading={false}
|
||||
isSaving={false}
|
||||
rollbackPendingOutputTypeId={null}
|
||||
preflightState="ready"
|
||||
authoringActions={{ openNodeMenu: onOpenNodeMenu }}
|
||||
authoringEntryAction={{
|
||||
label: 'Author',
|
||||
title: 'Open guided workflow authoring browser',
|
||||
helper: 'Open reference paths, production modules, starter steps, and raw nodes.',
|
||||
icon: () => null,
|
||||
}}
|
||||
onDispatchContextIdChange={onDispatchContextIdChange}
|
||||
onExecutionModeChange={onExecutionModeChange}
|
||||
onOpenNodeMenu={onOpenNodeMenu}
|
||||
onAutoLayout={onAutoLayout}
|
||||
onDeleteSelectedEdges={onDeleteSelectedEdges}
|
||||
onPreflight={onPreflight}
|
||||
onDispatch={onDispatch}
|
||||
onSave={onSave}
|
||||
onRollbackOutputType={onRollbackOutputType}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Workflow Canvas')).toBeInTheDocument()
|
||||
expect(screen.getByText('Still Image - Graph')).toBeInTheDocument()
|
||||
expect(screen.getByText('Still Graph')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('Shadow').length).toBeGreaterThan(0)
|
||||
expect(screen.getByText('Legacy Authoritative')).toBeInTheDocument()
|
||||
expect(screen.getByText(/2 linked output types/i)).toBeInTheDocument()
|
||||
expect(screen.getByText('Rollout Controls')).toBeInTheDocument()
|
||||
expect(screen.getByText('Shadow Still Output')).toBeInTheDocument()
|
||||
expect(screen.getByText('Legacy Archive Output')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('Order Line').length).toBeGreaterThan(0)
|
||||
expect(screen.getAllByText('Product A · Still').length).toBeGreaterThan(0)
|
||||
expect(screen.getByText('Right-click to add')).toBeInTheDocument()
|
||||
expect(screen.getByText('Preflight ready')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Delete (2)' })).toBeEnabled()
|
||||
const rollbackButtons = screen.getAllByRole('button', { name: 'Set Legacy' })
|
||||
expect(rollbackButtons.length).toBe(2)
|
||||
expect(rollbackButtons[0]?.getAttribute('title')).toContain('legacy_only')
|
||||
expect(rollbackButtons[1]).toBeDisabled()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Node' }))
|
||||
await user.click(screen.getByRole('button', { name: 'Author' }))
|
||||
await user.click(screen.getByRole('button', { name: 'Align' }))
|
||||
await user.click(screen.getByRole('button', { name: 'Delete (2)' }))
|
||||
await user.click(rollbackButtons[0] as HTMLElement)
|
||||
await user.click(screen.getByRole('button', { name: 'Dry Run' }))
|
||||
await user.click(screen.getByRole('button', { name: 'Run' }))
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }))
|
||||
await user.type(screen.getByPlaceholderText('context id'), 'order-123')
|
||||
await user.selectOptions(screen.getByRole('combobox'), 'shadow')
|
||||
await user.selectOptions(screen.getByRole('combobox', { name: 'Order line context' }), 'line-2')
|
||||
await user.selectOptions(screen.getByRole('combobox', { name: 'Mode' }), 'shadow')
|
||||
|
||||
expect(onOpenNodeMenu).toHaveBeenCalledOnce()
|
||||
expect(onAutoLayout).toHaveBeenCalledOnce()
|
||||
expect(onDeleteSelectedEdges).toHaveBeenCalledOnce()
|
||||
expect(onRollbackOutputType).toHaveBeenCalledWith('ot-1')
|
||||
expect(onPreflight).toHaveBeenCalledOnce()
|
||||
expect(onDispatch).toHaveBeenCalledOnce()
|
||||
expect(onSave).toHaveBeenCalledOnce()
|
||||
@@ -263,28 +558,54 @@ describe('WorkflowCanvasToolbar', () => {
|
||||
workflowName="CAD Intake"
|
||||
blueprintLabel={null}
|
||||
blueprintDescription={null}
|
||||
authoringFamilyLabel="CAD Intake"
|
||||
authoringFamilyClassName="bg-sky-100 text-sky-700"
|
||||
graphFamilyLabel="CAD Intake"
|
||||
graphFamilyClassName="bg-sky-100 text-sky-700"
|
||||
executionMode="legacy"
|
||||
executionModeLabel="Legacy"
|
||||
executionModeClassName="bg-slate-100 text-slate-700"
|
||||
executionModeHint="Legacy dispatcher stays authoritative."
|
||||
rolloutBadgeLabel="Unlinked"
|
||||
rolloutBadgeClassName="bg-surface-muted text-content-muted"
|
||||
rolloutStatusLabel="Legacy Only"
|
||||
rolloutStatusClassName="bg-slate-100 text-slate-700"
|
||||
rolloutSummary="No output types are linked to this workflow yet."
|
||||
linkedOutputTypeCount={0}
|
||||
linkedOutputTypes={[]}
|
||||
dispatchContextKind="cad_file"
|
||||
dispatchContextLabel="CAD File"
|
||||
dispatchContextId=""
|
||||
dispatchContextSummary={null}
|
||||
dispatchContextMeta={null}
|
||||
orderLineContextGroups={[]}
|
||||
executionModes={[{ value: 'legacy', label: 'Legacy' }]}
|
||||
selectedEdgeCount={0}
|
||||
canAutoLayout={false}
|
||||
canPreflight={false}
|
||||
canDispatch={false}
|
||||
hasValidationErrors
|
||||
isPreflightPending={false}
|
||||
isDispatchPending={false}
|
||||
isContextOptionsLoading={false}
|
||||
isSaving={false}
|
||||
rollbackPendingOutputTypeId={null}
|
||||
preflightState="required"
|
||||
authoringActions={{ openNodeMenu: vi.fn() }}
|
||||
authoringEntryAction={{
|
||||
label: 'Node',
|
||||
title: 'Open raw node browser',
|
||||
helper: 'Open the searchable node catalog directly on the canvas.',
|
||||
icon: () => null,
|
||||
}}
|
||||
onDispatchContextIdChange={vi.fn()}
|
||||
onExecutionModeChange={vi.fn()}
|
||||
onOpenNodeMenu={vi.fn()}
|
||||
onAutoLayout={vi.fn()}
|
||||
onDeleteSelectedEdges={vi.fn()}
|
||||
onPreflight={vi.fn()}
|
||||
onDispatch={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
onRollbackOutputType={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
@@ -305,7 +626,8 @@ describe('NodeCommandMenu', () => {
|
||||
<NodeCommandMenu
|
||||
definitions={nodeDefinitions}
|
||||
graphFamily="mixed"
|
||||
onSelectStep={onSelectStep}
|
||||
activeSteps={[]}
|
||||
actions={{ insertNode: step => onSelectStep(step) }}
|
||||
onClose={vi.fn()}
|
||||
renderIcon={iconName => <span>{iconName}</span>}
|
||||
/>,
|
||||
@@ -315,22 +637,187 @@ describe('NodeCommandMenu', () => {
|
||||
await user.type(screen.getByPlaceholderText('Search nodes'), 'blender{enter}')
|
||||
|
||||
expect(onSelectStep).toHaveBeenCalledWith('blender_still')
|
||||
expect(screen.getByRole('button', { name: 'All Categories' })).toBeInTheDocument()
|
||||
expect(screen.getByText('Quick Insert')).toBeInTheDocument()
|
||||
expect(screen.getByText('Graph Nodes')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('supports module insertion directly from the canvas authoring menu', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onInsertReferencePath = vi.fn()
|
||||
const onInsertModule = vi.fn()
|
||||
const onSelectStep = vi.fn()
|
||||
|
||||
render(
|
||||
<NodeCommandMenu
|
||||
definitions={nodeDefinitions}
|
||||
graphFamily="order_line"
|
||||
activeSteps={['order_line_setup']}
|
||||
actions={{
|
||||
insertNode: step => onSelectStep(step),
|
||||
insertReferencePath: bundleId => onInsertReferencePath(bundleId),
|
||||
insertModule: bundleId => onInsertModule(bundleId),
|
||||
}}
|
||||
onClose={vi.fn()}
|
||||
renderIcon={iconName => <span>{iconName}</span>}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Overview' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Paths' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Modules' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Starter' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Nodes' })).toBeInTheDocument()
|
||||
expect(screen.getByText('Recommended Path')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Insert Still Reference' })).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Insert Still Reference' }))
|
||||
|
||||
expect(onInsertReferencePath).toHaveBeenCalledWith('still_render_reference')
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Paths' }))
|
||||
expect(screen.getByText('Reference Paths')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Insert Still Render Reference' })).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Insert Still Render Reference' }))
|
||||
|
||||
expect(onInsertReferencePath).toHaveBeenNthCalledWith(2, 'still_render_reference')
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Modules' }))
|
||||
expect(screen.getByText('Production Modules')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Insert Still Render Core' }))
|
||||
|
||||
expect(onInsertModule).toHaveBeenCalledWith('still_render_core')
|
||||
})
|
||||
})
|
||||
|
||||
describe('NodeDefinitionsPanel', () => {
|
||||
test('groups nodes by runtime bucket and module in the utility rail library view', () => {
|
||||
test('organizes library authoring into overview and focused browser sections', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<NodeDefinitionsPanel definitions={nodeDefinitions} graphFamily="mixed" />)
|
||||
|
||||
expect(screen.getByText('Node Library')).toBeInTheDocument()
|
||||
expect(screen.getByText('Authoring Browser')).toBeInTheDocument()
|
||||
expect(screen.getByText('Authoring Flow')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Paths' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Modules' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Nodes' })).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Paths' }))
|
||||
expect(screen.getByText('Reference Paths')).toBeInTheDocument()
|
||||
expect(screen.getByText('Still Render Reference')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Modules' }))
|
||||
expect(screen.getByText('Production Modules')).toBeInTheDocument()
|
||||
expect(screen.getByText('Still Render Core')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Nodes' }))
|
||||
expect(screen.getAllByText('Raw Node Catalog').length).toBeGreaterThan(0)
|
||||
expect(screen.getByText('Quick Insert')).toBeInTheDocument()
|
||||
expect(screen.getByText('Runtime')).toBeInTheDocument()
|
||||
expect(screen.getByText('Family')).toBeInTheDocument()
|
||||
expect(screen.getByText('Category')).toBeInTheDocument()
|
||||
expect(screen.getByPlaceholderText('Search modules')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('CAD Intake').length).toBeGreaterThan(0)
|
||||
expect(screen.getAllByText('Order Rendering').length).toBeGreaterThan(0)
|
||||
expect(screen.getByText('Legacy Nodes')).toBeInTheDocument()
|
||||
expect(screen.getByText('Graph Nodes')).toBeInTheDocument()
|
||||
expect(screen.getByText('Blender Still')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('Blender Still').length).toBeGreaterThan(0)
|
||||
expect(screen.getAllByText('Graph').length).toBeGreaterThan(0)
|
||||
expect(screen.getByRole('button', { name: 'All Modules' })).toBeInTheDocument()
|
||||
expect(screen.getAllByText('Cad').length).toBeGreaterThan(0)
|
||||
expect(screen.getAllByText('Notifications').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('shows starter-path progress for still-render authoring flows', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onInsertReferencePath = vi.fn()
|
||||
const onInsertModule = vi.fn()
|
||||
const onSelectStep = vi.fn()
|
||||
render(
|
||||
<NodeDefinitionsPanel
|
||||
definitions={nodeDefinitions}
|
||||
graphFamily="order_line"
|
||||
activeSteps={['blender_still']}
|
||||
actions={{
|
||||
insertReferencePath: bundleId => onInsertReferencePath(bundleId),
|
||||
insertModule: bundleId => onInsertModule(bundleId),
|
||||
insertNode: step => onSelectStep(step),
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
|
||||
const stageStatusHeading = screen.getByText('Stage Status')
|
||||
const recommendedPathHeading = screen.getByText('Recommended Path')
|
||||
|
||||
expect(stageStatusHeading).toBeInTheDocument()
|
||||
expect(recommendedPathHeading).toBeInTheDocument()
|
||||
expect(
|
||||
stageStatusHeading.compareDocumentPosition(recommendedPathHeading) &
|
||||
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy()
|
||||
expect(screen.getByText('Still Render Reference')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Reapply Still Reference' })).toBeInTheDocument()
|
||||
expect(screen.getAllByRole('button', { name: 'Insert Publish' }).length).toBeGreaterThan(0)
|
||||
expect(screen.getAllByRole('button', { name: 'Add Order Line Setup' }).length).toBeGreaterThan(0)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Reapply Still Reference' }))
|
||||
await user.click(screen.getAllByRole('button', { name: 'Insert Publish' })[0] as HTMLElement)
|
||||
await user.click(screen.getAllByRole('button', { name: 'Add Order Line Setup' })[0] as HTMLElement)
|
||||
|
||||
expect(onInsertReferencePath).toHaveBeenCalledWith('still_render_reference')
|
||||
expect(onInsertModule).toHaveBeenCalledWith('output_publish_notify')
|
||||
expect(onSelectStep).toHaveBeenCalledWith('order_line_setup')
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Starter' }))
|
||||
expect(screen.getByText('Starter Path')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('Still-render assembly').length).toBeGreaterThan(0)
|
||||
expect(screen.getAllByText('1/8 present').length).toBeGreaterThan(0)
|
||||
expect(screen.getAllByText('Present').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('gives CAD authoring the same guided reference-path flow without duplicate intake stages', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onInsertReferencePath = vi.fn()
|
||||
const onInsertModule = vi.fn()
|
||||
const onSelectStep = vi.fn()
|
||||
|
||||
render(
|
||||
<NodeDefinitionsPanel
|
||||
definitions={nodeDefinitions}
|
||||
graphFamily="cad_file"
|
||||
activeSteps={['resolve_step_path']}
|
||||
actions={{
|
||||
insertReferencePath: bundleId => onInsertReferencePath(bundleId),
|
||||
insertModule: bundleId => onInsertModule(bundleId),
|
||||
insertNode: step => onSelectStep(step),
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Stage Status')).toBeInTheDocument()
|
||||
expect(screen.getByText('Start with the CAD intake assembly')).toBeInTheDocument()
|
||||
expect(screen.getByText('CAD Intake Reference')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Reapply CAD Intake Reference' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Complete CAD Intake' })).not.toBeInTheDocument()
|
||||
expect(screen.getAllByRole('button', { name: 'Add Extract OCC Objects' }).length).toBeGreaterThan(0)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Reapply CAD Intake Reference' }))
|
||||
await user.click(screen.getAllByRole('button', { name: 'Add Extract OCC Objects' })[0] as HTMLElement)
|
||||
|
||||
expect(onInsertReferencePath).toHaveBeenCalledWith('cad_intake_reference')
|
||||
expect(onInsertModule).not.toHaveBeenCalled()
|
||||
expect(onSelectStep).toHaveBeenCalledWith('occ_object_extract')
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Paths' }))
|
||||
expect(screen.getByText('Reference Paths')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Insert CAD Intake Reference' })).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Starter' }))
|
||||
expect(screen.getByText('Starter Path')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('CAD intake assembly').length).toBeGreaterThan(0)
|
||||
expect(screen.getAllByText('1/5 present').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('supports direct node insertion from the library sidebar', async () => {
|
||||
@@ -341,15 +828,89 @@ describe('NodeDefinitionsPanel', () => {
|
||||
<NodeDefinitionsPanel
|
||||
definitions={nodeDefinitions}
|
||||
graphFamily="mixed"
|
||||
onSelectStep={onSelectStep}
|
||||
actions={{ insertNode: step => onSelectStep(step) }}
|
||||
renderIcon={iconName => <span>{iconName}</span>}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getAllByRole('button', { name: 'Insert' })[1])
|
||||
await user.click(screen.getByRole('button', { name: 'Nodes' }))
|
||||
const blenderCard = screen.getAllByText('Blender Still')[0]?.closest('div.rounded-lg')
|
||||
expect(blenderCard).not.toBeNull()
|
||||
await user.click(within(blenderCard as HTMLElement).getByRole('button', { name: 'Insert Blender Still' }))
|
||||
|
||||
expect(onSelectStep).toHaveBeenCalledWith('blender_still')
|
||||
})
|
||||
|
||||
test('supports direct workflow-module insertion from the library sidebar', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onInsertModule = vi.fn()
|
||||
|
||||
render(
|
||||
<NodeDefinitionsPanel
|
||||
definitions={nodeDefinitions}
|
||||
graphFamily="order_line"
|
||||
activeSteps={[]}
|
||||
actions={{ insertModule: bundleId => onInsertModule(bundleId) }}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Modules' }))
|
||||
await user.click(screen.getByRole('button', { name: 'Insert Still Render Core' }))
|
||||
|
||||
expect(onInsertModule).toHaveBeenCalledWith('still_render_core')
|
||||
})
|
||||
})
|
||||
|
||||
describe('workflowAuthoringActions', () => {
|
||||
test('binds preferred position and after-insert callback once for every insert action', () => {
|
||||
const insertNode = vi.fn()
|
||||
const insertModule = vi.fn()
|
||||
const insertReferencePath = vi.fn()
|
||||
const onAfterInsert = vi.fn()
|
||||
|
||||
const bindings = bindWorkflowAuthoringInsertActions(
|
||||
{
|
||||
insertNode,
|
||||
insertModule,
|
||||
insertReferencePath,
|
||||
},
|
||||
{
|
||||
preferredPosition: { x: 240, y: 180 },
|
||||
onAfterInsert,
|
||||
},
|
||||
)
|
||||
|
||||
bindings.onSelectStep?.('blender_still')
|
||||
bindings.onInsertModule?.('still_render_core')
|
||||
bindings.onInsertReferencePath?.('still_render_reference')
|
||||
|
||||
expect(insertNode).toHaveBeenCalledWith('blender_still', { x: 240, y: 180 })
|
||||
expect(insertModule).toHaveBeenCalledWith('still_render_core', { x: 240, y: 180 })
|
||||
expect(insertReferencePath).toHaveBeenCalledWith('still_render_reference', { x: 240, y: 180 })
|
||||
expect(onAfterInsert).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
test('derives the primary authoring entry from the shared surface model', () => {
|
||||
const guidedEntry = getWorkflowAuthoringEntryAction(
|
||||
getWorkflowAuthoringSurfaceModel({
|
||||
definitions: nodeDefinitions,
|
||||
graphFamily: 'order_line',
|
||||
activeSteps: [],
|
||||
}),
|
||||
)
|
||||
const rawEntry = getWorkflowAuthoringEntryAction(
|
||||
getWorkflowAuthoringSurfaceModel({
|
||||
definitions: nodeDefinitions,
|
||||
graphFamily: 'mixed',
|
||||
activeSteps: [],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(guidedEntry.label).toBe('Author')
|
||||
expect(guidedEntry.title).toContain('guided workflow authoring')
|
||||
expect(rawEntry.label).toBe('Node')
|
||||
expect(rawEntry.title).toContain('raw node browser')
|
||||
})
|
||||
})
|
||||
|
||||
describe('WorkflowRunsPanel', () => {
|
||||
@@ -370,6 +931,15 @@ describe('WorkflowRunsPanel', () => {
|
||||
expect(screen.getByText('Workflow Runs')).toBeInTheDocument()
|
||||
expect(screen.getByText('Shadow Comparison')).toBeInTheDocument()
|
||||
expect(screen.getByText('Observer output matches authoritative output.')).toBeInTheDocument()
|
||||
expect(screen.getByText('Execution Mode')).toBeInTheDocument()
|
||||
expect(screen.getByText('Celery Task')).toBeInTheDocument()
|
||||
expect(screen.getByText('Duration: 2.3 s')).toBeInTheDocument()
|
||||
expect(screen.getByText('Rollout Gate: pass')).toBeInTheDocument()
|
||||
expect(screen.getByText('Ready For Rollout')).toBeInTheDocument()
|
||||
expect(screen.getByText('Operator Decision')).toBeInTheDocument()
|
||||
expect(screen.getByText('Observer output matches the authoritative legacy output byte-for-byte.')).toBeInTheDocument()
|
||||
expect(screen.getByText('Exact Match: yes')).toBeInTheDocument()
|
||||
expect(screen.getByText('Dimensions: match')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /run-shad/i }))
|
||||
|
||||
@@ -384,6 +954,12 @@ describe('WorkflowPreflightPanel', () => {
|
||||
expect(screen.getByText('Graph Preflight')).toBeInTheDocument()
|
||||
expect(screen.getByText('Graph requires one missing upstream artifact.')).toBeInTheDocument()
|
||||
expect(screen.getByText('Missing cad_preview artifact.')).toBeInTheDocument()
|
||||
expect(screen.getByText('Mode: graph')).toBeInTheDocument()
|
||||
expect(screen.getByText('Unsupported Node IDs')).toBeInTheDocument()
|
||||
expect(screen.getByText('node-legacy-1')).toBeInTheDocument()
|
||||
expect(screen.getByText('Code: missing-artifact')).toBeInTheDocument()
|
||||
expect(screen.getByText('Runtime: native')).toBeInTheDocument()
|
||||
expect(screen.getByText('Supported: yes')).toBeInTheDocument()
|
||||
expect(screen.getByText('cad_preview must be produced upstream.')).toBeInTheDocument()
|
||||
expect(screen.getByText('blocked')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -2,7 +2,16 @@ import type { Edge, Node } from '@xyflow/react'
|
||||
import { describe, expect, test } from 'vitest'
|
||||
|
||||
import type { WorkflowNodeDefinition } from '../../api/workflows'
|
||||
import { resolveParamsForStepChange, validateWorkflowDraft } from '../../components/workflows/workflowGraphDraft'
|
||||
import {
|
||||
buildWorkflowCanvasNodeData,
|
||||
graphNeedsAutoLayout,
|
||||
resolveParamsForStepChange,
|
||||
resolveNodeCollisions,
|
||||
validateWorkflowDraft,
|
||||
WORKFLOW_NODE_MIN_HEIGHT,
|
||||
WORKFLOW_NODE_VERTICAL_GAP,
|
||||
workflowToGraph,
|
||||
} from '../../components/workflows/workflowGraphDraft'
|
||||
|
||||
function createNode(id: string, step: string, label = step): Node {
|
||||
return {
|
||||
@@ -17,6 +26,13 @@ function createNode(id: string, step: string, label = step): Node {
|
||||
} as Node
|
||||
}
|
||||
|
||||
function createPositionedNode(id: string, step: string, x: number, y: number, label = step): Node {
|
||||
return {
|
||||
...createNode(id, step, label),
|
||||
position: { x, y },
|
||||
}
|
||||
}
|
||||
|
||||
function createEdge(source: string, target: string): Edge {
|
||||
return {
|
||||
id: `${source}-${target}`,
|
||||
@@ -105,7 +121,7 @@ const definitions: Record<string, WorkflowNodeDefinition> = {
|
||||
glb_bbox: {
|
||||
step: 'glb_bbox',
|
||||
label: 'Compute Bounding Box',
|
||||
family: 'order_line',
|
||||
family: 'shared',
|
||||
module_key: 'geometry.compute_bbox',
|
||||
category: 'processing',
|
||||
description: 'Compute bbox.',
|
||||
@@ -115,8 +131,8 @@ const definitions: Record<string, WorkflowNodeDefinition> = {
|
||||
fields: [],
|
||||
execution_kind: 'bridge',
|
||||
legacy_compatible: true,
|
||||
input_contract: { context: 'order_line', requires: ['glb_preview'] },
|
||||
output_contract: { context: 'order_line', provides: ['bbox'] },
|
||||
input_contract: { requires: ['glb_preview'] },
|
||||
output_contract: { provides: ['bbox'] },
|
||||
artifact_roles_consumed: ['glb_preview'],
|
||||
artifact_roles_produced: ['bbox'],
|
||||
legacy_source: 'legacy.glb_bbox',
|
||||
@@ -160,7 +176,7 @@ const definitions: Record<string, WorkflowNodeDefinition> = {
|
||||
},
|
||||
],
|
||||
execution_kind: 'native',
|
||||
legacy_compatible: false,
|
||||
legacy_compatible: true,
|
||||
input_contract: {
|
||||
context: 'order_line',
|
||||
requires: ['order_line_context', 'render_template', 'material_assignments', 'bbox'],
|
||||
@@ -208,6 +224,25 @@ const definitions: Record<string, WorkflowNodeDefinition> = {
|
||||
artifact_roles_produced: ['notification_event'],
|
||||
legacy_source: 'legacy.notify',
|
||||
},
|
||||
export_blend: {
|
||||
step: 'export_blend',
|
||||
label: 'Export Blend',
|
||||
family: 'order_line',
|
||||
module_key: 'rendering.export_blend',
|
||||
category: 'output',
|
||||
description: 'Export blend asset.',
|
||||
node_type: 'outputNode',
|
||||
icon: 'download',
|
||||
defaults: {},
|
||||
fields: [],
|
||||
execution_kind: 'bridge',
|
||||
legacy_compatible: true,
|
||||
input_contract: { context: 'order_line', requires: ['render_template'] },
|
||||
output_contract: { context: 'order_line', provides: ['blend_asset'] },
|
||||
artifact_roles_consumed: ['render_template'],
|
||||
artifact_roles_produced: ['blend_asset'],
|
||||
legacy_source: 'legacy.export_blend',
|
||||
},
|
||||
resolve_step_path: {
|
||||
step: 'resolve_step_path',
|
||||
label: 'Resolve STEP Path',
|
||||
@@ -299,6 +334,46 @@ describe('validateWorkflowDraft', () => {
|
||||
expect(result.errors).toEqual([])
|
||||
})
|
||||
|
||||
test('accepts legacy-compatible still render chains without an explicit material map resolver', () => {
|
||||
const nodes = [
|
||||
createNode('setup', 'order_line_setup', 'Order Line Setup'),
|
||||
createNode('template', 'resolve_template', 'Resolve Template'),
|
||||
createNode('bbox', 'glb_bbox', 'Compute Bounding Box'),
|
||||
createNode('render', 'blender_still', 'Render Still'),
|
||||
createNode('save', 'output_save', 'Save Output'),
|
||||
]
|
||||
const edges = [
|
||||
createEdge('setup', 'template'),
|
||||
createEdge('setup', 'bbox'),
|
||||
createEdge('setup', 'render'),
|
||||
createEdge('template', 'render'),
|
||||
createEdge('bbox', 'render'),
|
||||
createEdge('render', 'save'),
|
||||
]
|
||||
|
||||
const result = validateWorkflowDraft(nodes, edges, definitions, true)
|
||||
|
||||
expect(result.errors).toEqual([])
|
||||
})
|
||||
|
||||
test('accepts notify nodes fed by blend-export outputs', () => {
|
||||
const nodes = [
|
||||
createNode('setup', 'order_line_setup', 'Order Line Setup'),
|
||||
createNode('template', 'resolve_template', 'Resolve Template'),
|
||||
createNode('blend', 'export_blend', 'Export Blend'),
|
||||
createNode('notify', 'notify', 'Notify'),
|
||||
]
|
||||
const edges = [
|
||||
createEdge('setup', 'template'),
|
||||
createEdge('template', 'blend'),
|
||||
createEdge('blend', 'notify'),
|
||||
]
|
||||
|
||||
const result = validateWorkflowDraft(nodes, edges, definitions, true)
|
||||
|
||||
expect(result.errors).toEqual([])
|
||||
})
|
||||
|
||||
test('blocks mixed CAD-file and order-line graphs', () => {
|
||||
const result = validateWorkflowDraft(
|
||||
[createNode('cad', 'resolve_step_path', 'Resolve STEP Path'), createNode('render', 'blender_still', 'Render Still')],
|
||||
@@ -309,6 +384,92 @@ describe('validateWorkflowDraft', () => {
|
||||
|
||||
expect(result.errors).toContain('Workflow mixes CAD-file and order-line nodes. Split them into separate workflows.')
|
||||
})
|
||||
|
||||
test('accepts a CAD intake graph that feeds shared bbox into threejs thumbnail render', () => {
|
||||
const threejsDefinition: WorkflowNodeDefinition = {
|
||||
step: 'threejs_render',
|
||||
label: 'Render Thumbnail',
|
||||
family: 'cad_file',
|
||||
module_key: 'render.thumbnail.threejs',
|
||||
category: 'rendering',
|
||||
description: 'Render a thumbnail from the GLB preview.',
|
||||
node_type: 'renderNode',
|
||||
icon: 'camera',
|
||||
defaults: {},
|
||||
fields: [],
|
||||
execution_kind: 'bridge',
|
||||
legacy_compatible: true,
|
||||
input_contract: { context: 'cad_file', requires: ['glb_preview', 'bbox'] },
|
||||
output_contract: { context: 'cad_file', provides: ['rendered_image'] },
|
||||
artifact_roles_consumed: ['glb_preview', 'bbox'],
|
||||
artifact_roles_produced: ['rendered_image'],
|
||||
legacy_source: 'legacy.threejs_render',
|
||||
}
|
||||
const thumbnailSaveDefinition: WorkflowNodeDefinition = {
|
||||
step: 'thumbnail_save',
|
||||
label: 'Save Thumbnail',
|
||||
family: 'cad_file',
|
||||
module_key: 'media.save_thumbnail',
|
||||
category: 'output',
|
||||
description: 'Persist the thumbnail.',
|
||||
node_type: 'outputNode',
|
||||
icon: 'download',
|
||||
defaults: {},
|
||||
fields: [],
|
||||
execution_kind: 'bridge',
|
||||
legacy_compatible: true,
|
||||
input_contract: { context: 'cad_file', requires: ['rendered_image'] },
|
||||
output_contract: { context: 'cad_file', provides: ['cad_thumbnail_media'] },
|
||||
artifact_roles_consumed: ['rendered_image'],
|
||||
artifact_roles_produced: ['cad_thumbnail_media'],
|
||||
legacy_source: 'legacy.thumbnail_save',
|
||||
}
|
||||
const occGlbDefinition: WorkflowNodeDefinition = {
|
||||
step: 'occ_glb_export',
|
||||
label: 'Export GLB',
|
||||
family: 'cad_file',
|
||||
module_key: 'cad.export_glb',
|
||||
category: 'processing',
|
||||
description: 'Export a GLB preview.',
|
||||
node_type: 'processNode',
|
||||
icon: 'layers',
|
||||
defaults: {},
|
||||
fields: [],
|
||||
execution_kind: 'bridge',
|
||||
legacy_compatible: true,
|
||||
input_contract: { context: 'cad_file', requires: ['step_path'] },
|
||||
output_contract: { context: 'cad_file', provides: ['glb_preview'] },
|
||||
artifact_roles_consumed: ['step_path'],
|
||||
artifact_roles_produced: ['glb_preview'],
|
||||
legacy_source: 'legacy.occ_glb_export',
|
||||
}
|
||||
|
||||
const result = validateWorkflowDraft(
|
||||
[
|
||||
createNode('resolve', 'resolve_step_path', 'Resolve STEP Path'),
|
||||
createNode('glb', 'occ_glb_export', 'Export GLB'),
|
||||
createNode('bbox', 'glb_bbox', 'Compute Bounding Box'),
|
||||
createNode('thumb', 'threejs_render', 'Render Thumbnail'),
|
||||
createNode('save', 'thumbnail_save', 'Save Thumbnail'),
|
||||
],
|
||||
[
|
||||
createEdge('resolve', 'glb'),
|
||||
createEdge('glb', 'bbox'),
|
||||
createEdge('glb', 'thumb'),
|
||||
createEdge('bbox', 'thumb'),
|
||||
createEdge('thumb', 'save'),
|
||||
],
|
||||
{
|
||||
...definitions,
|
||||
occ_glb_export: occGlbDefinition,
|
||||
threejs_render: threejsDefinition,
|
||||
thumbnail_save: thumbnailSaveDefinition,
|
||||
},
|
||||
true,
|
||||
)
|
||||
|
||||
expect(result.errors).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveParamsForStepChange', () => {
|
||||
@@ -325,4 +486,103 @@ describe('resolveParamsForStepChange', () => {
|
||||
width: 1024,
|
||||
})
|
||||
})
|
||||
|
||||
test('preserves dynamic template input overrides for resolve_template nodes', () => {
|
||||
const next = resolveParamsForStepChange(definitions.resolve_template, {
|
||||
template_id_override: 'd7d7a1bb-2f14-4d83-99d1-7d7e36eb05d9',
|
||||
template_input__studio_variant: 'warm',
|
||||
template_input__camera_profile: 'macro',
|
||||
stale_key: 'drop-me',
|
||||
})
|
||||
|
||||
expect(next).toEqual({
|
||||
template_input__studio_variant: 'warm',
|
||||
template_input__camera_profile: 'macro',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveNodeCollisions', () => {
|
||||
test('pushes overlapping nodes away from a settled anchor without moving the anchor', () => {
|
||||
const nodes = [
|
||||
createPositionedNode('anchor', 'order_line_setup', 56, 48, 'Anchor'),
|
||||
createPositionedNode('overlap', 'resolve_template', 88, 76, 'Overlap'),
|
||||
]
|
||||
|
||||
const resolved = resolveNodeCollisions(nodes, ['anchor'])
|
||||
|
||||
expect(resolved.find(node => node.id === 'anchor')?.position).toEqual({ x: 56, y: 48 })
|
||||
expect(graphNeedsAutoLayout(resolved)).toBe(false)
|
||||
expect(resolved.find(node => node.id === 'overlap')?.position.y).toBeGreaterThanOrEqual(
|
||||
48 + WORKFLOW_NODE_MIN_HEIGHT + WORKFLOW_NODE_VERTICAL_GAP,
|
||||
)
|
||||
})
|
||||
|
||||
test('cascades pushed nodes so stacked collisions are fully resolved', () => {
|
||||
const nodes = [
|
||||
createPositionedNode('anchor', 'order_line_setup', 56, 48, 'Anchor'),
|
||||
createPositionedNode('middle', 'resolve_template', 56, 48, 'Middle'),
|
||||
createPositionedNode('tail', 'blender_still', 64, 56, 'Tail'),
|
||||
]
|
||||
|
||||
const resolved = resolveNodeCollisions(nodes, ['anchor'])
|
||||
const middle = resolved.find(node => node.id === 'middle')
|
||||
const tail = resolved.find(node => node.id === 'tail')
|
||||
|
||||
expect(middle).toBeTruthy()
|
||||
expect(tail).toBeTruthy()
|
||||
expect(graphNeedsAutoLayout(resolved)).toBe(false)
|
||||
expect(middle?.position).not.toEqual({ x: 56, y: 48 })
|
||||
expect(tail?.position).not.toEqual({ x: 64, y: 56 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('workflowToGraph', () => {
|
||||
test('derives explicit input and output ports from the node contract', () => {
|
||||
const data = buildWorkflowCanvasNodeData('blender_still', {}, definitions.blender_still)
|
||||
|
||||
expect(data.inputPorts?.map(port => port.label)).toEqual([
|
||||
'Order Line Context',
|
||||
'Render Template',
|
||||
'Material Assignments',
|
||||
'Bounding Box',
|
||||
])
|
||||
expect(data.outputPorts?.map(port => port.label)).toEqual(['Rendered Image'])
|
||||
expect(data.editableFieldCount).toBe(2)
|
||||
})
|
||||
|
||||
test('assigns semantic handle ids to edges based on matching contracts', () => {
|
||||
const graph = workflowToGraph(
|
||||
{
|
||||
version: 1,
|
||||
nodes: [
|
||||
{ id: 'setup', step: 'order_line_setup', params: {} },
|
||||
{ id: 'template', step: 'resolve_template', params: {} },
|
||||
{ id: 'materials', step: 'material_map_resolve', params: {} },
|
||||
{ id: 'bbox', step: 'glb_bbox', params: {} },
|
||||
{ id: 'render', step: 'blender_still', params: {} },
|
||||
],
|
||||
edges: [
|
||||
{ from: 'setup', to: 'render' },
|
||||
{ from: 'template', to: 'render' },
|
||||
{ from: 'materials', to: 'render' },
|
||||
{ from: 'bbox', to: 'render' },
|
||||
],
|
||||
},
|
||||
definitions,
|
||||
)
|
||||
|
||||
expect(graph.edges.find(edge => edge.source === 'setup' && edge.target === 'render')?.targetHandle).toBe(
|
||||
'input:order_line_context',
|
||||
)
|
||||
expect(graph.edges.find(edge => edge.source === 'template' && edge.target === 'render')?.targetHandle).toBe(
|
||||
'input:render_template',
|
||||
)
|
||||
expect(graph.edges.find(edge => edge.source === 'materials' && edge.target === 'render')?.targetHandle).toBe(
|
||||
'input:material_assignments',
|
||||
)
|
||||
expect(graph.edges.find(edge => edge.source === 'bbox' && edge.target === 'render')?.targetHandle).toBe(
|
||||
'input:bbox',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,414 @@
|
||||
import { describe, expect, test } from 'vitest'
|
||||
|
||||
import type { WorkflowNodeDefinition } from '../../api/workflows'
|
||||
import { createWorkflowModuleBundleInsertion, getWorkflowModuleBundles } from '../../components/workflows/workflowModuleBundles'
|
||||
import {
|
||||
createWorkflowReferenceBundleInsertion,
|
||||
getWorkflowReferenceBundles,
|
||||
} from '../../components/workflows/workflowReferenceBundles'
|
||||
|
||||
const definitions: WorkflowNodeDefinition[] = [
|
||||
{
|
||||
step: 'order_line_setup',
|
||||
label: 'Order Line Setup',
|
||||
family: 'order_line',
|
||||
module_key: 'order_line.prepare_render_context',
|
||||
category: 'input',
|
||||
description: 'Prepare render context.',
|
||||
node_type: 'inputNode',
|
||||
icon: 'refresh-cw',
|
||||
defaults: {},
|
||||
fields: [],
|
||||
execution_kind: 'bridge',
|
||||
legacy_compatible: true,
|
||||
input_contract: { context: 'order_line' },
|
||||
output_contract: { context: 'order_line', provides: ['order_line'] },
|
||||
artifact_roles_produced: [],
|
||||
artifact_roles_consumed: [],
|
||||
legacy_source: 'legacy.order_line_setup',
|
||||
},
|
||||
{
|
||||
step: 'resolve_template',
|
||||
label: 'Resolve Template',
|
||||
family: 'order_line',
|
||||
module_key: 'rendering.resolve_template',
|
||||
category: 'processing',
|
||||
description: 'Resolve template.',
|
||||
node_type: 'processNode',
|
||||
icon: 'layers',
|
||||
defaults: {},
|
||||
fields: [],
|
||||
execution_kind: 'bridge',
|
||||
legacy_compatible: true,
|
||||
input_contract: { context: 'order_line', requires: ['order_line'] },
|
||||
output_contract: { context: 'order_line', provides: ['render_template'] },
|
||||
artifact_roles_produced: [],
|
||||
artifact_roles_consumed: [],
|
||||
legacy_source: 'legacy.resolve_template',
|
||||
},
|
||||
{
|
||||
step: 'auto_populate_materials',
|
||||
label: 'Auto Populate Materials',
|
||||
family: 'order_line',
|
||||
module_key: 'materials.auto_populate',
|
||||
category: 'processing',
|
||||
description: 'Populate materials.',
|
||||
node_type: 'processNode',
|
||||
icon: 'layers',
|
||||
defaults: {},
|
||||
fields: [],
|
||||
execution_kind: 'bridge',
|
||||
legacy_compatible: true,
|
||||
input_contract: { context: 'order_line', requires: ['order_line'] },
|
||||
output_contract: { context: 'order_line', provides: ['cad_materials'] },
|
||||
artifact_roles_produced: [],
|
||||
artifact_roles_consumed: [],
|
||||
legacy_source: 'legacy.auto_populate_materials',
|
||||
},
|
||||
{
|
||||
step: 'glb_bbox',
|
||||
label: 'Compute Bounding Box',
|
||||
family: 'order_line',
|
||||
module_key: 'geometry.compute_bbox',
|
||||
category: 'processing',
|
||||
description: 'Compute bbox.',
|
||||
node_type: 'processNode',
|
||||
icon: 'layers',
|
||||
defaults: {},
|
||||
fields: [],
|
||||
execution_kind: 'native',
|
||||
legacy_compatible: false,
|
||||
input_contract: { context: 'order_line', requires: ['order_line'] },
|
||||
output_contract: { context: 'order_line', provides: ['bbox'] },
|
||||
artifact_roles_produced: [],
|
||||
artifact_roles_consumed: [],
|
||||
legacy_source: null,
|
||||
},
|
||||
{
|
||||
step: 'material_map_resolve',
|
||||
label: 'Resolve Material Map',
|
||||
family: 'order_line',
|
||||
module_key: 'materials.resolve_map',
|
||||
category: 'processing',
|
||||
description: 'Resolve material map.',
|
||||
node_type: 'processNode',
|
||||
icon: 'layers',
|
||||
defaults: {},
|
||||
fields: [],
|
||||
execution_kind: 'native',
|
||||
legacy_compatible: false,
|
||||
input_contract: { context: 'order_line', requires: ['order_line'] },
|
||||
output_contract: { context: 'order_line', provides: ['material_map'] },
|
||||
artifact_roles_produced: [],
|
||||
artifact_roles_consumed: [],
|
||||
legacy_source: null,
|
||||
},
|
||||
{
|
||||
step: 'blender_still',
|
||||
label: 'Blender Still',
|
||||
family: 'order_line',
|
||||
module_key: 'render.production.still',
|
||||
category: 'rendering',
|
||||
description: 'Render still image.',
|
||||
node_type: 'renderNode',
|
||||
icon: 'camera',
|
||||
defaults: {},
|
||||
fields: [],
|
||||
execution_kind: 'native',
|
||||
legacy_compatible: false,
|
||||
input_contract: { context: 'order_line', requires: ['order_line'] },
|
||||
output_contract: { context: 'order_line', provides: ['rendered_image'] },
|
||||
artifact_roles_produced: [],
|
||||
artifact_roles_consumed: [],
|
||||
legacy_source: null,
|
||||
},
|
||||
{
|
||||
step: 'output_save',
|
||||
label: 'Save Output',
|
||||
family: 'order_line',
|
||||
module_key: 'media.save_output',
|
||||
category: 'output',
|
||||
description: 'Save output.',
|
||||
node_type: 'outputNode',
|
||||
icon: 'download',
|
||||
defaults: {},
|
||||
fields: [],
|
||||
execution_kind: 'bridge',
|
||||
legacy_compatible: true,
|
||||
input_contract: { context: 'order_line', requires: ['rendered_image'] },
|
||||
output_contract: { context: 'order_line', provides: ['saved_output'] },
|
||||
artifact_roles_produced: [],
|
||||
artifact_roles_consumed: [],
|
||||
legacy_source: 'legacy.output_save',
|
||||
},
|
||||
{
|
||||
step: 'notify',
|
||||
label: 'Notify Result',
|
||||
family: 'order_line',
|
||||
module_key: 'notifications.emit',
|
||||
category: 'output',
|
||||
description: 'Notify result.',
|
||||
node_type: 'outputNode',
|
||||
icon: 'bell',
|
||||
defaults: {},
|
||||
fields: [],
|
||||
execution_kind: 'bridge',
|
||||
legacy_compatible: true,
|
||||
input_contract: { context: 'order_line', requires: ['saved_output'] },
|
||||
output_contract: { context: 'order_line', provides: ['notification'] },
|
||||
artifact_roles_produced: [],
|
||||
artifact_roles_consumed: [],
|
||||
legacy_source: 'legacy.notify',
|
||||
},
|
||||
]
|
||||
|
||||
const cadDefinitions: WorkflowNodeDefinition[] = [
|
||||
{
|
||||
step: 'resolve_step_path',
|
||||
label: 'Resolve STEP Path',
|
||||
family: 'cad_file',
|
||||
module_key: 'cad.resolve_step_path',
|
||||
category: 'input',
|
||||
description: 'Resolve the STEP path.',
|
||||
node_type: 'inputNode',
|
||||
icon: 'file-code-2',
|
||||
defaults: {},
|
||||
fields: [],
|
||||
execution_kind: 'bridge',
|
||||
legacy_compatible: true,
|
||||
input_contract: { context: 'cad_file' },
|
||||
output_contract: { context: 'cad_file', provides: ['cad_file_record'] },
|
||||
artifact_roles_produced: [],
|
||||
artifact_roles_consumed: [],
|
||||
legacy_source: 'legacy.resolve_step_path',
|
||||
},
|
||||
{
|
||||
step: 'occ_object_extract',
|
||||
label: 'Extract STEP Objects',
|
||||
family: 'cad_file',
|
||||
module_key: 'cad.extract_objects',
|
||||
category: 'processing',
|
||||
description: 'Extract objects.',
|
||||
node_type: 'processNode',
|
||||
icon: 'boxes',
|
||||
defaults: {},
|
||||
fields: [],
|
||||
execution_kind: 'native',
|
||||
legacy_compatible: false,
|
||||
input_contract: { context: 'cad_file', requires: ['cad_file_record'] },
|
||||
output_contract: { context: 'cad_file', provides: ['occ_scene'] },
|
||||
artifact_roles_produced: [],
|
||||
artifact_roles_consumed: [],
|
||||
legacy_source: null,
|
||||
},
|
||||
{
|
||||
step: 'occ_glb_export',
|
||||
label: 'Export GLB',
|
||||
family: 'cad_file',
|
||||
module_key: 'cad.export_glb',
|
||||
category: 'processing',
|
||||
description: 'Export GLB.',
|
||||
node_type: 'processNode',
|
||||
icon: 'package',
|
||||
defaults: {},
|
||||
fields: [],
|
||||
execution_kind: 'native',
|
||||
legacy_compatible: false,
|
||||
input_contract: { context: 'cad_file', requires: ['occ_scene'] },
|
||||
output_contract: { context: 'cad_file', provides: ['glb_preview'] },
|
||||
artifact_roles_produced: [],
|
||||
artifact_roles_consumed: [],
|
||||
legacy_source: null,
|
||||
},
|
||||
{
|
||||
step: 'stl_cache_generate',
|
||||
label: 'Generate STL Cache',
|
||||
family: 'cad_file',
|
||||
module_key: 'cad.generate_stl_cache',
|
||||
category: 'processing',
|
||||
description: 'Generate STL cache.',
|
||||
node_type: 'processNode',
|
||||
icon: 'database',
|
||||
defaults: {},
|
||||
fields: [],
|
||||
execution_kind: 'bridge',
|
||||
legacy_compatible: true,
|
||||
input_contract: { context: 'cad_file', requires: ['glb_preview'] },
|
||||
output_contract: { context: 'cad_file', provides: ['stl_cache'] },
|
||||
artifact_roles_produced: [],
|
||||
artifact_roles_consumed: [],
|
||||
legacy_source: 'legacy.stl_cache_generate',
|
||||
},
|
||||
{
|
||||
step: 'blender_render',
|
||||
label: 'Render Thumbnail (Blender)',
|
||||
family: 'cad_file',
|
||||
module_key: 'cad.thumbnail.blender',
|
||||
category: 'rendering',
|
||||
description: 'Render Blender thumbnail.',
|
||||
node_type: 'renderNode',
|
||||
icon: 'camera',
|
||||
defaults: {},
|
||||
fields: [],
|
||||
execution_kind: 'bridge',
|
||||
legacy_compatible: true,
|
||||
input_contract: { context: 'cad_file', requires: ['glb_preview'] },
|
||||
output_contract: { context: 'cad_file', provides: ['rendered_image'] },
|
||||
artifact_roles_produced: [],
|
||||
artifact_roles_consumed: [],
|
||||
legacy_source: 'legacy.blender_render',
|
||||
},
|
||||
{
|
||||
step: 'threejs_render',
|
||||
label: 'Render Thumbnail (Three.js)',
|
||||
family: 'cad_file',
|
||||
module_key: 'cad.thumbnail.threejs',
|
||||
category: 'rendering',
|
||||
description: 'Render Three.js thumbnail.',
|
||||
node_type: 'renderNode',
|
||||
icon: 'box',
|
||||
defaults: {},
|
||||
fields: [],
|
||||
execution_kind: 'bridge',
|
||||
legacy_compatible: true,
|
||||
input_contract: { context: 'cad_file', requires: ['glb_preview'] },
|
||||
output_contract: { context: 'cad_file', provides: ['rendered_image'] },
|
||||
artifact_roles_produced: [],
|
||||
artifact_roles_consumed: [],
|
||||
legacy_source: 'legacy.threejs_render',
|
||||
},
|
||||
{
|
||||
step: 'thumbnail_save',
|
||||
label: 'Save Thumbnail',
|
||||
family: 'cad_file',
|
||||
module_key: 'cad.thumbnail.save',
|
||||
category: 'output',
|
||||
description: 'Persist thumbnail.',
|
||||
node_type: 'outputNode',
|
||||
icon: 'download',
|
||||
defaults: {},
|
||||
fields: [],
|
||||
execution_kind: 'bridge',
|
||||
legacy_compatible: true,
|
||||
input_contract: { context: 'cad_file', requires: ['rendered_image'] },
|
||||
output_contract: { context: 'cad_file', provides: ['saved_thumbnail'] },
|
||||
artifact_roles_produced: [],
|
||||
artifact_roles_consumed: [],
|
||||
legacy_source: 'legacy.thumbnail_save',
|
||||
},
|
||||
]
|
||||
|
||||
describe('workflowModuleBundles', () => {
|
||||
test('exposes family-scoped bundles when required steps exist', () => {
|
||||
const bundles = getWorkflowModuleBundles(definitions, 'order_line')
|
||||
|
||||
expect(bundles.map(bundle => bundle.id)).toEqual(['still_render_core', 'output_publish_notify'])
|
||||
})
|
||||
|
||||
test('creates a connected bundle insertion graph for still-render authoring', () => {
|
||||
const insertion = createWorkflowModuleBundleInsertion({
|
||||
bundleId: 'still_render_core',
|
||||
graphFamily: 'order_line',
|
||||
nodeDefinitionsByStep: Object.fromEntries(definitions.map(definition => [definition.step, definition])),
|
||||
existingNodes: [],
|
||||
preferredPosition: { x: 200, y: 320 },
|
||||
})
|
||||
|
||||
expect(insertion.ok).toBe(true)
|
||||
if (!insertion.ok) return
|
||||
|
||||
expect(insertion.nodes).toHaveLength(6)
|
||||
expect(insertion.edges).toHaveLength(5)
|
||||
expect(insertion.nodes[0].position).toEqual({ x: 200, y: 320 })
|
||||
expect(insertion.nodes[1].position).toEqual({ x: 420, y: 320 })
|
||||
expect(insertion.nodes[0].data).toMatchObject({ step: 'order_line_setup', label: 'Order Line Setup' })
|
||||
expect(insertion.nodes[5].data).toMatchObject({ step: 'blender_still', label: 'Blender Still' })
|
||||
expect(insertion.edges[0]).toMatchObject({
|
||||
source: insertion.nodes[0].id,
|
||||
target: insertion.nodes[1].id,
|
||||
})
|
||||
})
|
||||
|
||||
test('exposes full reference paths for complete non-legacy authoring flows', () => {
|
||||
const bundles = getWorkflowReferenceBundles(definitions, 'order_line')
|
||||
|
||||
expect(bundles.map(bundle => bundle.id)).toEqual(['still_render_reference'])
|
||||
})
|
||||
|
||||
test('creates the canonical still-render reference graph with branched edges', () => {
|
||||
const insertion = createWorkflowReferenceBundleInsertion({
|
||||
bundleId: 'still_render_reference',
|
||||
graphFamily: 'order_line',
|
||||
nodeDefinitionsByStep: Object.fromEntries(definitions.map(definition => [definition.step, definition])),
|
||||
existingNodes: [],
|
||||
preferredPosition: { x: 200, y: 320 },
|
||||
})
|
||||
|
||||
expect(insertion.ok).toBe(true)
|
||||
if (!insertion.ok) return
|
||||
|
||||
expect(insertion.nodes).toHaveLength(8)
|
||||
expect(insertion.edges).toHaveLength(10)
|
||||
expect(insertion.nodes[0].position).toEqual({ x: 200, y: 440 })
|
||||
expect(insertion.nodes[1].position).toEqual({ x: 420, y: 440 })
|
||||
expect(insertion.nodes[5].data).toMatchObject({
|
||||
step: 'blender_still',
|
||||
label: 'Still Render',
|
||||
params: { use_custom_render_settings: true },
|
||||
})
|
||||
expect(insertion.edges).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
source: insertion.nodes[0].id,
|
||||
target: insertion.nodes[1].id,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
source: insertion.nodes[5].id,
|
||||
target: insertion.nodes[6].id,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
source: insertion.nodes[5].id,
|
||||
target: insertion.nodes[7].id,
|
||||
}),
|
||||
]),
|
||||
)
|
||||
})
|
||||
|
||||
test('creates the canonical CAD intake reference graph from the shared blueprint', () => {
|
||||
const insertion = createWorkflowReferenceBundleInsertion({
|
||||
bundleId: 'cad_intake_reference',
|
||||
graphFamily: 'cad_file',
|
||||
nodeDefinitionsByStep: Object.fromEntries(cadDefinitions.map(definition => [definition.step, definition])),
|
||||
existingNodes: [],
|
||||
preferredPosition: { x: 120, y: 240 },
|
||||
})
|
||||
|
||||
expect(insertion.ok).toBe(true)
|
||||
if (!insertion.ok) return
|
||||
|
||||
expect(insertion.nodes).toHaveLength(8)
|
||||
expect(insertion.edges).toHaveLength(7)
|
||||
expect(insertion.nodes.map(node => node.data.step)).toEqual([
|
||||
'resolve_step_path',
|
||||
'occ_object_extract',
|
||||
'occ_glb_export',
|
||||
'stl_cache_generate',
|
||||
'blender_render',
|
||||
'threejs_render',
|
||||
'thumbnail_save',
|
||||
'thumbnail_save',
|
||||
])
|
||||
expect(insertion.edges).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
source: insertion.nodes[2].id,
|
||||
target: insertion.nodes[4].id,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
source: insertion.nodes[2].id,
|
||||
target: insertion.nodes[5].id,
|
||||
}),
|
||||
]),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,158 @@
|
||||
import { describe, expect, test } from 'vitest'
|
||||
|
||||
import type { WorkflowNodeDefinition } from '../../api/workflows'
|
||||
import { buildWorkflowNodeCatalogModel } from '../../components/workflows/workflowNodeCatalog'
|
||||
import { getDefinitionAuthoringStage } from '../../components/workflows/workflowNodeLibrary'
|
||||
|
||||
const definitions: WorkflowNodeDefinition[] = [
|
||||
{
|
||||
step: 'resolve_step_path',
|
||||
label: 'Resolve STEP Path',
|
||||
family: 'cad_file',
|
||||
module_key: 'cad.resolve_step_path',
|
||||
category: 'input',
|
||||
description: 'Resolve CAD path.',
|
||||
node_type: 'inputNode',
|
||||
icon: 'file',
|
||||
defaults: {},
|
||||
fields: [],
|
||||
execution_kind: 'bridge',
|
||||
legacy_compatible: true,
|
||||
input_contract: { context: 'cad_file' },
|
||||
output_contract: { context: 'cad_file', provides: ['cad_file'] },
|
||||
artifact_roles_produced: [],
|
||||
artifact_roles_consumed: [],
|
||||
legacy_source: 'legacy.resolve_step_path',
|
||||
},
|
||||
{
|
||||
step: 'order_line_setup',
|
||||
label: 'Order Line Setup',
|
||||
family: 'order_line',
|
||||
module_key: 'order_line.prepare_render_context',
|
||||
category: 'input',
|
||||
description: 'Prepare order line.',
|
||||
node_type: 'inputNode',
|
||||
icon: 'file',
|
||||
defaults: {},
|
||||
fields: [],
|
||||
execution_kind: 'bridge',
|
||||
legacy_compatible: true,
|
||||
input_contract: { context: 'order_line' },
|
||||
output_contract: { context: 'order_line', provides: ['order_line'] },
|
||||
artifact_roles_produced: [],
|
||||
artifact_roles_consumed: [],
|
||||
legacy_source: 'legacy.order_line_setup',
|
||||
},
|
||||
{
|
||||
step: 'material_map_resolve',
|
||||
label: 'Resolve Material Map',
|
||||
family: 'order_line',
|
||||
module_key: 'materials.resolve_map',
|
||||
category: 'processing',
|
||||
description: 'Resolve materials.',
|
||||
node_type: 'processNode',
|
||||
icon: 'palette',
|
||||
defaults: {},
|
||||
fields: [],
|
||||
execution_kind: 'native',
|
||||
legacy_compatible: false,
|
||||
input_contract: { context: 'order_line', requires: ['order_line'] },
|
||||
output_contract: { context: 'order_line', provides: ['material_map'] },
|
||||
artifact_roles_produced: [],
|
||||
artifact_roles_consumed: [],
|
||||
legacy_source: null,
|
||||
},
|
||||
{
|
||||
step: 'blender_still',
|
||||
label: 'Blender Still',
|
||||
family: 'order_line',
|
||||
module_key: 'render.production.still',
|
||||
category: 'rendering',
|
||||
description: 'Render image.',
|
||||
node_type: 'renderNode',
|
||||
icon: 'camera',
|
||||
defaults: {},
|
||||
fields: [],
|
||||
execution_kind: 'native',
|
||||
legacy_compatible: false,
|
||||
input_contract: { context: 'order_line', requires: ['order_line'] },
|
||||
output_contract: { context: 'order_line', provides: ['rendered_image'] },
|
||||
artifact_roles_produced: [],
|
||||
artifact_roles_consumed: [],
|
||||
legacy_source: null,
|
||||
},
|
||||
{
|
||||
step: 'output_save',
|
||||
label: 'Save Output',
|
||||
family: 'order_line',
|
||||
module_key: 'media.save_output',
|
||||
category: 'output',
|
||||
description: 'Save output.',
|
||||
node_type: 'outputNode',
|
||||
icon: 'download',
|
||||
defaults: {},
|
||||
fields: [],
|
||||
execution_kind: 'bridge',
|
||||
legacy_compatible: true,
|
||||
input_contract: { context: 'order_line', requires: ['rendered_image'] },
|
||||
output_contract: { context: 'order_line', provides: ['saved_output'] },
|
||||
artifact_roles_produced: [],
|
||||
artifact_roles_consumed: [],
|
||||
legacy_source: 'legacy.output_save',
|
||||
},
|
||||
]
|
||||
|
||||
describe('workflow node organization', () => {
|
||||
test('assigns authoring stages from module namespaces and categories', () => {
|
||||
expect(getDefinitionAuthoringStage(definitions[0])).toBe('cad_intake')
|
||||
expect(getDefinitionAuthoringStage(definitions[1])).toBe('scene_prep')
|
||||
expect(getDefinitionAuthoringStage(definitions[2])).toBe('materials')
|
||||
expect(getDefinitionAuthoringStage(definitions[3])).toBe('render')
|
||||
expect(getDefinitionAuthoringStage(definitions[4])).toBe('publish')
|
||||
})
|
||||
|
||||
test('builds stage and family catalog models with module and runtime counts', () => {
|
||||
const model = buildWorkflowNodeCatalogModel(definitions)
|
||||
|
||||
expect(model.moduleFilters.map(module => module.label)).toEqual([
|
||||
'Cad',
|
||||
'Materials',
|
||||
'Media',
|
||||
'Order Line',
|
||||
'Render',
|
||||
])
|
||||
expect(model.runtimeCounts).toEqual({
|
||||
legacy: 3,
|
||||
bridge: 0,
|
||||
graph: 2,
|
||||
})
|
||||
|
||||
const stageIds = model.stageSections.map(section => section.stage)
|
||||
expect(stageIds).toEqual(['cad_intake', 'scene_prep', 'materials', 'render', 'publish'])
|
||||
|
||||
const renderSection = model.stageSections.find(section => section.stage === 'render')
|
||||
expect(renderSection?.modules[0]?.namespace).toBe('render')
|
||||
expect(renderSection?.runtimeCounts.graph).toBe(1)
|
||||
|
||||
const publishSection = model.stageSections.find(section => section.stage === 'publish')
|
||||
expect(publishSection?.modules[0]?.runtimeCounts.legacy).toBe(1)
|
||||
|
||||
expect(model.familySections.map(section => section.family)).toEqual(['cad_file', 'order_line'])
|
||||
|
||||
const cadFamilySection = model.familySections.find(section => section.family === 'cad_file')
|
||||
expect(cadFamilySection?.modules.map(module => module.namespace)).toEqual(['cad'])
|
||||
expect(cadFamilySection?.modules[0]?.stageSections.map(section => section.stage)).toEqual(['cad_intake'])
|
||||
|
||||
const orderFamilySection = model.familySections.find(section => section.family === 'order_line')
|
||||
expect(orderFamilySection?.modules.map(module => module.namespace)).toEqual([
|
||||
'materials',
|
||||
'media',
|
||||
'order_line',
|
||||
'render',
|
||||
])
|
||||
expect(orderFamilySection?.runtimeCounts.graph).toBe(2)
|
||||
expect(orderFamilySection?.modules.find(module => module.namespace === 'materials')?.stageSections[0]?.stage).toBe(
|
||||
'materials',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, test } from 'vitest'
|
||||
|
||||
import type { WorkflowCanvasPort } from '../../components/workflows/workflowGraphDraft'
|
||||
import {
|
||||
getWorkflowNodePortBadgeLabel,
|
||||
getWorkflowNodePortTitle,
|
||||
} from '../../components/workflows/workflowNodePresentation'
|
||||
|
||||
describe('workflowNodePresentation', () => {
|
||||
test('renders explicit labels for direct required inputs', () => {
|
||||
const port: WorkflowCanvasPort = {
|
||||
id: 'input:material_assignments',
|
||||
label: 'Material Assignments',
|
||||
roles: ['material_assignments'],
|
||||
kind: 'required',
|
||||
}
|
||||
|
||||
expect(getWorkflowNodePortBadgeLabel(port)).toBe('Material Assignments')
|
||||
expect(getWorkflowNodePortTitle(port)).toBe('Material Assignments')
|
||||
})
|
||||
|
||||
test('renders alternative sockets as explicit role choices', () => {
|
||||
const port: WorkflowCanvasPort = {
|
||||
id: 'input-any:rendered_image|rendered_frames|rendered_video|workflow_result|blend_asset',
|
||||
label: 'Any of Rendered Image / Rendered Frames / Rendered Video / Workflow Result / Blend Asset',
|
||||
roles: ['rendered_image', 'rendered_frames', 'rendered_video', 'workflow_result', 'blend_asset'],
|
||||
kind: 'alternative',
|
||||
}
|
||||
|
||||
expect(getWorkflowNodePortBadgeLabel(port)).toBe(
|
||||
'Any: Image / Frames / Video / Workflow Result / Blend Asset',
|
||||
)
|
||||
expect(getWorkflowNodePortTitle(port)).toBe(
|
||||
'Accepts any of: Rendered Image / Rendered Frames / Rendered Video / Workflow Result / Blend Asset',
|
||||
)
|
||||
})
|
||||
})
|
||||
+610
-18
@@ -1,6 +1,7 @@
|
||||
import api from './client'
|
||||
|
||||
export type OutputTypeWorkflowFamily = 'cad_file' | 'order_line'
|
||||
export type OutputTypeWorkflowRolloutMode = 'legacy_only' | 'shadow' | 'graph'
|
||||
export type OutputTypeArtifactKind =
|
||||
| 'still_image'
|
||||
| 'turntable_video'
|
||||
@@ -10,6 +11,17 @@ export type OutputTypeArtifactKind =
|
||||
| 'package'
|
||||
| 'custom'
|
||||
|
||||
export type OutputTypeInvocationOverrideKey = typeof OUTPUT_TYPE_INVOCATION_OVERRIDE_KEYS[number]
|
||||
export type OutputTypeInvocationOverrides = Partial<Record<OutputTypeInvocationOverrideKey, string | number | boolean>>
|
||||
export type OutputTypeContractIssueSeverity = 'error' | 'warning'
|
||||
export type OutputTypeContractCatalogMap<K extends string, V> = Record<K, V>
|
||||
|
||||
export interface OutputTypeParameterOwnershipCatalog {
|
||||
output_type_profile_keys: string[]
|
||||
template_runtime_keys: string[]
|
||||
workflow_node_keys_by_step: Record<string, string[]>
|
||||
}
|
||||
|
||||
export const OUTPUT_TYPE_INVOCATION_OVERRIDE_KEYS = [
|
||||
'width',
|
||||
'height',
|
||||
@@ -27,8 +39,171 @@ export const OUTPUT_TYPE_INVOCATION_OVERRIDE_KEYS = [
|
||||
'denoising_use_gpu',
|
||||
] as const
|
||||
|
||||
export const IMAGE_OUTPUT_FORMATS = ['png', 'jpg', 'jpeg', 'webp'] as const
|
||||
export const VIDEO_OUTPUT_FORMATS = ['mp4', 'webm', 'mov'] as const
|
||||
export const MODEL_OUTPUT_FORMATS = ['gltf', 'glb', 'stl', 'obj', 'usd', 'usdz'] as const
|
||||
export const BLEND_OUTPUT_FORMATS = ['blend'] as const
|
||||
|
||||
const CAD_FILE_ARTIFACT_KINDS: OutputTypeArtifactKind[] = ['thumbnail_image', 'model_export', 'package', 'custom']
|
||||
const ORDER_LINE_ARTIFACT_KINDS: OutputTypeArtifactKind[] = ['still_image', 'turntable_video', 'blend_asset', 'package', 'custom']
|
||||
const STATIC_RENDER_OVERRIDE_KEYS: OutputTypeInvocationOverrideKey[] = [
|
||||
'width',
|
||||
'height',
|
||||
'engine',
|
||||
'samples',
|
||||
'bg_color',
|
||||
'noise_threshold',
|
||||
'denoiser',
|
||||
'denoising_input_passes',
|
||||
'denoising_prefilter',
|
||||
'denoising_quality',
|
||||
'denoising_use_gpu',
|
||||
]
|
||||
const ANIMATION_OVERRIDE_KEYS: OutputTypeInvocationOverrideKey[] = ['frame_count', 'fps', 'turntable_axis']
|
||||
|
||||
export interface OutputTypeContractCatalog {
|
||||
workflow_families: OutputTypeWorkflowFamily[]
|
||||
workflow_rollout_modes: OutputTypeWorkflowRolloutMode[]
|
||||
artifact_kinds: OutputTypeArtifactKind[]
|
||||
allowed_artifact_kinds_by_family: OutputTypeContractCatalogMap<OutputTypeWorkflowFamily, OutputTypeArtifactKind[]>
|
||||
allowed_output_formats_by_family: OutputTypeContractCatalogMap<OutputTypeWorkflowFamily, string[]>
|
||||
allowed_invocation_override_keys_by_artifact_kind: OutputTypeContractCatalogMap<
|
||||
OutputTypeArtifactKind,
|
||||
OutputTypeInvocationOverrideKey[]
|
||||
>
|
||||
default_output_format_by_artifact_kind: OutputTypeContractCatalogMap<OutputTypeArtifactKind, string>
|
||||
parameter_ownership: OutputTypeParameterOwnershipCatalog
|
||||
}
|
||||
|
||||
const FALLBACK_OUTPUT_TYPE_CONTRACT_CATALOG: OutputTypeContractCatalog = {
|
||||
workflow_families: ['order_line', 'cad_file'],
|
||||
workflow_rollout_modes: ['legacy_only', 'shadow', 'graph'],
|
||||
artifact_kinds: ['still_image', 'turntable_video', 'model_export', 'thumbnail_image', 'blend_asset', 'package', 'custom'],
|
||||
allowed_artifact_kinds_by_family: {
|
||||
cad_file: [...CAD_FILE_ARTIFACT_KINDS],
|
||||
order_line: [...ORDER_LINE_ARTIFACT_KINDS],
|
||||
},
|
||||
allowed_output_formats_by_family: {
|
||||
cad_file: [...IMAGE_OUTPUT_FORMATS, ...MODEL_OUTPUT_FORMATS],
|
||||
order_line: [...IMAGE_OUTPUT_FORMATS, ...VIDEO_OUTPUT_FORMATS, ...BLEND_OUTPUT_FORMATS],
|
||||
},
|
||||
allowed_invocation_override_keys_by_artifact_kind: {
|
||||
still_image: [...STATIC_RENDER_OVERRIDE_KEYS],
|
||||
thumbnail_image: [...STATIC_RENDER_OVERRIDE_KEYS],
|
||||
turntable_video: [...STATIC_RENDER_OVERRIDE_KEYS, ...ANIMATION_OVERRIDE_KEYS],
|
||||
model_export: [],
|
||||
blend_asset: [],
|
||||
package: [...OUTPUT_TYPE_INVOCATION_OVERRIDE_KEYS],
|
||||
custom: [...OUTPUT_TYPE_INVOCATION_OVERRIDE_KEYS],
|
||||
},
|
||||
default_output_format_by_artifact_kind: {
|
||||
still_image: 'png',
|
||||
thumbnail_image: 'png',
|
||||
turntable_video: 'mp4',
|
||||
model_export: 'gltf',
|
||||
blend_asset: 'blend',
|
||||
package: 'png',
|
||||
custom: 'png',
|
||||
},
|
||||
parameter_ownership: {
|
||||
output_type_profile_keys: ['transparent_bg', 'cycles_device', 'material_override'],
|
||||
template_runtime_keys: ['target_collection', 'lighting_only', 'shadow_catcher', 'camera_orbit', 'template_inputs'],
|
||||
workflow_node_keys_by_step: {
|
||||
resolve_template: [
|
||||
'template_id_override',
|
||||
'require_template',
|
||||
'material_library_path',
|
||||
'disable_materials',
|
||||
'target_collection',
|
||||
'material_replace_mode',
|
||||
'lighting_only_mode',
|
||||
'shadow_catcher_mode',
|
||||
'camera_orbit_mode',
|
||||
],
|
||||
blender_still: [
|
||||
'use_custom_render_settings',
|
||||
'render_engine',
|
||||
'cycles_device',
|
||||
'samples',
|
||||
'width',
|
||||
'height',
|
||||
'transparent_bg',
|
||||
'noise_threshold',
|
||||
'denoiser',
|
||||
'denoising_input_passes',
|
||||
'denoising_prefilter',
|
||||
'denoising_quality',
|
||||
'denoising_use_gpu',
|
||||
'target_collection',
|
||||
'lighting_only',
|
||||
'shadow_catcher',
|
||||
'rotation_x',
|
||||
'rotation_y',
|
||||
'rotation_z',
|
||||
'focal_length_mm',
|
||||
'sensor_width_mm',
|
||||
'material_override',
|
||||
],
|
||||
blender_turntable: [
|
||||
'use_custom_render_settings',
|
||||
'render_engine',
|
||||
'cycles_device',
|
||||
'samples',
|
||||
'width',
|
||||
'height',
|
||||
'transparent_bg',
|
||||
'bg_color',
|
||||
'fps',
|
||||
'frame_count',
|
||||
'duration_s',
|
||||
'turntable_degrees',
|
||||
'turntable_axis',
|
||||
'camera_orbit',
|
||||
'target_collection',
|
||||
'lighting_only',
|
||||
'shadow_catcher',
|
||||
'rotation_x',
|
||||
'rotation_y',
|
||||
'rotation_z',
|
||||
'focal_length_mm',
|
||||
'sensor_width_mm',
|
||||
'material_override',
|
||||
],
|
||||
export_blend: ['output_name_suffix'],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
let cachedOutputTypeContractCatalog: OutputTypeContractCatalog = FALLBACK_OUTPUT_TYPE_CONTRACT_CATALOG
|
||||
|
||||
export interface OutputTypeWorkflowContractWorkflowLike {
|
||||
id: string
|
||||
name: string
|
||||
family: OutputTypeWorkflowFamily | 'mixed' | null
|
||||
supported_artifact_kinds?: OutputTypeArtifactKind[]
|
||||
}
|
||||
|
||||
export interface OutputTypeWorkflowContractIssue {
|
||||
code: string
|
||||
severity: OutputTypeContractIssueSeverity
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface OutputTypeInvocationProfile {
|
||||
renderer: string
|
||||
render_backend: string
|
||||
workflow_family: OutputTypeWorkflowFamily
|
||||
artifact_kind: OutputTypeArtifactKind
|
||||
output_format: string
|
||||
is_animation: boolean
|
||||
workflow_definition_id: string | null
|
||||
workflow_rollout_mode: OutputTypeWorkflowRolloutMode
|
||||
transparent_bg: boolean
|
||||
cycles_device: string | null
|
||||
material_override: string | null
|
||||
allowed_override_keys: OutputTypeInvocationOverrideKey[]
|
||||
invocation_overrides: OutputTypeInvocationOverrides
|
||||
}
|
||||
|
||||
export interface OutputType {
|
||||
id: string
|
||||
@@ -36,7 +211,7 @@ export interface OutputType {
|
||||
description: string | null
|
||||
renderer: string
|
||||
render_settings: Record<string, unknown>
|
||||
invocation_overrides: Record<string, unknown>
|
||||
invocation_overrides: OutputTypeInvocationOverrides
|
||||
output_format: string
|
||||
sort_order: number
|
||||
compatible_categories: string[]
|
||||
@@ -50,13 +225,141 @@ export interface OutputType {
|
||||
pricing_tier_name: string | null
|
||||
price_per_item: number | null
|
||||
workflow_definition_id: string | null
|
||||
workflow_rollout_mode: OutputTypeWorkflowRolloutMode
|
||||
workflow_name?: string | null
|
||||
material_override: string | null
|
||||
invocation_profile: OutputTypeInvocationProfile | null
|
||||
is_active: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
function isWorkflowFamily(value: unknown): value is OutputTypeWorkflowFamily {
|
||||
return value === 'cad_file' || value === 'order_line'
|
||||
}
|
||||
|
||||
function isWorkflowRolloutMode(value: unknown): value is OutputTypeWorkflowRolloutMode {
|
||||
return value === 'legacy_only' || value === 'shadow' || value === 'graph'
|
||||
}
|
||||
|
||||
function isArtifactKind(value: unknown): value is OutputTypeArtifactKind {
|
||||
return FALLBACK_OUTPUT_TYPE_CONTRACT_CATALOG.artifact_kinds.includes(value as OutputTypeArtifactKind)
|
||||
}
|
||||
|
||||
function isInvocationOverrideKey(value: unknown): value is OutputTypeInvocationOverrideKey {
|
||||
return OUTPUT_TYPE_INVOCATION_OVERRIDE_KEYS.includes(value as OutputTypeInvocationOverrideKey)
|
||||
}
|
||||
|
||||
function normalizeOrderedStrings<T extends string>(
|
||||
values: unknown,
|
||||
fallback: readonly T[],
|
||||
predicate: (value: unknown) => value is T,
|
||||
): T[] {
|
||||
const provided = Array.isArray(values) ? values.filter(predicate) : []
|
||||
const usable = provided.length > 0 ? provided : [...fallback]
|
||||
const usableSet = new Set(usable)
|
||||
return fallback.filter(value => usableSet.has(value))
|
||||
}
|
||||
|
||||
function normalizeStringList(values: unknown): string[] {
|
||||
return Array.isArray(values) ? values.filter((value): value is string => typeof value === 'string' && value.trim().length > 0) : []
|
||||
}
|
||||
|
||||
function normalizeRecordOfStringLists(values: unknown): Record<string, string[]> {
|
||||
if (!values || typeof values !== 'object' || Array.isArray(values)) return {}
|
||||
return Object.fromEntries(
|
||||
Object.entries(values).map(([key, value]) => [key, normalizeStringList(value)]),
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeOutputTypeContractCatalog(
|
||||
catalog: Partial<OutputTypeContractCatalog> | undefined | null,
|
||||
): OutputTypeContractCatalog {
|
||||
const workflowFamilies = normalizeOrderedStrings(
|
||||
catalog?.workflow_families,
|
||||
FALLBACK_OUTPUT_TYPE_CONTRACT_CATALOG.workflow_families,
|
||||
isWorkflowFamily,
|
||||
)
|
||||
const workflowRolloutModes = normalizeOrderedStrings(
|
||||
catalog?.workflow_rollout_modes,
|
||||
FALLBACK_OUTPUT_TYPE_CONTRACT_CATALOG.workflow_rollout_modes,
|
||||
isWorkflowRolloutMode,
|
||||
)
|
||||
const artifactKinds = normalizeOrderedStrings(
|
||||
catalog?.artifact_kinds,
|
||||
FALLBACK_OUTPUT_TYPE_CONTRACT_CATALOG.artifact_kinds,
|
||||
isArtifactKind,
|
||||
)
|
||||
|
||||
const allowedArtifactKindsByFamily = Object.fromEntries(
|
||||
workflowFamilies.map(family => [
|
||||
family,
|
||||
normalizeOrderedStrings(
|
||||
catalog?.allowed_artifact_kinds_by_family?.[family],
|
||||
FALLBACK_OUTPUT_TYPE_CONTRACT_CATALOG.allowed_artifact_kinds_by_family[family],
|
||||
isArtifactKind,
|
||||
),
|
||||
]),
|
||||
) as OutputTypeContractCatalog['allowed_artifact_kinds_by_family']
|
||||
|
||||
const allowedOutputFormatsByFamily = Object.fromEntries(
|
||||
workflowFamilies.map(family => [
|
||||
family,
|
||||
normalizeStringList(catalog?.allowed_output_formats_by_family?.[family]).length > 0
|
||||
? normalizeStringList(catalog?.allowed_output_formats_by_family?.[family])
|
||||
: [...FALLBACK_OUTPUT_TYPE_CONTRACT_CATALOG.allowed_output_formats_by_family[family]],
|
||||
]),
|
||||
) as OutputTypeContractCatalog['allowed_output_formats_by_family']
|
||||
|
||||
const allowedInvocationOverrideKeysByArtifactKind = Object.fromEntries(
|
||||
artifactKinds.map(artifactKind => [
|
||||
artifactKind,
|
||||
normalizeOrderedStrings(
|
||||
catalog?.allowed_invocation_override_keys_by_artifact_kind?.[artifactKind],
|
||||
FALLBACK_OUTPUT_TYPE_CONTRACT_CATALOG.allowed_invocation_override_keys_by_artifact_kind[artifactKind],
|
||||
isInvocationOverrideKey,
|
||||
),
|
||||
]),
|
||||
) as OutputTypeContractCatalog['allowed_invocation_override_keys_by_artifact_kind']
|
||||
|
||||
const defaultOutputFormatByArtifactKind = Object.fromEntries(
|
||||
artifactKinds.map(artifactKind => [
|
||||
artifactKind,
|
||||
typeof catalog?.default_output_format_by_artifact_kind?.[artifactKind] === 'string' &&
|
||||
catalog.default_output_format_by_artifact_kind[artifactKind].trim().length > 0
|
||||
? catalog.default_output_format_by_artifact_kind[artifactKind]
|
||||
: FALLBACK_OUTPUT_TYPE_CONTRACT_CATALOG.default_output_format_by_artifact_kind[artifactKind],
|
||||
]),
|
||||
) as OutputTypeContractCatalog['default_output_format_by_artifact_kind']
|
||||
|
||||
const normalizedNodeOwnership = normalizeRecordOfStringLists(catalog?.parameter_ownership?.workflow_node_keys_by_step)
|
||||
const parameterOwnership: OutputTypeParameterOwnershipCatalog = {
|
||||
output_type_profile_keys:
|
||||
normalizeStringList(catalog?.parameter_ownership?.output_type_profile_keys).length > 0
|
||||
? normalizeStringList(catalog?.parameter_ownership?.output_type_profile_keys)
|
||||
: [...FALLBACK_OUTPUT_TYPE_CONTRACT_CATALOG.parameter_ownership.output_type_profile_keys],
|
||||
template_runtime_keys:
|
||||
normalizeStringList(catalog?.parameter_ownership?.template_runtime_keys).length > 0
|
||||
? normalizeStringList(catalog?.parameter_ownership?.template_runtime_keys)
|
||||
: [...FALLBACK_OUTPUT_TYPE_CONTRACT_CATALOG.parameter_ownership.template_runtime_keys],
|
||||
workflow_node_keys_by_step:
|
||||
Object.keys(normalizedNodeOwnership).length > 0
|
||||
? normalizedNodeOwnership
|
||||
: { ...FALLBACK_OUTPUT_TYPE_CONTRACT_CATALOG.parameter_ownership.workflow_node_keys_by_step },
|
||||
}
|
||||
|
||||
return {
|
||||
workflow_families: workflowFamilies,
|
||||
workflow_rollout_modes: workflowRolloutModes,
|
||||
artifact_kinds: artifactKinds,
|
||||
allowed_artifact_kinds_by_family: allowedArtifactKindsByFamily,
|
||||
allowed_output_formats_by_family: allowedOutputFormatsByFamily,
|
||||
allowed_invocation_override_keys_by_artifact_kind: allowedInvocationOverrideKeysByArtifactKind,
|
||||
default_output_format_by_artifact_kind: defaultOutputFormatByArtifactKind,
|
||||
parameter_ownership: parameterOwnership,
|
||||
}
|
||||
}
|
||||
|
||||
export async function listOutputTypes(
|
||||
includeInactive = false,
|
||||
category?: string,
|
||||
@@ -64,25 +367,151 @@ export async function listOutputTypes(
|
||||
const params: Record<string, unknown> = { include_inactive: includeInactive }
|
||||
if (category) params.category = category
|
||||
const res = await api.get<OutputType[]>('/output-types', { params })
|
||||
return res.data
|
||||
return res.data.map(normalizeOutputType)
|
||||
}
|
||||
|
||||
export async function createOutputType(data: Partial<OutputType>): Promise<OutputType> {
|
||||
const res = await api.post<OutputType>('/output-types', data)
|
||||
return res.data
|
||||
return normalizeOutputType(res.data)
|
||||
}
|
||||
|
||||
export async function updateOutputType(id: string, data: Partial<OutputType>): Promise<OutputType> {
|
||||
const res = await api.patch<OutputType>(`/output-types/${id}`, data)
|
||||
return res.data
|
||||
return normalizeOutputType(res.data)
|
||||
}
|
||||
|
||||
export async function deleteOutputType(id: string): Promise<void> {
|
||||
await api.delete(`/output-types/${id}`)
|
||||
}
|
||||
|
||||
export function listAllowedArtifactKindsForFamily(family: OutputTypeWorkflowFamily): OutputTypeArtifactKind[] {
|
||||
return family === 'cad_file' ? [...CAD_FILE_ARTIFACT_KINDS] : [...ORDER_LINE_ARTIFACT_KINDS]
|
||||
export async function getOutputTypeContractCatalog(): Promise<OutputTypeContractCatalog> {
|
||||
const res = await api.get<OutputTypeContractCatalog>('/output-types/contract-catalog')
|
||||
cachedOutputTypeContractCatalog = normalizeOutputTypeContractCatalog(res.data)
|
||||
return cachedOutputTypeContractCatalog
|
||||
}
|
||||
|
||||
export function getCachedOutputTypeContractCatalog(): OutputTypeContractCatalog {
|
||||
return cachedOutputTypeContractCatalog
|
||||
}
|
||||
|
||||
export function listAllowedArtifactKindsForFamily(
|
||||
family: OutputTypeWorkflowFamily,
|
||||
contractCatalog: OutputTypeContractCatalog = cachedOutputTypeContractCatalog,
|
||||
): OutputTypeArtifactKind[] {
|
||||
return [...(contractCatalog.allowed_artifact_kinds_by_family[family] ?? FALLBACK_OUTPUT_TYPE_CONTRACT_CATALOG.allowed_artifact_kinds_by_family[family])]
|
||||
}
|
||||
|
||||
export function listAllowedOutputFormatsForFamily(
|
||||
family: OutputTypeWorkflowFamily,
|
||||
contractCatalog: OutputTypeContractCatalog = cachedOutputTypeContractCatalog,
|
||||
): string[] {
|
||||
return [...(contractCatalog.allowed_output_formats_by_family[family] ?? FALLBACK_OUTPUT_TYPE_CONTRACT_CATALOG.allowed_output_formats_by_family[family])]
|
||||
}
|
||||
|
||||
export function getDefaultOutputFormatForArtifactKind(
|
||||
artifactKind: OutputTypeArtifactKind,
|
||||
contractCatalog: OutputTypeContractCatalog = cachedOutputTypeContractCatalog,
|
||||
): string {
|
||||
return contractCatalog.default_output_format_by_artifact_kind[artifactKind]
|
||||
?? FALLBACK_OUTPUT_TYPE_CONTRACT_CATALOG.default_output_format_by_artifact_kind[artifactKind]
|
||||
}
|
||||
|
||||
export function workflowSupportsArtifactKindForOutputTypeContract(
|
||||
workflow: OutputTypeWorkflowContractWorkflowLike,
|
||||
artifactKind: OutputTypeArtifactKind,
|
||||
): boolean {
|
||||
return Array.isArray(workflow.supported_artifact_kinds) && workflow.supported_artifact_kinds.includes(artifactKind)
|
||||
}
|
||||
|
||||
export function getCompatibleWorkflowsForOutputTypeContract(
|
||||
workflows: OutputTypeWorkflowContractWorkflowLike[],
|
||||
workflowFamily: OutputTypeWorkflowFamily,
|
||||
artifactKind: OutputTypeArtifactKind,
|
||||
): OutputTypeWorkflowContractWorkflowLike[] {
|
||||
return workflows.filter(workflow =>
|
||||
workflow.family === workflowFamily &&
|
||||
workflowSupportsArtifactKindForOutputTypeContract(workflow, artifactKind),
|
||||
)
|
||||
}
|
||||
|
||||
export function listAllowedInvocationOverrideKeysForArtifactKind(
|
||||
artifactKind: OutputTypeArtifactKind,
|
||||
contractCatalog: OutputTypeContractCatalog = cachedOutputTypeContractCatalog,
|
||||
): OutputTypeInvocationOverrideKey[] {
|
||||
return [
|
||||
...(contractCatalog.allowed_invocation_override_keys_by_artifact_kind[artifactKind]
|
||||
?? FALLBACK_OUTPUT_TYPE_CONTRACT_CATALOG.allowed_invocation_override_keys_by_artifact_kind[artifactKind]),
|
||||
]
|
||||
}
|
||||
|
||||
function isInvocationOverrideValue(value: unknown): value is string | number | boolean {
|
||||
return typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean'
|
||||
}
|
||||
|
||||
function sanitizeInvocationOverrides(
|
||||
artifactKind: OutputTypeArtifactKind,
|
||||
overrides: Record<string, unknown> | undefined | null,
|
||||
contractCatalog: OutputTypeContractCatalog = cachedOutputTypeContractCatalog,
|
||||
): OutputTypeInvocationOverrides {
|
||||
const normalized: OutputTypeInvocationOverrides = {}
|
||||
const allowed = new Set(listAllowedInvocationOverrideKeysForArtifactKind(artifactKind, contractCatalog))
|
||||
for (const key of OUTPUT_TYPE_INVOCATION_OVERRIDE_KEYS) {
|
||||
if (!allowed.has(key)) continue
|
||||
const value = overrides?.[key]
|
||||
if (value !== undefined && value !== null && value !== '' && isInvocationOverrideValue(value)) {
|
||||
normalized[key] = value
|
||||
}
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
function buildFallbackInvocationProfile(outputType: OutputType): OutputTypeInvocationProfile {
|
||||
const artifactKind = outputType.artifact_kind ?? inferArtifactKind(
|
||||
outputType.workflow_family,
|
||||
outputType.output_format,
|
||||
outputType.is_animation,
|
||||
)
|
||||
return {
|
||||
renderer: outputType.renderer,
|
||||
render_backend: outputType.render_backend,
|
||||
workflow_family: outputType.workflow_family,
|
||||
artifact_kind: artifactKind,
|
||||
output_format: outputType.output_format,
|
||||
is_animation: outputType.is_animation,
|
||||
workflow_definition_id: outputType.workflow_definition_id,
|
||||
workflow_rollout_mode: outputType.workflow_rollout_mode ?? 'legacy_only',
|
||||
transparent_bg: outputType.transparent_bg,
|
||||
cycles_device: outputType.cycles_device,
|
||||
material_override: outputType.material_override,
|
||||
allowed_override_keys: listAllowedInvocationOverrideKeysForArtifactKind(artifactKind),
|
||||
invocation_overrides: sanitizeInvocationOverrides(artifactKind, {
|
||||
...outputType.render_settings,
|
||||
...outputType.invocation_overrides,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeOutputType(outputType: OutputType): OutputType {
|
||||
const invocationProfile = outputType.invocation_profile
|
||||
? {
|
||||
...outputType.invocation_profile,
|
||||
workflow_rollout_mode: outputType.invocation_profile.workflow_rollout_mode ?? outputType.workflow_rollout_mode ?? 'legacy_only',
|
||||
allowed_override_keys: outputType.invocation_profile.allowed_override_keys
|
||||
?.filter((key): key is OutputTypeInvocationOverrideKey => OUTPUT_TYPE_INVOCATION_OVERRIDE_KEYS.includes(key as OutputTypeInvocationOverrideKey))
|
||||
?? listAllowedInvocationOverrideKeysForArtifactKind(outputType.artifact_kind),
|
||||
invocation_overrides: sanitizeInvocationOverrides(
|
||||
outputType.invocation_profile.artifact_kind ?? outputType.artifact_kind,
|
||||
outputType.invocation_profile.invocation_overrides,
|
||||
),
|
||||
}
|
||||
: buildFallbackInvocationProfile(outputType)
|
||||
|
||||
return {
|
||||
...outputType,
|
||||
workflow_rollout_mode: outputType.workflow_rollout_mode ?? 'legacy_only',
|
||||
invocation_overrides: invocationProfile.invocation_overrides,
|
||||
invocation_profile: invocationProfile,
|
||||
}
|
||||
}
|
||||
|
||||
export function inferArtifactKind(
|
||||
@@ -92,10 +521,13 @@ export function inferArtifactKind(
|
||||
): OutputTypeArtifactKind {
|
||||
const normalizedFormat = outputFormat.trim().toLowerCase()
|
||||
|
||||
if (isAnimation || ['mp4', 'webm', 'mov'].includes(normalizedFormat)) {
|
||||
if (BLEND_OUTPUT_FORMATS.includes(normalizedFormat as (typeof BLEND_OUTPUT_FORMATS)[number])) {
|
||||
return 'blend_asset'
|
||||
}
|
||||
if (isAnimation || VIDEO_OUTPUT_FORMATS.includes(normalizedFormat as (typeof VIDEO_OUTPUT_FORMATS)[number])) {
|
||||
return 'turntable_video'
|
||||
}
|
||||
if (['gltf', 'glb', 'stl', 'obj', 'usd', 'usdz'].includes(normalizedFormat)) {
|
||||
if (MODEL_OUTPUT_FORMATS.includes(normalizedFormat as (typeof MODEL_OUTPUT_FORMATS)[number])) {
|
||||
return 'model_export'
|
||||
}
|
||||
if (workflowFamily === 'cad_file') {
|
||||
@@ -107,19 +539,179 @@ export function inferArtifactKind(
|
||||
export function isArtifactKindAllowedForFamily(
|
||||
workflowFamily: OutputTypeWorkflowFamily,
|
||||
artifactKind: OutputTypeArtifactKind,
|
||||
contractCatalog: OutputTypeContractCatalog = cachedOutputTypeContractCatalog,
|
||||
): boolean {
|
||||
return listAllowedArtifactKindsForFamily(workflowFamily).includes(artifactKind)
|
||||
return listAllowedArtifactKindsForFamily(workflowFamily, contractCatalog).includes(artifactKind)
|
||||
}
|
||||
|
||||
export function getOutputTypeWorkflowContractIssues(args: {
|
||||
workflowFamily: OutputTypeWorkflowFamily
|
||||
artifactKind: OutputTypeArtifactKind
|
||||
outputFormat: string
|
||||
isAnimation: boolean
|
||||
workflowDefinitionId?: string | null
|
||||
workflowRolloutMode: OutputTypeWorkflowRolloutMode
|
||||
workflows?: OutputTypeWorkflowContractWorkflowLike[]
|
||||
contractCatalog?: OutputTypeContractCatalog
|
||||
}): OutputTypeWorkflowContractIssue[] {
|
||||
const {
|
||||
workflowFamily,
|
||||
artifactKind,
|
||||
outputFormat,
|
||||
isAnimation,
|
||||
workflowDefinitionId,
|
||||
workflowRolloutMode,
|
||||
workflows = [],
|
||||
contractCatalog = cachedOutputTypeContractCatalog,
|
||||
} = args
|
||||
|
||||
const issues: OutputTypeWorkflowContractIssue[] = []
|
||||
const normalizedFormat = outputFormat.trim().toLowerCase()
|
||||
const selectedWorkflowId = workflowDefinitionId?.trim() ?? ''
|
||||
const selectedWorkflow = selectedWorkflowId
|
||||
? workflows.find(workflow => workflow.id === selectedWorkflowId) ?? null
|
||||
: null
|
||||
|
||||
if (!isArtifactKindAllowedForFamily(workflowFamily, artifactKind, contractCatalog)) {
|
||||
issues.push({
|
||||
code: 'artifact_family_mismatch',
|
||||
severity: 'error',
|
||||
message: `${artifactKind} is not allowed for the ${workflowFamily} workflow family.`,
|
||||
})
|
||||
}
|
||||
|
||||
if (normalizedFormat && !listAllowedOutputFormatsForFamily(workflowFamily, contractCatalog).includes(normalizedFormat)) {
|
||||
issues.push({
|
||||
code: 'format_family_mismatch',
|
||||
severity: 'error',
|
||||
message: `${normalizedFormat} is not allowed for the ${workflowFamily} workflow family.`,
|
||||
})
|
||||
}
|
||||
|
||||
if (workflowFamily === 'cad_file' && isAnimation) {
|
||||
issues.push({
|
||||
code: 'cad_animation_unsupported',
|
||||
severity: 'error',
|
||||
message: 'CAD intake workflows do not support animated output types.',
|
||||
})
|
||||
}
|
||||
|
||||
if (artifactKind === 'turntable_video') {
|
||||
if (!isAnimation) {
|
||||
issues.push({
|
||||
code: 'turntable_requires_animation',
|
||||
severity: 'error',
|
||||
message: 'Turntable Video requires animation to be enabled.',
|
||||
})
|
||||
}
|
||||
if (normalizedFormat && !VIDEO_OUTPUT_FORMATS.includes(normalizedFormat as (typeof VIDEO_OUTPUT_FORMATS)[number])) {
|
||||
issues.push({
|
||||
code: 'turntable_requires_video_format',
|
||||
severity: 'error',
|
||||
message: 'Turntable Video requires a video output format.',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
(artifactKind === 'still_image' || artifactKind === 'thumbnail_image') &&
|
||||
VIDEO_OUTPUT_FORMATS.includes(normalizedFormat as (typeof VIDEO_OUTPUT_FORMATS)[number])
|
||||
) {
|
||||
issues.push({
|
||||
code: 'image_artifact_with_video_format',
|
||||
severity: 'error',
|
||||
message: `${artifactKind} cannot use a video output format.`,
|
||||
})
|
||||
}
|
||||
|
||||
if (
|
||||
artifactKind === 'model_export' &&
|
||||
normalizedFormat &&
|
||||
!MODEL_OUTPUT_FORMATS.includes(normalizedFormat as (typeof MODEL_OUTPUT_FORMATS)[number])
|
||||
) {
|
||||
issues.push({
|
||||
code: 'model_export_requires_model_format',
|
||||
severity: 'error',
|
||||
message: 'Model Export requires a 3D export format such as gltf, glb, stl, obj, usd, or usdz.',
|
||||
})
|
||||
}
|
||||
|
||||
if (artifactKind === 'blend_asset') {
|
||||
if (isAnimation) {
|
||||
issues.push({
|
||||
code: 'blend_asset_animation_unsupported',
|
||||
severity: 'error',
|
||||
message: 'Blend Asset does not support animation output.',
|
||||
})
|
||||
}
|
||||
if (normalizedFormat && !BLEND_OUTPUT_FORMATS.includes(normalizedFormat as (typeof BLEND_OUTPUT_FORMATS)[number])) {
|
||||
issues.push({
|
||||
code: 'blend_asset_requires_blend_format',
|
||||
severity: 'error',
|
||||
message: 'Blend Asset requires the blend output format.',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
BLEND_OUTPUT_FORMATS.includes(normalizedFormat as (typeof BLEND_OUTPUT_FORMATS)[number]) &&
|
||||
artifactKind !== 'blend_asset'
|
||||
) {
|
||||
issues.push({
|
||||
code: 'blend_format_requires_blend_asset',
|
||||
severity: 'error',
|
||||
message: 'The blend output format requires the Blend Asset artifact kind.',
|
||||
})
|
||||
}
|
||||
|
||||
if (!selectedWorkflowId) {
|
||||
if (workflowRolloutMode !== 'legacy_only') {
|
||||
issues.push({
|
||||
code: 'rollout_requires_workflow',
|
||||
severity: 'error',
|
||||
message: 'Shadow or graph rollout requires a linked workflow definition.',
|
||||
})
|
||||
}
|
||||
return issues
|
||||
}
|
||||
|
||||
if (selectedWorkflow == null) {
|
||||
issues.push({
|
||||
code: 'workflow_missing',
|
||||
severity: 'error',
|
||||
message: 'The selected workflow definition could not be resolved.',
|
||||
})
|
||||
return issues
|
||||
}
|
||||
|
||||
if (selectedWorkflow.family === 'mixed') {
|
||||
issues.push({
|
||||
code: 'workflow_family_mixed',
|
||||
severity: 'error',
|
||||
message: `Workflow "${selectedWorkflow.name}" mixes CAD and order-line nodes and cannot be linked to an output type.`,
|
||||
})
|
||||
} else if (selectedWorkflow.family !== workflowFamily) {
|
||||
issues.push({
|
||||
code: 'workflow_family_mismatch',
|
||||
severity: 'error',
|
||||
message: `Workflow "${selectedWorkflow.name}" belongs to ${selectedWorkflow.family ?? 'an unknown'} family and does not match ${workflowFamily}.`,
|
||||
})
|
||||
}
|
||||
|
||||
if (!workflowSupportsArtifactKindForOutputTypeContract(selectedWorkflow, artifactKind)) {
|
||||
issues.push({
|
||||
code: 'workflow_artifact_mismatch',
|
||||
severity: 'error',
|
||||
message: `Workflow "${selectedWorkflow.name}" does not produce the ${artifactKind} artifact contract.`,
|
||||
})
|
||||
}
|
||||
|
||||
return issues
|
||||
}
|
||||
|
||||
export function getOutputTypeInvocationOverrides(outputType: OutputType): Record<string, unknown> {
|
||||
const normalized: Record<string, unknown> = {}
|
||||
for (const key of OUTPUT_TYPE_INVOCATION_OVERRIDE_KEYS) {
|
||||
const explicitValue = outputType.invocation_overrides?.[key]
|
||||
const legacyValue = outputType.render_settings?.[key]
|
||||
const value = explicitValue ?? legacyValue
|
||||
if (value !== undefined && value !== null && value !== '') {
|
||||
normalized[key] = value
|
||||
}
|
||||
if (outputType.invocation_profile?.invocation_overrides) {
|
||||
return outputType.invocation_profile.invocation_overrides
|
||||
}
|
||||
return normalized
|
||||
return buildFallbackInvocationProfile(outputType).invocation_overrides
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import api from './client';
|
||||
import type { WorkflowNodeFieldDefinition } from './workflows'
|
||||
|
||||
export interface RenderTemplate {
|
||||
id: string;
|
||||
@@ -15,6 +16,7 @@ export interface RenderTemplate {
|
||||
lighting_only: boolean;
|
||||
shadow_catcher_enabled: boolean;
|
||||
camera_orbit: boolean;
|
||||
workflow_input_schema: WorkflowNodeFieldDefinition[];
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
@@ -41,7 +43,7 @@ export async function createRenderTemplate(formData: FormData): Promise<RenderTe
|
||||
|
||||
export async function duplicateRenderTemplate(
|
||||
sourceId: string,
|
||||
overrides: Partial<Pick<RenderTemplate, 'name' | 'category_key' | 'target_collection' | 'material_replace_enabled' | 'lighting_only' | 'shadow_catcher_enabled' | 'camera_orbit'>> & { output_type_ids?: string[] },
|
||||
overrides: Partial<Pick<RenderTemplate, 'name' | 'category_key' | 'target_collection' | 'material_replace_enabled' | 'lighting_only' | 'shadow_catcher_enabled' | 'camera_orbit' | 'workflow_input_schema'>> & { output_type_ids?: string[] },
|
||||
): Promise<RenderTemplate> {
|
||||
const fd = new FormData();
|
||||
fd.append('name', overrides.name || 'Untitled (copy)');
|
||||
@@ -53,6 +55,7 @@ export async function duplicateRenderTemplate(
|
||||
fd.append('lighting_only', String(overrides.lighting_only ?? false));
|
||||
fd.append('shadow_catcher_enabled', String(overrides.shadow_catcher_enabled ?? false));
|
||||
fd.append('camera_orbit', String(overrides.camera_orbit ?? true));
|
||||
fd.append('workflow_input_schema', JSON.stringify(overrides.workflow_input_schema ?? []));
|
||||
const { data } = await api.post<RenderTemplate>('/render-templates', fd, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
@@ -61,7 +64,7 @@ export async function duplicateRenderTemplate(
|
||||
|
||||
export async function updateRenderTemplate(
|
||||
id: string,
|
||||
updates: Partial<Pick<RenderTemplate, 'name' | 'category_key' | 'output_type_ids' | 'target_collection' | 'material_replace_enabled' | 'lighting_only' | 'shadow_catcher_enabled' | 'camera_orbit' | 'is_active'>>,
|
||||
updates: Partial<Pick<RenderTemplate, 'name' | 'category_key' | 'output_type_ids' | 'target_collection' | 'material_replace_enabled' | 'lighting_only' | 'shadow_catcher_enabled' | 'camera_orbit' | 'workflow_input_schema' | 'is_active'>>,
|
||||
): Promise<RenderTemplate> {
|
||||
const { data } = await api.patch<RenderTemplate>(`/render-templates/${id}`, updates);
|
||||
return data;
|
||||
|
||||
+455
-107
@@ -1,8 +1,43 @@
|
||||
import api from './client'
|
||||
import type { OutputTypeArtifactKind, OutputTypeWorkflowRolloutMode } from './outputTypes'
|
||||
|
||||
export type WorkflowPresetType = 'still' | 'still_graph' | 'turntable' | 'multi_angle' | 'still_with_exports' | 'custom'
|
||||
export type WorkflowExecutionMode = 'legacy' | 'graph' | 'shadow'
|
||||
export type WorkflowStarterFamily = 'cad_file' | 'order_line'
|
||||
export type WorkflowBlueprintType = 'cad_intake' | 'order_rendering' | 'still_graph_reference'
|
||||
export type WorkflowCanonicalBlueprintType = WorkflowBlueprintType | 'starter_cad_intake' | 'starter_order_rendering'
|
||||
|
||||
export interface WorkflowRolloutLatestRun {
|
||||
workflow_run_id: string
|
||||
execution_mode: WorkflowExecutionMode
|
||||
status: string
|
||||
created_at: string
|
||||
completed_at: string | null
|
||||
}
|
||||
|
||||
export interface WorkflowRolloutLinkedOutputType {
|
||||
id: string
|
||||
name: string
|
||||
is_active: boolean
|
||||
artifact_kind: OutputTypeArtifactKind
|
||||
workflow_rollout_mode: OutputTypeWorkflowRolloutMode
|
||||
}
|
||||
|
||||
export interface WorkflowRolloutSummary {
|
||||
linked_output_type_count: number
|
||||
active_output_type_count: number
|
||||
linked_output_type_names: string[]
|
||||
linked_output_types: WorkflowRolloutLinkedOutputType[]
|
||||
rollout_modes: ('legacy_only' | 'shadow' | 'graph' | string)[]
|
||||
has_blocking_contracts: boolean
|
||||
blocking_reasons: string[]
|
||||
latest_run: WorkflowRolloutLatestRun | null
|
||||
latest_shadow_run: WorkflowRolloutLatestRun | null
|
||||
latest_rollout_gate_verdict: 'pass' | 'warn' | 'fail' | null
|
||||
latest_rollout_ready: boolean | null
|
||||
latest_rollout_status: 'ready_for_rollout' | 'hold_legacy_authoritative' | string | null
|
||||
latest_rollout_reasons: string[]
|
||||
}
|
||||
|
||||
export interface WorkflowDefinition {
|
||||
id: string
|
||||
@@ -10,6 +45,8 @@ export interface WorkflowDefinition {
|
||||
output_type_id: string | null
|
||||
config: WorkflowConfig
|
||||
family: WorkflowNodeFamily | 'mixed' | null
|
||||
supported_artifact_kinds?: OutputTypeArtifactKind[]
|
||||
rollout_summary: WorkflowRolloutSummary
|
||||
is_active: boolean
|
||||
created_at: string
|
||||
}
|
||||
@@ -132,6 +169,18 @@ export interface WorkflowPreflightResponse {
|
||||
nodes: WorkflowPreflightNode[]
|
||||
}
|
||||
|
||||
export interface WorkflowOrderLineContextOption {
|
||||
value: string
|
||||
label: string
|
||||
meta: string
|
||||
}
|
||||
|
||||
export interface WorkflowOrderLineContextGroup {
|
||||
order_id: string
|
||||
order_label: string
|
||||
options: WorkflowOrderLineContextOption[]
|
||||
}
|
||||
|
||||
export interface WorkflowDraftPreflightRequest {
|
||||
workflow_id?: string | null
|
||||
context_id: string
|
||||
@@ -162,6 +211,11 @@ export interface WorkflowRunComparison {
|
||||
execution_mode: WorkflowExecutionMode
|
||||
status: string
|
||||
summary: string
|
||||
rollout_gate_verdict: 'pass' | 'warn' | 'fail'
|
||||
workflow_rollout_ready: boolean
|
||||
workflow_rollout_status: 'ready_for_rollout' | 'hold_legacy_authoritative'
|
||||
rollout_reasons: string[]
|
||||
rollout_thresholds: Record<string, number>
|
||||
authoritative_output: WorkflowComparisonArtifact
|
||||
observer_output: WorkflowComparisonArtifact
|
||||
exact_match: boolean | null
|
||||
@@ -209,6 +263,9 @@ export const preflightWorkflowDraft = (
|
||||
): Promise<WorkflowPreflightResponse> =>
|
||||
api.post('/workflows/preflight', data).then(r => r.data)
|
||||
|
||||
export const getWorkflowOrderLineContexts = (limit = 50): Promise<WorkflowOrderLineContextGroup[]> =>
|
||||
api.get('/workflows/contexts/order-lines', { params: { limit } }).then(r => r.data)
|
||||
|
||||
export const getWorkflowRunComparison = (runId: string): Promise<WorkflowRunComparison> =>
|
||||
api.get(`/workflows/runs/${runId}/comparison`).then(r => r.data)
|
||||
|
||||
@@ -235,9 +292,12 @@ export interface WorkflowNodeFieldDefinition {
|
||||
step: number | null
|
||||
unit: string | null
|
||||
options: WorkflowNodeFieldOption[]
|
||||
allow_blank?: boolean
|
||||
max_length?: number | null
|
||||
text_format?: string
|
||||
}
|
||||
|
||||
export type WorkflowNodeFamily = 'cad_file' | 'order_line'
|
||||
export type WorkflowNodeFamily = 'cad_file' | 'order_line' | 'shared'
|
||||
|
||||
export interface WorkflowNodeDefinition {
|
||||
step: string
|
||||
@@ -280,32 +340,75 @@ export const getNodeDefinitions = (): Promise<WorkflowNodeDefinitionsResponse> =
|
||||
export const getPipelineSteps = (): Promise<PipelineStepsResponse> =>
|
||||
api.get('/workflows/pipeline-steps').then(r => r.data)
|
||||
|
||||
function buildStillGraphNodes(renderParams: WorkflowParams): { nodes: WorkflowNode[]; edges: WorkflowEdge[] } {
|
||||
function normalizeRenderParams(params: WorkflowParams = {}): WorkflowParams {
|
||||
const normalized = { ...params }
|
||||
const resolution = Array.isArray(normalized.resolution) ? normalized.resolution : undefined
|
||||
if (resolution && resolution.length === 2) {
|
||||
normalized.width = Number(resolution[0])
|
||||
normalized.height = Number(resolution[1])
|
||||
delete normalized.resolution
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
function buildWorkflowNode(
|
||||
id: string,
|
||||
step: string,
|
||||
x: number,
|
||||
y: number,
|
||||
options: {
|
||||
label: string
|
||||
type?: string
|
||||
params?: WorkflowParams
|
||||
},
|
||||
): WorkflowNode {
|
||||
return {
|
||||
id,
|
||||
step,
|
||||
params: { ...(options.params ?? {}) },
|
||||
ui: {
|
||||
type: options.type,
|
||||
label: options.label,
|
||||
position: { x, y },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function extractRenderParamsFromNodes(nodes: WorkflowNode[], step: string): WorkflowParams {
|
||||
const match = nodes.find(node => node.step === step)
|
||||
return normalizeRenderParams(match?.params ?? {})
|
||||
}
|
||||
|
||||
function buildOrderLineStillGraphNodes(renderParams: WorkflowParams): { nodes: WorkflowNode[]; edges: WorkflowEdge[] } {
|
||||
return {
|
||||
nodes: [
|
||||
{ id: 'setup', step: 'order_line_setup', params: {}, ui: { label: 'Order Line Setup', position: { x: 0, y: 160 } } },
|
||||
{ id: 'template', step: 'resolve_template', params: {}, ui: { label: 'Resolve Template', position: { x: 220, y: 160 } } },
|
||||
{
|
||||
id: 'populate_materials',
|
||||
step: 'auto_populate_materials',
|
||||
params: {},
|
||||
ui: { type: 'processNode', label: 'Auto Populate Materials', position: { x: 220, y: 320 } },
|
||||
},
|
||||
{ id: 'bbox', step: 'glb_bbox', params: {}, ui: { type: 'processNode', label: 'Compute Bounding Box', position: { x: 220, y: 40 } } },
|
||||
{
|
||||
id: 'resolve_materials',
|
||||
step: 'material_map_resolve',
|
||||
params: {},
|
||||
ui: { type: 'processNode', label: 'Resolve Material Map', position: { x: 440, y: 200 } },
|
||||
},
|
||||
{
|
||||
id: 'render',
|
||||
step: 'blender_still',
|
||||
params: { use_custom_render_settings: true, ...renderParams },
|
||||
ui: { type: 'renderNode', label: 'Still Render', position: { x: 680, y: 160 } },
|
||||
},
|
||||
{ id: 'output', step: 'output_save', params: {}, ui: { type: 'outputNode', label: 'Save Output', position: { x: 920, y: 120 } } },
|
||||
{ id: 'notify', step: 'notify', params: {}, ui: { type: 'outputNode', label: 'Notify Result', position: { x: 920, y: 220 } } },
|
||||
buildWorkflowNode('setup', 'order_line_setup', 0, 160, { label: 'Order Line Setup' }),
|
||||
buildWorkflowNode('template', 'resolve_template', 220, 160, { label: 'Resolve Template' }),
|
||||
buildWorkflowNode('populate_materials', 'auto_populate_materials', 220, 320, {
|
||||
label: 'Auto Populate Materials',
|
||||
type: 'processNode',
|
||||
}),
|
||||
buildWorkflowNode('bbox', 'glb_bbox', 220, 40, {
|
||||
label: 'Compute Bounding Box',
|
||||
type: 'processNode',
|
||||
}),
|
||||
buildWorkflowNode('resolve_materials', 'material_map_resolve', 440, 200, {
|
||||
label: 'Resolve Material Map',
|
||||
type: 'processNode',
|
||||
}),
|
||||
buildWorkflowNode('render', 'blender_still', 680, 160, {
|
||||
label: 'Still Render',
|
||||
type: 'renderNode',
|
||||
params: { use_custom_render_settings: false, ...renderParams },
|
||||
}),
|
||||
buildWorkflowNode('output', 'output_save', 920, 120, {
|
||||
label: 'Save Output',
|
||||
type: 'outputNode',
|
||||
}),
|
||||
buildWorkflowNode('notify', 'notify', 920, 220, {
|
||||
label: 'Notify Result',
|
||||
type: 'outputNode',
|
||||
}),
|
||||
],
|
||||
edges: [
|
||||
{ from: 'setup', to: 'template' },
|
||||
@@ -322,24 +425,25 @@ function buildStillGraphNodes(renderParams: WorkflowParams): { nodes: WorkflowNo
|
||||
}
|
||||
}
|
||||
|
||||
function migratePresetConfig(type: WorkflowPresetType, params: WorkflowParams = {}): WorkflowConfig {
|
||||
const renderParams = { ...params }
|
||||
const resolution = Array.isArray(renderParams.resolution) ? renderParams.resolution : undefined
|
||||
if (resolution && resolution.length === 2) {
|
||||
renderParams.width = Number(resolution[0])
|
||||
renderParams.height = Number(resolution[1])
|
||||
delete renderParams.resolution
|
||||
}
|
||||
function buildPresetWorkflowConfigInternal(type: WorkflowPresetType, params: WorkflowParams = {}): WorkflowConfig {
|
||||
const renderParams = normalizeRenderParams(params)
|
||||
|
||||
if (type === 'still') {
|
||||
return {
|
||||
version: 1,
|
||||
ui: { preset: type, execution_mode: 'legacy', family: 'order_line' },
|
||||
nodes: [
|
||||
{ id: 'setup', step: 'order_line_setup', params: {}, ui: { label: 'Order Line Setup', position: { x: 0, y: 100 } } },
|
||||
{ id: 'template', step: 'resolve_template', params: {}, ui: { label: 'Resolve Template', position: { x: 220, y: 100 } } },
|
||||
{ id: 'render', step: 'blender_still', params: renderParams, ui: { type: 'renderNode', label: 'Still Render', position: { x: 440, y: 100 } } },
|
||||
{ id: 'output', step: 'output_save', params: {}, ui: { type: 'outputNode', label: 'Save Output', position: { x: 660, y: 100 } } },
|
||||
buildWorkflowNode('setup', 'order_line_setup', 0, 100, { label: 'Order Line Setup' }),
|
||||
buildWorkflowNode('template', 'resolve_template', 220, 100, { label: 'Resolve Template' }),
|
||||
buildWorkflowNode('render', 'blender_still', 440, 100, {
|
||||
label: 'Still Render',
|
||||
type: 'renderNode',
|
||||
params: renderParams,
|
||||
}),
|
||||
buildWorkflowNode('output', 'output_save', 660, 100, {
|
||||
label: 'Save Output',
|
||||
type: 'outputNode',
|
||||
}),
|
||||
],
|
||||
edges: [
|
||||
{ from: 'setup', to: 'template' },
|
||||
@@ -350,7 +454,7 @@ function migratePresetConfig(type: WorkflowPresetType, params: WorkflowParams =
|
||||
}
|
||||
|
||||
if (type === 'still_graph') {
|
||||
const { nodes, edges } = buildStillGraphNodes(renderParams)
|
||||
const { nodes, edges } = buildOrderLineStillGraphNodes(renderParams)
|
||||
return {
|
||||
version: 1,
|
||||
ui: { preset: type, execution_mode: 'graph', family: 'order_line' },
|
||||
@@ -364,10 +468,17 @@ function migratePresetConfig(type: WorkflowPresetType, params: WorkflowParams =
|
||||
version: 1,
|
||||
ui: { preset: type, execution_mode: 'legacy', family: 'order_line' },
|
||||
nodes: [
|
||||
{ id: 'setup', step: 'order_line_setup', params: {}, ui: { label: 'Order Line Setup', position: { x: 0, y: 100 } } },
|
||||
{ id: 'template', step: 'resolve_template', params: {}, ui: { label: 'Resolve Template', position: { x: 220, y: 100 } } },
|
||||
{ id: 'turntable', step: 'blender_turntable', params: renderParams, ui: { type: 'renderFramesNode', label: 'Turntable Render', position: { x: 440, y: 100 } } },
|
||||
{ id: 'output', step: 'output_save', params: {}, ui: { type: 'outputNode', label: 'Save Output', position: { x: 660, y: 100 } } },
|
||||
buildWorkflowNode('setup', 'order_line_setup', 0, 100, { label: 'Order Line Setup' }),
|
||||
buildWorkflowNode('template', 'resolve_template', 220, 100, { label: 'Resolve Template' }),
|
||||
buildWorkflowNode('turntable', 'blender_turntable', 440, 100, {
|
||||
label: 'Turntable Render',
|
||||
type: 'renderFramesNode',
|
||||
params: renderParams,
|
||||
}),
|
||||
buildWorkflowNode('output', 'output_save', 660, 100, {
|
||||
label: 'Save Output',
|
||||
type: 'outputNode',
|
||||
}),
|
||||
],
|
||||
edges: [
|
||||
{ from: 'setup', to: 'template' },
|
||||
@@ -385,15 +496,19 @@ function migratePresetConfig(type: WorkflowPresetType, params: WorkflowParams =
|
||||
version: 1,
|
||||
ui: { preset: type, execution_mode: 'legacy', family: 'order_line' },
|
||||
nodes: [
|
||||
{ id: 'setup', step: 'order_line_setup', params: {}, ui: { label: 'Order Line Setup', position: { x: 0, y: 195 } } },
|
||||
{ id: 'template', step: 'resolve_template', params: {}, ui: { label: 'Resolve Template', position: { x: 220, y: 195 } } },
|
||||
...angles.map((angle, index) => ({
|
||||
id: `render_${index}`,
|
||||
step: 'blender_still',
|
||||
params: { ...sharedParams, rotation_z: angle },
|
||||
ui: { type: 'renderNode', label: `Render ${angle}°`, position: { x: 440, y: index * 130 } },
|
||||
})),
|
||||
{ id: 'output', step: 'output_save', params: {}, ui: { type: 'outputNode', label: 'Save Output', position: { x: 700, y: 195 } } },
|
||||
buildWorkflowNode('setup', 'order_line_setup', 0, 195, { label: 'Order Line Setup' }),
|
||||
buildWorkflowNode('template', 'resolve_template', 220, 195, { label: 'Resolve Template' }),
|
||||
...angles.map((angle, index) =>
|
||||
buildWorkflowNode(`render_${index}`, 'blender_still', 440, index * 130, {
|
||||
label: `Render ${angle}°`,
|
||||
type: 'renderNode',
|
||||
params: { ...sharedParams, rotation_z: angle },
|
||||
}),
|
||||
),
|
||||
buildWorkflowNode('output', 'output_save', 700, 195, {
|
||||
label: 'Save Output',
|
||||
type: 'outputNode',
|
||||
}),
|
||||
],
|
||||
edges: [
|
||||
{ from: 'setup', to: 'template' },
|
||||
@@ -408,11 +523,21 @@ function migratePresetConfig(type: WorkflowPresetType, params: WorkflowParams =
|
||||
version: 1,
|
||||
ui: { preset: type, execution_mode: 'legacy', family: 'order_line' },
|
||||
nodes: [
|
||||
{ id: 'setup', step: 'order_line_setup', params: {}, ui: { label: 'Order Line Setup', position: { x: 0, y: 100 } } },
|
||||
{ id: 'template', step: 'resolve_template', params: {}, ui: { label: 'Resolve Template', position: { x: 220, y: 100 } } },
|
||||
{ id: 'render', step: 'blender_still', params: renderParams, ui: { type: 'renderNode', label: 'Still Render', position: { x: 440, y: 100 } } },
|
||||
{ id: 'output', step: 'output_save', params: {}, ui: { type: 'outputNode', label: 'Save Output', position: { x: 660, y: 70 } } },
|
||||
{ id: 'blend', step: 'export_blend', params: {}, ui: { type: 'outputNode', label: 'Export Blend', position: { x: 660, y: 160 } } },
|
||||
buildWorkflowNode('setup', 'order_line_setup', 0, 100, { label: 'Order Line Setup' }),
|
||||
buildWorkflowNode('template', 'resolve_template', 220, 100, { label: 'Resolve Template' }),
|
||||
buildWorkflowNode('render', 'blender_still', 440, 100, {
|
||||
label: 'Still Render',
|
||||
type: 'renderNode',
|
||||
params: renderParams,
|
||||
}),
|
||||
buildWorkflowNode('output', 'output_save', 660, 70, {
|
||||
label: 'Save Output',
|
||||
type: 'outputNode',
|
||||
}),
|
||||
buildWorkflowNode('blend', 'export_blend', 660, 160, {
|
||||
label: 'Export Blend',
|
||||
type: 'outputNode',
|
||||
}),
|
||||
],
|
||||
edges: [
|
||||
{ from: 'setup', to: 'template' },
|
||||
@@ -427,22 +552,245 @@ function migratePresetConfig(type: WorkflowPresetType, params: WorkflowParams =
|
||||
version: 1,
|
||||
ui: { preset: 'custom', execution_mode: 'legacy', family: 'order_line' },
|
||||
nodes: [
|
||||
{
|
||||
id: 'setup',
|
||||
step: 'order_line_setup',
|
||||
params: {},
|
||||
ui: { label: 'Order Line Setup', position: { x: 120, y: 140 } },
|
||||
},
|
||||
buildWorkflowNode('setup', 'order_line_setup', 120, 140, {
|
||||
label: 'Order Line Setup',
|
||||
type: 'processNode',
|
||||
}),
|
||||
],
|
||||
edges: [],
|
||||
}
|
||||
}
|
||||
|
||||
export function buildWorkflowBlueprintConfig(blueprint: WorkflowBlueprintType): WorkflowConfig {
|
||||
if (blueprint === 'cad_intake') {
|
||||
return {
|
||||
version: 1,
|
||||
ui: { preset: 'custom', execution_mode: 'legacy', family: 'cad_file', blueprint },
|
||||
nodes: [
|
||||
buildWorkflowNode('resolve_step', 'resolve_step_path', 0, 180, { label: 'Resolve STEP Path' }),
|
||||
buildWorkflowNode('extract_objects', 'occ_object_extract', 220, 180, {
|
||||
label: 'Extract STEP Objects',
|
||||
}),
|
||||
buildWorkflowNode('export_glb', 'occ_glb_export', 440, 180, { label: 'Export GLB' }),
|
||||
buildWorkflowNode('bbox', 'glb_bbox', 660, 120, {
|
||||
label: 'Compute Bounding Box',
|
||||
type: 'processNode',
|
||||
}),
|
||||
buildWorkflowNode('stl_cache', 'stl_cache_generate', 660, 300, { label: 'Generate STL Cache' }),
|
||||
buildWorkflowNode('blender_thumb', 'blender_render', 880, 120, {
|
||||
label: 'Render Thumbnail (Blender)',
|
||||
type: 'renderNode',
|
||||
params: { render_engine: 'cycles', samples: 64, width: 512, height: 512 },
|
||||
}),
|
||||
buildWorkflowNode('threejs_thumb', 'threejs_render', 880, 320, {
|
||||
label: 'Render Thumbnail (Three.js)',
|
||||
type: 'renderNode',
|
||||
params: { width: 512, height: 512, transparent_bg: true },
|
||||
}),
|
||||
buildWorkflowNode('save_blender_thumb', 'thumbnail_save', 1100, 120, {
|
||||
label: 'Save Blender Thumbnail',
|
||||
type: 'outputNode',
|
||||
}),
|
||||
buildWorkflowNode('save_threejs_thumb', 'thumbnail_save', 1100, 320, {
|
||||
label: 'Save Three.js Thumbnail',
|
||||
type: 'outputNode',
|
||||
}),
|
||||
],
|
||||
edges: [
|
||||
{ from: 'resolve_step', to: 'extract_objects' },
|
||||
{ from: 'extract_objects', to: 'export_glb' },
|
||||
{ from: 'export_glb', to: 'bbox' },
|
||||
{ from: 'export_glb', to: 'stl_cache' },
|
||||
{ from: 'export_glb', to: 'blender_thumb' },
|
||||
{ from: 'export_glb', to: 'threejs_thumb' },
|
||||
{ from: 'bbox', to: 'threejs_thumb' },
|
||||
{ from: 'blender_thumb', to: 'save_blender_thumb' },
|
||||
{ from: 'threejs_thumb', to: 'save_threejs_thumb' },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
if (blueprint === 'order_rendering') {
|
||||
return {
|
||||
version: 1,
|
||||
ui: { preset: 'custom', execution_mode: 'legacy', family: 'order_line', blueprint },
|
||||
nodes: [
|
||||
buildWorkflowNode('setup', 'order_line_setup', 0, 220, { label: 'Order Line Setup' }),
|
||||
buildWorkflowNode('template', 'resolve_template', 220, 220, { label: 'Resolve Template' }),
|
||||
buildWorkflowNode('populate_materials', 'auto_populate_materials', 220, 360, {
|
||||
label: 'Auto Populate Materials',
|
||||
}),
|
||||
buildWorkflowNode('bbox', 'glb_bbox', 220, 80, { label: 'Compute Bounding Box' }),
|
||||
buildWorkflowNode('resolve_materials', 'material_map_resolve', 440, 220, {
|
||||
label: 'Resolve Material Map',
|
||||
}),
|
||||
buildWorkflowNode('still_render', 'blender_still', 680, 80, {
|
||||
label: 'Render Still',
|
||||
type: 'renderNode',
|
||||
params: { rotation_z: 0 },
|
||||
}),
|
||||
buildWorkflowNode('turntable_render', 'blender_turntable', 680, 220, {
|
||||
label: 'Render Turntable',
|
||||
type: 'renderFramesNode',
|
||||
params: { fps: 24, duration_s: 5 },
|
||||
}),
|
||||
buildWorkflowNode('blend_export', 'export_blend', 680, 360, {
|
||||
label: 'Export Blend',
|
||||
type: 'outputNode',
|
||||
}),
|
||||
buildWorkflowNode('save_still', 'output_save', 920, 80, {
|
||||
label: 'Save Still Output',
|
||||
type: 'outputNode',
|
||||
}),
|
||||
buildWorkflowNode('save_turntable', 'output_save', 920, 220, {
|
||||
label: 'Save Turntable Output',
|
||||
type: 'outputNode',
|
||||
}),
|
||||
buildWorkflowNode('notify_still', 'notify', 920, 140, {
|
||||
label: 'Notify Still Result',
|
||||
type: 'outputNode',
|
||||
}),
|
||||
buildWorkflowNode('notify_turntable', 'notify', 920, 280, {
|
||||
label: 'Notify Turntable Result',
|
||||
type: 'outputNode',
|
||||
}),
|
||||
buildWorkflowNode('notify_export', 'notify', 920, 360, {
|
||||
label: 'Notify Blend Export',
|
||||
type: 'outputNode',
|
||||
}),
|
||||
],
|
||||
edges: [
|
||||
{ from: 'setup', to: 'template' },
|
||||
{ from: 'setup', to: 'populate_materials' },
|
||||
{ from: 'setup', to: 'bbox' },
|
||||
{ from: 'template', to: 'resolve_materials' },
|
||||
{ from: 'populate_materials', to: 'resolve_materials' },
|
||||
{ from: 'resolve_materials', to: 'still_render' },
|
||||
{ from: 'resolve_materials', to: 'turntable_render' },
|
||||
{ from: 'bbox', to: 'still_render' },
|
||||
{ from: 'bbox', to: 'turntable_render' },
|
||||
{ from: 'template', to: 'still_render' },
|
||||
{ from: 'template', to: 'turntable_render' },
|
||||
{ from: 'template', to: 'blend_export' },
|
||||
{ from: 'still_render', to: 'save_still' },
|
||||
{ from: 'still_render', to: 'notify_still' },
|
||||
{ from: 'turntable_render', to: 'save_turntable' },
|
||||
{ from: 'turntable_render', to: 'notify_turntable' },
|
||||
{ from: 'blend_export', to: 'notify_export' },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
const { nodes, edges } = buildOrderLineStillGraphNodes({
|
||||
render_engine: 'cycles',
|
||||
samples: 256,
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
})
|
||||
|
||||
return {
|
||||
version: 1,
|
||||
ui: { preset: 'custom', execution_mode: 'graph', family: 'order_line', blueprint },
|
||||
nodes,
|
||||
edges,
|
||||
}
|
||||
}
|
||||
|
||||
function buildStarterWorkflowConfigInternal(family: WorkflowStarterFamily = 'order_line'): WorkflowConfig {
|
||||
if (family === 'cad_file') {
|
||||
return {
|
||||
version: 1,
|
||||
ui: {
|
||||
preset: 'custom',
|
||||
execution_mode: 'legacy',
|
||||
family: 'cad_file',
|
||||
blueprint: 'starter_cad_intake',
|
||||
},
|
||||
nodes: [
|
||||
buildWorkflowNode('resolve_step', 'resolve_step_path', 120, 140, {
|
||||
label: 'Resolve STEP Path',
|
||||
type: 'inputNode',
|
||||
}),
|
||||
],
|
||||
edges: [],
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
version: 1,
|
||||
ui: {
|
||||
preset: 'custom',
|
||||
execution_mode: 'legacy',
|
||||
family: 'order_line',
|
||||
blueprint: 'starter_order_rendering',
|
||||
},
|
||||
nodes: [
|
||||
buildWorkflowNode('setup', 'order_line_setup', 120, 140, {
|
||||
label: 'Order Line Setup',
|
||||
type: 'processNode',
|
||||
}),
|
||||
],
|
||||
edges: [],
|
||||
}
|
||||
}
|
||||
|
||||
export function buildStillGraphNodes(renderParams: WorkflowParams): { nodes: WorkflowNode[]; edges: WorkflowEdge[] } {
|
||||
return buildOrderLineStillGraphNodes(normalizeRenderParams(renderParams))
|
||||
}
|
||||
|
||||
function migratePresetConfig(type: WorkflowPresetType, params: WorkflowParams = {}): WorkflowConfig {
|
||||
return buildPresetWorkflowConfigInternal(type, params)
|
||||
}
|
||||
|
||||
function normalizeWorkflowDefinition(raw: WorkflowDefinition): WorkflowDefinition {
|
||||
const config = normalizeWorkflowConfig(raw.config as unknown as Record<string, unknown>)
|
||||
return {
|
||||
...raw,
|
||||
family: raw.family ?? inferWorkflowFamily(config),
|
||||
supported_artifact_kinds: Array.isArray(raw.supported_artifact_kinds)
|
||||
? raw.supported_artifact_kinds
|
||||
: [],
|
||||
rollout_summary: {
|
||||
linked_output_type_count: Number(raw.rollout_summary?.linked_output_type_count ?? 0),
|
||||
active_output_type_count: Number(raw.rollout_summary?.active_output_type_count ?? 0),
|
||||
linked_output_type_names: Array.isArray(raw.rollout_summary?.linked_output_type_names)
|
||||
? raw.rollout_summary.linked_output_type_names
|
||||
: [],
|
||||
linked_output_types: Array.isArray(raw.rollout_summary?.linked_output_types)
|
||||
? raw.rollout_summary.linked_output_types
|
||||
.filter((outputType): outputType is WorkflowRolloutLinkedOutputType => (
|
||||
outputType != null
|
||||
&& typeof outputType === 'object'
|
||||
&& typeof outputType.id === 'string'
|
||||
&& typeof outputType.name === 'string'
|
||||
))
|
||||
.map(outputType => ({
|
||||
id: outputType.id,
|
||||
name: outputType.name,
|
||||
is_active: Boolean(outputType.is_active),
|
||||
artifact_kind: outputType.artifact_kind,
|
||||
workflow_rollout_mode: outputType.workflow_rollout_mode ?? 'legacy_only',
|
||||
}))
|
||||
: [],
|
||||
rollout_modes: Array.isArray(raw.rollout_summary?.rollout_modes)
|
||||
? raw.rollout_summary.rollout_modes
|
||||
: [],
|
||||
has_blocking_contracts: Boolean(raw.rollout_summary?.has_blocking_contracts),
|
||||
blocking_reasons: Array.isArray(raw.rollout_summary?.blocking_reasons)
|
||||
? raw.rollout_summary.blocking_reasons
|
||||
: [],
|
||||
latest_run: raw.rollout_summary?.latest_run ?? null,
|
||||
latest_shadow_run: raw.rollout_summary?.latest_shadow_run ?? null,
|
||||
latest_rollout_gate_verdict: raw.rollout_summary?.latest_rollout_gate_verdict ?? null,
|
||||
latest_rollout_ready:
|
||||
typeof raw.rollout_summary?.latest_rollout_ready === 'boolean'
|
||||
? raw.rollout_summary.latest_rollout_ready
|
||||
: null,
|
||||
latest_rollout_status: raw.rollout_summary?.latest_rollout_status ?? null,
|
||||
latest_rollout_reasons: Array.isArray(raw.rollout_summary?.latest_rollout_reasons)
|
||||
? raw.rollout_summary.latest_rollout_reasons
|
||||
: [],
|
||||
},
|
||||
config,
|
||||
}
|
||||
}
|
||||
@@ -455,14 +803,51 @@ export function normalizeWorkflowConfig(raw: Record<string, unknown>): WorkflowC
|
||||
params: { ...(node.params ?? {}) },
|
||||
}))
|
||||
const edges = Array.isArray(raw.edges) ? (raw.edges as WorkflowEdge[]) : []
|
||||
const mergedUi = {
|
||||
...rawUi,
|
||||
execution_mode: rawUi.execution_mode ?? 'legacy',
|
||||
}
|
||||
|
||||
if (rawUi.preset === 'still_graph') {
|
||||
const canonical = buildPresetWorkflowConfigInternal('still_graph', extractRenderParamsFromNodes(nodes, 'blender_still'))
|
||||
return {
|
||||
...canonical,
|
||||
ui: {
|
||||
...canonical.ui,
|
||||
...mergedUi,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if (rawUi.blueprint === 'cad_intake' || rawUi.blueprint === 'order_rendering' || rawUi.blueprint === 'still_graph_reference') {
|
||||
const canonical = buildWorkflowBlueprintConfig(rawUi.blueprint)
|
||||
return {
|
||||
...canonical,
|
||||
ui: {
|
||||
...canonical.ui,
|
||||
...mergedUi,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if (rawUi.blueprint === 'starter_cad_intake' || rawUi.blueprint === 'starter_order_rendering') {
|
||||
const canonical = buildStarterWorkflowConfigInternal(rawUi.blueprint === 'starter_cad_intake' ? 'cad_file' : 'order_line')
|
||||
return {
|
||||
...canonical,
|
||||
ui: {
|
||||
...canonical.ui,
|
||||
...mergedUi,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
version: Number(raw.version ?? 1),
|
||||
nodes,
|
||||
edges,
|
||||
ui: {
|
||||
...rawUi,
|
||||
execution_mode: rawUi.execution_mode ?? 'legacy',
|
||||
family: rawUi.family ?? inferWorkflowFamily({ version: Number(raw.version ?? 1), nodes, edges }),
|
||||
...mergedUi,
|
||||
family: rawUi.family ?? inferWorkflowFamily({ version: Number(raw.version ?? 1), nodes, edges }) ?? undefined,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -480,49 +865,11 @@ export function normalizeWorkflowConfig(raw: Record<string, unknown>): WorkflowC
|
||||
}
|
||||
|
||||
export function createPresetWorkflowConfig(type: WorkflowPresetType, params: WorkflowParams = {}): WorkflowConfig {
|
||||
return migratePresetConfig(type, params)
|
||||
return buildPresetWorkflowConfigInternal(type, params)
|
||||
}
|
||||
|
||||
export function createStarterWorkflowConfig(family: WorkflowStarterFamily = 'order_line'): WorkflowConfig {
|
||||
if (family === 'cad_file') {
|
||||
return {
|
||||
version: 1,
|
||||
ui: {
|
||||
preset: 'custom',
|
||||
execution_mode: 'legacy',
|
||||
family: 'cad_file',
|
||||
blueprint: 'starter_cad_intake',
|
||||
},
|
||||
nodes: [
|
||||
{
|
||||
id: 'resolve_step',
|
||||
step: 'resolve_step_path',
|
||||
params: {},
|
||||
ui: { type: 'inputNode', label: 'Resolve STEP Path', position: { x: 120, y: 140 } },
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
version: 1,
|
||||
ui: {
|
||||
preset: 'custom',
|
||||
execution_mode: 'legacy',
|
||||
family: 'order_line',
|
||||
blueprint: 'starter_order_rendering',
|
||||
},
|
||||
nodes: [
|
||||
{
|
||||
id: 'setup',
|
||||
step: 'order_line_setup',
|
||||
params: {},
|
||||
ui: { type: 'processNode', label: 'Order Line Setup', position: { x: 120, y: 140 } },
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
}
|
||||
return buildStarterWorkflowConfigInternal(family)
|
||||
}
|
||||
|
||||
export function getWorkflowPresetType(config: WorkflowConfig): WorkflowPresetType {
|
||||
@@ -542,11 +889,12 @@ export function inferWorkflowFamily(config: WorkflowConfig): WorkflowNodeFamily
|
||||
case 'threejs_render':
|
||||
case 'thumbnail_save':
|
||||
return 'cad_file'
|
||||
case 'glb_bbox':
|
||||
return null
|
||||
case 'order_line_setup':
|
||||
case 'resolve_template':
|
||||
case 'material_map_resolve':
|
||||
case 'auto_populate_materials':
|
||||
case 'glb_bbox':
|
||||
case 'blender_still':
|
||||
case 'blender_turntable':
|
||||
case 'output_save':
|
||||
@@ -557,7 +905,7 @@ export function inferWorkflowFamily(config: WorkflowConfig): WorkflowNodeFamily
|
||||
return null
|
||||
}
|
||||
})
|
||||
.filter((family): family is WorkflowNodeFamily => family !== null),
|
||||
.filter((family): family is Exclude<WorkflowNodeFamily, 'shared'> => family !== null),
|
||||
)
|
||||
if (families.size === 0) return null
|
||||
if (families.size > 1) return 'mixed'
|
||||
|
||||
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.',
|
||||
}
|
||||
}
|
||||
@@ -4,12 +4,10 @@
|
||||
* Wraps the app with a single WebSocket connection. On incoming events it
|
||||
* invalidates the relevant React Query caches so all subscribers refresh.
|
||||
*/
|
||||
import { createContext, useContext, useCallback } from 'react'
|
||||
import { useCallback } from 'react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useWebSocket, type WSEvent } from '../hooks/useWebSocket'
|
||||
|
||||
const WebSocketContext = createContext<null>(null)
|
||||
|
||||
export function WebSocketProvider({ children }: { children: React.ReactNode }) {
|
||||
const qc = useQueryClient()
|
||||
|
||||
@@ -49,14 +47,5 @@ export function WebSocketProvider({ children }: { children: React.ReactNode }) {
|
||||
|
||||
useWebSocket({ onEvent })
|
||||
|
||||
return (
|
||||
<WebSocketContext.Provider value={null}>
|
||||
{children}
|
||||
</WebSocketContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export function useWebSocketContext() {
|
||||
return useContext(WebSocketContext)
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ interface UseWebSocketOptions {
|
||||
const WS_BASE =
|
||||
window.location.protocol === 'https:'
|
||||
? `wss://${window.location.host}`
|
||||
: `ws://${window.location.hostname}:8888`
|
||||
: `ws://${window.location.host}`
|
||||
|
||||
const PING_INTERVAL_MS = 25_000
|
||||
const MAX_BACKOFF_MS = 30_000
|
||||
@@ -33,18 +33,32 @@ export function useWebSocket({ onEvent, enabled = true }: UseWebSocketOptions =
|
||||
const backoffRef = useRef(1000)
|
||||
const pingRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
const reconnectRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const connectionVersionRef = useRef(0)
|
||||
const onEventRef = useRef(onEvent)
|
||||
onEventRef.current = onEvent
|
||||
|
||||
const cleanup = useCallback(() => {
|
||||
connectionVersionRef.current += 1
|
||||
if (pingRef.current) clearInterval(pingRef.current)
|
||||
if (reconnectRef.current) clearTimeout(reconnectRef.current)
|
||||
if (wsRef.current) {
|
||||
wsRef.current.onclose = null
|
||||
wsRef.current.onerror = null
|
||||
wsRef.current.onmessage = null
|
||||
wsRef.current.close()
|
||||
wsRef.current = null
|
||||
const ws = wsRef.current
|
||||
wsRef.current = null
|
||||
|
||||
if (ws) {
|
||||
if (ws.readyState === WebSocket.CONNECTING) {
|
||||
ws.onopen = () => ws.close(1000, 'teardown')
|
||||
ws.onclose = null
|
||||
ws.onerror = null
|
||||
ws.onmessage = null
|
||||
return
|
||||
}
|
||||
|
||||
ws.onclose = null
|
||||
ws.onerror = null
|
||||
ws.onmessage = null
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.close(1000, 'teardown')
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
@@ -54,9 +68,15 @@ export function useWebSocket({ onEvent, enabled = true }: UseWebSocketOptions =
|
||||
|
||||
const url = `${WS_BASE}/api/ws?token=${encodeURIComponent(token)}`
|
||||
const ws = new WebSocket(url)
|
||||
const connectionVersion = connectionVersionRef.current + 1
|
||||
connectionVersionRef.current = connectionVersion
|
||||
wsRef.current = ws
|
||||
|
||||
ws.onopen = () => {
|
||||
if (connectionVersion !== connectionVersionRef.current) {
|
||||
ws.close(1000, 'stale')
|
||||
return
|
||||
}
|
||||
backoffRef.current = 1000 // reset backoff on successful connect
|
||||
// Keep-alive pings
|
||||
pingRef.current = setInterval(() => {
|
||||
@@ -67,6 +87,7 @@ export function useWebSocket({ onEvent, enabled = true }: UseWebSocketOptions =
|
||||
}
|
||||
|
||||
ws.onmessage = (evt) => {
|
||||
if (connectionVersion !== connectionVersionRef.current) return
|
||||
try {
|
||||
const event = JSON.parse(evt.data) as WSEvent
|
||||
onEventRef.current?.(event)
|
||||
@@ -76,6 +97,7 @@ export function useWebSocket({ onEvent, enabled = true }: UseWebSocketOptions =
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
if (connectionVersion !== connectionVersionRef.current) return
|
||||
if (pingRef.current) clearInterval(pingRef.current)
|
||||
// Schedule reconnect with exponential backoff
|
||||
const delay = backoffRef.current
|
||||
@@ -84,7 +106,10 @@ export function useWebSocket({ onEvent, enabled = true }: UseWebSocketOptions =
|
||||
}
|
||||
|
||||
ws.onerror = () => {
|
||||
ws.close()
|
||||
if (connectionVersion !== connectionVersionRef.current) return
|
||||
if (ws.readyState === WebSocket.CONNECTING || ws.readyState === WebSocket.OPEN) {
|
||||
ws.close()
|
||||
}
|
||||
}
|
||||
}, [token, enabled, cleanup])
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ export default function LoginPage() {
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
autoComplete="email"
|
||||
required
|
||||
className="input-base w-full"
|
||||
placeholder="admin@hartomat.com"
|
||||
@@ -59,6 +60,7 @@ export default function LoginPage() {
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
required
|
||||
className="input-base w-full pr-10"
|
||||
/>
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
listMaterials, createMaterial, updateMaterial, deleteMaterial,
|
||||
seedHartOMatMaterials, addAlias, deleteAlias, seedAliases,
|
||||
seedHartOMatMaterials, addAlias, deleteAlias, listAliases, seedAliases,
|
||||
batchCreateAliases,
|
||||
} from '../api/materials'
|
||||
import type { Material } from '../api/materials'
|
||||
@@ -584,12 +584,10 @@ function AliasPill({
|
||||
materials: Material[]
|
||||
}) {
|
||||
// We need the alias ID for deletion - find it from the material's aliases list
|
||||
// Since we only have alias strings from MaterialOut, we need to query the ID
|
||||
// We'll use a lazy approach: delete by fetching aliases for this material
|
||||
// Since we only have alias strings from MaterialOut, we need to query the ID.
|
||||
const handleDelete = async () => {
|
||||
try {
|
||||
const { listAliases: fetchAliases } = await import('../api/materials')
|
||||
const aliases = await fetchAliases(materialId)
|
||||
const aliases = await listAliases(materialId)
|
||||
const found = aliases.find((a) => a.alias === alias)
|
||||
if (found) {
|
||||
onDelete.mutate(found.id)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { useState, useEffect, useMemo, useLayoutEffect, useCallback } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import {
|
||||
ReactFlow,
|
||||
Background,
|
||||
@@ -14,15 +15,21 @@ import '@xyflow/react/dist/style.css'
|
||||
import { useThemeStore, resolveTheme } from '../store/theme'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { NewWorkflowModal } from '../components/workflows/NewWorkflowModal'
|
||||
import { NodeCommandMenu } from '../components/workflows/NodeCommandMenu'
|
||||
import { NodeCommandMenu, NODE_COMMAND_MENU_WIDTH } from '../components/workflows/NodeCommandMenu'
|
||||
import { WorkflowCanvasToolbar } from '../components/workflows/WorkflowCanvasToolbar'
|
||||
import { WorkflowCanvasUtilitySidebar } from '../components/workflows/WorkflowCanvasUtilitySidebar'
|
||||
import { WorkflowEditorEmptyState } from '../components/workflows/WorkflowEditorEmptyState'
|
||||
import { WorkflowListSidebar } from '../components/workflows/WorkflowListSidebar'
|
||||
import { WorkflowValidationBanner } from '../components/workflows/WorkflowValidationBanner'
|
||||
import {
|
||||
WORKFLOW_NODE_MIN_HEIGHT,
|
||||
WORKFLOW_NODE_WIDTH,
|
||||
type WorkflowCanvasNodeData,
|
||||
} from '../components/workflows/workflowGraphDraft'
|
||||
import {
|
||||
getWorkflowNodePortBadgeLabel,
|
||||
getWorkflowNodePortTitle,
|
||||
} from '../components/workflows/workflowNodePresentation'
|
||||
import {
|
||||
BLUEPRINT_DESCRIPTION,
|
||||
BLUEPRINT_LABELS,
|
||||
@@ -43,6 +50,7 @@ import {
|
||||
type WorkflowExecutionMode,
|
||||
type WorkflowPresetType,
|
||||
} from '../api/workflows'
|
||||
import { updateOutputType } from '../api/outputTypes'
|
||||
import {
|
||||
FileUp,
|
||||
RefreshCw,
|
||||
@@ -63,6 +71,12 @@ import {
|
||||
EXECUTION_MODE_BADGE_STYLES,
|
||||
EXECUTION_MODE_LABELS,
|
||||
} from '../components/workflows/workflowRunPresentation'
|
||||
import { getWorkflowRolloutPresentation } from '../components/workflows/workflowRolloutPresentation'
|
||||
import {
|
||||
getWorkflowAuthoringEntryAction,
|
||||
type WorkflowAuthoringActions,
|
||||
} from '../components/workflows/workflowAuthoringActions'
|
||||
import { getWorkflowAuthoringSurfaceModel } from '../components/workflows/workflowAuthoringSurface'
|
||||
import { useWorkflowCanvasController } from '../components/workflows/useWorkflowCanvasController'
|
||||
|
||||
function renderWorkflowIcon(iconName?: string, size = 14) {
|
||||
@@ -88,33 +102,228 @@ function renderWorkflowIcon(iconName?: string, size = 14) {
|
||||
// ─── Custom Node Components ──────────────────────────────────────────────────
|
||||
|
||||
interface BaseNodeProps {
|
||||
label: string
|
||||
data: WorkflowCanvasNodeData
|
||||
icon: React.ReactNode
|
||||
accentClass: string
|
||||
description?: string
|
||||
selected?: boolean
|
||||
hasSource?: boolean
|
||||
hasTarget?: boolean
|
||||
}
|
||||
|
||||
function BaseNode({ label, icon, accentClass, description, selected, hasSource = true, hasTarget = true }: BaseNodeProps) {
|
||||
function getHandleOffset(index: number, total: number) {
|
||||
if (total <= 1) return '50%'
|
||||
const topPadding = 22
|
||||
const bottomPadding = 22
|
||||
const usableHeight = WORKFLOW_NODE_MIN_HEIGHT - topPadding - bottomPadding
|
||||
const step = usableHeight / (total - 1)
|
||||
return `${topPadding + step * index}px`
|
||||
}
|
||||
|
||||
type NodeBadge = {
|
||||
id: string
|
||||
label: string
|
||||
title?: string
|
||||
tone?: 'default' | 'muted'
|
||||
}
|
||||
|
||||
function formatCountLabel(count: number, singular: string, plural: string) {
|
||||
return `${count} ${count === 1 ? singular : plural}`
|
||||
}
|
||||
|
||||
function getNodeConfigurationSummary(data: WorkflowCanvasNodeData) {
|
||||
const inputPorts = data.inputPorts ?? []
|
||||
const editableFieldCount = data.editableFieldCount ?? 0
|
||||
const hasDynamicVariables = Boolean(data.dynamicVariableHint)
|
||||
|
||||
const inputSummary =
|
||||
inputPorts.length > 0
|
||||
? `Canvas expects ${formatCountLabel(inputPorts.length, 'input socket', 'input sockets')}.`
|
||||
: 'Entry node, no upstream sockets required.'
|
||||
|
||||
if (editableFieldCount > 0) {
|
||||
return `${inputSummary} Inspector exposes ${formatCountLabel(editableFieldCount, 'local variable', 'local variables')}.`
|
||||
}
|
||||
|
||||
if (hasDynamicVariables) {
|
||||
return `${inputSummary} Template-selected inspector variables appear after choosing a template.`
|
||||
}
|
||||
|
||||
return `${inputSummary} No inspector variables, behavior comes from connections and runtime context.`
|
||||
}
|
||||
|
||||
function BadgeList({
|
||||
badges,
|
||||
emptyLabel,
|
||||
badgeClassName,
|
||||
maxVisibleBadges = 3,
|
||||
}: {
|
||||
badges: NodeBadge[]
|
||||
emptyLabel: string
|
||||
badgeClassName: string
|
||||
maxVisibleBadges?: number
|
||||
}) {
|
||||
if (badges.length === 0) {
|
||||
return <p className="text-[10px] text-content-muted">{emptyLabel}</p>
|
||||
}
|
||||
|
||||
const visibleBadges = badges.slice(0, maxVisibleBadges)
|
||||
const hiddenCount = badges.length - visibleBadges.length
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1 overflow-hidden">
|
||||
{visibleBadges.map(badge => (
|
||||
<span
|
||||
key={badge.id}
|
||||
className={`rounded-full border px-1.5 py-0.5 text-[9px] font-medium leading-4 ${
|
||||
badge.tone === 'muted'
|
||||
? 'border-border-default bg-surface text-content-muted'
|
||||
: badgeClassName
|
||||
}`}
|
||||
title={badge.title ?? badge.label}
|
||||
>
|
||||
{badge.label}
|
||||
</span>
|
||||
))}
|
||||
{hiddenCount > 0 && (
|
||||
<span
|
||||
className="rounded-full border border-border-default bg-surface px-1.5 py-0.5 text-[9px] font-medium leading-4 text-content-muted"
|
||||
title={badges.slice(maxVisibleBadges).map(badge => badge.title ?? badge.label).join(', ')}
|
||||
>
|
||||
+{hiddenCount} more
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function NodeSummaryRow({
|
||||
label,
|
||||
badges,
|
||||
emptyLabel,
|
||||
badgeClassName,
|
||||
maxVisibleBadges,
|
||||
}: {
|
||||
label: string
|
||||
badges: NodeBadge[]
|
||||
emptyLabel: string
|
||||
badgeClassName: string
|
||||
maxVisibleBadges?: number
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-start gap-2 rounded-lg border border-border-default bg-surface-hover/50 px-2 py-1">
|
||||
<p className="min-w-[4.6rem] pt-[1px] text-[9px] font-semibold uppercase tracking-wide text-content-muted">
|
||||
{label}
|
||||
</p>
|
||||
<div className="min-w-0 flex-1">
|
||||
<BadgeList
|
||||
badges={badges}
|
||||
emptyLabel={emptyLabel}
|
||||
badgeClassName={badgeClassName}
|
||||
maxVisibleBadges={maxVisibleBadges}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BaseNode({ data, icon, accentClass, selected }: BaseNodeProps) {
|
||||
const inputPorts = data.inputPorts ?? []
|
||||
const outputPorts = data.outputPorts ?? []
|
||||
const editableFieldCount = data.editableFieldCount ?? 0
|
||||
const configurationSummary = getNodeConfigurationSummary(data)
|
||||
const variableBadges: NodeBadge[] = [
|
||||
...(data.editableFieldLabels ?? []).map(label => ({
|
||||
id: `variable:${label}`,
|
||||
label,
|
||||
title: label,
|
||||
})),
|
||||
...(data.dynamicVariableHint
|
||||
? [
|
||||
{
|
||||
id: 'variable:dynamic-hint',
|
||||
label: 'Template Inputs',
|
||||
title: data.dynamicVariableHint,
|
||||
tone: 'muted' as const,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]
|
||||
const inputBadges: NodeBadge[] = inputPorts.map(port => ({
|
||||
id: port.id,
|
||||
label: getWorkflowNodePortBadgeLabel(port),
|
||||
title: getWorkflowNodePortTitle(port),
|
||||
}))
|
||||
const outputBadges: NodeBadge[] = outputPorts.map(port => ({
|
||||
id: port.id,
|
||||
label: getWorkflowNodePortBadgeLabel(port),
|
||||
title: getWorkflowNodePortTitle(port),
|
||||
}))
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`rounded-lg border-2 p-3 min-w-[140px] bg-surface shadow-sm transition-colors ${
|
||||
className={`relative flex h-full flex-col rounded-xl border-2 bg-surface px-3 py-3 shadow-sm transition-colors ${
|
||||
selected ? 'border-accent' : 'border-border-default'
|
||||
}`}
|
||||
style={{
|
||||
width: WORKFLOW_NODE_WIDTH,
|
||||
minHeight: WORKFLOW_NODE_MIN_HEIGHT,
|
||||
height: WORKFLOW_NODE_MIN_HEIGHT,
|
||||
}}
|
||||
>
|
||||
{hasTarget && (
|
||||
<Handle type="target" position={Position.Left} className="w-3 h-3 bg-content-muted border-2 border-surface" />
|
||||
)}
|
||||
<div className={`flex items-center gap-2 mb-1 ${accentClass}`}>
|
||||
{inputPorts.map((port, index) => (
|
||||
<Handle
|
||||
key={port.id}
|
||||
id={port.id}
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
title={port.label}
|
||||
className={`h-3 w-3 border-2 border-surface ${
|
||||
port.kind === 'alternative' ? 'bg-sky-400' : 'bg-content-muted'
|
||||
}`}
|
||||
style={{ top: getHandleOffset(index, inputPorts.length) }}
|
||||
/>
|
||||
))}
|
||||
{outputPorts.map((port, index) => (
|
||||
<Handle
|
||||
key={port.id}
|
||||
id={port.id}
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
title={port.label}
|
||||
className="h-3 w-3 bg-content-muted border-2 border-surface"
|
||||
style={{ top: getHandleOffset(index, outputPorts.length) }}
|
||||
/>
|
||||
))}
|
||||
<div className={`mb-1 min-h-[1.25rem] flex items-center gap-2 ${accentClass}`}>
|
||||
{icon}
|
||||
<span className="font-medium text-sm">{label}</span>
|
||||
<span className="font-medium text-sm">{data.label}</span>
|
||||
</div>
|
||||
<div className="h-8 overflow-hidden">
|
||||
{data.description && <p className="line-clamp-2 text-xs text-content-muted">{data.description}</p>}
|
||||
</div>
|
||||
<div className="mt-2 space-y-1.5 text-[10px] leading-4 text-content-secondary">
|
||||
<NodeSummaryRow
|
||||
label={`Sockets${inputPorts.length > 0 ? ` · ${inputPorts.length}` : ''}`}
|
||||
badges={inputBadges}
|
||||
emptyLabel="Entry node"
|
||||
badgeClassName="border-sky-200 bg-sky-50 text-sky-700 dark:border-sky-900/40 dark:bg-sky-900/40 dark:text-sky-300"
|
||||
maxVisibleBadges={5}
|
||||
/>
|
||||
<NodeSummaryRow
|
||||
label={`Variables${editableFieldCount > 0 ? ` · ${editableFieldCount}` : ''}`}
|
||||
badges={variableBadges}
|
||||
emptyLabel="No inspector vars"
|
||||
badgeClassName="border-violet-200 bg-violet-50 text-violet-700 dark:border-violet-900/40 dark:bg-violet-900/40 dark:text-violet-300"
|
||||
/>
|
||||
<NodeSummaryRow
|
||||
label={`Outputs${outputPorts.length > 0 ? ` · ${outputPorts.length}` : ''}`}
|
||||
badges={outputBadges}
|
||||
emptyLabel="Terminal node"
|
||||
badgeClassName="border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900/40 dark:bg-emerald-900/40 dark:text-emerald-300"
|
||||
maxVisibleBadges={4}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-auto pt-2 text-[10px] text-content-muted">
|
||||
{configurationSummary}
|
||||
</div>
|
||||
{description && <p className="text-xs text-content-muted">{description}</p>}
|
||||
{hasSource && (
|
||||
<Handle type="source" position={Position.Right} className="w-3 h-3 bg-content-muted border-2 border-surface" />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -122,12 +331,10 @@ function BaseNode({ label, icon, accentClass, description, selected, hasSource =
|
||||
function InputNode({ data, selected }: { data: WorkflowCanvasNodeData; selected?: boolean }) {
|
||||
return (
|
||||
<BaseNode
|
||||
label={data.label}
|
||||
data={data}
|
||||
icon={renderWorkflowIcon(data.icon)}
|
||||
accentClass="text-green-600"
|
||||
description={data.description}
|
||||
selected={selected}
|
||||
hasTarget={false}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -135,10 +342,9 @@ function InputNode({ data, selected }: { data: WorkflowCanvasNodeData; selected?
|
||||
function ConvertNode({ data, selected }: { data: WorkflowCanvasNodeData; selected?: boolean }) {
|
||||
return (
|
||||
<BaseNode
|
||||
label={data.label}
|
||||
data={data}
|
||||
icon={renderWorkflowIcon(data.icon)}
|
||||
accentClass="text-blue-600"
|
||||
description={data.description}
|
||||
selected={selected}
|
||||
/>
|
||||
)
|
||||
@@ -147,10 +353,9 @@ function ConvertNode({ data, selected }: { data: WorkflowCanvasNodeData; selecte
|
||||
function ProcessNode({ data, selected }: { data: WorkflowCanvasNodeData; selected?: boolean }) {
|
||||
return (
|
||||
<BaseNode
|
||||
label={data.label}
|
||||
data={data}
|
||||
icon={renderWorkflowIcon(data.icon)}
|
||||
accentClass="text-sky-600"
|
||||
description={data.description}
|
||||
selected={selected}
|
||||
/>
|
||||
)
|
||||
@@ -160,14 +365,14 @@ function RenderNode({ data, selected }: { data: WorkflowCanvasNodeData; selected
|
||||
const params = data.params ?? {}
|
||||
return (
|
||||
<BaseNode
|
||||
label={data.label}
|
||||
data={{
|
||||
...data,
|
||||
description: params.render_engine
|
||||
? `${params.render_engine} · ${params.samples ?? 256} samples`
|
||||
: data.description,
|
||||
}}
|
||||
icon={renderWorkflowIcon(data.icon)}
|
||||
accentClass="text-orange-600"
|
||||
description={
|
||||
params.render_engine
|
||||
? `${params.render_engine} · ${params.samples ?? 256} samples`
|
||||
: data.description
|
||||
}
|
||||
selected={selected}
|
||||
/>
|
||||
)
|
||||
@@ -177,10 +382,12 @@ function RenderFramesNode({ data, selected }: { data: WorkflowCanvasNodeData; se
|
||||
const params = data.params ?? {}
|
||||
return (
|
||||
<BaseNode
|
||||
label={data.label}
|
||||
data={{
|
||||
...data,
|
||||
description: params.fps ? `${params.fps} fps · ${params.duration_s ?? '?'}s` : data.description,
|
||||
}}
|
||||
icon={renderWorkflowIcon(data.icon)}
|
||||
accentClass="text-orange-600"
|
||||
description={params.fps ? `${params.fps} fps · ${params.duration_s ?? '?'}s` : data.description}
|
||||
selected={selected}
|
||||
/>
|
||||
)
|
||||
@@ -189,12 +396,10 @@ function RenderFramesNode({ data, selected }: { data: WorkflowCanvasNodeData; se
|
||||
function OutputNode({ data, selected }: { data: WorkflowCanvasNodeData; selected?: boolean }) {
|
||||
return (
|
||||
<BaseNode
|
||||
label={data.label}
|
||||
data={data}
|
||||
icon={renderWorkflowIcon(data.icon)}
|
||||
accentClass="text-slate-600"
|
||||
description={data.description}
|
||||
selected={selected}
|
||||
hasSource={false}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -223,6 +428,7 @@ interface FlowCanvasProps {
|
||||
}
|
||||
|
||||
function FlowCanvas({ workflow, onSave, isSaving }: FlowCanvasProps) {
|
||||
const queryClient = useQueryClient()
|
||||
const {
|
||||
reactFlowWrapper,
|
||||
nodeDefinitions,
|
||||
@@ -242,7 +448,15 @@ function FlowCanvas({ workflow, onSave, isSaving }: FlowCanvasProps) {
|
||||
preflightMutation,
|
||||
dispatchContextId,
|
||||
setDispatchContextId,
|
||||
isOrderLineGraph,
|
||||
isOrderLineContextsLoading,
|
||||
orderLineContextGroups,
|
||||
dispatchContextLabel,
|
||||
dispatchContextSummary,
|
||||
dispatchContextMeta,
|
||||
preflightResult,
|
||||
preflightState,
|
||||
hasFreshSuccessfulPreflight,
|
||||
executionMode,
|
||||
setExecutionMode,
|
||||
nodeMenuAnchor,
|
||||
@@ -250,16 +464,20 @@ function FlowCanvas({ workflow, onSave, isSaving }: FlowCanvasProps) {
|
||||
activeUtilityTab,
|
||||
setActiveUtilityTab,
|
||||
validation,
|
||||
authoringFamily,
|
||||
graphFamily,
|
||||
onConnect,
|
||||
onNodeClick,
|
||||
onEdgeClick,
|
||||
onPaneClick,
|
||||
handleSelectionChange,
|
||||
handleParamsChange,
|
||||
handlePipelineStepChange,
|
||||
handlePaneContextMenu,
|
||||
handleNodeContextMenu,
|
||||
insertNode,
|
||||
insertModuleBundle,
|
||||
insertReferenceBundle,
|
||||
handleOpenToolbarNodeMenu,
|
||||
handleAutoLayout,
|
||||
handleDeleteSelectedEdges,
|
||||
@@ -270,25 +488,76 @@ function FlowCanvas({ workflow, onSave, isSaving }: FlowCanvasProps) {
|
||||
handlePreflight,
|
||||
setReactFlowInstance,
|
||||
} = useWorkflowCanvasController({ workflow, onSave })
|
||||
const nodeMenuStyle = useMemo(() => {
|
||||
if (!nodeMenuAnchor || !reactFlowWrapper.current) return null
|
||||
const [isCanvasReady, setIsCanvasReady] = useState(false)
|
||||
const [nodeMenuElement, setNodeMenuElement] = useState<HTMLDivElement | null>(null)
|
||||
const [nodeMenuSize, setNodeMenuSize] = useState({ width: NODE_COMMAND_MENU_WIDTH, height: 420 })
|
||||
|
||||
useEffect(() => {
|
||||
const wrapper = reactFlowWrapper.current
|
||||
if (!wrapper) return
|
||||
|
||||
const updateCanvasReadiness = () => {
|
||||
const bounds = wrapper.getBoundingClientRect()
|
||||
setIsCanvasReady(bounds.width > 0 && bounds.height > 0)
|
||||
}
|
||||
|
||||
updateCanvasReadiness()
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
updateCanvasReadiness()
|
||||
})
|
||||
resizeObserver.observe(wrapper)
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect()
|
||||
}
|
||||
}, [reactFlowWrapper, workflow.id])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!nodeMenuElement) return
|
||||
|
||||
const measure = () => {
|
||||
const bounds = nodeMenuElement.getBoundingClientRect()
|
||||
setNodeMenuSize({
|
||||
width: Math.max(Math.ceil(bounds.width), NODE_COMMAND_MENU_WIDTH),
|
||||
height: Math.max(Math.ceil(bounds.height), 320),
|
||||
})
|
||||
}
|
||||
|
||||
measure()
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
measure()
|
||||
})
|
||||
resizeObserver.observe(nodeMenuElement)
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect()
|
||||
}
|
||||
}, [nodeMenuElement, nodeMenuAnchor])
|
||||
|
||||
const nodeMenuRef = useCallback((element: HTMLDivElement | null) => {
|
||||
setNodeMenuElement(element)
|
||||
}, [])
|
||||
|
||||
const nodeMenuStyle = useMemo(() => {
|
||||
if (!nodeMenuAnchor || typeof window === 'undefined') return null
|
||||
|
||||
const bounds = reactFlowWrapper.current.getBoundingClientRect()
|
||||
const MENU_WIDTH = 380
|
||||
const HORIZONTAL_MARGIN = 16
|
||||
const VERTICAL_MARGIN = 16
|
||||
const width = nodeMenuSize.width
|
||||
const height = Math.min(nodeMenuSize.height, window.innerHeight - VERTICAL_MARGIN * 2)
|
||||
|
||||
const left = Math.min(
|
||||
Math.max(nodeMenuAnchor.clientX - bounds.left, HORIZONTAL_MARGIN),
|
||||
Math.max(bounds.width - MENU_WIDTH - HORIZONTAL_MARGIN, HORIZONTAL_MARGIN),
|
||||
Math.max(nodeMenuAnchor.clientX, HORIZONTAL_MARGIN),
|
||||
Math.max(window.innerWidth - width - HORIZONTAL_MARGIN, HORIZONTAL_MARGIN),
|
||||
)
|
||||
const top = Math.min(
|
||||
Math.max(nodeMenuAnchor.clientY - bounds.top, VERTICAL_MARGIN),
|
||||
Math.max(bounds.height - 160, VERTICAL_MARGIN),
|
||||
Math.max(nodeMenuAnchor.clientY, VERTICAL_MARGIN),
|
||||
Math.max(window.innerHeight - height - VERTICAL_MARGIN, VERTICAL_MARGIN),
|
||||
)
|
||||
|
||||
return { left, top }
|
||||
}, [nodeMenuAnchor])
|
||||
}, [nodeMenuAnchor, nodeMenuSize.height, nodeMenuSize.width])
|
||||
|
||||
const { mode } = useThemeStore()
|
||||
const isDark = resolveTheme(mode) === 'dark'
|
||||
@@ -309,6 +578,46 @@ function FlowCanvas({ workflow, onSave, isSaving }: FlowCanvasProps) {
|
||||
})),
|
||||
[edges, isDark],
|
||||
)
|
||||
const activeSteps = useMemo(
|
||||
() =>
|
||||
nodes
|
||||
.map(node => (node.data as WorkflowCanvasNodeData | undefined)?.step)
|
||||
.filter((step): step is string => Boolean(step)),
|
||||
[nodes],
|
||||
)
|
||||
const authoringActions = useMemo<WorkflowAuthoringActions>(
|
||||
() => ({
|
||||
openNodeMenu: handleOpenToolbarNodeMenu,
|
||||
insertNode,
|
||||
insertModule: insertModuleBundle,
|
||||
insertReferencePath: insertReferenceBundle,
|
||||
}),
|
||||
[handleOpenToolbarNodeMenu, insertModuleBundle, insertNode, insertReferenceBundle],
|
||||
)
|
||||
const authoringSurfaceModel = useMemo(
|
||||
() => getWorkflowAuthoringSurfaceModel({ definitions: nodeDefinitions, graphFamily: authoringFamily, activeSteps }),
|
||||
[activeSteps, authoringFamily, nodeDefinitions],
|
||||
)
|
||||
const authoringEntryAction = useMemo(
|
||||
() => getWorkflowAuthoringEntryAction(authoringSurfaceModel),
|
||||
[authoringSurfaceModel],
|
||||
)
|
||||
const rolloutPresentation = useMemo(
|
||||
() => getWorkflowRolloutPresentation(workflow.rollout_summary),
|
||||
[workflow.rollout_summary],
|
||||
)
|
||||
const rollbackOutputTypeMutation = useMutation({
|
||||
mutationFn: ({ outputTypeId }: { outputTypeId: string }) =>
|
||||
updateOutputType(outputTypeId, { workflow_rollout_mode: 'legacy_only' }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['workflows'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['output-types'] })
|
||||
toast.success('Output type rollout reverted to legacy')
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Failed to revert output type rollout')
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
@@ -320,90 +629,101 @@ function FlowCanvas({ workflow, onSave, isSaving }: FlowCanvasProps) {
|
||||
? BLUEPRINT_DESCRIPTION[getWorkflowBlueprint(workflow.config)!] ?? 'Reference workflow graph.'
|
||||
: null
|
||||
}
|
||||
authoringFamilyLabel={GRAPH_FAMILY_LABELS[authoringFamily]}
|
||||
authoringFamilyClassName={GRAPH_FAMILY_STYLES[authoringFamily]}
|
||||
graphFamilyLabel={GRAPH_FAMILY_LABELS[graphFamily]}
|
||||
graphFamilyClassName={GRAPH_FAMILY_STYLES[graphFamily]}
|
||||
executionMode={executionMode}
|
||||
executionModeLabel={EXECUTION_MODE_LABELS[executionMode]}
|
||||
executionModeClassName={EXECUTION_MODE_BADGE_STYLES[executionMode]}
|
||||
executionModeHint={EXECUTION_MODE_HINTS[executionMode]}
|
||||
rolloutBadgeLabel={rolloutPresentation.badgeLabel}
|
||||
rolloutBadgeClassName={rolloutPresentation.badgeClassName}
|
||||
rolloutStatusLabel={rolloutPresentation.statusLabel}
|
||||
rolloutStatusClassName={rolloutPresentation.statusClassName}
|
||||
rolloutSummary={rolloutPresentation.summary}
|
||||
linkedOutputTypeCount={workflow.rollout_summary.linked_output_type_count}
|
||||
linkedOutputTypes={workflow.rollout_summary.linked_output_types}
|
||||
dispatchContextKind={isOrderLineGraph ? 'order_line' : graphFamily === 'cad_file' ? 'cad_file' : null}
|
||||
dispatchContextLabel={dispatchContextLabel}
|
||||
dispatchContextId={dispatchContextId}
|
||||
dispatchContextSummary={dispatchContextSummary}
|
||||
dispatchContextMeta={dispatchContextMeta}
|
||||
orderLineContextGroups={orderLineContextGroups}
|
||||
executionModes={(['legacy', 'graph', 'shadow'] as WorkflowExecutionMode[]).map(mode => ({
|
||||
value: mode,
|
||||
label: EXECUTION_MODE_LABELS[mode],
|
||||
}))}
|
||||
selectedEdgeCount={selectedEdgeIds.length}
|
||||
canAutoLayout={nodes.length > 0}
|
||||
canPreflight={dispatchContextId.trim().length > 0}
|
||||
canDispatch={dispatchContextId.trim().length > 0 && hasFreshSuccessfulPreflight}
|
||||
hasValidationErrors={validation.errors.length > 0}
|
||||
isPreflightPending={preflightMutation.isPending}
|
||||
isDispatchPending={dispatchMutation.isPending}
|
||||
isContextOptionsLoading={isOrderLineContextsLoading}
|
||||
isSaving={isSaving}
|
||||
rollbackPendingOutputTypeId={rollbackOutputTypeMutation.variables?.outputTypeId ?? null}
|
||||
preflightState={preflightState}
|
||||
authoringActions={authoringActions}
|
||||
authoringEntryAction={authoringEntryAction}
|
||||
onDispatchContextIdChange={setDispatchContextId}
|
||||
onExecutionModeChange={value => setExecutionMode(value as WorkflowExecutionMode)}
|
||||
onOpenNodeMenu={handleOpenToolbarNodeMenu}
|
||||
onAutoLayout={handleAutoLayout}
|
||||
onDeleteSelectedEdges={handleDeleteSelectedEdges}
|
||||
onPreflight={handlePreflight}
|
||||
onDispatch={handleDispatch}
|
||||
onSave={handleSave}
|
||||
onRollbackOutputType={outputTypeId => rollbackOutputTypeMutation.mutate({ outputTypeId })}
|
||||
/>
|
||||
|
||||
<WorkflowValidationBanner errors={validation.errors} warnings={validation.warnings} />
|
||||
|
||||
{/* Canvas + Sidepanel */}
|
||||
<div className="flex flex-1 min-h-0">
|
||||
<div className="flex h-full flex-1 min-h-0 flex-col xl:flex-row">
|
||||
<div
|
||||
ref={reactFlowWrapper}
|
||||
className="relative flex-1 min-h-[680px]"
|
||||
className="relative flex min-h-[680px] min-w-0 flex-1 overflow-hidden xl:h-full"
|
||||
onContextMenu={event => event.preventDefault()}
|
||||
>
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={canvasEdges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onConnect={onConnect}
|
||||
onNodeClick={onNodeClick}
|
||||
onEdgeClick={onEdgeClick}
|
||||
onEdgeContextMenu={onEdgeContextMenu}
|
||||
onEdgeDoubleClick={onEdgeDoubleClick}
|
||||
onNodeContextMenu={handleNodeContextMenu}
|
||||
onPaneClick={onPaneClick}
|
||||
onPaneContextMenu={handlePaneContextMenu}
|
||||
onInit={setReactFlowInstance}
|
||||
nodeTypes={nodeTypes}
|
||||
colorMode={isDark ? 'dark' : 'light'}
|
||||
defaultEdgeOptions={{
|
||||
interactionWidth: 44,
|
||||
selectable: true,
|
||||
focusable: true,
|
||||
}}
|
||||
deleteKeyCode={['Backspace', 'Delete']}
|
||||
fitView
|
||||
fitViewOptions={{ padding: 0.2 }}
|
||||
className="h-full w-full"
|
||||
>
|
||||
<Background gap={16} />
|
||||
<Controls />
|
||||
<MiniMap nodeStrokeWidth={3} zoomable pannable />
|
||||
</ReactFlow>
|
||||
|
||||
{nodeMenuAnchor && nodeMenuStyle && (
|
||||
<div
|
||||
className="absolute z-20"
|
||||
style={{
|
||||
left: nodeMenuStyle.left,
|
||||
top: nodeMenuStyle.top,
|
||||
{isCanvasReady ? (
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={canvasEdges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onConnect={onConnect}
|
||||
onNodeClick={onNodeClick}
|
||||
onEdgeClick={onEdgeClick}
|
||||
onEdgeContextMenu={onEdgeContextMenu}
|
||||
onEdgeDoubleClick={onEdgeDoubleClick}
|
||||
onNodeContextMenu={handleNodeContextMenu}
|
||||
onPaneClick={onPaneClick}
|
||||
onPaneContextMenu={handlePaneContextMenu}
|
||||
onSelectionChange={handleSelectionChange}
|
||||
onInit={setReactFlowInstance}
|
||||
nodeTypes={nodeTypes}
|
||||
colorMode={isDark ? 'dark' : 'light'}
|
||||
defaultEdgeOptions={{
|
||||
interactionWidth: 44,
|
||||
selectable: true,
|
||||
focusable: true,
|
||||
}}
|
||||
deleteKeyCode={['Backspace', 'Delete']}
|
||||
fitView
|
||||
fitViewOptions={{ padding: 0.2 }}
|
||||
className="h-full w-full"
|
||||
>
|
||||
<NodeCommandMenu
|
||||
definitions={nodeDefinitions}
|
||||
graphFamily={graphFamily}
|
||||
onClose={() => setNodeMenuAnchor(null)}
|
||||
onSelectStep={step => insertNode(step, nodeMenuAnchor.flowPosition)}
|
||||
renderIcon={renderWorkflowIcon}
|
||||
/>
|
||||
<Background gap={16} />
|
||||
<Controls />
|
||||
<MiniMap nodeStrokeWidth={3} zoomable pannable />
|
||||
</ReactFlow>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center text-sm text-content-muted">
|
||||
Preparing workflow canvas…
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
<WorkflowCanvasUtilitySidebar
|
||||
@@ -414,8 +734,9 @@ function FlowCanvas({ workflow, onSave, isSaving }: FlowCanvasProps) {
|
||||
onNodeStepChange={handlePipelineStepChange}
|
||||
nodeDefinitions={nodeDefinitions}
|
||||
nodeDefinitionsByStep={nodeDefinitionsByStep}
|
||||
graphFamily={graphFamily}
|
||||
onInsertNode={step => insertNode(step)}
|
||||
graphFamily={authoringFamily}
|
||||
activeSteps={activeSteps}
|
||||
authoringActions={authoringActions}
|
||||
renderNodeIcon={renderWorkflowIcon}
|
||||
workflowRuns={workflowRuns}
|
||||
selectedRunId={selectedRun?.id ?? null}
|
||||
@@ -426,6 +747,27 @@ function FlowCanvas({ workflow, onSave, isSaving }: FlowCanvasProps) {
|
||||
isPreflightPending={preflightMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
{nodeMenuAnchor && nodeMenuStyle && createPortal(
|
||||
<div
|
||||
ref={nodeMenuRef}
|
||||
className="fixed z-[100]"
|
||||
style={{
|
||||
left: nodeMenuStyle.left,
|
||||
top: nodeMenuStyle.top,
|
||||
}}
|
||||
>
|
||||
<NodeCommandMenu
|
||||
definitions={nodeDefinitions}
|
||||
graphFamily={authoringFamily}
|
||||
activeSteps={activeSteps}
|
||||
actions={authoringActions}
|
||||
preferredPosition={nodeMenuAnchor.flowPosition}
|
||||
onClose={() => setNodeMenuAnchor(null)}
|
||||
renderIcon={renderWorkflowIcon}
|
||||
/>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -555,6 +897,7 @@ export default function WorkflowEditor() {
|
||||
const executionMode = wf.config.ui?.execution_mode ?? 'legacy'
|
||||
const blueprint = getWorkflowBlueprint(wf.config)
|
||||
const workflowFamily = inferWorkflowFamily(wf.config, nodeDefinitionsByStep)
|
||||
const rolloutPresentation = getWorkflowRolloutPresentation(wf.rollout_summary)
|
||||
|
||||
return {
|
||||
id: wf.id,
|
||||
@@ -566,6 +909,12 @@ export default function WorkflowEditor() {
|
||||
familyClassName: GRAPH_FAMILY_STYLES[workflowFamily],
|
||||
executionModeLabel: EXECUTION_MODE_LABELS[executionMode],
|
||||
executionModeClassName: EXECUTION_MODE_BADGE_STYLES[executionMode],
|
||||
rolloutBadgeLabel: rolloutPresentation.badgeLabel,
|
||||
rolloutBadgeClassName: rolloutPresentation.badgeClassName,
|
||||
rolloutStatusLabel: rolloutPresentation.statusLabel,
|
||||
rolloutStatusClassName: rolloutPresentation.statusClassName,
|
||||
rolloutSummary: rolloutPresentation.summary,
|
||||
linkedOutputTypeCount: wf.rollout_summary.linked_output_type_count,
|
||||
blueprintLabel: blueprint ? BLUEPRINT_LABELS[blueprint] ?? 'Blueprint' : null,
|
||||
isReference: isReferenceBlueprint(wf.config),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user