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

835 lines
26 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) {
return (
<div>
<label className={labelClass}>Solution Type</label>
<input
type="text"
value={spec.model.sim?.solution_type || 'Not configured'}
readOnly
className={`${inputClass} bg-dark-900 cursor-not-allowed`}
title="Solver type is determined by the model file."
/>
<p className="text-xs text-dark-500 mt-1">Detected from model file.</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) {
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}>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) {
return (
<>
<div>
<label className={labelClass}>Name</label>
<input
type="text"
value={node.name}
onChange={(e) => onChange('name', e.target.value)}
className={inputClass}
/>
</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="less_than">&lt; Less than</option>
<option value="less_equal">&lt;= Less or equal</option>
<option value="greater_than">&gt; Greater than</option>
<option value="greater_equal">&gt;= Greater or equal</option>
<option value="equal">= Equal</option>
</select>
</div>
<div>
<label className={labelClass}>Threshold</label>
<input
type="number"
value={node.threshold}
onChange={(e) => onChange('threshold', parseFloat(e.target.value))}
className={inputClass}
/>
</div>
</div>
</>
);
}
export default NodeConfigPanelV2;