Files
Atomizer/atomizer-dashboard/frontend/src/components/canvas/panels/NodeConfigPanelV2.tsx

1069 lines
34 KiB
TypeScript
Raw Normal View History

/**
* NodeConfigPanelV2 - Configuration panel for AtomizerSpec v2.0 nodes
*
* This component uses useSpecStore instead of the legacy useCanvasStore.
* It renders type-specific configuration forms based on the selected node.
*
* For custom extractors, integrates CodeEditorPanel with Claude AI generation.
*/
import { useState, useMemo, useCallback } from 'react';
import { Microscope, Trash2, X, AlertCircle, Code, FileCode } from 'lucide-react';
import { CodeEditorPanel } from './CodeEditorPanel';
import { generateExtractorCode, validateExtractorCode, streamExtractorCode, checkCodeDependencies, testExtractorCode } from '../../../lib/api/claude';
import {
useSpecStore,
useSpec,
useSelectedNodeId,
useSelectedNode,
} from '../../../hooks/useSpecStore';
import { usePanelStore } from '../../../hooks/usePanelStore';
import { FileBrowser } from './FileBrowser';
import {
DesignVariable,
Extractor,
Objective,
Constraint,
} from '../../../types/atomizer-spec';
// Common input class for dark theme
const inputClass = "w-full px-3 py-2 bg-dark-800 border border-dark-600 text-white placeholder-dark-400 rounded-lg focus:border-primary-500 focus:outline-none transition-colors";
const selectClass = "w-full px-3 py-2 bg-dark-800 border border-dark-600 text-white rounded-lg focus:border-primary-500 focus:outline-none transition-colors";
const labelClass = "block text-sm font-medium text-dark-300 mb-1";
interface NodeConfigPanelV2Props {
/** Called when panel should close */
onClose?: () => void;
}
export function NodeConfigPanelV2({ onClose }: NodeConfigPanelV2Props) {
const spec = useSpec();
const selectedNodeId = useSelectedNodeId();
const selectedNode = useSelectedNode();
const { updateNode, removeNode, clearSelection } = useSpecStore();
const [showFileBrowser, setShowFileBrowser] = useState(false);
const [isUpdating, setIsUpdating] = useState(false);
const [error, setError] = useState<string | null>(null);
// Determine node type from ID prefix or from the node itself
const nodeType = useMemo(() => {
if (!selectedNodeId) return null;
// Synthetic nodes have fixed IDs
if (selectedNodeId === 'model') return 'model';
if (selectedNodeId === 'solver') return 'solver';
if (selectedNodeId === 'algorithm') return 'algorithm';
if (selectedNodeId === 'surrogate') return 'surrogate';
// Real nodes have prefixed IDs
const prefix = selectedNodeId.split('_')[0];
switch (prefix) {
case 'dv': return 'designVar';
case 'ext': return 'extractor';
case 'obj': return 'objective';
case 'con': return 'constraint';
default: return null;
}
}, [selectedNodeId]);
// Get label for display
const nodeLabel = useMemo(() => {
if (!selectedNodeId || !spec) return 'Node';
switch (nodeType) {
case 'model': return spec.meta.study_name || 'Model';
case 'solver': return spec.model.sim?.solution_type || 'Solver';
case 'algorithm': return spec.optimization.algorithm?.type || 'Algorithm';
case 'surrogate': return 'Neural Surrogate';
default:
if (selectedNode) {
return (selectedNode as any).name || selectedNodeId;
}
return selectedNodeId;
}
}, [selectedNodeId, selectedNode, nodeType, spec]);
// Handle field changes
const handleChange = useCallback(async (field: string, value: unknown) => {
if (!selectedNodeId || !selectedNode) return;
setIsUpdating(true);
setError(null);
try {
await updateNode(selectedNodeId, { [field]: value });
} catch (err) {
console.error('Failed to update node:', err);
setError(err instanceof Error ? err.message : 'Update failed');
} finally {
setIsUpdating(false);
}
}, [selectedNodeId, selectedNode, updateNode]);
// Handle delete
const handleDelete = useCallback(async () => {
if (!selectedNodeId) return;
// Synthetic nodes can't be deleted
if (['model', 'solver', 'algorithm', 'surrogate'].includes(selectedNodeId)) {
setError('This node cannot be deleted');
return;
}
setIsUpdating(true);
setError(null);
try {
await removeNode(selectedNodeId);
clearSelection();
onClose?.();
} catch (err) {
console.error('Failed to delete node:', err);
setError(err instanceof Error ? err.message : 'Delete failed');
} finally {
setIsUpdating(false);
}
}, [selectedNodeId, removeNode, clearSelection, onClose]);
// Don't render if no node selected
if (!selectedNodeId || !spec) {
return null;
}
// Check if this is a synthetic node (model, solver, algorithm, surrogate)
const isSyntheticNode = ['model', 'solver', 'algorithm', 'surrogate'].includes(selectedNodeId);
return (
<div className="w-80 bg-dark-850 border-l border-dark-700 flex flex-col h-full">
{/* Header */}
<div className="flex justify-between items-center p-4 border-b border-dark-700">
<h3 className="font-semibold text-white truncate flex-1">
Configure {nodeLabel}
</h3>
<div className="flex items-center gap-2">
{!isSyntheticNode && (
<button
onClick={handleDelete}
disabled={isUpdating}
className="p-1.5 text-red-400 hover:text-red-300 hover:bg-red-500/10 rounded transition-colors"
title="Delete node"
>
<Trash2 size={16} />
</button>
)}
{onClose && (
<button
onClick={onClose}
className="p-1.5 text-dark-400 hover:text-white hover:bg-dark-700 rounded transition-colors"
title="Close panel"
>
<X size={16} />
</button>
)}
</div>
</div>
{/* Error display */}
{error && (
<div className="mx-4 mt-4 p-3 bg-red-500/10 border border-red-500/30 rounded-lg flex items-start gap-2">
<AlertCircle size={16} className="text-red-400 flex-shrink-0 mt-0.5" />
<p className="text-sm text-red-400 flex-1">{error}</p>
<button onClick={() => setError(null)} className="text-red-400 hover:text-red-300">
<X size={14} />
</button>
</div>
)}
{/* Content */}
<div className="flex-1 overflow-y-auto p-4">
<div className="space-y-4">
{/* Loading indicator */}
{isUpdating && (
<div className="text-xs text-primary-400 animate-pulse">Updating...</div>
)}
{/* Model node (synthetic) */}
{nodeType === 'model' && spec.model && (
<ModelNodeConfig spec={spec} />
)}
{/* Solver node (synthetic) */}
{nodeType === 'solver' && (
<SolverNodeConfig spec={spec} />
)}
{/* Algorithm node (synthetic) */}
{nodeType === 'algorithm' && (
<AlgorithmNodeConfig spec={spec} />
)}
{/* Surrogate node (synthetic) */}
{nodeType === 'surrogate' && (
<SurrogateNodeConfig spec={spec} />
)}
{/* Design Variable */}
{nodeType === 'designVar' && selectedNode && (
<DesignVarNodeConfig
node={selectedNode as DesignVariable}
onChange={handleChange}
/>
)}
{/* Extractor */}
{nodeType === 'extractor' && selectedNode && (
<ExtractorNodeConfig
node={selectedNode as Extractor}
onChange={handleChange}
/>
)}
{/* Objective */}
{nodeType === 'objective' && selectedNode && (
<ObjectiveNodeConfig
node={selectedNode as Objective}
onChange={handleChange}
/>
)}
{/* Constraint */}
{nodeType === 'constraint' && selectedNode && (
<ConstraintNodeConfig
node={selectedNode as Constraint}
onChange={handleChange}
/>
)}
</div>
</div>
{/* File Browser Modal */}
<FileBrowser
isOpen={showFileBrowser}
onClose={() => setShowFileBrowser(false)}
onSelect={() => {
// This would update the model path - but model is synthetic
setShowFileBrowser(false);
}}
fileTypes={['.sim', '.prt', '.fem', '.afem']}
/>
{/* Introspection is now handled by FloatingIntrospectionPanel via usePanelStore */}
</div>
);
}
// ============================================================================
// Type-specific configuration components
// ============================================================================
interface SpecConfigProps {
spec: NonNullable<ReturnType<typeof useSpec>>;
}
function ModelNodeConfig({ spec }: SpecConfigProps) {
const { setIntrospectionData, openPanel } = usePanelStore();
const handleOpenIntrospection = () => {
// Set up introspection data and open the panel
setIntrospectionData({
filePath: spec.model.sim?.path || '',
studyId: useSpecStore.getState().studyId || undefined,
});
openPanel('introspection');
};
return (
<>
<div>
<label className={labelClass}>Model File</label>
<input
type="text"
value={spec.model.sim?.path || ''}
readOnly
className={`${inputClass} font-mono text-sm bg-dark-900 cursor-not-allowed`}
title="Model path is read-only. Change via study configuration."
/>
<p className="text-xs text-dark-500 mt-1">Read-only. Set in study configuration.</p>
</div>
<div>
<label className={labelClass}>Solver Type</label>
<input
type="text"
value={spec.model.sim?.solution_type || 'Not detected'}
readOnly
className={`${inputClass} bg-dark-900 cursor-not-allowed`}
/>
</div>
{spec.model.sim?.path && (
<button
onClick={handleOpenIntrospection}
className="w-full flex items-center justify-center gap-2 px-3 py-2.5 bg-primary-500/20
hover:bg-primary-500/30 border border-primary-500/30 rounded-lg
text-primary-400 text-sm font-medium transition-colors"
>
<Microscope size={16} />
Introspect Model
</button>
)}
{/* Note: IntrospectionPanel is now rendered by PanelContainer, not here */}
</>
);
}
function SolverNodeConfig({ spec }: SpecConfigProps) {
const { patchSpec } = useSpecStore();
const [isUpdating, setIsUpdating] = useState(false);
const engine = spec.model.sim?.engine || 'nxnastran';
const solutionType = spec.model.sim?.solution_type || 'SOL101';
const scriptPath = spec.model.sim?.script_path || '';
const isPython = engine === 'python';
const handleEngineChange = async (newEngine: string) => {
setIsUpdating(true);
try {
await patchSpec('model.sim.engine', newEngine);
} catch (err) {
console.error('Failed to update engine:', err);
} finally {
setIsUpdating(false);
}
};
const handleSolutionTypeChange = async (newType: string) => {
setIsUpdating(true);
try {
await patchSpec('model.sim.solution_type', newType);
} catch (err) {
console.error('Failed to update solution type:', err);
} finally {
setIsUpdating(false);
}
};
const handleScriptPathChange = async (newPath: string) => {
setIsUpdating(true);
try {
await patchSpec('model.sim.script_path', newPath);
} catch (err) {
console.error('Failed to update script path:', err);
} finally {
setIsUpdating(false);
}
};
return (
<>
{isUpdating && (
<div className="text-xs text-primary-400 animate-pulse">Updating...</div>
)}
<div>
<label className={labelClass}>Solver Engine</label>
<select
value={engine}
onChange={(e) => handleEngineChange(e.target.value)}
className={selectClass}
>
<option value="nxnastran">NX Nastran (built-in)</option>
<option value="mscnastran">MSC Nastran (external)</option>
<option value="python">Python Script</option>
<option value="abaqus" disabled>Abaqus (coming soon)</option>
<option value="ansys" disabled>ANSYS (coming soon)</option>
</select>
<p className="text-xs text-dark-500 mt-1">
{isPython ? 'Run custom Python analysis script' : 'Select FEA solver software'}
</p>
</div>
{!isPython && (
<div>
<label className={labelClass}>Solution Type</label>
<select
value={solutionType}
onChange={(e) => handleSolutionTypeChange(e.target.value)}
className={selectClass}
>
<option value="SOL101">SOL101 - Linear Statics</option>
<option value="SOL103">SOL103 - Normal Modes</option>
<option value="SOL105">SOL105 - Buckling</option>
<option value="SOL106">SOL106 - Nonlinear Statics</option>
<option value="SOL111">SOL111 - Modal Frequency Response</option>
<option value="SOL112">SOL112 - Modal Transient Response</option>
<option value="SOL200">SOL200 - Design Optimization</option>
</select>
</div>
)}
{isPython && (
<div>
<label className={labelClass}>Script Path</label>
<input
type="text"
value={scriptPath}
onChange={(e) => handleScriptPathChange(e.target.value)}
placeholder="path/to/solver_script.py"
className={`${inputClass} font-mono text-sm`}
/>
<p className="text-xs text-dark-500 mt-1">
Python script must define solve(params) function
</p>
</div>
)}
</>
);
}
function AlgorithmNodeConfig({ spec }: SpecConfigProps) {
const algo = spec.optimization.algorithm;
return (
<>
<div>
<label className={labelClass}>Method</label>
<input
type="text"
value={algo?.type || 'TPE'}
readOnly
className={`${inputClass} bg-dark-900 cursor-not-allowed`}
/>
<p className="text-xs text-dark-500 mt-1">Edit in optimization settings.</p>
</div>
<div>
<label className={labelClass}>Max Trials</label>
<input
type="number"
value={spec.optimization.budget?.max_trials || 100}
readOnly
className={`${inputClass} bg-dark-900 cursor-not-allowed`}
/>
</div>
</>
);
}
function SurrogateNodeConfig({ spec }: SpecConfigProps) {
const surrogate = spec.optimization.surrogate;
return (
<>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="surrogate-enabled"
checked={surrogate?.enabled || false}
readOnly
className="w-4 h-4 rounded bg-dark-800 border-dark-600 text-primary-500 cursor-not-allowed"
/>
<label htmlFor="surrogate-enabled" className="text-sm font-medium text-dark-300">
Neural Surrogate {surrogate?.enabled ? 'Enabled' : 'Disabled'}
</label>
</div>
{surrogate?.enabled && (
<>
<div>
<label className={labelClass}>Model Type</label>
<input
type="text"
value={surrogate.type || 'MLP'}
readOnly
className={`${inputClass} bg-dark-900 cursor-not-allowed`}
/>
</div>
<div>
<label className={labelClass}>Min Training Samples</label>
<input
type="number"
value={surrogate.config?.min_training_samples || 20}
readOnly
className={`${inputClass} bg-dark-900 cursor-not-allowed`}
/>
</div>
</>
)}
<p className="text-xs text-dark-500">Edit in optimization settings.</p>
</>
);
}
// ============================================================================
// Editable node configs
// ============================================================================
interface DesignVarNodeConfigProps {
node: DesignVariable;
onChange: (field: string, value: unknown) => void;
}
function DesignVarNodeConfig({ node, onChange }: DesignVarNodeConfigProps) {
return (
<>
<div>
<label className={labelClass}>Name</label>
<input
type="text"
value={node.name}
onChange={(e) => onChange('name', e.target.value)}
className={inputClass}
/>
</div>
<div>
<label className={labelClass}>Expression Name</label>
<input
type="text"
value={node.expression_name}
onChange={(e) => onChange('expression_name', e.target.value)}
placeholder="NX expression name"
className={`${inputClass} font-mono text-sm`}
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className={labelClass}>Min</label>
<input
type="number"
value={node.bounds.min}
onChange={(e) => onChange('bounds', { ...node.bounds, min: parseFloat(e.target.value) })}
className={inputClass}
/>
</div>
<div>
<label className={labelClass}>Max</label>
<input
type="number"
value={node.bounds.max}
onChange={(e) => onChange('bounds', { ...node.bounds, max: parseFloat(e.target.value) })}
className={inputClass}
/>
</div>
</div>
{node.baseline !== undefined && (
<div>
<label className={labelClass}>Baseline</label>
<input
type="number"
value={node.baseline}
onChange={(e) => onChange('baseline', parseFloat(e.target.value))}
className={inputClass}
/>
</div>
)}
<div>
<label className={labelClass}>Units</label>
<input
type="text"
value={node.units || ''}
onChange={(e) => onChange('units', e.target.value)}
placeholder="mm"
className={inputClass}
/>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id={`${node.id}-enabled`}
checked={node.enabled !== false}
onChange={(e) => onChange('enabled', e.target.checked)}
className="w-4 h-4 rounded bg-dark-800 border-dark-600 text-primary-500 focus:ring-primary-500"
/>
<label htmlFor={`${node.id}-enabled`} className="text-sm text-dark-300">
Enabled
</label>
</div>
</>
);
}
interface ExtractorNodeConfigProps {
node: Extractor;
onChange: (field: string, value: unknown) => void;
}
// Default template for custom extractors
const DEFAULT_EXTRACTOR_TEMPLATE = `"""
Custom Extractor Function
This function is called after FEA simulation completes.
It receives the results and should return extracted values.
"""
from pyNastran.op2.op2 import OP2
import numpy as np
def extract(op2_path: str, fem_path: str, params: dict, subcase_id: int = 1) -> dict:
"""
Extract physics from FEA results.
Args:
op2_path: Path to OP2 results file
fem_path: Path to FEM file
params: Current design variable values
subcase_id: Subcase ID to analyze
Returns:
Dict with extracted values, e.g. {'max_stress': 150.5, 'mass': 2.3}
"""
# Load OP2 results
op2 = OP2()
op2.read_op2(op2_path)
# Example: Extract max displacement
if subcase_id in op2.displacements:
disp = op2.displacements[subcase_id]
magnitudes = np.sqrt(np.sum(disp.data[0, :, 1:4]**2, axis=1))
max_disp = float(np.max(magnitudes))
else:
max_disp = 0.0
return {
'max_displacement': max_disp,
}
`;
function ExtractorNodeConfig({ node, onChange }: ExtractorNodeConfigProps) {
const [showCodeEditor, setShowCodeEditor] = useState(false);
const studyId = useSpecStore(state => state.studyId);
const extractorOptions = [
{ id: 'E1', name: 'Displacement', type: 'displacement' },
{ id: 'E2', name: 'Frequency', type: 'frequency' },
{ id: 'E3', name: 'Solid Stress', type: 'solid_stress' },
{ id: 'E4', name: 'BDF Mass', type: 'mass_bdf' },
{ id: 'E5', name: 'CAD Mass', type: 'mass_expression' },
{ id: 'E8', name: 'Zernike (OP2)', type: 'zernike_op2' },
{ id: 'E9', name: 'Zernike (CSV)', type: 'zernike_csv' },
{ id: 'E10', name: 'Zernike (RMS)', type: 'zernike_rms' },
];
// Check if this is a custom function type
// Using string comparison to handle both 'custom_function' and potential legacy 'custom' values
const isCustomType = node.type === 'custom_function' || (node.type as string) === 'custom';
// Get current source code
const currentCode = node.function?.source_code || DEFAULT_EXTRACTOR_TEMPLATE;
// Handle Claude generation request (non-streaming fallback)
const handleRequestGeneration = useCallback(async (prompt: string): Promise<string> => {
const response = await generateExtractorCode({
prompt,
study_id: studyId || undefined,
existing_code: node.function?.source_code,
output_names: node.outputs?.map(o => o.name) || [],
});
return response.code;
}, [studyId, node.function?.source_code, node.outputs]);
// Handle streaming generation (preferred)
const handleStreamingGeneration = useCallback((
request: { prompt: string; study_id?: string; existing_code?: string; output_names?: string[] },
callbacks: { onToken: (t: string) => void; onComplete: (c: string, o: string[]) => void; onError: (e: string) => void }
) => {
return streamExtractorCode(request, callbacks);
}, []);
// Handle code change from editor
const handleCodeChange = useCallback((code: string) => {
onChange('function', {
...node.function,
name: node.function?.name || 'custom_extract',
source_code: code,
});
}, [node.function, onChange]);
// Handle code validation (includes syntax check and dependency check)
const handleValidateCode = useCallback(async (code: string) => {
// First check syntax
const syntaxResult = await validateExtractorCode(code);
if (!syntaxResult.valid) {
return {
success: false,
error: syntaxResult.error,
};
}
// Then check dependencies
const depResult = await checkCodeDependencies(code);
// Build combined result
const warnings: string[] = [...depResult.warnings];
if (depResult.missing.length > 0) {
warnings.push(`Missing packages: ${depResult.missing.join(', ')}`);
}
return {
success: true,
error: warnings.length > 0 ? `Warnings: ${warnings.join('; ')}` : undefined,
outputs: depResult.imports.length > 0 ? { imports: depResult.imports.join(', ') } : undefined,
};
}, []);
// Handle live testing against OP2 file
const handleTestCode = useCallback(async (code: string) => {
const result = await testExtractorCode({
code,
study_id: studyId || undefined,
subcase_id: 1,
});
return result;
}, [studyId]);
return (
<>
<div>
<label className={labelClass}>Name</label>
<input
type="text"
value={node.name}
onChange={(e) => onChange('name', e.target.value)}
className={inputClass}
/>
</div>
<div>
<label className={labelClass}>Extractor Type</label>
<select
value={node.type}
onChange={(e) => onChange('type', e.target.value)}
className={selectClass}
>
<option value="">Select...</option>
{extractorOptions.map(opt => (
<option key={opt.id} value={opt.type}>
{opt.id} - {opt.name}
</option>
))}
<option value="custom_function">Custom Function</option>
</select>
</div>
{/* Custom Code Editor Button */}
{isCustomType && (
<>
<button
onClick={() => setShowCodeEditor(true)}
className="w-full flex items-center justify-center gap-2 px-3 py-2.5
bg-violet-500/20 hover:bg-violet-500/30 border border-violet-500/30
rounded-lg text-violet-400 text-sm font-medium transition-colors"
>
<Code size={16} />
Edit Custom Code
</button>
{node.function?.source_code && (
<div className="text-xs text-dark-500 flex items-center gap-1.5">
<FileCode size={12} />
Custom code defined ({node.function.source_code.split('\n').length} lines)
</div>
)}
</>
)}
{/* Code Editor Modal */}
{showCodeEditor && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
<div className="w-[900px] h-[700px] bg-dark-850 rounded-xl overflow-hidden shadow-2xl border border-dark-600 flex flex-col">
{/* Code Editor with built-in header containing toolbar buttons */}
<CodeEditorPanel
initialCode={currentCode}
extractorName={`Custom Extractor: ${node.name}`}
outputs={node.outputs?.map(o => o.name) || []}
onChange={handleCodeChange}
onRequestGeneration={handleRequestGeneration}
onRequestStreamingGeneration={handleStreamingGeneration}
onRun={handleValidateCode}
onTest={handleTestCode}
onClose={() => setShowCodeEditor(false)}
showHeader={true}
height="100%"
studyId={studyId || undefined}
/>
</div>
</div>
)}
{/* Outputs */}
<div>
<label className={labelClass}>Outputs</label>
<input
type="text"
value={node.outputs?.map(o => o.name).join(', ') || ''}
readOnly
placeholder="value, unit"
className={`${inputClass} bg-dark-900 cursor-not-allowed`}
/>
<p className="text-xs text-dark-500 mt-1">
{isCustomType
? 'Detected from return statement in code.'
: 'Outputs are defined by extractor type.'}
</p>
</div>
</>
);
}
interface ObjectiveNodeConfigProps {
node: Objective;
onChange: (field: string, value: unknown) => void;
}
function ObjectiveNodeConfig({ node, onChange }: ObjectiveNodeConfigProps) {
const spec = useSpec();
const extractors = spec?.extractors || [];
const currentExtractorId = node.source?.extractor_id || '__UNSET__';
const currentOutputName = node.source?.output_name || '__UNSET__';
const selectedExtractor = extractors.find((e) => e.id === currentExtractorId);
const outputOptions = selectedExtractor?.outputs?.map((o) => o.name) || [];
const handleExtractorChange = (extractorId: string) => {
// Reset output_name to a sensible default when extractor changes
const ext = extractors.find((e) => e.id === extractorId);
const outs = ext?.outputs?.map((o) => o.name) || [];
const preferred = outs.includes('value') ? 'value' : outs[0] || '__UNSET__';
onChange('source', {
extractor_id: extractorId,
output_name: preferred,
});
};
const handleOutputChange = (outputName: string) => {
onChange('source', {
extractor_id: currentExtractorId,
output_name: outputName,
});
};
return (
<>
<div>
<label className={labelClass}>Name</label>
<input
type="text"
value={node.name}
onChange={(e) => onChange('name', e.target.value)}
className={inputClass}
/>
</div>
<div>
<label className={labelClass}>Source Extractor</label>
<select
value={currentExtractorId}
onChange={(e) => handleExtractorChange(e.target.value)}
className={selectClass}
>
<option value="__UNSET__">(not connected)</option>
{extractors.map((ext) => (
<option key={ext.id} value={ext.id}>
{ext.id} {ext.name}
</option>
))}
</select>
</div>
<div>
<label className={labelClass}>Source Output</label>
<select
value={currentOutputName}
onChange={(e) => handleOutputChange(e.target.value)}
className={selectClass}
disabled={currentExtractorId === '__UNSET__'}
>
{currentExtractorId === '__UNSET__' ? (
<option value="__UNSET__">(select an extractor)</option>
) : (
outputOptions.map((name) => (
<option key={name} value={name}>
{name}
</option>
))
)}
</select>
<p className="text-xs text-dark-500 mt-1">
This drives execution. Canvas wires are just a visual check.
</p>
</div>
<div>
<label className={labelClass}>Direction</label>
<select
value={node.direction}
onChange={(e) => onChange('direction', e.target.value)}
className={selectClass}
>
<option value="minimize">Minimize</option>
<option value="maximize">Maximize</option>
</select>
</div>
<div>
<label className={labelClass}>Weight</label>
<input
type="number"
step="0.1"
min="0"
value={node.weight ?? 1}
onChange={(e) => onChange('weight', parseFloat(e.target.value))}
className={inputClass}
/>
</div>
{node.target !== undefined && (
<div>
<label className={labelClass}>Target Value</label>
<input
type="number"
value={node.target}
onChange={(e) => onChange('target', parseFloat(e.target.value))}
className={inputClass}
/>
</div>
)}
</>
);
}
interface ConstraintNodeConfigProps {
node: Constraint;
onChange: (field: string, value: unknown) => void;
}
function ConstraintNodeConfig({ node, onChange }: ConstraintNodeConfigProps) {
const spec = useSpec();
const extractors = spec?.extractors || [];
const currentExtractorId = node.source?.extractor_id || '__UNSET__';
const currentOutputName = node.source?.output_name || '__UNSET__';
const selectedExtractor = extractors.find((e) => e.id === currentExtractorId);
const outputOptions = selectedExtractor?.outputs?.map((o) => o.name) || [];
const handleExtractorChange = (extractorId: string) => {
const ext = extractors.find((e) => e.id === extractorId);
const outs = ext?.outputs?.map((o) => o.name) || [];
const preferred = outs.includes('value') ? 'value' : outs[0] || '__UNSET__';
onChange('source', {
extractor_id: extractorId,
output_name: preferred,
});
};
const handleOutputChange = (outputName: string) => {
onChange('source', {
extractor_id: currentExtractorId,
output_name: outputName,
});
};
return (
<>
<div>
<label className={labelClass}>Name</label>
<input
type="text"
value={node.name}
onChange={(e) => onChange('name', e.target.value)}
className={inputClass}
/>
</div>
<div>
<label className={labelClass}>Source Extractor</label>
<select
value={currentExtractorId}
onChange={(e) => handleExtractorChange(e.target.value)}
className={selectClass}
>
<option value="__UNSET__">(not connected)</option>
{extractors.map((ext) => (
<option key={ext.id} value={ext.id}>
{ext.id} {ext.name}
</option>
))}
</select>
</div>
<div>
<label className={labelClass}>Source Output</label>
<select
value={currentOutputName}
onChange={(e) => handleOutputChange(e.target.value)}
className={selectClass}
disabled={currentExtractorId === '__UNSET__'}
>
{currentExtractorId === '__UNSET__' ? (
<option value="__UNSET__">(select an extractor)</option>
) : (
outputOptions.map((name) => (
<option key={name} value={name}>
{name}
</option>
))
)}
</select>
<p className="text-xs text-dark-500 mt-1">
This drives execution. Canvas wires are just a visual check.
</p>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className={labelClass}>Type</label>
<select
value={node.type}
onChange={(e) => onChange('type', e.target.value)}
className={selectClass}
>
<option value="hard">Hard</option>
<option value="soft">Soft</option>
</select>
<p className="text-xs text-dark-500 mt-1">Spec type (hard/soft). Operator is set below.</p>
</div>
<div>
<label className={labelClass}>Operator</label>
<select
value={node.operator}
onChange={(e) => onChange('operator', e.target.value)}
className={selectClass}
>
<option value="<=">&lt;=</option>
<option value="<">&lt;</option>
<option value=">=">&gt;=</option>
<option value=">">&gt;</option>
<option value="==">==</option>
</select>
</div>
</div>
<div>
<label className={labelClass}>Threshold</label>
<input
type="number"
value={node.threshold}
onChange={(e) => onChange('threshold', parseFloat(e.target.value))}
className={inputClass}
/>
</div>
</>
);
}
export default NodeConfigPanelV2;