2 Commits

Author SHA1 Message Date
2f0f45de86 fix(spec): Correct volume extractor structure in m1_mirror_cost_reduction_lateral
- Change custom_function.code to function.source_code per AtomizerSpec v2.0 schema
2026-01-20 14:14:20 -05:00
47f8b50112 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/
2026-01-20 14:14:14 -05:00
6 changed files with 1266 additions and 930 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
*/
import { useCallback, useRef, useEffect, useMemo, DragEvent } from 'react';
import { useCallback, useRef, useEffect, useMemo, useState, DragEvent } from 'react';
import ReactFlow, {
Background,
Controls,
@@ -22,6 +22,7 @@ import ReactFlow, {
NodeChange,
EdgeChange,
Connection,
applyNodeChanges,
} from 'reactflow';
import 'reactflow/dist/style.css';
@@ -74,8 +75,28 @@ function getDefaultNodeData(type: AddableNodeType, position: { x: number; y: num
case 'extractor':
return {
name: `extractor_${timestamp}`,
type: 'custom',
type: 'custom_function', // Must be valid ExtractorType
builtin: false,
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,
};
case 'objective':
@@ -90,7 +111,8 @@ function getDefaultNodeData(type: AddableNodeType, position: { x: number; y: num
case 'constraint':
return {
name: `constraint_${timestamp}`,
type: 'upper',
constraint_type: 'hard', // Must be 'hard' or 'soft'
operator: '<=',
limit: 1.0,
source_extractor_id: null,
source_output: null,
@@ -208,12 +230,23 @@ function SpecRendererInner({
nodesRef.current = 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
const onNodesChange = useCallback(
(changes: NodeChange[]) => {
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) {
if (change.type === 'position' && change.position && change.dragging === false) {
// Dragging ended - update spec
@@ -458,7 +491,7 @@ function SpecRendererInner({
)}
<ReactFlow
nodes={nodes}
nodes={localNodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}

View File

@@ -20,6 +20,7 @@ import { useCanvasStore } from '../../../hooks/useCanvasStore';
interface IntrospectionPanelProps {
filePath: string;
studyId?: string;
onClose: () => void;
}
@@ -56,7 +57,7 @@ interface IntrospectionResult {
warnings: string[];
}
export function IntrospectionPanel({ filePath, onClose }: IntrospectionPanelProps) {
export function IntrospectionPanel({ filePath, studyId, onClose }: IntrospectionPanelProps) {
const [result, setResult] = useState<IntrospectionResult | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -73,21 +74,37 @@ export function IntrospectionPanel({ filePath, onClose }: IntrospectionPanelProp
setIsLoading(true);
setError(null);
try {
const res = await fetch('/api/nx/introspect', {
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) throw new Error('Introspection failed');
}
if (!res.ok) {
const errData = await res.json().catch(() => ({}));
throw new Error(errData.detail || 'Introspection failed');
}
const data = await res.json();
setResult(data);
// Handle different response formats
setResult(data.introspection || data);
} catch (e) {
setError('Failed to introspect model');
console.error(e);
const msg = e instanceof Error ? e.message : 'Failed to introspect model';
setError(msg);
console.error('Introspection error:', e);
} finally {
setIsLoading(false);
}
}, [filePath]);
}, [filePath, studyId]);
useEffect(() => {
runIntrospection();

View File

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

View File

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

View File

@@ -2,9 +2,9 @@
"meta": {
"version": "2.0",
"created": "2026-01-17T15:35:12.024432Z",
"modified": "2026-01-17T16:33:51.000000Z",
"modified": "2026-01-20T19:01:36.016065Z",
"created_by": "migration",
"modified_by": "claude",
"modified_by": "canvas",
"study_name": "m1_mirror_cost_reduction_lateral",
"description": "Lateral support optimization with new U-joint expressions (lateral_inner_u, lateral_outer_u) for cost reduction model. Focus on WFE and MFG only - no mass objective.",
"tags": [
@@ -204,16 +204,12 @@
},
{
"id": "ext_003",
"name": "Volume Extractor",
"type": "custom",
"name": "Volume Extractor custom",
"type": "custom_function",
"builtin": false,
"config": {
"density_kg_m3": 2530.0
},
"custom_function": {
"name": "extract_volume",
"code": "def extract_volume(trial_dir, config, context):\n \"\"\"\n Extract volume from mass using material density.\n Volume = Mass / Density\n \n For Zerodur glass-ceramic: density ~ 2530 kg/m³\n \"\"\"\n import json\n from pathlib import Path\n \n # Get mass from the mass extractor results\n results_file = Path(trial_dir) / 'results.json'\n if results_file.exists():\n with open(results_file) as f:\n results = json.load(f)\n mass_kg = results.get('mass_kg', 0)\n else:\n # If no results yet, try to get from context\n mass_kg = context.get('mass_kg', 0)\n \n density = config.get('density_kg_m3', 2530.0) # Zerodur default\n \n # Volume in m³\n volume_m3 = mass_kg / density if density > 0 else 0\n \n # Also calculate in liters for convenience (1 m³ = 1000 L)\n volume_liters = volume_m3 * 1000\n \n return {\n 'volume_m3': volume_m3,\n 'volume_liters': volume_liters\n }\n"
},
"outputs": [
{
"name": "volume_m3",
@@ -227,7 +223,55 @@
"canvas_position": {
"x": 740,
"y": 400
},
"function": {
"name": "extract_volume",
"module": null,
"signature": null,
"source_code": "def extract_volume(trial_dir, config, context):\n \"\"\"\n Extract volume from mass using material density.\n Volume = Mass / Density\n \n For Zerodur glass-ceramic: density ~ 2530 kg/mó\n \"\"\"\n import json\n from pathlib import Path\n \n # Get mass from the mass extractor results\n results_file = Path(trial_dir) / 'results.json'\n if results_file.exists():\n with open(results_file) as f:\n results = json.load(f)\n mass_kg = results.get('mass_kg', 0)\n else:\n # If no results yet, try to get from context\n mass_kg = context.get('mass_kg', 0)\n \n density = config.get('density_kg_m3', 2530.0) # Zerodur default\n \n # Volume in mó\n volume_m3 = mass_kg / density if density > 0 else 0\n \n # Also calculate in liters for convenience (1 mó = 1000 L)\n volume_liters = volume_m3 * 1000000\n \n return {\n 'volume_m3': volume_m3,\n 'volume_liters': volume_liters\n }\n"
}
},
{
"name": "a1768934465995fsfdadd",
"type": "custom_function",
"builtin": false,
"enabled": true,
"function": {
"name": "extract",
"source_code": "def extract(op2_path: str, config: dict = None) -> dict:\n \"\"\"\n Custom extractor function.\n \n Args:\n op2_path: Path to the OP2 results file\n config: Optional configuration dict\n \n Returns:\n Dictionary with extracted values\n \"\"\"\n # TODO: Implement extraction logic\n return {'value': 0.0}\n"
},
"outputs": [
{
"name": "value",
"metric": "custom"
}
],
"canvas_position": {
"x": 661.4703818070815,
"y": 655.713625352519
},
"id": "ext_004"
},
{
"name": "extractor_1768934622682",
"type": "custom_function",
"builtin": false,
"enabled": true,
"function": {
"name": "extract",
"source_code": "def extract(op2_path: str, config: dict = None) -> dict:\n \"\"\"\n Custom extractor function.\n \n Args:\n op2_path: Path to the OP2 results file\n config: Optional configuration dict\n \n Returns:\n Dictionary with extracted values\n \"\"\"\n # TODO: Implement extraction logic\n return {'value': 0.0}\n"
},
"outputs": [
{
"name": "value",
"metric": "custom"
}
],
"canvas_position": {
"x": 588.8370255010856,
"y": 516.8654070156841
},
"id": "ext_005"
}
],
"objectives": [