## Cleanup (v0.5.0) - Delete 102+ orphaned MCP session temp files - Remove build artifacts (htmlcov, dist, __pycache__) - Archive superseded plan docs (RALPH_LOOP V2/V3, CANVAS V3, etc.) - Move debug/analysis scripts from tests/ to tools/analysis/ - Archive redundant NX journals to archive/nx_journals/ - Archive monolithic PROTOCOL.md to docs/archive/ - Update .gitignore with missing patterns - Clean old study files (optimization_log_old.txt, run_optimization_old.py) ## Canvas UX (Phases 7-9) - Phase 7: Resizable panels with localStorage persistence - Left sidebar: 200-400px, Right panel: 280-600px - New useResizablePanel hook and ResizeHandle component - Phase 8: Enable all palette items - All 8 node types now draggable - Singleton logic for model/solver/algorithm/surrogate - Phase 9: Solver configuration - Add SolverEngine type (nxnastran, mscnastran, python, etc.) - Add NastranSolutionType (SOL101-SOL200) - Engine/solution dropdowns in config panel - Python script path support ## Documentation - Update CHANGELOG.md with recent versions - Update docs/00_INDEX.md - Create examples/README.md - Add docs/plans/CANVAS_UX_IMPROVEMENTS.md
75 KiB
Ralph Loop: Canvas Professional Upgrade V2
Purpose: Complete Canvas overhaul with Claude integration, auto-loading, and professional UI Execution: Autonomous, all phases sequential, no stopping
Launch Command
cd C:\Users\antoi\Atomizer
claude --dangerously-skip-permissions
Paste everything below the line.
You are executing a multi-phase autonomous development session to upgrade the Atomizer Canvas to production quality.
Mission Summary
Transform the Canvas from a prototype into a fully functional optimization workflow builder with:
- Professional Lucide icons (no emojis)
- Working Claude CLI integration
- Auto-load from optimization_config.json
- NX model introspection (.sim → expressions)
- Expression search/dropdown for design variables
- Responsive full-screen canvas
- "Process with Claude" button using Atomizer protocols
- Complete MCP tool implementation
Environment
Working Directory: C:/Users/antoi/Atomizer
Frontend: atomizer-dashboard/frontend/
Backend: atomizer-dashboard/backend/
MCP Server: mcp-server/atomizer-tools/
Python: C:/Users/antoi/anaconda3/envs/atomizer/python.exe
Node: Available in PATH
Git: Push to origin AND github
Execution Rules
- TodoWrite - Track every task, mark complete immediately
- Sequential phases - Complete each phase fully before next
- Test builds - Run
npm run buildafter each phase - No questions - Use provided code, make reasonable decisions
- Commit per phase - Git commit after each major phase
PHASE 1: Professional Icons (Replace All Emojis)
Replace ALL emoji icons with Lucide React icons across the Canvas.
T1.1 - Update NodePalette.tsx
File: atomizer-dashboard/frontend/src/components/canvas/palette/NodePalette.tsx
import { DragEvent } from 'react';
import { NodeType } from '../../../lib/canvas/schema';
import {
Cube,
Cpu,
SlidersHorizontal,
FlaskConical,
Target,
ShieldAlert,
BrainCircuit,
Rocket,
} from 'lucide-react';
interface PaletteItem {
type: NodeType;
label: string;
icon: React.ReactNode;
description: string;
color: string;
}
const PALETTE_ITEMS: PaletteItem[] = [
{ type: 'model', label: 'Model', icon: <Cube size={18} />, description: 'NX model file (.prt, .sim)', color: 'text-blue-400' },
{ type: 'solver', label: 'Solver', icon: <Cpu size={18} />, description: 'Nastran solution type', color: 'text-violet-400' },
{ type: 'designVar', label: 'Design Variable', icon: <SlidersHorizontal size={18} />, description: 'Parameter to optimize', color: 'text-emerald-400' },
{ type: 'extractor', label: 'Extractor', icon: <FlaskConical size={18} />, description: 'Physics result extraction', color: 'text-cyan-400' },
{ type: 'objective', label: 'Objective', icon: <Target size={18} />, description: 'Optimization goal', color: 'text-rose-400' },
{ type: 'constraint', label: 'Constraint', icon: <ShieldAlert size={18} />, description: 'Design constraint', color: 'text-amber-400' },
{ type: 'algorithm', label: 'Algorithm', icon: <BrainCircuit size={18} />, description: 'Optimization method', color: 'text-indigo-400' },
{ type: 'surrogate', label: 'Surrogate', icon: <Rocket size={18} />, description: 'Neural acceleration', color: 'text-pink-400' },
];
export function NodePalette() {
const onDragStart = (event: DragEvent, nodeType: NodeType) => {
event.dataTransfer.setData('application/reactflow', nodeType);
event.dataTransfer.effectAllowed = 'move';
};
return (
<div className="w-56 bg-dark-850 border-r border-dark-700 flex flex-col">
<div className="p-4 border-b border-dark-700">
<h3 className="text-xs font-semibold text-dark-400 uppercase tracking-wider">
Components
</h3>
<p className="text-xs text-dark-500 mt-1">
Drag to canvas
</p>
</div>
<div className="flex-1 overflow-y-auto p-3 space-y-1.5">
{PALETTE_ITEMS.map((item) => (
<div
key={item.type}
draggable
onDragStart={(e) => onDragStart(e, item.type)}
className="flex items-center gap-2.5 px-3 py-2.5 bg-dark-800/50 rounded-lg border border-dark-700/50
cursor-grab hover:border-primary-500/50 hover:bg-dark-800
active:cursor-grabbing transition-all group"
>
<div className={`${item.color} opacity-80 group-hover:opacity-100 transition-opacity`}>
{item.icon}
</div>
<div className="flex-1 min-w-0">
<div className="font-medium text-dark-200 text-sm leading-tight">{item.label}</div>
<div className="text-[10px] text-dark-500 truncate">{item.description}</div>
</div>
</div>
))}
</div>
</div>
);
}
T1.2 - Update BaseNode.tsx with Lucide Icons
File: atomizer-dashboard/frontend/src/components/canvas/nodes/BaseNode.tsx
import { memo, ReactNode } from 'react';
import { Handle, Position, NodeProps } from 'reactflow';
import { AlertCircle } from 'lucide-react';
import { BaseNodeData } from '../../../lib/canvas/schema';
interface BaseNodeProps extends NodeProps<BaseNodeData> {
icon: ReactNode;
iconColor: string;
children?: ReactNode;
inputs?: number;
outputs?: number;
}
function BaseNodeComponent({
data,
selected,
icon,
iconColor,
children,
inputs = 1,
outputs = 1,
}: BaseNodeProps) {
const hasErrors = data.errors && data.errors.length > 0;
return (
<div
className={`
relative px-3 py-2.5 rounded-lg border min-w-[160px] max-w-[200px]
bg-dark-850 shadow-lg transition-all duration-150
${selected ? 'border-primary-400 ring-2 ring-primary-400/20' : 'border-dark-600'}
${!data.configured ? 'border-dashed border-dark-500' : ''}
${hasErrors ? 'border-red-500/70' : ''}
`}
>
{inputs > 0 && (
<Handle
type="target"
position={Position.Left}
className="!w-2.5 !h-2.5 !bg-dark-500 !border-2 !border-dark-700 hover:!bg-primary-400 hover:!border-primary-500 transition-colors"
/>
)}
<div className="flex items-center gap-2">
<div className={`${iconColor} flex-shrink-0`}>
{icon}
</div>
<div className="flex-1 min-w-0">
<div className="font-medium text-white text-sm truncate">{data.label}</div>
</div>
{!data.configured && (
<div className="w-1.5 h-1.5 rounded-full bg-amber-400 flex-shrink-0" />
)}
</div>
{children && (
<div className="mt-1.5 text-xs text-dark-400 truncate">
{children}
</div>
)}
{hasErrors && (
<div className="mt-1.5 flex items-center gap-1 text-xs text-red-400">
<AlertCircle size={10} />
<span className="truncate">{data.errors![0]}</span>
</div>
)}
{outputs > 0 && (
<Handle
type="source"
position={Position.Right}
className="!w-2.5 !h-2.5 !bg-dark-500 !border-2 !border-dark-700 hover:!bg-primary-400 hover:!border-primary-500 transition-colors"
/>
)}
</div>
);
}
export const BaseNode = memo(BaseNodeComponent);
T1.3 - Update All Node Components
Update each node file to use Lucide icons:
ModelNode.tsx:
import { memo } from 'react';
import { NodeProps } from 'reactflow';
import { Cube } from 'lucide-react';
import { BaseNode } from './BaseNode';
import { ModelNodeData } from '../../../lib/canvas/schema';
function ModelNodeComponent(props: NodeProps<ModelNodeData>) {
const { data } = props;
return (
<BaseNode {...props} icon={<Cube size={16} />} iconColor="text-blue-400" inputs={0}>
{data.filePath ? data.filePath.split(/[/\\]/).pop() : 'No file selected'}
</BaseNode>
);
}
export const ModelNode = memo(ModelNodeComponent);
SolverNode.tsx:
import { memo } from 'react';
import { NodeProps } from 'reactflow';
import { Cpu } from 'lucide-react';
import { BaseNode } from './BaseNode';
import { SolverNodeData } from '../../../lib/canvas/schema';
function SolverNodeComponent(props: NodeProps<SolverNodeData>) {
const { data } = props;
return (
<BaseNode {...props} icon={<Cpu size={16} />} iconColor="text-violet-400">
{data.solverType || 'Select solution'}
</BaseNode>
);
}
export const SolverNode = memo(SolverNodeComponent);
DesignVarNode.tsx:
import { memo } from 'react';
import { NodeProps } from 'reactflow';
import { SlidersHorizontal } from 'lucide-react';
import { BaseNode } from './BaseNode';
import { DesignVarNodeData } from '../../../lib/canvas/schema';
function DesignVarNodeComponent(props: NodeProps<DesignVarNodeData>) {
const { data } = props;
return (
<BaseNode {...props} icon={<SlidersHorizontal size={16} />} iconColor="text-emerald-400">
{data.expressionName ? (
<span className="font-mono">{data.expressionName}</span>
) : (
'Select expression'
)}
</BaseNode>
);
}
export const DesignVarNode = memo(DesignVarNodeComponent);
ExtractorNode.tsx:
import { memo } from 'react';
import { NodeProps } from 'reactflow';
import { FlaskConical } from 'lucide-react';
import { BaseNode } from './BaseNode';
import { ExtractorNodeData } from '../../../lib/canvas/schema';
function ExtractorNodeComponent(props: NodeProps<ExtractorNodeData>) {
const { data } = props;
return (
<BaseNode {...props} icon={<FlaskConical size={16} />} iconColor="text-cyan-400">
{data.extractorName || 'Select extractor'}
</BaseNode>
);
}
export const ExtractorNode = memo(ExtractorNodeComponent);
ObjectiveNode.tsx:
import { memo } from 'react';
import { NodeProps } from 'reactflow';
import { Target } from 'lucide-react';
import { BaseNode } from './BaseNode';
import { ObjectiveNodeData } from '../../../lib/canvas/schema';
function ObjectiveNodeComponent(props: NodeProps<ObjectiveNodeData>) {
const { data } = props;
return (
<BaseNode {...props} icon={<Target size={16} />} iconColor="text-rose-400">
{data.name ? `${data.direction === 'maximize' ? '↑' : '↓'} ${data.name}` : 'Set objective'}
</BaseNode>
);
}
export const ObjectiveNode = memo(ObjectiveNodeComponent);
ConstraintNode.tsx:
import { memo } from 'react';
import { NodeProps } from 'reactflow';
import { ShieldAlert } from 'lucide-react';
import { BaseNode } from './BaseNode';
import { ConstraintNodeData } from '../../../lib/canvas/schema';
function ConstraintNodeComponent(props: NodeProps<ConstraintNodeData>) {
const { data } = props;
return (
<BaseNode {...props} icon={<ShieldAlert size={16} />} iconColor="text-amber-400">
{data.name && data.operator && data.value !== undefined
? `${data.name} ${data.operator} ${data.value}`
: 'Set constraint'}
</BaseNode>
);
}
export const ConstraintNode = memo(ConstraintNodeComponent);
AlgorithmNode.tsx:
import { memo } from 'react';
import { NodeProps } from 'reactflow';
import { BrainCircuit } from 'lucide-react';
import { BaseNode } from './BaseNode';
import { AlgorithmNodeData } from '../../../lib/canvas/schema';
function AlgorithmNodeComponent(props: NodeProps<AlgorithmNodeData>) {
const { data } = props;
return (
<BaseNode {...props} icon={<BrainCircuit size={16} />} iconColor="text-indigo-400">
{data.method ? `${data.method} (${data.maxTrials || 100} trials)` : 'Select method'}
</BaseNode>
);
}
export const AlgorithmNode = memo(AlgorithmNodeComponent);
SurrogateNode.tsx:
import { memo } from 'react';
import { NodeProps } from 'reactflow';
import { Rocket } from 'lucide-react';
import { BaseNode } from './BaseNode';
import { SurrogateNodeData } from '../../../lib/canvas/schema';
function SurrogateNodeComponent(props: NodeProps<SurrogateNodeData>) {
const { data } = props;
return (
<BaseNode {...props} icon={<Rocket size={16} />} iconColor="text-pink-400" outputs={0}>
{data.enabled ? (data.modelType || 'Auto') : 'Disabled'}
</BaseNode>
);
}
export const SurrogateNode = memo(SurrogateNodeComponent);
T1.4 - Update templates.ts with Lucide Icons
File: atomizer-dashboard/frontend/src/lib/canvas/templates.ts
Replace emoji icons with Lucide icon names (strings for serialization):
export interface CanvasTemplate {
id: string;
name: string;
description: string;
category: 'structural' | 'thermal' | 'optical' | 'general';
icon: string; // Lucide icon name
complexity: 'simple' | 'medium' | 'advanced';
nodes: number;
intent: Partial<OptimizationIntent>;
}
export const CANVAS_TEMPLATES: CanvasTemplate[] = [
{
id: 'mass-minimization',
name: 'Mass Minimization',
description: 'Single-objective mass reduction with stress constraint',
category: 'structural',
icon: 'Scale',
complexity: 'simple',
nodes: 6,
intent: {
// ... keep existing intent
},
},
{
id: 'multi-objective',
name: 'Multi-Objective',
description: 'Pareto optimization with mass and displacement',
category: 'structural',
icon: 'GitBranch',
complexity: 'medium',
nodes: 7,
intent: {
// ... keep existing intent
},
},
{
id: 'turbo-optimization',
name: 'Turbo Mode',
description: 'Neural-accelerated optimization with surrogate',
category: 'general',
icon: 'Zap',
complexity: 'advanced',
nodes: 8,
intent: {
// ... keep existing intent
},
},
{
id: 'mirror-zernike',
name: 'Mirror WFE',
description: 'Zernike wavefront error optimization for optics',
category: 'optical',
icon: 'CircleDot',
complexity: 'advanced',
nodes: 7,
intent: {
// ... keep existing intent
},
},
{
id: 'frequency-optimization',
name: 'Frequency Target',
description: 'Natural frequency optimization with modal analysis',
category: 'structural',
icon: 'Activity',
complexity: 'medium',
nodes: 6,
intent: {
// ... keep existing intent
},
},
];
PHASE 2: Responsive Full-Screen Canvas
Make the canvas properly responsive and full-screen.
T2.1 - Update AtomizerCanvas.tsx for Full Screen
File: atomizer-dashboard/frontend/src/components/canvas/AtomizerCanvas.tsx
import { useCallback, useRef, DragEvent, useState, useEffect } from 'react';
import ReactFlow, {
Background,
Controls,
MiniMap,
ReactFlowProvider,
ReactFlowInstance,
BackgroundVariant,
} from 'reactflow';
import 'reactflow/dist/style.css';
import { nodeTypes } from './nodes';
import { NodePalette } from './palette/NodePalette';
import { NodeConfigPanel } from './panels/NodeConfigPanel';
import { ValidationPanel } from './panels/ValidationPanel';
import { ChatPanel } from './panels/ChatPanel';
import { useCanvasStore } from '../../hooks/useCanvasStore';
import { useCanvasChat } from '../../hooks/useCanvasChat';
import { NodeType } from '../../lib/canvas/schema';
import {
CheckCircle,
Wand2,
Play,
MessageSquare,
X,
Loader2,
} from 'lucide-react';
function CanvasFlow() {
const reactFlowWrapper = useRef<HTMLDivElement>(null);
const reactFlowInstance = useRef<ReactFlowInstance | null>(null);
const [showChat, setShowChat] = useState(false);
const [showValidation, setShowValidation] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
const {
nodes,
edges,
selectedNode,
onNodesChange,
onEdgesChange,
onConnect,
addNode,
selectNode,
validation,
validate,
toIntent,
clear,
} = useCanvasStore();
const { processWithClaude, isConnected, isThinking } = useCanvasChat();
const onDragOver = useCallback((event: DragEvent) => {
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
}, []);
const onDrop = useCallback(
(event: DragEvent) => {
event.preventDefault();
const type = event.dataTransfer.getData('application/reactflow') as NodeType;
if (!type || !reactFlowInstance.current || !reactFlowWrapper.current) return;
const bounds = reactFlowWrapper.current.getBoundingClientRect();
const position = reactFlowInstance.current.screenToFlowPosition({
x: event.clientX - bounds.left,
y: event.clientY - bounds.top,
});
addNode(type, position);
},
[addNode]
);
const onNodeClick = useCallback(
(_: React.MouseEvent, node: { id: string }) => {
selectNode(node.id);
setShowChat(false);
},
[selectNode]
);
const onPaneClick = useCallback(() => {
selectNode(null);
}, [selectNode]);
const handleValidate = useCallback(() => {
const result = validate();
setShowValidation(true);
setTimeout(() => setShowValidation(false), 5000);
return result;
}, [validate]);
const handleProcess = useCallback(async () => {
const result = validate();
if (!result.valid) {
setShowValidation(true);
return;
}
setIsProcessing(true);
setShowChat(true);
try {
const intent = toIntent();
await processWithClaude(intent);
} finally {
setIsProcessing(false);
}
}, [validate, toIntent, processWithClaude]);
return (
<div className="flex h-full w-full bg-dark-900">
{/* Left: Node Palette */}
<NodePalette />
{/* Center: Canvas - Takes remaining space */}
<div className="flex-1 flex flex-col min-w-0">
{/* Canvas Area */}
<div className="flex-1 relative" ref={reactFlowWrapper}>
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
onInit={(instance) => { reactFlowInstance.current = instance; }}
onDragOver={onDragOver}
onDrop={onDrop}
onNodeClick={onNodeClick}
onPaneClick={onPaneClick}
nodeTypes={nodeTypes}
fitView
fitViewOptions={{ padding: 0.2 }}
minZoom={0.1}
maxZoom={2}
className="bg-dark-900"
proOptions={{ hideAttribution: true }}
>
<Background
variant={BackgroundVariant.Dots}
gap={20}
size={1}
color="#1e293b"
/>
<Controls
className="!bg-dark-800 !border-dark-700 !rounded-lg !shadow-lg
[&>button]:!bg-dark-700 [&>button]:!border-dark-600
[&>button]:!text-dark-300 [&>button:hover]:!bg-dark-600
[&>button:hover]:!text-white"
showInteractive={false}
/>
<MiniMap
className="!bg-dark-800/90 !border-dark-700 !rounded-lg"
nodeColor={(n) => {
if (n.selected) return '#00d4e6';
return '#334155';
}}
maskColor="rgba(5, 10, 18, 0.85)"
pannable
zoomable
/>
</ReactFlow>
{/* Floating Action Buttons */}
<div className="absolute bottom-4 right-4 flex items-center gap-2">
<button
onClick={() => setShowChat(!showChat)}
className={`p-2.5 rounded-lg transition-all ${
showChat
? 'bg-primary-500 text-white'
: 'bg-dark-800 text-dark-300 hover:bg-dark-700 hover:text-white'
} border border-dark-600`}
title="Toggle Chat"
>
<MessageSquare size={18} />
</button>
<div className="w-px h-8 bg-dark-700" />
<button
onClick={handleValidate}
className="flex items-center gap-2 px-3 py-2 bg-dark-800 text-dark-200
rounded-lg hover:bg-dark-700 hover:text-white transition-all
border border-dark-600"
>
<CheckCircle size={16} />
<span className="text-sm font-medium">Validate</span>
</button>
<button
onClick={handleProcess}
disabled={isProcessing || nodes.length === 0}
className={`flex items-center gap-2 px-4 py-2 rounded-lg transition-all
border font-medium text-sm ${
isProcessing
? 'bg-primary-600 text-white border-primary-500'
: nodes.length === 0
? 'bg-dark-800 text-dark-500 border-dark-700 cursor-not-allowed'
: 'bg-primary-500 text-white border-primary-400 hover:bg-primary-600'
}`}
>
{isProcessing ? (
<>
<Loader2 size={16} className="animate-spin" />
<span>Processing...</span>
</>
) : (
<>
<Wand2 size={16} />
<span>Process with Claude</span>
</>
)}
</button>
</div>
{/* Validation Toast */}
{showValidation && (
<div className="absolute top-4 left-1/2 -translate-x-1/2 z-50">
<ValidationPanel
validation={validation}
onClose={() => setShowValidation(false)}
/>
</div>
)}
</div>
</div>
{/* Right: Config Panel or Chat */}
{(selectedNode || showChat) && (
<div className="w-80 border-l border-dark-700 bg-dark-850 flex flex-col">
{selectedNode && !showChat ? (
<NodeConfigPanel nodeId={selectedNode} />
) : (
<ChatPanel onClose={() => setShowChat(false)} />
)}
</div>
)}
</div>
);
}
export function AtomizerCanvas() {
return (
<ReactFlowProvider>
<CanvasFlow />
</ReactFlowProvider>
);
}
T2.2 - Update CanvasView.tsx for Full Screen Layout
File: atomizer-dashboard/frontend/src/pages/CanvasView.tsx
import { useState } from 'react';
import { AtomizerCanvas } from '../components/canvas/AtomizerCanvas';
import { TemplateSelector } from '../components/canvas/panels/TemplateSelector';
import { ConfigImporter } from '../components/canvas/panels/ConfigImporter';
import { useCanvasStore } from '../hooks/useCanvasStore';
import {
LayoutTemplate,
FileInput,
Trash2,
FolderOpen,
} from 'lucide-react';
export function CanvasView() {
const [showTemplates, setShowTemplates] = useState(false);
const [showImporter, setShowImporter] = useState(false);
const { clear, nodes } = useCanvasStore();
return (
<div className="h-screen flex flex-col bg-dark-900">
{/* Compact Header */}
<header className="flex-shrink-0 h-12 bg-dark-850 border-b border-dark-700 px-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<h1 className="text-sm font-semibold text-white">Canvas Builder</h1>
<span className="text-xs text-dark-500">
{nodes.length} node{nodes.length !== 1 ? 's' : ''}
</span>
</div>
<div className="flex items-center gap-1.5">
<button
onClick={() => setShowTemplates(true)}
className="flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium
text-dark-300 hover:text-white hover:bg-dark-700 rounded transition-colors"
>
<LayoutTemplate size={14} />
Templates
</button>
<button
onClick={() => setShowImporter(true)}
className="flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium
text-dark-300 hover:text-white hover:bg-dark-700 rounded transition-colors"
>
<FileInput size={14} />
Import
</button>
<div className="w-px h-4 bg-dark-700 mx-1" />
<button
onClick={clear}
disabled={nodes.length === 0}
className="flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium
text-dark-400 hover:text-red-400 hover:bg-red-500/10 rounded
transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<Trash2 size={14} />
Clear
</button>
</div>
</header>
{/* Canvas - Takes all remaining space */}
<main className="flex-1 min-h-0">
<AtomizerCanvas />
</main>
{/* Modals */}
{showTemplates && (
<TemplateSelector onClose={() => setShowTemplates(false)} />
)}
{showImporter && (
<ConfigImporter onClose={() => setShowImporter(false)} />
)}
</div>
);
}
PHASE 3: Auto-Load from Optimization Config
When a study is loaded, auto-populate the canvas from its optimization_config.json.
T3.1 - Add Backend Endpoint for Study Config
File: atomizer-dashboard/backend/api/routes/studies.py
Add this endpoint (find the router and add):
@router.get("/{study_path:path}/config")
async def get_study_config(study_path: str):
"""Load optimization_config.json from a study directory."""
import json
from pathlib import Path
# Resolve study path
studies_root = Path(__file__).parent.parent.parent.parent.parent / "studies"
config_path = studies_root / study_path / "optimization_config.json"
if not config_path.exists():
# Try with 1_config subdirectory
config_path = studies_root / study_path / "1_config" / "optimization_config.json"
if not config_path.exists():
raise HTTPException(status_code=404, detail=f"Config not found for study: {study_path}")
try:
with open(config_path, 'r') as f:
config = json.load(f)
return config
except json.JSONDecodeError as e:
raise HTTPException(status_code=400, detail=f"Invalid JSON: {str(e)}")
T3.2 - Add Backend Endpoint to List Studies
@router.get("/")
async def list_studies():
"""List all available studies with their status."""
from pathlib import Path
import json
studies_root = Path(__file__).parent.parent.parent.parent.parent / "studies"
studies = []
for category in studies_root.iterdir():
if not category.is_dir() or category.name.startswith('.'):
continue
for study_dir in category.iterdir():
if not study_dir.is_dir():
continue
# Find config
config_path = study_dir / "optimization_config.json"
if not config_path.exists():
config_path = study_dir / "1_config" / "optimization_config.json"
# Find database
db_path = study_dir / "3_results" / "study.db"
if not db_path.exists():
db_path = study_dir / "2_results" / "study.db"
trial_count = 0
if db_path.exists():
try:
import sqlite3
conn = sqlite3.connect(str(db_path))
cursor = conn.execute("SELECT COUNT(*) FROM trials WHERE state = 'COMPLETE'")
trial_count = cursor.fetchone()[0]
conn.close()
except:
pass
studies.append({
"path": f"{category.name}/{study_dir.name}",
"name": study_dir.name,
"category": category.name,
"has_config": config_path.exists(),
"trial_count": trial_count,
})
return {"studies": studies}
T3.3 - Update useCanvasStore to Load from Config
File: atomizer-dashboard/frontend/src/hooks/useCanvasStore.ts
Add/update the loadFromConfig function:
loadFromConfig: (config: OptimizationConfig) => {
const nodes: Node<CanvasNodeData>[] = [];
const edges: Edge[] = [];
let nodeId = 0;
const getId = () => `node_${++nodeId}`;
// Layout positions
const colX = [50, 250, 450, 650, 850];
let row = 0;
const getY = () => 50 + (row++) * 120;
// Model node
if (config.nx_model?.prt_path || config.nx_model?.sim_path) {
const modelId = getId();
nodes.push({
id: modelId,
type: 'model',
position: { x: colX[0], y: 100 },
data: {
type: 'model',
label: 'Model',
configured: true,
filePath: config.nx_model.sim_path || config.nx_model.prt_path,
fileType: config.nx_model.sim_path ? 'sim' : 'prt',
},
});
}
// Solver node
if (config.solver) {
const solverId = getId();
nodes.push({
id: solverId,
type: 'solver',
position: { x: colX[1], y: 100 },
data: {
type: 'solver',
label: 'Solver',
configured: true,
solverType: `SOL${config.solver.solution_type}`,
},
});
}
// Design variables
row = 0;
config.design_variables?.forEach((dv, i) => {
const dvId = getId();
nodes.push({
id: dvId,
type: 'designVar',
position: { x: colX[0], y: 250 + i * 100 },
data: {
type: 'designVar',
label: dv.name,
configured: true,
expressionName: dv.nx_expression || dv.name,
minValue: dv.lower_bound,
maxValue: dv.upper_bound,
unit: dv.unit,
},
});
});
// Extractors from objectives
const extractorIds: Record<string, string> = {};
config.objectives?.forEach((obj, i) => {
if (!extractorIds[obj.extractor_id]) {
const extId = getId();
extractorIds[obj.extractor_id] = extId;
nodes.push({
id: extId,
type: 'extractor',
position: { x: colX[2], y: 100 + i * 100 },
data: {
type: 'extractor',
label: obj.extractor_id,
configured: true,
extractorId: obj.extractor_id,
extractorName: obj.name,
},
});
}
});
// Objectives
config.objectives?.forEach((obj, i) => {
const objId = getId();
nodes.push({
id: objId,
type: 'objective',
position: { x: colX[3], y: 100 + i * 100 },
data: {
type: 'objective',
label: obj.name,
configured: true,
name: obj.name,
direction: obj.direction,
weight: obj.weight,
},
});
// Connect extractor to objective
if (extractorIds[obj.extractor_id]) {
edges.push({
id: `e_${extractorIds[obj.extractor_id]}_${objId}`,
source: extractorIds[obj.extractor_id],
target: objId,
});
}
});
// Constraints
config.constraints?.forEach((con, i) => {
const conId = getId();
nodes.push({
id: conId,
type: 'constraint',
position: { x: colX[3], y: 300 + i * 100 },
data: {
type: 'constraint',
label: con.name,
configured: true,
name: con.name,
operator: con.type === 'upper' ? '<=' : '>=',
value: con.upper_bound ?? con.lower_bound,
},
});
});
// Algorithm
if (config.optimization) {
const algoId = getId();
nodes.push({
id: algoId,
type: 'algorithm',
position: { x: colX[4], y: 150 },
data: {
type: 'algorithm',
label: 'Algorithm',
configured: true,
method: config.optimization.sampler || 'TPE',
maxTrials: config.optimization.n_trials || 100,
},
});
}
// Surrogate
if (config.surrogate?.enabled) {
const surId = getId();
nodes.push({
id: surId,
type: 'surrogate',
position: { x: colX[4], y: 300 },
data: {
type: 'surrogate',
label: 'Surrogate',
configured: true,
enabled: true,
modelType: config.surrogate.type || 'MLP',
minTrials: config.surrogate.min_trials || 20,
},
});
}
set({
nodes,
edges,
selectedNode: null,
validation: { valid: false, errors: [], warnings: [] },
});
nodeIdCounter = nodeId;
},
T3.4 - Update ConfigImporter with Study Browser
File: atomizer-dashboard/frontend/src/components/canvas/panels/ConfigImporter.tsx
import { useState, useEffect } from 'react';
import { useCanvasStore } from '../../../hooks/useCanvasStore';
import {
X,
Upload,
FileCode,
FolderOpen,
Search,
Check,
Loader2,
AlertCircle,
} from 'lucide-react';
interface Study {
path: string;
name: string;
category: string;
has_config: boolean;
trial_count: number;
}
interface ConfigImporterProps {
onClose: () => void;
}
export function ConfigImporter({ onClose }: ConfigImporterProps) {
const [activeTab, setActiveTab] = useState<'file' | 'paste' | 'study'>('study');
const [jsonInput, setJsonInput] = useState('');
const [studies, setStudies] = useState<Study[]>([]);
const [searchQuery, setSearchQuery] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selectedStudy, setSelectedStudy] = useState<string | null>(null);
const { loadFromConfig } = useCanvasStore();
// Load studies on mount
useEffect(() => {
loadStudies();
}, []);
const loadStudies = async () => {
setLoading(true);
setError(null);
try {
const res = await fetch('/api/studies/');
if (!res.ok) throw new Error('Failed to load studies');
const data = await res.json();
setStudies(data.studies || []);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load studies');
} finally {
setLoading(false);
}
};
const loadStudyConfig = async (studyPath: string) => {
setLoading(true);
setError(null);
try {
const res = await fetch(`/api/studies/${encodeURIComponent(studyPath)}/config`);
if (!res.ok) throw new Error('Config not found');
const config = await res.json();
loadFromConfig(config);
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load config');
} finally {
setLoading(false);
}
};
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (event) => {
try {
const config = JSON.parse(event.target?.result as string);
loadFromConfig(config);
onClose();
} catch {
setError('Invalid JSON file');
}
};
reader.readAsText(file);
};
const handlePasteImport = () => {
try {
const config = JSON.parse(jsonInput);
loadFromConfig(config);
onClose();
} catch {
setError('Invalid JSON');
}
};
const filteredStudies = studies.filter(s =>
s.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
s.category.toLowerCase().includes(searchQuery.toLowerCase())
);
const tabs = [
{ id: 'study', label: 'Load Study', icon: FolderOpen },
{ id: 'file', label: 'Upload File', icon: Upload },
{ id: 'paste', label: 'Paste JSON', icon: FileCode },
] as const;
return (
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50">
<div className="bg-dark-850 border border-dark-700 rounded-xl shadow-2xl w-full max-w-2xl max-h-[80vh] flex flex-col">
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-dark-700">
<h2 className="text-lg font-semibold text-white">Import Configuration</h2>
<button onClick={onClose} className="text-dark-400 hover:text-white transition-colors">
<X size={20} />
</button>
</div>
{/* Tabs */}
<div className="flex border-b border-dark-700">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => { setActiveTab(tab.id); setError(null); }}
className={`flex items-center gap-2 px-4 py-3 text-sm font-medium transition-colors border-b-2 -mb-px ${
activeTab === tab.id
? 'text-primary-400 border-primary-400'
: 'text-dark-400 border-transparent hover:text-white'
}`}
>
<tab.icon size={16} />
{tab.label}
</button>
))}
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-5">
{error && (
<div className="mb-4 flex items-center gap-2 px-3 py-2 bg-red-500/10 border border-red-500/30 rounded-lg text-red-400 text-sm">
<AlertCircle size={16} />
{error}
</div>
)}
{activeTab === 'study' && (
<div className="space-y-4">
{/* Search */}
<div className="relative">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-dark-500" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search studies..."
className="w-full pl-9 pr-3 py-2 bg-dark-800 border border-dark-600 rounded-lg
text-white placeholder-dark-500 text-sm
focus:border-primary-500 focus:ring-1 focus:ring-primary-500"
/>
</div>
{/* Study List */}
<div className="space-y-1.5 max-h-[400px] overflow-y-auto">
{loading ? (
<div className="flex items-center justify-center py-8 text-dark-400">
<Loader2 size={20} className="animate-spin mr-2" />
Loading studies...
</div>
) : filteredStudies.length === 0 ? (
<div className="text-center py-8 text-dark-500">
No studies found
</div>
) : (
filteredStudies.map((study) => (
<button
key={study.path}
onClick={() => setSelectedStudy(study.path)}
disabled={!study.has_config}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-left transition-colors ${
selectedStudy === study.path
? 'bg-primary-500/20 border border-primary-500/50'
: study.has_config
? 'bg-dark-800/50 border border-dark-700/50 hover:bg-dark-800 hover:border-dark-600'
: 'bg-dark-800/30 border border-dark-700/30 opacity-50 cursor-not-allowed'
}`}
>
<FolderOpen size={16} className={selectedStudy === study.path ? 'text-primary-400' : 'text-dark-400'} />
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-white truncate">{study.name}</div>
<div className="text-xs text-dark-500">{study.category} • {study.trial_count} trials</div>
</div>
{selectedStudy === study.path && <Check size={16} className="text-primary-400" />}
</button>
))
)}
</div>
</div>
)}
{activeTab === 'file' && (
<div className="flex flex-col items-center justify-center py-12">
<label className="flex flex-col items-center gap-3 px-8 py-6 border-2 border-dashed border-dark-600 rounded-xl cursor-pointer hover:border-primary-500/50 transition-colors">
<Upload size={32} className="text-dark-400" />
<div className="text-sm text-dark-300">Click to upload optimization_config.json</div>
<input type="file" accept=".json" onChange={handleFileUpload} className="hidden" />
</label>
</div>
)}
{activeTab === 'paste' && (
<div className="space-y-4">
<textarea
value={jsonInput}
onChange={(e) => setJsonInput(e.target.value)}
placeholder="Paste optimization_config.json content here..."
className="w-full h-64 px-3 py-2 bg-dark-800 border border-dark-600 rounded-lg
text-white font-mono text-sm placeholder-dark-500
focus:border-primary-500 focus:ring-1 focus:ring-primary-500 resize-none"
/>
</div>
)}
</div>
{/* Footer */}
<div className="flex justify-end gap-2 px-5 py-4 border-t border-dark-700">
<button
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-dark-300 hover:text-white transition-colors"
>
Cancel
</button>
{activeTab === 'study' && (
<button
onClick={() => selectedStudy && loadStudyConfig(selectedStudy)}
disabled={!selectedStudy || loading}
className="flex items-center gap-2 px-4 py-2 bg-primary-500 text-white rounded-lg
text-sm font-medium hover:bg-primary-600 transition-colors
disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? <Loader2 size={16} className="animate-spin" /> : <Check size={16} />}
Load Study
</button>
)}
{activeTab === 'paste' && (
<button
onClick={handlePasteImport}
disabled={!jsonInput.trim()}
className="flex items-center gap-2 px-4 py-2 bg-primary-500 text-white rounded-lg
text-sm font-medium hover:bg-primary-600 transition-colors
disabled:opacity-50 disabled:cursor-not-allowed"
>
<Check size={16} />
Import
</button>
)}
</div>
</div>
</div>
);
}
PHASE 4: NX Model Introspection
Add ability to introspect .sim files to extract expressions and model info.
T4.1 - Add Backend NX Introspection Endpoint
File: atomizer-dashboard/backend/api/routes/nx.py (create new file)
"""NX Model Introspection Endpoints."""
from fastapi import APIRouter, HTTPException
from pathlib import Path
import subprocess
import json
import os
router = APIRouter(prefix="/api/nx", tags=["nx"])
PYTHON_PATH = r"C:\Users\antoi\anaconda3\envs\atomizer\python.exe"
ATOMIZER_ROOT = Path(__file__).parent.parent.parent.parent.parent
@router.post("/introspect")
async def introspect_model(file_path: str):
"""
Introspect an NX model file to extract expressions and configuration.
Works with .prt, .sim, .fem files.
"""
# Resolve path
path = Path(file_path)
if not path.is_absolute():
path = ATOMIZER_ROOT / file_path
if not path.exists():
raise HTTPException(status_code=404, detail=f"File not found: {file_path}")
# Run introspection script
script = ATOMIZER_ROOT / "optimization_engine" / "nx" / "introspect_model.py"
if not script.exists():
# Return mock data if script doesn't exist yet
return {
"file_path": str(path),
"file_type": path.suffix.lower().replace('.', ''),
"expressions": [],
"solver_type": None,
"linked_files": [],
"error": "Introspection script not found - returning empty data"
}
try:
result = subprocess.run(
[PYTHON_PATH, str(script), str(path)],
capture_output=True,
text=True,
timeout=60,
cwd=str(ATOMIZER_ROOT)
)
if result.returncode != 0:
raise HTTPException(status_code=500, detail=f"Introspection failed: {result.stderr}")
return json.loads(result.stdout)
except subprocess.TimeoutExpired:
raise HTTPException(status_code=504, detail="Introspection timed out")
except json.JSONDecodeError:
raise HTTPException(status_code=500, detail="Invalid introspection output")
@router.get("/expressions")
async def get_expressions(file_path: str):
"""Get just the expressions from an NX model."""
result = await introspect_model(file_path)
return {
"file_path": file_path,
"expressions": result.get("expressions", [])
}
T4.2 - Add Route to Main App
File: atomizer-dashboard/backend/main.py
Add import and include router:
from api.routes import nx
app.include_router(nx.router)
T4.3 - Create Introspection Script
File: optimization_engine/nx/introspect_model.py
#!/usr/bin/env python
"""
NX Model Introspection Script.
Extracts expressions, solver info, and linked files from NX models.
Usage: python introspect_model.py <path_to_model>
"""
import sys
import json
from pathlib import Path
def introspect_prt(path: Path) -> dict:
"""Introspect a .prt file for expressions."""
# Try to use NXOpen if available
try:
import NXOpen
# ... NXOpen introspection code
except ImportError:
pass
# Fallback: Parse any associated expression files
expressions = []
# Look for expression export files
exp_file = path.with_suffix('.exp')
if exp_file.exists():
with open(exp_file, 'r') as f:
for line in f:
if '=' in line and not line.strip().startswith('//'):
name = line.split('=')[0].strip()
expressions.append({
"name": name,
"type": "unknown",
"value": None,
"unit": None
})
return {
"file_path": str(path),
"file_type": "prt",
"expressions": expressions,
"solver_type": None,
"linked_files": []
}
def introspect_sim(path: Path) -> dict:
"""Introspect a .sim file for solver and linked files."""
result = {
"file_path": str(path),
"file_type": "sim",
"expressions": [],
"solver_type": None,
"linked_files": []
}
# Find linked .fem file
fem_path = path.with_suffix('.fem')
if not fem_path.exists():
# Try common naming patterns
for suffix in ['_fem1.fem', '_fem.fem']:
candidate = path.parent / (path.stem.replace('_sim1', '').replace('_sim', '') + suffix)
if candidate.exists():
fem_path = candidate
break
if fem_path.exists():
result["linked_files"].append({
"type": "fem",
"path": str(fem_path)
})
# Find linked .prt file
prt_path = path.parent / (path.stem.replace('_sim1', '').replace('_sim', '') + '.prt')
if prt_path.exists():
result["linked_files"].append({
"type": "prt",
"path": str(prt_path)
})
# Get expressions from prt
prt_result = introspect_prt(prt_path)
result["expressions"] = prt_result["expressions"]
# Try to detect solver type from sim file
try:
with open(path, 'rb') as f:
content = f.read(10000).decode('utf-8', errors='ignore')
if 'SOL 101' in content or 'SESTATIC' in content:
result["solver_type"] = "SOL101"
elif 'SOL 103' in content or 'SEMODES' in content:
result["solver_type"] = "SOL103"
elif 'SOL 105' in content:
result["solver_type"] = "SOL105"
elif 'SOL 106' in content:
result["solver_type"] = "SOL106"
elif 'SOL 111' in content:
result["solver_type"] = "SOL111"
elif 'SOL 112' in content:
result["solver_type"] = "SOL112"
except:
pass
return result
def introspect_fem(path: Path) -> dict:
"""Introspect a .fem file."""
result = {
"file_path": str(path),
"file_type": "fem",
"expressions": [],
"solver_type": None,
"linked_files": []
}
# Find linked files
prt_path = path.parent / (path.stem.replace('_fem1', '').replace('_fem', '') + '.prt')
if prt_path.exists():
result["linked_files"].append({"type": "prt", "path": str(prt_path)})
prt_result = introspect_prt(prt_path)
result["expressions"] = prt_result["expressions"]
return result
def main():
if len(sys.argv) < 2:
print(json.dumps({"error": "No file path provided"}))
sys.exit(1)
path = Path(sys.argv[1])
if not path.exists():
print(json.dumps({"error": f"File not found: {path}"}))
sys.exit(1)
suffix = path.suffix.lower()
if suffix == '.prt':
result = introspect_prt(path)
elif suffix == '.sim':
result = introspect_sim(path)
elif suffix == '.fem':
result = introspect_fem(path)
else:
result = {
"file_path": str(path),
"file_type": suffix.replace('.', ''),
"expressions": [],
"solver_type": None,
"linked_files": [],
"error": f"Unsupported file type: {suffix}"
}
print(json.dumps(result, indent=2))
if __name__ == "__main__":
main()
PHASE 5: Expression Search/Dropdown in NodeConfigPanel
Add expression dropdown with search for design variables.
T5.1 - Update NodeConfigPanel with Expression Search
File: atomizer-dashboard/frontend/src/components/canvas/panels/NodeConfigPanel.tsx
Add this component and update the design variable section:
import { useState, useEffect, useMemo } from 'react';
import { useCanvasStore } from '../../../hooks/useCanvasStore';
import { CanvasNodeData } from '../../../lib/canvas/schema';
import {
Trash2,
Check,
Search,
Loader2,
ChevronDown,
RefreshCw,
X,
} from 'lucide-react';
// Expression search dropdown component
function ExpressionSelector({
value,
onChange,
modelPath,
}: {
value: string;
onChange: (expr: string) => void;
modelPath?: string;
}) {
const [isOpen, setIsOpen] = useState(false);
const [search, setSearch] = useState('');
const [expressions, setExpressions] = useState<Array<{ name: string; value?: number; unit?: string }>>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const loadExpressions = async () => {
if (!modelPath) {
setError('No model selected');
return;
}
setLoading(true);
setError(null);
try {
const res = await fetch(`/api/nx/expressions?file_path=${encodeURIComponent(modelPath)}`);
if (!res.ok) throw new Error('Failed to load expressions');
const data = await res.json();
setExpressions(data.expressions || []);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load');
// Add some mock expressions for testing
setExpressions([
{ name: 'thickness', value: 10, unit: 'mm' },
{ name: 'width', value: 50, unit: 'mm' },
{ name: 'height', value: 100, unit: 'mm' },
{ name: 'radius', value: 25, unit: 'mm' },
{ name: 'angle', value: 45, unit: 'deg' },
]);
} finally {
setLoading(false);
}
};
useEffect(() => {
if (isOpen && expressions.length === 0 && !loading) {
loadExpressions();
}
}, [isOpen]);
const filtered = useMemo(() => {
if (!search) return expressions;
const q = search.toLowerCase();
return expressions.filter(e => e.name.toLowerCase().includes(q));
}, [expressions, search]);
return (
<div className="relative">
<button
type="button"
onClick={() => setIsOpen(!isOpen)}
className="w-full flex items-center justify-between px-3 py-2 bg-dark-800 border border-dark-600 rounded-lg
text-left text-sm text-white hover:border-dark-500 transition-colors"
>
<span className={value ? 'font-mono' : 'text-dark-500'}>
{value || 'Select expression...'}
</span>
<ChevronDown size={16} className={`text-dark-400 transition-transform ${isOpen ? 'rotate-180' : ''}`} />
</button>
{isOpen && (
<div className="absolute top-full left-0 right-0 mt-1 bg-dark-800 border border-dark-600 rounded-lg shadow-xl z-50 overflow-hidden">
{/* Search */}
<div className="p-2 border-b border-dark-700">
<div className="relative">
<Search size={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-dark-500" />
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search expressions..."
className="w-full pl-8 pr-8 py-1.5 bg-dark-700 border border-dark-600 rounded text-sm text-white placeholder-dark-500 focus:border-primary-500"
autoFocus
/>
<button
onClick={loadExpressions}
className="absolute right-2 top-1/2 -translate-y-1/2 text-dark-400 hover:text-white"
title="Refresh"
>
<RefreshCw size={14} className={loading ? 'animate-spin' : ''} />
</button>
</div>
</div>
{/* List */}
<div className="max-h-48 overflow-y-auto">
{loading ? (
<div className="flex items-center justify-center py-4 text-dark-400">
<Loader2 size={16} className="animate-spin mr-2" />
Loading...
</div>
) : filtered.length === 0 ? (
<div className="py-4 text-center text-dark-500 text-sm">
{expressions.length === 0 ? 'No expressions found' : 'No matches'}
</div>
) : (
filtered.map((expr) => (
<button
key={expr.name}
type="button"
onClick={() => {
onChange(expr.name);
setIsOpen(false);
setSearch('');
}}
className={`w-full flex items-center justify-between px-3 py-2 text-sm hover:bg-dark-700 transition-colors ${
value === expr.name ? 'bg-primary-500/20 text-primary-400' : 'text-white'
}`}
>
<span className="font-mono">{expr.name}</span>
{expr.value !== undefined && (
<span className="text-dark-500 text-xs">
{expr.value} {expr.unit || ''}
</span>
)}
</button>
))
)}
</div>
{/* Manual input option */}
<div className="p-2 border-t border-dark-700">
<button
type="button"
onClick={() => {
const name = prompt('Enter expression name:');
if (name) {
onChange(name);
setIsOpen(false);
}
}}
className="w-full text-xs text-dark-400 hover:text-white transition-colors"
>
+ Enter manually
</button>
</div>
</div>
)}
</div>
);
}
interface NodeConfigPanelProps {
nodeId: string;
}
export function NodeConfigPanel({ nodeId }: NodeConfigPanelProps) {
const { nodes, updateNodeData, deleteSelected } = useCanvasStore();
const node = nodes.find((n) => n.id === nodeId);
if (!node) return null;
const { data } = node;
// Find model node to get file path for expression lookup
const modelNode = nodes.find(n => n.data.type === 'model');
const modelPath = modelNode?.data.type === 'model' ? (modelNode.data as any).filePath : undefined;
const handleChange = (field: string, value: unknown) => {
updateNodeData(nodeId, { [field]: value, configured: true });
};
const inputClass = "w-full px-3 py-2 bg-dark-800 border border-dark-600 rounded-lg text-white text-sm placeholder-dark-500 focus:border-primary-500 focus:ring-1 focus:ring-primary-500 transition-colors";
const selectClass = "w-full px-3 py-2 bg-dark-800 border border-dark-600 rounded-lg text-white text-sm focus:border-primary-500 transition-colors";
const labelClass = "block text-xs font-medium text-dark-400 mb-1.5";
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="flex-shrink-0 px-4 py-3 border-b border-dark-700">
<div className="flex items-center justify-between">
<div>
<h3 className="font-medium text-white text-sm">Configure Node</h3>
<p className="text-xs text-dark-500 capitalize">{data.type}</p>
</div>
<button
onClick={deleteSelected}
className="p-1.5 text-dark-400 hover:text-red-400 hover:bg-red-500/10 rounded transition-colors"
title="Delete node"
>
<Trash2 size={16} />
</button>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{/* Label - common to all */}
<div>
<label className={labelClass}>Label</label>
<input
type="text"
value={data.label}
onChange={(e) => handleChange('label', e.target.value)}
className={inputClass}
/>
</div>
{/* Design Variable - with Expression Selector */}
{data.type === 'designVar' && (
<>
<div>
<label className={labelClass}>Expression Name</label>
<ExpressionSelector
value={(data as any).expressionName || ''}
onChange={(expr) => handleChange('expressionName', expr)}
modelPath={modelPath}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className={labelClass}>Min Value</label>
<input
type="number"
value={(data as any).minValue ?? ''}
onChange={(e) => handleChange('minValue', parseFloat(e.target.value) || 0)}
className={inputClass}
placeholder="0"
/>
</div>
<div>
<label className={labelClass}>Max Value</label>
<input
type="number"
value={(data as any).maxValue ?? ''}
onChange={(e) => handleChange('maxValue', parseFloat(e.target.value) || 100)}
className={inputClass}
placeholder="100"
/>
</div>
</div>
<div>
<label className={labelClass}>Unit</label>
<input
type="text"
value={(data as any).unit || ''}
onChange={(e) => handleChange('unit', e.target.value)}
className={inputClass}
placeholder="mm"
/>
</div>
</>
)}
{/* Add other node type configurations following the same pattern */}
{/* ... (include all other node types from earlier) ... */}
</div>
{/* Footer status */}
<div className="flex-shrink-0 px-4 py-3 border-t border-dark-700">
<div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full ${data.configured ? 'bg-green-400' : 'bg-amber-400'}`} />
<span className={`text-xs ${data.configured ? 'text-green-400' : 'text-amber-400'}`}>
{data.configured ? 'Configured' : 'Needs configuration'}
</span>
</div>
</div>
</div>
);
}
PHASE 6: Claude Chat Integration & Process Button
Fix the Claude CLI integration and add the "Process with Claude" functionality.
T6.1 - Update useCanvasChat Hook
File: atomizer-dashboard/frontend/src/hooks/useCanvasChat.ts
import { useCallback, useState } from 'react';
import { useChat } from './useChat';
import { OptimizationIntent } from '../lib/canvas/intent';
export function useCanvasChat() {
const { sendMessage, messages, isConnected, isThinking, clearMessages } = useChat();
const [lastIntent, setLastIntent] = useState<OptimizationIntent | null>(null);
const processWithClaude = useCallback(async (intent: OptimizationIntent) => {
setLastIntent(intent);
const message = `I have designed an optimization workflow using the Canvas Builder. Please analyze this configuration and help me create the optimization study.
## Optimization Intent
\`\`\`json
${JSON.stringify(intent, null, 2)}
\`\`\`
## Instructions
1. **Validate** the configuration for completeness and correctness
2. **Recommend** any improvements based on Atomizer protocols (SYS_12 for extractors, SYS_15 for method selection)
3. **Create** the study if the configuration is valid, or explain what needs to be fixed
Use your knowledge of:
- Atomizer extractors (E1-E10)
- Optimization methods (TPE, CMA-ES, NSGA-II, GP-BO)
- Best practices from LAC (Learning Atomizer Core)
Please proceed with the analysis.`;
await sendMessage(message);
}, [sendMessage]);
const validateWithClaude = useCallback(async (intent: OptimizationIntent) => {
const message = `Please validate this Canvas optimization intent WITHOUT creating a study:
\`\`\`json
${JSON.stringify(intent, null, 2)}
\`\`\`
Check for:
1. Missing required components
2. Invalid extractor selections
3. Algorithm appropriateness for the problem type
4. Constraint feasibility
Provide specific recommendations.`;
await sendMessage(message);
}, [sendMessage]);
const analyzeWithClaude = useCallback(async (intent: OptimizationIntent) => {
const message = `Analyze this optimization setup and provide expert recommendations:
\`\`\`json
${JSON.stringify(intent, null, 2)}
\`\`\`
Consider:
1. Is ${intent.optimization?.method || 'the selected method'} appropriate for ${intent.objectives?.length || 0} objective(s)?
2. Are the design variable ranges reasonable?
3. Would a neural surrogate help with this problem?
4. What trial count would you recommend?
Use Atomizer protocols SYS_15 (Method Selector) and SYS_14 (Neural Acceleration) for guidance.`;
await sendMessage(message);
}, [sendMessage]);
return {
processWithClaude,
validateWithClaude,
analyzeWithClaude,
messages,
isConnected,
isThinking,
clearMessages,
lastIntent,
};
}
T6.2 - Update ChatPanel for Canvas Context
File: atomizer-dashboard/frontend/src/components/canvas/panels/ChatPanel.tsx
import { useRef, useEffect } from 'react';
import { useCanvasChat } from '../../../hooks/useCanvasChat';
import { useCanvasStore } from '../../../hooks/useCanvasStore';
import { ChatMessage } from '../../chat/ChatMessage';
import { ThinkingIndicator } from '../../chat/ThinkingIndicator';
import {
X,
Send,
Sparkles,
Wifi,
WifiOff,
Trash2,
} from 'lucide-react';
interface ChatPanelProps {
onClose: () => void;
}
export function ChatPanel({ onClose }: ChatPanelProps) {
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const { messages, isConnected, isThinking, clearMessages } = useCanvasChat();
const { nodes, toIntent, validation } = useCanvasStore();
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
const handleSend = async () => {
const input = inputRef.current;
if (!input?.value.trim()) return;
// TODO: Send message
input.value = '';
};
const nodeCount = nodes.length;
const isValid = validation.valid;
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="flex-shrink-0 px-4 py-3 border-b border-dark-700">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Sparkles size={16} className="text-primary-400" />
<span className="font-medium text-white text-sm">Claude Assistant</span>
</div>
<div className="flex items-center gap-1">
<div className={`w-2 h-2 rounded-full ${isConnected ? 'bg-green-400' : 'bg-red-400'}`} />
<button
onClick={clearMessages}
className="p-1.5 text-dark-400 hover:text-white transition-colors"
title="Clear chat"
>
<Trash2 size={14} />
</button>
<button
onClick={onClose}
className="p-1.5 text-dark-400 hover:text-white transition-colors"
>
<X size={16} />
</button>
</div>
</div>
{/* Canvas status */}
<div className="mt-2 flex items-center gap-2 text-xs">
<span className="text-dark-500">{nodeCount} nodes</span>
<span className="text-dark-700">•</span>
<span className={isValid ? 'text-green-400' : 'text-amber-400'}>
{isValid ? 'Valid' : 'Needs validation'}
</span>
</div>
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{messages.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-center px-4">
<Sparkles size={32} className="text-dark-600 mb-3" />
<p className="text-dark-400 text-sm mb-1">Canvas Assistant</p>
<p className="text-dark-500 text-xs">
Click "Process with Claude" to analyze your workflow and create a study.
</p>
</div>
) : (
messages.map((msg, i) => (
<ChatMessage key={i} message={msg} />
))
)}
{isThinking && <ThinkingIndicator />}
<div ref={messagesEndRef} />
</div>
{/* Input */}
<div className="flex-shrink-0 p-3 border-t border-dark-700">
<div className="flex items-center gap-2">
<input
ref={inputRef}
type="text"
placeholder="Ask about your optimization..."
className="flex-1 px-3 py-2 bg-dark-800 border border-dark-600 rounded-lg
text-sm text-white placeholder-dark-500
focus:border-primary-500 focus:ring-1 focus:ring-primary-500"
onKeyDown={(e) => e.key === 'Enter' && handleSend()}
/>
<button
onClick={handleSend}
className="p-2 bg-primary-500 text-white rounded-lg hover:bg-primary-600 transition-colors"
>
<Send size={16} />
</button>
</div>
</div>
</div>
);
}
PHASE 7: MCP Canvas Tools
Add the missing MCP tools for canvas intent handling.
T7.1 - Update canvas.ts MCP Tools
File: mcp-server/atomizer-tools/src/tools/canvas.ts
Verify and update the canvas tools to properly handle intent validation and execution:
import { AtomizerTool } from '../index.js';
import { spawn } from 'child_process';
import { ATOMIZER_ROOT, PYTHON_PATH } from '../utils/paths.js';
import path from 'path';
// Helper to run Python scripts
async function runPython(script: string, args: Record<string, unknown>): Promise<string> {
return new Promise((resolve, reject) => {
const proc = spawn(PYTHON_PATH, [
path.join(ATOMIZER_ROOT, script),
JSON.stringify(args)
], {
cwd: ATOMIZER_ROOT,
env: { ...process.env, ATOMIZER_ROOT }
});
let stdout = '';
let stderr = '';
proc.stdout.on('data', (data) => { stdout += data; });
proc.stderr.on('data', (data) => { stderr += data; });
proc.on('close', (code) => {
if (code === 0) {
resolve(stdout);
} else {
reject(new Error(stderr || `Process exited with code ${code}`));
}
});
});
}
export const canvasTools: AtomizerTool[] = [
{
definition: {
name: 'validate_canvas_intent',
description: 'Validate an optimization intent from the Canvas Builder. Checks for completeness, valid extractors, appropriate algorithms, and constraint feasibility.',
inputSchema: {
type: 'object',
properties: {
intent: {
type: 'object',
description: 'The OptimizationIntent JSON from the canvas',
},
},
required: ['intent'],
},
},
handler: async (args) => {
const intent = args.intent as Record<string, unknown>;
const errors: string[] = [];
const warnings: string[] = [];
const recommendations: string[] = [];
// Check model
if (!intent.model || !(intent.model as any).path) {
errors.push('Missing model file - add a Model node with file path');
}
// Check solver
if (!intent.solver || !(intent.solver as any).type) {
errors.push('Missing solver type - add a Solver node');
}
// Check design variables
const dvs = (intent.design_variables as any[]) || [];
if (dvs.length === 0) {
errors.push('No design variables - add at least one Design Variable node');
} else {
dvs.forEach((dv, i) => {
if (!dv.name) errors.push(`Design variable ${i + 1}: missing name`);
if (dv.min === undefined || dv.max === undefined) {
warnings.push(`Design variable "${dv.name}": missing bounds`);
} else if (dv.min >= dv.max) {
errors.push(`Design variable "${dv.name}": min must be less than max`);
}
});
}
// Check objectives
const objectives = (intent.objectives as any[]) || [];
if (objectives.length === 0) {
errors.push('No objectives - add at least one Objective node');
}
// Check algorithm
const opt = intent.optimization as any;
if (!opt?.method) {
warnings.push('No algorithm specified - will use default TPE');
} else if (objectives.length > 1 && opt.method !== 'NSGA-II') {
recommendations.push(`Multi-objective detected: consider using NSGA-II instead of ${opt.method}`);
}
// Check extractors
const extractors = (intent.extractors as any[]) || [];
const validExtractors = ['E1', 'E2', 'E3', 'E4', 'E5', 'E8', 'E9', 'E10'];
extractors.forEach((ext) => {
if (!validExtractors.includes(ext.id)) {
warnings.push(`Unknown extractor: ${ext.id}`);
}
});
const valid = errors.length === 0;
return {
content: [{
type: 'text',
text: JSON.stringify({
valid,
errors,
warnings,
recommendations,
summary: valid
? `Intent is valid: ${dvs.length} variables, ${objectives.length} objectives, ${opt?.method || 'TPE'} method`
: `Intent has ${errors.length} error(s) that must be fixed`,
}, null, 2),
}],
};
},
},
{
definition: {
name: 'execute_canvas_intent',
description: 'Create an optimization study from a Canvas intent. Generates optimization_config.json and study structure.',
inputSchema: {
type: 'object',
properties: {
intent: {
type: 'object',
description: 'The validated OptimizationIntent JSON',
},
study_name: {
type: 'string',
description: 'Name for the study (snake_case)',
},
auto_run: {
type: 'boolean',
description: 'Whether to start optimization immediately',
},
},
required: ['intent', 'study_name'],
},
},
handler: async (args) => {
const { intent, study_name, auto_run } = args as {
intent: Record<string, unknown>;
study_name: string;
auto_run?: boolean;
};
try {
// Call Python study creator
const result = await runPython(
'optimization_engine/study/creator.py',
{ intent, study_name, auto_run: auto_run || false }
);
return {
content: [{
type: 'text',
text: result,
}],
};
} catch (error) {
return {
content: [{
type: 'text',
text: `Failed to create study: ${error instanceof Error ? error.message : 'Unknown error'}`,
}],
isError: true,
};
}
},
},
{
definition: {
name: 'interpret_canvas_intent',
description: 'Analyze a Canvas intent and provide optimization recommendations without creating anything.',
inputSchema: {
type: 'object',
properties: {
intent: {
type: 'object',
description: 'The OptimizationIntent JSON to analyze',
},
},
required: ['intent'],
},
},
handler: async (args) => {
const intent = args.intent as Record<string, unknown>;
const objectives = (intent.objectives as any[]) || [];
const dvs = (intent.design_variables as any[]) || [];
const opt = intent.optimization as any;
const analysis = {
problem_type: objectives.length > 1 ? 'multi-objective' : 'single-objective',
complexity: dvs.length <= 3 ? 'low' : dvs.length <= 10 ? 'medium' : 'high',
recommended_method: objectives.length > 1 ? 'NSGA-II' : dvs.length > 10 ? 'CMA-ES' : 'TPE',
recommended_trials: Math.max(50, dvs.length * 20),
surrogate_recommended: dvs.length >= 5 || (opt?.max_trials || 100) > 100,
notes: [] as string[],
};
if (opt?.method && opt.method !== analysis.recommended_method) {
analysis.notes.push(
`Current method ${opt.method} may not be optimal. Consider ${analysis.recommended_method}.`
);
}
if (analysis.surrogate_recommended && !intent.surrogate) {
analysis.notes.push(
'Neural surrogate recommended for this problem complexity. Add a Surrogate node.'
);
}
return {
content: [{
type: 'text',
text: JSON.stringify(analysis, null, 2),
}],
};
},
},
];
PHASE 8: Final Integration & Testing
T8.1 - Build and Test
# Build MCP server
cd mcp-server/atomizer-tools
npm run build
# Build frontend
cd ../../atomizer-dashboard/frontend
npm run build
# Start dev server
npm run dev
T8.2 - Commit All Changes
git add .
git commit -m "feat: Canvas Professional Upgrade V2
Phase 1: Professional Lucide Icons
- Replace all emoji icons with Lucide React
- Update NodePalette, BaseNode, all node components
- Update templates with icon names
Phase 2: Responsive Full-Screen Canvas
- Canvas adapts to window size
- Compact header, full canvas area
- Floating action buttons
Phase 3: Auto-Load from Config
- Backend endpoint for study configs
- Study browser with search
- loadFromConfig in canvas store
Phase 4: NX Model Introspection
- Backend introspection endpoint
- Extract expressions from .prt/.sim/.fem
- Detect solver type from .sim
Phase 5: Expression Search/Dropdown
- ExpressionSelector component
- Search and filter expressions
- Manual entry fallback
Phase 6: Claude Chat Integration
- useCanvasChat hook with Claude prompts
- Process with Claude button
- Context-aware chat panel
Phase 7: MCP Canvas Tools
- validate_canvas_intent
- execute_canvas_intent
- interpret_canvas_intent
Phase 8: Testing & Polish
- Full build verification
- Dark theme consistency
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>"
git push origin main && git push github main
ACCEPTANCE CRITERIA
Before marking complete, verify:
npm run buildpasses in frontendnpm run buildpasses in MCP server- Canvas displays with Lucide icons (no emojis)
- Canvas fills available screen space
- Nodes can be dragged from palette
- Clicking a node opens config panel
- Config panel has dark theme styling
- Expression dropdown appears for design variables
- Import button shows study browser
- "Process with Claude" button triggers chat
- Templates modal opens and shows templates
- All text is readable (white on dark)
BEGIN EXECUTION
Execute all phases sequentially. Use TodoWrite to track progress. Complete each phase fully before moving to the next. Do not stop between phases.
GO.