/** * Introspection Panel - Shows discovered expressions and extractors from NX model */ import { useState, useEffect, useCallback, useMemo } from 'react'; import { X, Search, RefreshCw, Plus, ChevronDown, ChevronRight, FileBox, Cpu, FlaskConical, SlidersHorizontal, AlertTriangle, } from 'lucide-react'; import { useCanvasStore } from '../../../hooks/useCanvasStore'; interface IntrospectionPanelProps { filePath: string; studyId?: string; onClose: () => void; } interface Expression { name: string; value: number; rhs?: string; min?: number; max?: number; unit?: string; units?: string; // API returns 'units' not 'unit' type: string; source?: string; } interface Extractor { id: string; name: string; description?: string; always?: boolean; } interface DependentFile { path: string; type: string; name: string; } // The API returns expressions in a nested structure interface ExpressionsResult { user: Expression[]; internal: Expression[]; total_count: number; user_count: number; } interface IntrospectionResult { part_file?: string; part_path?: string; file_path?: string; file_type?: string; success?: boolean; error?: string | null; // Expressions can be either an array (old format) or object with user/internal (new format) expressions: Expression[] | ExpressionsResult; solver_type?: string | null; dependent_files?: DependentFile[]; extractors_available?: Extractor[]; warnings?: string[]; // Additional fields from NX introspection mass_properties?: Record; materials?: Record; bodies?: Record; attributes?: Array<{ title: string; value: string }>; units?: Record; linked_parts?: Record; } export function IntrospectionPanel({ filePath, studyId, onClose }: IntrospectionPanelProps) { const [result, setResult] = useState(null); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); const [expandedSections, setExpandedSections] = useState>( new Set(['expressions', 'extractors']) ); const [searchTerm, setSearchTerm] = useState(''); const { addNode, nodes } = useCanvasStore(); const runIntrospection = useCallback(async () => { if (!filePath) return; setIsLoading(true); setError(null); try { let res; // If we have a studyId, use the study-aware introspection endpoint if (studyId) { // Don't encode studyId - it may contain slashes for nested paths (e.g., M1_Mirror/study_name) res = await fetch(`/api/optimization/studies/${studyId}/nx/introspect`); } else { // Fallback to direct path introspection res = await fetch('/api/nx/introspect', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ file_path: filePath }), }); } if (!res.ok) { const errData = await res.json().catch(() => ({})); throw new Error(errData.detail || 'Introspection failed'); } const data = await res.json(); // Handle different response formats setResult(data.introspection || data); } catch (e) { const msg = e instanceof Error ? e.message : 'Failed to introspect model'; setError(msg); console.error('Introspection error:', e); } finally { setIsLoading(false); } }, [filePath, studyId]); useEffect(() => { runIntrospection(); }, [runIntrospection]); const toggleSection = (section: string) => { setExpandedSections((prev) => { const next = new Set(prev); if (next.has(section)) next.delete(section); else next.add(section); return next; }); }; const addExpressionAsDesignVar = (expr: Expression) => { // Find a good position (left of model node) const modelNode = nodes.find((n) => n.data.type === 'model'); const existingDvars = nodes.filter((n) => n.data.type === 'designVar'); const position = { x: (modelNode?.position.x || 300) - 250, y: (modelNode?.position.y || 100) + existingDvars.length * 100, }; // Calculate min/max based on value if not provided const minValue = expr.min ?? expr.value * 0.5; const maxValue = expr.max ?? expr.value * 1.5; addNode('designVar', position, { label: expr.name, expressionName: expr.name, minValue, maxValue, unit: expr.unit, configured: true, }); }; const addExtractorNode = (extractor: Extractor) => { // Find a good position (right of solver node) const solverNode = nodes.find((n) => n.data.type === 'solver'); const existingExtractors = nodes.filter((n) => n.data.type === 'extractor'); const position = { x: (solverNode?.position.x || 400) + 200, y: (solverNode?.position.y || 100) + existingExtractors.length * 100, }; addNode('extractor', position, { label: extractor.name, extractorId: extractor.id, extractorName: extractor.name, configured: true, }); }; // Handle both array format (old) and object format (new API) const allExpressions: Expression[] = useMemo(() => { if (!result?.expressions) return []; // Check if expressions is an array (old format) or object (new format) if (Array.isArray(result.expressions)) { return result.expressions; } // New format: { user: [...], internal: [...] } const exprObj = result.expressions as ExpressionsResult; return [...(exprObj.user || []), ...(exprObj.internal || [])]; }, [result?.expressions]); const filteredExpressions = allExpressions.filter((e) => e.name.toLowerCase().includes(searchTerm.toLowerCase()) ); return (
{/* Header */}
Model Introspection
{/* Search */}
setSearchTerm(e.target.value)} className="w-full px-3 py-1.5 bg-dark-800 border border-dark-600 rounded-lg text-sm text-white placeholder-dark-500 focus:outline-none focus:border-primary-500" />
{/* Content */}
{isLoading ? (
Analyzing model...
) : error ? (
{error}
) : result ? (
{/* Solver Type */} {result.solver_type && (
Solver: {result.solver_type}
)} {/* Expressions Section */}
{expandedSections.has('expressions') && (
{filteredExpressions.length === 0 ? (

No expressions found

) : ( filteredExpressions.map((expr) => (

{expr.name}

{expr.value} {expr.units || expr.unit || ''} {expr.source === 'inferred' && ( (inferred) )}

)) )}
)}
{/* Extractors Section - only show if available */} {(result.extractors_available?.length ?? 0) > 0 && (
{expandedSections.has('extractors') && (
{(result.extractors_available || []).map((ext) => (

{ext.name}

{ext.id} {ext.description && ` - ${ext.description}`}

))}
)}
)} {/* Dependent Files */} {(result.dependent_files?.length ?? 0) > 0 && (
{expandedSections.has('files') && (
{(result.dependent_files || []).map((file) => (

{file.name}

{file.type}

))}
)}
)} {/* Warnings */} {(result.warnings?.length ?? 0) > 0 && (

Warnings

{(result.warnings || []).map((w, i) => (

{w}

))}
)}
) : (
Select a model to introspect
)}
); }