/** * 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(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 (
{/* Header */}

Configure {nodeLabel}

{!isSyntheticNode && ( )} {onClose && ( )}
{/* Error display */} {error && (

{error}

)} {/* Content */}
{/* Loading indicator */} {isUpdating && (
Updating...
)} {/* Model node (synthetic) */} {nodeType === 'model' && spec.model && ( )} {/* Solver node (synthetic) */} {nodeType === 'solver' && ( )} {/* Algorithm node (synthetic) */} {nodeType === 'algorithm' && ( )} {/* Surrogate node (synthetic) */} {nodeType === 'surrogate' && ( )} {/* Design Variable */} {nodeType === 'designVar' && selectedNode && ( )} {/* Extractor */} {nodeType === 'extractor' && selectedNode && ( )} {/* Objective */} {nodeType === 'objective' && selectedNode && ( )} {/* Constraint */} {nodeType === 'constraint' && selectedNode && ( )}
{/* File Browser Modal */} 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 */}
); } // ============================================================================ // Type-specific configuration components // ============================================================================ interface SpecConfigProps { spec: NonNullable>; } 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 ( <>

Read-only. Set in study configuration.

{spec.model.sim?.path && ( )} {/* Note: IntrospectionPanel is now rendered by PanelContainer, not here */} ); } function SolverNodeConfig({ spec }: SpecConfigProps) { return (

Detected from model file.

); } function AlgorithmNodeConfig({ spec }: SpecConfigProps) { const algo = spec.optimization.algorithm; return ( <>

Edit in optimization settings.

); } function SurrogateNodeConfig({ spec }: SpecConfigProps) { const surrogate = spec.optimization.surrogate; return ( <>
{surrogate?.enabled && ( <>
)}

Edit in optimization settings.

); } // ============================================================================ // Editable node configs // ============================================================================ interface DesignVarNodeConfigProps { node: DesignVariable; onChange: (field: string, value: unknown) => void; } function DesignVarNodeConfig({ node, onChange }: DesignVarNodeConfigProps) { return ( <>
onChange('name', e.target.value)} className={inputClass} />
onChange('expression_name', e.target.value)} placeholder="NX expression name" className={`${inputClass} font-mono text-sm`} />
onChange('bounds', { ...node.bounds, min: parseFloat(e.target.value) })} className={inputClass} />
onChange('bounds', { ...node.bounds, max: parseFloat(e.target.value) })} className={inputClass} />
{node.baseline !== undefined && (
onChange('baseline', parseFloat(e.target.value))} className={inputClass} />
)}
onChange('units', e.target.value)} placeholder="mm" className={inputClass} />
onChange('enabled', e.target.checked)} className="w-4 h-4 rounded bg-dark-800 border-dark-600 text-primary-500 focus:ring-primary-500" />
); } 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 => { 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 ( <>
onChange('name', e.target.value)} className={inputClass} />
{/* Custom Code Editor Button */} {isCustomType && ( <> {node.function?.source_code && (
Custom code defined ({node.function.source_code.split('\n').length} lines)
)} )} {/* Code Editor Modal */} {showCodeEditor && (
{/* Code Editor with built-in header containing toolbar buttons */} o.name) || []} onChange={handleCodeChange} onRequestGeneration={handleRequestGeneration} onRequestStreamingGeneration={handleStreamingGeneration} onRun={handleValidateCode} onTest={handleTestCode} onClose={() => setShowCodeEditor(false)} showHeader={true} height="100%" studyId={studyId || undefined} />
)} {/* Outputs */}
o.name).join(', ') || ''} readOnly placeholder="value, unit" className={`${inputClass} bg-dark-900 cursor-not-allowed`} />

{isCustomType ? 'Detected from return statement in code.' : 'Outputs are defined by extractor type.'}

); } interface ObjectiveNodeConfigProps { node: Objective; onChange: (field: string, value: unknown) => void; } function ObjectiveNodeConfig({ node, onChange }: ObjectiveNodeConfigProps) { return ( <>
onChange('name', e.target.value)} className={inputClass} />
onChange('weight', parseFloat(e.target.value))} className={inputClass} />
{node.target !== undefined && (
onChange('target', parseFloat(e.target.value))} className={inputClass} />
)} ); } interface ConstraintNodeConfigProps { node: Constraint; onChange: (field: string, value: unknown) => void; } function ConstraintNodeConfig({ node, onChange }: ConstraintNodeConfigProps) { return ( <>
onChange('name', e.target.value)} className={inputClass} />
onChange('threshold', parseFloat(e.target.value))} className={inputClass} />
); } export default NodeConfigPanelV2;