fix(canvas): Bug fixes for node movement, drag-drop, config panel, and introspection

- SpecRenderer: Add localNodes state with applyNodeChanges for smooth node dragging
- SpecRenderer: Fix getDefaultNodeData() - extractor uses 'custom_function' type with function definition
- SpecRenderer: Fix constraint default - use constraint_type instead of type
- CanvasView: Show config panel INSTEAD of chat when node selected (not blocked)
- NodeConfigPanelV2: Enable showHeader for code editor toolbar (Generate/Snippets/Validate/Test buttons)
- NodeConfigPanelV2: Pass studyId to IntrospectionPanel
- IntrospectionPanel: Accept studyId prop and use correct API endpoint
- optimization.py: Search multiple directories for model files including 1_setup/model/
This commit is contained in:
2026-01-20 14:14:14 -05:00
parent cf8c57fdac
commit 47f8b50112
5 changed files with 1214 additions and 922 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -10,7 +10,7 @@
* P2.7-P2.10: SpecRenderer component with node/edge/selection handling * P2.7-P2.10: SpecRenderer component with node/edge/selection handling
*/ */
import { useCallback, useRef, useEffect, useMemo, DragEvent } from 'react'; import { useCallback, useRef, useEffect, useMemo, useState, DragEvent } from 'react';
import ReactFlow, { import ReactFlow, {
Background, Background,
Controls, Controls,
@@ -22,6 +22,7 @@ import ReactFlow, {
NodeChange, NodeChange,
EdgeChange, EdgeChange,
Connection, Connection,
applyNodeChanges,
} from 'reactflow'; } from 'reactflow';
import 'reactflow/dist/style.css'; import 'reactflow/dist/style.css';
@@ -74,8 +75,28 @@ function getDefaultNodeData(type: AddableNodeType, position: { x: number; y: num
case 'extractor': case 'extractor':
return { return {
name: `extractor_${timestamp}`, name: `extractor_${timestamp}`,
type: 'custom', type: 'custom_function', // Must be valid ExtractorType
builtin: false,
enabled: true, enabled: true,
// Custom function extractors need a function definition
function: {
name: 'extract',
source_code: `def extract(op2_path: str, config: dict = None) -> dict:
"""
Custom extractor function.
Args:
op2_path: Path to the OP2 results file
config: Optional configuration dict
Returns:
Dictionary with extracted values
"""
# TODO: Implement extraction logic
return {'value': 0.0}
`,
},
outputs: [{ name: 'value', metric: 'custom' }],
canvas_position: position, canvas_position: position,
}; };
case 'objective': case 'objective':
@@ -90,7 +111,8 @@ function getDefaultNodeData(type: AddableNodeType, position: { x: number; y: num
case 'constraint': case 'constraint':
return { return {
name: `constraint_${timestamp}`, name: `constraint_${timestamp}`,
type: 'upper', constraint_type: 'hard', // Must be 'hard' or 'soft'
operator: '<=',
limit: 1.0, limit: 1.0,
source_extractor_id: null, source_extractor_id: null,
source_output: null, source_output: null,
@@ -208,12 +230,23 @@ function SpecRendererInner({
nodesRef.current = nodes; nodesRef.current = nodes;
}, [nodes]); }, [nodes]);
// Track local node state for smooth dragging
const [localNodes, setLocalNodes] = useState(nodes);
// Sync local nodes with spec-derived nodes when spec changes
useEffect(() => {
setLocalNodes(nodes);
}, [nodes]);
// Handle node position changes // Handle node position changes
const onNodesChange = useCallback( const onNodesChange = useCallback(
(changes: NodeChange[]) => { (changes: NodeChange[]) => {
if (!editable) return; if (!editable) return;
// Handle position changes // Apply changes to local state for smooth dragging
setLocalNodes((nds) => applyNodeChanges(changes, nds));
// Handle position changes - save to spec when drag ends
for (const change of changes) { for (const change of changes) {
if (change.type === 'position' && change.position && change.dragging === false) { if (change.type === 'position' && change.position && change.dragging === false) {
// Dragging ended - update spec // Dragging ended - update spec
@@ -458,7 +491,7 @@ function SpecRendererInner({
)} )}
<ReactFlow <ReactFlow
nodes={nodes} nodes={localNodes}
edges={edges} edges={edges}
onNodesChange={onNodesChange} onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange} onEdgesChange={onEdgesChange}

View File

@@ -20,6 +20,7 @@ import { useCanvasStore } from '../../../hooks/useCanvasStore';
interface IntrospectionPanelProps { interface IntrospectionPanelProps {
filePath: string; filePath: string;
studyId?: string;
onClose: () => void; onClose: () => void;
} }
@@ -56,7 +57,7 @@ interface IntrospectionResult {
warnings: string[]; warnings: string[];
} }
export function IntrospectionPanel({ filePath, onClose }: IntrospectionPanelProps) { export function IntrospectionPanel({ filePath, studyId, onClose }: IntrospectionPanelProps) {
const [result, setResult] = useState<IntrospectionResult | null>(null); const [result, setResult] = useState<IntrospectionResult | null>(null);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -73,21 +74,37 @@ export function IntrospectionPanel({ filePath, onClose }: IntrospectionPanelProp
setIsLoading(true); setIsLoading(true);
setError(null); setError(null);
try { try {
const res = await fetch('/api/nx/introspect', { let res;
method: 'POST',
headers: { 'Content-Type': 'application/json' }, // If we have a studyId, use the study-aware introspection endpoint
body: JSON.stringify({ file_path: filePath }), if (studyId) {
}); // Don't encode studyId - it may contain slashes for nested paths (e.g., M1_Mirror/study_name)
if (!res.ok) throw new Error('Introspection failed'); 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(); const data = await res.json();
setResult(data);
// Handle different response formats
setResult(data.introspection || data);
} catch (e) { } catch (e) {
setError('Failed to introspect model'); const msg = e instanceof Error ? e.message : 'Failed to introspect model';
console.error(e); setError(msg);
console.error('Introspection error:', e);
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}, [filePath]); }, [filePath, studyId]);
useEffect(() => { useEffect(() => {
runIntrospection(); runIntrospection();

View File

@@ -254,6 +254,7 @@ export function NodeConfigPanelV2({ onClose }: NodeConfigPanelV2Props) {
<div className="fixed top-20 right-96 z-40"> <div className="fixed top-20 right-96 z-40">
<IntrospectionPanel <IntrospectionPanel
filePath={spec.model.sim.path} filePath={spec.model.sim.path}
studyId={useSpecStore.getState().studyId || undefined}
onClose={() => setShowIntrospection(false)} onClose={() => setShowIntrospection(false)}
/> />
</div> </div>
@@ -313,6 +314,7 @@ function ModelNodeConfig({ spec }: SpecConfigProps) {
<div className="fixed top-20 right-96 z-40"> <div className="fixed top-20 right-96 z-40">
<IntrospectionPanel <IntrospectionPanel
filePath={spec.model.sim.path} filePath={spec.model.sim.path}
studyId={useSpecStore.getState().studyId || undefined}
onClose={() => setShowIntrospection(false)} onClose={() => setShowIntrospection(false)}
/> />
</div> </div>
@@ -694,38 +696,21 @@ function ExtractorNodeConfig({ node, onChange }: ExtractorNodeConfigProps) {
{showCodeEditor && ( {showCodeEditor && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"> <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"> <div className="w-[900px] h-[700px] bg-dark-850 rounded-xl overflow-hidden shadow-2xl border border-dark-600 flex flex-col">
{/* Modal Header */} {/* Code Editor with built-in header containing toolbar buttons */}
<div className="flex items-center justify-between px-4 py-3 border-b border-dark-700 bg-dark-900"> <CodeEditorPanel
<div className="flex items-center gap-3"> initialCode={currentCode}
<FileCode size={18} className="text-violet-400" /> extractorName={`Custom Extractor: ${node.name}`}
<span className="font-medium text-white">Custom Extractor: {node.name}</span> outputs={node.outputs?.map(o => o.name) || []}
<span className="text-xs text-dark-500 bg-dark-800 px-2 py-0.5 rounded">.py</span> onChange={handleCodeChange}
</div> onRequestGeneration={handleRequestGeneration}
<button onRequestStreamingGeneration={handleStreamingGeneration}
onClick={() => setShowCodeEditor(false)} onRun={handleValidateCode}
className="p-1.5 rounded hover:bg-dark-700 text-dark-400 hover:text-white transition-colors" onTest={handleTestCode}
> onClose={() => setShowCodeEditor(false)}
<X size={18} /> showHeader={true}
</button> height="100%"
</div> studyId={studyId || undefined}
/>
{/* Code Editor */}
<div className="flex-1">
<CodeEditorPanel
initialCode={currentCode}
extractorName={node.name}
outputs={node.outputs?.map(o => o.name) || []}
onChange={handleCodeChange}
onRequestGeneration={handleRequestGeneration}
onRequestStreamingGeneration={handleStreamingGeneration}
onRun={handleValidateCode}
onTest={handleTestCode}
onClose={() => setShowCodeEditor(false)}
showHeader={false}
height="100%"
studyId={studyId || undefined}
/>
</div>
</div> </div>
</div> </div>
)} )}

View File

@@ -472,7 +472,8 @@ export function CanvasView() {
</div> </div>
{/* Config Panel - use V2 for spec mode, legacy for AtomizerCanvas */} {/* Config Panel - use V2 for spec mode, legacy for AtomizerCanvas */}
{selectedNodeId && !showChat && ( {/* Shows INSTEAD of chat when a node is selected */}
{selectedNodeId ? (
useSpecMode ? ( useSpecMode ? (
<NodeConfigPanelV2 onClose={() => useSpecStore.getState().clearSelection()} /> <NodeConfigPanelV2 onClose={() => useSpecStore.getState().clearSelection()} />
) : ( ) : (
@@ -480,10 +481,7 @@ export function CanvasView() {
<NodeConfigPanel nodeId={selectedNodeId} /> <NodeConfigPanel nodeId={selectedNodeId} />
</div> </div>
) )
)} ) : showChat ? (
{/* Chat/Assistant Panel */}
{showChat && (
<div className="w-96 border-l border-dark-700 bg-dark-850 flex flex-col"> <div className="w-96 border-l border-dark-700 bg-dark-850 flex flex-col">
{/* Chat Header */} {/* Chat Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-dark-700"> <div className="flex items-center justify-between px-4 py-3 border-b border-dark-700">
@@ -524,7 +522,7 @@ export function CanvasView() {
isConnected={isConnected} isConnected={isConnected}
/> />
</div> </div>
)} ) : null}
</main> </main>
{/* Template Selector Modal */} {/* Template Selector Modal */}