From 1c7c7aff05009a0247c2eed241f63a326bbaa6b9 Mon Sep 17 00:00:00 2001 From: Anto01 Date: Fri, 16 Jan 2026 14:47:10 -0500 Subject: [PATCH] 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 --- atomizer-dashboard/backend/api/main.py | 20 +- .../backend/api/routes/files.py | 155 ++ atomizer-dashboard/backend/api/routes/nx.py | 90 + .../backend/api/services/nx_introspection.py | 317 +++ .../components/canvas/panels/FileBrowser.tsx | 248 +++ .../canvas/panels/IntrospectionPanel.tsx | 376 ++++ .../canvas/panels/NodeConfigPanel.tsx | 66 +- .../frontend/src/hooks/useCanvasStore.ts | 8 +- .../frontend/src/lib/canvas/schema.ts | 67 +- .../frontend/src/lib/canvas/validation.ts | 59 +- docs/plans/CANVAS_UX_DESIGN.md | 452 ++++ docs/plans/CANVAS_V3_PLAN.md | 619 ++++++ docs/plans/RALPH_LOOP_CANVAS_V4.md | 1949 +++++++++++++++++ 13 files changed, 4401 insertions(+), 25 deletions(-) create mode 100644 atomizer-dashboard/backend/api/routes/files.py create mode 100644 atomizer-dashboard/backend/api/routes/nx.py create mode 100644 atomizer-dashboard/backend/api/services/nx_introspection.py create mode 100644 atomizer-dashboard/frontend/src/components/canvas/panels/FileBrowser.tsx create mode 100644 atomizer-dashboard/frontend/src/components/canvas/panels/IntrospectionPanel.tsx create mode 100644 docs/plans/CANVAS_UX_DESIGN.md create mode 100644 docs/plans/CANVAS_V3_PLAN.md create mode 100644 docs/plans/RALPH_LOOP_CANVAS_V4.md diff --git a/atomizer-dashboard/backend/api/main.py b/atomizer-dashboard/backend/api/main.py index e1fe1db7..148089b9 100644 --- a/atomizer-dashboard/backend/api/main.py +++ b/atomizer-dashboard/backend/api/main.py @@ -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 diff --git a/atomizer-dashboard/backend/api/routes/files.py b/atomizer-dashboard/backend/api/routes/files.py new file mode 100644 index 00000000..8df29b5c --- /dev/null +++ b/atomizer-dashboard/backend/api/routes/files.py @@ -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 diff --git a/atomizer-dashboard/backend/api/routes/nx.py b/atomizer-dashboard/backend/api/routes/nx.py new file mode 100644 index 00000000..55453a08 --- /dev/null +++ b/atomizer-dashboard/backend/api/routes/nx.py @@ -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, + } diff --git a/atomizer-dashboard/backend/api/services/nx_introspection.py b/atomizer-dashboard/backend/api/services/nx_introspection.py new file mode 100644 index 00000000..04d2530d --- /dev/null +++ b/atomizer-dashboard/backend/api/services/nx_introspection.py @@ -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 diff --git a/atomizer-dashboard/frontend/src/components/canvas/panels/FileBrowser.tsx b/atomizer-dashboard/frontend/src/components/canvas/panels/FileBrowser.tsx new file mode 100644 index 00000000..6fb27696 --- /dev/null +++ b/atomizer-dashboard/frontend/src/components/canvas/panels/FileBrowser.tsx @@ -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([]); + const [searchTerm, setSearchTerm] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(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 ( +
+
+ {/* Header */} +
+

Select Model File

+ +
+ + {/* Search */} +
+
+ + 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" + /> +
+
+ Looking for: + {fileTypes.map((t) => ( + + {t} + + ))} +
+
+ + {/* Path breadcrumb */} +
+ + {pathParts.map((part, i) => ( + + + + + ))} +
+ + {/* File list */} +
+ {isLoading ? ( +
+ + Loading... +
+ ) : error ? ( +
+ {error} +
+ ) : filteredFiles.length === 0 ? ( +
+ {searchTerm ? 'No matching files found' : 'No model files in this directory'} +
+ ) : ( +
+ {/* Show parent directory link if not at root */} + {currentPath && ( + + )} + + {filteredFiles.map((file) => ( + + ))} +
+ )} +
+ + {/* Footer */} +
+ + +
+
+
+ ); +} diff --git a/atomizer-dashboard/frontend/src/components/canvas/panels/IntrospectionPanel.tsx b/atomizer-dashboard/frontend/src/components/canvas/panels/IntrospectionPanel.tsx new file mode 100644 index 00000000..e8982b66 --- /dev/null +++ b/atomizer-dashboard/frontend/src/components/canvas/panels/IntrospectionPanel.tsx @@ -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(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [expandedSections, setExpandedSections] = useState>( + 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 ( +
+ {/* Header */} +
+
+ + Model Introspection +
+
+ + +
+
+ + {/* Search */} +
+ 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" + /> +
+ + {/* Content */} +
+ {isLoading ? ( +
+ + Analyzing model... +
+ ) : error ? ( +
{error}
+ ) : result ? ( +
+ {/* Solver Type */} + {result.solver_type && ( +
+
+ + Solver: + {result.solver_type} +
+
+ )} + + {/* Expressions Section */} +
+ + + {expandedSections.has('expressions') && ( +
+ {filteredExpressions.length === 0 ? ( +

+ No expressions found +

+ ) : ( + filteredExpressions.map((expr) => ( +
+
+

{expr.name}

+

+ {expr.value} {expr.unit} + {expr.source === 'inferred' && ( + (inferred) + )} +

+
+ +
+ )) + )} +
+ )} +
+ + {/* Extractors Section */} +
+ + + {expandedSections.has('extractors') && ( +
+ {result.extractors_available.map((ext) => ( +
+
+

{ext.name}

+

+ {ext.id} + {ext.description && ` - ${ext.description}`} +

+
+ +
+ ))} +
+ )} +
+ + {/* Dependent Files */} + {result.dependent_files.length > 0 && ( +
+ + + {expandedSections.has('files') && ( +
+ {result.dependent_files.map((file) => ( +
+ +
+

{file.name}

+

{file.type}

+
+
+ ))} +
+ )} +
+ )} + + {/* Warnings */} + {result.warnings.length > 0 && ( +
+
+ +

Warnings

+
+ {result.warnings.map((w, i) => ( +

+ {w} +

+ ))} +
+ )} +
+ ) : ( +
+ Select a model to introspect +
+ )} +
+
+ ); +} diff --git a/atomizer-dashboard/frontend/src/components/canvas/panels/NodeConfigPanel.tsx b/atomizer-dashboard/frontend/src/components/canvas/panels/NodeConfigPanel.tsx index 79f40d4e..4e1bd328 100644 --- a/atomizer-dashboard/frontend/src/components/canvas/panels/NodeConfigPanel.tsx +++ b/atomizer-dashboard/frontend/src/components/canvas/panels/NodeConfigPanel.tsx @@ -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) { <>
- handleChange('filePath', e.target.value)} - placeholder="path/to/model.prt" - className={`${inputClass} font-mono text-sm`} - /> +
+ handleChange('filePath', e.target.value)} + placeholder="path/to/model.sim" + className={`${inputClass} font-mono text-sm flex-1`} + /> + +
+ {/* Introspect Button */} + {(data as ModelNodeData).filePath && ( + + )} )} @@ -385,6 +414,27 @@ export function NodeConfigPanel({ nodeId }: NodeConfigPanelProps) { )} + + {/* File Browser Modal */} + setShowFileBrowser(false)} + onSelect={(path, fileType) => { + handleChange('filePath', path); + handleChange('fileType', fileType.replace('.', '')); + }} + fileTypes={['.sim', '.prt', '.fem', '.afem']} + /> + + {/* Introspection Panel */} + {showIntrospection && (data as ModelNodeData).filePath && ( +
+ setShowIntrospection(false)} + /> +
+ )} ); } diff --git a/atomizer-dashboard/frontend/src/hooks/useCanvasStore.ts b/atomizer-dashboard/frontend/src/hooks/useCanvasStore.ts index 0c564e55..a3e460d9 100644 --- a/atomizer-dashboard/frontend/src/hooks/useCanvasStore.ts +++ b/atomizer-dashboard/frontend/src/hooks/useCanvasStore.ts @@ -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) => void; updateNodeData: (nodeId: string, data: Partial) => void; selectNode: (nodeId: string | null) => void; selectEdge: (edgeId: string | null) => void; @@ -112,12 +112,14 @@ export const useCanvasStore = create((set, get) => ({ set({ edges: addEdge(connection, get().edges) }); }, - addNode: (type, position) => { + addNode: (type, position, customData) => { const newNode: Node = { id: getNodeId(), type, position, - data: getDefaultData(type), + data: customData + ? { ...getDefaultData(type), ...customData } as CanvasNodeData + : getDefaultData(type), }; set({ nodes: [...get().nodes, newNode] }); }, diff --git a/atomizer-dashboard/frontend/src/lib/canvas/schema.ts b/atomizer-dashboard/frontend/src/lib/canvas/schema.ts index b5c0d839..5c1cb5c7 100644 --- a/atomizer-dashboard/frontend/src/lib/canvas/schema.ts +++ b/atomizer-dashboard/frontend/src/lib/canvas/schema.ts @@ -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 = { - 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 = { + 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 + }, }; diff --git a/atomizer-dashboard/frontend/src/lib/canvas/validation.ts b/atomizer-dashboard/frontend/src/lib/canvas/validation.ts index 88823352..ee74b5d7 100644 --- a/atomizer-dashboard/frontend/src/lib/canvas/validation.ts +++ b/atomizer-dashboard/frontend/src/lib/canvas/validation.ts @@ -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 { diff --git a/docs/plans/CANVAS_UX_DESIGN.md b/docs/plans/CANVAS_UX_DESIGN.md new file mode 100644 index 00000000..afed2328 --- /dev/null +++ b/docs/plans/CANVAS_UX_DESIGN.md @@ -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; + createNewStudy: (name: string, category: string) => Promise; + closeStudy: () => void; + refreshStudies: () => Promise; + + // 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; + saveToStudy: (studyPath: string, overwrite: boolean) => Promise; + 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.* diff --git a/docs/plans/CANVAS_V3_PLAN.md b/docs/plans/CANVAS_V3_PLAN.md new file mode 100644 index 00000000..c0a67e85 --- /dev/null +++ b/docs/plans/CANVAS_V3_PLAN.md @@ -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 + 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, // 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(); + 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 ( +
+ {/* Minimal header */} +
+ Canvas Builder +
+ + + +
+
+ + {/* Canvas fills remaining space */} +
+ +
+
+ ); +} +``` + +### 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 ( +
+ +

{error}

+ +
+ ); + } + + // ... 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.* diff --git a/docs/plans/RALPH_LOOP_CANVAS_V4.md b/docs/plans/RALPH_LOOP_CANVAS_V4.md new file mode 100644 index 00000000..c798bad1 --- /dev/null +++ b/docs/plans/RALPH_LOOP_CANVAS_V4.md @@ -0,0 +1,1949 @@ +# Ralph Loop: Canvas V4 - Model Introspection & Claude Integration + +**Purpose:** Fix critical Canvas bugs and implement NX model introspection with Claude control +**Execution:** Autonomous, all phases sequential, no stopping +**Estimated Duration:** 12 work units +**Priority:** HIGH - Core functionality broken + +--- + +## 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 fix critical Canvas Builder issues and implement NX model introspection. + +## Critical Issues to Fix + +| Issue | Impact | Priority | +|-------|--------|----------| +| **Claude bot SQL error on Validate** | Blocking - can't validate | P0 | +| **Execute with Claude broken** | Blocking - can't create studies | P0 | +| **Expressions not connected to Model** | UX - wrong data flow | P1 | +| **No file browser for .sim** | UX - can't find files | P1 | +| **No model introspection** | Feature - manual config only | P2 | + +## Key Learnings to Implement + +1. **Data Flow**: Expressions → Model (not Model → Expressions) +2. **Model has multiple inputs**: One per design variable +3. **Introspection discovers**: Expressions, solver type, dependent files (.fem, .prt) +4. **Auto-filter**: Selecting .sim shows only related elements + +## Environment + +``` +Working Directory: C:/Users/antoi/Atomizer +Frontend: atomizer-dashboard/frontend/ +Backend: atomizer-dashboard/backend/ +Python: C:/Users/antoi/anaconda3/envs/atomizer/python.exe +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 after each phase** - Run `npm run build` to verify +4. **Read before edit** - ALWAYS read files before modifying +5. **No guessing** - If unsure, read more code first +6. **Commit per phase** - Git commit after each major phase + +--- + +# PHASE 1: Fix Claude Bot SQL Error (CRITICAL) + +## T1.1 - Investigate SQL Error + +First, find the source of the SQL error: + +``` +Read: atomizer-dashboard/backend/api/services/conversation_store.py +Read: atomizer-dashboard/backend/api/services/session_manager.py +Read: atomizer-dashboard/backend/api/routes/terminal.py +``` + +**Common SQL issues:** +- Table not created before use +- Column mismatch +- Connection not initialized +- Missing migrations + +**Look for:** +- `CREATE TABLE IF NOT EXISTS` - ensure tables exist +- Try/catch around all DB operations +- Connection initialization on startup + +## T1.2 - Fix Database Initialization + +**File:** `atomizer-dashboard/backend/api/services/conversation_store.py` + +Ensure robust initialization: + +```python +import sqlite3 +from pathlib import Path +from contextlib import contextmanager +import logging + +logger = logging.getLogger(__name__) + +class ConversationStore: + def __init__(self, db_path: str = "sessions.db"): + self.db_path = Path(db_path) + self._init_db() + + def _init_db(self): + """Initialize database with all required tables.""" + try: + with self._get_connection() as conn: + cursor = conn.cursor() + + # Sessions table + cursor.execute(''' + CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + study_path TEXT, + context TEXT + ) + ''') + + # Messages table + cursor.execute(''' + CREATE TABLE IF NOT EXISTS messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + role TEXT NOT NULL, + content TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (session_id) REFERENCES sessions(id) + ) + ''') + + conn.commit() + logger.info(f"Database initialized: {self.db_path}") + except Exception as e: + logger.error(f"Failed to initialize database: {e}") + raise + + @contextmanager + def _get_connection(self): + """Get a database connection with proper error handling.""" + conn = None + try: + conn = sqlite3.connect(str(self.db_path)) + conn.row_factory = sqlite3.Row + yield conn + except sqlite3.Error as e: + logger.error(f"Database error: {e}") + if conn: + conn.rollback() + raise + finally: + if conn: + conn.close() + + def get_or_create_session(self, session_id: str, study_path: str = None): + """Get existing session or create new one.""" + try: + with self._get_connection() as conn: + cursor = conn.cursor() + + # Check if exists + cursor.execute('SELECT * FROM sessions WHERE id = ?', (session_id,)) + row = cursor.fetchone() + + if row: + return dict(row) + + # Create new + cursor.execute( + 'INSERT INTO sessions (id, study_path) VALUES (?, ?)', + (session_id, study_path) + ) + conn.commit() + + return { + 'id': session_id, + 'study_path': study_path, + 'created_at': None + } + except Exception as e: + logger.error(f"Failed to get/create session: {e}") + return {'id': session_id, 'study_path': study_path} + + def add_message(self, session_id: str, role: str, content: str): + """Add a message to session history.""" + try: + with self._get_connection() as conn: + cursor = conn.cursor() + cursor.execute( + 'INSERT INTO messages (session_id, role, content) VALUES (?, ?, ?)', + (session_id, role, content) + ) + conn.commit() + except Exception as e: + logger.error(f"Failed to add message: {e}") + # Don't raise - message logging is not critical + + def get_messages(self, session_id: str, limit: int = 50): + """Get recent messages for a session.""" + try: + with self._get_connection() as conn: + cursor = conn.cursor() + cursor.execute( + '''SELECT role, content, created_at + FROM messages + WHERE session_id = ? + ORDER BY created_at DESC + LIMIT ?''', + (session_id, limit) + ) + rows = cursor.fetchall() + return [dict(row) for row in reversed(rows)] + except Exception as e: + logger.error(f"Failed to get messages: {e}") + return [] +``` + +## T1.3 - Fix Session Manager + +**File:** `atomizer-dashboard/backend/api/services/session_manager.py` + +Add error handling: + +```python +class SessionManager: + def __init__(self): + self.store = ConversationStore() + self.active_sessions = {} + + async def get_session(self, session_id: str): + """Get or create a session with error handling.""" + try: + if session_id not in self.active_sessions: + session_data = self.store.get_or_create_session(session_id) + self.active_sessions[session_id] = session_data + return self.active_sessions.get(session_id) + except Exception as e: + logger.error(f"Session error: {e}") + # Return minimal session on error + return {'id': session_id, 'study_path': None} +``` + +## T1.4 - Add Health Check Endpoint + +**File:** `atomizer-dashboard/backend/api/main.py` + +```python +@app.get("/api/health") +async def health_check(): + """Health check with database status.""" + try: + from api.services.conversation_store import ConversationStore + store = ConversationStore() + store.get_or_create_session("health_check") + return {"status": "healthy", "database": "connected"} + except Exception as e: + return {"status": "unhealthy", "database": str(e)} +``` + +--- + +# PHASE 2: Fix Execute with Claude (CRITICAL) + +## T2.1 - Investigate Chat Integration + +Read the chat components: + +``` +Read: atomizer-dashboard/frontend/src/hooks/useCanvasChat.ts +Read: atomizer-dashboard/frontend/src/hooks/useChat.ts +Read: atomizer-dashboard/frontend/src/components/canvas/panels/ChatPanel.tsx +Read: atomizer-dashboard/backend/api/routes/terminal.py +``` + +## T2.2 - Fix WebSocket Chat Hook + +**File:** `atomizer-dashboard/frontend/src/hooks/useChat.ts` + +Ensure proper connection handling: + +```typescript +import { useState, useCallback, useRef, useEffect } from 'react'; + +interface ChatMessage { + role: 'user' | 'assistant' | 'system'; + content: string; + timestamp?: Date; +} + +interface UseChatOptions { + endpoint?: string; + sessionId?: string; + onError?: (error: string) => void; +} + +export function useChat(options: UseChatOptions = {}) { + const { + endpoint = '/api/chat', + sessionId = 'default', + onError, + } = options; + + const [messages, setMessages] = useState([]); + const [isConnected, setIsConnected] = useState(false); + const [isThinking, setIsThinking] = useState(false); + const [error, setError] = useState(null); + + const wsRef = useRef(null); + const reconnectTimeoutRef = useRef(null); + const reconnectAttempts = useRef(0); + const maxReconnectAttempts = 5; + + const connect = useCallback(() => { + if (wsRef.current?.readyState === WebSocket.OPEN) { + return; + } + + try { + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const wsUrl = `${protocol}//${window.location.host}${endpoint}?session_id=${sessionId}`; + + console.log('Connecting to WebSocket:', wsUrl); + wsRef.current = new WebSocket(wsUrl); + + wsRef.current.onopen = () => { + console.log('WebSocket connected'); + setIsConnected(true); + setError(null); + reconnectAttempts.current = 0; + }; + + wsRef.current.onmessage = (event) => { + try { + const data = JSON.parse(event.data); + + if (data.type === 'message') { + setMessages((prev) => [...prev, { + role: data.role || 'assistant', + content: data.content, + timestamp: new Date(), + }]); + setIsThinking(false); + } else if (data.type === 'thinking') { + setIsThinking(true); + } else if (data.type === 'error') { + setError(data.message); + setIsThinking(false); + onError?.(data.message); + } else if (data.type === 'done') { + setIsThinking(false); + } + } catch (e) { + console.error('Failed to parse message:', e); + } + }; + + wsRef.current.onclose = (event) => { + console.log('WebSocket closed:', event.code, event.reason); + setIsConnected(false); + + // Attempt reconnect + if (reconnectAttempts.current < maxReconnectAttempts) { + reconnectAttempts.current++; + const delay = Math.min(1000 * Math.pow(2, reconnectAttempts.current), 30000); + console.log(`Reconnecting in ${delay}ms (attempt ${reconnectAttempts.current})`); + + reconnectTimeoutRef.current = setTimeout(() => { + connect(); + }, delay); + } else { + setError('Connection lost. Please refresh the page.'); + } + }; + + wsRef.current.onerror = (event) => { + console.error('WebSocket error:', event); + setError('Connection error'); + }; + } catch (e) { + console.error('Failed to create WebSocket:', e); + setError('Failed to connect'); + } + }, [endpoint, sessionId, onError]); + + const disconnect = useCallback(() => { + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current); + } + if (wsRef.current) { + wsRef.current.close(); + wsRef.current = null; + } + setIsConnected(false); + }, []); + + const sendMessage = useCallback(async (content: string) => { + if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) { + setError('Not connected'); + return; + } + + // Add user message immediately + setMessages((prev) => [...prev, { + role: 'user', + content, + timestamp: new Date(), + }]); + + setIsThinking(true); + setError(null); + + try { + wsRef.current.send(JSON.stringify({ + type: 'message', + content, + session_id: sessionId, + })); + } catch (e) { + console.error('Failed to send message:', e); + setError('Failed to send message'); + setIsThinking(false); + } + }, [sessionId]); + + const clearMessages = useCallback(() => { + setMessages([]); + }, []); + + const reconnect = useCallback(() => { + disconnect(); + reconnectAttempts.current = 0; + setTimeout(connect, 100); + }, [connect, disconnect]); + + // Auto-connect on mount + useEffect(() => { + connect(); + return () => disconnect(); + }, [connect, disconnect]); + + return { + messages, + isConnected, + isThinking, + error, + sendMessage, + clearMessages, + reconnect, + connect, + disconnect, + }; +} +``` + +## T2.3 - Fix Canvas Chat Hook + +**File:** `atomizer-dashboard/frontend/src/hooks/useCanvasChat.ts` + +```typescript +import { useCallback } from 'react'; +import { useChat } from './useChat'; +import { useCanvasStore } from './useCanvasStore'; +import { toOptimizationIntent } from '../lib/canvas/intent'; + +export function useCanvasChat() { + const { nodes, edges, validation } = useCanvasStore(); + + const chat = useChat({ + endpoint: '/api/chat/canvas', + sessionId: `canvas_${Date.now()}`, + onError: (error) => { + console.error('Canvas chat error:', error); + }, + }); + + const validateWithClaude = useCallback(async () => { + try { + const intent = toOptimizationIntent(nodes, edges); + const message = `Please validate this Canvas workflow configuration: + +\`\`\`json +${JSON.stringify(intent, null, 2)} +\`\`\` + +Check for: +1. Missing required nodes (Model, Solver, at least one Objective, Algorithm) +2. Invalid connections +3. Configuration issues +4. Suggest improvements`; + + await chat.sendMessage(message); + } catch (e) { + console.error('Validation error:', e); + } + }, [nodes, edges, chat]); + + const processWithClaude = useCallback(async (studyName?: string, options?: { + overwrite?: boolean; + copyModels?: boolean; + }) => { + try { + const intent = toOptimizationIntent(nodes, edges); + const message = `Create an optimization study from this Canvas workflow: + +Study Name: ${studyName || 'new_study'} +Options: ${JSON.stringify(options || {})} + +\`\`\`json +${JSON.stringify(intent, null, 2)} +\`\`\` + +Please: +1. Validate the configuration +2. Generate optimization_config.json +3. Generate run_optimization.py using Atomizer protocols +4. Create the study folder structure`; + + await chat.sendMessage(message); + } catch (e) { + console.error('Process error:', e); + } + }, [nodes, edges, chat]); + + const askClaude = useCallback(async (question: string) => { + try { + const intent = toOptimizationIntent(nodes, edges); + const message = `Context - Current Canvas workflow: +\`\`\`json +${JSON.stringify(intent, null, 2)} +\`\`\` + +Question: ${question}`; + + await chat.sendMessage(message); + } catch (e) { + console.error('Ask error:', e); + } + }, [nodes, edges, chat]); + + return { + ...chat, + validateWithClaude, + processWithClaude, + askClaude, + }; +} +``` + +## T2.4 - Fix Backend Chat WebSocket + +**File:** `atomizer-dashboard/backend/api/routes/terminal.py` + +```python +from fastapi import APIRouter, WebSocket, WebSocketDisconnect +from api.services.session_manager import SessionManager +from api.services.claude_agent import ClaudeAgent +import json +import logging +import asyncio + +router = APIRouter() +logger = logging.getLogger(__name__) + +session_manager = SessionManager() + +@router.websocket("/api/chat") +@router.websocket("/api/chat/canvas") +async def chat_websocket(websocket: WebSocket): + """WebSocket endpoint for Claude chat.""" + await websocket.accept() + + session_id = websocket.query_params.get('session_id', 'default') + logger.info(f"Chat WebSocket connected: {session_id}") + + try: + # Initialize session + session = await session_manager.get_session(session_id) + + # Send connection confirmation + await websocket.send_json({ + "type": "connected", + "session_id": session_id, + }) + + while True: + try: + # Receive message + data = await websocket.receive_json() + + if data.get('type') == 'message': + content = data.get('content', '') + + # Send thinking indicator + await websocket.send_json({"type": "thinking"}) + + try: + # Process with Claude + agent = ClaudeAgent() + response = await agent.process_message( + session_id=session_id, + message=content, + context=session.get('context') + ) + + # Send response + await websocket.send_json({ + "type": "message", + "role": "assistant", + "content": response, + }) + + except Exception as e: + logger.error(f"Claude error: {e}") + await websocket.send_json({ + "type": "error", + "message": str(e), + }) + + # Send done + await websocket.send_json({"type": "done"}) + + except json.JSONDecodeError as e: + logger.error(f"Invalid JSON: {e}") + await websocket.send_json({ + "type": "error", + "message": "Invalid message format", + }) + + except WebSocketDisconnect: + logger.info(f"Chat WebSocket disconnected: {session_id}") + except Exception as e: + logger.error(f"WebSocket error: {e}") + try: + await websocket.send_json({ + "type": "error", + "message": str(e), + }) + except: + pass +``` + +--- + +# PHASE 3: Fix Node Connection Flow (Expressions → Model) + +## T3.1 - Update Node Schema for Inputs + +**File:** `atomizer-dashboard/frontend/src/lib/canvas/schema.ts` + +Add input/output handle definitions: + +```typescript +// Node handle positions +export interface NodeHandles { + inputs: HandleConfig[]; + outputs: HandleConfig[]; +} + +export interface HandleConfig { + id: string; + type: 'source' | 'target'; + position: 'top' | 'bottom' | 'left' | 'right'; + label?: string; +} + +// Define handles for each node type +export const NODE_HANDLES: Record = { + model: { + inputs: [ + { id: 'params', type: 'target', position: 'left', label: 'Design Vars' }, + ], + outputs: [ + { id: 'sim', type: 'source', position: 'right', label: 'Simulation' }, + ], + }, + solver: { + inputs: [ + { id: 'model', type: 'target', position: 'left', label: 'Model' }, + ], + outputs: [ + { id: 'results', type: 'source', position: 'right', label: 'Results' }, + ], + }, + designVar: { + inputs: [], + outputs: [ + { id: 'value', type: 'source', position: 'right', label: 'Value' }, + ], + }, + extractor: { + inputs: [ + { id: 'results', type: 'target', position: 'left', label: 'Results' }, + ], + outputs: [ + { id: 'value', type: 'source', position: 'right', label: 'Value' }, + ], + }, + objective: { + inputs: [ + { id: 'value', type: 'target', position: 'left', label: 'Value' }, + ], + outputs: [ + { id: 'objective', type: 'source', position: 'right', label: 'To Algo' }, + ], + }, + constraint: { + inputs: [ + { id: 'value', type: 'target', position: 'left', label: 'Value' }, + ], + outputs: [], + }, + algorithm: { + inputs: [ + { id: 'objectives', type: 'target', position: 'left', label: 'Objectives' }, + ], + outputs: [], + }, + surrogate: { + inputs: [ + { id: 'algo', type: 'target', position: 'left', label: 'Algorithm' }, + ], + outputs: [], + }, +}; +``` + +## T3.2 - Update BaseNode with Multiple Handles + +**File:** `atomizer-dashboard/frontend/src/components/canvas/nodes/BaseNode.tsx` + +```typescript +import { Handle, Position } from 'reactflow'; +import { NODE_HANDLES } from '../../../lib/canvas/schema'; + +interface BaseNodeProps { + type: NodeType; + data: CanvasNodeData; + selected: boolean; + children?: React.ReactNode; +} + +export function BaseNode({ type, data, selected, children }: BaseNodeProps) { + const handles = NODE_HANDLES[type]; + const icon = getIconForType(type); + const iconColor = getColorForType(type); + + return ( +
+ {/* Input handles (left side) */} + {handles.inputs.map((handle, idx) => ( + + ))} + + {/* Content */} +
+
+ {icon} +
+
+
+ {data.label} +
+
+
+ + {children && ( +
+ {children} +
+ )} + + {/* Output handles (right side) */} + {handles.outputs.map((handle, idx) => ( + + ))} +
+ ); +} +``` + +## T3.3 - Update Validation for New Flow + +**File:** `atomizer-dashboard/frontend/src/lib/canvas/validation.ts` + +Update validation to check new connection flow: + +```typescript +export function validateCanvas(nodes: Node[], edges: Edge[]): ValidationResult { + const errors: string[] = []; + const warnings: string[] = []; + + // Get nodes by type + const modelNodes = nodes.filter(n => n.data.type === 'model'); + const solverNodes = nodes.filter(n => n.data.type === 'solver'); + const dvarNodes = nodes.filter(n => n.data.type === 'designVar'); + const extractorNodes = nodes.filter(n => n.data.type === 'extractor'); + const objectiveNodes = nodes.filter(n => n.data.type === 'objective'); + const algoNodes = nodes.filter(n => n.data.type === 'algorithm'); + + // Required nodes + if (modelNodes.length === 0) errors.push('Missing Model node'); + if (solverNodes.length === 0) errors.push('Missing Solver node'); + if (objectiveNodes.length === 0) errors.push('Missing Objective node'); + if (algoNodes.length === 0) errors.push('Missing Algorithm node'); + + // Check connections + // Design Variables should connect TO Model + dvarNodes.forEach(dvar => { + const hasConnection = edges.some(e => + e.source === dvar.id && + modelNodes.some(m => m.id === e.target) + ); + if (!hasConnection) { + warnings.push(`Design variable "${dvar.data.label}" not connected to Model`); + } + }); + + // Model should connect to Solver + modelNodes.forEach(model => { + const hasConnection = edges.some(e => + e.source === model.id && + solverNodes.some(s => s.id === e.target) + ); + if (!hasConnection) { + errors.push(`Model "${model.data.label}" not connected to Solver`); + } + }); + + // Solver should connect to Extractors + solverNodes.forEach(solver => { + const connectedExtractors = edges.filter(e => + e.source === solver.id && + extractorNodes.some(ex => ex.id === e.target) + ); + if (connectedExtractors.length === 0) { + warnings.push('Solver not connected to any Extractor'); + } + }); + + // Extractors should connect to Objectives + objectiveNodes.forEach(obj => { + const hasConnection = edges.some(e => + extractorNodes.some(ex => ex.id === e.source) && + e.target === obj.id + ); + if (!hasConnection) { + errors.push(`Objective "${obj.data.label}" not connected to any Extractor`); + } + }); + + // Objectives should connect to Algorithm + const algoNode = algoNodes[0]; + if (algoNode) { + objectiveNodes.forEach(obj => { + const hasConnection = edges.some(e => + e.source === obj.id && e.target === algoNode.id + ); + if (!hasConnection) { + warnings.push(`Objective "${obj.data.label}" not connected to Algorithm`); + } + }); + } + + // Check configuration + nodes.forEach(node => { + if (!node.data.configured) { + warnings.push(`"${node.data.label}" is not fully configured`); + } + }); + + return { + valid: errors.length === 0, + errors, + warnings, + }; +} +``` + +--- + +# PHASE 4: Add File Browser for Model Selection + +## T4.1 - Create File Browser Component + +**File:** `atomizer-dashboard/frontend/src/components/canvas/panels/FileBrowser.tsx` + +```typescript +import { useState, useEffect } from 'react'; +import { X, Folder, FileBox, ChevronRight, ChevronDown, Search, RefreshCw } from 'lucide-react'; + +interface FileBrowserProps { + isOpen: boolean; + onClose: () => void; + onSelect: (filePath: string) => void; + fileTypes?: string[]; // e.g., ['.sim', '.prt', '.fem', '.afem'] + initialPath?: string; +} + +interface FileEntry { + name: string; + path: string; + isDirectory: boolean; + children?: FileEntry[]; +} + +export function FileBrowser({ isOpen, onClose, onSelect, fileTypes = ['.sim', '.prt', '.fem', '.afem'], initialPath }: FileBrowserProps) { + const [currentPath, setCurrentPath] = useState(initialPath || ''); + const [files, setFiles] = useState([]); + const [expandedPaths, setExpandedPaths] = useState>(new Set()); + const [searchTerm, setSearchTerm] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const loadDirectory = async (path: string) => { + setIsLoading(true); + setError(null); + try { + const res = await fetch(`/api/files/list?path=${encodeURIComponent(path)}&types=${fileTypes.join(',')}`); + if (!res.ok) throw new Error('Failed to load directory'); + const data = await res.json(); + setFiles(data.files || []); + } catch (e) { + setError('Failed to load files'); + console.error(e); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + if (isOpen) { + loadDirectory(currentPath); + } + }, [isOpen, currentPath]); + + const toggleExpand = (path: string) => { + setExpandedPaths(prev => { + const next = new Set(prev); + if (next.has(path)) { + next.delete(path); + } else { + next.add(path); + } + return next; + }); + }; + + const handleSelect = (file: FileEntry) => { + if (file.isDirectory) { + toggleExpand(file.path); + } else { + onSelect(file.path); + onClose(); + } + }; + + const filteredFiles = files.filter(f => + f.name.toLowerCase().includes(searchTerm.toLowerCase()) + ); + + if (!isOpen) return null; + + return ( +
+
+ {/* Header */} +
+

Select Model File

+ +
+ + {/* Search */} +
+
+ + 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" + /> +
+
+ Looking for: + {fileTypes.map(t => ( + {t} + ))} +
+
+ + {/* Path breadcrumb */} +
+ + {currentPath.split('/').filter(Boolean).map((part, i, arr) => ( + + + + + ))} +
+ + {/* File list */} +
+ {isLoading ? ( +
+ + Loading... +
+ ) : error ? ( +
+ {error} +
+ ) : filteredFiles.length === 0 ? ( +
+ No matching files found +
+ ) : ( +
+ {filteredFiles.map((file) => ( + + ))} +
+ )} +
+ + {/* Footer */} +
+ +
+
+
+ ); +} +``` + +## T4.2 - Add Backend File List Endpoint + +**File:** `atomizer-dashboard/backend/api/routes/files.py` + +```python +from fastapi import APIRouter, Query +from pathlib import Path +from typing import List +import os + +router = APIRouter(prefix="/api/files", tags=["files"]) + +STUDIES_ROOT = Path(__file__).parent.parent.parent.parent.parent / "studies" + + +@router.get("/list") +async def list_files( + path: str = "", + types: str = ".sim,.prt,.fem,.afem" +): + """List files in a directory, filtered by type.""" + allowed_types = [t.strip().lower() for t in types.split(',')] + + base_path = STUDIES_ROOT / path if path else STUDIES_ROOT + + if not base_path.exists(): + return {"files": [], "path": path} + + files = [] + + try: + for entry in sorted(base_path.iterdir(), key=lambda x: (not x.is_dir(), x.name.lower())): + if entry.name.startswith('.'): + continue + + if entry.is_dir(): + # Include directories + files.append({ + "name": entry.name, + "path": str(entry.relative_to(STUDIES_ROOT)), + "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)), + "isDirectory": False, + }) + except Exception as e: + return {"files": [], "path": path, "error": str(e)} + + return {"files": files, "path": path} +``` + +## T4.3 - Register Files Router + +**File:** `atomizer-dashboard/backend/api/main.py` + +Add: +```python +from api.routes import files + +app.include_router(files.router) +``` + +--- + +# PHASE 5: Implement Model Introspection + +## T5.1 - Create NX Introspection Service + +**File:** `atomizer-dashboard/backend/api/services/nx_introspection.py` + +```python +from pathlib import Path +from typing import Dict, List, Any, Optional +import logging +import re + +logger = logging.getLogger(__name__) + +STUDIES_ROOT = Path(__file__).parent.parent.parent.parent.parent / "studies" + + +class NXIntrospector: + """Introspect NX model files to discover expressions, dependencies, and solver info.""" + + def __init__(self, file_path: str): + self.file_path = STUDIES_ROOT / file_path + self.file_type = self.file_path.suffix.lower() + + def introspect(self) -> Dict[str, Any]: + """Full introspection of the model file.""" + result = { + "file_path": str(self.file_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()) + 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": [], + "expressions": [], + } + + parent_dir = self.file_path.parent + base_name = self.file_path.stem + + # Find related files + for ext in ['.prt', '.fem', '.afem']: + # Look for exact match or _fem suffix variations + patterns = [ + parent_dir / f"{base_name}{ext}", + parent_dir / f"{base_name.replace('_sim1', '')}{ext}", + parent_dir / f"{base_name.replace('_sim1', '_fem1')}{ext}", + ] + for pattern in patterns: + if pattern.exists(): + result["dependent_files"].append({ + "path": str(pattern.relative_to(STUDIES_ROOT)), + "type": ext[1:], # Remove dot + }) + + # Find idealized part (*_i.prt) + for f in parent_dir.glob("*_i.prt"): + result["dependent_files"].append({ + "path": str(f.relative_to(STUDIES_ROOT)), + "type": "idealized_prt", + }) + + # Try to determine solver type (would need NX API for accurate detection) + # For now, infer from file contents or naming + result["solver_type"] = self._detect_solver_type() + + # Get expressions from associated .prt + prt_files = [d for d in result["dependent_files"] if d["type"] in ["prt", "idealized_prt"]] + for prt in prt_files: + prt_path = STUDIES_ROOT / prt["path"] + if prt_path.exists(): + # In real implementation, would use NX API + # For now, try to detect from common expression patterns + result["expressions"].extend(self._discover_expressions_from_file(prt_path)) + + return result + + def _introspect_prt(self) -> Dict[str, Any]: + """Introspect .prt file.""" + result = { + "expressions": [], + "dependent_files": [], + } + + # Look for associated .sim and .fem files + parent_dir = self.file_path.parent + base_name = self.file_path.stem + + for ext in ['.sim', '.fem', '.afem']: + patterns = [ + parent_dir / f"{base_name}{ext}", + parent_dir / f"{base_name}_sim1{ext}", + parent_dir / f"{base_name}_fem1{ext}", + ] + for pattern in patterns: + if pattern.exists(): + result["dependent_files"].append({ + "path": str(pattern.relative_to(STUDIES_ROOT)), + "type": ext[1:], + }) + + result["expressions"] = self._discover_expressions_from_file(self.file_path) + + return result + + def _introspect_fem(self) -> Dict[str, Any]: + """Introspect .fem or .afem file.""" + return { + "expressions": [], + "dependent_files": [], + } + + def _detect_solver_type(self) -> Optional[str]: + """Detect solver type from file name or contents.""" + name_lower = self.file_path.name.lower() + + # Infer from naming conventions + if 'modal' in name_lower or 'freq' in name_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 + + # Default to static + return 'SOL101' + + def _discover_expressions_from_file(self, file_path: Path) -> List[Dict[str, Any]]: + """Discover expressions from a part file.""" + # In real implementation, this would use NX Open API + # For now, return common expression patterns for demo + common_expressions = [ + {"name": "thickness", "value": 10.0, "unit": "mm", "type": "dimension"}, + {"name": "length", "value": 100.0, "unit": "mm", "type": "dimension"}, + {"name": "width", "value": 50.0, "unit": "mm", "type": "dimension"}, + {"name": "height", "value": 25.0, "unit": "mm", "type": "dimension"}, + {"name": "wall_thickness", "value": 2.0, "unit": "mm", "type": "dimension"}, + {"name": "rib_height", "value": 5.0, "unit": "mm", "type": "dimension"}, + {"name": "fillet_radius", "value": 3.0, "unit": "mm", "type": "dimension"}, + {"name": "hole_diameter", "value": 8.0, "unit": "mm", "type": "dimension"}, + ] + + # Filter to likely candidates based on file name + return common_expressions + + def _suggest_extractors(self, solver_type: Optional[str]) -> List[Dict[str, Any]]: + """Suggest extractors based on solver type.""" + extractors = [ + {"id": "E4", "name": "Mass (BDF)", "always": True}, + {"id": "E5", "name": "Mass (Expression)", "always": True}, + ] + + if solver_type == 'SOL101': + extractors.extend([ + {"id": "E1", "name": "Displacement", "always": False}, + {"id": "E3", "name": "Stress", "always": False}, + ]) + elif solver_type == 'SOL103': + extractors.extend([ + {"id": "E2", "name": "Frequency", "always": False}, + ]) + + # Always include Zernike for mirrors + extractors.extend([ + {"id": "E8", "name": "Zernike Coefficients", "always": False}, + {"id": "E9", "name": "Zernike RMS", "always": False}, + {"id": "E10", "name": "Zernike WFE", "always": False}, + ]) + + return extractors +``` + +## T5.2 - Create Introspection API Endpoint + +**File:** `atomizer-dashboard/backend/api/routes/nx.py` + +```python +from fastapi import APIRouter, HTTPException +from api.services.nx_introspection import NXIntrospector + +router = APIRouter(prefix="/api/nx", tags=["nx"]) + + +@router.post("/introspect") +async def introspect_model(file_path: str): + """Introspect an NX model file to discover expressions, solver type, and dependencies.""" + try: + introspector = NXIntrospector(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.""" + try: + introspector = NXIntrospector(file_path) + result = introspector.introspect() + return { + "expressions": result.get("expressions", []), + "file_path": file_path, + } + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) +``` + +## T5.3 - Register NX Router + +**File:** `atomizer-dashboard/backend/api/main.py` + +Add: +```python +from api.routes import nx + +app.include_router(nx.router) +``` + +--- + +# PHASE 6: Add Introspection Panel to Model Config + +## T6.1 - Create Introspection Panel Component + +**File:** `atomizer-dashboard/frontend/src/components/canvas/panels/IntrospectionPanel.tsx` + +```typescript +import { useState, useEffect } from 'react'; +import { X, Search, RefreshCw, Plus, ChevronDown, ChevronRight, FileBox, Cpu, FlaskConical, SlidersHorizontal } from 'lucide-react'; +import { useCanvasStore } from '../../../hooks/useCanvasStore'; + +interface IntrospectionPanelProps { + filePath: string; + onClose: () => void; +} + +interface IntrospectionResult { + file_path: string; + file_type: string; + expressions: Array<{ + name: string; + value: number; + unit: string; + type: string; + }>; + solver_type: string | null; + dependent_files: Array<{ + path: string; + type: string; + }>; + extractors_available: Array<{ + id: string; + name: string; + always: boolean; + }>; + warnings: string[]; +} + +export function IntrospectionPanel({ filePath, onClose }: IntrospectionPanelProps) { + const [result, setResult] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [expandedSections, setExpandedSections] = useState>(new Set(['expressions', 'extractors'])); + const [searchTerm, setSearchTerm] = useState(''); + + const { addNode, nodes, edges } = useCanvasStore(); + + const runIntrospection = async () => { + 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); + } + }; + + useEffect(() => { + if (filePath) { + runIntrospection(); + } + }, [filePath]); + + 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: IntrospectionResult['expressions'][0]) => { + // 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, + }; + + addNode('designVar', position, { + label: expr.name, + expressionName: expr.name, + minValue: expr.value * 0.5, + maxValue: expr.value * 1.5, + unit: expr.unit, + configured: true, + }); + }; + + const addExtractor = (extractor: IntrospectionResult['extractors_available'][0]) => { + // 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 ( +
+ {/* Header */} +
+
+ + Model Introspection +
+
+ + +
+
+ + {/* Search */} +
+ 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" + /> +
+ + {/* Content */} +
+ {isLoading ? ( +
+ + Analyzing model... +
+ ) : error ? ( +
{error}
+ ) : result ? ( +
+ {/* Solver Type */} + {result.solver_type && ( +
+
+ + Solver: + {result.solver_type} +
+
+ )} + + {/* Expressions Section */} +
+ + + {expandedSections.has('expressions') && ( +
+ {filteredExpressions.map((expr) => ( +
+
+

{expr.name}

+

+ {expr.value} {expr.unit} +

+
+ +
+ ))} +
+ )} +
+ + {/* Extractors Section */} +
+ + + {expandedSections.has('extractors') && ( +
+ {result.extractors_available.map((ext) => ( +
+
+

{ext.name}

+

{ext.id}

+
+ +
+ ))} +
+ )} +
+ + {/* Dependent Files */} + {result.dependent_files.length > 0 && ( +
+ + + {expandedSections.has('files') && ( +
+ {result.dependent_files.map((file) => ( +
+ +
+

{file.path.split('/').pop()}

+

{file.type}

+
+
+ ))} +
+ )} +
+ )} + + {/* Warnings */} + {result.warnings.length > 0 && ( +
+

Warnings

+ {result.warnings.map((w, i) => ( +

{w}

+ ))} +
+ )} +
+ ) : ( +
+ Select a model to introspect +
+ )} +
+
+ ); +} +``` + +--- + +# PHASE 7: Integrate Into NodeConfigPanel + +## T7.1 - Update Model Node Config + +**File:** `atomizer-dashboard/frontend/src/components/canvas/panels/NodeConfigPanel.tsx` + +Add file browser and introspection button to Model node configuration: + +```typescript +// Add to imports +import { FileBrowser } from './FileBrowser'; +import { IntrospectionPanel } from './IntrospectionPanel'; +import { FolderSearch, Microscope } from 'lucide-react'; + +// In the component, add state +const [showFileBrowser, setShowFileBrowser] = useState(false); +const [showIntrospection, setShowIntrospection] = useState(false); + +// In the Model node config section, add: +{selectedNode.data.type === 'model' && ( +
+ {/* File Path with Browse Button */} +
+ +
+ updateData('filePath', e.target.value)} + placeholder="path/to/model.sim" + className="flex-1 px-3 py-1.5 bg-dark-800 border border-dark-600 rounded-lg + text-sm text-white placeholder-dark-500" + /> + +
+
+ + {/* Introspect Button */} + {selectedNode.data.filePath && ( + + )} + + {/* File Type Display */} + {selectedNode.data.fileType && ( +
+ Type: {selectedNode.data.fileType.toUpperCase()} +
+ )} +
+)} + +// Add dialogs at the end of the component +{showFileBrowser && ( + setShowFileBrowser(false)} + onSelect={(path) => { + updateData('filePath', path); + updateData('fileType', path.split('.').pop()); + setShowFileBrowser(false); + }} + fileTypes={['.sim', '.prt', '.fem', '.afem']} + /> +)} + +{showIntrospection && selectedNode.data.filePath && ( +
+ setShowIntrospection(false)} + /> +
+)} +``` + +--- + +# PHASE 8: Build, Test, and Commit + +## T8.1 - Build Frontend + +```bash +cd atomizer-dashboard/frontend +npm run build +``` + +## T8.2 - Test Backend + +```bash +cd atomizer-dashboard/backend +python -c " +from api.services.conversation_store import ConversationStore +from api.services.nx_introspection import NXIntrospector + +# Test DB +store = ConversationStore() +session = store.get_or_create_session('test') +print('DB OK:', session) + +# Test introspection +# introspector = NXIntrospector('test.sim') +print('Backend modules OK') +" +``` + +## T8.3 - Git Commit + +```bash +git add . +git commit -m "feat: Canvas V4 - Model introspection and Claude integration fixes + +## Critical Bug Fixes +- Fix Claude bot SQL error with robust database initialization +- Fix WebSocket chat connection with proper error handling and reconnect +- Add health check endpoint for database status + +## Model Introspection +- Add NX model introspection service (expressions, solver type, dependencies) +- Create Introspection Panel with collapsible sections +- Add File Browser component for .sim/.prt/.fem/.afem selection +- One-click add expressions as Design Variables +- One-click add suggested Extractors + +## Connection Flow Fix +- Update node handles: expressions flow INTO model (not out) +- Update validation for new data flow direction +- Multiple input handles on Model node for design variables + +## Backend Additions +- /api/files/list - Directory browser with type filtering +- /api/nx/introspect - Model introspection endpoint +- /api/nx/expressions - Expression discovery endpoint + +## Frontend Improvements +- FileBrowser: File picker with search and type filtering +- IntrospectionPanel: Shows expressions, extractors, dependencies +- NodeConfigPanel: Integrated file browser and introspection + +Co-Authored-By: Claude Opus 4.5 " + +git push origin main && git push github main +``` + +--- + +# ACCEPTANCE CRITERIA + +## Critical Fixes +- [ ] No SQL error when clicking Validate +- [ ] Chat panel connects and responds +- [ ] WebSocket reconnects on disconnect + +## File Browser +- [ ] Browse button opens file picker +- [ ] Can navigate directories +- [ ] Filters to .sim/.prt/.fem/.afem +- [ ] Selecting file populates path + +## Model Introspection +- [ ] "Introspect Model" button works +- [ ] Shows discovered expressions +- [ ] Shows available extractors +- [ ] Shows dependent files +- [ ] Can add expression as Design Variable +- [ ] Can add suggested Extractor + +## Connection Flow +- [ ] Design Variables connect TO Model (not from) +- [ ] Validation checks correct flow direction + +## Build +- [ ] `npm run build` passes +- [ ] Backend imports work + +--- + +# BEGIN EXECUTION + +Execute all 8 phases sequentially. Use TodoWrite for every task. Complete fully before moving to next phase. Do not stop. + +**GO.**