feat(canvas): Add file browser, introspection, and improve node flow

Phase 1-7 of Canvas V4 Ralph Loop implementation:

Backend:
- Add /api/files routes for browsing model files
- Add /api/nx routes for NX model introspection
- Add NXIntrospector service to discover expressions and extractors
- Add health check with database status

Frontend:
- Add FileBrowser component for selecting .sim/.prt/.fem files
- Add IntrospectionPanel to discover expressions and extractors
- Update NodeConfigPanel with browse and introspect buttons
- Update schema with NODE_HANDLES for proper flow direction
- Update validation for correct DesignVar -> Model -> Solver flow
- Update useCanvasStore.addNode() to accept custom data

Flow correction: Design Variables now connect TO Model (as source),
not FROM Model. This matches the actual data flow in optimization.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-16 14:47:10 -05:00
parent 62284a995e
commit 1c7c7aff05
13 changed files with 4401 additions and 25 deletions

View File

@@ -13,7 +13,7 @@ import sys
# Add parent directory to path to import optimization_engine
sys.path.append(str(Path(__file__).parent.parent.parent.parent))
from api.routes import optimization, claude, terminal, insights, context
from api.routes import optimization, claude, terminal, insights, context, files, nx
from api.websocket import optimization_stream
@@ -58,6 +58,8 @@ app.include_router(claude.router, prefix="/api/claude", tags=["claude"])
app.include_router(terminal.router, prefix="/api/terminal", tags=["terminal"])
app.include_router(insights.router, prefix="/api/insights", tags=["insights"])
app.include_router(context.router, prefix="/api/context", tags=["context"])
app.include_router(files.router, prefix="/api/files", tags=["files"])
app.include_router(nx.router, prefix="/api/nx", tags=["nx"])
@app.get("/")
async def root():
@@ -67,8 +69,20 @@ async def root():
@app.get("/health")
async def health_check():
"""Health check endpoint"""
return {"status": "healthy"}
"""Health check endpoint with database status"""
try:
from api.services.conversation_store import ConversationStore
store = ConversationStore()
# Test database by creating/getting a health check session
store.get_session("health_check")
db_status = "connected"
except Exception as e:
db_status = f"error: {str(e)}"
return {
"status": "healthy" if db_status == "connected" else "degraded",
"database": db_status,
}
if __name__ == "__main__":
import uvicorn

View File

@@ -0,0 +1,155 @@
"""
Files API Routes
Provides file browsing capabilities for the Canvas Builder.
"""
from fastapi import APIRouter, Query
from pathlib import Path
from typing import List, Optional
import os
router = APIRouter()
# Path to studies root (go up 5 levels from this file)
_file_path = os.path.abspath(__file__)
ATOMIZER_ROOT = Path(os.path.normpath(os.path.dirname(os.path.dirname(os.path.dirname(
os.path.dirname(os.path.dirname(_file_path))
)))))
STUDIES_ROOT = ATOMIZER_ROOT / "studies"
@router.get("/list")
async def list_files(
path: str = "",
types: str = ".sim,.prt,.fem,.afem"
):
"""
List files in a directory, filtered by type.
Args:
path: Relative path from studies root (empty for root)
types: Comma-separated list of file extensions to include
Returns:
List of files and directories with their paths
"""
allowed_types = [t.strip().lower() for t in types.split(',') if t.strip()]
base_path = STUDIES_ROOT / path if path else STUDIES_ROOT
if not base_path.exists():
return {"files": [], "path": path, "error": "Directory not found"}
files = []
try:
for entry in sorted(base_path.iterdir(), key=lambda x: (not x.is_dir(), x.name.lower())):
# Skip hidden files and directories
if entry.name.startswith('.'):
continue
if entry.is_dir():
# Include directories
files.append({
"name": entry.name,
"path": str(entry.relative_to(STUDIES_ROOT)).replace("\\", "/"),
"isDirectory": True,
})
else:
# Include files matching type filter
suffix = entry.suffix.lower()
if suffix in allowed_types:
files.append({
"name": entry.name,
"path": str(entry.relative_to(STUDIES_ROOT)).replace("\\", "/"),
"isDirectory": False,
"size": entry.stat().st_size,
})
except PermissionError:
return {"files": [], "path": path, "error": "Permission denied"}
except Exception as e:
return {"files": [], "path": path, "error": str(e)}
return {"files": files, "path": path}
@router.get("/search")
async def search_files(
query: str,
types: str = ".sim,.prt,.fem,.afem",
max_results: int = 50
):
"""
Search for files by name pattern.
Args:
query: Search pattern (partial name match)
types: Comma-separated list of file extensions to include
max_results: Maximum number of results to return
Returns:
List of matching files with their paths
"""
allowed_types = [t.strip().lower() for t in types.split(',') if t.strip()]
query_lower = query.lower()
files = []
def search_recursive(directory: Path, depth: int = 0):
"""Recursively search for matching files"""
if depth > 10 or len(files) >= max_results: # Limit depth and results
return
try:
for entry in directory.iterdir():
if len(files) >= max_results:
return
if entry.name.startswith('.'):
continue
if entry.is_dir():
search_recursive(entry, depth + 1)
elif entry.suffix.lower() in allowed_types:
if query_lower in entry.name.lower():
files.append({
"name": entry.name,
"path": str(entry.relative_to(STUDIES_ROOT)).replace("\\", "/"),
"isDirectory": False,
"size": entry.stat().st_size,
})
except (PermissionError, OSError):
pass
search_recursive(STUDIES_ROOT)
return {"files": files, "query": query, "total": len(files)}
@router.get("/exists")
async def check_file_exists(path: str):
"""
Check if a file exists.
Args:
path: Relative path from studies root
Returns:
Boolean indicating if file exists and file info
"""
file_path = STUDIES_ROOT / path
exists = file_path.exists()
result = {
"exists": exists,
"path": path,
}
if exists:
result["isDirectory"] = file_path.is_dir()
if file_path.is_file():
result["size"] = file_path.stat().st_size
result["name"] = file_path.name
return result

View File

@@ -0,0 +1,90 @@
"""
NX API Routes
Provides NX model introspection capabilities for the Canvas Builder.
"""
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from typing import Optional
router = APIRouter()
class IntrospectRequest(BaseModel):
file_path: str
@router.post("/introspect")
async def introspect_model(request: IntrospectRequest):
"""
Introspect an NX model file to discover expressions, solver type, and dependencies.
Args:
file_path: Relative path from studies root (e.g., "M1_Mirror/study_v1/model.sim")
Returns:
Introspection result with expressions, solver_type, dependent_files, extractors
"""
try:
from api.services.nx_introspection import NXIntrospector
introspector = NXIntrospector(request.file_path)
result = introspector.introspect()
return result
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/expressions")
async def get_expressions(file_path: str):
"""
Get expressions from an NX model.
Args:
file_path: Relative path from studies root
Returns:
List of expressions with names, values, units
"""
try:
from api.services.nx_introspection import NXIntrospector
introspector = NXIntrospector(file_path)
result = introspector.introspect()
return {
"expressions": result.get("expressions", []),
"file_path": file_path,
"source": "introspection",
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/extractors")
async def list_extractors(solver_type: Optional[str] = None):
"""
List available extractors, optionally filtered by solver type.
Args:
solver_type: Optional solver type (SOL101, SOL103, etc.)
Returns:
List of available extractors with their descriptions
"""
from api.services.nx_introspection import NXIntrospector
# Create a dummy introspector to get extractor suggestions
class DummyIntrospector:
def __init__(self):
self.parent_dir = ""
dummy = NXIntrospector.__new__(NXIntrospector)
dummy.parent_dir = ""
extractors = dummy._suggest_extractors(solver_type)
return {
"extractors": extractors,
"solver_type": solver_type,
}

View File

@@ -0,0 +1,317 @@
"""
NX Model Introspection Service
Discovers expressions, solver types, and dependent files from NX model files.
Used by the Canvas Builder to help users configure optimization workflows.
"""
import json
import os
import re
from pathlib import Path
from typing import Any, Dict, List, Optional
import logging
logger = logging.getLogger(__name__)
# Path to studies root
_file_path = os.path.abspath(__file__)
ATOMIZER_ROOT = Path(os.path.normpath(os.path.dirname(os.path.dirname(os.path.dirname(
os.path.dirname(os.path.dirname(_file_path))
)))))
STUDIES_ROOT = ATOMIZER_ROOT / "studies"
class NXIntrospector:
"""Introspect NX model files to discover expressions, dependencies, and solver info."""
def __init__(self, file_path: str):
"""
Initialize introspector with a file path.
Args:
file_path: Relative path from studies root (e.g., "M1_Mirror/study_v1/model.sim")
"""
self.relative_path = file_path.replace("\\", "/")
self.file_path = STUDIES_ROOT / self.relative_path
self.file_type = self.file_path.suffix.lower()
self.parent_dir = self.file_path.parent
def introspect(self) -> Dict[str, Any]:
"""
Full introspection of the model file.
Returns:
Dict with expressions, solver_type, dependent_files, extractors_available, warnings
"""
result = {
"file_path": self.relative_path,
"file_type": self.file_type,
"expressions": [],
"solver_type": None,
"dependent_files": [],
"extractors_available": [],
"warnings": [],
}
if not self.file_path.exists():
result["warnings"].append(f"File not found: {self.file_path}")
return result
try:
if self.file_type == '.sim':
result.update(self._introspect_sim())
elif self.file_type == '.prt':
result.update(self._introspect_prt())
elif self.file_type in ['.fem', '.afem']:
result.update(self._introspect_fem())
# Try to load expressions from optimization_config.json if present
config_expressions = self._load_expressions_from_config()
if config_expressions:
result["expressions"] = config_expressions
# If still no expressions, try from study history
if not result["expressions"]:
result["expressions"] = self._discover_common_expressions()
except Exception as e:
logger.error(f"Introspection error: {e}")
result["warnings"].append(str(e))
# Suggest extractors based on solver type
result["extractors_available"] = self._suggest_extractors(result.get("solver_type"))
return result
def _introspect_sim(self) -> Dict[str, Any]:
"""Introspect .sim file."""
result = {
"solver_type": None,
"dependent_files": [],
}
base_name = self.file_path.stem
# Find related files in the same directory and parent
search_dirs = [self.parent_dir]
if self.parent_dir.name in ['1_config', '1_setup', 'config', 'setup']:
search_dirs.append(self.parent_dir.parent)
for search_dir in search_dirs:
if not search_dir.exists():
continue
for ext in ['.prt', '.fem', '.afem']:
# Look for variations of the file name
patterns = [
f"{base_name}{ext}",
f"{base_name.replace('_sim1', '')}{ext}",
f"{base_name.replace('_sim1', '_fem1')}{ext}",
]
for pattern in patterns:
file_candidate = search_dir / pattern
if file_candidate.exists():
result["dependent_files"].append({
"path": str(file_candidate.relative_to(STUDIES_ROOT)).replace("\\", "/"),
"type": ext[1:],
"name": file_candidate.name,
})
# Find idealized part (*_i.prt) - critical for mesh updates
for f in search_dir.glob("*_i.prt"):
result["dependent_files"].append({
"path": str(f.relative_to(STUDIES_ROOT)).replace("\\", "/"),
"type": "idealized_prt",
"name": f.name,
})
# Try to determine solver type
result["solver_type"] = self._detect_solver_type()
return result
def _introspect_prt(self) -> Dict[str, Any]:
"""Introspect .prt file."""
result = {
"dependent_files": [],
}
base_name = self.file_path.stem
# Look for associated .sim and .fem files
search_dirs = [self.parent_dir]
if self.parent_dir.name in ['1_config', '1_setup', 'config', 'setup']:
search_dirs.append(self.parent_dir.parent)
for search_dir in search_dirs:
if not search_dir.exists():
continue
for ext in ['.sim', '.fem', '.afem']:
patterns = [
f"{base_name}{ext}",
f"{base_name}_sim1{ext}",
f"{base_name}_fem1{ext}",
]
for pattern in patterns:
file_candidate = search_dir / pattern
if file_candidate.exists():
result["dependent_files"].append({
"path": str(file_candidate.relative_to(STUDIES_ROOT)).replace("\\", "/"),
"type": ext[1:],
"name": file_candidate.name,
})
return result
def _introspect_fem(self) -> Dict[str, Any]:
"""Introspect .fem or .afem file."""
result = {
"dependent_files": [],
}
base_name = self.file_path.stem
# Look for associated files
for ext in ['.prt', '.sim']:
patterns = [
f"{base_name}{ext}",
f"{base_name.replace('_fem1', '')}{ext}",
f"{base_name.replace('_fem1', '_sim1')}{ext}",
]
for pattern in patterns:
file_candidate = self.parent_dir / pattern
if file_candidate.exists():
result["dependent_files"].append({
"path": str(file_candidate.relative_to(STUDIES_ROOT)).replace("\\", "/"),
"type": ext[1:],
"name": file_candidate.name,
})
return result
def _detect_solver_type(self) -> Optional[str]:
"""Detect solver type from file name or contents."""
name_lower = self.file_path.name.lower()
parent_lower = str(self.parent_dir).lower()
# Infer from naming conventions
if 'modal' in name_lower or 'freq' in name_lower or 'modal' in parent_lower:
return 'SOL103' # Modal analysis
elif 'static' in name_lower or 'stress' in name_lower:
return 'SOL101' # Static analysis
elif 'thermal' in name_lower or 'heat' in name_lower:
return 'SOL153' # Thermal
elif 'dynamic' in name_lower:
return 'SOL111' # Frequency response
elif 'mirror' in parent_lower or 'wfe' in parent_lower:
return 'SOL101' # Mirrors usually use static analysis
# Default to static
return 'SOL101'
def _load_expressions_from_config(self) -> List[Dict[str, Any]]:
"""Load expressions from optimization_config.json if it exists."""
expressions = []
# Look for config file in study directory
config_paths = [
self.parent_dir / "optimization_config.json",
self.parent_dir / "1_config" / "optimization_config.json",
self.parent_dir / "1_setup" / "optimization_config.json",
self.parent_dir.parent / "optimization_config.json",
self.parent_dir.parent / "1_config" / "optimization_config.json",
]
for config_path in config_paths:
if config_path.exists():
try:
with open(config_path, 'r') as f:
config = json.load(f)
# Extract design variables
design_vars = config.get("design_variables", [])
for dv in design_vars:
expressions.append({
"name": dv.get("name", dv.get("expression", "unknown")),
"value": (dv.get("min", 0) + dv.get("max", 100)) / 2,
"min": dv.get("min"),
"max": dv.get("max"),
"unit": dv.get("unit", "mm"),
"type": "design_variable",
"source": "config",
})
return expressions
except Exception as e:
logger.warning(f"Failed to load config: {e}")
return expressions
def _discover_common_expressions(self) -> List[Dict[str, Any]]:
"""Discover common expressions based on study type."""
# Check parent directory name to infer study type
parent_lower = str(self.parent_dir).lower()
if 'mirror' in parent_lower:
return [
{"name": "flatback_thickness", "value": 30.0, "unit": "mm", "type": "dimension", "source": "inferred"},
{"name": "rib_height", "value": 40.0, "unit": "mm", "type": "dimension", "source": "inferred"},
{"name": "rib_width", "value": 8.0, "unit": "mm", "type": "dimension", "source": "inferred"},
{"name": "fillet_radius", "value": 5.0, "unit": "mm", "type": "dimension", "source": "inferred"},
{"name": "web_thickness", "value": 4.0, "unit": "mm", "type": "dimension", "source": "inferred"},
]
elif 'bracket' in parent_lower:
return [
{"name": "thickness", "value": 5.0, "unit": "mm", "type": "dimension", "source": "inferred"},
{"name": "width", "value": 50.0, "unit": "mm", "type": "dimension", "source": "inferred"},
{"name": "height", "value": 30.0, "unit": "mm", "type": "dimension", "source": "inferred"},
{"name": "fillet_radius", "value": 3.0, "unit": "mm", "type": "dimension", "source": "inferred"},
{"name": "hole_diameter", "value": 8.0, "unit": "mm", "type": "dimension", "source": "inferred"},
]
elif 'beam' in parent_lower:
return [
{"name": "height", "value": 100.0, "unit": "mm", "type": "dimension", "source": "inferred"},
{"name": "width", "value": 50.0, "unit": "mm", "type": "dimension", "source": "inferred"},
{"name": "web_thickness", "value": 5.0, "unit": "mm", "type": "dimension", "source": "inferred"},
{"name": "flange_thickness", "value": 8.0, "unit": "mm", "type": "dimension", "source": "inferred"},
]
# Generic expressions
return [
{"name": "thickness", "value": 10.0, "unit": "mm", "type": "dimension", "source": "inferred"},
{"name": "length", "value": 100.0, "unit": "mm", "type": "dimension", "source": "inferred"},
{"name": "width", "value": 50.0, "unit": "mm", "type": "dimension", "source": "inferred"},
{"name": "height", "value": 25.0, "unit": "mm", "type": "dimension", "source": "inferred"},
{"name": "fillet_radius", "value": 3.0, "unit": "mm", "type": "dimension", "source": "inferred"},
]
def _suggest_extractors(self, solver_type: Optional[str]) -> List[Dict[str, Any]]:
"""Suggest extractors based on solver type."""
extractors = [
{"id": "E4", "name": "Mass (BDF)", "description": "Extract mass from BDF file", "always": True},
{"id": "E5", "name": "Mass (Expression)", "description": "Extract mass from NX expression", "always": True},
]
if solver_type == 'SOL101':
extractors.extend([
{"id": "E1", "name": "Displacement", "description": "Max displacement from static analysis", "always": False},
{"id": "E3", "name": "Stress", "description": "Von Mises stress from static analysis", "always": False},
])
elif solver_type == 'SOL103':
extractors.extend([
{"id": "E2", "name": "Frequency", "description": "Natural frequencies from modal analysis", "always": False},
])
# Check if study appears to be mirror-related
parent_lower = str(self.parent_dir).lower()
if 'mirror' in parent_lower or 'wfe' in parent_lower:
extractors.extend([
{"id": "E8", "name": "Zernike Coefficients", "description": "Zernike polynomial coefficients", "always": False},
{"id": "E9", "name": "Zernike RMS", "description": "RMS wavefront error", "always": False},
{"id": "E10", "name": "Zernike WFE", "description": "Weighted WFE metric", "always": False},
])
return extractors

View File

@@ -0,0 +1,248 @@
/**
* File Browser - Modal for selecting NX model files
*/
import { useState, useEffect, useCallback } from 'react';
import {
X,
Folder,
FileBox,
ChevronRight,
ChevronDown,
Search,
RefreshCw,
Home,
} from 'lucide-react';
interface FileBrowserProps {
isOpen: boolean;
onClose: () => void;
onSelect: (filePath: string, fileType: string) => void;
fileTypes?: string[];
initialPath?: string;
}
interface FileEntry {
name: string;
path: string;
isDirectory: boolean;
size?: number;
}
export function FileBrowser({
isOpen,
onClose,
onSelect,
fileTypes = ['.sim', '.prt', '.fem', '.afem'],
initialPath = '',
}: FileBrowserProps) {
const [currentPath, setCurrentPath] = useState(initialPath);
const [files, setFiles] = useState<FileEntry[]>([]);
const [searchTerm, setSearchTerm] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const loadDirectory = useCallback(async (path: string) => {
setIsLoading(true);
setError(null);
try {
const typesParam = fileTypes.join(',');
const res = await fetch(
`/api/files/list?path=${encodeURIComponent(path)}&types=${encodeURIComponent(typesParam)}`
);
if (!res.ok) throw new Error('Failed to load directory');
const data = await res.json();
if (data.error) {
setError(data.error);
setFiles([]);
} else {
setFiles(data.files || []);
}
} catch (e) {
setError('Failed to load files');
console.error(e);
} finally {
setIsLoading(false);
}
}, [fileTypes]);
useEffect(() => {
if (isOpen) {
loadDirectory(currentPath);
}
}, [isOpen, currentPath, loadDirectory]);
const handleSelect = (file: FileEntry) => {
if (file.isDirectory) {
setCurrentPath(file.path);
setSearchTerm('');
} else {
const ext = '.' + file.name.split('.').pop()?.toLowerCase();
onSelect(file.path, ext);
onClose();
}
};
const navigateUp = () => {
const parts = currentPath.split('/').filter(Boolean);
parts.pop();
setCurrentPath(parts.join('/'));
setSearchTerm('');
};
const navigateTo = (path: string) => {
setCurrentPath(path);
setSearchTerm('');
};
const filteredFiles = files.filter((f) =>
f.name.toLowerCase().includes(searchTerm.toLowerCase())
);
const pathParts = currentPath.split('/').filter(Boolean);
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
<div className="bg-dark-850 border border-dark-700 rounded-xl w-full max-w-2xl max-h-[80vh] flex flex-col shadow-2xl">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-dark-700">
<h3 className="font-semibold text-white">Select Model File</h3>
<button
onClick={onClose}
className="p-1.5 text-dark-400 hover:text-white hover:bg-dark-700 rounded-lg transition-colors"
>
<X size={20} />
</button>
</div>
{/* Search */}
<div className="px-4 py-3 border-b border-dark-700">
<div className="relative">
<Search
size={16}
className="absolute left-3 top-1/2 -translate-y-1/2 text-dark-500"
/>
<input
type="text"
placeholder="Search files..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-9 pr-4 py-2 bg-dark-800 border border-dark-600 rounded-lg
text-white placeholder-dark-500 text-sm focus:outline-none focus:border-primary-500"
/>
</div>
<div className="flex items-center gap-2 mt-2 text-xs text-dark-500">
<span>Looking for:</span>
{fileTypes.map((t) => (
<span key={t} className="px-1.5 py-0.5 bg-dark-700 rounded">
{t}
</span>
))}
</div>
</div>
{/* Path breadcrumb */}
<div className="px-4 py-2 text-sm text-dark-400 flex items-center gap-1 border-b border-dark-700 overflow-x-auto">
<button
onClick={() => navigateTo('')}
className="hover:text-white flex items-center gap-1 flex-shrink-0"
>
<Home size={14} />
<span>studies</span>
</button>
{pathParts.map((part, i) => (
<span key={i} className="flex items-center gap-1 flex-shrink-0">
<ChevronRight size={14} />
<button
onClick={() => navigateTo(pathParts.slice(0, i + 1).join('/'))}
className="hover:text-white"
>
{part}
</button>
</span>
))}
</div>
{/* File list */}
<div className="flex-1 overflow-auto p-2">
{isLoading ? (
<div className="flex items-center justify-center h-32 text-dark-500">
<RefreshCw size={20} className="animate-spin mr-2" />
Loading...
</div>
) : error ? (
<div className="flex items-center justify-center h-32 text-red-400">
{error}
</div>
) : filteredFiles.length === 0 ? (
<div className="flex items-center justify-center h-32 text-dark-500">
{searchTerm ? 'No matching files found' : 'No model files in this directory'}
</div>
) : (
<div className="space-y-1">
{/* Show parent directory link if not at root */}
{currentPath && (
<button
onClick={navigateUp}
className="w-full flex items-center gap-2 px-3 py-2 rounded-lg text-left
hover:bg-dark-700 transition-colors text-dark-300"
>
<ChevronDown size={16} className="text-dark-500 rotate-90" />
<Folder size={16} className="text-dark-400" />
<span>..</span>
</button>
)}
{filteredFiles.map((file) => (
<button
key={file.path}
onClick={() => handleSelect(file)}
className={`w-full flex items-center gap-2 px-3 py-2 rounded-lg text-left
hover:bg-dark-700 transition-colors
${file.isDirectory ? 'text-dark-300' : 'text-white'}`}
>
{file.isDirectory ? (
<>
<ChevronRight size={16} className="text-dark-500" />
<Folder size={16} className="text-amber-400" />
</>
) : (
<>
<span className="w-4" />
<FileBox size={16} className="text-primary-400" />
</>
)}
<span className="flex-1 truncate">{file.name}</span>
{!file.isDirectory && (
<span className="text-xs text-dark-500 uppercase">
{file.name.split('.').pop()}
</span>
)}
</button>
))}
</div>
)}
</div>
{/* Footer */}
<div className="px-4 py-3 border-t border-dark-700 flex justify-between items-center">
<button
onClick={() => loadDirectory(currentPath)}
className="flex items-center gap-1.5 px-3 py-1.5 text-dark-400 hover:text-white transition-colors"
>
<RefreshCw size={14} />
Refresh
</button>
<button
onClick={onClose}
className="px-4 py-2 text-dark-300 hover:text-white transition-colors"
>
Cancel
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,376 @@
/**
* Introspection Panel - Shows discovered expressions and extractors from NX model
*/
import { useState, useEffect, useCallback } from 'react';
import {
X,
Search,
RefreshCw,
Plus,
ChevronDown,
ChevronRight,
FileBox,
Cpu,
FlaskConical,
SlidersHorizontal,
AlertTriangle,
} from 'lucide-react';
import { useCanvasStore } from '../../../hooks/useCanvasStore';
interface IntrospectionPanelProps {
filePath: string;
onClose: () => void;
}
interface Expression {
name: string;
value: number;
min?: number;
max?: number;
unit: string;
type: string;
source?: string;
}
interface Extractor {
id: string;
name: string;
description?: string;
always?: boolean;
}
interface DependentFile {
path: string;
type: string;
name: string;
}
interface IntrospectionResult {
file_path: string;
file_type: string;
expressions: Expression[];
solver_type: string | null;
dependent_files: DependentFile[];
extractors_available: Extractor[];
warnings: string[];
}
export function IntrospectionPanel({ filePath, onClose }: IntrospectionPanelProps) {
const [result, setResult] = useState<IntrospectionResult | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [expandedSections, setExpandedSections] = useState<Set<string>>(
new Set(['expressions', 'extractors'])
);
const [searchTerm, setSearchTerm] = useState('');
const { addNode, nodes } = useCanvasStore();
const runIntrospection = useCallback(async () => {
if (!filePath) return;
setIsLoading(true);
setError(null);
try {
const res = await fetch('/api/nx/introspect', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ file_path: filePath }),
});
if (!res.ok) throw new Error('Introspection failed');
const data = await res.json();
setResult(data);
} catch (e) {
setError('Failed to introspect model');
console.error(e);
} finally {
setIsLoading(false);
}
}, [filePath]);
useEffect(() => {
runIntrospection();
}, [runIntrospection]);
const toggleSection = (section: string) => {
setExpandedSections((prev) => {
const next = new Set(prev);
if (next.has(section)) next.delete(section);
else next.add(section);
return next;
});
};
const addExpressionAsDesignVar = (expr: Expression) => {
// Find a good position (left of model node)
const modelNode = nodes.find((n) => n.data.type === 'model');
const existingDvars = nodes.filter((n) => n.data.type === 'designVar');
const position = {
x: (modelNode?.position.x || 300) - 250,
y: (modelNode?.position.y || 100) + existingDvars.length * 100,
};
// Calculate min/max based on value if not provided
const minValue = expr.min ?? expr.value * 0.5;
const maxValue = expr.max ?? expr.value * 1.5;
addNode('designVar', position, {
label: expr.name,
expressionName: expr.name,
minValue,
maxValue,
unit: expr.unit,
configured: true,
});
};
const addExtractorNode = (extractor: Extractor) => {
// Find a good position (right of solver node)
const solverNode = nodes.find((n) => n.data.type === 'solver');
const existingExtractors = nodes.filter((n) => n.data.type === 'extractor');
const position = {
x: (solverNode?.position.x || 400) + 200,
y: (solverNode?.position.y || 100) + existingExtractors.length * 100,
};
addNode('extractor', position, {
label: extractor.name,
extractorId: extractor.id,
extractorName: extractor.name,
configured: true,
});
};
const filteredExpressions =
result?.expressions.filter((e) =>
e.name.toLowerCase().includes(searchTerm.toLowerCase())
) || [];
return (
<div className="bg-dark-850 border border-dark-700 rounded-xl w-80 max-h-[70vh] flex flex-col shadow-xl">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-dark-700">
<div className="flex items-center gap-2">
<Search size={16} className="text-primary-400" />
<span className="font-medium text-white text-sm">Model Introspection</span>
</div>
<div className="flex items-center gap-1">
<button
onClick={runIntrospection}
disabled={isLoading}
className="p-1.5 text-dark-400 hover:text-white hover:bg-dark-700 rounded transition-colors"
title="Refresh"
>
<RefreshCw size={14} className={isLoading ? 'animate-spin' : ''} />
</button>
<button
onClick={onClose}
className="p-1.5 text-dark-400 hover:text-white hover:bg-dark-700 rounded transition-colors"
>
<X size={14} />
</button>
</div>
</div>
{/* Search */}
<div className="px-4 py-2 border-b border-dark-700">
<input
type="text"
placeholder="Filter expressions..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full px-3 py-1.5 bg-dark-800 border border-dark-600 rounded-lg
text-sm text-white placeholder-dark-500 focus:outline-none focus:border-primary-500"
/>
</div>
{/* Content */}
<div className="flex-1 overflow-auto">
{isLoading ? (
<div className="flex items-center justify-center h-32 text-dark-500">
<RefreshCw size={20} className="animate-spin mr-2" />
Analyzing model...
</div>
) : error ? (
<div className="p-4 text-red-400 text-sm">{error}</div>
) : result ? (
<div className="p-2 space-y-2">
{/* Solver Type */}
{result.solver_type && (
<div className="p-2 bg-dark-800 rounded-lg">
<div className="flex items-center gap-2 text-sm">
<Cpu size={14} className="text-violet-400" />
<span className="text-dark-300">Solver:</span>
<span className="text-white font-medium">{result.solver_type}</span>
</div>
</div>
)}
{/* Expressions Section */}
<div className="border border-dark-700 rounded-lg overflow-hidden">
<button
onClick={() => toggleSection('expressions')}
className="w-full flex items-center justify-between px-3 py-2 bg-dark-800 hover:bg-dark-750 transition-colors"
>
<div className="flex items-center gap-2">
<SlidersHorizontal size={14} className="text-emerald-400" />
<span className="text-sm font-medium text-white">
Expressions ({filteredExpressions.length})
</span>
</div>
{expandedSections.has('expressions') ? (
<ChevronDown size={14} className="text-dark-400" />
) : (
<ChevronRight size={14} className="text-dark-400" />
)}
</button>
{expandedSections.has('expressions') && (
<div className="p-2 space-y-1 max-h-48 overflow-y-auto">
{filteredExpressions.length === 0 ? (
<p className="text-xs text-dark-500 text-center py-2">
No expressions found
</p>
) : (
filteredExpressions.map((expr) => (
<div
key={expr.name}
className="flex items-center justify-between p-2 bg-dark-850 rounded hover:bg-dark-750 group transition-colors"
>
<div className="flex-1 min-w-0">
<p className="text-sm text-white truncate">{expr.name}</p>
<p className="text-xs text-dark-500">
{expr.value} {expr.unit}
{expr.source === 'inferred' && (
<span className="ml-1 text-amber-500">(inferred)</span>
)}
</p>
</div>
<button
onClick={() => addExpressionAsDesignVar(expr)}
className="p-1.5 text-dark-500 hover:text-primary-400 hover:bg-dark-700 rounded
opacity-0 group-hover:opacity-100 transition-all"
title="Add as Design Variable"
>
<Plus size={14} />
</button>
</div>
))
)}
</div>
)}
</div>
{/* Extractors Section */}
<div className="border border-dark-700 rounded-lg overflow-hidden">
<button
onClick={() => toggleSection('extractors')}
className="w-full flex items-center justify-between px-3 py-2 bg-dark-800 hover:bg-dark-750 transition-colors"
>
<div className="flex items-center gap-2">
<FlaskConical size={14} className="text-cyan-400" />
<span className="text-sm font-medium text-white">
Available Extractors ({result.extractors_available.length})
</span>
</div>
{expandedSections.has('extractors') ? (
<ChevronDown size={14} className="text-dark-400" />
) : (
<ChevronRight size={14} className="text-dark-400" />
)}
</button>
{expandedSections.has('extractors') && (
<div className="p-2 space-y-1 max-h-48 overflow-y-auto">
{result.extractors_available.map((ext) => (
<div
key={ext.id}
className="flex items-center justify-between p-2 bg-dark-850 rounded hover:bg-dark-750 group transition-colors"
>
<div className="flex-1 min-w-0">
<p className="text-sm text-white">{ext.name}</p>
<p className="text-xs text-dark-500">
{ext.id}
{ext.description && ` - ${ext.description}`}
</p>
</div>
<button
onClick={() => addExtractorNode(ext)}
className="p-1.5 text-dark-500 hover:text-primary-400 hover:bg-dark-700 rounded
opacity-0 group-hover:opacity-100 transition-all"
title="Add Extractor"
>
<Plus size={14} />
</button>
</div>
))}
</div>
)}
</div>
{/* Dependent Files */}
{result.dependent_files.length > 0 && (
<div className="border border-dark-700 rounded-lg overflow-hidden">
<button
onClick={() => toggleSection('files')}
className="w-full flex items-center justify-between px-3 py-2 bg-dark-800 hover:bg-dark-750 transition-colors"
>
<div className="flex items-center gap-2">
<FileBox size={14} className="text-amber-400" />
<span className="text-sm font-medium text-white">
Dependent Files ({result.dependent_files.length})
</span>
</div>
{expandedSections.has('files') ? (
<ChevronDown size={14} className="text-dark-400" />
) : (
<ChevronRight size={14} className="text-dark-400" />
)}
</button>
{expandedSections.has('files') && (
<div className="p-2 space-y-1 max-h-32 overflow-y-auto">
{result.dependent_files.map((file) => (
<div
key={file.path}
className="flex items-center gap-2 p-2 bg-dark-850 rounded"
>
<FileBox size={14} className="text-dark-400 flex-shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm text-white truncate">{file.name}</p>
<p className="text-xs text-dark-500">{file.type}</p>
</div>
</div>
))}
</div>
)}
</div>
)}
{/* Warnings */}
{result.warnings.length > 0 && (
<div className="p-2 bg-amber-500/10 border border-amber-500/30 rounded-lg">
<div className="flex items-center gap-1.5 mb-1">
<AlertTriangle size={12} className="text-amber-400" />
<p className="text-xs text-amber-400 font-medium">Warnings</p>
</div>
{result.warnings.map((w, i) => (
<p key={i} className="text-xs text-amber-300">
{w}
</p>
))}
</div>
)}
</div>
) : (
<div className="flex items-center justify-center h-32 text-dark-500 text-sm">
Select a model to introspect
</div>
)}
</div>
</div>
);
}

View File

@@ -1,5 +1,9 @@
import { useState } from 'react';
import { FolderSearch, Microscope } from 'lucide-react';
import { useCanvasStore } from '../../../hooks/useCanvasStore';
import { ExpressionSelector } from './ExpressionSelector';
import { FileBrowser } from './FileBrowser';
import { IntrospectionPanel } from './IntrospectionPanel';
import {
ModelNodeData,
SolverNodeData,
@@ -24,6 +28,9 @@ export function NodeConfigPanel({ nodeId }: NodeConfigPanelProps) {
const { nodes, updateNodeData, deleteSelected } = useCanvasStore();
const node = nodes.find((n) => n.id === nodeId);
const [showFileBrowser, setShowFileBrowser] = useState(false);
const [showIntrospection, setShowIntrospection] = useState(false);
if (!node) return null;
const { data } = node;
@@ -63,15 +70,24 @@ export function NodeConfigPanel({ nodeId }: NodeConfigPanelProps) {
<>
<div>
<label className={labelClass}>
File Path
Model File
</label>
<input
type="text"
value={(data as ModelNodeData).filePath || ''}
onChange={(e) => handleChange('filePath', e.target.value)}
placeholder="path/to/model.prt"
className={`${inputClass} font-mono text-sm`}
/>
<div className="flex gap-2">
<input
type="text"
value={(data as ModelNodeData).filePath || ''}
onChange={(e) => handleChange('filePath', e.target.value)}
placeholder="path/to/model.sim"
className={`${inputClass} font-mono text-sm flex-1`}
/>
<button
onClick={() => setShowFileBrowser(true)}
className="px-3 py-2 bg-dark-700 hover:bg-dark-600 rounded-lg text-dark-300 hover:text-white transition-colors"
title="Browse files"
>
<FolderSearch size={18} />
</button>
</div>
</div>
<div>
<label className={labelClass}>
@@ -86,8 +102,21 @@ export function NodeConfigPanel({ nodeId }: NodeConfigPanelProps) {
<option value="prt">Part (.prt)</option>
<option value="fem">FEM (.fem)</option>
<option value="sim">Simulation (.sim)</option>
<option value="afem">Assembled FEM (.afem)</option>
</select>
</div>
{/* Introspect Button */}
{(data as ModelNodeData).filePath && (
<button
onClick={() => setShowIntrospection(true)}
className="w-full flex items-center justify-center gap-2 px-3 py-2.5 bg-primary-500/20
hover:bg-primary-500/30 border border-primary-500/30 rounded-lg
text-primary-400 text-sm font-medium transition-colors"
>
<Microscope size={16} />
Introspect Model
</button>
)}
</>
)}
@@ -385,6 +414,27 @@ export function NodeConfigPanel({ nodeId }: NodeConfigPanelProps) {
</>
)}
</div>
{/* File Browser Modal */}
<FileBrowser
isOpen={showFileBrowser}
onClose={() => setShowFileBrowser(false)}
onSelect={(path, fileType) => {
handleChange('filePath', path);
handleChange('fileType', fileType.replace('.', ''));
}}
fileTypes={['.sim', '.prt', '.fem', '.afem']}
/>
{/* Introspection Panel */}
{showIntrospection && (data as ModelNodeData).filePath && (
<div className="fixed top-20 right-96 z-40">
<IntrospectionPanel
filePath={(data as ModelNodeData).filePath!}
onClose={() => setShowIntrospection(false)}
/>
</div>
)}
</div>
);
}

View File

@@ -15,7 +15,7 @@ interface CanvasState {
onNodesChange: (changes: NodeChange[]) => void;
onEdgesChange: (changes: EdgeChange[]) => void;
onConnect: (connection: Connection) => void;
addNode: (type: NodeType, position: { x: number; y: number }) => void;
addNode: (type: NodeType, position: { x: number; y: number }, data?: Partial<CanvasNodeData>) => void;
updateNodeData: (nodeId: string, data: Partial<CanvasNodeData>) => void;
selectNode: (nodeId: string | null) => void;
selectEdge: (edgeId: string | null) => void;
@@ -112,12 +112,14 @@ export const useCanvasStore = create<CanvasState>((set, get) => ({
set({ edges: addEdge(connection, get().edges) });
},
addNode: (type, position) => {
addNode: (type, position, customData) => {
const newNode: Node<CanvasNodeData> = {
id: getNodeId(),
type,
position,
data: getDefaultData(type),
data: customData
? { ...getDefaultData(type), ...customData } as CanvasNodeData
: getDefaultData(type),
};
set({ nodes: [...get().nodes, newNode] });
},

View File

@@ -89,14 +89,63 @@ export interface CanvasEdge {
targetHandle?: string;
}
// Valid connections
// Valid connections - defines what a node can connect TO (as source)
// Flow: DesignVar -> Model -> Solver -> Extractor -> Objective/Constraint -> Algorithm -> Surrogate
export const VALID_CONNECTIONS: Record<NodeType, NodeType[]> = {
model: ['solver', 'designVar'],
solver: ['extractor'],
designVar: ['model'],
extractor: ['objective', 'constraint'],
objective: ['algorithm'],
constraint: ['algorithm'],
algorithm: ['surrogate'],
surrogate: [],
model: ['solver'], // Model outputs to Solver
solver: ['extractor'], // Solver outputs to Extractor
designVar: ['model'], // DesignVar outputs to Model (expressions feed into model)
extractor: ['objective', 'constraint'], // Extractor outputs to Objective/Constraint
objective: ['algorithm'], // Objective outputs to Algorithm
constraint: ['algorithm'], // Constraint outputs to Algorithm
algorithm: ['surrogate'], // Algorithm outputs to Surrogate
surrogate: [], // Surrogate is terminal
};
// Node handle configuration for proper flow direction
export interface HandleConfig {
id: string;
label?: string;
}
export interface NodeHandleConfig {
inputs: HandleConfig[];
outputs: HandleConfig[];
}
// Define handles for each node type
// Flow: DesignVar(s) -> Model -> Solver -> Extractor(s) -> Objective(s) -> Algorithm
export const NODE_HANDLES: Record<NodeType, NodeHandleConfig> = {
model: {
inputs: [{ id: 'params', label: 'Parameters' }], // Receives from DesignVars
outputs: [{ id: 'sim', label: 'Simulation' }], // Sends to Solver
},
solver: {
inputs: [{ id: 'model', label: 'Model' }], // Receives from Model
outputs: [{ id: 'results', label: 'Results' }], // Sends to Extractors
},
designVar: {
inputs: [], // No inputs - this is a source
outputs: [{ id: 'value', label: 'Value' }], // Sends to Model
},
extractor: {
inputs: [{ id: 'results', label: 'Results' }], // Receives from Solver
outputs: [{ id: 'value', label: 'Value' }], // Sends to Objective/Constraint
},
objective: {
inputs: [{ id: 'value', label: 'Value' }], // Receives from Extractor
outputs: [{ id: 'objective', label: 'Objective' }], // Sends to Algorithm
},
constraint: {
inputs: [{ id: 'value', label: 'Value' }], // Receives from Extractor
outputs: [{ id: 'constraint', label: 'Constraint' }], // Sends to Algorithm
},
algorithm: {
inputs: [{ id: 'objectives', label: 'Objectives' }], // Receives from Objectives/Constraints
outputs: [{ id: 'algo', label: 'Algorithm' }], // Sends to Surrogate
},
surrogate: {
inputs: [{ id: 'algo', label: 'Algorithm' }], // Receives from Algorithm
outputs: [], // No outputs - this is a sink
},
};

View File

@@ -74,13 +74,68 @@ export function validateGraph(
}
}
// Check connectivity
// Check connectivity - verify proper flow direction
// Design Variables should connect TO Model (as source -> target)
const modelNodes = nodes.filter(n => n.data.type === 'model');
for (const dvar of designVars) {
const connectsToModel = edges.some(e =>
e.source === dvar.id && modelNodes.some(m => m.id === e.target)
);
if (!connectsToModel) {
warnings.push(`${dvar.data.label} is not connected to a Model`);
}
}
// Model should connect TO Solver
const solverNodes = nodes.filter(n => n.data.type === 'solver');
for (const model of modelNodes) {
const connectsToSolver = edges.some(e =>
e.source === model.id && solverNodes.some(s => s.id === e.target)
);
if (!connectsToSolver) {
errors.push(`${model.data.label} is not connected to a Solver`);
}
}
// Solver should connect TO Extractors
for (const solver of solverNodes) {
const connectsToExtractor = edges.some(e =>
e.source === solver.id && extractors.some(ex => ex.id === e.target)
);
if (!connectsToExtractor) {
warnings.push(`${solver.data.label} is not connected to any Extractor`);
}
}
// Extractors should connect TO Objectives or Constraints
const objectives = nodes.filter(n => n.data.type === 'objective');
const constraints = nodes.filter(n => n.data.type === 'constraint');
for (const extractor of extractors) {
const connectsToObjective = edges.some(e =>
e.source === extractor.id &&
(objectives.some(obj => obj.id === e.target) || constraints.some(c => c.id === e.target))
);
if (!connectsToObjective) {
warnings.push(`${extractor.data.label} is not connected to any Objective or Constraint`);
}
}
// Objectives should connect TO Algorithm
const algorithmNodes = nodes.filter(n => n.data.type === 'algorithm');
for (const obj of objectives) {
const hasIncoming = edges.some(e => e.target === obj.id);
const hasIncoming = edges.some(e =>
extractors.some(ex => ex.id === e.source) && e.target === obj.id
);
if (!hasIncoming) {
errors.push(`${obj.data.label} has no connected extractor`);
}
const connectsToAlgorithm = edges.some(e =>
e.source === obj.id && algorithmNodes.some(a => a.id === e.target)
);
if (!connectsToAlgorithm) {
warnings.push(`${obj.data.label} is not connected to an Algorithm`);
}
}
return {

View File

@@ -0,0 +1,452 @@
# Canvas UX Design - Study Management Flow
**Created**: January 16, 2026
**Status**: Design Phase
---
## Problem Statement
The Canvas Builder needs clear answers to these questions:
1. **When processing a canvas**, does it overwrite the current study or create a new one?
2. **How do users start fresh** - create a new study from scratch?
3. **How does the Home page** handle study selection vs. creation?
4. **What's the relationship** between Canvas, Dashboard, and study context?
---
## Proposed Solution: Study-Aware Canvas
### Core Concepts
| Concept | Description |
|---------|-------------|
| **Study Context** | Global state tracking which study is "active" |
| **Canvas Mode** | Either "editing existing" or "creating new" |
| **Process Dialog** | Explicit choice: update vs. create new |
| **Study Browser** | Unified view for selecting/creating studies |
---
## User Flows
### Flow 1: Open Existing Study → View/Edit → Run
```
Home Page
├── Study List shows all studies with status
│ • bracket_v3 [Running] 47/100 trials
│ • mirror_wfe_v2 [Paused] 23/50 trials
│ • beam_freq [Complete] 100/100 trials
└── User clicks "bracket_v3"
┌─────────────────────────────────────┐
│ Study: bracket_v3 │
│ │
│ ┌─────────────────────────────────┐ │
│ │ Dashboard │ Results │ Canvas │ │
│ └─────────────────────────────────┘ │
│ │
│ [Run Optimization] [Edit Canvas] │
└─────────────────────────────────────┘
├── "Dashboard" → Live monitoring
├── "Results" → Reports & analysis
└── "Canvas" → View/edit workflow
Canvas loads from optimization_config.json
Shows all nodes + connections
User can modify and re-process
```
### Flow 2: Create New Study from Scratch
```
Home Page
└── User clicks [+ New Study]
┌─────────────────────────────────────┐
│ Create New Study │
│ │
│ Study Name: [my_new_bracket_____] │
│ │
│ Category: [Bracket ▼] │
│ ├─ Bracket │
│ ├─ Beam │
│ ├─ Mirror │
│ └─ + New Category... │
│ │
│ Start with: │
│ ● Blank Canvas │
│ ○ Template: [Mass Minimization ▼] │
│ ○ Copy from: [bracket_v3 ▼] │
│ │
│ [Cancel] [Create Study] │
└─────────────────────────────────────┘
Backend creates folder structure:
studies/Bracket/my_new_bracket/
├── 1_config/
├── 2_iterations/
└── 3_results/
Opens Canvas in "New Study" mode
Header shows: "Creating: my_new_bracket"
Canvas is blank (or has template)
```
### Flow 3: Canvas → Process → Create/Update Decision
```
User is in Canvas (either mode)
└── Clicks [Process with Claude]
┌─────────────────────────────────────────────┐
│ Generate Optimization │
│ │
│ Claude will generate: │
│ • optimization_config.json │
│ • run_optimization.py │
│ │
│ ─────────────────────────────────────────── │
│ │
│ If editing existing study: │
│ ┌─────────────────────────────────────────┐ │
│ │ ● Update current study │ │
│ │ Will modify: bracket_v3 │ │
│ │ ⚠ Overwrites existing config │ │
│ │ │ │
│ │ ○ Create new study │ │
│ │ Name: [bracket_v4_____________] │ │
│ │ □ Copy model files from bracket_v3 │ │
│ └─────────────────────────────────────────┘ │
│ │
│ If creating new study: │
│ ┌─────────────────────────────────────────┐ │
│ │ Creating: my_new_bracket │ │
│ │ │ │
│ │ Model files needed: │
│ │ □ Upload .prt file │
│ │ □ Upload .sim file │
│ │ - or - │
│ │ □ Copy from: [bracket_v3 ▼] │ │
│ └─────────────────────────────────────────┘ │
│ │
│ [Cancel] [Generate & Create] │
└─────────────────────────────────────────────┘
Claude processes intent:
1. Validates configuration
2. Generates optimization_config.json
3. Creates run_optimization.py
4. If new study: switches context to it
Success dialog:
┌─────────────────────────────────────┐
│ ✓ Study Created Successfully │
│ │
│ bracket_v4 is ready to run │
│ │
│ [View Dashboard] [Run Optimization]│
└─────────────────────────────────────┘
```
---
## UI Components
### 1. Home Page (Redesigned)
```
┌─────────────────────────────────────────────────────────────────┐
│ Atomizer [+ New Study]│
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ Search studies... 🔍 │ │
│ └────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────┐│
│ │ Recent Studies ││
│ │ ││
│ │ ┌─────────────────────────────────────────────────────────┐││
│ │ │ 🟢 bracket_v3 Running │││
│ │ │ Bracket • 47/100 trials • Best: 2.34 kg │││
│ │ │ [Dashboard] [Canvas] [Results] │││
│ │ └─────────────────────────────────────────────────────────┘││
│ │ ││
│ │ ┌─────────────────────────────────────────────────────────┐││
│ │ │ 🟡 mirror_wfe_v2 Paused │││
│ │ │ Mirror • 23/50 trials • Best: 5.67 nm │││
│ │ │ [Dashboard] [Canvas] [Results] │││
│ │ └─────────────────────────────────────────────────────────┘││
│ │ ││
│ │ ┌─────────────────────────────────────────────────────────┐││
│ │ │ ✓ beam_freq_tuning Completed │││
│ │ │ Beam • 100/100 trials • Best: 0.12 Hz error │││
│ │ │ [Dashboard] [Canvas] [Results] │││
│ │ └─────────────────────────────────────────────────────────┘││
│ └─────────────────────────────────────────────────────────────┘│
│ │
│ Categories: [All] [Bracket] [Beam] [Mirror] │
│ │
└─────────────────────────────────────────────────────────────────┘
```
### 2. Study Header (When Study Selected)
```
┌─────────────────────────────────────────────────────────────────┐
│ ← Back bracket_v3 🟢 Running 47/100 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────┬──────────┬──────────┬──────────┐ │
│ │Dashboard │ Results │ Canvas │ Settings │ │
│ └──────────┴──────────┴──────────┴──────────┘ │
│ │
│ [Run Optimization ▶] [Pause ⏸] [Generate Report] │
│ │
└─────────────────────────────────────────────────────────────────┘
```
### 3. Canvas Header (Context-Aware)
**When editing existing study:**
```
┌─────────────────────────────────────────────────────────────────┐
│ Canvas Builder Editing: bracket_v3 │
├─────────────────────────────────────────────────────────────────┤
│ [Templates] [Import] 5 nodes [Validate] [Process ▶] │
└─────────────────────────────────────────────────────────────────┘
```
**When creating new study:**
```
┌─────────────────────────────────────────────────────────────────┐
│ Canvas Builder Creating: my_new_bracket │
├─────────────────────────────────────────────────────────────────┤
│ [Templates] [Import] 0 nodes [Validate] [Create Study ▶] │
└─────────────────────────────────────────────────────────────────┘
```
### 4. Process/Create Dialog
```
┌─────────────────────────────────────────────────────────────────┐
│ Generate Optimization ✕ │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Claude will analyze your workflow and generate: │
│ • optimization_config.json │
│ • run_optimization.py (using Atomizer protocols) │
│ │
│ ───────────────────────────────────────────────────────────────│
│ │
│ Save to: │
│ │
│ ○ Update existing study │
│ └─ bracket_v3 (overwrites current config) │
│ │
│ ● Create new study │
│ └─ Study name: [bracket_v4_______________] │
│ Category: [Bracket ▼] │
│ ☑ Copy model files from bracket_v3 │
│ │
│ ───────────────────────────────────────────────────────────────│
│ │
│ After creation: │
│ ☑ Open new study automatically │
│ ☐ Start optimization immediately │
│ │
│ [Cancel] [Generate] │
└─────────────────────────────────────────────────────────────────┘
```
---
## Technical Architecture
### 1. Global Study Context
```typescript
// contexts/StudyContext.tsx
interface Study {
path: string; // "Bracket/bracket_v3"
name: string; // "bracket_v3"
category: string; // "Bracket"
status: 'not_started' | 'running' | 'paused' | 'completed';
trialCount: number;
maxTrials: number;
bestValue?: number;
hasConfig: boolean;
}
interface StudyContextState {
// Current selection
currentStudy: Study | null;
isCreatingNew: boolean;
pendingStudyName: string | null;
// Actions
selectStudy: (studyPath: string) => Promise<void>;
createNewStudy: (name: string, category: string) => Promise<Study>;
closeStudy: () => void;
refreshStudies: () => Promise<void>;
// All studies
studies: Study[];
categories: string[];
}
```
### 2. Canvas Store Enhancement
```typescript
// hooks/useCanvasStore.ts additions
interface CanvasState {
// Existing...
nodes: Node[];
edges: Edge[];
// New: Study context
sourceStudyPath: string | null; // Where loaded from (null = new)
isModified: boolean; // Has unsaved changes
// Actions
loadFromStudy: (studyPath: string) => Promise<void>;
saveToStudy: (studyPath: string, overwrite: boolean) => Promise<void>;
markClean: () => void;
}
```
### 3. Backend API Additions
```python
# api/routes/studies.py
@router.post("/")
async def create_study(request: CreateStudyRequest):
"""Create new study folder structure."""
# Creates:
# studies/{category}/{name}/
# ├── 1_config/
# ├── 2_iterations/
# └── 3_results/
pass
@router.post("/{study_path}/copy-models")
async def copy_model_files(study_path: str, source_study: str):
"""Copy .prt, .sim, .fem files from another study."""
pass
@router.post("/{study_path}/generate")
async def generate_from_intent(study_path: str, intent: dict, overwrite: bool = False):
"""Generate optimization_config.json and run_optimization.py from canvas intent."""
pass
@router.get("/categories")
async def list_categories():
"""List all study categories."""
pass
```
### 4. File Structure
When a new study is created:
```
studies/
└── Bracket/
└── my_new_bracket/
├── 1_config/
│ └── (empty until "Process")
├── 2_iterations/
│ └── (empty until optimization runs)
└── 3_results/
└── (empty until optimization runs)
```
After "Process with Claude":
```
studies/
└── Bracket/
└── my_new_bracket/
├── 1_config/
│ ├── optimization_config.json ← Generated
│ └── workflow_config.json ← Generated (optional)
├── 2_iterations/
├── 3_results/
├── model.prt ← Copied from source
├── model_sim1.sim ← Copied from source
└── run_optimization.py ← Generated
```
---
## Workflow Summary
### Starting Points
| Entry Point | Context | Canvas Mode |
|-------------|---------|-------------|
| Home → Click study → Canvas | Existing study | Edit existing |
| Home → New Study → Blank | New study | Create new |
| Home → New Study → Template | New study | Create new (pre-filled) |
| Home → New Study → Copy from | New study | Create new (pre-filled) |
### Process Outcomes
| Canvas Mode | Process Choice | Result |
|-------------|----------------|--------|
| Edit existing | Update current | Overwrites config in same study |
| Edit existing | Create new | Creates new study, switches to it |
| Create new | (only option) | Creates files in new study |
### Post-Process Navigation
| Option | Action |
|--------|--------|
| Open Dashboard | Navigate to /dashboard with new study |
| Run Optimization | Start run_optimization.py, show Dashboard |
| Stay in Canvas | Keep editing (for iterations) |
---
## Benefits
1. **Clear mental model**: Users always know if they're editing or creating
2. **No accidental overwrites**: Explicit choice with warning
3. **Version control friendly**: Easy to create v2, v3, etc.
4. **Discoverable**: Home page shows all studies at a glance
5. **Flexible entry points**: Multiple ways to start/continue work
---
## Implementation Priority
1. **Home Page redesign** with study list and "New Study" button
2. **Study context** global state
3. **Create Study dialog** with options
4. **Process dialog** with update/create choice
5. **Canvas context awareness** (header shows current study)
6. **Backend endpoints** for study creation and file generation
---
*This design enables a clean, intuitive workflow from study discovery to optimization execution.*

View File

@@ -0,0 +1,619 @@
# Canvas V3 - Comprehensive Fix & Enhancement Plan
**Created**: January 16, 2026
**Status**: Planning
**Priority**: High
---
## Problem Analysis
### Critical Bugs (Must Fix)
| Issue | Impact | Root Cause (Likely) |
|-------|--------|---------------------|
| **Atomizer Assistant broken** | High - core feature unusable | WebSocket connection or chat hook error |
| **Cannot delete connections** | High - workflow editing blocked | Missing edge selection/delete handler |
| **Drag & drop positioning wrong** | Medium - UX frustration | Position calculation not accounting for scroll/zoom |
### Data Loading Issues
| Issue | Impact | Root Cause (Likely) |
|-------|--------|---------------------|
| **Auto-connect missing** | High - manual work required | `loadFromConfig` creates nodes but not edges |
| **Missing elements (OPD extractor)** | High - incomplete workflows | Incomplete parsing of `optimization_config.json` |
| **Constraints not shown** | High - incomplete workflows | Constraints not parsed from config |
| **Algorithm not pre-selected** | Medium - extra clicks | Algorithm node not created from config |
### UI/UX Issues
| Issue | Impact | Root Cause (Likely) |
|-------|--------|---------------------|
| **Interface too small** | Medium - wasted screen space | Fixed dimensions, not responsive |
| **Insufficient contrast** | Medium - accessibility | White text on light blue background |
| **Font size too small** | Low - readability | Hardcoded small font sizes |
### Feature Requests
| Feature | Value | Complexity |
|---------|-------|------------|
| **Auto-complete with Claude** | High - smart assistance | Medium |
| **Templates/guidelines** | Medium - onboarding | Low |
| **Canvas ↔ Assistant integration** | High - conversational control | High |
---
## Implementation Strategy
### Phased Approach
```
Phase 1: Critical Fixes (2 hours)
Phase 2: Data Loading (3 hours)
Phase 3: UI/UX Polish (2 hours)
Phase 4: Claude Integration (3 hours)
Phase 5: Testing & Commit (1 hour)
```
---
## Phase 1: Critical Bug Fixes
### 1.1 Fix Atomizer Assistant Error
**Investigation Steps:**
1. Check `ChatPanel.tsx` for error handling
2. Check `useCanvasChat.ts` hook for connection issues
3. Verify WebSocket endpoint `/api/chat/` is working
4. Check if `useChat.ts` base hook has errors
**Likely Fix:**
- Add error boundary around chat component
- Add null checks for WebSocket connection
- Provide fallback UI when chat unavailable
### 1.2 Enable Connection Deletion
**Current State:** Edges can't be selected or deleted
**Implementation:**
```tsx
// In AtomizerCanvas.tsx
<ReactFlow
edgesUpdatable={true}
edgesFocusable={true}
deleteKeyCode={['Backspace', 'Delete']}
onEdgeClick={(event, edge) => selectEdge(edge.id)}
/>
```
**Store Update:**
```typescript
// In useCanvasStore.ts
deleteEdge: (edgeId: string) => {
set((state) => ({
edges: state.edges.filter((e) => e.id !== edgeId),
}));
},
```
### 1.3 Fix Drag & Drop Positioning
**Problem:** New nodes appear at wrong position (not where dropped)
**Fix in AtomizerCanvas.tsx:**
```typescript
const onDrop = useCallback((event: DragEvent) => {
event.preventDefault();
if (!reactFlowInstance.current || !reactFlowWrapper.current) return;
const type = event.dataTransfer.getData('application/reactflow') as NodeType;
if (!type) return;
// Get correct position accounting for viewport transform
const bounds = reactFlowWrapper.current.getBoundingClientRect();
const position = reactFlowInstance.current.screenToFlowPosition({
x: event.clientX - bounds.left,
y: event.clientY - bounds.top,
});
addNode(type, position);
}, [addNode]);
```
---
## Phase 2: Data Loading Improvements
### 2.1 Enhanced `loadFromConfig` Function
**Goal:** When loading a study, create ALL nodes AND edges automatically.
**Current Problems:**
- Nodes created but not connected
- Some extractors/constraints missing
- Algorithm not created
**New Implementation Strategy:**
```typescript
loadFromConfig: (config: OptimizationConfig) => {
const nodes: Node[] = [];
const edges: Edge[] = [];
// Layout constants
const COLS = { model: 50, dvar: 50, solver: 250, extractor: 450, obj: 650, algo: 850 };
const ROW_HEIGHT = 100;
const START_Y = 100;
// Track IDs for connections
const nodeIds = {
model: '',
solver: '',
dvars: [] as string[],
extractors: {} as Record<string, string>, // extractor_id -> node_id
objectives: [] as string[],
constraints: [] as string[],
algorithm: '',
};
// 1. Create Model Node
if (config.nx_model) {
const id = `model_${Date.now()}`;
nodeIds.model = id;
nodes.push({
id,
type: 'model',
position: { x: COLS.model, y: START_Y },
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',
},
});
}
// 2. Create Solver Node
if (config.solver) {
const id = `solver_${Date.now()}`;
nodeIds.solver = id;
nodes.push({
id,
type: 'solver',
position: { x: COLS.solver, y: START_Y },
data: {
type: 'solver',
label: 'Solver',
configured: true,
solverType: `SOL${config.solver.solution_type}`,
},
});
// Connect Model → Solver
if (nodeIds.model) {
edges.push({
id: `e_model_solver`,
source: nodeIds.model,
target: nodeIds.solver,
animated: true,
});
}
}
// 3. Create Design Variables (connected to Model)
config.design_variables?.forEach((dv, i) => {
const id = `dvar_${i}_${Date.now()}`;
nodeIds.dvars.push(id);
nodes.push({
id,
type: 'designVar',
position: { x: COLS.dvar, y: START_Y + 150 + i * ROW_HEIGHT },
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,
},
});
// Connect DVar → Model (or keep disconnected, they're inputs)
});
// 4. Create Extractors from objectives AND constraints
const allExtractors = new Set<string>();
config.objectives?.forEach(obj => allExtractors.add(obj.extractor_id));
config.constraints?.forEach(con => {
if (con.extractor_id) allExtractors.add(con.extractor_id);
});
let extractorRow = 0;
allExtractors.forEach((extractorId) => {
const id = `extractor_${extractorId}_${Date.now()}`;
nodeIds.extractors[extractorId] = id;
// Find extractor config
const objWithExt = config.objectives?.find(o => o.extractor_id === extractorId);
const conWithExt = config.constraints?.find(c => c.extractor_id === extractorId);
nodes.push({
id,
type: 'extractor',
position: { x: COLS.extractor, y: START_Y + extractorRow * ROW_HEIGHT },
data: {
type: 'extractor',
label: extractorId,
configured: true,
extractorId: extractorId,
extractorName: objWithExt?.name || conWithExt?.name || extractorId,
},
});
extractorRow++;
// Connect Solver → Extractor
if (nodeIds.solver) {
edges.push({
id: `e_solver_${extractorId}`,
source: nodeIds.solver,
target: id,
});
}
});
// 5. Create Objectives (connected to Extractors)
config.objectives?.forEach((obj, i) => {
const id = `obj_${i}_${Date.now()}`;
nodeIds.objectives.push(id);
nodes.push({
id,
type: 'objective',
position: { x: COLS.obj, y: START_Y + i * ROW_HEIGHT },
data: {
type: 'objective',
label: obj.name,
configured: true,
name: obj.name,
direction: obj.direction,
weight: obj.weight,
},
});
// Connect Extractor → Objective
const extractorNodeId = nodeIds.extractors[obj.extractor_id];
if (extractorNodeId) {
edges.push({
id: `e_ext_obj_${i}`,
source: extractorNodeId,
target: id,
});
}
});
// 6. Create Constraints (connected to Extractors)
config.constraints?.forEach((con, i) => {
const id = `con_${i}_${Date.now()}`;
nodeIds.constraints.push(id);
const objY = START_Y + (config.objectives?.length || 0) * ROW_HEIGHT;
nodes.push({
id,
type: 'constraint',
position: { x: COLS.obj, y: objY + i * ROW_HEIGHT },
data: {
type: 'constraint',
label: con.name,
configured: true,
name: con.name,
operator: con.type === 'upper' ? '<=' : con.type === 'lower' ? '>=' : '==',
value: con.upper_bound ?? con.lower_bound ?? con.target,
},
});
// Connect Extractor → Constraint
if (con.extractor_id) {
const extractorNodeId = nodeIds.extractors[con.extractor_id];
if (extractorNodeId) {
edges.push({
id: `e_ext_con_${i}`,
source: extractorNodeId,
target: id,
});
}
}
});
// 7. Create Algorithm Node
if (config.optimization) {
const id = `algo_${Date.now()}`;
nodeIds.algorithm = id;
nodes.push({
id,
type: 'algorithm',
position: { x: COLS.algo, y: START_Y },
data: {
type: 'algorithm',
label: 'Algorithm',
configured: true,
method: config.optimization.sampler || 'TPE',
maxTrials: config.optimization.n_trials || 100,
},
});
// Connect Objectives → Algorithm
nodeIds.objectives.forEach((objId, i) => {
edges.push({
id: `e_obj_algo_${i}`,
source: objId,
target: id,
});
});
}
// 8. Create Surrogate Node (if enabled)
if (config.surrogate?.enabled) {
const id = `surrogate_${Date.now()}`;
nodes.push({
id,
type: 'surrogate',
position: { x: COLS.algo, y: START_Y + 150 },
data: {
type: 'surrogate',
label: 'Surrogate',
configured: true,
enabled: true,
modelType: config.surrogate.type || 'MLP',
minTrials: config.surrogate.min_trials || 20,
},
});
}
// Apply to store
set({
nodes,
edges,
selectedNode: null,
validation: { valid: false, errors: [], warnings: [] },
});
};
```
### 2.2 Parse Full Config Structure
**Ensure we handle:**
- `nx_model.sim_path` / `prt_path` / `fem_path`
- `solver.solution_type`
- `design_variables[]` with all fields
- `objectives[]` with `extractor_id`, `name`, `direction`, `weight`
- `constraints[]` with `extractor_id`, `type`, `upper_bound`, `lower_bound`
- `optimization.sampler`, `n_trials`
- `surrogate.enabled`, `type`, `min_trials`
- `post_processing[]` (for future)
---
## Phase 3: UI/UX Polish
### 3.1 Responsive Full-Screen Canvas
**CanvasView.tsx:**
```tsx
export function CanvasView() {
return (
<div className="h-screen w-screen flex flex-col overflow-hidden">
{/* Minimal header */}
<header className="flex-shrink-0 h-10 bg-dark-900 border-b border-dark-700 px-4 flex items-center justify-between">
<span className="text-sm font-medium text-white">Canvas Builder</span>
<div className="flex gap-2">
<button>Templates</button>
<button>Import</button>
<button>Clear</button>
</div>
</header>
{/* Canvas fills remaining space */}
<main className="flex-1 min-h-0">
<AtomizerCanvas />
</main>
</div>
);
}
```
### 3.2 Fix Contrast Issues
**Problem:** White text on light blue is hard to read
**Solution:** Use darker backgrounds or different text colors
```css
/* Node backgrounds */
.bg-dark-850 /* #0A1525 - dark enough for white text */
/* Avoid light blue backgrounds with white text */
/* If using blue, use dark blue (#1e3a5f) or switch to light text */
/* Specific fixes */
.node-header {
background: #0F1E32; /* dark-800 */
color: #FFFFFF;
}
.node-content {
background: #0A1525; /* dark-850 */
color: #E2E8F0; /* light gray */
}
/* Badge/pill text */
.badge-primary {
background: rgba(0, 212, 230, 0.15); /* primary with low opacity */
color: #00D4E6; /* primary-400 */
border: 1px solid rgba(0, 212, 230, 0.3);
}
```
### 3.3 Increase Font Sizes
**Current vs New:**
| Element | Current | New |
|---------|---------|-----|
| Node label | 12px | 14px |
| Node detail | 10px | 12px |
| Palette item | 12px | 14px |
| Panel headers | 14px | 16px |
| Config labels | 10px | 12px |
---
## Phase 4: Claude Integration
### 4.1 Fix Chat Panel Connection
**Error Handling:**
```tsx
function ChatPanel({ onClose }: ChatPanelProps) {
const { messages, isConnected, isThinking, error } = useCanvasChat();
if (error) {
return (
<div className="flex flex-col items-center justify-center h-full p-4">
<AlertCircle className="text-red-400 mb-2" size={24} />
<p className="text-red-400 text-sm text-center">{error}</p>
<button
onClick={reconnect}
className="mt-4 px-3 py-1.5 bg-dark-700 rounded text-sm"
>
Retry Connection
</button>
</div>
);
}
// ... rest of component
}
```
### 4.2 Auto-Complete with Claude
**Concept:** A button that sends current canvas state to Claude and asks for suggestions.
**UI:**
- Button: "Complete with Claude" next to Validate
- Opens chat panel with Claude's analysis
- Claude suggests: missing nodes, connections, configuration improvements
**Implementation:**
```typescript
// In useCanvasChat.ts
const autoCompleteWithClaude = useCallback(async () => {
const intent = toIntent();
const message = `Analyze this Canvas workflow and suggest what's missing or could be improved:
\`\`\`json
${JSON.stringify(intent, null, 2)}
\`\`\`
Please:
1. Identify any missing required components
2. Suggest extractors that should be added based on the objectives
3. Recommend connections that should be made
4. Propose any configuration improvements
Be specific and actionable.`;
await sendMessage(message);
}, [toIntent, sendMessage]);
```
### 4.3 Canvas ↔ Assistant Integration (Future)
**Vision:** Claude can modify the canvas through conversation.
**Commands:**
- "Add a displacement extractor"
- "Connect the mass extractor to objective 1"
- "Set the algorithm to CMA-ES with 200 trials"
- "Load the bracket_v3 study"
**Implementation Approach:**
1. Define canvas manipulation actions as Claude tools
2. Parse Claude responses for action intents
3. Execute actions via store methods
**MCP Tools (New):**
- `canvas_add_node` - Add a node of specified type
- `canvas_remove_node` - Remove a node by ID
- `canvas_connect` - Connect two nodes
- `canvas_disconnect` - Remove a connection
- `canvas_configure` - Update node configuration
- `canvas_load_study` - Load a study into canvas
---
## Phase 5: Testing & Validation
### Test Cases
| Test | Steps | Expected |
|------|-------|----------|
| Load study with connections | Import → Select study → Load | All nodes + edges appear |
| Delete connection | Click edge → Press Delete | Edge removed |
| Drag & drop position | Drag node to specific spot | Node appears at drop location |
| Chat panel opens | Click chat icon | Panel opens without error |
| Full screen canvas | Open /canvas | Canvas fills window |
| Contrast readable | View all nodes | All text legible |
### Build Verification
```bash
cd atomizer-dashboard/frontend
npm run build
# Must pass without errors
```
---
## Files to Modify
| File | Changes |
|------|---------|
| `useCanvasStore.ts` | Enhanced `loadFromConfig`, `deleteEdge` |
| `AtomizerCanvas.tsx` | Edge deletion, drag/drop fix, responsive |
| `CanvasView.tsx` | Full-screen layout |
| `ChatPanel.tsx` | Error handling, reconnect |
| `useCanvasChat.ts` | Auto-complete function, error state |
| `BaseNode.tsx` | Font sizes, contrast |
| `NodePalette.tsx` | Font sizes |
| `NodeConfigPanel.tsx` | Font sizes, contrast |
---
## Summary
**Total Effort:** ~11 hours across 5 phases
**Priority Order:**
1. Fix Atomizer Assistant (blocking)
2. Fix connection deletion (blocking editing)
3. Fix data loading (core functionality)
4. UI/UX polish (user experience)
5. Claude integration (enhancement)
**Success Criteria:**
- [ ] All bugs from user report fixed
- [ ] Loading a study shows ALL elements with connections
- [ ] Canvas is responsive and readable
- [ ] Chat panel works without errors
- [ ] Build passes without errors
---
*Plan created for Ralph Loop autonomous execution.*

File diff suppressed because it is too large Load Diff