Files
Atomizer/docs/archive/review/RALPH_LOOP_CANVAS_V2.md
Antoine 8d9d55356c docs: Archive stale docs and create Atomizer-HQ agent documentation
Archive Management:
- Moved RALPH_LOOP, CANVAS, and dashboard implementation plans to archive/review/ for CEO review
- Moved completed restructuring plan and protocol v1 to archive/historical/
- Moved old session summaries to archive/review/

New HQ Documentation (docs/hq/):
- README.md: Overview of Atomizer-HQ multi-agent optimization team
- PROJECT_STRUCTURE.md: Standard KB-integrated project layout with Hydrotech reference
- KB_CONVENTIONS.md: Knowledge Base accumulation principles with generation tracking
- AGENT_WORKFLOWS.md: Project lifecycle phases and agent handoffs (OP_09 integration)
- STUDY_CONVENTIONS.md: Technical study execution standards and atomizer_spec.json format

Index Update:
- Reorganized docs/00_INDEX.md with HQ docs prominent
- Updated structure to reflect new agent-focused organization
- Maintained core documentation access for engineers

No files deleted, only moved to appropriate archive locations.
2026-02-09 02:48:35 +00:00

2453 lines
75 KiB
Markdown

# 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
```powershell
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:
1. Professional Lucide icons (no emojis)
2. Working Claude CLI integration
3. Auto-load from optimization_config.json
4. NX model introspection (.sim → expressions)
5. Expression search/dropdown for design variables
6. Responsive full-screen canvas
7. "Process with Claude" button using Atomizer protocols
8. 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
1. **TodoWrite** - Track every task, mark complete immediately
2. **Sequential phases** - Complete each phase fully before next
3. **Test builds** - Run `npm run build` after each phase
4. **No questions** - Use provided code, make reasonable decisions
5. **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`
```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`
```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:**
```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:**
```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:**
```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:**
```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:**
```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:**
```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:**
```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:**
```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):
```tsx
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`
```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`
```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):
```python
@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
```python
@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:
```typescript
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`
```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)
```python
"""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:
```python
from api.routes import nx
app.include_router(nx.router)
```
## T4.3 - Create Introspection Script
**File:** `optimization_engine/nx/introspect_model.py`
```python
#!/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:
```tsx
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`
```typescript
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`
```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:
```typescript
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
```bash
# 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
```bash
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:
1. [ ] `npm run build` passes in frontend
2. [ ] `npm run build` passes in MCP server
3. [ ] Canvas displays with Lucide icons (no emojis)
4. [ ] Canvas fills available screen space
5. [ ] Nodes can be dragged from palette
6. [ ] Clicking a node opens config panel
7. [ ] Config panel has dark theme styling
8. [ ] Expression dropdown appears for design variables
9. [ ] Import button shows study browser
10. [ ] "Process with Claude" button triggers chat
11. [ ] Templates modal opens and shows templates
12. [ ] 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.**