## 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
2453 lines
75 KiB
Markdown
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.**
|