28 Commits

Author SHA1 Message Date
a3f18dc377 chore: Project cleanup and Canvas UX improvements (Phase 7-9)
## Cleanup (v0.5.0)
- Delete 102+ orphaned MCP session temp files
- Remove build artifacts (htmlcov, dist, __pycache__)
- Archive superseded plan docs (RALPH_LOOP V2/V3, CANVAS V3, etc.)
- Move debug/analysis scripts from tests/ to tools/analysis/
- Archive redundant NX journals to archive/nx_journals/
- Archive monolithic PROTOCOL.md to docs/archive/
- Update .gitignore with missing patterns
- Clean old study files (optimization_log_old.txt, run_optimization_old.py)

## Canvas UX (Phases 7-9)
- Phase 7: Resizable panels with localStorage persistence
  - Left sidebar: 200-400px, Right panel: 280-600px
  - New useResizablePanel hook and ResizeHandle component
- Phase 8: Enable all palette items
  - All 8 node types now draggable
  - Singleton logic for model/solver/algorithm/surrogate
- Phase 9: Solver configuration
  - Add SolverEngine type (nxnastran, mscnastran, python, etc.)
  - Add NastranSolutionType (SOL101-SOL200)
  - Engine/solution dropdowns in config panel
  - Python script path support

## Documentation
- Update CHANGELOG.md with recent versions
- Update docs/00_INDEX.md
- Create examples/README.md
- Add docs/plans/CANVAS_UX_IMPROVEMENTS.md
2026-01-24 15:17:34 -05:00
2cb8dccc3a feat: Add WebSocket live updates and convergence visualization
Phase 4 - Live Updates:
- Create useOptimizationStream hook for real-time trial updates
- Replace polling with WebSocket subscription in SpecRenderer
- Auto-report errors to ErrorPanel via panel store
- Add progress tracking (FEA count, NN count, best trial)

Phase 5 - Convergence Visualization:
- Add ConvergenceSparkline component for mini line charts
- Add ProgressRing component for circular progress indicator
- Update ObjectiveNode to show convergence trend sparkline
- Add history field to ObjectiveNodeData schema
- Add live progress indicator centered on canvas when running

Bug fixes:
- Fix TypeScript errors in FloatingIntrospectionPanel (type casts)
- Fix ValidationPanel using wrong store method (selectNode vs setSelectedNodeId)
- Fix NodeConfigPanelV2 unused state variable
- Fix specValidator source.extractor_id path
- Clean up unused imports across components
2026-01-21 21:48:35 -05:00
c224b16ac3 feat: Add panel management, validation, and error handling to canvas
Phase 1 - Panel Management System:
- Create usePanelStore.ts for centralized panel state management
- Add PanelContainer.tsx for draggable floating panels
- Create FloatingIntrospectionPanel.tsx (persistent, doesn't disappear on node click)
- Create ResultsPanel.tsx for trial result details
- Refactor NodeConfigPanelV2 to use panel store for introspection
- Integrate PanelContainer into CanvasView

Phase 2 - Pre-run Validation:
- Create specValidator.ts with comprehensive validation rules
- Add ValidationPanel (enhanced version with error navigation)
- Add Validate button to SpecRenderer with status indicator
- Block run if validation fails
- Check for: design vars, objectives, extractors, bounds, connections

Phase 3 - Error Handling & Recovery:
- Create ErrorPanel.tsx for displaying optimization errors
- Add error classification (nx_crash, solver_fail, extractor_error, etc.)
- Add recovery suggestions based on error type
- Update status endpoint to return error info
- Add _get_study_error_info helper to check error_status.json and DB
- Integrate error detection into status polling

Documentation:
- Add CANVAS_ROBUSTNESS_PLAN.md with full implementation plan
2026-01-21 21:35:31 -05:00
e1c59a51c1 feat: Add optimization execution and live results overlay to canvas
Phase 2 - Execution Bridge:
- Update /start endpoint to fallback to generic runner when no study script exists
- Auto-detect model files (.prt, .sim) from 1_setup/model/ directory
- Pass atomizer_spec.json path to generic runner

Phase 3 - Live Monitoring & Results Overlay:
- Add ResultBadge component for displaying values on canvas nodes
- Extend schema with resultValue and isFeasible fields
- Update DesignVarNode, ObjectiveNode, ConstraintNode, ExtractorNode to show results
- Add Run/Stop buttons and Results toggle to SpecRenderer
- Poll /status endpoint every 3s and map best_trial values to nodes
- Show green/red badges for constraint feasibility
2026-01-21 21:21:47 -05:00
f725e75164 feat: Add SIM file introspection journal and enhanced file-type specific UI
- Create introspect_sim.py NX journal to extract solutions, BCs from SIM files
- Update introspect_sim_file() to optionally call NX journal for full introspection
- Add FEM mesh section (nodes, elements, materials, properties) to IntrospectionPanel
- Add SIM solutions and boundary conditions sections to IntrospectionPanel
- Show introspection method and NX errors in panel
2026-01-20 21:20:14 -05:00
e954b130f5 feat: Multi-file introspection for FEM/SIM/PRT with PyNastran parsing 2026-01-20 21:14:16 -05:00
5b22439357 feat: Add part selector dropdown to IntrospectionPanel
- Fetch available parts from /nx/parts on panel mount
- Dropdown to select which part to introspect (default = assembly)
- Hides idealized parts (*_i.prt) from dropdown
- Shows part size in dropdown (KB or MB)
- Header shows selected part name in primary color
- Refresh button respects current part selection
- Auto-introspects when part selection changes
2026-01-20 21:04:36 -05:00
0c252e3a65 feat: Add sub-part introspection and existing FEA results UI
Backend:
- GET /nx/parts - List all .prt files in model directory
- GET /nx/introspect/{part_name} - Introspect a specific part file
  (e.g., M1_Blank.prt instead of just the assembly)
- Each part gets its own cache file (_introspection_{stem}.json)

Frontend IntrospectionPanel:
- Add 'FEA Results' section showing existing OP2/F06 sources
- Green checkmark when results exist, shows recommended source
- Expand file_deps and fea_results sections by default
- Add CheckCircle2 and Database icons

This allows introspecting component parts that contain the actual
design variable expressions (e.g., M1_Blank has 56 expressions
while the assembly ASSY_M1 only has 5).
2026-01-20 20:59:04 -05:00
4749944a48 feat: Add extract endpoint to use existing FEA results without re-solving
- scan_existing_fea_results() scans study for existing OP2/F06 files
- Introspection now returns existing_fea_results with recommended source
- New POST /nx/extract endpoint runs extractors on existing OP2 files
- Supports: displacement, stress, frequency, mass_bdf, zernike
- No NX solve needed - uses PyNastran and Atomizer extractors directly

This allows users to test extractors and get physics data from existing
simulation results without re-running the FEA solver.
2026-01-20 20:51:25 -05:00
3229c31349 fix: Rewrite run-baseline to use NXSolver iteration folder pattern
- Use same approach as run_optimization.py with use_iteration_folders=True
- NXSolver.create_iteration_folder() handles proper file copying
- Read NX settings from atomizer_spec.json or optimization_config.json
- Extract Nastran version from NX install path
- Creates iter0 folder for baseline (consistent with optimization numbering)

This fixes the issue where manually copying files didn't preserve
NX file dependency chain (.sim -> .afm -> .fem -> _i.prt -> .prt)
2026-01-20 19:06:40 -05:00
14354a2606 feat: Add NX file dependency tree to introspection panel
Backend:
- Add scan_nx_file_dependencies() function to parse NX file chain
- Uses naming conventions to build dependency tree (.sim -> .afm -> .fem -> _i.prt -> .prt)
- Include file_dependencies in introspection response
- Works without NX (pure file-based analysis)

Frontend:
- Add FileDependencies interface for typed API response
- Add collapsible 'File Dependencies' section with tree visualization
- Color-coded file types (purple=sim, blue=afm, green=fem, yellow=idealized, orange=prt)
- Shows orphan geometry files that aren't in the dependency chain
2026-01-20 15:33:04 -05:00
abbc7b1b50 feat: Add detailed Nastran memory error detection in run-baseline
- Parse Nastran log file to detect memory allocation failures
- Extract requested vs available memory from log
- Provide actionable error message with specific values
- Include log files in result_files response
2026-01-20 15:29:29 -05:00
1cdcc17ffd fix: NX installation path detection for run-baseline endpoint
- Read nx_install_path from atomizer_spec.json if available
- Auto-detect from common Siemens installation paths
- Fixes issue where NX2512 wasn't found (actual path is DesigncenterNX2512)
2026-01-20 15:23:10 -05:00
5c419e2358 fix(canvas): Multiple fixes for drag-drop, undo/redo, and code generation
Drag-drop fixes:
- Fix Objective default data: use nested 'source' object with extractor_id/output_name
- Fix Constraint default data: use 'type' field (not constraint_type), 'threshold' (not limit)

Undo/Redo fixes:
- Remove dependency on isDirty flag (which is always false due to auto-save)
- Record snapshots based on actual spec changes via deep comparison

Code generation improvements:
- Update system prompt to support multiple extractor types:
  * OP2-based extractors for FEA results (stress, displacement, frequency)
  * Expression-based extractors for NX model values (dimensions, volumes)
  * Computed extractors for derived values (no FEA needed)
- Claude will now choose appropriate signature based on user's description
2026-01-20 15:08:49 -05:00
89694088a2 feat(canvas): Add 'Run Baseline' FEA simulation feature to IntrospectionPanel
Backend:
- Add POST /api/optimization/studies/{study_id}/nx/run-baseline endpoint
- Creates trial_baseline folder in 2_iterations/
- Copies all model files and runs NXSolver
- Returns paths to result files (.op2, .f06, .bdf) for extractor testing

Frontend:
- Add 'Run Baseline Simulation' button to IntrospectionPanel
- Show progress spinner during simulation
- Display result files when complete (OP2, F06, BDF)
- Show error messages if simulation fails

This enables:
- Testing custom extractors against real FEA results
- Validating the simulation pipeline before optimization
- Inspecting boundary conditions and loads
2026-01-20 14:50:50 -05:00
91cf9ca1fd fix(canvas): Add Save/Reload buttons and expand IntrospectionPanel to show all model data
CanvasView:
- Fix Save button visibility - now shows when spec is loaded (grayed if no changes)
- Separate logic for spec mode vs legacy mode save buttons
- Fix Reload button visibility

IntrospectionPanel:
- Add Mass Properties section (mass, volume, surface area, CoG, body count)
- Add Linked Parts section showing file dependencies
- Add Bodies section (solid/sheet body counts)
- Add Units section showing unit system
- Type-safe access to all nested properties
2026-01-20 14:47:09 -05:00
ced79b8d39 fix(canvas): Fix IntrospectionPanel to handle new NX introspection API response format
- Handle expressions as object with user/internal arrays (new format) or direct array (old)
- Add useMemo for expression filtering
- Make extractors_available, dependent_files, warnings optional with safe access
- Support both 'unit' and 'units' field names
2026-01-20 14:26:20 -05:00
2f0f45de86 fix(spec): Correct volume extractor structure in m1_mirror_cost_reduction_lateral
- Change custom_function.code to function.source_code per AtomizerSpec v2.0 schema
2026-01-20 14:14:20 -05:00
47f8b50112 fix(canvas): Bug fixes for node movement, drag-drop, config panel, and introspection
- SpecRenderer: Add localNodes state with applyNodeChanges for smooth node dragging
- SpecRenderer: Fix getDefaultNodeData() - extractor uses 'custom_function' type with function definition
- SpecRenderer: Fix constraint default - use constraint_type instead of type
- CanvasView: Show config panel INSTEAD of chat when node selected (not blocked)
- NodeConfigPanelV2: Enable showHeader for code editor toolbar (Generate/Snippets/Validate/Test buttons)
- NodeConfigPanelV2: Pass studyId to IntrospectionPanel
- IntrospectionPanel: Accept studyId prop and use correct API endpoint
- optimization.py: Search multiple directories for model files including 1_setup/model/
2026-01-20 14:14:14 -05:00
cf8c57fdac chore: Add Atomizer launcher and utility scripts
- atomizer.ico: Application icon
- launch_atomizer.bat: One-click launcher for dashboard
- create_desktop_shortcut.ps1: Desktop shortcut creator
- restart_backend.bat, start_backend_8002.bat: Dev utilities
2026-01-20 13:12:12 -05:00
6c30224341 feat(config): AtomizerSpec v2.0 Pydantic models, validators, and tests
Config Layer:
- spec_models.py: Pydantic models for AtomizerSpec v2.0
- spec_validator.py: Semantic validation with detailed error reporting

Extractors:
- custom_extractor_loader.py: Runtime custom extractor loading
- spec_extractor_builder.py: Build extractors from spec definitions

Tools:
- migrate_to_spec_v2.py: CLI tool for batch migration

Tests:
- test_migrator.py: Migration tests
- test_spec_manager.py: SpecManager service tests
- test_spec_api.py: REST API tests
- test_mcp_tools.py: MCP tool tests
- test_e2e_unified_config.py: End-to-end config tests
2026-01-20 13:12:03 -05:00
27e78d3d56 feat(canvas): Custom extractor components, migrator, and MCP spec tools
Canvas Components:
- CustomExtractorNode.tsx: Node for custom Python extractors
- CustomExtractorPanel.tsx: Configuration panel for custom extractors
- ConnectionStatusIndicator.tsx: WebSocket status display
- atomizer-spec.ts: TypeScript types for AtomizerSpec v2.0

Config:
- migrator.py: Legacy config to AtomizerSpec v2.0 migration
- Updated __init__.py exports for config and extractors

MCP Tools:
- spec.ts: MCP tools for spec manipulation
- index.ts: Tool registration updates
2026-01-20 13:11:42 -05:00
cb6b130908 feat(config): Add AtomizerSpec v2.0 schema and migrate all studies
Added JSON Schema:
- optimization_engine/schemas/atomizer_spec_v2.json

Migrated 28 studies to AtomizerSpec v2.0 format:
- Drone_Gimbal studies (1)
- M1_Mirror studies (21)
- M2_Mirror studies (2)
- SheetMetal_Bracket studies (4)

Each atomizer_spec.json is the unified configuration containing:
- Design variables with bounds and expressions
- Extractors (standard and custom)
- Objectives and constraints
- Optimization algorithm settings
- Canvas layout information
2026-01-20 13:11:23 -05:00
f067497e08 refactor(dashboard): Remove unused Plotly components
Removed plotly/ directory with unused chart wrappers:
- PlotlyConvergencePlot, PlotlyCorrelationHeatmap
- PlotlyFeasibilityChart, PlotlyParallelCoordinates
- PlotlyParameterImportance, PlotlyParetoPlot
- PlotlyRunComparison, PlotlySurrogateQuality

These were replaced by Recharts-based implementations.
2026-01-20 13:11:02 -05:00
ba0b9a1fae feat(dashboard): Enhanced chat, spec management, and Claude integration
Backend:
- spec.py: New AtomizerSpec REST API endpoints
- spec_manager.py: SpecManager service for unified config
- interview_engine.py: Study creation interview logic
- claude.py: Enhanced Claude API with context
- optimization.py: Extended optimization endpoints
- context_builder.py, session_manager.py: Improved services

Frontend:
- Chat components: Enhanced message rendering, tool call cards
- Hooks: useClaudeCode, useSpecWebSocket, improved useChat
- Pages: Updated Dashboard, Analysis, Insights, Setup, Home
- Components: ParallelCoordinatesPlot, ParetoPlot improvements
- App.tsx: Route updates for canvas/studio

Infrastructure:
- vite.config.ts: Build configuration updates
- start/stop-dashboard.bat: Script improvements
2026-01-20 13:10:47 -05:00
b05412f807 feat(canvas): Claude Code integration with streaming, snippets, and live preview
Backend:
- Add POST /generate-extractor for AI code generation via Claude CLI
- Add POST /generate-extractor/stream for SSE streaming generation
- Add POST /validate-extractor with enhanced syntax checking
- Add POST /check-dependencies for import analysis
- Add POST /test-extractor for live OP2 file testing
- Add ClaudeCodeSession service for managing CLI sessions

Frontend:
- Add lib/api/claude.ts with typed API functions
- Enhance CodeEditorPanel with:
  - Streaming generation with live preview
  - Code snippets library (6 templates: displacement, stress, frequency, mass, energy, reaction)
  - Test button for live OP2 validation
  - Cancel button for stopping generation
  - Dependency warnings display
- Integrate streaming and testing into NodeConfigPanelV2

Uses Claude CLI (--print mode) to leverage Pro/Max subscription without API costs.
2026-01-20 13:08:12 -05:00
ffd41e3a60 feat(canvas): Studio Enhancement Phase 3 & 4 - undo/redo and Monaco editor
Phase 3 - Undo/Redo System:
- Create generic useUndoRedo hook with configurable options
- Add localStorage persistence for per-study history (max 30 steps)
- Create useSpecUndoRedo hook integrating with useSpecStore
- Add useUndoRedoKeyboard hook for Ctrl+Z/Ctrl+Y shortcuts
- Add undo/redo buttons to canvas header with tooltips
- Debounced history recording (1s delay after changes)

Phase 4 - Monaco Code Editor:
- Create CodeEditorPanel component with Monaco editor
- Add Python syntax highlighting and auto-completion
- Include pyNastran/OP2 specific completions
- Add Claude AI code generation integration (placeholder)
- Include code validation/run functionality
- Show output variables preview section
- Add copy-to-clipboard and generation prompt UI

Dependencies:
- Add @monaco-editor/react package

Technical:
- All TypeScript checks pass
- All 15 unit tests pass
- Production build successful
2026-01-20 11:58:21 -05:00
c4a3cff91a feat(canvas): Studio Enhancement Phase 1 & 2 - v2.0 architecture and file structure
Phase 1 - Foundation:
- Add NodeConfigPanelV2 using useSpecStore for AtomizerSpec v2.0 mode
- Deprecate AtomizerCanvas and useCanvasStore with migration docs
- Add VITE_USE_LEGACY_CANVAS env var for emergency fallback
- Enhance NodePalette with collapse support, filtering, exports
- Add drag-drop support to SpecRenderer with default node data
- Setup test infrastructure (Vitest + Playwright configs)
- Add useSpecStore unit tests (15 tests)

Phase 2 - File Structure & Model:
- Create FileStructurePanel with tree view of study files
- Add ModelNodeV2 with collapsible file dependencies
- Add tabbed left sidebar (Components/Files tabs)
- Add GET /api/files/structure/{study_id} backend endpoint
- Auto-expand 1_setup folders in file tree
- Show model file introspection with solver type and expressions

Technical:
- All TypeScript checks pass
- All 15 unit tests pass
- Production build successful
2026-01-20 11:53:26 -05:00
195 changed files with 44549 additions and 6074 deletions

View File

@@ -1 +0,0 @@
{"mcpServers": {"atomizer": {"command": "node", "args": ["C:\\Users\\antoi\\Atomizer\\mcp-server\\atomizer-tools\\dist\\index.js"], "env": {"ATOMIZER_MODE": "user", "ATOMIZER_ROOT": "C:\\Users\\antoi\\Atomizer"}}}}

View File

@@ -1 +0,0 @@
{"mcpServers": {"atomizer": {"command": "node", "args": ["C:\\Users\\antoi\\Atomizer\\mcp-server\\atomizer-tools\\dist\\index.js"], "env": {"ATOMIZER_MODE": "user", "ATOMIZER_ROOT": "C:\\Users\\antoi\\Atomizer"}}}}

View File

@@ -1,45 +0,0 @@
# Atomizer Assistant
You are the Atomizer Assistant - an expert system for structural optimization using FEA.
**Current Mode**: USER
Your role:
- Help engineers with FEA optimization workflows
- Create, configure, and run optimization studies
- Analyze results and provide insights
- Explain FEA concepts and methodology
Important guidelines:
- Be concise and professional
- Use technical language appropriate for engineers
- You are "Atomizer Assistant", not a generic AI
- Use the available MCP tools to perform actions
- When asked about studies, use the appropriate tools to get real data
---
# Current Study: m1_mirror_flatback_lateral
**Status**: Study directory not found.
---
# User Mode Instructions
You can help with optimization workflows:
- Create and configure studies
- Run optimizations
- Analyze results
- Generate reports
- Explain FEA concepts
**For code modifications**, suggest switching to Power Mode.
Available tools:
- `list_studies`, `get_study_status`, `create_study`
- `run_optimization`, `stop_optimization`, `get_optimization_status`
- `get_trial_data`, `analyze_convergence`, `compare_trials`, `get_best_design`
- `generate_report`, `export_data`
- `explain_physics`, `recommend_method`, `query_extractors`

View File

@@ -1,45 +0,0 @@
# Atomizer Assistant
You are the Atomizer Assistant - an expert system for structural optimization using FEA.
**Current Mode**: USER
Your role:
- Help engineers with FEA optimization workflows
- Create, configure, and run optimization studies
- Analyze results and provide insights
- Explain FEA concepts and methodology
Important guidelines:
- Be concise and professional
- Use technical language appropriate for engineers
- You are "Atomizer Assistant", not a generic AI
- Use the available MCP tools to perform actions
- When asked about studies, use the appropriate tools to get real data
---
# Current Study: m1_mirror_flatback_lateral
**Status**: Study directory not found.
---
# User Mode Instructions
You can help with optimization workflows:
- Create and configure studies
- Run optimizations
- Analyze results
- Generate reports
- Explain FEA concepts
**For code modifications**, suggest switching to Power Mode.
Available tools:
- `list_studies`, `get_study_status`, `create_study`
- `run_optimization`, `stop_optimization`, `get_optimization_status`
- `get_trial_data`, `analyze_convergence`, `compare_trials`, `get_best_design`
- `generate_report`, `export_data`
- `explain_physics`, `recommend_method`, `query_extractors`

View File

@@ -62,7 +62,23 @@
"Bash(xargs -I{} git ls-tree -r -l HEAD {})", "Bash(xargs -I{} git ls-tree -r -l HEAD {})",
"Bash(sort:*)", "Bash(sort:*)",
"Bash(C:Usersantoianaconda3envsatomizerpython.exe introspect_model.py)", "Bash(C:Usersantoianaconda3envsatomizerpython.exe introspect_model.py)",
"Bash(xargs:*)" "Bash(xargs:*)",
"Bash(ping:*)",
"Bash(C:Usersantoianaconda3envsatomizerpython.exe -c \"import requests; r = requests.post\\(''http://127.0.0.1:8001/api/claude/sessions'', json={''mode'': ''user''}\\); print\\(f''Status: {r.status_code}''\\); print\\(f''Response: {r.text}''\\)\")",
"Bash(start \"Atomizer Backend\" cmd /k C:UsersantoiAtomizerrestart_backend.bat)",
"Bash(start \"Test Backend\" cmd /c \"cd /d C:\\\\Users\\\\antoi\\\\Atomizer\\\\atomizer-dashboard\\\\backend && C:\\\\Users\\\\antoi\\\\anaconda3\\\\Scripts\\\\activate.bat atomizer && python -m uvicorn api.main:app --port 8002\")",
"Bash(C:Usersantoianaconda3envsatomizerpython.exe C:UsersantoiAtomizertest_backend.py)",
"Bash(start \"Backend 8002\" C:UsersantoiAtomizerstart_backend_8002.bat)",
"Bash(C:Usersantoianaconda3envsatomizerpython.exe -c \"from api.main import app; print\\(''Import OK''\\)\")",
"Bash(find:*)",
"Bash(npx tailwindcss:*)",
"Bash(C:Usersantoianaconda3envsatomizerpython.exe -c \"from pathlib import Path; p = Path\\(''C:/Users/antoi/Atomizer/studies''\\) / ''M1_Mirror/m1_mirror_cost_reduction_lateral''; print\\(''exists:'', p.exists\\(\\), ''path:'', p\\)\")",
"Bash(C:Usersantoianaconda3envsatomizerpython.exe -c \"import sys, json; d=json.load\\(sys.stdin\\); print\\(''Study:'', d.get\\(''meta'',{}\\).get\\(''study_name'',''N/A''\\)\\); print\\(''Design Variables:''\\); [print\\(f'' - {dv[\"\"name\"\"]} \\({dv[\"\"expression_name\"\"]}\\)''\\) for dv in d.get\\(''design_variables'',[]\\)]\")",
"Bash(C:Usersantoianaconda3envsatomizerpython.exe -m py_compile:*)",
"Skill(ralph-loop:ralph-loop)",
"Skill(ralph-loop:ralph-loop:*)",
"mcp__Claude_in_Chrome__computer",
"mcp__Claude_in_Chrome__navigate"
], ],
"deny": [], "deny": [],
"ask": [] "ask": []

12
.gitignore vendored
View File

@@ -110,5 +110,17 @@ _dat_run*.dat
.claude-mcp-*.json .claude-mcp-*.json
.claude-prompt-*.md .claude-prompt-*.md
# Backend logs
backend_stdout.log
backend_stderr.log
*.log.bak
# Linter/formatter caches
.ruff_cache/
.mypy_cache/
# Auto-generated documentation (regenerate with: python -m optimization_engine.auto_doc all) # Auto-generated documentation (regenerate with: python -m optimization_engine.auto_doc all)
docs/generated/ docs/generated/
# Malformed filenames (Windows path used as filename)
C:*

View File

@@ -6,6 +6,64 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased] ## [Unreleased]
## [0.5.0] - 2025-01-24
### Project Cleanup & Organization
- Deleted 102+ orphaned MCP session temp files
- Removed build artifacts (htmlcov, dist, __pycache__)
- Archived superseded plan documents (RALPH_LOOP V2/V3, CANVAS V3, etc.)
- Moved debug/analysis scripts from tests/ to tools/analysis/
- Updated .gitignore with missing patterns
- Cleaned empty directories
## [0.4.0] - 2025-01-22
### Canvas UX Improvements (Phases 7-9)
- **Resizable Panels**: Left sidebar (200-400px) and right panel (280-600px) with localStorage persistence
- **All Palette Items Enabled**: All 8 node types now draggable (model, solver, designVar, extractor, objective, constraint, algorithm, surrogate)
- **Solver Configuration**: Engine selection (NX Nastran, MSC Nastran, Python Script) with solution type dropdowns (SOL101-SOL200)
### AtomizerSpec v2.0
- Unified JSON configuration schema for all studies
- Added SolverEngine and NastranSolutionType types
- Canvas position persistence for all nodes
- Migration support from legacy optimization_config.json
## [0.3.0] - 2025-01-18
### Dashboard V3.1 - Canvas Builder
- Visual workflow builder with 9 node types
- Spec ↔ ReactFlow bidirectional converter
- WebSocket real-time synchronization
- Claude chat integration
- Custom extractors with in-canvas code editor
- Model introspection panel
### Learning Atomizer Core (LAC)
- Persistent memory system for accumulated knowledge
- Session insights recording (failures, workarounds, patterns)
- Optimization outcome tracking
## [0.2.5] - 2025-01-16
### GNN Surrogate for Zernike Optimization
- PolarMirrorGraph with fixed 3000-node polar grid
- ZernikeGNN model with design-conditioned convolutions
- Differentiable GPU-accelerated Zernike fitting
- Training pipeline with multi-task loss
### DevLoop Automation
- Closed-loop development system with AI agents
- Gemini planning, Claude implementation
- Playwright browser testing for dashboard UI
## [0.2.1] - 2025-01-07
### Optimization Engine v2.0 Restructure
- Reorganized into modular subpackages (core/, nx/, study/, config/)
- SpecManager for AtomizerSpec handling
- Deprecation warnings for old import paths
### Phase 3.3 - Dashboard & Multi-Solution Support (November 23, 2025) ### Phase 3.3 - Dashboard & Multi-Solution Support (November 23, 2025)
#### Added #### Added

View File

@@ -55,6 +55,49 @@ If working directory is inside a study (`studies/*/`):
- If no study context: Offer to create one or list available studies - If no study context: Offer to create one or list available studies
- After code changes: Update documentation proactively (SYS_12, cheatsheet) - After code changes: Update documentation proactively (SYS_12, cheatsheet)
### Step 5: Use DevLoop for Multi-Step Development Tasks
**CRITICAL: For any development task with 3+ steps, USE DEVLOOP instead of manual work.**
DevLoop is the closed-loop development system that coordinates AI agents for autonomous development:
```bash
# Plan a task with Gemini
python tools/devloop_cli.py plan "fix extractor exports"
# Implement with Claude
python tools/devloop_cli.py implement
# Test filesystem/API
python tools/devloop_cli.py test --study support_arm
# Test dashboard UI with Playwright
python tools/devloop_cli.py browser --level full
# Analyze failures
python tools/devloop_cli.py analyze
# Full autonomous cycle
python tools/devloop_cli.py start "add new stress extractor"
```
**When to use DevLoop:**
- Fixing bugs that require multiple file changes
- Adding new features or extractors
- Debugging optimization failures
- Testing dashboard UI changes
- Any task that would take 3+ manual steps
**Browser test levels:**
- `quick` - Smoke test (1 test)
- `home` - Home page verification (2 tests)
- `full` - All UI tests (5+ tests)
- `study` - Canvas/dashboard for specific study
**DO NOT default to manual debugging** - use the automation we built!
**Full documentation**: `docs/guides/DEVLOOP.md`
--- ---
## Quick Start - Protocol Operating System ## Quick Start - Protocol Operating System

File diff suppressed because one or more lines are too long

View File

@@ -13,7 +13,7 @@ import sys
# Add parent directory to path to import optimization_engine # Add parent directory to path to import optimization_engine
sys.path.append(str(Path(__file__).parent.parent.parent.parent)) sys.path.append(str(Path(__file__).parent.parent.parent.parent))
from api.routes import optimization, claude, terminal, insights, context, files, nx from api.routes import optimization, claude, terminal, insights, context, files, nx, claude_code, spec
from api.websocket import optimization_stream from api.websocket import optimization_stream
@@ -60,6 +60,9 @@ app.include_router(insights.router, prefix="/api/insights", tags=["insights"])
app.include_router(context.router, prefix="/api/context", tags=["context"]) app.include_router(context.router, prefix="/api/context", tags=["context"])
app.include_router(files.router, prefix="/api/files", tags=["files"]) app.include_router(files.router, prefix="/api/files", tags=["files"])
app.include_router(nx.router, prefix="/api/nx", tags=["nx"]) app.include_router(nx.router, prefix="/api/nx", tags=["nx"])
app.include_router(claude_code.router, prefix="/api", tags=["claude-code"])
app.include_router(spec.router, prefix="/api", tags=["spec"])
app.include_router(spec.validate_router, prefix="/api", tags=["spec"])
@app.get("/") @app.get("/")
async def root(): async def root():

View File

@@ -187,7 +187,15 @@ async def session_websocket(websocket: WebSocket, session_id: str):
continue continue
# Get canvas state from message or use stored state # Get canvas state from message or use stored state
canvas_state = data.get("canvas_state") or current_canvas_state msg_canvas = data.get("canvas_state")
canvas_state = msg_canvas if msg_canvas is not None else current_canvas_state
# Debug logging
if canvas_state:
node_count = len(canvas_state.get("nodes", []))
print(f"[Claude WS] Sending message with canvas state: {node_count} nodes")
else:
print("[Claude WS] Sending message WITHOUT canvas state")
async for chunk in manager.send_message( async for chunk in manager.send_message(
session_id, session_id,
@@ -401,6 +409,175 @@ async def websocket_chat(websocket: WebSocket):
pass pass
# ========== POWER MODE: Direct API with Write Tools ==========
@router.websocket("/sessions/{session_id}/ws/power")
async def power_mode_websocket(websocket: WebSocket, session_id: str):
"""
WebSocket for power mode chat using direct Anthropic API with write tools.
Unlike the regular /ws endpoint which uses Claude CLI + MCP,
this uses AtomizerClaudeAgent directly with built-in write tools.
This allows immediate modifications without permission prompts.
Message formats (client -> server):
{"type": "message", "content": "user message"}
{"type": "set_study", "study_id": "study_name"}
{"type": "ping"}
Message formats (server -> client):
{"type": "text", "content": "..."}
{"type": "tool_call", "tool": "...", "input": {...}}
{"type": "tool_result", "result": "..."}
{"type": "done", "tool_calls": [...]}
{"type": "error", "message": "..."}
{"type": "spec_modified", "changes": [...]}
{"type": "pong"}
"""
await websocket.accept()
manager = get_session_manager()
session = manager.get_session(session_id)
if not session:
await websocket.send_json({"type": "error", "message": "Session not found"})
await websocket.close()
return
# Import AtomizerClaudeAgent for direct API access
from api.services.claude_agent import AtomizerClaudeAgent
# Create agent with study context
agent = AtomizerClaudeAgent(study_id=session.study_id)
conversation_history: List[Dict[str, Any]] = []
# Load initial spec and set canvas state so Claude sees current canvas
initial_spec = agent.load_current_spec()
if initial_spec:
# Send initial spec to frontend
await websocket.send_json({
"type": "spec_updated",
"spec": initial_spec,
"reason": "initial_load"
})
try:
while True:
data = await websocket.receive_json()
if data.get("type") == "message":
content = data.get("content", "")
if not content:
continue
try:
# Use streaming API with tool support for real-time response
last_tool_calls = []
async for event in agent.chat_stream_with_tools(content, conversation_history):
event_type = event.get("type")
if event_type == "text":
# Stream text tokens to frontend immediately
await websocket.send_json({
"type": "text",
"content": event.get("content", ""),
})
elif event_type == "tool_call":
# Tool is being called
tool_info = event.get("tool", {})
await websocket.send_json({
"type": "tool_call",
"tool": tool_info,
})
elif event_type == "tool_result":
# Tool finished executing
tool_name = event.get("tool", "")
await websocket.send_json({
"type": "tool_result",
"tool": tool_name,
"result": event.get("result", ""),
})
# If it was a write tool, send full updated spec
if tool_name in ["add_design_variable", "add_extractor",
"add_objective", "add_constraint",
"update_spec_field", "remove_node",
"create_study"]:
# Load updated spec and update agent's canvas state
updated_spec = agent.load_current_spec()
if updated_spec:
await websocket.send_json({
"type": "spec_updated",
"tool": tool_name,
"spec": updated_spec, # Full spec for direct canvas update
})
elif event_type == "done":
# Streaming complete
last_tool_calls = event.get("tool_calls", [])
await websocket.send_json({
"type": "done",
"tool_calls": last_tool_calls,
})
# Update conversation history for next message
# Note: For proper history tracking, we'd need to store messages properly
# For now, we append the user message and response
conversation_history.append({"role": "user", "content": content})
conversation_history.append({"role": "assistant", "content": event.get("response", "")})
except Exception as e:
import traceback
traceback.print_exc()
await websocket.send_json({
"type": "error",
"message": str(e),
})
elif data.get("type") == "canvas_edit":
# User made a manual edit to the canvas - update Claude's context
spec = data.get("spec")
if spec:
agent.set_canvas_state(spec)
await websocket.send_json({
"type": "canvas_edit_received",
"acknowledged": True
})
elif data.get("type") == "set_study":
study_id = data.get("study_id")
if study_id:
await manager.set_study_context(session_id, study_id)
# Recreate agent with new study context
agent = AtomizerClaudeAgent(study_id=study_id)
conversation_history = [] # Clear history on study change
# Load spec for new study
new_spec = agent.load_current_spec()
await websocket.send_json({
"type": "context_updated",
"study_id": study_id,
})
if new_spec:
await websocket.send_json({
"type": "spec_updated",
"spec": new_spec,
"reason": "study_change"
})
elif data.get("type") == "ping":
await websocket.send_json({"type": "pong"})
except WebSocketDisconnect:
pass
except Exception as e:
try:
await websocket.send_json({"type": "error", "message": str(e)})
except:
pass
@router.get("/suggestions") @router.get("/suggestions")
async def get_chat_suggestions(study_id: Optional[str] = None): async def get_chat_suggestions(study_id: Optional[str] = None):
""" """

View File

@@ -0,0 +1,920 @@
"""
Claude Code WebSocket Routes
Provides WebSocket endpoint that connects to actual Claude Code CLI.
This gives dashboard users the same power as terminal Claude Code users.
Unlike the MCP-based approach in claude.py:
- Spawns actual Claude Code CLI processes
- Full file editing capabilities
- Full command execution
- Opus 4.5 model with unlimited tool use
Also provides single-shot endpoints for code generation:
- POST /generate-extractor: Generate Python extractor code
- POST /validate-extractor: Validate Python syntax
"""
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, HTTPException, Body
from pydantic import BaseModel
from typing import Dict, Optional, List
import json
import asyncio
import re
import os
from pathlib import Path
from api.services.claude_code_session import (
get_claude_code_manager,
ClaudeCodeSession,
ATOMIZER_ROOT,
)
router = APIRouter(prefix="/claude-code", tags=["Claude Code"])
# ==================== Extractor Code Generation ====================
class ExtractorGenerationRequest(BaseModel):
"""Request model for extractor code generation"""
prompt: str # User's description
study_id: Optional[str] = None # Study context
existing_code: Optional[str] = None # Current code to improve
output_names: List[str] = [] # Expected outputs
class ExtractorGenerationResponse(BaseModel):
"""Response model for generated code"""
code: str # Generated Python code
outputs: List[str] # Detected output names
explanation: Optional[str] = None # Brief explanation
class CodeValidationRequest(BaseModel):
"""Request model for code validation"""
code: str
class CodeValidationResponse(BaseModel):
"""Response model for validation result"""
valid: bool
error: Optional[str] = None
@router.post("/generate-extractor", response_model=ExtractorGenerationResponse)
async def generate_extractor_code(request: ExtractorGenerationRequest):
"""
Generate Python extractor code using Claude Code CLI.
Uses --print mode for single-shot generation (no session state).
Focused system prompt for fast, accurate results.
Args:
request: ExtractorGenerationRequest with prompt and context
Returns:
ExtractorGenerationResponse with generated code and detected outputs
"""
# Build focused system prompt for extractor generation
system_prompt = """You are generating a Python custom extractor function for Atomizer FEA optimization.
IMPORTANT: Choose the appropriate function signature based on what data is needed:
## Option 1: FEA Results (OP2) - Use for stresses, displacements, frequencies, forces
```python
def extract(op2_path: str, fem_path: str, params: dict, subcase_id: int = 1) -> dict:
from pyNastran.op2.op2 import OP2
op2 = OP2()
op2.read_op2(op2_path)
# Access: op2.displacements[subcase_id], op2.cquad4_stress[subcase_id], etc.
return {"max_stress": value}
```
## Option 2: Expression/Computed Values (no FEA needed) - Use for dimensions, volumes, derived values
```python
def extract(trial_dir: str, config: dict, context: dict) -> dict:
import json
from pathlib import Path
# Read mass properties (if available from model introspection)
mass_file = Path(trial_dir) / "mass_properties.json"
if mass_file.exists():
with open(mass_file) as f:
props = json.load(f)
mass = props.get("mass_kg", 0)
# Or use config values directly (e.g., expression values)
length_mm = config.get("length_expression", 100)
# context has results from other extractors
other_value = context.get("other_extractor_output", 0)
return {"computed_value": length_mm * 2}
```
Available imports: pyNastran.op2.op2.OP2, numpy, pathlib.Path, json
Common OP2 patterns:
- Displacement: op2.displacements[subcase_id].data[0, :, 1:4] (x,y,z)
- Stress: op2.cquad4_stress[subcase_id] or op2.ctria3_stress[subcase_id]
- Eigenvalues: op2.eigenvalues[subcase_id]
- Mass: op2.grid_point_weight (if available)
Return ONLY the complete Python code wrapped in ```python ... ```. No explanations."""
# Build user prompt with context
user_prompt = f"Generate a custom extractor that: {request.prompt}"
if request.existing_code:
user_prompt += (
f"\n\nImprove or modify this existing code:\n```python\n{request.existing_code}\n```"
)
if request.output_names:
user_prompt += (
f"\n\nThe function should output these keys: {', '.join(request.output_names)}"
)
try:
# Call Claude CLI with focused prompt (single-shot, no session)
process = await asyncio.create_subprocess_exec(
"claude",
"--print",
"--system-prompt",
system_prompt,
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=str(ATOMIZER_ROOT),
env={
**os.environ,
"ATOMIZER_ROOT": str(ATOMIZER_ROOT),
},
)
# Send prompt and wait for response (60 second timeout)
stdout, stderr = await asyncio.wait_for(
process.communicate(user_prompt.encode("utf-8")), timeout=60.0
)
if process.returncode != 0:
error_text = stderr.decode("utf-8", errors="replace")
raise HTTPException(status_code=500, detail=f"Claude CLI error: {error_text[:500]}")
output = stdout.decode("utf-8", errors="replace")
# Extract Python code from markdown code block
code_match = re.search(r"```python\s*(.*?)\s*```", output, re.DOTALL)
if code_match:
code = code_match.group(1).strip()
else:
# Try to find def extract( directly (Claude might not use code blocks)
if "def extract(" in output:
# Extract from def extract to end of function
code = output.strip()
else:
raise HTTPException(
status_code=500,
detail="Failed to parse generated code - no Python code block found",
)
# Detect output names from return statement
detected_outputs: List[str] = []
return_match = re.search(r"return\s*\{([^}]+)\}", code)
if return_match:
# Parse dict keys like 'max_stress': ... or "mass": ...
key_matches = re.findall(r"['\"]([^'\"]+)['\"]:", return_match.group(1))
detected_outputs = key_matches
# Use detected outputs or fall back to requested ones
final_outputs = detected_outputs if detected_outputs else request.output_names
# Extract any explanation text before the code block
explanation = None
parts = output.split("```python")
if len(parts) > 1 and parts[0].strip():
explanation = parts[0].strip()[:300] # First 300 chars max
return ExtractorGenerationResponse(
code=code, outputs=final_outputs, explanation=explanation
)
except asyncio.TimeoutError:
raise HTTPException(
status_code=504, detail="Code generation timed out (60s limit). Try a simpler prompt."
)
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Generation failed: {str(e)}")
class DependencyCheckResponse(BaseModel):
"""Response model for dependency check"""
imports: List[str]
available: List[str]
missing: List[str]
warnings: List[str]
# Known available packages in the atomizer environment
KNOWN_PACKAGES = {
"pyNastran": ["pyNastran", "pyNastran.op2", "pyNastran.bdf"],
"numpy": ["numpy", "np"],
"scipy": ["scipy"],
"pandas": ["pandas", "pd"],
"pathlib": ["pathlib", "Path"],
"json": ["json"],
"os": ["os"],
"re": ["re"],
"math": ["math"],
"typing": ["typing"],
"collections": ["collections"],
"itertools": ["itertools"],
"functools": ["functools"],
}
def extract_imports(code: str) -> List[str]:
"""Extract import statements from Python code using AST"""
import ast
imports = []
try:
tree = ast.parse(code)
for node in ast.walk(tree):
if isinstance(node, ast.Import):
for alias in node.names:
imports.append(alias.name.split(".")[0])
elif isinstance(node, ast.ImportFrom):
if node.module:
imports.append(node.module.split(".")[0])
except SyntaxError:
# Fall back to regex if AST fails
import re
import_pattern = r"^(?:from\s+(\w+)|import\s+(\w+))"
for line in code.split("\n"):
match = re.match(import_pattern, line.strip())
if match:
imports.append(match.group(1) or match.group(2))
return list(set(imports))
@router.post("/check-dependencies", response_model=DependencyCheckResponse)
async def check_code_dependencies(request: CodeValidationRequest):
"""
Check which imports in the code are available in the atomizer environment.
Args:
request: CodeValidationRequest with code to check
Returns:
DependencyCheckResponse with available and missing packages
"""
imports = extract_imports(request.code)
available = []
missing = []
warnings = []
# Known available in atomizer
known_available = set()
for pkg, aliases in KNOWN_PACKAGES.items():
known_available.update([a.split(".")[0] for a in aliases])
for imp in imports:
if imp in known_available:
available.append(imp)
else:
# Check if it's a standard library module
try:
import importlib.util
spec = importlib.util.find_spec(imp)
if spec is not None:
available.append(imp)
else:
missing.append(imp)
except (ImportError, ModuleNotFoundError):
missing.append(imp)
# Add warnings for potentially problematic imports
if "matplotlib" in imports:
warnings.append("matplotlib may cause issues in headless NX environment")
if "tensorflow" in imports or "torch" in imports:
warnings.append("Deep learning frameworks may cause memory issues during optimization")
return DependencyCheckResponse(
imports=imports, available=available, missing=missing, warnings=warnings
)
@router.post("/validate-extractor", response_model=CodeValidationResponse)
async def validate_extractor_code(request: CodeValidationRequest):
"""
Validate Python extractor code syntax and structure.
Args:
request: CodeValidationRequest with code to validate
Returns:
CodeValidationResponse with valid flag and optional error message
"""
import ast
try:
tree = ast.parse(request.code)
# Check for extract function
has_extract = False
extract_returns_dict = False
for node in ast.walk(tree):
if isinstance(node, ast.FunctionDef) and node.name == "extract":
has_extract = True
# Check if it has a return statement
for child in ast.walk(node):
if isinstance(child, ast.Return) and child.value:
if isinstance(child.value, ast.Dict):
extract_returns_dict = True
elif isinstance(child.value, ast.Name):
# Variable return, could be a dict
extract_returns_dict = True
if not has_extract:
return CodeValidationResponse(
valid=False, error="Code must define a function named 'extract'"
)
if not extract_returns_dict:
return CodeValidationResponse(
valid=False, error="extract() function should return a dict"
)
return CodeValidationResponse(valid=True, error=None)
except SyntaxError as e:
return CodeValidationResponse(valid=False, error=f"Line {e.lineno}: {e.msg}")
except Exception as e:
return CodeValidationResponse(valid=False, error=str(e))
# ==================== Live Preview / Test Execution ====================
class TestExtractorRequest(BaseModel):
"""Request model for testing extractor code"""
code: str
study_id: Optional[str] = None
subcase_id: int = 1
class TestExtractorResponse(BaseModel):
"""Response model for extractor test"""
success: bool
outputs: Optional[Dict[str, float]] = None
error: Optional[str] = None
execution_time_ms: Optional[float] = None
@router.post("/test-extractor", response_model=TestExtractorResponse)
async def test_extractor_code(request: TestExtractorRequest):
"""
Test extractor code against a sample or study OP2 file.
This executes the code in a sandboxed environment and returns the results.
If a study_id is provided, it uses the most recent trial's OP2 file.
Otherwise, it uses mock data for testing.
Args:
request: TestExtractorRequest with code and optional study context
Returns:
TestExtractorResponse with extracted outputs or error
"""
import time
import tempfile
import traceback
start_time = time.time()
# Find OP2 file to test against
op2_path = None
fem_path = None
if request.study_id:
# Look for the most recent trial's OP2 file
from pathlib import Path
study_path = ATOMIZER_ROOT / "studies" / request.study_id
if not study_path.exists():
# Try nested path
for parent in (ATOMIZER_ROOT / "studies").iterdir():
if parent.is_dir():
nested = parent / request.study_id
if nested.exists():
study_path = nested
break
if study_path.exists():
# Look in 2_iterations for trial folders
iterations_dir = study_path / "2_iterations"
if iterations_dir.exists():
# Find the latest trial folder with an OP2 file
trial_folders = sorted(
[
d
for d in iterations_dir.iterdir()
if d.is_dir() and d.name.startswith("trial_")
],
reverse=True,
)
for trial_dir in trial_folders:
op2_files = list(trial_dir.glob("*.op2"))
fem_files = list(trial_dir.glob("*.fem"))
if op2_files:
op2_path = str(op2_files[0])
if fem_files:
fem_path = str(fem_files[0])
break
if not op2_path:
# No OP2 file available - run in "dry run" mode with mock
return TestExtractorResponse(
success=False,
error="No OP2 file available for testing. Run at least one optimization trial first.",
execution_time_ms=(time.time() - start_time) * 1000,
)
# Execute the code in a sandboxed way
try:
# Create a temporary module
with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as f:
f.write(request.code)
temp_file = f.name
try:
# Import the module
import importlib.util
spec = importlib.util.spec_from_file_location("temp_extractor", temp_file)
if spec is None or spec.loader is None:
return TestExtractorResponse(
success=False,
error="Failed to load code as module",
execution_time_ms=(time.time() - start_time) * 1000,
)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
# Check for extract function
if not hasattr(module, "extract"):
return TestExtractorResponse(
success=False,
error="Code does not define an 'extract' function",
execution_time_ms=(time.time() - start_time) * 1000,
)
# Call the extract function
extract_fn = module.extract
result = extract_fn(
op2_path=op2_path,
fem_path=fem_path or "",
params={}, # Empty params for testing
subcase_id=request.subcase_id,
)
if not isinstance(result, dict):
return TestExtractorResponse(
success=False,
error=f"extract() returned {type(result).__name__}, expected dict",
execution_time_ms=(time.time() - start_time) * 1000,
)
# Convert all values to float for JSON serialization
outputs = {}
for k, v in result.items():
try:
outputs[k] = float(v)
except (TypeError, ValueError):
outputs[k] = 0.0 # Can't convert, use 0
return TestExtractorResponse(
success=True, outputs=outputs, execution_time_ms=(time.time() - start_time) * 1000
)
finally:
# Clean up temp file
import os
try:
os.unlink(temp_file)
except:
pass
except Exception as e:
error_msg = f"{type(e).__name__}: {str(e)}"
tb = traceback.format_exc()
# Include relevant part of traceback
if "temp_extractor.py" in tb:
lines = tb.split("\n")
relevant = [l for l in lines if "temp_extractor.py" in l or "line" in l.lower()]
if relevant:
error_msg += f"\n{relevant[-1]}"
return TestExtractorResponse(
success=False, error=error_msg, execution_time_ms=(time.time() - start_time) * 1000
)
# ==================== Streaming Generation ====================
from fastapi.responses import StreamingResponse
@router.post("/generate-extractor/stream")
async def generate_extractor_code_stream(request: ExtractorGenerationRequest):
"""
Stream Python extractor code generation using Claude Code CLI.
Uses Server-Sent Events (SSE) to stream tokens as they arrive.
Event types:
- data: {"type": "token", "content": "..."} - Partial code token
- data: {"type": "done", "code": "...", "outputs": [...]} - Final result
- data: {"type": "error", "message": "..."} - Error occurred
Args:
request: ExtractorGenerationRequest with prompt and context
Returns:
StreamingResponse with text/event-stream content type
"""
# Build focused system prompt for extractor generation
system_prompt = """You are generating a Python custom extractor function for Atomizer FEA optimization.
The function MUST:
1. Have signature: def extract(op2_path: str, fem_path: str, params: dict, subcase_id: int = 1) -> dict
2. Return a dict with extracted values (e.g., {"max_stress": 150.5, "mass": 2.3})
3. Use pyNastran.op2.op2.OP2 for reading OP2 results
4. Handle missing data gracefully with try/except blocks
Available imports (already available, just use them):
- from pyNastran.op2.op2 import OP2
- import numpy as np
- from pathlib import Path
Common patterns:
- Displacement: op2.displacements[subcase_id].data[0, :, 1:4] (x,y,z components)
- Stress: op2.cquad4_stress[subcase_id] or op2.ctria3_stress[subcase_id]
- Eigenvalues: op2.eigenvalues[subcase_id]
Return ONLY the complete Python code wrapped in ```python ... ```. No explanations outside the code block."""
# Build user prompt with context
user_prompt = f"Generate a custom extractor that: {request.prompt}"
if request.existing_code:
user_prompt += (
f"\n\nImprove or modify this existing code:\n```python\n{request.existing_code}\n```"
)
if request.output_names:
user_prompt += (
f"\n\nThe function should output these keys: {', '.join(request.output_names)}"
)
async def generate():
full_output = ""
try:
# Call Claude CLI with streaming output
process = await asyncio.create_subprocess_exec(
"claude",
"--print",
"--system-prompt",
system_prompt,
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=str(ATOMIZER_ROOT),
env={
**os.environ,
"ATOMIZER_ROOT": str(ATOMIZER_ROOT),
},
)
# Write prompt to stdin and close
process.stdin.write(user_prompt.encode("utf-8"))
await process.stdin.drain()
process.stdin.close()
# Stream stdout chunks as they arrive
while True:
chunk = await asyncio.wait_for(
process.stdout.read(256), # Read in small chunks for responsiveness
timeout=60.0,
)
if not chunk:
break
decoded = chunk.decode("utf-8", errors="replace")
full_output += decoded
# Send token event
yield f"data: {json.dumps({'type': 'token', 'content': decoded})}\n\n"
# Wait for process to complete
await process.wait()
# Check for errors
if process.returncode != 0:
stderr = await process.stderr.read()
error_text = stderr.decode("utf-8", errors="replace")
yield f"data: {json.dumps({'type': 'error', 'message': f'Claude CLI error: {error_text[:500]}'})}\n\n"
return
# Parse the complete output to extract code
code_match = re.search(r"```python\s*(.*?)\s*```", full_output, re.DOTALL)
if code_match:
code = code_match.group(1).strip()
elif "def extract(" in full_output:
code = full_output.strip()
else:
yield f"data: {json.dumps({'type': 'error', 'message': 'Failed to parse generated code'})}\n\n"
return
# Detect output names
detected_outputs: List[str] = []
return_match = re.search(r"return\s*\{([^}]+)\}", code)
if return_match:
key_matches = re.findall(r"['\"]([^'\"]+)['\"]:", return_match.group(1))
detected_outputs = key_matches
final_outputs = detected_outputs if detected_outputs else request.output_names
# Send completion event with parsed code
yield f"data: {json.dumps({'type': 'done', 'code': code, 'outputs': final_outputs})}\n\n"
except asyncio.TimeoutError:
yield f"data: {json.dumps({'type': 'error', 'message': 'Generation timed out (60s limit)'})}\n\n"
except Exception as e:
yield f"data: {json.dumps({'type': 'error', 'message': str(e)})}\n\n"
return StreamingResponse(
generate(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no", # Disable nginx buffering
},
)
# ==================== Session Management ====================
# Store active WebSocket connections
_active_connections: Dict[str, WebSocket] = {}
@router.post("/sessions")
async def create_claude_code_session(study_id: Optional[str] = None):
"""
Create a new Claude Code session.
Args:
study_id: Optional study to provide context
Returns:
Session info including session_id
"""
try:
manager = get_claude_code_manager()
session = manager.create_session(study_id)
return {
"session_id": session.session_id,
"study_id": session.study_id,
"working_dir": str(session.working_dir),
"message": "Claude Code session created. Connect via WebSocket to chat.",
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/sessions/{session_id}")
async def get_claude_code_session(session_id: str):
"""Get session info"""
manager = get_claude_code_manager()
session = manager.get_session(session_id)
if not session:
raise HTTPException(status_code=404, detail="Session not found")
return {
"session_id": session.session_id,
"study_id": session.study_id,
"working_dir": str(session.working_dir),
"has_canvas_state": session.canvas_state is not None,
"conversation_length": len(session.conversation_history),
}
@router.delete("/sessions/{session_id}")
async def delete_claude_code_session(session_id: str):
"""Delete a session"""
manager = get_claude_code_manager()
manager.remove_session(session_id)
return {"message": "Session deleted"}
@router.websocket("/ws")
async def claude_code_websocket(websocket: WebSocket):
"""
WebSocket for full Claude Code CLI access (no session required).
This is a simplified endpoint that creates a session per connection.
Message formats (client -> server):
{"type": "init", "study_id": "optional_study_name"}
{"type": "message", "content": "user message"}
{"type": "set_canvas", "canvas_state": {...}}
{"type": "ping"}
Message formats (server -> client):
{"type": "initialized", "session_id": "...", "study_id": "..."}
{"type": "text", "content": "..."}
{"type": "done"}
{"type": "refresh_canvas", "study_id": "...", "reason": "..."}
{"type": "error", "content": "..."}
{"type": "pong"}
"""
print("[ClaudeCode WS] Connection attempt received")
await websocket.accept()
print("[ClaudeCode WS] WebSocket accepted")
manager = get_claude_code_manager()
session: Optional[ClaudeCodeSession] = None
try:
while True:
data = await websocket.receive_json()
msg_type = data.get("type")
if msg_type == "init":
# Create or reinitialize session
study_id = data.get("study_id")
session = manager.create_session(study_id)
_active_connections[session.session_id] = websocket
await websocket.send_json(
{
"type": "initialized",
"session_id": session.session_id,
"study_id": session.study_id,
"working_dir": str(session.working_dir),
}
)
elif msg_type == "message":
if not session:
# Auto-create session if not initialized
session = manager.create_session()
_active_connections[session.session_id] = websocket
content = data.get("content", "")
if not content:
continue
# Update canvas state if provided with message
if data.get("canvas_state"):
session.set_canvas_state(data["canvas_state"])
# Stream response from Claude Code CLI
async for chunk in session.send_message(content):
await websocket.send_json(chunk)
elif msg_type == "set_canvas":
if session:
session.set_canvas_state(data.get("canvas_state", {}))
await websocket.send_json(
{
"type": "canvas_updated",
}
)
elif msg_type == "ping":
await websocket.send_json({"type": "pong"})
except WebSocketDisconnect:
# Clean up on disconnect
if session:
_active_connections.pop(session.session_id, None)
# Keep session in manager for potential reconnect
except Exception as e:
try:
await websocket.send_json(
{
"type": "error",
"content": str(e),
}
)
except:
pass
if session:
_active_connections.pop(session.session_id, None)
@router.websocket("/ws/{study_id:path}")
async def claude_code_websocket_with_study(websocket: WebSocket, study_id: str):
"""
WebSocket for Claude Code CLI with study context.
Same as /ws but automatically initializes with the given study.
Message formats (client -> server):
{"type": "message", "content": "user message"}
{"type": "set_canvas", "canvas_state": {...}}
{"type": "ping"}
Message formats (server -> client):
{"type": "initialized", "session_id": "...", "study_id": "..."}
{"type": "text", "content": "..."}
{"type": "done"}
{"type": "refresh_canvas", "study_id": "...", "reason": "..."}
{"type": "error", "content": "..."}
{"type": "pong"}
"""
print(f"[ClaudeCode WS] Connection attempt received for study: {study_id}")
await websocket.accept()
print(f"[ClaudeCode WS] WebSocket accepted for study: {study_id}")
manager = get_claude_code_manager()
session = manager.create_session(study_id)
_active_connections[session.session_id] = websocket
# Send initialization message
await websocket.send_json(
{
"type": "initialized",
"session_id": session.session_id,
"study_id": session.study_id,
"working_dir": str(session.working_dir),
}
)
try:
while True:
data = await websocket.receive_json()
msg_type = data.get("type")
if msg_type == "message":
content = data.get("content", "")
if not content:
continue
# Update canvas state if provided with message
if data.get("canvas_state"):
session.set_canvas_state(data["canvas_state"])
# Stream response from Claude Code CLI
async for chunk in session.send_message(content):
await websocket.send_json(chunk)
elif msg_type == "set_canvas":
session.set_canvas_state(data.get("canvas_state", {}))
await websocket.send_json(
{
"type": "canvas_updated",
}
)
elif msg_type == "ping":
await websocket.send_json({"type": "pong"})
except WebSocketDisconnect:
_active_connections.pop(session.session_id, None)
except Exception as e:
try:
await websocket.send_json(
{
"type": "error",
"content": str(e),
}
)
except:
pass
_active_connections.pop(session.session_id, None)

View File

@@ -19,23 +19,26 @@ router = APIRouter()
class ImportRequest(BaseModel): class ImportRequest(BaseModel):
"""Request to import a file from a Windows path""" """Request to import a file from a Windows path"""
source_path: str source_path: str
study_name: str study_name: str
copy_related: bool = True copy_related: bool = True
# Path to studies root (go up 5 levels from this file) # Path to studies root (go up 5 levels from this file)
_file_path = os.path.abspath(__file__) _file_path = os.path.abspath(__file__)
ATOMIZER_ROOT = Path(os.path.normpath(os.path.dirname(os.path.dirname(os.path.dirname( ATOMIZER_ROOT = Path(
os.path.dirname(os.path.dirname(_file_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" STUDIES_ROOT = ATOMIZER_ROOT / "studies"
@router.get("/list") @router.get("/list")
async def list_files( async def list_files(path: str = "", types: str = ".sim,.prt,.fem,.afem"):
path: str = "",
types: str = ".sim,.prt,.fem,.afem"
):
""" """
List files in a directory, filtered by type. List files in a directory, filtered by type.
@@ -46,7 +49,7 @@ async def list_files(
Returns: Returns:
List of files and directories with their paths List of files and directories with their paths
""" """
allowed_types = [t.strip().lower() for t in types.split(',') if t.strip()] allowed_types = [t.strip().lower() for t in types.split(",") if t.strip()]
base_path = STUDIES_ROOT / path if path else STUDIES_ROOT base_path = STUDIES_ROOT / path if path else STUDIES_ROOT
@@ -58,26 +61,30 @@ async def list_files(
try: try:
for entry in sorted(base_path.iterdir(), key=lambda x: (not x.is_dir(), x.name.lower())): for entry in sorted(base_path.iterdir(), key=lambda x: (not x.is_dir(), x.name.lower())):
# Skip hidden files and directories # Skip hidden files and directories
if entry.name.startswith('.'): if entry.name.startswith("."):
continue continue
if entry.is_dir(): if entry.is_dir():
# Include directories # Include directories
files.append({ files.append(
"name": entry.name, {
"path": str(entry.relative_to(STUDIES_ROOT)).replace("\\", "/"), "name": entry.name,
"isDirectory": True, "path": str(entry.relative_to(STUDIES_ROOT)).replace("\\", "/"),
}) "isDirectory": True,
}
)
else: else:
# Include files matching type filter # Include files matching type filter
suffix = entry.suffix.lower() suffix = entry.suffix.lower()
if suffix in allowed_types: if suffix in allowed_types:
files.append({ files.append(
"name": entry.name, {
"path": str(entry.relative_to(STUDIES_ROOT)).replace("\\", "/"), "name": entry.name,
"isDirectory": False, "path": str(entry.relative_to(STUDIES_ROOT)).replace("\\", "/"),
"size": entry.stat().st_size, "isDirectory": False,
}) "size": entry.stat().st_size,
}
)
except PermissionError: except PermissionError:
return {"files": [], "path": path, "error": "Permission denied"} return {"files": [], "path": path, "error": "Permission denied"}
except Exception as e: except Exception as e:
@@ -87,11 +94,7 @@ async def list_files(
@router.get("/search") @router.get("/search")
async def search_files( async def search_files(query: str, types: str = ".sim,.prt,.fem,.afem", max_results: int = 50):
query: str,
types: str = ".sim,.prt,.fem,.afem",
max_results: int = 50
):
""" """
Search for files by name pattern. Search for files by name pattern.
@@ -103,7 +106,7 @@ async def search_files(
Returns: Returns:
List of matching files with their paths List of matching files with their paths
""" """
allowed_types = [t.strip().lower() for t in types.split(',') if t.strip()] allowed_types = [t.strip().lower() for t in types.split(",") if t.strip()]
query_lower = query.lower() query_lower = query.lower()
files = [] files = []
@@ -118,19 +121,21 @@ async def search_files(
if len(files) >= max_results: if len(files) >= max_results:
return return
if entry.name.startswith('.'): if entry.name.startswith("."):
continue continue
if entry.is_dir(): if entry.is_dir():
search_recursive(entry, depth + 1) search_recursive(entry, depth + 1)
elif entry.suffix.lower() in allowed_types: elif entry.suffix.lower() in allowed_types:
if query_lower in entry.name.lower(): if query_lower in entry.name.lower():
files.append({ files.append(
"name": entry.name, {
"path": str(entry.relative_to(STUDIES_ROOT)).replace("\\", "/"), "name": entry.name,
"isDirectory": False, "path": str(entry.relative_to(STUDIES_ROOT)).replace("\\", "/"),
"size": entry.stat().st_size, "isDirectory": False,
}) "size": entry.stat().st_size,
}
)
except (PermissionError, OSError): except (PermissionError, OSError):
pass pass
@@ -190,18 +195,18 @@ def find_related_nx_files(source_path: Path) -> List[Path]:
# Extract base name by removing _sim1, _fem1, _i suffixes # Extract base name by removing _sim1, _fem1, _i suffixes
base_name = stem base_name = stem
base_name = re.sub(r'_sim\d*$', '', base_name) base_name = re.sub(r"_sim\d*$", "", base_name)
base_name = re.sub(r'_fem\d*$', '', base_name) base_name = re.sub(r"_fem\d*$", "", base_name)
base_name = re.sub(r'_i$', '', base_name) base_name = re.sub(r"_i$", "", base_name)
# Define patterns to search for # Define patterns to search for
patterns = [ patterns = [
f"{base_name}.prt", # Main geometry f"{base_name}.prt", # Main geometry
f"{base_name}_i.prt", # Idealized part f"{base_name}_i.prt", # Idealized part
f"{base_name}_fem*.fem", # FEM files f"{base_name}_fem*.fem", # FEM files
f"{base_name}_fem*_i.prt", # Idealized FEM parts f"{base_name}_fem*_i.prt", # Idealized FEM parts
f"{base_name}_sim*.sim", # Simulation files f"{base_name}_sim*.sim", # Simulation files
f"{base_name}.afem", # Assembled FEM f"{base_name}.afem", # Assembled FEM
] ]
# Search for matching files # Search for matching files
@@ -244,7 +249,7 @@ async def validate_external_path(path: str):
} }
# Check if it's a valid NX file type # Check if it's a valid NX file type
valid_extensions = ['.prt', '.sim', '.fem', '.afem'] valid_extensions = [".prt", ".sim", ".fem", ".afem"]
if source_path.suffix.lower() not in valid_extensions: if source_path.suffix.lower() not in valid_extensions:
return { return {
"valid": False, "valid": False,
@@ -297,7 +302,9 @@ async def import_from_path(request: ImportRequest):
source_path = Path(request.source_path) source_path = Path(request.source_path)
if not source_path.exists(): if not source_path.exists():
raise HTTPException(status_code=404, detail=f"Source file not found: {request.source_path}") raise HTTPException(
status_code=404, detail=f"Source file not found: {request.source_path}"
)
# Create study folder structure # Create study folder structure
study_dir = STUDIES_ROOT / request.study_name study_dir = STUDIES_ROOT / request.study_name
@@ -316,22 +323,26 @@ async def import_from_path(request: ImportRequest):
# Skip if already exists (avoid overwrite) # Skip if already exists (avoid overwrite)
if dest_file.exists(): if dest_file.exists():
imported.append({ imported.append(
"name": src_file.name, {
"status": "skipped", "name": src_file.name,
"reason": "Already exists", "status": "skipped",
"path": str(dest_file.relative_to(STUDIES_ROOT)).replace("\\", "/"), "reason": "Already exists",
}) "path": str(dest_file.relative_to(STUDIES_ROOT)).replace("\\", "/"),
}
)
continue continue
# Copy file # Copy file
shutil.copy2(src_file, dest_file) shutil.copy2(src_file, dest_file)
imported.append({ imported.append(
"name": src_file.name, {
"status": "imported", "name": src_file.name,
"path": str(dest_file.relative_to(STUDIES_ROOT)).replace("\\", "/"), "status": "imported",
"size": dest_file.stat().st_size, "path": str(dest_file.relative_to(STUDIES_ROOT)).replace("\\", "/"),
}) "size": dest_file.stat().st_size,
}
)
return { return {
"success": True, "success": True,
@@ -371,27 +382,31 @@ async def upload_files(
for file in files: for file in files:
# Validate file type # Validate file type
suffix = Path(file.filename).suffix.lower() suffix = Path(file.filename).suffix.lower()
if suffix not in ['.prt', '.sim', '.fem', '.afem']: if suffix not in [".prt", ".sim", ".fem", ".afem"]:
uploaded.append({ uploaded.append(
"name": file.filename, {
"status": "rejected", "name": file.filename,
"reason": f"Invalid file type: {suffix}", "status": "rejected",
}) "reason": f"Invalid file type: {suffix}",
}
)
continue continue
dest_file = model_dir / file.filename dest_file = model_dir / file.filename
# Save file # Save file
content = await file.read() content = await file.read()
with open(dest_file, 'wb') as f: with open(dest_file, "wb") as f:
f.write(content) f.write(content)
uploaded.append({ uploaded.append(
"name": file.filename, {
"status": "uploaded", "name": file.filename,
"path": str(dest_file.relative_to(STUDIES_ROOT)).replace("\\", "/"), "status": "uploaded",
"size": len(content), "path": str(dest_file.relative_to(STUDIES_ROOT)).replace("\\", "/"),
}) "size": len(content),
}
)
return { return {
"success": True, "success": True,
@@ -402,3 +417,96 @@ async def upload_files(
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.get("/structure/{study_id:path}")
async def get_study_structure(study_id: str):
"""
Get the file structure tree for a study.
Args:
study_id: Study ID (can include path separators like M1_Mirror/m1_mirror_flatback)
Returns:
Hierarchical file tree with type information
"""
# Resolve study path
study_path = STUDIES_ROOT / study_id
if not study_path.exists():
raise HTTPException(status_code=404, detail=f"Study not found: {study_id}")
if not study_path.is_dir():
raise HTTPException(status_code=400, detail=f"Not a directory: {study_id}")
# File extensions to highlight as model files
model_extensions = {".prt", ".sim", ".fem", ".afem"}
result_extensions = {".op2", ".f06", ".dat", ".bdf", ".csv", ".json"}
def build_tree(directory: Path, depth: int = 0) -> List[dict]:
"""Recursively build file tree."""
if depth > 5: # Limit depth to prevent infinite recursion
return []
entries = []
try:
items = sorted(directory.iterdir(), key=lambda x: (not x.is_dir(), x.name.lower()))
for item in items:
# Skip hidden files/dirs and __pycache__
if item.name.startswith(".") or item.name == "__pycache__":
continue
# Skip very large directories (e.g., trial folders with many iterations)
if item.is_dir() and item.name.startswith("trial_"):
# Just count trials, don't recurse into each
entries.append(
{
"name": item.name,
"path": str(item.relative_to(STUDIES_ROOT)).replace("\\", "/"),
"type": "directory",
"children": [], # Empty children for trial folders
}
)
continue
if item.is_dir():
children = build_tree(item, depth + 1)
entries.append(
{
"name": item.name,
"path": str(item.relative_to(STUDIES_ROOT)).replace("\\", "/"),
"type": "directory",
"children": children,
}
)
else:
ext = item.suffix.lower()
entries.append(
{
"name": item.name,
"path": str(item.relative_to(STUDIES_ROOT)).replace("\\", "/"),
"type": "file",
"extension": ext,
"size": item.stat().st_size,
"isModelFile": ext in model_extensions,
"isResultFile": ext in result_extensions,
}
)
except PermissionError:
pass
except Exception as e:
print(f"Error reading directory {directory}: {e}")
return entries
# Build the tree starting from study root
files = build_tree(study_path)
return {
"study_id": study_id,
"path": str(study_path),
"files": files,
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,646 @@
"""
AtomizerSpec v2.0 API Endpoints
REST API for managing AtomizerSpec configurations.
All spec modifications flow through these endpoints.
Endpoints:
- GET /studies/{study_id}/spec - Get full spec
- PUT /studies/{study_id}/spec - Replace entire spec
- PATCH /studies/{study_id}/spec - Partial update
- POST /studies/{study_id}/spec/validate - Validate spec
- POST /studies/{study_id}/spec/nodes - Add node
- PATCH /studies/{study_id}/spec/nodes/{node_id} - Update node
- DELETE /studies/{study_id}/spec/nodes/{node_id} - Delete node
- POST /studies/{study_id}/spec/custom-functions - Add custom extractor
- WebSocket /studies/{study_id}/spec/sync - Real-time sync
"""
from fastapi import APIRouter, HTTPException, WebSocket, WebSocketDisconnect, Query
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
from pathlib import Path
from typing import Any, Dict, List, Optional, Union
import json
import sys
import asyncio
# Add project root to path
sys.path.append(str(Path(__file__).parent.parent.parent.parent.parent))
from api.services.spec_manager import (
SpecManager,
SpecManagerError,
SpecNotFoundError,
SpecConflictError,
get_spec_manager,
)
from optimization_engine.config.spec_models import (
AtomizerSpec,
ValidationReport,
)
from optimization_engine.config.spec_validator import SpecValidationError
router = APIRouter(prefix="/studies/{study_id:path}/spec", tags=["spec"])
# Base studies directory
STUDIES_DIR = Path(__file__).parent.parent.parent.parent.parent / "studies"
# ============================================================================
# Request/Response Models
# ============================================================================
class SpecPatchRequest(BaseModel):
"""Request for patching a spec field."""
path: str = Field(..., description="JSONPath to the field (e.g., 'objectives[0].weight')")
value: Any = Field(..., description="New value")
modified_by: str = Field(default="api", description="Who is making the change")
class NodeAddRequest(BaseModel):
"""Request for adding a node."""
type: str = Field(..., description="Node type: designVar, extractor, objective, constraint")
data: Dict[str, Any] = Field(..., description="Node data")
modified_by: str = Field(default="canvas", description="Who is making the change")
class NodeUpdateRequest(BaseModel):
"""Request for updating a node."""
updates: Dict[str, Any] = Field(..., description="Fields to update")
modified_by: str = Field(default="canvas", description="Who is making the change")
class CustomFunctionRequest(BaseModel):
"""Request for adding a custom extractor function."""
name: str = Field(..., description="Function name")
code: str = Field(..., description="Python source code")
outputs: List[str] = Field(..., description="Output names")
description: Optional[str] = Field(default=None, description="Human-readable description")
modified_by: str = Field(default="claude", description="Who is making the change")
class ExtractorValidationRequest(BaseModel):
"""Request for validating custom extractor code."""
function_name: str = Field(default="extract", description="Expected function name")
source: str = Field(..., description="Python source code to validate")
class SpecUpdateResponse(BaseModel):
"""Response for spec modification operations."""
success: bool
hash: str
modified: str
modified_by: str
class NodeAddResponse(BaseModel):
"""Response for node add operation."""
success: bool
node_id: str
message: str
class ValidationResponse(BaseModel):
"""Response for validation endpoint."""
valid: bool
errors: List[Dict[str, Any]]
warnings: List[Dict[str, Any]]
summary: Dict[str, int]
# ============================================================================
# Helper Functions
# ============================================================================
def resolve_study_path(study_id: str) -> Path:
"""Find study folder by scanning all topic directories.
Supports both formats:
- "study_name" - Will scan topic folders to find it
- "Topic/study_name" - Direct nested path (e.g., "M1_Mirror/m1_mirror_v1")
"""
# Handle nested paths (e.g., "M1_Mirror/m1_mirror_cost_reduction_lateral")
if "/" in study_id:
nested_path = STUDIES_DIR / study_id.replace("/", "\\") # Handle Windows paths
if nested_path.exists() and nested_path.is_dir():
return nested_path
# Also try with forward slashes (Path handles both)
nested_path = STUDIES_DIR / study_id
if nested_path.exists() and nested_path.is_dir():
return nested_path
# Direct path (flat structure)
direct_path = STUDIES_DIR / study_id
if direct_path.exists() and direct_path.is_dir():
return direct_path
# Scan topic folders (nested structure)
for topic_dir in STUDIES_DIR.iterdir():
if topic_dir.is_dir() and not topic_dir.name.startswith('.'):
study_dir = topic_dir / study_id
if study_dir.exists() and study_dir.is_dir():
return study_dir
raise HTTPException(status_code=404, detail=f"Study not found: {study_id}")
def get_manager(study_id: str) -> SpecManager:
"""Get SpecManager for a study."""
study_path = resolve_study_path(study_id)
return get_spec_manager(study_path)
# ============================================================================
# REST Endpoints
# ============================================================================
@router.get("", response_model=None)
async def get_spec(study_id: str):
"""
Get the full AtomizerSpec for a study.
Returns the complete spec JSON with all design variables, extractors,
objectives, constraints, and canvas state.
"""
manager = get_manager(study_id)
if not manager.exists():
raise HTTPException(
status_code=404,
detail=f"No AtomizerSpec found for study '{study_id}'. Use migration or create new spec."
)
try:
spec = manager.load()
return spec.model_dump(mode='json')
except SpecValidationError as e:
# Return spec even if invalid, but include validation info
raw = manager.load_raw()
return JSONResponse(
status_code=200,
content={
**raw,
"_validation_error": str(e)
}
)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/raw")
async def get_spec_raw(study_id: str):
"""
Get the raw spec JSON without validation.
Useful for debugging or when spec is invalid.
"""
manager = get_manager(study_id)
if not manager.exists():
raise HTTPException(status_code=404, detail=f"No spec found for study '{study_id}'")
try:
return manager.load_raw()
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/hash")
async def get_spec_hash(study_id: str):
"""Get the current spec hash for conflict detection."""
manager = get_manager(study_id)
if not manager.exists():
raise HTTPException(status_code=404, detail=f"No spec found for study '{study_id}'")
return {"hash": manager.get_hash()}
@router.put("", response_model=SpecUpdateResponse)
async def replace_spec(
study_id: str,
spec: Dict[str, Any],
modified_by: str = Query(default="api"),
expected_hash: Optional[str] = Query(default=None)
):
"""
Replace the entire spec.
Validates the new spec before saving. Optionally check for conflicts
using expected_hash parameter.
"""
manager = get_manager(study_id)
try:
new_hash = manager.save(spec, modified_by=modified_by, expected_hash=expected_hash)
reloaded = manager.load()
return SpecUpdateResponse(
success=True,
hash=new_hash,
modified=reloaded.meta.modified or "",
modified_by=modified_by
)
except SpecConflictError as e:
raise HTTPException(
status_code=409,
detail={
"message": str(e),
"current_hash": e.current_hash
}
)
except SpecValidationError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.patch("", response_model=SpecUpdateResponse)
async def patch_spec(study_id: str, request: SpecPatchRequest):
"""
Partial update to spec using JSONPath.
Example paths:
- "objectives[0].weight" - Update objective weight
- "design_variables[1].bounds.max" - Update DV bound
- "meta.description" - Update description
"""
manager = get_manager(study_id)
if not manager.exists():
raise HTTPException(status_code=404, detail=f"No spec found for study '{study_id}'")
try:
spec = manager.patch(request.path, request.value, modified_by=request.modified_by)
return SpecUpdateResponse(
success=True,
hash=manager.get_hash(),
modified=spec.meta.modified or "",
modified_by=request.modified_by
)
except SpecValidationError as e:
raise HTTPException(status_code=400, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/validate", response_model=ValidationResponse)
async def validate_spec(study_id: str):
"""
Validate the spec and return detailed report.
Returns errors, warnings, and summary of the spec contents.
"""
manager = get_manager(study_id)
if not manager.exists():
raise HTTPException(status_code=404, detail=f"No spec found for study '{study_id}'")
try:
report = manager.validate_and_report()
return ValidationResponse(
valid=report.valid,
errors=[e.model_dump() for e in report.errors],
warnings=[w.model_dump() for w in report.warnings],
summary=report.summary.model_dump()
)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
# ============================================================================
# Node CRUD Endpoints
# ============================================================================
@router.post("/nodes", response_model=NodeAddResponse)
async def add_node(study_id: str, request: NodeAddRequest):
"""
Add a new node to the spec.
Supported types: designVar, extractor, objective, constraint
"""
manager = get_manager(study_id)
if not manager.exists():
raise HTTPException(status_code=404, detail=f"No spec found for study '{study_id}'")
valid_types = ["designVar", "extractor", "objective", "constraint"]
if request.type not in valid_types:
raise HTTPException(
status_code=400,
detail=f"Invalid node type '{request.type}'. Valid: {valid_types}"
)
try:
node_id = manager.add_node(request.type, request.data, modified_by=request.modified_by)
return NodeAddResponse(
success=True,
node_id=node_id,
message=f"Added {request.type} node: {node_id}"
)
except SpecValidationError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.patch("/nodes/{node_id}")
async def update_node(study_id: str, node_id: str, request: NodeUpdateRequest):
"""Update an existing node's properties."""
manager = get_manager(study_id)
if not manager.exists():
raise HTTPException(status_code=404, detail=f"No spec found for study '{study_id}'")
try:
manager.update_node(node_id, request.updates, modified_by=request.modified_by)
return {"success": True, "message": f"Updated node {node_id}"}
except SpecManagerError as e:
raise HTTPException(status_code=404, detail=str(e))
except SpecValidationError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/nodes/{node_id}")
async def delete_node(
study_id: str,
node_id: str,
modified_by: str = Query(default="canvas")
):
"""
Delete a node and all edges referencing it.
Use with caution - this will also remove any objectives or constraints
that reference a deleted extractor.
"""
manager = get_manager(study_id)
if not manager.exists():
raise HTTPException(status_code=404, detail=f"No spec found for study '{study_id}'")
try:
manager.remove_node(node_id, modified_by=modified_by)
return {"success": True, "message": f"Removed node {node_id}"}
except SpecManagerError as e:
raise HTTPException(status_code=404, detail=str(e))
except SpecValidationError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
# ============================================================================
# Custom Function Endpoint
# ============================================================================
@router.post("/custom-functions", response_model=NodeAddResponse)
async def add_custom_function(study_id: str, request: CustomFunctionRequest):
"""
Add a custom Python function as an extractor.
The function will be available in the optimization workflow.
Claude can use this to add new physics extraction logic.
"""
manager = get_manager(study_id)
if not manager.exists():
raise HTTPException(status_code=404, detail=f"No spec found for study '{study_id}'")
try:
extractor_id = manager.add_custom_function(
name=request.name,
code=request.code,
outputs=request.outputs,
description=request.description,
modified_by=request.modified_by
)
return NodeAddResponse(
success=True,
node_id=extractor_id,
message=f"Added custom extractor: {request.name}"
)
except SpecValidationError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
# Separate router for non-study-specific endpoints
validate_router = APIRouter(prefix="/spec", tags=["spec"])
@validate_router.post("/validate-extractor")
async def validate_custom_extractor(request: ExtractorValidationRequest):
"""
Validate custom extractor Python code.
Checks syntax, security patterns, and function signature.
Does not require a study - can be used before adding to spec.
"""
try:
from optimization_engine.extractors.custom_extractor_loader import (
validate_extractor_code,
ExtractorSecurityError,
)
try:
is_valid, errors = validate_extractor_code(request.source, request.function_name)
return {
"valid": is_valid,
"errors": errors
}
except ExtractorSecurityError as e:
return {
"valid": False,
"errors": [str(e)]
}
except ImportError as e:
raise HTTPException(
status_code=500,
detail=f"Custom extractor loader not available: {e}"
)
# ============================================================================
# Edge Endpoints
# ============================================================================
@router.post("/edges")
async def add_edge(
study_id: str,
source: str = Query(..., description="Source node ID"),
target: str = Query(..., description="Target node ID"),
modified_by: str = Query(default="canvas")
):
"""Add a canvas edge between two nodes."""
manager = get_manager(study_id)
if not manager.exists():
raise HTTPException(status_code=404, detail=f"No spec found for study '{study_id}'")
try:
manager.add_edge(source, target, modified_by=modified_by)
return {"success": True, "message": f"Added edge {source} -> {target}"}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/edges")
async def delete_edge(
study_id: str,
source: str = Query(..., description="Source node ID"),
target: str = Query(..., description="Target node ID"),
modified_by: str = Query(default="canvas")
):
"""Remove a canvas edge."""
manager = get_manager(study_id)
if not manager.exists():
raise HTTPException(status_code=404, detail=f"No spec found for study '{study_id}'")
try:
manager.remove_edge(source, target, modified_by=modified_by)
return {"success": True, "message": f"Removed edge {source} -> {target}"}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
# ============================================================================
# WebSocket Sync Endpoint
# ============================================================================
class WebSocketSubscriber:
"""WebSocket subscriber adapter."""
def __init__(self, websocket: WebSocket):
self.websocket = websocket
async def send_json(self, data: Dict[str, Any]) -> None:
await self.websocket.send_json(data)
@router.websocket("/sync")
async def websocket_sync(websocket: WebSocket, study_id: str):
"""
WebSocket endpoint for real-time spec sync.
Clients receive notifications when spec changes:
- spec_updated: Spec was modified
- node_added: New node added
- node_removed: Node removed
- validation_error: Validation failed
"""
await websocket.accept()
manager = get_manager(study_id)
subscriber = WebSocketSubscriber(websocket)
# Subscribe to updates
manager.subscribe(subscriber)
try:
# Send initial connection ack
await websocket.send_json({
"type": "connection_ack",
"study_id": study_id,
"hash": manager.get_hash() if manager.exists() else None,
"message": "Connected to spec sync"
})
# Keep connection alive and handle client messages
while True:
try:
data = await asyncio.wait_for(
websocket.receive_json(),
timeout=30.0 # Heartbeat interval
)
# Handle client messages
msg_type = data.get("type")
if msg_type == "ping":
await websocket.send_json({"type": "pong"})
elif msg_type == "patch_node":
# Client requests node update
try:
manager.update_node(
data["node_id"],
data.get("data", {}),
modified_by=data.get("modified_by", "canvas")
)
except Exception as e:
await websocket.send_json({
"type": "error",
"message": str(e)
})
elif msg_type == "update_position":
# Client updates node position
try:
manager.update_node_position(
data["node_id"],
data["position"],
modified_by=data.get("modified_by", "canvas")
)
except Exception as e:
await websocket.send_json({
"type": "error",
"message": str(e)
})
except asyncio.TimeoutError:
# Send heartbeat
await websocket.send_json({"type": "heartbeat"})
except WebSocketDisconnect:
pass
finally:
manager.unsubscribe(subscriber)
# ============================================================================
# Create/Initialize Spec
# ============================================================================
@router.post("/create")
async def create_spec(
study_id: str,
spec: Dict[str, Any],
modified_by: str = Query(default="api")
):
"""
Create a new spec for a study.
Use this when migrating from old config or creating a new study.
Will fail if spec already exists (use PUT to replace).
"""
manager = get_manager(study_id)
if manager.exists():
raise HTTPException(
status_code=409,
detail=f"Spec already exists for '{study_id}'. Use PUT to replace."
)
try:
# Ensure meta fields are set
if "meta" not in spec:
spec["meta"] = {}
spec["meta"]["created_by"] = modified_by
new_hash = manager.save(spec, modified_by=modified_by)
return {
"success": True,
"hash": new_hash,
"message": f"Created spec for {study_id}"
}
except SpecValidationError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))

View File

@@ -3,5 +3,13 @@ Atomizer Dashboard Services
""" """
from .claude_agent import AtomizerClaudeAgent from .claude_agent import AtomizerClaudeAgent
from .spec_manager import SpecManager, SpecManagerError, SpecNotFoundError, SpecConflictError, get_spec_manager
__all__ = ['AtomizerClaudeAgent'] __all__ = [
'AtomizerClaudeAgent',
'SpecManager',
'SpecManagerError',
'SpecNotFoundError',
'SpecConflictError',
'get_spec_manager',
]

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,451 @@
"""
Claude Code CLI Session Manager
Spawns actual Claude Code CLI processes with full Atomizer access.
This gives dashboard users the same power as terminal users.
Unlike the MCP-based approach:
- Claude can actually edit files (not just return instructions)
- Claude can run Python scripts
- Claude can execute git commands
- Full Opus 4.5 capabilities
"""
import asyncio
import json
import os
import uuid
from pathlib import Path
from typing import AsyncGenerator, Dict, Optional, Any
# Atomizer paths
ATOMIZER_ROOT = Path(__file__).parent.parent.parent.parent.parent
STUDIES_DIR = ATOMIZER_ROOT / "studies"
class ClaudeCodeSession:
"""
Manages a Claude Code CLI session with full capabilities.
Unlike MCP tools, this spawns the actual claude CLI which has:
- Full file system access
- Full command execution
- Opus 4.5 model
- All Claude Code capabilities
"""
def __init__(self, session_id: str, study_id: Optional[str] = None):
self.session_id = session_id
self.study_id = study_id
self.canvas_state: Optional[Dict] = None
self.conversation_history: list = []
# Determine working directory
self.working_dir = ATOMIZER_ROOT
if study_id:
# Handle nested study paths like "M1_Mirror/m1_mirror_flatback_lateral"
study_path = STUDIES_DIR / study_id
if study_path.exists():
self.working_dir = study_path
else:
# Try finding it in subdirectories
for parent in STUDIES_DIR.iterdir():
if parent.is_dir():
nested_path = parent / study_id
if nested_path.exists():
self.working_dir = nested_path
break
def set_canvas_state(self, canvas_state: Dict):
"""Update canvas state from frontend"""
self.canvas_state = canvas_state
async def send_message(self, message: str) -> AsyncGenerator[Dict[str, Any], None]:
"""
Send message to Claude Code CLI and stream response.
Uses claude CLI with:
- --print for output
- --dangerously-skip-permissions for full access (controlled environment)
- Runs from Atomizer root to get CLAUDE.md context automatically
- Study-specific context injected into prompt
Yields:
Dict messages: {"type": "text", "content": "..."} or {"type": "done"}
"""
# Build comprehensive prompt with all context
full_prompt = self._build_full_prompt(message)
# Create MCP config file for the session
mcp_config_file = ATOMIZER_ROOT / f".claude-mcp-{self.session_id}.json"
mcp_config = {
"mcpServers": {
"atomizer-tools": {
"command": "npx",
"args": ["-y", "ts-node", str(ATOMIZER_ROOT / "atomizer-dashboard" / "mcp-server" / "src" / "index.ts")],
"cwd": str(ATOMIZER_ROOT / "atomizer-dashboard" / "mcp-server"),
"env": {
"ATOMIZER_ROOT": str(ATOMIZER_ROOT),
"STUDIES_DIR": str(STUDIES_DIR),
}
}
}
}
mcp_config_file.write_text(json.dumps(mcp_config, indent=2), encoding='utf-8')
try:
# Spawn claude CLI from ATOMIZER_ROOT so it picks up CLAUDE.md
# This gives it full Atomizer context automatically
# Note: prompt is passed via stdin for complex multi-line prompts
process = await asyncio.create_subprocess_exec(
"claude",
"--print",
"--dangerously-skip-permissions",
"--mcp-config", str(mcp_config_file),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
stdin=asyncio.subprocess.PIPE,
cwd=str(ATOMIZER_ROOT),
env={
**os.environ,
"ATOMIZER_STUDY": self.study_id or "",
"ATOMIZER_STUDY_PATH": str(self.working_dir),
"ATOMIZER_ROOT": str(ATOMIZER_ROOT),
}
)
# Write prompt to stdin
process.stdin.write(full_prompt.encode('utf-8'))
await process.stdin.drain()
process.stdin.close()
# Read and yield output as it comes
full_output = ""
# Stream stdout
while True:
chunk = await process.stdout.read(512)
if not chunk:
break
decoded = chunk.decode('utf-8', errors='replace')
full_output += decoded
yield {"type": "text", "content": decoded}
# Wait for process to complete
await process.wait()
# Check for errors
stderr = await process.stderr.read()
if stderr and process.returncode != 0:
error_text = stderr.decode('utf-8', errors='replace')
yield {"type": "error", "content": f"\n[Error]: {error_text}"}
# Update conversation history
self.conversation_history.append({"role": "user", "content": message})
self.conversation_history.append({"role": "assistant", "content": full_output})
# Signal completion
yield {"type": "done"}
# Check if any files were modified and signal canvas refresh
if self._output_indicates_file_changes(full_output):
yield {
"type": "refresh_canvas",
"study_id": self.study_id,
"reason": "Claude modified study files"
}
finally:
# Clean up temp files
if mcp_config_file.exists():
try:
mcp_config_file.unlink()
except:
pass
def _build_full_prompt(self, message: str) -> str:
"""Build comprehensive prompt with all context"""
parts = []
# Study context
study_context = self._build_study_context() if self.study_id else ""
if study_context:
parts.append("## Current Study Context")
parts.append(study_context)
# Canvas context
if self.canvas_state:
canvas_context = self._build_canvas_context()
if canvas_context:
parts.append("## Current Canvas State")
parts.append(canvas_context)
# Conversation history (last few exchanges)
if self.conversation_history:
parts.append("## Recent Conversation")
for msg in self.conversation_history[-6:]:
role = "User" if msg["role"] == "user" else "Assistant"
# Truncate long messages
content = msg["content"][:500] + "..." if len(msg["content"]) > 500 else msg["content"]
parts.append(f"**{role}:** {content}")
parts.append("")
# User's actual request
parts.append("## User Request")
parts.append(message)
parts.append("")
# Critical instruction
parts.append("## Important")
parts.append("You have FULL power to edit files in this environment. When asked to make changes:")
parts.append("1. Use the Edit or Write tools to ACTUALLY MODIFY the files")
parts.append("2. Show a brief summary of what you changed")
parts.append("3. Do not just describe changes - MAKE THEM")
parts.append("")
parts.append("After making changes to optimization_config.json, the dashboard canvas will auto-refresh.")
return "\n".join(parts)
def _build_study_context(self) -> str:
"""Build detailed context for the active study"""
if not self.study_id:
return ""
context_parts = [f"**Study ID:** `{self.study_id}`"]
context_parts.append(f"**Study Path:** `{self.working_dir}`")
context_parts.append("")
# Find and read optimization_config.json
config_path = self.working_dir / "1_setup" / "optimization_config.json"
if not config_path.exists():
config_path = self.working_dir / "optimization_config.json"
if config_path.exists():
try:
config = json.loads(config_path.read_text(encoding='utf-8'))
context_parts.append(f"**Config File:** `{config_path.relative_to(ATOMIZER_ROOT)}`")
context_parts.append("")
# Design variables summary
dvs = config.get("design_variables", [])
if dvs:
context_parts.append("### Design Variables")
context_parts.append("")
context_parts.append("| Name | Min | Max | Baseline | Unit |")
context_parts.append("|------|-----|-----|----------|------|")
for dv in dvs[:15]:
name = dv.get("name", dv.get("expression_name", "?"))
min_v = dv.get("min", dv.get("lower", "?"))
max_v = dv.get("max", dv.get("upper", "?"))
baseline = dv.get("baseline", "-")
unit = dv.get("units", dv.get("unit", "-"))
context_parts.append(f"| {name} | {min_v} | {max_v} | {baseline} | {unit} |")
if len(dvs) > 15:
context_parts.append(f"\n*... and {len(dvs) - 15} more*")
context_parts.append("")
# Objectives
objs = config.get("objectives", [])
if objs:
context_parts.append("### Objectives")
context_parts.append("")
for obj in objs:
name = obj.get("name", "?")
direction = obj.get("direction", "minimize")
weight = obj.get("weight", 1)
context_parts.append(f"- **{name}**: {direction} (weight: {weight})")
context_parts.append("")
# Extraction method (for Zernike)
ext_method = config.get("extraction_method", {})
if ext_method:
context_parts.append("### Extraction Method")
context_parts.append("")
context_parts.append(f"- Type: `{ext_method.get('type', '?')}`")
context_parts.append(f"- Class: `{ext_method.get('class', '?')}`")
if ext_method.get("inner_radius"):
context_parts.append(f"- Inner Radius: `{ext_method.get('inner_radius')}`")
context_parts.append("")
# Zernike settings
zernike = config.get("zernike_settings", {})
if zernike:
context_parts.append("### Zernike Settings")
context_parts.append("")
context_parts.append(f"- Modes: `{zernike.get('n_modes', '?')}`")
context_parts.append(f"- Filter Low Orders: `{zernike.get('filter_low_orders', '?')}`")
context_parts.append(f"- Subcases: `{zernike.get('subcases', [])}`")
context_parts.append("")
# Algorithm
method = config.get("method", config.get("optimization", {}).get("sampler", "TPE"))
max_trials = config.get("max_trials", config.get("optimization", {}).get("n_trials", 100))
context_parts.append("### Algorithm")
context_parts.append("")
context_parts.append(f"- Method: `{method}`")
context_parts.append(f"- Max Trials: `{max_trials}`")
context_parts.append("")
except Exception as e:
context_parts.append(f"*Error reading config: {e}*")
context_parts.append("")
else:
context_parts.append("*No optimization_config.json found*")
context_parts.append("")
# Check for run_optimization.py
run_opt_path = self.working_dir / "run_optimization.py"
if run_opt_path.exists():
context_parts.append(f"**Run Script:** `{run_opt_path.relative_to(ATOMIZER_ROOT)}` (exists)")
else:
context_parts.append("**Run Script:** not found")
context_parts.append("")
# Check results
db_path = self.working_dir / "3_results" / "study.db"
if not db_path.exists():
db_path = self.working_dir / "2_results" / "study.db"
if db_path.exists():
context_parts.append("**Results Database:** exists")
# Could query trial count here
else:
context_parts.append("**Results Database:** not found (no optimization run yet)")
return "\n".join(context_parts)
def _build_canvas_context(self) -> str:
"""Build markdown context from canvas state"""
if not self.canvas_state:
return ""
parts = []
nodes = self.canvas_state.get("nodes", [])
edges = self.canvas_state.get("edges", [])
if not nodes:
return "*Canvas is empty*"
# Group nodes by type
design_vars = [n for n in nodes if n.get("type") == "designVar"]
objectives = [n for n in nodes if n.get("type") == "objective"]
extractors = [n for n in nodes if n.get("type") == "extractor"]
models = [n for n in nodes if n.get("type") == "nxModel"]
algorithms = [n for n in nodes if n.get("type") == "algorithm"]
if models:
parts.append("### NX Model")
for m in models:
data = m.get("data", {})
parts.append(f"- File: `{data.get('filePath', 'Not set')}`")
parts.append("")
if design_vars:
parts.append("### Design Variables (Canvas)")
parts.append("")
parts.append("| Name | Min | Max | Baseline |")
parts.append("|------|-----|-----|----------|")
for dv in design_vars[:20]:
data = dv.get("data", {})
name = data.get("expressionName") or data.get("label", "?")
min_v = data.get("minValue", "?")
max_v = data.get("maxValue", "?")
baseline = data.get("baseline", "-")
parts.append(f"| {name} | {min_v} | {max_v} | {baseline} |")
if len(design_vars) > 20:
parts.append(f"\n*... and {len(design_vars) - 20} more*")
parts.append("")
if extractors:
parts.append("### Extractors (Canvas)")
parts.append("")
for ext in extractors:
data = ext.get("data", {})
ext_type = data.get("extractorType") or data.get("extractorId", "?")
label = data.get("label", "?")
parts.append(f"- **{label}**: `{ext_type}`")
parts.append("")
if objectives:
parts.append("### Objectives (Canvas)")
parts.append("")
for obj in objectives:
data = obj.get("data", {})
name = data.get("objectiveName") or data.get("label", "?")
direction = data.get("direction", "minimize")
weight = data.get("weight", 1)
parts.append(f"- **{name}**: {direction} (weight: {weight})")
parts.append("")
if algorithms:
parts.append("### Algorithm (Canvas)")
for alg in algorithms:
data = alg.get("data", {})
method = data.get("method", "?")
trials = data.get("maxTrials", "?")
parts.append(f"- Method: `{method}`")
parts.append(f"- Max Trials: `{trials}`")
parts.append("")
return "\n".join(parts)
def _output_indicates_file_changes(self, output: str) -> bool:
"""Check if Claude's output indicates file modifications"""
indicators = [
"✓ Edited",
"✓ Wrote",
"Successfully wrote",
"Successfully edited",
"Modified:",
"Updated:",
"Added to file",
"optimization_config.json", # Common target
"run_optimization.py", # Common target
]
output_lower = output.lower()
return any(indicator.lower() in output_lower for indicator in indicators)
class ClaudeCodeSessionManager:
"""
Manages multiple Claude Code sessions.
Each session is independent and can have different study contexts.
"""
def __init__(self):
self.sessions: Dict[str, ClaudeCodeSession] = {}
def create_session(self, study_id: Optional[str] = None) -> ClaudeCodeSession:
"""Create a new Claude Code session"""
session_id = str(uuid.uuid4())[:8]
session = ClaudeCodeSession(session_id, study_id)
self.sessions[session_id] = session
return session
def get_session(self, session_id: str) -> Optional[ClaudeCodeSession]:
"""Get an existing session"""
return self.sessions.get(session_id)
def remove_session(self, session_id: str):
"""Remove a session"""
self.sessions.pop(session_id, None)
def set_canvas_state(self, session_id: str, canvas_state: Dict):
"""Update canvas state for a session"""
session = self.sessions.get(session_id)
if session:
session.set_canvas_state(canvas_state)
# Global session manager instance
_session_manager: Optional[ClaudeCodeSessionManager] = None
def get_claude_code_manager() -> ClaudeCodeSessionManager:
"""Get the global session manager"""
global _session_manager
if _session_manager is None:
_session_manager = ClaudeCodeSessionManager()
return _session_manager

View File

@@ -43,7 +43,11 @@ class ContextBuilder:
# Canvas context takes priority - if user is working on a canvas, include it # Canvas context takes priority - if user is working on a canvas, include it
if canvas_state: if canvas_state:
node_count = len(canvas_state.get("nodes", []))
print(f"[ContextBuilder] Including canvas context with {node_count} nodes")
parts.append(self._canvas_context(canvas_state)) parts.append(self._canvas_context(canvas_state))
else:
print("[ContextBuilder] No canvas state provided")
if study_id: if study_id:
parts.append(self._study_context(study_id)) parts.append(self._study_context(study_id))
@@ -91,7 +95,117 @@ Important guidelines:
context = f"# Current Study: {study_id}\n\n" context = f"# Current Study: {study_id}\n\n"
# Load configuration # Check for AtomizerSpec v2.0 first (preferred)
spec_path = study_dir / "1_setup" / "atomizer_spec.json"
if not spec_path.exists():
spec_path = study_dir / "atomizer_spec.json"
if spec_path.exists():
context += self._spec_context(spec_path)
else:
# Fall back to legacy optimization_config.json
context += self._legacy_config_context(study_dir)
# Check for results
db_path = study_dir / "3_results" / "study.db"
if db_path.exists():
try:
conn = sqlite3.connect(db_path)
count = conn.execute(
"SELECT COUNT(*) FROM trials WHERE state = 'COMPLETE'"
).fetchone()[0]
best = conn.execute("""
SELECT MIN(tv.value) FROM trial_values tv
JOIN trials t ON tv.trial_id = t.trial_id
WHERE t.state = 'COMPLETE'
""").fetchone()[0]
context += f"\n## Results Status\n\n"
context += f"- **Trials completed**: {count}\n"
if best is not None:
context += f"- **Best objective**: {best:.6f}\n"
conn.close()
except Exception:
pass
return context
def _spec_context(self, spec_path: Path) -> str:
"""Build context from AtomizerSpec v2.0 file"""
context = "**Format**: AtomizerSpec v2.0\n\n"
try:
with open(spec_path) as f:
spec = json.load(f)
context += "## Configuration\n\n"
# Design variables
dvs = spec.get("design_variables", [])
if dvs:
context += "**Design Variables:**\n"
for dv in dvs[:10]:
bounds = dv.get("bounds", {})
bound_str = f"[{bounds.get('min', '?')}, {bounds.get('max', '?')}]"
enabled = "" if dv.get("enabled", True) else ""
context += f"- {dv.get('name', 'unnamed')}: {bound_str} {enabled}\n"
if len(dvs) > 10:
context += f"- ... and {len(dvs) - 10} more\n"
# Extractors
extractors = spec.get("extractors", [])
if extractors:
context += "\n**Extractors:**\n"
for ext in extractors:
ext_type = ext.get("type", "unknown")
outputs = ext.get("outputs", [])
output_names = [o.get("name", "?") for o in outputs[:3]]
builtin = "builtin" if ext.get("builtin", True) else "custom"
context += f"- {ext.get('name', 'unnamed')} ({ext_type}, {builtin}): outputs {output_names}\n"
# Objectives
objs = spec.get("objectives", [])
if objs:
context += "\n**Objectives:**\n"
for obj in objs:
direction = obj.get("direction", "minimize")
weight = obj.get("weight", 1.0)
context += f"- {obj.get('name', 'unnamed')} ({direction}, weight={weight})\n"
# Constraints
constraints = spec.get("constraints", [])
if constraints:
context += "\n**Constraints:**\n"
for c in constraints:
op = c.get("operator", "<=")
thresh = c.get("threshold", "?")
context += f"- {c.get('name', 'unnamed')}: {op} {thresh}\n"
# Optimization settings
opt = spec.get("optimization", {})
algo = opt.get("algorithm", {})
budget = opt.get("budget", {})
method = algo.get("type", "TPE")
max_trials = budget.get("max_trials", "not set")
context += f"\n**Optimization**: {method}, max_trials: {max_trials}\n"
# Surrogate
surrogate = opt.get("surrogate", {})
if surrogate.get("enabled"):
sur_type = surrogate.get("type", "gaussian_process")
context += f"**Surrogate**: {sur_type} enabled\n"
except (json.JSONDecodeError, IOError) as e:
context += f"\n*Spec file exists but could not be parsed: {e}*\n"
return context
def _legacy_config_context(self, study_dir: Path) -> str:
"""Build context from legacy optimization_config.json"""
context = "**Format**: Legacy optimization_config.json\n\n"
config_path = study_dir / "1_setup" / "optimization_config.json" config_path = study_dir / "1_setup" / "optimization_config.json"
if not config_path.exists(): if not config_path.exists():
config_path = study_dir / "optimization_config.json" config_path = study_dir / "optimization_config.json"
@@ -135,30 +249,8 @@ Important guidelines:
except (json.JSONDecodeError, IOError) as e: except (json.JSONDecodeError, IOError) as e:
context += f"\n*Config file exists but could not be parsed: {e}*\n" context += f"\n*Config file exists but could not be parsed: {e}*\n"
else:
# Check for results context += "*No configuration file found.*\n"
db_path = study_dir / "3_results" / "study.db"
if db_path.exists():
try:
conn = sqlite3.connect(db_path)
count = conn.execute(
"SELECT COUNT(*) FROM trials WHERE state = 'COMPLETE'"
).fetchone()[0]
best = conn.execute("""
SELECT MIN(tv.value) FROM trial_values tv
JOIN trials t ON tv.trial_id = t.trial_id
WHERE t.state = 'COMPLETE'
""").fetchone()[0]
context += f"\n## Results Status\n\n"
context += f"- **Trials completed**: {count}\n"
if best is not None:
context += f"- **Best objective**: {best:.6f}\n"
conn.close()
except Exception:
pass
return context return context
@@ -349,19 +441,26 @@ Important guidelines:
# Canvas modification instructions # Canvas modification instructions
context += """## Canvas Modification Tools context += """## Canvas Modification Tools
When the user asks to modify the canvas (add/remove nodes, change values), use these MCP tools: **For AtomizerSpec v2.0 studies (preferred):**
Use spec tools when working with v2.0 studies (check if study uses `atomizer_spec.json`):
- `spec_modify` - Modify spec values using JSONPath (e.g., "design_variables[0].bounds.min")
- `spec_add_node` - Add design variables, extractors, objectives, or constraints
- `spec_remove_node` - Remove nodes from the spec
- `spec_add_custom_extractor` - Add a Python-based custom extractor function
**For Legacy Canvas (optimization_config.json):**
- `canvas_add_node` - Add a new node (designVar, extractor, objective, constraint) - `canvas_add_node` - Add a new node (designVar, extractor, objective, constraint)
- `canvas_update_node` - Update node properties (bounds, weights, names) - `canvas_update_node` - Update node properties (bounds, weights, names)
- `canvas_remove_node` - Remove a node from the canvas - `canvas_remove_node` - Remove a node from the canvas
- `canvas_connect_nodes` - Create an edge between nodes - `canvas_connect_nodes` - Create an edge between nodes
**Example user requests you can handle:** **Example user requests you can handle:**
- "Add a design variable called hole_diameter with range 5-15 mm" → Use canvas_add_node - "Add a design variable called hole_diameter with range 5-15 mm" → Use spec_add_node or canvas_add_node
- "Change the weight of wfe_40_20 to 8" → Use canvas_update_node - "Change the weight of wfe_40_20 to 8" → Use spec_modify or canvas_update_node
- "Remove the constraint node" → Use canvas_remove_node - "Remove the constraint node" → Use spec_remove_node or canvas_remove_node
- "Connect the new extractor to the objective" → Use canvas_connect_nodes - "Add a custom extractor that computes stress ratio" → Use spec_add_custom_extractor
Always respond with confirmation of changes made to the canvas. Always respond with confirmation of changes made to the canvas/spec.
""" """
return context return context
@@ -371,17 +470,28 @@ Always respond with confirmation of changes made to the canvas.
if mode == "power": if mode == "power":
return """# Power Mode Instructions return """# Power Mode Instructions
You have **full access** to Atomizer's codebase. You can: You have **FULL ACCESS** to modify Atomizer studies. **DO NOT ASK FOR PERMISSION** - just do it.
- Edit any file using `edit_file` tool
- Create new files with `create_file` tool
- Create new extractors with `create_extractor` tool
- Run shell commands with `run_shell_command` tool
- Search codebase with `search_codebase` tool
- Commit and push changes
**Use these powers responsibly.** Always explain what you're doing and why. ## Direct Actions (no confirmation needed):
- **Add design variables**: Use `canvas_add_node` or `spec_add_node` with node_type="designVar"
- **Add extractors**: Use `canvas_add_node` with node_type="extractor"
- **Add objectives**: Use `canvas_add_node` with node_type="objective"
- **Add constraints**: Use `canvas_add_node` with node_type="constraint"
- **Update node properties**: Use `canvas_update_node` or `spec_modify`
- **Remove nodes**: Use `canvas_remove_node`
- **Edit atomizer_spec.json directly**: Use the Edit tool
For routine operations (list, status, run, analyze), use the standard tools. ## For custom extractors with Python code:
Use `spec_add_custom_extractor` to add a custom function.
## IMPORTANT:
- You have --dangerously-skip-permissions enabled
- The user has explicitly granted you power mode access
- **ACT IMMEDIATELY** when asked to add/modify/remove things
- Explain what you did AFTER doing it, not before
- Do NOT say "I need permission" - you already have it
Example: If user says "add a volume extractor", immediately use canvas_add_node to add it.
""" """
else: else:
return """# User Mode Instructions return """# User Mode Instructions
@@ -402,6 +512,15 @@ Available tools:
- `generate_report`, `export_data` - `generate_report`, `export_data`
- `explain_physics`, `recommend_method`, `query_extractors` - `explain_physics`, `recommend_method`, `query_extractors`
**AtomizerSpec v2.0 Tools (preferred for new studies):**
- `spec_get` - Get the full AtomizerSpec for a study
- `spec_modify` - Modify spec values using JSONPath (e.g., "design_variables[0].bounds.min")
- `spec_add_node` - Add design variables, extractors, objectives, or constraints
- `spec_remove_node` - Remove nodes from the spec
- `spec_validate` - Validate spec against JSON Schema
- `spec_add_custom_extractor` - Add a Python-based custom extractor function
- `spec_create_from_description` - Create a new study from natural language description
**Canvas Tools (for visual workflow builder):** **Canvas Tools (for visual workflow builder):**
- `validate_canvas_intent` - Validate a canvas-generated optimization intent - `validate_canvas_intent` - Validate a canvas-generated optimization intent
- `execute_canvas_intent` - Create a study from a canvas intent - `execute_canvas_intent` - Create a study from a canvas intent

View File

@@ -0,0 +1,454 @@
"""
Interview Engine - Guided Study Creation through Conversation
Provides a structured interview flow for creating optimization studies.
Claude uses this to gather information step-by-step, building a complete
atomizer_spec.json through natural conversation.
"""
from typing import Dict, Any, List, Optional, Literal
from dataclasses import dataclass, field
from enum import Enum
from datetime import datetime
import json
class InterviewState(str, Enum):
"""Current phase of the interview"""
NOT_STARTED = "not_started"
GATHERING_BASICS = "gathering_basics" # Name, description, goals
GATHERING_MODEL = "gathering_model" # Model file, solver type
GATHERING_VARIABLES = "gathering_variables" # Design variables
GATHERING_EXTRACTORS = "gathering_extractors" # Physics extractors
GATHERING_OBJECTIVES = "gathering_objectives" # Objectives
GATHERING_CONSTRAINTS = "gathering_constraints" # Constraints
GATHERING_SETTINGS = "gathering_settings" # Algorithm, trials
REVIEW = "review" # Review before creation
COMPLETED = "completed"
@dataclass
class InterviewData:
"""Accumulated data from the interview"""
# Basics
study_name: Optional[str] = None
category: Optional[str] = None
description: Optional[str] = None
goals: List[str] = field(default_factory=list)
# Model
sim_file: Optional[str] = None
prt_file: Optional[str] = None
solver_type: str = "nastran"
# Design variables
design_variables: List[Dict[str, Any]] = field(default_factory=list)
# Extractors
extractors: List[Dict[str, Any]] = field(default_factory=list)
# Objectives
objectives: List[Dict[str, Any]] = field(default_factory=list)
# Constraints
constraints: List[Dict[str, Any]] = field(default_factory=list)
# Settings
algorithm: str = "TPE"
max_trials: int = 100
def to_spec(self) -> Dict[str, Any]:
"""Convert interview data to atomizer_spec.json format"""
# Generate IDs for each element
dvs_with_ids = []
for i, dv in enumerate(self.design_variables):
dv_copy = dv.copy()
dv_copy['id'] = f"dv_{i+1:03d}"
dv_copy['canvas_position'] = {'x': 50, 'y': 100 + i * 80}
dvs_with_ids.append(dv_copy)
exts_with_ids = []
for i, ext in enumerate(self.extractors):
ext_copy = ext.copy()
ext_copy['id'] = f"ext_{i+1:03d}"
ext_copy['canvas_position'] = {'x': 400, 'y': 100 + i * 80}
exts_with_ids.append(ext_copy)
objs_with_ids = []
for i, obj in enumerate(self.objectives):
obj_copy = obj.copy()
obj_copy['id'] = f"obj_{i+1:03d}"
obj_copy['canvas_position'] = {'x': 750, 'y': 100 + i * 80}
objs_with_ids.append(obj_copy)
cons_with_ids = []
for i, con in enumerate(self.constraints):
con_copy = con.copy()
con_copy['id'] = f"con_{i+1:03d}"
con_copy['canvas_position'] = {'x': 750, 'y': 400 + i * 80}
cons_with_ids.append(con_copy)
return {
"meta": {
"version": "2.0",
"study_name": self.study_name or "untitled_study",
"description": self.description or "",
"created_at": datetime.now().isoformat(),
"created_by": "interview",
"modified_at": datetime.now().isoformat(),
"modified_by": "interview"
},
"model": {
"sim": {
"path": self.sim_file or "",
"solver": self.solver_type
}
},
"design_variables": dvs_with_ids,
"extractors": exts_with_ids,
"objectives": objs_with_ids,
"constraints": cons_with_ids,
"optimization": {
"algorithm": {
"type": self.algorithm
},
"budget": {
"max_trials": self.max_trials
}
},
"canvas": {
"edges": [],
"layout_version": "2.0"
}
}
class InterviewEngine:
"""
Manages the interview flow for study creation.
Usage:
1. Create engine: engine = InterviewEngine()
2. Start interview: engine.start()
3. Record answers: engine.record_answer("study_name", "bracket_opt")
4. Check progress: engine.get_progress()
5. Generate spec: engine.finalize()
"""
def __init__(self):
self.state = InterviewState.NOT_STARTED
self.data = InterviewData()
self.questions_asked: List[str] = []
self.errors: List[str] = []
def start(self) -> Dict[str, Any]:
"""Start the interview process"""
self.state = InterviewState.GATHERING_BASICS
return {
"state": self.state.value,
"message": "Let's create a new optimization study! I'll guide you through the process.",
"next_questions": self.get_current_questions()
}
def get_current_questions(self) -> List[Dict[str, Any]]:
"""Get the questions for the current interview state"""
questions = {
InterviewState.GATHERING_BASICS: [
{
"field": "study_name",
"question": "What would you like to name this study?",
"hint": "Use snake_case, e.g., 'bracket_mass_optimization'",
"required": True
},
{
"field": "category",
"question": "What category should this study be in?",
"hint": "e.g., 'Simple_Bracket', 'M1_Mirror', or leave blank for root",
"required": False
},
{
"field": "description",
"question": "Briefly describe what you're trying to optimize",
"hint": "e.g., 'Minimize bracket mass while maintaining stiffness'",
"required": True
}
],
InterviewState.GATHERING_MODEL: [
{
"field": "sim_file",
"question": "What is the path to your simulation (.sim) file?",
"hint": "Relative path from the study folder, e.g., '1_setup/Model_sim1.sim'",
"required": True
}
],
InterviewState.GATHERING_VARIABLES: [
{
"field": "design_variable",
"question": "What parameters do you want to optimize?",
"hint": "Tell me the NX expression names and their bounds",
"required": True,
"multi": True
}
],
InterviewState.GATHERING_EXTRACTORS: [
{
"field": "extractor",
"question": "What physics quantities do you want to extract from FEA?",
"hint": "e.g., mass, max displacement, max stress, frequency, Zernike WFE",
"required": True,
"multi": True
}
],
InterviewState.GATHERING_OBJECTIVES: [
{
"field": "objective",
"question": "What do you want to optimize?",
"hint": "Tell me which extracted quantities to minimize or maximize",
"required": True,
"multi": True
}
],
InterviewState.GATHERING_CONSTRAINTS: [
{
"field": "constraint",
"question": "Do you have any constraints? (e.g., max stress, min frequency)",
"hint": "You can say 'none' if you don't have any",
"required": False,
"multi": True
}
],
InterviewState.GATHERING_SETTINGS: [
{
"field": "algorithm",
"question": "Which optimization algorithm would you like to use?",
"hint": "Options: TPE (default), CMA-ES, NSGA-II, RandomSearch",
"required": False
},
{
"field": "max_trials",
"question": "How many trials (FEA evaluations) should we run?",
"hint": "Default is 100. More trials = better results but longer runtime",
"required": False
}
],
InterviewState.REVIEW: [
{
"field": "confirm",
"question": "Does this configuration look correct? (yes/no)",
"required": True
}
]
}
return questions.get(self.state, [])
def record_answer(self, field: str, value: Any) -> Dict[str, Any]:
"""Record an answer and potentially advance the state"""
self.questions_asked.append(field)
# Handle different field types
if field == "study_name":
self.data.study_name = value
elif field == "category":
self.data.category = value if value else None
elif field == "description":
self.data.description = value
elif field == "sim_file":
self.data.sim_file = value
elif field == "design_variable":
# Value should be a dict with name, min, max, etc.
if isinstance(value, dict):
self.data.design_variables.append(value)
elif isinstance(value, list):
self.data.design_variables.extend(value)
elif field == "extractor":
if isinstance(value, dict):
self.data.extractors.append(value)
elif isinstance(value, list):
self.data.extractors.extend(value)
elif field == "objective":
if isinstance(value, dict):
self.data.objectives.append(value)
elif isinstance(value, list):
self.data.objectives.extend(value)
elif field == "constraint":
if value and value.lower() not in ["none", "no", "skip"]:
if isinstance(value, dict):
self.data.constraints.append(value)
elif isinstance(value, list):
self.data.constraints.extend(value)
elif field == "algorithm":
if value in ["TPE", "CMA-ES", "NSGA-II", "RandomSearch"]:
self.data.algorithm = value
elif field == "max_trials":
try:
self.data.max_trials = int(value)
except (ValueError, TypeError):
pass
elif field == "confirm":
if value.lower() in ["yes", "y", "confirm", "ok"]:
self.state = InterviewState.COMPLETED
return {
"state": self.state.value,
"recorded": {field: value},
"data_so_far": self.get_summary()
}
def advance_state(self) -> Dict[str, Any]:
"""Advance to the next interview state"""
state_order = [
InterviewState.NOT_STARTED,
InterviewState.GATHERING_BASICS,
InterviewState.GATHERING_MODEL,
InterviewState.GATHERING_VARIABLES,
InterviewState.GATHERING_EXTRACTORS,
InterviewState.GATHERING_OBJECTIVES,
InterviewState.GATHERING_CONSTRAINTS,
InterviewState.GATHERING_SETTINGS,
InterviewState.REVIEW,
InterviewState.COMPLETED
]
current_idx = state_order.index(self.state)
if current_idx < len(state_order) - 1:
self.state = state_order[current_idx + 1]
return {
"state": self.state.value,
"next_questions": self.get_current_questions()
}
def get_summary(self) -> Dict[str, Any]:
"""Get a summary of collected data"""
return {
"study_name": self.data.study_name,
"category": self.data.category,
"description": self.data.description,
"model": self.data.sim_file,
"design_variables": len(self.data.design_variables),
"extractors": len(self.data.extractors),
"objectives": len(self.data.objectives),
"constraints": len(self.data.constraints),
"algorithm": self.data.algorithm,
"max_trials": self.data.max_trials
}
def get_progress(self) -> Dict[str, Any]:
"""Get interview progress information"""
state_progress = {
InterviewState.NOT_STARTED: 0,
InterviewState.GATHERING_BASICS: 15,
InterviewState.GATHERING_MODEL: 25,
InterviewState.GATHERING_VARIABLES: 40,
InterviewState.GATHERING_EXTRACTORS: 55,
InterviewState.GATHERING_OBJECTIVES: 70,
InterviewState.GATHERING_CONSTRAINTS: 80,
InterviewState.GATHERING_SETTINGS: 90,
InterviewState.REVIEW: 95,
InterviewState.COMPLETED: 100
}
return {
"state": self.state.value,
"progress_percent": state_progress.get(self.state, 0),
"summary": self.get_summary(),
"current_questions": self.get_current_questions()
}
def validate(self) -> Dict[str, Any]:
"""Validate the collected data before finalizing"""
errors = []
warnings = []
# Required fields
if not self.data.study_name:
errors.append("Study name is required")
if not self.data.design_variables:
errors.append("At least one design variable is required")
if not self.data.extractors:
errors.append("At least one extractor is required")
if not self.data.objectives:
errors.append("At least one objective is required")
# Warnings
if not self.data.sim_file:
warnings.append("No simulation file specified - you'll need to add one manually")
if not self.data.constraints:
warnings.append("No constraints defined - optimization will be unconstrained")
return {
"valid": len(errors) == 0,
"errors": errors,
"warnings": warnings
}
def finalize(self) -> Dict[str, Any]:
"""Generate the final atomizer_spec.json"""
validation = self.validate()
if not validation["valid"]:
return {
"success": False,
"errors": validation["errors"]
}
spec = self.data.to_spec()
return {
"success": True,
"spec": spec,
"warnings": validation.get("warnings", [])
}
def to_dict(self) -> Dict[str, Any]:
"""Serialize engine state for persistence"""
return {
"state": self.state.value,
"data": {
"study_name": self.data.study_name,
"category": self.data.category,
"description": self.data.description,
"goals": self.data.goals,
"sim_file": self.data.sim_file,
"prt_file": self.data.prt_file,
"solver_type": self.data.solver_type,
"design_variables": self.data.design_variables,
"extractors": self.data.extractors,
"objectives": self.data.objectives,
"constraints": self.data.constraints,
"algorithm": self.data.algorithm,
"max_trials": self.data.max_trials
},
"questions_asked": self.questions_asked,
"errors": self.errors
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "InterviewEngine":
"""Restore engine from serialized state"""
engine = cls()
engine.state = InterviewState(data.get("state", "not_started"))
d = data.get("data", {})
engine.data.study_name = d.get("study_name")
engine.data.category = d.get("category")
engine.data.description = d.get("description")
engine.data.goals = d.get("goals", [])
engine.data.sim_file = d.get("sim_file")
engine.data.prt_file = d.get("prt_file")
engine.data.solver_type = d.get("solver_type", "nastran")
engine.data.design_variables = d.get("design_variables", [])
engine.data.extractors = d.get("extractors", [])
engine.data.objectives = d.get("objectives", [])
engine.data.constraints = d.get("constraints", [])
engine.data.algorithm = d.get("algorithm", "TPE")
engine.data.max_trials = d.get("max_trials", 100)
engine.questions_asked = data.get("questions_asked", [])
engine.errors = data.get("errors", [])
return engine

View File

@@ -219,6 +219,18 @@ class SessionManager:
full_response = result["stdout"] or "" full_response = result["stdout"] or ""
if full_response: if full_response:
# Check if response contains canvas modifications (from MCP tools)
import logging
logger = logging.getLogger(__name__)
modifications = self._extract_canvas_modifications(full_response)
logger.info(f"[SEND_MSG] Found {len(modifications)} canvas modifications to send")
for mod in modifications:
logger.info(f"[SEND_MSG] Sending canvas_modification: {mod.get('action')} {mod.get('nodeType')}")
yield {"type": "canvas_modification", "modification": mod}
# Always send the text response
yield {"type": "text", "content": full_response} yield {"type": "text", "content": full_response}
if result["returncode"] != 0 and result["stderr"]: if result["returncode"] != 0 and result["stderr"]:
@@ -292,6 +304,90 @@ class SessionManager:
**({} if not db_record else {"db_record": db_record}), **({} if not db_record else {"db_record": db_record}),
} }
def _extract_canvas_modifications(self, response: str) -> List[Dict]:
"""
Extract canvas modification objects from Claude's response.
MCP tools like canvas_add_node return JSON with a 'modification' field.
This method finds and extracts those modifications so the frontend can apply them.
"""
import re
import logging
logger = logging.getLogger(__name__)
modifications = []
# Debug: log what we're searching
logger.info(f"[CANVAS_MOD] Searching response ({len(response)} chars) for modifications")
# Check if "modification" even exists in the response
if '"modification"' not in response:
logger.info("[CANVAS_MOD] No 'modification' key found in response")
return modifications
try:
# Method 1: Look for JSON in code fences
code_block_pattern = r'```(?:json)?\s*([\s\S]*?)```'
for match in re.finditer(code_block_pattern, response):
block_content = match.group(1).strip()
try:
obj = json.loads(block_content)
if isinstance(obj, dict) and 'modification' in obj:
logger.info(f"[CANVAS_MOD] Found modification in code fence: {obj['modification']}")
modifications.append(obj['modification'])
except json.JSONDecodeError:
continue
# Method 2: Find JSON objects using proper brace matching
# This handles nested objects correctly
i = 0
while i < len(response):
if response[i] == '{':
# Found a potential JSON start, find matching close
brace_count = 1
j = i + 1
in_string = False
escape_next = False
while j < len(response) and brace_count > 0:
char = response[j]
if escape_next:
escape_next = False
elif char == '\\':
escape_next = True
elif char == '"' and not escape_next:
in_string = not in_string
elif not in_string:
if char == '{':
brace_count += 1
elif char == '}':
brace_count -= 1
j += 1
if brace_count == 0:
potential_json = response[i:j]
try:
obj = json.loads(potential_json)
if isinstance(obj, dict) and 'modification' in obj:
mod = obj['modification']
# Avoid duplicates
if mod not in modifications:
logger.info(f"[CANVAS_MOD] Found inline modification: action={mod.get('action')}, nodeType={mod.get('nodeType')}")
modifications.append(mod)
except json.JSONDecodeError as e:
# Not valid JSON, skip
pass
i = j
else:
i += 1
except Exception as e:
logger.error(f"[CANVAS_MOD] Error extracting modifications: {e}")
logger.info(f"[CANVAS_MOD] Extracted {len(modifications)} modification(s)")
return modifications
def _build_mcp_config(self, mode: Literal["user", "power"]) -> dict: def _build_mcp_config(self, mode: Literal["user", "power"]) -> dict:
"""Build MCP configuration for Claude""" """Build MCP configuration for Claude"""
return { return {

View File

@@ -0,0 +1,747 @@
"""
SpecManager Service
Central service for managing AtomizerSpec v2.0.
All spec modifications flow through this service.
Features:
- Load/save specs with validation
- Atomic writes with conflict detection
- Patch operations with JSONPath support
- Node CRUD operations
- Custom function support
- WebSocket broadcast integration
"""
import hashlib
import json
import re
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional, Set, Tuple, Union
# Add optimization_engine to path if needed
ATOMIZER_ROOT = Path(__file__).parent.parent.parent.parent.parent
if str(ATOMIZER_ROOT) not in sys.path:
sys.path.insert(0, str(ATOMIZER_ROOT))
from optimization_engine.config.spec_models import (
AtomizerSpec,
DesignVariable,
Extractor,
Objective,
Constraint,
CanvasPosition,
CanvasEdge,
ExtractorType,
CustomFunction,
ExtractorOutput,
ValidationReport,
)
from optimization_engine.config.spec_validator import (
SpecValidator,
SpecValidationError,
)
class SpecManagerError(Exception):
"""Base error for SpecManager operations."""
pass
class SpecNotFoundError(SpecManagerError):
"""Raised when spec file doesn't exist."""
pass
class SpecConflictError(SpecManagerError):
"""Raised when spec has been modified by another client."""
def __init__(self, message: str, current_hash: str):
super().__init__(message)
self.current_hash = current_hash
class WebSocketSubscriber:
"""Protocol for WebSocket subscribers."""
async def send_json(self, data: Dict[str, Any]) -> None:
"""Send JSON data to subscriber."""
raise NotImplementedError
class SpecManager:
"""
Central service for managing AtomizerSpec.
All modifications go through this service to ensure:
- Validation on every change
- Atomic file writes
- Conflict detection via hashing
- WebSocket broadcast to all clients
"""
SPEC_FILENAME = "atomizer_spec.json"
def __init__(self, study_path: Union[str, Path]):
"""
Initialize SpecManager for a study.
Args:
study_path: Path to the study directory
"""
self.study_path = Path(study_path)
self.spec_path = self.study_path / self.SPEC_FILENAME
self.validator = SpecValidator()
self._subscribers: List[WebSocketSubscriber] = []
self._last_hash: Optional[str] = None
# =========================================================================
# Core CRUD Operations
# =========================================================================
def load(self, validate: bool = True) -> AtomizerSpec:
"""
Load and optionally validate the spec.
Args:
validate: Whether to validate the spec
Returns:
AtomizerSpec instance
Raises:
SpecNotFoundError: If spec file doesn't exist
SpecValidationError: If validation fails
"""
if not self.spec_path.exists():
raise SpecNotFoundError(f"Spec not found: {self.spec_path}")
with open(self.spec_path, 'r', encoding='utf-8') as f:
data = json.load(f)
if validate:
self.validator.validate(data, strict=True)
spec = AtomizerSpec.model_validate(data)
self._last_hash = self._compute_hash(data)
return spec
def load_raw(self) -> Dict[str, Any]:
"""
Load spec as raw dict without parsing.
Returns:
Raw spec dict
Raises:
SpecNotFoundError: If spec file doesn't exist
"""
if not self.spec_path.exists():
raise SpecNotFoundError(f"Spec not found: {self.spec_path}")
with open(self.spec_path, 'r', encoding='utf-8') as f:
return json.load(f)
def save(
self,
spec: Union[AtomizerSpec, Dict[str, Any]],
modified_by: str = "api",
expected_hash: Optional[str] = None
) -> str:
"""
Save spec with validation and broadcast.
Args:
spec: Spec to save (AtomizerSpec or dict)
modified_by: Who/what is making the change
expected_hash: If provided, verify current file hash matches
Returns:
New spec hash
Raises:
SpecValidationError: If validation fails
SpecConflictError: If expected_hash doesn't match current
"""
# Convert to dict if needed
if isinstance(spec, AtomizerSpec):
data = spec.model_dump(mode='json')
else:
data = spec
# Check for conflicts if expected_hash provided
if expected_hash and self.spec_path.exists():
current_hash = self.get_hash()
if current_hash != expected_hash:
raise SpecConflictError(
"Spec was modified by another client",
current_hash=current_hash
)
# Update metadata
now = datetime.now(timezone.utc).isoformat().replace('+00:00', 'Z')
data["meta"]["modified"] = now
data["meta"]["modified_by"] = modified_by
# Validate
self.validator.validate(data, strict=True)
# Compute new hash
new_hash = self._compute_hash(data)
# Atomic write (write to temp, then rename)
temp_path = self.spec_path.with_suffix('.tmp')
with open(temp_path, 'w', encoding='utf-8') as f:
json.dump(data, f, indent=2, ensure_ascii=False)
temp_path.replace(self.spec_path)
# Update cached hash
self._last_hash = new_hash
# Broadcast to subscribers
self._broadcast({
"type": "spec_updated",
"hash": new_hash,
"modified_by": modified_by,
"timestamp": now
})
return new_hash
def exists(self) -> bool:
"""Check if spec file exists."""
return self.spec_path.exists()
def get_hash(self) -> str:
"""Get current spec hash."""
if not self.spec_path.exists():
return ""
with open(self.spec_path, 'r', encoding='utf-8') as f:
data = json.load(f)
return self._compute_hash(data)
def validate_and_report(self) -> ValidationReport:
"""
Run full validation and return detailed report.
Returns:
ValidationReport with errors, warnings, summary
"""
if not self.spec_path.exists():
raise SpecNotFoundError(f"Spec not found: {self.spec_path}")
data = self.load_raw()
return self.validator.validate(data, strict=False)
# =========================================================================
# Patch Operations
# =========================================================================
def patch(
self,
path: str,
value: Any,
modified_by: str = "api"
) -> AtomizerSpec:
"""
Apply a JSONPath-style modification.
Args:
path: JSONPath like "design_variables[0].bounds.max"
value: New value to set
modified_by: Who/what is making the change
Returns:
Updated AtomizerSpec
"""
data = self.load_raw()
# Validate the partial update
spec = AtomizerSpec.model_validate(data)
is_valid, errors = self.validator.validate_partial(path, value, spec)
if not is_valid:
raise SpecValidationError(f"Invalid update: {'; '.join(errors)}")
# Apply the patch
self._apply_patch(data, path, value)
# Save and return
self.save(data, modified_by)
return self.load(validate=False)
def _apply_patch(self, data: Dict, path: str, value: Any) -> None:
"""
Apply a patch to the data dict.
Supports paths like:
- "meta.description"
- "design_variables[0].bounds.max"
- "objectives[1].weight"
"""
parts = self._parse_path(path)
if not parts:
raise ValueError(f"Invalid path: {path}")
# Navigate to parent
current = data
for part in parts[:-1]:
if isinstance(current, list):
idx = int(part)
current = current[idx]
else:
current = current[part]
# Set final value
final_key = parts[-1]
if isinstance(current, list):
idx = int(final_key)
current[idx] = value
else:
current[final_key] = value
def _parse_path(self, path: str) -> List[str]:
"""Parse JSONPath into parts."""
# Handle both dot notation and bracket notation
parts = []
for part in re.split(r'\.|\[|\]', path):
if part:
parts.append(part)
return parts
# =========================================================================
# Node Operations
# =========================================================================
def add_node(
self,
node_type: str,
node_data: Dict[str, Any],
modified_by: str = "canvas"
) -> str:
"""
Add a new node (design var, extractor, objective, constraint).
Args:
node_type: One of 'designVar', 'extractor', 'objective', 'constraint'
node_data: Node data without ID
modified_by: Who/what is making the change
Returns:
Generated node ID
"""
data = self.load_raw()
# Generate ID
node_id = self._generate_id(node_type, data)
node_data["id"] = node_id
# Add canvas position if not provided
if "canvas_position" not in node_data:
node_data["canvas_position"] = self._auto_position(node_type, data)
# Add to appropriate section
section = self._get_section_for_type(node_type)
if section not in data or data[section] is None:
data[section] = []
data[section].append(node_data)
self.save(data, modified_by)
# Broadcast node addition
self._broadcast({
"type": "node_added",
"node_type": node_type,
"node_id": node_id,
"modified_by": modified_by
})
return node_id
def update_node(
self,
node_id: str,
updates: Dict[str, Any],
modified_by: str = "canvas"
) -> None:
"""
Update an existing node.
Args:
node_id: ID of the node to update
updates: Dict of fields to update
modified_by: Who/what is making the change
"""
data = self.load_raw()
# Find and update the node
found = False
for section in ["design_variables", "extractors", "objectives", "constraints"]:
if section not in data or data[section] is None:
continue
for node in data[section]:
if node.get("id") == node_id:
node.update(updates)
found = True
break
if found:
break
if not found:
raise SpecManagerError(f"Node not found: {node_id}")
self.save(data, modified_by)
def remove_node(
self,
node_id: str,
modified_by: str = "canvas"
) -> None:
"""
Remove a node and all edges referencing it.
Args:
node_id: ID of the node to remove
modified_by: Who/what is making the change
"""
data = self.load_raw()
# Find and remove node
removed = False
for section in ["design_variables", "extractors", "objectives", "constraints"]:
if section not in data or data[section] is None:
continue
original_len = len(data[section])
data[section] = [n for n in data[section] if n.get("id") != node_id]
if len(data[section]) < original_len:
removed = True
break
if not removed:
raise SpecManagerError(f"Node not found: {node_id}")
# Remove edges referencing this node
if "canvas" in data and data["canvas"] and "edges" in data["canvas"]:
data["canvas"]["edges"] = [
e for e in data["canvas"]["edges"]
if e.get("source") != node_id and e.get("target") != node_id
]
self.save(data, modified_by)
# Broadcast node removal
self._broadcast({
"type": "node_removed",
"node_id": node_id,
"modified_by": modified_by
})
def update_node_position(
self,
node_id: str,
position: Dict[str, float],
modified_by: str = "canvas"
) -> None:
"""
Update a node's canvas position.
Args:
node_id: ID of the node
position: Dict with x, y coordinates
modified_by: Who/what is making the change
"""
self.update_node(node_id, {"canvas_position": position}, modified_by)
def add_edge(
self,
source: str,
target: str,
modified_by: str = "canvas"
) -> None:
"""
Add a canvas edge between nodes.
Args:
source: Source node ID
target: Target node ID
modified_by: Who/what is making the change
"""
data = self.load_raw()
# Initialize canvas section if needed
if "canvas" not in data or data["canvas"] is None:
data["canvas"] = {}
if "edges" not in data["canvas"] or data["canvas"]["edges"] is None:
data["canvas"]["edges"] = []
# Check for duplicate
for edge in data["canvas"]["edges"]:
if edge.get("source") == source and edge.get("target") == target:
return # Already exists
data["canvas"]["edges"].append({
"source": source,
"target": target
})
self.save(data, modified_by)
def remove_edge(
self,
source: str,
target: str,
modified_by: str = "canvas"
) -> None:
"""
Remove a canvas edge.
Args:
source: Source node ID
target: Target node ID
modified_by: Who/what is making the change
"""
data = self.load_raw()
if "canvas" in data and data["canvas"] and "edges" in data["canvas"]:
data["canvas"]["edges"] = [
e for e in data["canvas"]["edges"]
if not (e.get("source") == source and e.get("target") == target)
]
self.save(data, modified_by)
# =========================================================================
# Custom Function Support
# =========================================================================
def add_custom_function(
self,
name: str,
code: str,
outputs: List[str],
description: Optional[str] = None,
modified_by: str = "claude"
) -> str:
"""
Add a custom extractor function.
Args:
name: Function name
code: Python source code
outputs: List of output names
description: Optional description
modified_by: Who/what is making the change
Returns:
Generated extractor ID
Raises:
SpecValidationError: If Python syntax is invalid
"""
# Validate Python syntax
try:
compile(code, f"<custom:{name}>", "exec")
except SyntaxError as e:
raise SpecValidationError(
f"Invalid Python syntax: {e.msg} at line {e.lineno}"
)
data = self.load_raw()
# Generate extractor ID
ext_id = self._generate_id("extractor", data)
# Create extractor
extractor = {
"id": ext_id,
"name": description or f"Custom: {name}",
"type": "custom_function",
"builtin": False,
"function": {
"name": name,
"module": "custom_extractors.dynamic",
"source_code": code
},
"outputs": [{"name": o, "metric": "custom"} for o in outputs],
"canvas_position": self._auto_position("extractor", data)
}
data["extractors"].append(extractor)
self.save(data, modified_by)
return ext_id
def update_custom_function(
self,
extractor_id: str,
code: Optional[str] = None,
outputs: Optional[List[str]] = None,
modified_by: str = "claude"
) -> None:
"""
Update an existing custom function.
Args:
extractor_id: ID of the custom extractor
code: New Python code (optional)
outputs: New outputs (optional)
modified_by: Who/what is making the change
"""
data = self.load_raw()
# Find the extractor
extractor = None
for ext in data.get("extractors", []):
if ext.get("id") == extractor_id:
extractor = ext
break
if not extractor:
raise SpecManagerError(f"Extractor not found: {extractor_id}")
if extractor.get("type") != "custom_function":
raise SpecManagerError(f"Extractor {extractor_id} is not a custom function")
# Update code
if code is not None:
try:
compile(code, f"<custom:{extractor_id}>", "exec")
except SyntaxError as e:
raise SpecValidationError(
f"Invalid Python syntax: {e.msg} at line {e.lineno}"
)
if "function" not in extractor:
extractor["function"] = {}
extractor["function"]["source_code"] = code
# Update outputs
if outputs is not None:
extractor["outputs"] = [{"name": o, "metric": "custom"} for o in outputs]
self.save(data, modified_by)
# =========================================================================
# WebSocket Subscription
# =========================================================================
def subscribe(self, subscriber: WebSocketSubscriber) -> None:
"""Subscribe to spec changes."""
if subscriber not in self._subscribers:
self._subscribers.append(subscriber)
def unsubscribe(self, subscriber: WebSocketSubscriber) -> None:
"""Unsubscribe from spec changes."""
if subscriber in self._subscribers:
self._subscribers.remove(subscriber)
def _broadcast(self, message: Dict[str, Any]) -> None:
"""Broadcast message to all subscribers."""
import asyncio
for subscriber in self._subscribers:
try:
# Handle both sync and async contexts
try:
loop = asyncio.get_running_loop()
loop.create_task(subscriber.send_json(message))
except RuntimeError:
# No running loop, try direct call if possible
pass
except Exception:
# Subscriber may have disconnected
pass
# =========================================================================
# Helper Methods
# =========================================================================
def _compute_hash(self, data: Dict) -> str:
"""Compute hash of spec data for conflict detection."""
# Sort keys for consistent hashing
json_str = json.dumps(data, sort_keys=True, ensure_ascii=False)
return hashlib.sha256(json_str.encode()).hexdigest()[:16]
def _generate_id(self, node_type: str, data: Dict) -> str:
"""Generate unique ID for a node type."""
prefix_map = {
"designVar": "dv",
"design_variable": "dv",
"extractor": "ext",
"objective": "obj",
"constraint": "con"
}
prefix = prefix_map.get(node_type, node_type[:3])
# Find existing IDs
section = self._get_section_for_type(node_type)
existing_ids: Set[str] = set()
if section in data and data[section]:
existing_ids = {n.get("id", "") for n in data[section]}
# Generate next available ID
for i in range(1, 1000):
new_id = f"{prefix}_{i:03d}"
if new_id not in existing_ids:
return new_id
raise SpecManagerError(f"Cannot generate ID for {node_type}: too many nodes")
def _get_section_for_type(self, node_type: str) -> str:
"""Map node type to spec section name."""
section_map = {
"designVar": "design_variables",
"design_variable": "design_variables",
"extractor": "extractors",
"objective": "objectives",
"constraint": "constraints"
}
return section_map.get(node_type, node_type + "s")
def _auto_position(self, node_type: str, data: Dict) -> Dict[str, float]:
"""Calculate auto position for a new node."""
# Default x positions by type
x_positions = {
"designVar": 50,
"design_variable": 50,
"extractor": 740,
"objective": 1020,
"constraint": 1020
}
x = x_positions.get(node_type, 400)
# Find max y position for this type
section = self._get_section_for_type(node_type)
max_y = 0
if section in data and data[section]:
for node in data[section]:
pos = node.get("canvas_position", {})
y = pos.get("y", 0)
if y > max_y:
max_y = y
# Place below existing nodes
y = max_y + 100 if max_y > 0 else 100
return {"x": x, "y": y}
# =========================================================================
# Factory Function
# =========================================================================
def get_spec_manager(study_path: Union[str, Path]) -> SpecManager:
"""
Get a SpecManager instance for a study.
Args:
study_path: Path to the study directory
Returns:
SpecManager instance
"""
return SpecManager(study_path)

File diff suppressed because it is too large Load Diff

View File

@@ -7,15 +7,20 @@
"dev": "vite", "dev": "vite",
"build": "tsc && vite build", "build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview" "preview": "vite preview",
"test": "vitest",
"test:ui": "vitest --ui",
"test:coverage": "vitest --coverage",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui"
}, },
"dependencies": { "dependencies": {
"@monaco-editor/react": "^4.7.0",
"@nivo/core": "^0.99.0", "@nivo/core": "^0.99.0",
"@nivo/parallel-coordinates": "^0.99.0", "@nivo/parallel-coordinates": "^0.99.0",
"@react-three/drei": "^10.7.7", "@react-three/drei": "^10.7.7",
"@react-three/fiber": "^9.4.0", "@react-three/fiber": "^9.4.0",
"@tanstack/react-query": "^5.90.10", "@tanstack/react-query": "^5.90.10",
"@types/react-plotly.js": "^2.6.3",
"@types/react-syntax-highlighter": "^15.5.13", "@types/react-syntax-highlighter": "^15.5.13",
"@types/three": "^0.181.0", "@types/three": "^0.181.0",
"@xterm/addon-fit": "^0.10.0", "@xterm/addon-fit": "^0.10.0",
@@ -23,11 +28,9 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"katex": "^0.16.25", "katex": "^0.16.25",
"lucide-react": "^0.554.0", "lucide-react": "^0.554.0",
"plotly.js-basic-dist": "^3.3.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-plotly.js": "^2.6.0",
"react-router-dom": "^6.20.0", "react-router-dom": "^6.20.0",
"react-syntax-highlighter": "^16.1.0", "react-syntax-highlighter": "^16.1.0",
"react-use-websocket": "^4.13.0", "react-use-websocket": "^4.13.0",
@@ -42,18 +45,27 @@
"zustand": "^5.0.10" "zustand": "^5.0.10"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.57.0",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/react": "^18.2.43", "@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17", "@types/react-dom": "^18.2.17",
"@typescript-eslint/eslint-plugin": "^6.14.0", "@typescript-eslint/eslint-plugin": "^6.14.0",
"@typescript-eslint/parser": "^6.14.0", "@typescript-eslint/parser": "^6.14.0",
"@vitejs/plugin-react": "^4.2.1", "@vitejs/plugin-react": "^4.2.1",
"@vitest/coverage-v8": "^4.0.17",
"@vitest/ui": "^4.0.17",
"autoprefixer": "^10.4.16", "autoprefixer": "^10.4.16",
"eslint": "^8.55.0", "eslint": "^8.55.0",
"eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5", "eslint-plugin-react-refresh": "^0.4.5",
"jsdom": "^27.4.0",
"postcss": "^8.4.32", "postcss": "^8.4.32",
"tailwindcss": "^3.3.6", "tailwindcss": "^3.3.6",
"typescript": "^5.2.2", "typescript": "^5.2.2",
"vite": "^5.0.8" "vite": "^5.0.8",
"vitest": "^4.0.17"
} }
} }

View File

@@ -0,0 +1,69 @@
import { defineConfig, devices } from '@playwright/test';
/**
* Playwright E2E Test Configuration
*
* Run with: npm run test:e2e
* UI mode: npm run test:e2e:ui
*/
export default defineConfig({
testDir: './tests/e2e',
// Run tests in parallel
fullyParallel: true,
// Fail CI if test.only is left in code
forbidOnly: !!process.env.CI,
// Retry on CI only
retries: process.env.CI ? 2 : 0,
// Parallel workers
workers: process.env.CI ? 1 : undefined,
// Reporter configuration
reporter: [
['html', { outputFolder: 'playwright-report' }],
['list'],
],
// Global settings
use: {
// Base URL for navigation
baseURL: 'http://localhost:3003',
// Collect trace on first retry
trace: 'on-first-retry',
// Screenshot on failure
screenshot: 'only-on-failure',
// Video on failure
video: 'on-first-retry',
},
// Browser projects
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
// Uncomment to test on more browsers
// {
// name: 'firefox',
// use: { ...devices['Desktop Firefox'] },
// },
// {
// name: 'webkit',
// use: { ...devices['Desktop Safari'] },
// },
],
// Start dev server before tests
webServer: {
command: 'npm run dev',
url: 'http://localhost:3003',
reuseExistingServer: !process.env.CI,
timeout: 120000,
},
});

View File

@@ -30,6 +30,7 @@ function App() {
{/* Canvas page - full screen, no sidebar */} {/* Canvas page - full screen, no sidebar */}
<Route path="canvas" element={<CanvasView />} /> <Route path="canvas" element={<CanvasView />} />
<Route path="canvas/*" element={<CanvasView />} />
{/* Study pages - with sidebar layout */} {/* Study pages - with sidebar layout */}
<Route element={<MainLayout />}> <Route element={<MainLayout />}>

View File

@@ -26,8 +26,8 @@ interface DesignVariable {
name: string; name: string;
parameter?: string; // Optional: the actual parameter name if different from name parameter?: string; // Optional: the actual parameter name if different from name
unit?: string; unit?: string;
min: number; min?: number;
max: number; max?: number;
} }
interface Constraint { interface Constraint {

View File

@@ -8,14 +8,15 @@ import { ScatterChart, Scatter, Line, XAxis, YAxis, CartesianGrid, Tooltip, Cell
interface ParetoTrial { interface ParetoTrial {
trial_number: number; trial_number: number;
values: [number, number]; values: number[]; // Support variable number of objectives
params: Record<string, number>; params: Record<string, number>;
constraint_satisfied?: boolean; constraint_satisfied?: boolean;
} }
interface Objective { interface Objective {
name: string; name: string;
type: 'minimize' | 'maximize'; type?: 'minimize' | 'maximize';
direction?: 'minimize' | 'maximize'; // Alternative field used by some configs
unit?: string; unit?: string;
} }

View File

@@ -1,3 +1,19 @@
/**
* @deprecated This component is deprecated as of January 2026.
* Use SpecRenderer instead, which works with AtomizerSpec v2.0.
*
* Migration guide:
* - Replace <AtomizerCanvas studyId="..." /> with <SpecRenderer studyId="..." />
* - Use useSpecStore instead of useCanvasStore for state management
* - Spec mode uses atomizer_spec.json instead of optimization_config.json
*
* This component is kept for emergency fallback only. Enable legacy mode
* by setting VITE_USE_LEGACY_CANVAS=true in your environment.
*
* @see SpecRenderer for the new implementation
* @see useSpecStore for the new state management
*/
import { useCallback, useRef, useState, useEffect, DragEvent } from 'react'; import { useCallback, useRef, useState, useEffect, DragEvent } from 'react';
import ReactFlow, { import ReactFlow, {
Background, Background,
@@ -8,7 +24,6 @@ import ReactFlow, {
Edge, Edge,
} from 'reactflow'; } from 'reactflow';
import 'reactflow/dist/style.css'; import 'reactflow/dist/style.css';
import { MessageCircle, Plug, X, AlertCircle, RefreshCw } from 'lucide-react';
import { nodeTypes } from './nodes'; import { nodeTypes } from './nodes';
import { NodePalette } from './palette/NodePalette'; import { NodePalette } from './palette/NodePalette';
@@ -16,15 +31,21 @@ import { NodeConfigPanel } from './panels/NodeConfigPanel';
import { ValidationPanel } from './panels/ValidationPanel'; import { ValidationPanel } from './panels/ValidationPanel';
import { ExecuteDialog } from './panels/ExecuteDialog'; import { ExecuteDialog } from './panels/ExecuteDialog';
import { useCanvasStore } from '../../hooks/useCanvasStore'; import { useCanvasStore } from '../../hooks/useCanvasStore';
import { useCanvasChat } from '../../hooks/useCanvasChat';
import { NodeType } from '../../lib/canvas/schema'; import { NodeType } from '../../lib/canvas/schema';
import { ChatPanel } from './panels/ChatPanel';
function CanvasFlow() { interface CanvasFlowProps {
initialStudyId?: string;
initialStudyPath?: string;
onStudyChange?: (studyId: string) => void;
}
function CanvasFlow({ initialStudyId, initialStudyPath, onStudyChange }: CanvasFlowProps) {
const reactFlowWrapper = useRef<HTMLDivElement>(null); const reactFlowWrapper = useRef<HTMLDivElement>(null);
const reactFlowInstance = useRef<ReactFlowInstance | null>(null); const reactFlowInstance = useRef<ReactFlowInstance | null>(null);
const [showExecuteDialog, setShowExecuteDialog] = useState(false); const [showExecuteDialog, setShowExecuteDialog] = useState(false);
const [showChat, setShowChat] = useState(false); const [studyId, setStudyId] = useState<string | null>(initialStudyId || null);
const [studyPath, setStudyPath] = useState<string | null>(initialStudyPath || null);
const [isExecuting, setIsExecuting] = useState(false);
const { const {
nodes, nodes,
@@ -41,32 +62,38 @@ function CanvasFlow() {
validation, validation,
validate, validate,
toIntent, toIntent,
loadFromConfig,
} = useCanvasStore(); } = useCanvasStore();
const [chatError, setChatError] = useState<string | null>(null); const [isLoadingStudy, setIsLoadingStudy] = useState(false);
const [loadError, setLoadError] = useState<string | null>(null);
const { // Load a study config into the canvas
messages, const handleLoadStudy = async () => {
isThinking, if (!studyId) return;
isExecuting,
isConnected,
executeIntent,
validateIntent,
analyzeIntent,
sendMessage,
} = useCanvasChat({
onError: (error) => {
console.error('Canvas chat error:', error);
setChatError(error);
},
});
const handleReconnect = useCallback(() => { setIsLoadingStudy(true);
setChatError(null); setLoadError(null);
// Force refresh chat connection by toggling panel try {
setShowChat(false); const response = await fetch(`/api/optimization/studies/${encodeURIComponent(studyId)}/config`);
setTimeout(() => setShowChat(true), 100); if (!response.ok) {
}, []); throw new Error(`Failed to load study: ${response.status}`);
}
const data = await response.json();
loadFromConfig(data.config);
setStudyPath(data.path);
// Notify parent of study change (for URL updates)
if (onStudyChange) {
onStudyChange(studyId);
}
} catch (error) {
console.error('Failed to load study:', error);
setLoadError(error instanceof Error ? error.message : 'Failed to load study');
} finally {
setIsLoadingStudy(false);
}
};
const onDragOver = useCallback((event: DragEvent) => { const onDragOver = useCallback((event: DragEvent) => {
event.preventDefault(); event.preventDefault();
@@ -80,7 +107,6 @@ function CanvasFlow() {
const type = event.dataTransfer.getData('application/reactflow') as NodeType; const type = event.dataTransfer.getData('application/reactflow') as NodeType;
if (!type || !reactFlowInstance.current) return; if (!type || !reactFlowInstance.current) return;
// screenToFlowPosition expects screen coordinates directly
const position = reactFlowInstance.current.screenToFlowPosition({ const position = reactFlowInstance.current.screenToFlowPosition({
x: event.clientX, x: event.clientX,
y: event.clientY, y: event.clientY,
@@ -114,7 +140,6 @@ function CanvasFlow() {
useEffect(() => { useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Delete' || event.key === 'Backspace') { if (event.key === 'Delete' || event.key === 'Backspace') {
// Don't delete if focus is on an input
const target = event.target as HTMLElement; const target = event.target as HTMLElement;
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) { if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
return; return;
@@ -128,22 +153,7 @@ function CanvasFlow() {
}, [deleteSelected]); }, [deleteSelected]);
const handleValidate = () => { const handleValidate = () => {
const result = validate(); validate();
if (result.valid) {
// Also send to Claude for intelligent feedback
const intent = toIntent();
validateIntent(intent);
setShowChat(true);
}
};
const handleAnalyze = () => {
const result = validate();
if (result.valid) {
const intent = toIntent();
analyzeIntent(intent);
setShowChat(true);
}
}; };
const handleExecuteClick = () => { const handleExecuteClick = () => {
@@ -153,12 +163,43 @@ function CanvasFlow() {
} }
}; };
const handleExecute = async (studyName: string, autoRun: boolean, _mode: 'create' | 'update', _existingStudyId?: string) => { const handleExecute = async (studyName: string, autoRun: boolean, mode: 'create' | 'update', existingStudyId?: string) => {
const intent = toIntent(); setIsExecuting(true);
// For now, both modes use the same executeIntent - backend will handle the mode distinction try {
await executeIntent(intent, studyName, autoRun); const intent = toIntent();
setShowExecuteDialog(false);
setShowChat(true); // Call API to create/update study from intent
const endpoint = mode === 'update' && existingStudyId
? `/api/optimization/studies/${encodeURIComponent(existingStudyId)}/update-from-intent`
: '/api/optimization/studies/create-from-intent';
const response = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
study_name: studyName,
intent,
auto_run: autoRun,
}),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || `Failed to ${mode} study`);
}
const result = await response.json();
setStudyId(studyName);
setStudyPath(result.path);
console.log(`Study ${mode}d:`, result);
} catch (error) {
console.error(`Failed to ${mode} study:`, error);
setLoadError(error instanceof Error ? error.message : `Failed to ${mode} study`);
} finally {
setIsExecuting(false);
setShowExecuteDialog(false);
}
}; };
return ( return (
@@ -168,6 +209,37 @@ function CanvasFlow() {
{/* Center: Canvas */} {/* Center: Canvas */}
<div className="flex-1 relative" ref={reactFlowWrapper}> <div className="flex-1 relative" ref={reactFlowWrapper}>
{/* Study Context Bar */}
<div className="absolute top-4 left-4 right-4 z-10 flex items-center gap-2">
<input
type="text"
value={studyId || ''}
onChange={(e) => setStudyId(e.target.value || null)}
placeholder="Study ID (e.g., M1_Mirror/m1_mirror_flatback)"
className="flex-1 max-w-md px-3 py-2 bg-dark-800/90 backdrop-blur border border-dark-600 text-white placeholder-dark-500 rounded-lg text-sm focus:border-primary-500 focus:outline-none"
/>
<button
onClick={handleLoadStudy}
disabled={!studyId || isLoadingStudy}
className="px-4 py-2 bg-primary-600 text-white rounded-lg text-sm hover:bg-primary-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{isLoadingStudy ? 'Loading...' : 'Load Study'}
</button>
{studyPath && (
<span className="text-xs text-dark-400 truncate max-w-xs" title={studyPath}>
{studyPath.split(/[/\\]/).slice(-2).join('/')}
</span>
)}
</div>
{/* Error Banner */}
{loadError && (
<div className="absolute top-16 left-4 right-4 z-10 px-4 py-2 bg-red-900/90 backdrop-blur border border-red-700 text-red-200 rounded-lg text-sm flex justify-between items-center">
<span>{loadError}</span>
<button onClick={() => setLoadError(null)} className="text-red-400 hover:text-red-200">×</button>
</div>
)}
<ReactFlow <ReactFlow
nodes={nodes} nodes={nodes}
edges={edges.map(e => ({ edges={edges.map(e => ({
@@ -203,44 +275,22 @@ function CanvasFlow() {
{/* Action Buttons */} {/* Action Buttons */}
<div className="absolute bottom-4 right-4 flex gap-2 z-10"> <div className="absolute bottom-4 right-4 flex gap-2 z-10">
<button
onClick={() => setShowChat(!showChat)}
className={`px-3 py-2 rounded-lg transition-colors ${
showChat
? 'bg-primary-600/20 text-primary-400 border border-primary-500/50'
: 'bg-dark-800 text-dark-300 hover:bg-dark-700 border border-dark-600'
}`}
title="Toggle Chat"
>
{isConnected ? <MessageCircle size={18} /> : <Plug size={18} />}
</button>
<button <button
onClick={handleValidate} onClick={handleValidate}
className="px-4 py-2 bg-dark-700 text-white rounded-lg hover:bg-dark-600 border border-dark-600 transition-colors" className="px-4 py-2 bg-dark-700 text-white rounded-lg hover:bg-dark-600 border border-dark-600 transition-colors"
> >
Validate Validate
</button> </button>
<button
onClick={handleAnalyze}
disabled={!validation.valid}
className={`px-4 py-2 rounded-lg transition-colors border ${
validation.valid
? 'bg-purple-600 text-white hover:bg-purple-500 border-purple-500'
: 'bg-dark-800 text-dark-500 cursor-not-allowed border-dark-700'
}`}
>
Analyze
</button>
<button <button
onClick={handleExecuteClick} onClick={handleExecuteClick}
disabled={!validation.valid} disabled={!validation.valid || isExecuting}
className={`px-4 py-2 rounded-lg transition-colors border ${ className={`px-4 py-2 rounded-lg transition-colors border ${
validation.valid validation.valid && !isExecuting
? 'bg-primary-600 text-white hover:bg-primary-500 border-primary-500' ? 'bg-primary-600 text-white hover:bg-primary-500 border-primary-500'
: 'bg-dark-800 text-dark-500 cursor-not-allowed border-dark-700' : 'bg-dark-800 text-dark-500 cursor-not-allowed border-dark-700'
}`} }`}
> >
Execute with Claude {isExecuting ? 'Creating...' : 'Create Study'}
</button> </button>
</div> </div>
@@ -250,43 +300,8 @@ function CanvasFlow() {
)} )}
</div> </div>
{/* Right: Config Panel or Chat */} {/* Right: Config Panel */}
{showChat ? ( {selectedNode && <NodeConfigPanel nodeId={selectedNode} />}
<div className="w-96 border-l border-dark-700 flex flex-col bg-dark-850">
<div className="p-3 border-b border-dark-700 flex justify-between items-center">
<h3 className="font-semibold text-white">Claude Assistant</h3>
<button
onClick={() => setShowChat(false)}
className="text-dark-400 hover:text-white transition-colors"
>
<X size={18} />
</button>
</div>
{chatError ? (
<div className="flex-1 flex flex-col items-center justify-center p-6 text-center">
<AlertCircle size={32} className="text-red-400 mb-3" />
<p className="text-white font-medium mb-1">Connection Error</p>
<p className="text-sm text-dark-400 mb-4">{chatError}</p>
<button
onClick={handleReconnect}
className="flex items-center gap-2 px-4 py-2 bg-primary-600 hover:bg-primary-500 text-white rounded-lg transition-colors"
>
<RefreshCw size={16} />
Reconnect
</button>
</div>
) : (
<ChatPanel
messages={messages}
isThinking={isThinking || isExecuting}
onSendMessage={sendMessage}
isConnected={isConnected}
/>
)}
</div>
) : selectedNode ? (
<NodeConfigPanel nodeId={selectedNode} />
) : null}
{/* Execute Dialog */} {/* Execute Dialog */}
<ExecuteDialog <ExecuteDialog
@@ -299,10 +314,20 @@ function CanvasFlow() {
); );
} }
export function AtomizerCanvas() { interface AtomizerCanvasProps {
studyId?: string;
studyPath?: string;
onStudyChange?: (studyId: string) => void;
}
export function AtomizerCanvas({ studyId, studyPath, onStudyChange }: AtomizerCanvasProps = {}) {
return ( return (
<ReactFlowProvider> <ReactFlowProvider>
<CanvasFlow /> <CanvasFlow
initialStudyId={studyId}
initialStudyPath={studyPath}
onStudyChange={onStudyChange}
/>
</ReactFlowProvider> </ReactFlowProvider>
); );
} }

View File

@@ -0,0 +1,49 @@
/**
* ConnectionStatusIndicator - Visual indicator for WebSocket connection status.
*/
import { ConnectionStatus } from '../../hooks/useSpecWebSocket';
interface ConnectionStatusIndicatorProps {
status: ConnectionStatus;
className?: string;
}
/**
* Visual indicator for WebSocket connection status.
* Can be used in the canvas UI to show sync state.
*/
export function ConnectionStatusIndicator({
status,
className = '',
}: ConnectionStatusIndicatorProps) {
const statusConfig = {
disconnected: {
color: 'bg-gray-500',
label: 'Disconnected',
},
connecting: {
color: 'bg-yellow-500 animate-pulse',
label: 'Connecting...',
},
connected: {
color: 'bg-green-500',
label: 'Connected',
},
reconnecting: {
color: 'bg-yellow-500 animate-pulse',
label: 'Reconnecting...',
},
};
const config = statusConfig[status];
return (
<div className={`flex items-center gap-2 ${className}`}>
<div className={`w-2 h-2 rounded-full ${config.color}`} />
<span className="text-xs text-dark-400">{config.label}</span>
</div>
);
}
export default ConnectionStatusIndicator;

View File

@@ -0,0 +1,67 @@
/**
* ResizeHandle - Visual drag handle for resizable panels
*
* A thin vertical bar that can be dragged to resize panels.
* Shows visual feedback on hover and during drag.
*/
import { memo } from 'react';
interface ResizeHandleProps {
/** Mouse down handler to start dragging */
onMouseDown: (e: React.MouseEvent) => void;
/** Double click handler to reset size */
onDoubleClick?: () => void;
/** Whether panel is currently being dragged */
isDragging?: boolean;
/** Position of the handle ('left' or 'right' edge of the panel) */
position?: 'left' | 'right';
}
function ResizeHandleComponent({
onMouseDown,
onDoubleClick,
isDragging = false,
position = 'right',
}: ResizeHandleProps) {
return (
<div
className={`
absolute top-0 bottom-0 w-1 z-30
cursor-col-resize
transition-colors duration-150
${position === 'right' ? 'right-0' : 'left-0'}
${isDragging
? 'bg-primary-500'
: 'bg-transparent hover:bg-primary-500/50'
}
`}
onMouseDown={onMouseDown}
onDoubleClick={onDoubleClick}
title="Drag to resize, double-click to reset"
>
{/* Wider hit area for easier grabbing */}
<div
className={`
absolute top-0 bottom-0 w-3
${position === 'right' ? '-left-1' : '-right-1'}
`}
/>
{/* Visual indicator dots (shown on hover via CSS) */}
<div className={`
absolute top-1/2 -translate-y-1/2
${position === 'right' ? '-left-0.5' : '-right-0.5'}
flex flex-col gap-1 opacity-0 hover:opacity-100 transition-opacity
${isDragging ? 'opacity-100' : ''}
`}>
<div className="w-1 h-1 rounded-full bg-dark-400" />
<div className="w-1 h-1 rounded-full bg-dark-400" />
<div className="w-1 h-1 rounded-full bg-dark-400" />
</div>
</div>
);
}
export const ResizeHandle = memo(ResizeHandleComponent);
export default ResizeHandle;

View File

@@ -0,0 +1,936 @@
/**
* SpecRenderer - ReactFlow canvas that renders from AtomizerSpec v2.0
*
* This component replaces the legacy canvas approach with a spec-driven architecture:
* - Reads from useSpecStore instead of useCanvasStore
* - Converts spec to ReactFlow nodes/edges using spec converters
* - All changes flow through the spec store and sync with backend
* - Supports WebSocket real-time updates
*
* P2.7-P2.10: SpecRenderer component with node/edge/selection handling
*/
import { useCallback, useRef, useEffect, useMemo, useState, DragEvent } from 'react';
import { Play, Square, Loader2, Eye, EyeOff, CheckCircle, AlertCircle } from 'lucide-react';
import ReactFlow, {
Background,
Controls,
MiniMap,
ReactFlowProvider,
ReactFlowInstance,
Edge,
Node,
NodeChange,
EdgeChange,
Connection,
applyNodeChanges,
} from 'reactflow';
import 'reactflow/dist/style.css';
import { nodeTypes } from './nodes';
import { specToNodes, specToEdges } from '../../lib/spec';
import {
useSpecStore,
useSpec,
useSpecLoading,
useSpecError,
useSelectedNodeId,
useSelectedEdgeId,
} from '../../hooks/useSpecStore';
import { useSpecWebSocket } from '../../hooks/useSpecWebSocket';
import { usePanelStore } from '../../hooks/usePanelStore';
import { useOptimizationStream } from '../../hooks/useOptimizationStream';
import { ConnectionStatusIndicator } from './ConnectionStatusIndicator';
import { ProgressRing } from './visualization/ConvergenceSparkline';
import { CanvasNodeData } from '../../lib/canvas/schema';
import { validateSpec, canRunOptimization } from '../../lib/validation/specValidator';
// ============================================================================
// Drag-Drop Helpers
// ============================================================================
import { SINGLETON_TYPES } from './palette/NodePalette';
/** All node types that can be added via drag-drop */
const ADDABLE_NODE_TYPES = ['model', 'solver', 'designVar', 'extractor', 'objective', 'constraint', 'algorithm', 'surrogate'] as const;
type AddableNodeType = typeof ADDABLE_NODE_TYPES[number];
function isAddableNodeType(type: string): type is AddableNodeType {
return ADDABLE_NODE_TYPES.includes(type as AddableNodeType);
}
/** Check if a node type is a singleton (only one allowed) */
function isSingletonType(type: string): boolean {
return SINGLETON_TYPES.includes(type as typeof SINGLETON_TYPES[number]);
}
/** Maps canvas NodeType to spec API type */
function mapNodeTypeToSpecType(type: AddableNodeType): 'designVar' | 'extractor' | 'objective' | 'constraint' | 'model' | 'solver' | 'algorithm' | 'surrogate' {
return type;
}
/** Creates default data for a new node of the given type */
function getDefaultNodeData(type: AddableNodeType, position: { x: number; y: number }): Record<string, unknown> {
const timestamp = Date.now();
switch (type) {
case 'model':
return {
name: 'Model',
sim: {
path: '',
solver: 'nastran',
},
canvas_position: position,
};
case 'solver':
return {
name: 'Solver',
engine: 'nxnastran',
solution_type: 'SOL101',
canvas_position: position,
};
case 'designVar':
return {
name: `variable_${timestamp}`,
expression_name: `expr_${timestamp}`,
type: 'continuous',
bounds: { min: 0, max: 1 },
baseline: 0.5,
enabled: true,
canvas_position: position,
};
case 'extractor':
return {
name: `extractor_${timestamp}`,
type: 'custom_function', // Must be valid ExtractorType
builtin: false,
enabled: true,
// Custom function extractors need a function definition
function: {
name: 'extract',
source_code: `def extract(op2_path: str, config: dict = None) -> dict:
"""
Custom extractor function.
Args:
op2_path: Path to the OP2 results file
config: Optional configuration dict
Returns:
Dictionary with extracted values
"""
# TODO: Implement extraction logic
return {'value': 0.0}
`,
},
outputs: [{ name: 'value', metric: 'custom' }],
canvas_position: position,
};
case 'objective':
return {
name: `objective_${timestamp}`,
direction: 'minimize',
weight: 1.0,
// Source is required - use placeholder that user must configure
source: {
extractor_id: 'ext_001', // Placeholder - user needs to configure
output_name: 'value',
},
canvas_position: position,
};
case 'constraint':
return {
name: `constraint_${timestamp}`,
type: 'hard', // Must be 'hard' or 'soft' (field is 'type' not 'constraint_type')
operator: '<=',
threshold: 1.0, // Field is 'threshold' not 'limit'
// Source is required
source: {
extractor_id: 'ext_001', // Placeholder - user needs to configure
output_name: 'value',
},
enabled: true,
canvas_position: position,
};
case 'algorithm':
return {
name: 'Algorithm',
type: 'TPE',
budget: {
max_trials: 100,
},
canvas_position: position,
};
case 'surrogate':
return {
name: 'Surrogate',
enabled: false,
model_type: 'MLP',
min_trials: 20,
canvas_position: position,
};
}
}
// ============================================================================
// Component Props
// ============================================================================
interface SpecRendererProps {
/**
* Optional study ID to load on mount.
* If not provided, assumes spec is already loaded in the store.
*/
studyId?: string;
/**
* Callback when study changes (for URL updates)
*/
onStudyChange?: (studyId: string) => void;
/**
* Show loading overlay while spec is loading
*/
showLoadingOverlay?: boolean;
/**
* Enable/disable editing (drag, connect, delete)
*/
editable?: boolean;
/**
* Enable real-time WebSocket sync (default: true)
*/
enableWebSocket?: boolean;
/**
* Show connection status indicator (default: true when WebSocket enabled)
*/
showConnectionStatus?: boolean;
}
function SpecRendererInner({
studyId,
onStudyChange,
showLoadingOverlay = true,
editable = true,
enableWebSocket = true,
showConnectionStatus = true,
}: SpecRendererProps) {
const reactFlowWrapper = useRef<HTMLDivElement>(null);
const reactFlowInstance = useRef<ReactFlowInstance | null>(null);
// Spec store state and actions
const spec = useSpec();
const isLoading = useSpecLoading();
const error = useSpecError();
const selectedNodeId = useSelectedNodeId();
const selectedEdgeId = useSelectedEdgeId();
const {
loadSpec,
selectNode,
selectEdge,
clearSelection,
updateNodePosition,
addNode,
addEdge,
removeEdge,
removeNode,
setError,
} = useSpecStore();
// WebSocket for real-time sync
const storeStudyId = useSpecStore((s) => s.studyId);
const wsStudyId = enableWebSocket ? storeStudyId : null;
const { status: wsStatus } = useSpecWebSocket(wsStudyId);
// Panel store for validation and error panels
const { setValidationData, addError, openPanel } = usePanelStore();
// Optimization WebSocket stream for real-time updates
const {
status: optimizationStatus,
progress: wsProgress,
bestTrial: wsBestTrial,
recentTrials,
} = useOptimizationStream(studyId, {
autoReportErrors: true,
onTrialComplete: (trial) => {
console.log('[SpecRenderer] Trial completed:', trial.trial_number);
},
onNewBest: (best) => {
console.log('[SpecRenderer] New best found:', best.value);
setShowResults(true); // Auto-show results when new best found
},
});
// Optimization execution state
const isRunning = optimizationStatus === 'running';
const [isStarting, setIsStarting] = useState(false);
const [showResults, setShowResults] = useState(false);
const [validationStatus, setValidationStatus] = useState<'valid' | 'invalid' | 'unchecked'>('unchecked');
// Build trial history for sparklines (extract objective values from recent trials)
const trialHistory = useMemo(() => {
const history: Record<string, number[]> = {};
for (const trial of recentTrials) {
// Map objective values - assumes single objective for now
if (trial.objective !== null) {
const key = 'primary';
if (!history[key]) history[key] = [];
history[key].push(trial.objective);
}
// Could also extract individual params/results for multi-objective
}
// Reverse so oldest is first (for sparkline)
for (const key of Object.keys(history)) {
history[key].reverse();
}
return history;
}, [recentTrials]);
// Build best trial data for node display
const bestTrial = useMemo((): {
trial_number: number;
objective: number;
design_variables: Record<string, number>;
results: Record<string, number>;
} | null => {
if (!wsBestTrial) return null;
return {
trial_number: wsBestTrial.trial_number,
objective: wsBestTrial.value,
design_variables: wsBestTrial.params,
results: { primary: wsBestTrial.value, ...wsBestTrial.params },
};
}, [wsBestTrial]);
// Note: Polling removed - now using WebSocket via useOptimizationStream hook
// The hook handles: status updates, best trial updates, error reporting
// Validate the spec and show results in panel
const handleValidate = useCallback(() => {
if (!spec) return;
const result = validateSpec(spec);
setValidationData(result);
setValidationStatus(result.valid ? 'valid' : 'invalid');
// Auto-open validation panel if there are issues
if (!result.valid || result.warnings.length > 0) {
openPanel('validation');
}
return result;
}, [spec, setValidationData, openPanel]);
const handleRun = async () => {
if (!studyId || !spec) return;
// Validate before running
const validation = handleValidate();
if (!validation || !validation.valid) {
// Show validation panel with errors
return;
}
// Also do a quick sanity check
const { canRun, reason } = canRunOptimization(spec);
if (!canRun) {
addError({
type: 'config_error',
message: reason || 'Cannot run optimization',
recoverable: false,
suggestions: ['Check the validation panel for details'],
timestamp: Date.now(),
});
return;
}
setIsStarting(true);
try {
const res = await fetch(`/api/optimization/studies/${studyId}/run`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ trials: spec?.optimization?.budget?.max_trials || 50 })
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.detail || 'Failed to start');
}
// isRunning is now derived from WebSocket state (optimizationStatus === 'running')
setValidationStatus('unchecked'); // Clear validation status when running
} catch (e) {
const errorMessage = e instanceof Error ? e.message : 'Failed to start optimization';
setError(errorMessage);
// Also add to error panel for persistence
addError({
type: 'system_error',
message: errorMessage,
recoverable: true,
suggestions: ['Check if the backend is running', 'Verify the study configuration'],
timestamp: Date.now(),
});
} finally {
setIsStarting(false);
}
};
const handleStop = async () => {
if (!studyId) return;
try {
const res = await fetch(`/api/optimization/studies/${studyId}/stop`, { method: 'POST' });
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.detail || 'Failed to stop');
}
// isRunning will update via WebSocket when optimization actually stops
} catch (e) {
const errorMessage = e instanceof Error ? e.message : 'Failed to stop optimization';
setError(errorMessage);
addError({
type: 'system_error',
message: errorMessage,
recoverable: false,
suggestions: ['The optimization may still be running in the background'],
timestamp: Date.now(),
});
}
};
// Load spec on mount if studyId provided
useEffect(() => {
if (studyId) {
loadSpec(studyId).then(() => {
if (onStudyChange) {
onStudyChange(studyId);
}
});
}
}, [studyId, loadSpec, onStudyChange]);
// Convert spec to ReactFlow nodes
const nodes = useMemo(() => {
const baseNodes = specToNodes(spec);
// Always map nodes to include history for sparklines (even if not showing results)
return baseNodes.map(node => {
// Create a mutable copy with explicit any type for dynamic property assignment
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const newData: any = { ...node.data };
// Add history for sparklines on objective nodes
if (node.type === 'objective') {
newData.history = trialHistory['primary'] || [];
}
// Map results to nodes when showing results
if (showResults && bestTrial) {
if (node.type === 'designVar' && newData.expressionName) {
const val = bestTrial.design_variables?.[newData.expressionName];
if (val !== undefined) newData.resultValue = val;
} else if (node.type === 'objective') {
const outputName = newData.outputName;
if (outputName && bestTrial.results?.[outputName] !== undefined) {
newData.resultValue = bestTrial.results[outputName];
}
} else if (node.type === 'constraint') {
const outputName = newData.outputName;
if (outputName && bestTrial.results?.[outputName] !== undefined) {
const val = bestTrial.results[outputName];
newData.resultValue = val;
// Check feasibility
const op = newData.operator;
const threshold = newData.value;
if (op === '<=' && threshold !== undefined) newData.isFeasible = val <= threshold;
else if (op === '>=' && threshold !== undefined) newData.isFeasible = val >= threshold;
else if (op === '<' && threshold !== undefined) newData.isFeasible = val < threshold;
else if (op === '>' && threshold !== undefined) newData.isFeasible = val > threshold;
else if (op === '==' && threshold !== undefined) newData.isFeasible = Math.abs(val - threshold) < 1e-6;
}
} else if (node.type === 'extractor') {
const outputNames = newData.outputNames;
if (outputNames && outputNames.length > 0 && bestTrial.results) {
const firstOut = outputNames[0];
if (bestTrial.results[firstOut] !== undefined) {
newData.resultValue = bestTrial.results[firstOut];
}
}
}
}
return { ...node, data: newData };
});
}, [spec, showResults, bestTrial, trialHistory]);
// Convert spec to ReactFlow edges with selection styling
const edges = useMemo(() => {
const baseEdges = specToEdges(spec);
return baseEdges.map((edge) => ({
...edge,
style: {
stroke: edge.id === selectedEdgeId ? '#60a5fa' : '#6b7280',
strokeWidth: edge.id === selectedEdgeId ? 3 : 2,
},
animated: edge.id === selectedEdgeId,
}));
}, [spec, selectedEdgeId]);
// Track node positions for change handling
const nodesRef = useRef<Node<CanvasNodeData>[]>(nodes);
useEffect(() => {
nodesRef.current = nodes;
}, [nodes]);
// Track local node state for smooth dragging
const [localNodes, setLocalNodes] = useState(nodes);
// Sync local nodes with spec-derived nodes when spec changes
useEffect(() => {
setLocalNodes(nodes);
}, [nodes]);
// Handle node position changes
const onNodesChange = useCallback(
(changes: NodeChange[]) => {
if (!editable) return;
// Apply changes to local state for smooth dragging
setLocalNodes((nds) => applyNodeChanges(changes, nds));
// Handle position changes - save to spec when drag ends
for (const change of changes) {
if (change.type === 'position' && change.position && change.dragging === false) {
// Dragging ended - update spec
updateNodePosition(change.id, {
x: change.position.x,
y: change.position.y,
});
}
}
},
[editable, updateNodePosition]
);
// Handle edge changes (deletion)
const onEdgesChange = useCallback(
(changes: EdgeChange[]) => {
if (!editable) return;
for (const change of changes) {
if (change.type === 'remove') {
// Find the edge being removed
const edge = edges.find((e) => e.id === change.id);
if (edge) {
removeEdge(edge.source, edge.target).catch((err) => {
console.error('Failed to remove edge:', err);
setError(err.message);
});
}
}
}
},
[editable, edges, removeEdge, setError]
);
// Handle new connections
const onConnect = useCallback(
(connection: Connection) => {
if (!editable) return;
if (!connection.source || !connection.target) return;
addEdge(connection.source, connection.target).catch((err) => {
console.error('Failed to add edge:', err);
setError(err.message);
});
},
[editable, addEdge, setError]
);
// Handle node clicks for selection
const onNodeClick = useCallback(
(_: React.MouseEvent, node: { id: string }) => {
selectNode(node.id);
},
[selectNode]
);
// Handle edge clicks for selection
const onEdgeClick = useCallback(
(_: React.MouseEvent, edge: Edge) => {
selectEdge(edge.id);
},
[selectEdge]
);
// Handle pane clicks to clear selection
const onPaneClick = useCallback(() => {
clearSelection();
}, [clearSelection]);
// Keyboard handler for Delete/Backspace
useEffect(() => {
if (!editable) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Delete' || event.key === 'Backspace') {
const target = event.target as HTMLElement;
if (
target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.isContentEditable
) {
return;
}
// Delete selected edge first
if (selectedEdgeId) {
const edge = edges.find((e) => e.id === selectedEdgeId);
if (edge) {
removeEdge(edge.source, edge.target).catch((err) => {
console.error('Failed to delete edge:', err);
setError(err.message);
});
}
return;
}
// Delete selected node
if (selectedNodeId) {
// Don't allow deleting synthetic nodes (model, solver, optimization)
if (['model', 'solver', 'optimization', 'surrogate'].includes(selectedNodeId)) {
return;
}
removeNode(selectedNodeId).catch((err) => {
console.error('Failed to delete node:', err);
setError(err.message);
});
}
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [editable, selectedNodeId, selectedEdgeId, edges, removeNode, removeEdge, setError]);
// =========================================================================
// Drag-Drop Handlers
// =========================================================================
const onDragOver = useCallback(
(event: DragEvent) => {
if (!editable) return;
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
},
[editable]
);
const onDrop = useCallback(
async (event: DragEvent) => {
if (!editable || !reactFlowInstance.current) return;
event.preventDefault();
const type = event.dataTransfer.getData('application/reactflow');
if (!type || !isAddableNodeType(type)) {
console.warn('Invalid or non-addable node type dropped:', type);
return;
}
// Check if this is a singleton type that already exists
if (isSingletonType(type)) {
const existingNode = localNodes.find(n => n.type === type);
if (existingNode) {
// Select the existing node instead of creating a duplicate
selectNode(existingNode.id);
// Show a toast notification would be nice here
console.log(`${type} already exists - selected existing node`);
return;
}
}
// Convert screen position to flow position
const position = reactFlowInstance.current.screenToFlowPosition({
x: event.clientX,
y: event.clientY,
});
// Create default data for the node
const nodeData = getDefaultNodeData(type, position);
const specType = mapNodeTypeToSpecType(type);
// For structural types (model, solver, algorithm, surrogate), these are
// part of the spec structure rather than array items. Handle differently.
const structuralTypes = ['model', 'solver', 'algorithm', 'surrogate'];
if (structuralTypes.includes(type)) {
// These nodes are derived from spec structure - they shouldn't be "added"
// They already exist if the spec has that section configured
console.log(`${type} is a structural node - configure via spec directly`);
setError(`${type} nodes are configured via the spec. Use the config panel to edit.`);
return;
}
try {
const nodeId = await addNode(specType as 'designVar' | 'extractor' | 'objective' | 'constraint', nodeData);
// Select the newly created node
selectNode(nodeId);
} catch (err) {
console.error('Failed to add node:', err);
setError(err instanceof Error ? err.message : 'Failed to add node');
}
},
[editable, addNode, selectNode, setError, localNodes]
);
// Loading state
if (showLoadingOverlay && isLoading && !spec) {
return (
<div className="flex-1 flex items-center justify-center bg-dark-900">
<div className="text-center">
<div className="animate-spin w-8 h-8 border-2 border-primary-500 border-t-transparent rounded-full mx-auto mb-4" />
<p className="text-dark-400">Loading spec...</p>
</div>
</div>
);
}
// Error state
if (error && !spec) {
return (
<div className="flex-1 flex items-center justify-center bg-dark-900">
<div className="text-center max-w-md">
<div className="w-12 h-12 rounded-full bg-red-900/50 flex items-center justify-center mx-auto mb-4">
<svg className="w-6 h-6 text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<h3 className="text-lg font-medium text-white mb-2">Failed to load spec</h3>
<p className="text-dark-400 mb-4">{error}</p>
{studyId && (
<button
onClick={() => loadSpec(studyId)}
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-500 transition-colors"
>
Retry
</button>
)}
</div>
</div>
);
}
// Empty state
if (!spec) {
return (
<div className="flex-1 flex items-center justify-center bg-dark-900">
<div className="text-center">
<p className="text-dark-400">No spec loaded</p>
<p className="text-dark-500 text-sm mt-2">Load a study to see its optimization configuration</p>
</div>
</div>
);
}
return (
<div className="w-full h-full relative" ref={reactFlowWrapper}>
{/* Status indicators (overlay) */}
<div className="absolute top-4 right-4 z-20 flex items-center gap-2">
{/* WebSocket connection status */}
{enableWebSocket && showConnectionStatus && (
<div className="px-3 py-1.5 bg-dark-800/90 backdrop-blur rounded-lg border border-dark-600">
<ConnectionStatusIndicator status={wsStatus} />
</div>
)}
{/* Loading indicator */}
{isLoading && (
<div className="flex items-center gap-2 px-3 py-1.5 bg-dark-800/90 backdrop-blur rounded-lg border border-dark-600">
<div className="animate-spin w-4 h-4 border-2 border-primary-500 border-t-transparent rounded-full" />
<span className="text-xs text-dark-300">Syncing...</span>
</div>
)}
</div>
{/* Error banner (overlay) */}
{error && (
<div className="absolute top-4 left-4 right-20 z-20 px-4 py-2 bg-red-900/90 backdrop-blur border border-red-700 text-red-200 rounded-lg text-sm flex justify-between items-center">
<span>{error}</span>
<button
onClick={() => setError(null)}
className="text-red-400 hover:text-red-200 ml-2"
>
×
</button>
</div>
)}
<ReactFlow
nodes={localNodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
onInit={(instance) => {
reactFlowInstance.current = instance;
}}
onDragOver={onDragOver}
onDrop={onDrop}
onNodeClick={onNodeClick}
onEdgeClick={onEdgeClick}
onPaneClick={onPaneClick}
nodeTypes={nodeTypes}
fitView
deleteKeyCode={null} // We handle delete ourselves
nodesDraggable={editable}
nodesConnectable={editable}
elementsSelectable={true}
className="bg-dark-900"
>
<Background color="#374151" gap={20} />
<Controls className="!bg-dark-800 !border-dark-600 !rounded-lg [&>button]:!bg-dark-700 [&>button]:!border-dark-600 [&>button]:!fill-dark-300 [&>button:hover]:!bg-dark-600" />
<MiniMap
className="!bg-dark-800 !border-dark-600 !rounded-lg"
nodeColor="#4B5563"
maskColor="rgba(0, 0, 0, 0.5)"
/>
</ReactFlow>
{/* Action Buttons */}
<div className="absolute bottom-4 right-4 z-10 flex gap-2">
{/* Results toggle */}
{bestTrial && (
<button
onClick={() => setShowResults(!showResults)}
className={`flex items-center gap-2 px-3 py-2 rounded-lg transition-colors border ${
showResults
? 'bg-primary-600/90 text-white border-primary-500 hover:bg-primary-500'
: 'bg-dark-800 text-dark-300 border-dark-600 hover:text-white hover:border-dark-500'
}`}
title={showResults ? "Hide Results" : "Show Best Trial Results"}
>
{showResults ? <Eye size={16} /> : <EyeOff size={16} />}
<span className="text-sm font-medium">Results</span>
</button>
)}
{/* Validate button - shows validation status */}
<button
onClick={handleValidate}
className={`flex items-center gap-2 px-3 py-2 rounded-lg transition-colors border ${
validationStatus === 'valid'
? 'bg-green-600/20 text-green-400 border-green-500/50 hover:bg-green-600/30'
: validationStatus === 'invalid'
? 'bg-red-600/20 text-red-400 border-red-500/50 hover:bg-red-600/30'
: 'bg-dark-800 text-dark-300 border-dark-600 hover:text-white hover:border-dark-500'
}`}
title="Validate spec before running"
>
{validationStatus === 'valid' ? (
<CheckCircle size={16} />
) : validationStatus === 'invalid' ? (
<AlertCircle size={16} />
) : (
<CheckCircle size={16} />
)}
<span className="text-sm font-medium">Validate</span>
</button>
{/* Run/Stop button */}
{isRunning ? (
<button
onClick={handleStop}
className="flex items-center gap-2 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-500 shadow-lg transition-colors font-medium"
>
<Square size={16} fill="currentColor" />
Stop
</button>
) : (
<button
onClick={handleRun}
disabled={isStarting || validationStatus === 'invalid'}
className={`flex items-center gap-2 px-4 py-2 rounded-lg shadow-lg transition-colors font-medium ${
validationStatus === 'invalid'
? 'bg-dark-700 text-dark-400 cursor-not-allowed'
: 'bg-emerald-600 text-white hover:bg-emerald-500 disabled:opacity-50 disabled:cursor-not-allowed'
}`}
title={validationStatus === 'invalid' ? 'Fix validation errors first' : 'Start optimization'}
>
{isStarting ? (
<Loader2 size={16} className="animate-spin" />
) : (
<Play size={16} fill="currentColor" />
)}
Run
</button>
)}
</div>
{/* Study name badge */}
<div className="absolute bottom-4 left-4 z-10 px-3 py-1.5 bg-dark-800/90 backdrop-blur rounded-lg border border-dark-600">
<span className="text-sm text-dark-300">{spec.meta.study_name}</span>
</div>
{/* Progress indicator when running */}
{isRunning && wsProgress && (
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 z-10 flex items-center gap-3 px-4 py-2 bg-dark-800/95 backdrop-blur rounded-lg border border-dark-600 shadow-lg">
<ProgressRing
progress={wsProgress.percentage}
size={36}
strokeWidth={3}
color="#10b981"
/>
<div className="flex flex-col">
<span className="text-sm font-medium text-white">
Trial {wsProgress.current} / {wsProgress.total}
</span>
<span className="text-xs text-dark-400">
{wsProgress.fea_count > 0 && `${wsProgress.fea_count} FEA`}
{wsProgress.fea_count > 0 && wsProgress.nn_count > 0 && ' + '}
{wsProgress.nn_count > 0 && `${wsProgress.nn_count} NN`}
{wsProgress.fea_count === 0 && wsProgress.nn_count === 0 && 'Running...'}
</span>
</div>
{wsBestTrial && (
<div className="flex flex-col border-l border-dark-600 pl-3 ml-1">
<span className="text-xs text-dark-400">Best</span>
<span className="text-sm font-medium text-emerald-400">
{typeof wsBestTrial.value === 'number'
? wsBestTrial.value.toFixed(4)
: wsBestTrial.value}
</span>
</div>
)}
</div>
)}
</div>
);
}
/**
* SpecRenderer with ReactFlowProvider wrapper.
*
* Usage:
* ```tsx
* // Load spec on mount
* <SpecRenderer studyId="M1_Mirror/m1_mirror_flatback" />
*
* // Use with already-loaded spec
* const { loadSpec } = useSpecStore();
* await loadSpec('M1_Mirror/m1_mirror_flatback');
* <SpecRenderer />
* ```
*/
export function SpecRenderer(props: SpecRendererProps) {
return (
<ReactFlowProvider>
<SpecRendererInner {...props} />
</ReactFlowProvider>
);
}
export default SpecRenderer;

View File

@@ -1,5 +1,6 @@
// Main Canvas Component // Main Canvas Component
export { AtomizerCanvas } from './AtomizerCanvas'; export { AtomizerCanvas } from './AtomizerCanvas';
export { SpecRenderer } from './SpecRenderer';
// Palette // Palette
export { NodePalette } from './palette/NodePalette'; export { NodePalette } from './palette/NodePalette';

View File

@@ -2,12 +2,14 @@ import { memo } from 'react';
import { NodeProps } from 'reactflow'; import { NodeProps } from 'reactflow';
import { ShieldAlert } from 'lucide-react'; import { ShieldAlert } from 'lucide-react';
import { BaseNode } from './BaseNode'; import { BaseNode } from './BaseNode';
import { ResultBadge } from './ResultBadge';
import { ConstraintNodeData } from '../../../lib/canvas/schema'; import { ConstraintNodeData } from '../../../lib/canvas/schema';
function ConstraintNodeComponent(props: NodeProps<ConstraintNodeData>) { function ConstraintNodeComponent(props: NodeProps<ConstraintNodeData>) {
const { data } = props; const { data } = props;
return ( return (
<BaseNode {...props} icon={<ShieldAlert size={16} />} iconColor="text-amber-400"> <BaseNode {...props} icon={<ShieldAlert size={16} />} iconColor="text-amber-400">
<ResultBadge value={data.resultValue} isFeasible={data.isFeasible} />
{data.name && data.operator && data.value !== undefined {data.name && data.operator && data.value !== undefined
? `${data.name} ${data.operator} ${data.value}` ? `${data.name} ${data.operator} ${data.value}`
: 'Set constraint'} : 'Set constraint'}

View File

@@ -0,0 +1,58 @@
/**
* CustomExtractorNode - Canvas node for custom Python extractors
*
* Displays custom extractors defined with inline Python code.
* Visually distinct from builtin extractors with a code icon.
*
* P3.11: Custom extractor UI component
*/
import { memo } from 'react';
import { NodeProps } from 'reactflow';
import { Code2 } from 'lucide-react';
import { BaseNode } from './BaseNode';
export interface CustomExtractorNodeData {
type: 'customExtractor';
label: string;
configured: boolean;
extractorId?: string;
extractorName?: string;
functionName?: string;
functionSource?: string;
outputs?: Array<{ name: string; units?: string }>;
dependencies?: string[];
}
function CustomExtractorNodeComponent(props: NodeProps<CustomExtractorNodeData>) {
const { data } = props;
// Show validation status
const hasCode = !!data.functionSource?.trim();
const hasOutputs = (data.outputs?.length ?? 0) > 0;
const isConfigured = hasCode && hasOutputs;
return (
<BaseNode
{...props}
icon={<Code2 size={16} />}
iconColor={isConfigured ? 'text-violet-400' : 'text-dark-500'}
>
<div className="flex flex-col">
<span className={isConfigured ? 'text-white' : 'text-dark-400'}>
{data.extractorName || data.functionName || 'Custom Extractor'}
</span>
{!isConfigured && (
<span className="text-xs text-amber-400">Needs configuration</span>
)}
{isConfigured && data.outputs && (
<span className="text-xs text-dark-400">
{data.outputs.length} output{data.outputs.length !== 1 ? 's' : ''}
</span>
)}
</div>
</BaseNode>
);
}
export const CustomExtractorNode = memo(CustomExtractorNodeComponent);

View File

@@ -2,12 +2,14 @@ import { memo } from 'react';
import { NodeProps } from 'reactflow'; import { NodeProps } from 'reactflow';
import { SlidersHorizontal } from 'lucide-react'; import { SlidersHorizontal } from 'lucide-react';
import { BaseNode } from './BaseNode'; import { BaseNode } from './BaseNode';
import { ResultBadge } from './ResultBadge';
import { DesignVarNodeData } from '../../../lib/canvas/schema'; import { DesignVarNodeData } from '../../../lib/canvas/schema';
function DesignVarNodeComponent(props: NodeProps<DesignVarNodeData>) { function DesignVarNodeComponent(props: NodeProps<DesignVarNodeData>) {
const { data } = props; const { data } = props;
return ( return (
<BaseNode {...props} icon={<SlidersHorizontal size={16} />} iconColor="text-emerald-400" inputs={0} outputs={1}> <BaseNode {...props} icon={<SlidersHorizontal size={16} />} iconColor="text-emerald-400" inputs={0} outputs={1}>
<ResultBadge value={data.resultValue} unit={data.unit} />
{data.expressionName ? ( {data.expressionName ? (
<span className="font-mono">{data.expressionName}</span> <span className="font-mono">{data.expressionName}</span>
) : ( ) : (

View File

@@ -2,12 +2,14 @@ import { memo } from 'react';
import { NodeProps } from 'reactflow'; import { NodeProps } from 'reactflow';
import { FlaskConical } from 'lucide-react'; import { FlaskConical } from 'lucide-react';
import { BaseNode } from './BaseNode'; import { BaseNode } from './BaseNode';
import { ResultBadge } from './ResultBadge';
import { ExtractorNodeData } from '../../../lib/canvas/schema'; import { ExtractorNodeData } from '../../../lib/canvas/schema';
function ExtractorNodeComponent(props: NodeProps<ExtractorNodeData>) { function ExtractorNodeComponent(props: NodeProps<ExtractorNodeData>) {
const { data } = props; const { data } = props;
return ( return (
<BaseNode {...props} icon={<FlaskConical size={16} />} iconColor="text-cyan-400"> <BaseNode {...props} icon={<FlaskConical size={16} />} iconColor="text-cyan-400">
<ResultBadge value={data.resultValue} />
{data.extractorName || 'Select extractor'} {data.extractorName || 'Select extractor'}
</BaseNode> </BaseNode>
); );

View File

@@ -0,0 +1,260 @@
/**
* ModelNodeV2 - Enhanced model node with collapsible file dependencies
*
* Features:
* - Shows main model file (.sim)
* - Collapsible section showing related files (.prt, .fem, _i.prt)
* - Hover to reveal file path
* - Click to introspect model
* - Shows solver type badge
*/
import { memo, useState, useCallback, useEffect } from 'react';
import { NodeProps, Handle, Position } from 'reactflow';
import {
Box,
ChevronDown,
ChevronRight,
FileBox,
FileCode,
Cpu,
RefreshCw,
AlertCircle,
CheckCircle,
} from 'lucide-react';
import { ModelNodeData } from '../../../lib/canvas/schema';
interface DependentFile {
name: string;
path: string;
type: 'prt' | 'fem' | 'sim' | 'idealized' | 'other';
exists: boolean;
}
interface IntrospectionResult {
expressions: Array<{
name: string;
value: number | string;
units?: string;
formula?: string;
}>;
solver_type?: string;
dependent_files?: string[];
}
function ModelNodeV2Component(props: NodeProps<ModelNodeData>) {
const { data, selected } = props;
const [isExpanded, setIsExpanded] = useState(false);
const [dependencies, setDependencies] = useState<DependentFile[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [introspection, setIntrospection] = useState<IntrospectionResult | null>(null);
const [error, setError] = useState<string | null>(null);
// Extract filename from path
const fileName = data.filePath ? data.filePath.split(/[/\\]/).pop() : 'No file selected';
// Load dependencies when expanded
const loadDependencies = useCallback(async () => {
if (!data.filePath) return;
setIsLoading(true);
setError(null);
try {
// Call introspection API to get dependent files
const response = await fetch(
`/api/nx/introspect`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ file_path: data.filePath }),
}
);
if (!response.ok) {
throw new Error('Failed to introspect model');
}
const result = await response.json();
setIntrospection(result);
// Parse dependent files
const deps: DependentFile[] = [];
if (result.dependent_files) {
for (const filePath of result.dependent_files) {
const name = filePath.split(/[/\\]/).pop() || filePath;
const ext = name.split('.').pop()?.toLowerCase();
let type: DependentFile['type'] = 'other';
if (name.includes('_i.prt')) {
type = 'idealized';
} else if (ext === 'prt') {
type = 'prt';
} else if (ext === 'fem' || ext === 'afem') {
type = 'fem';
} else if (ext === 'sim') {
type = 'sim';
}
deps.push({
name,
path: filePath,
type,
exists: true, // Assume exists from introspection
});
}
}
setDependencies(deps);
} catch (err) {
console.error('Failed to load model dependencies:', err);
setError('Failed to introspect');
} finally {
setIsLoading(false);
}
}, [data.filePath]);
// Load on first expand
useEffect(() => {
if (isExpanded && dependencies.length === 0 && !isLoading && data.filePath) {
loadDependencies();
}
}, [isExpanded, dependencies.length, isLoading, data.filePath, loadDependencies]);
// Get icon for file type
const getFileIcon = (type: DependentFile['type']) => {
switch (type) {
case 'prt':
return <Box size={12} className="text-blue-400" />;
case 'fem':
return <FileCode size={12} className="text-emerald-400" />;
case 'sim':
return <Cpu size={12} className="text-violet-400" />;
case 'idealized':
return <Box size={12} className="text-cyan-400" />;
default:
return <FileBox size={12} className="text-dark-400" />;
}
};
return (
<div
className={`
relative rounded-xl border min-w-[200px] max-w-[280px]
bg-dark-800 shadow-xl transition-all duration-150 overflow-hidden
${selected ? 'border-primary-400 ring-2 ring-primary-400/30 shadow-primary-500/20' : 'border-dark-600'}
${!data.configured ? 'border-dashed border-dark-500' : ''}
`}
>
{/* Input handle */}
<Handle
type="target"
position={Position.Left}
className="!w-3 !h-3 !bg-dark-400 !border-2 !border-dark-600 hover:!bg-primary-400 hover:!border-primary-500 transition-colors"
/>
{/* Main content */}
<div className="px-4 py-3">
<div className="flex items-center gap-2.5">
<div className="text-blue-400 flex-shrink-0">
<Box size={16} />
</div>
<div className="flex-1 min-w-0">
<div className="font-semibold text-white text-sm truncate">
{data.label || 'Model'}
</div>
</div>
{!data.configured && (
<div className="w-2 h-2 rounded-full bg-amber-400 flex-shrink-0 animate-pulse" />
)}
</div>
{/* File info */}
<div className="mt-2 text-xs text-dark-300 truncate" title={data.filePath}>
{fileName}
</div>
{/* Solver badge */}
{introspection?.solver_type && (
<div className="mt-2 inline-flex items-center gap-1 px-2 py-0.5 rounded bg-violet-500/20 text-violet-400 text-xs">
<Cpu size={10} />
{introspection.solver_type}
</div>
)}
</div>
{/* Dependencies section (collapsible) */}
{data.filePath && (
<div className="border-t border-dark-700">
<button
onClick={() => setIsExpanded(!isExpanded)}
className="w-full px-4 py-2 flex items-center gap-2 text-xs text-dark-400 hover:text-white hover:bg-dark-700/50 transition-colors"
>
{isExpanded ? (
<ChevronDown size={12} />
) : (
<ChevronRight size={12} />
)}
<span>Dependencies</span>
{dependencies.length > 0 && (
<span className="ml-auto text-dark-500">{dependencies.length}</span>
)}
{isLoading && (
<RefreshCw size={12} className="ml-auto animate-spin text-primary-400" />
)}
</button>
{isExpanded && (
<div className="px-3 pb-3 space-y-1">
{error ? (
<div className="flex items-center gap-1 text-xs text-red-400">
<AlertCircle size={12} />
{error}
</div>
) : dependencies.length === 0 && !isLoading ? (
<div className="text-xs text-dark-500 py-1">
No dependencies found
</div>
) : (
dependencies.map((dep) => (
<div
key={dep.path}
className="flex items-center gap-2 px-2 py-1 rounded bg-dark-900/50 text-xs"
title={dep.path}
>
{getFileIcon(dep.type)}
<span className="flex-1 truncate text-dark-300">{dep.name}</span>
{dep.exists ? (
<CheckCircle size={10} className="text-emerald-400 flex-shrink-0" />
) : (
<AlertCircle size={10} className="text-amber-400 flex-shrink-0" />
)}
</div>
))
)}
{/* Expressions count */}
{introspection?.expressions && introspection.expressions.length > 0 && (
<div className="mt-2 pt-2 border-t border-dark-700">
<div className="text-xs text-dark-400">
{introspection.expressions.length} expressions found
</div>
</div>
)}
</div>
)}
</div>
)}
{/* Output handle */}
<Handle
type="source"
position={Position.Right}
className="!w-3 !h-3 !bg-dark-400 !border-2 !border-dark-600 hover:!bg-primary-400 hover:!border-primary-500 transition-colors"
/>
</div>
);
}
export const ModelNodeV2 = memo(ModelNodeV2Component);

View File

@@ -2,13 +2,38 @@ import { memo } from 'react';
import { NodeProps } from 'reactflow'; import { NodeProps } from 'reactflow';
import { Target } from 'lucide-react'; import { Target } from 'lucide-react';
import { BaseNode } from './BaseNode'; import { BaseNode } from './BaseNode';
import { ResultBadge } from './ResultBadge';
import { ConvergenceSparkline } from '../visualization/ConvergenceSparkline';
import { ObjectiveNodeData } from '../../../lib/canvas/schema'; import { ObjectiveNodeData } from '../../../lib/canvas/schema';
function ObjectiveNodeComponent(props: NodeProps<ObjectiveNodeData>) { function ObjectiveNodeComponent(props: NodeProps<ObjectiveNodeData>) {
const { data } = props; const { data } = props;
const hasHistory = data.history && data.history.length > 1;
return ( return (
<BaseNode {...props} icon={<Target size={16} />} iconColor="text-rose-400"> <BaseNode {...props} icon={<Target size={16} />} iconColor="text-rose-400">
{data.name ? `${data.direction === 'maximize' ? '↑' : '↓'} ${data.name}` : 'Set objective'} <div className="flex flex-col gap-1">
<div className="flex items-center justify-between">
<span className="text-sm">
{data.name ? `${data.direction === 'maximize' ? '↑' : '↓'} ${data.name}` : 'Set objective'}
</span>
<ResultBadge value={data.resultValue} label="Best" />
</div>
{/* Convergence sparkline */}
{hasHistory && (
<div className="mt-1 -mb-1">
<ConvergenceSparkline
values={data.history!}
width={120}
height={20}
direction={data.direction || 'minimize'}
color={data.direction === 'maximize' ? '#34d399' : '#60a5fa'}
showBest={true}
/>
</div>
)}
</div>
</BaseNode> </BaseNode>
); );
} }

View File

@@ -0,0 +1,39 @@
import { memo } from 'react';
interface ResultBadgeProps {
value: number | string | null | undefined;
unit?: string;
isFeasible?: boolean; // For constraints
label?: string;
}
export const ResultBadge = memo(function ResultBadge({ value, unit, isFeasible, label }: ResultBadgeProps) {
if (value === null || value === undefined) return null;
const displayValue = typeof value === 'number'
? value.toLocaleString(undefined, { maximumFractionDigits: 4 })
: value;
// Determine color based on feasibility (if provided)
let bgColor = 'bg-primary-500/20';
let textColor = 'text-primary-300';
let borderColor = 'border-primary-500/30';
if (isFeasible === true) {
bgColor = 'bg-emerald-500/20';
textColor = 'text-emerald-300';
borderColor = 'border-emerald-500/30';
} else if (isFeasible === false) {
bgColor = 'bg-red-500/20';
textColor = 'text-red-300';
borderColor = 'border-red-500/30';
}
return (
<div className={`absolute -top-3 -right-2 px-2 py-0.5 rounded-full border ${bgColor} ${borderColor} ${textColor} text-xs font-mono shadow-lg backdrop-blur-sm z-10 flex items-center gap-1`}>
{label && <span className="opacity-70 mr-1">{label}:</span>}
<span className="font-bold">{displayValue}</span>
{unit && <span className="opacity-70 text-[10px] ml-0.5">{unit}</span>}
</div>
);
});

View File

@@ -1,14 +1,44 @@
import { memo } from 'react'; import { memo } from 'react';
import { NodeProps } from 'reactflow'; import { NodeProps } from 'reactflow';
import { Cpu } from 'lucide-react'; import { Cpu, Terminal } from 'lucide-react';
import { BaseNode } from './BaseNode'; import { BaseNode } from './BaseNode';
import { SolverNodeData } from '../../../lib/canvas/schema'; import { SolverNodeData, SolverEngine } from '../../../lib/canvas/schema';
// Human-readable engine names
const ENGINE_LABELS: Record<SolverEngine, string> = {
nxnastran: 'NX Nastran',
mscnastran: 'MSC Nastran',
python: 'Python Script',
abaqus: 'Abaqus',
ansys: 'ANSYS',
};
function SolverNodeComponent(props: NodeProps<SolverNodeData>) { function SolverNodeComponent(props: NodeProps<SolverNodeData>) {
const { data } = props; const { data } = props;
// Build display string: "Engine - SolutionType" or just one
const engineLabel = data.engine ? ENGINE_LABELS[data.engine] : null;
const solverTypeLabel = data.solverType || null;
let displayText: string;
if (engineLabel && solverTypeLabel) {
displayText = `${engineLabel} (${solverTypeLabel})`;
} else if (engineLabel) {
displayText = engineLabel;
} else if (solverTypeLabel) {
displayText = solverTypeLabel;
} else {
displayText = 'Configure solver';
}
// Use Terminal icon for Python, Cpu for others
const icon = data.engine === 'python'
? <Terminal size={16} />
: <Cpu size={16} />;
return ( return (
<BaseNode {...props} icon={<Cpu size={16} />} iconColor="text-violet-400"> <BaseNode {...props} icon={icon} iconColor="text-violet-400">
{data.solverType || 'Select solution'} {displayText}
</BaseNode> </BaseNode>
); );
} }

View File

@@ -1,4 +1,5 @@
import { ModelNode } from './ModelNode'; import { ModelNode } from './ModelNode';
import { ModelNodeV2 } from './ModelNodeV2';
import { SolverNode } from './SolverNode'; import { SolverNode } from './SolverNode';
import { DesignVarNode } from './DesignVarNode'; import { DesignVarNode } from './DesignVarNode';
import { ExtractorNode } from './ExtractorNode'; import { ExtractorNode } from './ExtractorNode';
@@ -9,6 +10,7 @@ import { SurrogateNode } from './SurrogateNode';
export { export {
ModelNode, ModelNode,
ModelNodeV2,
SolverNode, SolverNode,
DesignVarNode, DesignVarNode,
ExtractorNode, ExtractorNode,
@@ -18,8 +20,12 @@ export {
SurrogateNode, SurrogateNode,
}; };
// Use ModelNodeV2 by default for enhanced dependency display
// Set USE_LEGACY_MODEL_NODE=true to use the original
const useEnhancedModelNode = !import.meta.env.VITE_USE_LEGACY_MODEL_NODE;
export const nodeTypes = { export const nodeTypes = {
model: ModelNode, model: useEnhancedModelNode ? ModelNodeV2 : ModelNode,
solver: SolverNode, solver: SolverNode,
designVar: DesignVarNode, designVar: DesignVarNode,
extractor: ExtractorNode, extractor: ExtractorNode,

View File

@@ -1,5 +1,15 @@
/**
* NodePalette - Draggable component library for canvas
*
* Features:
* - Draggable node items for canvas drop
* - Collapsible mode (icons only)
* - Filterable by node type
* - Works with both AtomizerCanvas and SpecRenderer
*/
import { DragEvent } from 'react'; import { DragEvent } from 'react';
import { NodeType } from '../../../lib/canvas/schema'; import { ChevronLeft, ChevronRight } from 'lucide-react';
import { import {
Box, Box,
Cpu, Cpu,
@@ -9,63 +19,240 @@ import {
ShieldAlert, ShieldAlert,
BrainCircuit, BrainCircuit,
Rocket, Rocket,
LucideIcon,
} from 'lucide-react'; } from 'lucide-react';
import { NodeType } from '../../../lib/canvas/schema';
interface PaletteItem { // ============================================================================
// Types
// ============================================================================
export interface PaletteItem {
type: NodeType; type: NodeType;
label: string; label: string;
icon: React.ReactNode; icon: LucideIcon;
description: string; description: string;
color: string; color: string;
/** Whether this can be added via drag-drop (synthetic nodes cannot) */
canAdd: boolean;
} }
const PALETTE_ITEMS: PaletteItem[] = [ export interface NodePaletteProps {
{ type: 'model', label: 'Model', icon: <Box size={18} />, description: 'NX model file (.prt, .sim)', color: 'text-blue-400' }, /** Whether palette is collapsed (icon-only mode) */
{ type: 'solver', label: 'Solver', icon: <Cpu size={18} />, description: 'Nastran solution type', color: 'text-violet-400' }, collapsed?: boolean;
{ type: 'designVar', label: 'Design Variable', icon: <SlidersHorizontal size={18} />, description: 'Parameter to optimize', color: 'text-emerald-400' }, /** Callback when collapse state changes */
{ type: 'extractor', label: 'Extractor', icon: <FlaskConical size={18} />, description: 'Physics result extraction', color: 'text-cyan-400' }, onToggleCollapse?: () => void;
{ type: 'objective', label: 'Objective', icon: <Target size={18} />, description: 'Optimization goal', color: 'text-rose-400' }, /** Custom className for container */
{ type: 'constraint', label: 'Constraint', icon: <ShieldAlert size={18} />, description: 'Design constraint', color: 'text-amber-400' }, className?: string;
{ type: 'algorithm', label: 'Algorithm', icon: <BrainCircuit size={18} />, description: 'Optimization method', color: 'text-indigo-400' }, /** Filter which node types to show */
{ type: 'surrogate', label: 'Surrogate', icon: <Rocket size={18} />, description: 'Neural acceleration', color: 'text-pink-400' }, visibleTypes?: NodeType[];
/** Show toggle button */
showToggle?: boolean;
}
// ============================================================================
// Constants
// ============================================================================
/** Singleton node types - only one of each allowed on canvas */
export const SINGLETON_TYPES: NodeType[] = ['model', 'solver', 'algorithm', 'surrogate'];
export const PALETTE_ITEMS: PaletteItem[] = [
{
type: 'model',
label: 'Model',
icon: Box,
description: 'NX model file (.prt, .sim)',
color: 'text-blue-400',
canAdd: true, // Singleton - only one allowed
},
{
type: 'solver',
label: 'Solver',
icon: Cpu,
description: 'Analysis solver config',
color: 'text-violet-400',
canAdd: true, // Singleton - only one allowed
},
{
type: 'designVar',
label: 'Design Variable',
icon: SlidersHorizontal,
description: 'Parameter to optimize',
color: 'text-emerald-400',
canAdd: true,
},
{
type: 'extractor',
label: 'Extractor',
icon: FlaskConical,
description: 'Physics result extraction',
color: 'text-cyan-400',
canAdd: true,
},
{
type: 'objective',
label: 'Objective',
icon: Target,
description: 'Optimization goal',
color: 'text-rose-400',
canAdd: true,
},
{
type: 'constraint',
label: 'Constraint',
icon: ShieldAlert,
description: 'Design constraint',
color: 'text-amber-400',
canAdd: true,
},
{
type: 'algorithm',
label: 'Algorithm',
icon: BrainCircuit,
description: 'Optimization method',
color: 'text-indigo-400',
canAdd: true, // Singleton - only one allowed
},
{
type: 'surrogate',
label: 'Surrogate',
icon: Rocket,
description: 'Neural acceleration',
color: 'text-pink-400',
canAdd: true, // Singleton - only one allowed
},
]; ];
export function NodePalette() { /** Items that can be added via drag-drop */
const onDragStart = (event: DragEvent, nodeType: NodeType) => { export const ADDABLE_ITEMS = PALETTE_ITEMS.filter(item => item.canAdd);
event.dataTransfer.setData('application/reactflow', nodeType);
// ============================================================================
// Component
// ============================================================================
export function NodePalette({
collapsed = false,
onToggleCollapse,
className = '',
visibleTypes,
showToggle = true,
}: NodePaletteProps) {
// Filter items if visibleTypes is provided
const items = visibleTypes
? PALETTE_ITEMS.filter(item => visibleTypes.includes(item.type))
: PALETTE_ITEMS;
const onDragStart = (event: DragEvent, item: PaletteItem) => {
if (!item.canAdd) {
event.preventDefault();
return;
}
event.dataTransfer.setData('application/reactflow', item.type);
event.dataTransfer.effectAllowed = 'move'; event.dataTransfer.effectAllowed = 'move';
}; };
return ( // Collapsed mode - icons only
<div className="w-60 bg-dark-850 border-r border-dark-700 flex flex-col"> if (collapsed) {
<div className="p-4 border-b border-dark-700"> return (
<h3 className="text-sm font-semibold text-dark-300 uppercase tracking-wider"> <div className={`w-14 bg-dark-850 border-r border-dark-700 flex flex-col ${className}`}>
Components {/* Toggle Button */}
</h3> {showToggle && onToggleCollapse && (
<p className="text-xs text-dark-400 mt-1"> <button
Drag to canvas onClick={onToggleCollapse}
</p> className="p-4 border-b border-dark-700 hover:bg-dark-800 transition-colors flex items-center justify-center"
</div> title="Expand palette"
<div className="flex-1 overflow-y-auto p-3 space-y-2">
{PALETTE_ITEMS.map((item) => (
<div
key={item.type}
draggable
onDragStart={(e) => onDragStart(e, item.type)}
className="flex items-center gap-3 px-3 py-3 bg-dark-800/50 rounded-lg border border-dark-700/50
cursor-grab hover:border-primary-500/50 hover:bg-dark-800
active:cursor-grabbing transition-all group"
> >
<div className={`${item.color} opacity-90 group-hover:opacity-100 transition-opacity`}> <ChevronRight size={18} className="text-dark-400" />
{item.icon} </button>
)}
{/* Collapsed Items */}
<div className="flex-1 overflow-y-auto py-2">
{items.map((item) => {
const Icon = item.icon;
const isDraggable = item.canAdd;
return (
<div
key={item.type}
draggable={isDraggable}
onDragStart={(e) => onDragStart(e, item)}
className={`p-3 mx-2 my-1 rounded-lg transition-all flex items-center justify-center
${isDraggable
? 'cursor-grab hover:bg-dark-800 active:cursor-grabbing'
: 'cursor-default opacity-50'
}`}
title={`${item.label}${!isDraggable ? ' (auto-created)' : ''}`}
>
<Icon size={18} className={item.color} />
</div>
);
})}
</div>
</div>
);
}
// Expanded mode - full display
return (
<div className={`w-60 bg-dark-850 border-r border-dark-700 flex flex-col ${className}`}>
{/* Header */}
<div className="p-4 border-b border-dark-700 flex items-center justify-between">
<div>
<h3 className="text-sm font-semibold text-dark-300 uppercase tracking-wider">
Components
</h3>
<p className="text-xs text-dark-400 mt-1">
Drag to canvas
</p>
</div>
{showToggle && onToggleCollapse && (
<button
onClick={onToggleCollapse}
className="p-1.5 rounded hover:bg-dark-800 transition-colors"
title="Collapse palette"
>
<ChevronLeft size={16} className="text-dark-400" />
</button>
)}
</div>
{/* Items */}
<div className="flex-1 overflow-y-auto p-3 space-y-2">
{items.map((item) => {
const Icon = item.icon;
const isDraggable = item.canAdd;
return (
<div
key={item.type}
draggable={isDraggable}
onDragStart={(e) => onDragStart(e, item)}
className={`flex items-center gap-3 px-3 py-3 rounded-lg border transition-all group
${isDraggable
? 'bg-dark-800/50 border-dark-700/50 cursor-grab hover:border-primary-500/50 hover:bg-dark-800 active:cursor-grabbing'
: 'bg-dark-900/30 border-dark-800/30 cursor-default'
}`}
title={!isDraggable ? 'Auto-created from study configuration' : undefined}
>
<div className={`${item.color} ${isDraggable ? 'opacity-90 group-hover:opacity-100' : 'opacity-50'} transition-opacity`}>
<Icon size={18} />
</div>
<div className="flex-1 min-w-0">
<div className={`font-semibold text-sm leading-tight ${isDraggable ? 'text-white' : 'text-dark-400'}`}>
{item.label}
</div>
<div className="text-xs text-dark-400 truncate">
{isDraggable ? item.description : 'Auto-created'}
</div>
</div>
</div> </div>
<div className="flex-1 min-w-0"> );
<div className="font-semibold text-white text-sm leading-tight">{item.label}</div> })}
<div className="text-xs text-dark-400 truncate">{item.description}</div>
</div>
</div>
))}
</div> </div>
</div> </div>
); );
} }
export default NodePalette;

View File

@@ -0,0 +1,844 @@
/**
* CodeEditorPanel - Monaco editor for custom extractor Python code
*
* Features:
* - Python syntax highlighting
* - Auto-completion for common patterns
* - Error display
* - Claude AI code generation with streaming support
* - Preview of extracted outputs
* - Code snippets library
*/
import { useState, useCallback, useRef, useEffect } from 'react';
import Editor, { OnMount, OnChange } from '@monaco-editor/react';
import {
Play,
Wand2,
Copy,
Check,
AlertCircle,
RefreshCw,
X,
ChevronDown,
ChevronRight,
FileCode,
Sparkles,
Square,
BookOpen,
FlaskConical,
} from 'lucide-react';
// Monaco editor types
type Monaco = Parameters<OnMount>[1];
type EditorInstance = Parameters<OnMount>[0];
/** Streaming generation callbacks */
export interface StreamingCallbacks {
onToken: (token: string) => void;
onComplete: (code: string, outputs: string[]) => void;
onError: (error: string) => void;
}
/** Request format for streaming generation */
export interface StreamingGenerationRequest {
prompt: string;
study_id?: string;
existing_code?: string;
output_names?: string[];
}
interface CodeEditorPanelProps {
/** Initial code content */
initialCode?: string;
/** Callback when code changes */
onChange?: (code: string) => void;
/** Callback when user requests Claude generation (non-streaming) */
onRequestGeneration?: (prompt: string) => Promise<string>;
/** Callback for streaming generation (preferred over onRequestGeneration) */
onRequestStreamingGeneration?: (
request: StreamingGenerationRequest,
callbacks: StreamingCallbacks
) => AbortController;
/** Whether the panel is read-only */
readOnly?: boolean;
/** Extractor name for context */
extractorName?: string;
/** Output variable names */
outputs?: string[];
/** Optional height (default: 300px) */
height?: number | string;
/** Show/hide header */
showHeader?: boolean;
/** Callback when running code (validation) */
onRun?: (code: string) => Promise<{ success: boolean; error?: string; outputs?: Record<string, unknown> }>;
/** Callback for live testing against OP2 file */
onTest?: (code: string) => Promise<{ success: boolean; error?: string; outputs?: Record<string, number>; execution_time_ms?: number }>;
/** Close button callback */
onClose?: () => void;
/** Study ID for context in generation */
studyId?: string;
}
// Default Python template for custom extractors
const DEFAULT_EXTRACTOR_TEMPLATE = `"""
Custom Extractor Function
This function is called after FEA simulation completes.
It receives the results and should return extracted values.
Available inputs:
- op2_path: Path to the .op2 results file
- fem_path: Path to the .fem file
- params: Dict of current design variable values
- subcase_id: Current subcase being analyzed (optional)
Return a dict with your extracted values.
"""
from pyNastran.op2.op2 import OP2
import numpy as np
def extract(op2_path: str, fem_path: str, params: dict, subcase_id: int = 1) -> dict:
"""
Extract physics from FEA results.
Args:
op2_path: Path to OP2 results file
fem_path: Path to FEM file
params: Current design variable values
subcase_id: Subcase ID to analyze
Returns:
Dict with extracted values, e.g. {'max_stress': 150.5, 'mass': 2.3}
"""
# Load OP2 results
op2 = OP2()
op2.read_op2(op2_path)
# Example: Extract max displacement
if subcase_id in op2.displacements:
disp = op2.displacements[subcase_id]
# Get magnitude of displacement vectors
magnitudes = np.sqrt(np.sum(disp.data[0, :, 1:4]**2, axis=1))
max_disp = float(np.max(magnitudes))
else:
max_disp = 0.0
return {
'max_displacement': max_disp,
# Add more outputs as needed
}
`;
// Code snippets library
interface CodeSnippet {
id: string;
name: string;
category: string;
description: string;
code: string;
}
const CODE_SNIPPETS: CodeSnippet[] = [
{
id: 'displacement',
name: 'Max Displacement',
category: 'Displacement',
description: 'Extract maximum displacement magnitude from results',
code: `"""Extract maximum displacement magnitude"""
from pyNastran.op2.op2 import OP2
import numpy as np
def extract(op2_path: str, fem_path: str, params: dict, subcase_id: int = 1) -> dict:
op2 = OP2()
op2.read_op2(op2_path)
if subcase_id in op2.displacements:
disp = op2.displacements[subcase_id]
# Displacement data: [time, node, component] where component 1-3 are x,y,z
magnitudes = np.sqrt(np.sum(disp.data[0, :, 1:4]**2, axis=1))
max_disp = float(np.max(magnitudes))
else:
max_disp = 0.0
return {'max_displacement': max_disp}
`,
},
{
id: 'stress_vonmises',
name: 'Von Mises Stress',
category: 'Stress',
description: 'Extract maximum von Mises stress from shell elements',
code: `"""Extract maximum von Mises stress from shell elements"""
from pyNastran.op2.op2 import OP2
import numpy as np
def extract(op2_path: str, fem_path: str, params: dict, subcase_id: int = 1) -> dict:
op2 = OP2()
op2.read_op2(op2_path)
max_stress = 0.0
# Check CQUAD4 elements
if subcase_id in op2.cquad4_stress:
stress = op2.cquad4_stress[subcase_id]
# Von Mises is typically in the last column
vm_stress = stress.data[0, :, -1] # [time, element, component]
max_stress = max(max_stress, float(np.max(np.abs(vm_stress))))
# Check CTRIA3 elements
if subcase_id in op2.ctria3_stress:
stress = op2.ctria3_stress[subcase_id]
vm_stress = stress.data[0, :, -1]
max_stress = max(max_stress, float(np.max(np.abs(vm_stress))))
return {'max_vonmises': max_stress}
`,
},
{
id: 'frequency',
name: 'Natural Frequency',
category: 'Modal',
description: 'Extract first natural frequency from modal analysis',
code: `"""Extract natural frequencies from modal analysis"""
from pyNastran.op2.op2 import OP2
def extract(op2_path: str, fem_path: str, params: dict, subcase_id: int = 1) -> dict:
op2 = OP2()
op2.read_op2(op2_path)
freq_1 = 0.0
freq_2 = 0.0
freq_3 = 0.0
if subcase_id in op2.eigenvalues:
eig = op2.eigenvalues[subcase_id]
freqs = eig.radians / (2 * 3.14159) # Convert to Hz
if len(freqs) >= 1:
freq_1 = float(freqs[0])
if len(freqs) >= 2:
freq_2 = float(freqs[1])
if len(freqs) >= 3:
freq_3 = float(freqs[2])
return {
'freq_1': freq_1,
'freq_2': freq_2,
'freq_3': freq_3,
}
`,
},
{
id: 'mass_grid',
name: 'Grid Point Mass',
category: 'Mass',
description: 'Extract total mass from grid point weight generator',
code: `"""Extract mass from grid point weight generator"""
from pyNastran.op2.op2 import OP2
def extract(op2_path: str, fem_path: str, params: dict, subcase_id: int = 1) -> dict:
op2 = OP2()
op2.read_op2(op2_path)
total_mass = 0.0
if hasattr(op2, 'grid_point_weight') and op2.grid_point_weight:
gpw = op2.grid_point_weight
# Mass is typically M[0,0] in the mass matrix
if hasattr(gpw, 'mass') and len(gpw.mass) > 0:
total_mass = float(gpw.mass[0])
return {'total_mass': total_mass}
`,
},
{
id: 'strain_energy',
name: 'Strain Energy',
category: 'Energy',
description: 'Extract total strain energy from elements',
code: `"""Extract strain energy from elements"""
from pyNastran.op2.op2 import OP2
import numpy as np
def extract(op2_path: str, fem_path: str, params: dict, subcase_id: int = 1) -> dict:
op2 = OP2()
op2.read_op2(op2_path)
total_energy = 0.0
# Sum strain energy from all element types
for key in dir(op2):
if 'strain_energy' in key.lower():
result = getattr(op2, key)
if isinstance(result, dict) and subcase_id in result:
se = result[subcase_id]
if hasattr(se, 'data'):
total_energy += float(np.sum(se.data))
return {'strain_energy': total_energy}
`,
},
{
id: 'reaction_force',
name: 'Reaction Forces',
category: 'Force',
description: 'Extract reaction forces at constrained nodes',
code: `"""Extract reaction forces at single point constraints"""
from pyNastran.op2.op2 import OP2
import numpy as np
def extract(op2_path: str, fem_path: str, params: dict, subcase_id: int = 1) -> dict:
op2 = OP2()
op2.read_op2(op2_path)
max_reaction = 0.0
total_reaction_z = 0.0
if subcase_id in op2.spc_forces:
spc = op2.spc_forces[subcase_id]
# SPC forces: [time, node, component] where 1-3 are Fx,Fy,Fz
forces = spc.data[0, :, 1:4] # Get Fx, Fy, Fz
magnitudes = np.sqrt(np.sum(forces**2, axis=1))
max_reaction = float(np.max(magnitudes))
total_reaction_z = float(np.sum(forces[:, 2])) # Sum of Fz
return {
'max_reaction': max_reaction,
'total_reaction_z': total_reaction_z,
}
`,
},
];
export function CodeEditorPanel({
initialCode = DEFAULT_EXTRACTOR_TEMPLATE,
onChange,
onRequestGeneration,
onRequestStreamingGeneration,
readOnly = false,
extractorName = 'custom_extractor',
outputs = [],
height = 400,
showHeader = true,
onRun,
onTest,
onClose,
studyId,
}: CodeEditorPanelProps) {
const [code, setCode] = useState(initialCode);
const [streamingCode, setStreamingCode] = useState(''); // Partial code during streaming
const [isGenerating, setIsGenerating] = useState(false);
const [isRunning, setIsRunning] = useState(false);
const [isTesting, setIsTesting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [runResult, setRunResult] = useState<{ success: boolean; outputs?: Record<string, unknown> } | null>(null);
const [testResult, setTestResult] = useState<{ success: boolean; outputs?: Record<string, number>; execution_time_ms?: number } | null>(null);
const [copied, setCopied] = useState(false);
const [showPromptInput, setShowPromptInput] = useState(false);
const [generationPrompt, setGenerationPrompt] = useState('');
const [showOutputs, setShowOutputs] = useState(true);
const [showSnippets, setShowSnippets] = useState(false);
const editorRef = useRef<EditorInstance | null>(null);
const monacoRef = useRef<Monaco | null>(null);
const abortControllerRef = useRef<AbortController | null>(null);
// Cleanup abort controller on unmount
useEffect(() => {
return () => {
abortControllerRef.current?.abort();
};
}, []);
// Handle editor mount
const handleEditorMount: OnMount = (editor, monaco) => {
editorRef.current = editor;
monacoRef.current = monaco;
// Configure Python language
monaco.languages.registerCompletionItemProvider('python', {
provideCompletionItems: (model: Parameters<typeof monaco.editor.createModel>[0], position: { lineNumber: number; column: number }) => {
const word = model.getWordUntilPosition(position);
const range = {
startLineNumber: position.lineNumber,
endLineNumber: position.lineNumber,
startColumn: word.startColumn,
endColumn: word.endColumn,
};
const suggestions = [
{
label: 'op2.read_op2',
kind: monaco.languages.CompletionItemKind.Method,
insertText: 'op2.read_op2(op2_path)',
documentation: 'Read OP2 results file',
range,
},
{
label: 'op2.displacements',
kind: monaco.languages.CompletionItemKind.Property,
insertText: 'op2.displacements[subcase_id]',
documentation: 'Access displacement results for a subcase',
range,
},
{
label: 'op2.eigenvectors',
kind: monaco.languages.CompletionItemKind.Property,
insertText: 'op2.eigenvectors[subcase_id]',
documentation: 'Access eigenvector results for modal analysis',
range,
},
{
label: 'np.max',
kind: monaco.languages.CompletionItemKind.Function,
insertText: 'np.max(${1:array})',
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
documentation: 'Get maximum value from array',
range,
},
{
label: 'np.sqrt',
kind: monaco.languages.CompletionItemKind.Function,
insertText: 'np.sqrt(${1:array})',
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
documentation: 'Square root of array elements',
range,
},
{
label: 'extract_function',
kind: monaco.languages.CompletionItemKind.Snippet,
insertText: `def extract(op2_path: str, fem_path: str, params: dict, subcase_id: int = 1) -> dict:
"""Extract physics from FEA results."""
op2 = OP2()
op2.read_op2(op2_path)
# Your extraction logic here
return {
'\${1:output_name}': \${2:value},
}`,
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
documentation: 'Insert a complete extract function template',
range,
},
];
return { suggestions };
},
});
};
// Handle code change
const handleCodeChange: OnChange = (value) => {
const newCode = value || '';
setCode(newCode);
setError(null);
setRunResult(null);
onChange?.(newCode);
};
// Copy code to clipboard
const handleCopy = useCallback(() => {
navigator.clipboard.writeText(code);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}, [code]);
// Request Claude generation (with streaming support)
const handleGenerate = useCallback(async () => {
if ((!onRequestGeneration && !onRequestStreamingGeneration) || !generationPrompt.trim()) return;
setIsGenerating(true);
setError(null);
setStreamingCode('');
// Prefer streaming if available
if (onRequestStreamingGeneration) {
abortControllerRef.current = onRequestStreamingGeneration(
{
prompt: generationPrompt,
study_id: studyId,
existing_code: code !== DEFAULT_EXTRACTOR_TEMPLATE ? code : undefined,
output_names: outputs,
},
{
onToken: (token) => {
setStreamingCode(prev => prev + token);
},
onComplete: (generatedCode, _outputs) => {
setCode(generatedCode);
setStreamingCode('');
onChange?.(generatedCode);
setShowPromptInput(false);
setGenerationPrompt('');
setIsGenerating(false);
},
onError: (errorMsg) => {
setError(errorMsg);
setStreamingCode('');
setIsGenerating(false);
},
}
);
} else if (onRequestGeneration) {
// Fallback to non-streaming
try {
const generatedCode = await onRequestGeneration(generationPrompt);
setCode(generatedCode);
onChange?.(generatedCode);
setShowPromptInput(false);
setGenerationPrompt('');
} catch (err) {
setError(err instanceof Error ? err.message : 'Generation failed');
} finally {
setIsGenerating(false);
}
}
}, [onRequestGeneration, onRequestStreamingGeneration, generationPrompt, onChange, code, outputs, studyId]);
// Cancel ongoing generation
const handleCancelGeneration = useCallback(() => {
abortControllerRef.current?.abort();
abortControllerRef.current = null;
setIsGenerating(false);
setStreamingCode('');
}, []);
// Run/validate code
const handleRun = useCallback(async () => {
if (!onRun) return;
setIsRunning(true);
setError(null);
setRunResult(null);
try {
const result = await onRun(code);
setRunResult(result);
if (!result.success && result.error) {
setError(result.error);
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Validation failed');
} finally {
setIsRunning(false);
}
}, [code, onRun]);
// Test code against real OP2 file
const handleTest = useCallback(async () => {
if (!onTest) return;
setIsTesting(true);
setError(null);
setTestResult(null);
try {
const result = await onTest(code);
setTestResult(result);
if (!result.success && result.error) {
setError(result.error);
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Test failed');
} finally {
setIsTesting(false);
}
}, [code, onTest]);
return (
<div className="flex flex-col h-full bg-dark-850 border-l border-dark-700">
{/* Header */}
{showHeader && (
<div className="flex items-center justify-between px-4 py-2 border-b border-dark-700">
<div className="flex items-center gap-2">
<FileCode size={16} className="text-emerald-400" />
<span className="font-medium text-white text-sm">{extractorName}</span>
<span className="text-xs text-dark-500">.py</span>
</div>
<div className="flex items-center gap-2">
{/* Snippets Button */}
<button
onClick={() => setShowSnippets(!showSnippets)}
className={`p-1.5 rounded transition-colors ${showSnippets ? 'text-amber-400 bg-amber-500/20' : 'text-dark-400 hover:text-amber-400 hover:bg-amber-500/10'}`}
title="Code Snippets"
>
<BookOpen size={16} />
</button>
{/* Claude Generate Button */}
{(onRequestGeneration || onRequestStreamingGeneration) && (
<button
onClick={() => setShowPromptInput(!showPromptInput)}
className={`p-1.5 rounded transition-colors ${showPromptInput ? 'text-violet-400 bg-violet-500/20' : 'text-violet-400 hover:bg-violet-500/20'}`}
title="Generate with Claude"
>
<Sparkles size={16} />
</button>
)}
{/* Copy Button */}
<button
onClick={handleCopy}
className="p-1.5 rounded text-dark-400 hover:text-white hover:bg-dark-700 transition-colors"
title="Copy code"
>
{copied ? <Check size={16} className="text-emerald-400" /> : <Copy size={16} />}
</button>
{/* Run Button */}
{onRun && (
<button
onClick={handleRun}
disabled={isRunning || isTesting}
className="p-1.5 rounded text-emerald-400 hover:bg-emerald-500/20 transition-colors disabled:opacity-50"
title="Validate code syntax"
>
{isRunning ? (
<RefreshCw size={16} className="animate-spin" />
) : (
<Play size={16} />
)}
</button>
)}
{/* Test Button - Live Preview */}
{onTest && (
<button
onClick={handleTest}
disabled={isRunning || isTesting}
className="p-1.5 rounded text-cyan-400 hover:bg-cyan-500/20 transition-colors disabled:opacity-50"
title="Test against real OP2 file"
>
{isTesting ? (
<RefreshCw size={16} className="animate-spin" />
) : (
<FlaskConical size={16} />
)}
</button>
)}
{/* Close Button */}
{onClose && (
<button
onClick={onClose}
className="p-1.5 rounded text-dark-400 hover:text-white hover:bg-dark-700 transition-colors"
>
<X size={16} />
</button>
)}
</div>
</div>
)}
{/* Claude Prompt Input */}
{showPromptInput && (
<div className="px-4 py-3 border-b border-dark-700 bg-violet-500/5">
<div className="flex items-center gap-2 mb-2">
<Wand2 size={14} className="text-violet-400" />
<span className="text-xs text-violet-400 font-medium">Generate with Claude</span>
</div>
<textarea
value={generationPrompt}
onChange={(e) => setGenerationPrompt(e.target.value)}
placeholder="Describe what you want to extract... e.g., 'Extract maximum von Mises stress and total mass from the model'"
className="w-full px-3 py-2 bg-dark-800 border border-dark-600 rounded-lg text-sm text-white placeholder-dark-500 resize-none focus:outline-none focus:border-violet-500"
rows={2}
/>
<div className="flex justify-end gap-2 mt-2">
<button
onClick={() => setShowPromptInput(false)}
disabled={isGenerating}
className="px-3 py-1.5 text-xs text-dark-400 hover:text-white transition-colors disabled:opacity-50"
>
Cancel
</button>
{isGenerating ? (
<button
onClick={handleCancelGeneration}
className="px-3 py-1.5 text-xs bg-red-600 text-white rounded hover:bg-red-500 transition-colors flex items-center gap-1.5"
>
<Square size={12} />
Stop
</button>
) : (
<button
onClick={handleGenerate}
disabled={!generationPrompt.trim()}
className="px-3 py-1.5 text-xs bg-violet-600 text-white rounded hover:bg-violet-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center gap-1.5"
>
<Sparkles size={12} />
Generate
</button>
)}
</div>
{/* Streaming Preview */}
{isGenerating && streamingCode && (
<div className="mt-3 p-3 bg-dark-900 rounded-lg border border-dark-600 max-h-48 overflow-auto">
<div className="flex items-center gap-2 mb-2">
<RefreshCw size={12} className="text-violet-400 animate-spin" />
<span className="text-xs text-violet-400">Generating code...</span>
</div>
<pre className="text-xs text-dark-300 font-mono whitespace-pre-wrap">{streamingCode}</pre>
</div>
)}
</div>
)}
{/* Code Snippets Panel */}
{showSnippets && (
<div className="px-4 py-3 border-b border-dark-700 bg-amber-500/5 max-h-64 overflow-y-auto">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<BookOpen size={14} className="text-amber-400" />
<span className="text-xs text-amber-400 font-medium">Code Snippets</span>
</div>
<button
onClick={() => setShowSnippets(false)}
className="p-1 rounded hover:bg-dark-700 text-dark-400 hover:text-white"
>
<X size={14} />
</button>
</div>
<div className="space-y-2">
{CODE_SNIPPETS.map((snippet) => (
<button
key={snippet.id}
onClick={() => {
setCode(snippet.code);
onChange?.(snippet.code);
setShowSnippets(false);
}}
className="w-full text-left p-2 rounded-lg bg-dark-800 hover:bg-dark-700 border border-dark-600 hover:border-amber-500/30 transition-colors group"
>
<div className="flex items-center gap-2 mb-1">
<span className="text-sm font-medium text-white group-hover:text-amber-400 transition-colors">
{snippet.name}
</span>
<span className="text-xs text-dark-500 bg-dark-700 px-1.5 py-0.5 rounded">
{snippet.category}
</span>
</div>
<p className="text-xs text-dark-400">{snippet.description}</p>
</button>
))}
</div>
</div>
)}
{/* Error Display */}
{error && (
<div className="px-4 py-2 bg-red-500/10 border-b border-red-500/30 flex items-center gap-2">
<AlertCircle size={14} className="text-red-400 flex-shrink-0" />
<span className="text-xs text-red-400 font-mono">{error}</span>
</div>
)}
{/* Monaco Editor */}
<div className="flex-1 min-h-0">
<Editor
height={height}
language="python"
theme="vs-dark"
value={code}
onChange={handleCodeChange}
onMount={handleEditorMount}
options={{
readOnly,
minimap: { enabled: false },
fontSize: 13,
lineNumbers: 'on',
scrollBeyondLastLine: false,
wordWrap: 'on',
automaticLayout: true,
tabSize: 4,
insertSpaces: true,
padding: { top: 8, bottom: 8 },
scrollbar: {
vertical: 'auto',
horizontal: 'auto',
},
}}
/>
</div>
{/* Test Results Preview */}
{testResult && testResult.success && testResult.outputs && (
<div className="border-t border-dark-700 bg-cyan-500/5">
<div className="px-4 py-2 flex items-center gap-2 text-xs">
<FlaskConical size={12} className="text-cyan-400" />
<span className="text-cyan-400 font-medium">Live Test Results</span>
{testResult.execution_time_ms && (
<span className="ml-auto text-dark-500">
{testResult.execution_time_ms.toFixed(0)}ms
</span>
)}
</div>
<div className="px-4 pb-3 space-y-1">
{Object.entries(testResult.outputs).map(([key, value]) => (
<div
key={key}
className="flex items-center justify-between px-2 py-1 bg-dark-800 rounded text-xs"
>
<span className="text-cyan-400 font-mono">{key}</span>
<span className="text-white font-medium">{typeof value === 'number' ? value.toFixed(6) : String(value)}</span>
</div>
))}
</div>
</div>
)}
{/* Outputs Preview */}
{(outputs.length > 0 || runResult?.outputs) && (
<div className="border-t border-dark-700">
<button
onClick={() => setShowOutputs(!showOutputs)}
className="w-full px-4 py-2 flex items-center gap-2 text-xs text-dark-400 hover:text-white hover:bg-dark-800/50 transition-colors"
>
{showOutputs ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
<span>Expected Outputs</span>
<span className="ml-auto text-dark-500">
{runResult?.outputs
? Object.keys(runResult.outputs).length
: outputs.length}
</span>
</button>
{showOutputs && (
<div className="px-4 pb-3 space-y-1">
{runResult?.outputs ? (
Object.entries(runResult.outputs).map(([key, value]) => (
<div
key={key}
className="flex items-center justify-between px-2 py-1 bg-dark-800 rounded text-xs"
>
<span className="text-emerald-400 font-mono">{key}</span>
<span className="text-dark-300">{String(value)}</span>
</div>
))
) : (
outputs.map((output) => (
<div
key={output}
className="flex items-center px-2 py-1 bg-dark-800 rounded text-xs"
>
<span className="text-dark-400 font-mono">{output}</span>
</div>
))
)}
</div>
)}
</div>
)}
</div>
);
}
export default CodeEditorPanel;

View File

@@ -0,0 +1,360 @@
/**
* CustomExtractorPanel - Panel for editing custom Python extractors
*
* Provides a code editor for writing custom extraction functions,
* output definitions, and validation.
*
* P3.12: Custom extractor UI component
*/
import { useState, useCallback } from 'react';
import { X, Play, AlertCircle, CheckCircle, Plus, Trash2, HelpCircle } from 'lucide-react';
interface CustomExtractorOutput {
name: string;
units?: string;
description?: string;
}
interface CustomExtractorPanelProps {
isOpen: boolean;
onClose: () => void;
initialName?: string;
initialFunctionName?: string;
initialSource?: string;
initialOutputs?: CustomExtractorOutput[];
initialDependencies?: string[];
onSave: (data: {
name: string;
functionName: string;
source: string;
outputs: CustomExtractorOutput[];
dependencies: string[];
}) => void;
}
// Common styling classes
const inputClass =
'w-full px-3 py-2 bg-dark-800 border border-dark-600 text-white placeholder-dark-400 rounded-lg focus:border-primary-500 focus:outline-none transition-colors';
const labelClass = 'block text-sm font-medium text-dark-300 mb-1';
// Default extractor template
const DEFAULT_SOURCE = `def extract(op2_path, bdf_path=None, params=None, working_dir=None):
"""
Custom extractor function.
Args:
op2_path: Path to the OP2 results file
bdf_path: Optional path to the BDF model file
params: Dictionary of current design parameters
working_dir: Path to the current trial directory
Returns:
Dictionary of output_name -> value
OR a single float value
OR a list/tuple of values (mapped to outputs in order)
"""
import numpy as np
from pyNastran.op2.op2 import OP2
# Load OP2 results
op2 = OP2(op2_path, debug=False)
# Example: compute custom metric
# ... your extraction logic here ...
result = 0.0
return {"custom_output": result}
`;
export function CustomExtractorPanel({
isOpen,
onClose,
initialName = '',
initialFunctionName = 'extract',
initialSource = DEFAULT_SOURCE,
initialOutputs = [{ name: 'custom_output', units: '' }],
initialDependencies = [],
onSave,
}: CustomExtractorPanelProps) {
const [name, setName] = useState(initialName);
const [functionName, setFunctionName] = useState(initialFunctionName);
const [source, setSource] = useState(initialSource);
const [outputs, setOutputs] = useState<CustomExtractorOutput[]>(initialOutputs);
const [dependencies] = useState<string[]>(initialDependencies);
const [validation, setValidation] = useState<{
valid: boolean;
errors: string[];
} | null>(null);
const [isValidating, setIsValidating] = useState(false);
const [showHelp, setShowHelp] = useState(false);
// Add a new output
const addOutput = useCallback(() => {
setOutputs((prev) => [...prev, { name: '', units: '' }]);
}, []);
// Remove an output
const removeOutput = useCallback((index: number) => {
setOutputs((prev) => prev.filter((_, i) => i !== index));
}, []);
// Update an output
const updateOutput = useCallback(
(index: number, field: keyof CustomExtractorOutput, value: string) => {
setOutputs((prev) =>
prev.map((out, i) => (i === index ? { ...out, [field]: value } : out))
);
},
[]
);
// Validate the code
const validateCode = useCallback(async () => {
setIsValidating(true);
setValidation(null);
try {
const response = await fetch('/api/spec/validate-extractor', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
function_name: functionName,
source: source,
}),
});
const result = await response.json();
setValidation({
valid: result.valid,
errors: result.errors || [],
});
} catch (error) {
setValidation({
valid: false,
errors: ['Failed to validate: ' + (error instanceof Error ? error.message : 'Unknown error')],
});
} finally {
setIsValidating(false);
}
}, [functionName, source]);
// Handle save
const handleSave = useCallback(() => {
// Filter out empty outputs
const validOutputs = outputs.filter((o) => o.name.trim());
if (!name.trim()) {
setValidation({ valid: false, errors: ['Name is required'] });
return;
}
if (validOutputs.length === 0) {
setValidation({ valid: false, errors: ['At least one output is required'] });
return;
}
onSave({
name: name.trim(),
functionName: functionName.trim() || 'extract',
source,
outputs: validOutputs,
dependencies: dependencies.filter((d) => d.trim()),
});
onClose();
}, [name, functionName, source, outputs, dependencies, onSave, onClose]);
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-dark-850 rounded-xl shadow-2xl w-[900px] max-h-[90vh] flex flex-col border border-dark-700">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-dark-700">
<h2 className="text-lg font-semibold text-white">Custom Extractor</h2>
<div className="flex items-center gap-2">
<button
onClick={() => setShowHelp(!showHelp)}
className="p-2 text-dark-400 hover:text-white hover:bg-dark-700 rounded-lg transition-colors"
title="Show help"
>
<HelpCircle size={20} />
</button>
<button
onClick={onClose}
className="p-2 text-dark-400 hover:text-white hover:bg-dark-700 rounded-lg transition-colors"
>
<X size={20} />
</button>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-auto p-6">
{/* Help Section */}
{showHelp && (
<div className="mb-4 p-4 bg-primary-900/20 border border-primary-700 rounded-lg">
<h3 className="text-sm font-semibold text-primary-400 mb-2">How Custom Extractors Work</h3>
<ul className="text-sm text-dark-300 space-y-1">
<li> Your function receives the path to OP2 results and optional BDF/params</li>
<li> Use pyNastran, numpy, scipy for data extraction and analysis</li>
<li> Return a dictionary mapping output names to numeric values</li>
<li> Outputs can be used as objectives or constraints in optimization</li>
<li> Code runs in a sandboxed environment (no file I/O beyond OP2/BDF)</li>
</ul>
</div>
)}
<div className="grid grid-cols-2 gap-6">
{/* Left Column - Basic Info & Outputs */}
<div className="space-y-4">
{/* Name */}
<div>
<label className={labelClass}>Extractor Name</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="My Custom Extractor"
className={inputClass}
/>
</div>
{/* Function Name */}
<div>
<label className={labelClass}>Function Name</label>
<input
type="text"
value={functionName}
onChange={(e) => setFunctionName(e.target.value)}
placeholder="extract"
className={`${inputClass} font-mono`}
/>
<p className="text-xs text-dark-500 mt-1">
Name of the Python function in your code
</p>
</div>
{/* Outputs */}
<div>
<label className={labelClass}>Outputs</label>
<div className="space-y-2">
{outputs.map((output, index) => (
<div key={index} className="flex gap-2">
<input
type="text"
value={output.name}
onChange={(e) => updateOutput(index, 'name', e.target.value)}
placeholder="output_name"
className={`${inputClass} font-mono flex-1`}
/>
<input
type="text"
value={output.units || ''}
onChange={(e) => updateOutput(index, 'units', e.target.value)}
placeholder="units"
className={`${inputClass} w-24`}
/>
<button
onClick={() => removeOutput(index)}
className="p-2 text-red-400 hover:text-red-300 hover:bg-red-900/20 rounded-lg transition-colors"
disabled={outputs.length === 1}
>
<Trash2 size={16} />
</button>
</div>
))}
<button
onClick={addOutput}
className="flex items-center gap-1 text-sm text-primary-400 hover:text-primary-300 transition-colors"
>
<Plus size={14} />
Add Output
</button>
</div>
</div>
{/* Validation Status */}
{validation && (
<div
className={`p-3 rounded-lg border ${
validation.valid
? 'bg-green-900/20 border-green-700'
: 'bg-red-900/20 border-red-700'
}`}
>
<div className="flex items-center gap-2">
{validation.valid ? (
<CheckCircle size={16} className="text-green-400" />
) : (
<AlertCircle size={16} className="text-red-400" />
)}
<span
className={`text-sm font-medium ${
validation.valid ? 'text-green-400' : 'text-red-400'
}`}
>
{validation.valid ? 'Code is valid' : 'Validation failed'}
</span>
</div>
{validation.errors.length > 0 && (
<ul className="mt-2 text-sm text-red-300 space-y-1">
{validation.errors.map((err, i) => (
<li key={i}> {err}</li>
))}
</ul>
)}
</div>
)}
</div>
{/* Right Column - Code Editor */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className={labelClass}>Python Code</label>
<button
onClick={validateCode}
disabled={isValidating}
className="flex items-center gap-1 px-3 py-1 bg-primary-600 hover:bg-primary-500
text-white text-sm rounded-lg transition-colors disabled:opacity-50"
>
<Play size={14} />
{isValidating ? 'Validating...' : 'Validate'}
</button>
</div>
<textarea
value={source}
onChange={(e) => {
setSource(e.target.value);
setValidation(null);
}}
className={`${inputClass} h-[400px] font-mono text-sm resize-none`}
spellCheck={false}
/>
<p className="text-xs text-dark-500">
Available modules: numpy, scipy, pyNastran, math, statistics
</p>
</div>
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-dark-700">
<button
onClick={onClose}
className="px-4 py-2 text-dark-300 hover:text-white hover:bg-dark-700 rounded-lg transition-colors"
>
Cancel
</button>
<button
onClick={handleSave}
className="px-4 py-2 bg-primary-600 hover:bg-primary-500 text-white rounded-lg transition-colors"
>
Save Extractor
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,255 @@
/**
* ErrorPanel - Displays optimization errors with recovery options
*
* Shows errors that occurred during optimization with:
* - Error classification (NX crash, solver failure, etc.)
* - Recovery suggestions
* - Ability to dismiss individual errors
* - Support for multiple simultaneous errors
*/
import { useMemo } from 'react';
import {
X,
AlertTriangle,
AlertOctagon,
RefreshCw,
Minimize2,
Maximize2,
Trash2,
Bug,
Cpu,
FileWarning,
Settings,
Server,
} from 'lucide-react';
import { useErrorPanel, usePanelStore, OptimizationError } from '../../../hooks/usePanelStore';
interface ErrorPanelProps {
onClose: () => void;
onRetry?: (trial?: number) => void;
onSkipTrial?: (trial: number) => void;
}
export function ErrorPanel({ onClose, onRetry, onSkipTrial }: ErrorPanelProps) {
const panel = useErrorPanel();
const { minimizePanel, dismissError, clearErrors } = usePanelStore();
const sortedErrors = useMemo(() => {
return [...panel.errors].sort((a, b) => b.timestamp - a.timestamp);
}, [panel.errors]);
if (!panel.open || panel.errors.length === 0) return null;
// Minimized view
if (panel.minimized) {
return (
<div
className="bg-dark-850 border border-red-500/50 rounded-lg shadow-xl flex items-center gap-2 px-3 py-2 cursor-pointer hover:bg-dark-800 transition-colors"
onClick={() => minimizePanel('error')}
>
<AlertOctagon size={16} className="text-red-400" />
<span className="text-sm text-white font-medium">
{panel.errors.length} Error{panel.errors.length !== 1 ? 's' : ''}
</span>
<Maximize2 size={14} className="text-dark-400" />
</div>
);
}
return (
<div className="bg-dark-850 border border-red-500/30 rounded-xl w-[420px] max-h-[500px] flex flex-col shadow-xl">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-dark-700 bg-red-500/5">
<div className="flex items-center gap-2">
<AlertOctagon size={18} className="text-red-400" />
<span className="font-medium text-white">
Optimization Errors ({panel.errors.length})
</span>
</div>
<div className="flex items-center gap-1">
{panel.errors.length > 1 && (
<button
onClick={clearErrors}
className="p-1.5 text-dark-400 hover:text-red-400 hover:bg-red-500/10 rounded transition-colors"
title="Clear all errors"
>
<Trash2 size={14} />
</button>
)}
<button
onClick={() => minimizePanel('error')}
className="p-1.5 text-dark-400 hover:text-white hover:bg-dark-700 rounded transition-colors"
title="Minimize"
>
<Minimize2 size={14} />
</button>
<button
onClick={onClose}
className="p-1.5 text-dark-400 hover:text-white hover:bg-dark-700 rounded transition-colors"
>
<X size={14} />
</button>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-3 space-y-3">
{sortedErrors.map((error) => (
<ErrorItem
key={error.timestamp}
error={error}
onDismiss={() => dismissError(error.timestamp)}
onRetry={onRetry}
onSkipTrial={onSkipTrial}
/>
))}
</div>
</div>
);
}
// ============================================================================
// Error Item Component
// ============================================================================
interface ErrorItemProps {
error: OptimizationError;
onDismiss: () => void;
onRetry?: (trial?: number) => void;
onSkipTrial?: (trial: number) => void;
}
function ErrorItem({ error, onDismiss, onRetry, onSkipTrial }: ErrorItemProps) {
const icon = getErrorIcon(error.type);
const typeLabel = getErrorTypeLabel(error.type);
const timeAgo = getTimeAgo(error.timestamp);
return (
<div className="bg-dark-800 rounded-lg border border-dark-700 overflow-hidden">
{/* Error header */}
<div className="flex items-start gap-3 p-3">
<div className="p-2 bg-red-500/10 rounded-lg flex-shrink-0">
{icon}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs font-medium text-red-400 uppercase tracking-wide">
{typeLabel}
</span>
{error.trial !== undefined && (
<span className="text-xs text-dark-500">
Trial #{error.trial}
</span>
)}
<span className="text-xs text-dark-600 ml-auto">
{timeAgo}
</span>
</div>
<p className="text-sm text-white">{error.message}</p>
{error.details && (
<p className="text-xs text-dark-400 mt-1 font-mono bg-dark-900 p-2 rounded mt-2 max-h-20 overflow-y-auto">
{error.details}
</p>
)}
</div>
<button
onClick={onDismiss}
className="p-1 text-dark-500 hover:text-white hover:bg-dark-700 rounded transition-colors flex-shrink-0"
title="Dismiss"
>
<X size={14} />
</button>
</div>
{/* Suggestions */}
{error.suggestions.length > 0 && (
<div className="px-3 pb-3">
<p className="text-xs text-dark-500 mb-1.5">Suggestions:</p>
<ul className="text-xs text-dark-300 space-y-1">
{error.suggestions.map((suggestion, idx) => (
<li key={idx} className="flex items-start gap-1.5">
<span className="text-dark-500">-</span>
<span>{suggestion}</span>
</li>
))}
</ul>
</div>
)}
{/* Actions */}
{error.recoverable && (
<div className="flex items-center gap-2 px-3 pb-3">
{onRetry && (
<button
onClick={() => onRetry(error.trial)}
className="flex items-center gap-1.5 px-3 py-1.5 bg-primary-600 hover:bg-primary-500
text-white text-xs font-medium rounded transition-colors"
>
<RefreshCw size={12} />
Retry{error.trial !== undefined ? ' Trial' : ''}
</button>
)}
{onSkipTrial && error.trial !== undefined && (
<button
onClick={() => onSkipTrial(error.trial!)}
className="flex items-center gap-1.5 px-3 py-1.5 bg-dark-700 hover:bg-dark-600
text-dark-200 text-xs font-medium rounded transition-colors"
>
Skip Trial
</button>
)}
</div>
)}
</div>
);
}
// ============================================================================
// Helper Functions
// ============================================================================
function getErrorIcon(type: OptimizationError['type']) {
switch (type) {
case 'nx_crash':
return <Cpu size={16} className="text-red-400" />;
case 'solver_fail':
return <AlertTriangle size={16} className="text-amber-400" />;
case 'extractor_error':
return <FileWarning size={16} className="text-orange-400" />;
case 'config_error':
return <Settings size={16} className="text-blue-400" />;
case 'system_error':
return <Server size={16} className="text-purple-400" />;
default:
return <Bug size={16} className="text-red-400" />;
}
}
function getErrorTypeLabel(type: OptimizationError['type']) {
switch (type) {
case 'nx_crash':
return 'NX Crash';
case 'solver_fail':
return 'Solver Failure';
case 'extractor_error':
return 'Extractor Error';
case 'config_error':
return 'Configuration Error';
case 'system_error':
return 'System Error';
default:
return 'Unknown Error';
}
}
function getTimeAgo(timestamp: number): string {
const seconds = Math.floor((Date.now() - timestamp) / 1000);
if (seconds < 60) return 'just now';
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
return `${Math.floor(seconds / 86400)}d ago`;
}
export default ErrorPanel;

View File

@@ -0,0 +1,310 @@
/**
* FileStructurePanel - Shows study file structure in the canvas sidebar
*
* Features:
* - Tree view of study directory
* - Highlights model files (.prt, .fem, .sim)
* - Shows file dependencies
* - One-click to set as model source
* - Refresh button to reload
*/
import { useState, useEffect, useCallback } from 'react';
import {
Folder,
FolderOpen,
FileBox,
ChevronRight,
RefreshCw,
Box,
Cpu,
FileCode,
AlertCircle,
CheckCircle,
Plus,
} from 'lucide-react';
interface FileNode {
name: string;
path: string;
type: 'file' | 'directory';
extension?: string;
size?: number;
children?: FileNode[];
isModelFile?: boolean;
isSelected?: boolean;
}
interface FileStructurePanelProps {
studyId: string | null;
onModelSelect?: (filePath: string, fileType: string) => void;
selectedModelPath?: string;
className?: string;
}
// File type to icon mapping
const FILE_ICONS: Record<string, { icon: typeof FileBox; color: string }> = {
'.prt': { icon: Box, color: 'text-blue-400' },
'.sim': { icon: Cpu, color: 'text-violet-400' },
'.fem': { icon: FileCode, color: 'text-emerald-400' },
'.afem': { icon: FileCode, color: 'text-emerald-400' },
'.dat': { icon: FileBox, color: 'text-amber-400' },
'.bdf': { icon: FileBox, color: 'text-amber-400' },
'.op2': { icon: FileBox, color: 'text-rose-400' },
'.f06': { icon: FileBox, color: 'text-dark-400' },
};
const MODEL_EXTENSIONS = ['.prt', '.sim', '.fem', '.afem'];
export function FileStructurePanel({
studyId,
onModelSelect,
selectedModelPath,
className = '',
}: FileStructurePanelProps) {
const [files, setFiles] = useState<FileNode[]>([]);
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(new Set());
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Load study file structure
const loadFileStructure = useCallback(async () => {
if (!studyId) {
setFiles([]);
return;
}
setIsLoading(true);
setError(null);
try {
const response = await fetch(`/api/files/structure/${encodeURIComponent(studyId)}`);
if (!response.ok) {
if (response.status === 404) {
setError('Study not found');
} else {
throw new Error(`Failed to load: ${response.status}`);
}
setFiles([]);
return;
}
const data = await response.json();
// Process the file tree to mark model files
const processNode = (node: FileNode): FileNode => {
if (node.type === 'directory' && node.children) {
return {
...node,
children: node.children.map(processNode),
};
}
const ext = '.' + node.name.split('.').pop()?.toLowerCase();
return {
...node,
extension: ext,
isModelFile: MODEL_EXTENSIONS.includes(ext),
isSelected: node.path === selectedModelPath,
};
};
const processedFiles = (data.files || []).map(processNode);
setFiles(processedFiles);
// Auto-expand 1_setup and root directories
const toExpand = new Set<string>();
processedFiles.forEach((node: FileNode) => {
if (node.type === 'directory') {
toExpand.add(node.path);
if (node.name === '1_setup' && node.children) {
node.children.forEach((child: FileNode) => {
if (child.type === 'directory') {
toExpand.add(child.path);
}
});
}
}
});
setExpandedPaths(toExpand);
} catch (err) {
console.error('Failed to load file structure:', err);
setError('Failed to load files');
} finally {
setIsLoading(false);
}
}, [studyId, selectedModelPath]);
useEffect(() => {
loadFileStructure();
}, [loadFileStructure]);
// Toggle directory expansion
const toggleExpand = (path: string) => {
setExpandedPaths((prev) => {
const next = new Set(prev);
if (next.has(path)) {
next.delete(path);
} else {
next.add(path);
}
return next;
});
};
// Handle file selection
const handleFileClick = (node: FileNode) => {
if (node.type === 'directory') {
toggleExpand(node.path);
} else if (node.isModelFile && onModelSelect) {
onModelSelect(node.path, node.extension || '');
}
};
// Render a file/folder node
const renderNode = (node: FileNode, depth: number = 0) => {
const isExpanded = expandedPaths.has(node.path);
const isDirectory = node.type === 'directory';
const fileInfo = node.extension ? FILE_ICONS[node.extension] : null;
const Icon = isDirectory
? isExpanded
? FolderOpen
: Folder
: fileInfo?.icon || FileBox;
const iconColor = isDirectory
? 'text-amber-400'
: fileInfo?.color || 'text-dark-400';
const isSelected = node.path === selectedModelPath;
return (
<div key={node.path}>
<button
onClick={() => handleFileClick(node)}
className={`
w-full flex items-center gap-2 px-2 py-1.5 text-left text-sm rounded-md
transition-colors group
${isSelected ? 'bg-primary-500/20 text-primary-400' : 'hover:bg-dark-700/50'}
${node.isModelFile ? 'cursor-pointer' : isDirectory ? 'cursor-pointer' : 'cursor-default opacity-60'}
`}
style={{ paddingLeft: `${depth * 16 + 8}px` }}
>
{/* Expand/collapse chevron for directories */}
{isDirectory ? (
<ChevronRight
size={14}
className={`text-dark-500 transition-transform flex-shrink-0 ${
isExpanded ? 'rotate-90' : ''
}`}
/>
) : (
<span className="w-3.5 flex-shrink-0" />
)}
{/* Icon */}
<Icon size={16} className={`${iconColor} flex-shrink-0`} />
{/* Name */}
<span
className={`flex-1 truncate ${
isSelected ? 'text-primary-400 font-medium' : 'text-dark-200'
}`}
>
{node.name}
</span>
{/* Model file indicator */}
{node.isModelFile && !isSelected && (
<span title="Set as model">
<Plus
size={14}
className="text-dark-500 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0"
/>
</span>
)}
{/* Selected indicator */}
{isSelected && (
<CheckCircle size={14} className="text-primary-400 flex-shrink-0" />
)}
</button>
{/* Children */}
{isDirectory && isExpanded && node.children && (
<div>
{node.children.map((child) => renderNode(child, depth + 1))}
</div>
)}
</div>
);
};
// No study selected state
if (!studyId) {
return (
<div className={`p-4 ${className}`}>
<div className="text-center text-dark-400 text-sm">
<Folder size={32} className="mx-auto mb-2 text-dark-500" />
<p>No study selected</p>
<p className="text-xs text-dark-500 mt-1">
Load a study to see its files
</p>
</div>
</div>
);
}
return (
<div className={`flex flex-col h-full ${className}`}>
{/* Header */}
<div className="flex items-center justify-between px-3 py-2 border-b border-dark-700">
<div className="flex items-center gap-2">
<Folder size={16} className="text-amber-400" />
<span className="text-sm font-medium text-white">Files</span>
</div>
<button
onClick={loadFileStructure}
disabled={isLoading}
className="p-1 rounded hover:bg-dark-700 text-dark-400 hover:text-white transition-colors"
title="Refresh"
>
<RefreshCw size={14} className={isLoading ? 'animate-spin' : ''} />
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-2">
{isLoading && files.length === 0 ? (
<div className="flex items-center justify-center h-20 text-dark-400 text-sm">
<RefreshCw size={16} className="animate-spin mr-2" />
Loading...
</div>
) : error ? (
<div className="flex items-center justify-center h-20 text-red-400 text-sm gap-2">
<AlertCircle size={16} />
{error}
</div>
) : files.length === 0 ? (
<div className="text-center text-dark-400 text-sm py-4">
<p>No files found</p>
<p className="text-xs text-dark-500 mt-1">
Add model files to 1_setup/
</p>
</div>
) : (
<div className="space-y-0.5">
{files.map((node) => renderNode(node))}
</div>
)}
</div>
{/* Footer hint */}
<div className="px-3 py-2 border-t border-dark-700 text-xs text-dark-500">
Click a model file to select it
</div>
</div>
);
}
export default FileStructurePanel;

View File

@@ -0,0 +1,485 @@
/**
* FloatingIntrospectionPanel - Persistent introspection panel using store
*
* This is a wrapper around the existing IntrospectionPanel that:
* 1. Gets its state from usePanelStore instead of local state
* 2. Persists data when the panel is closed and reopened
* 3. Can be opened from anywhere without losing state
*/
import { useState, useEffect, useCallback, useMemo } from 'react';
import {
X,
Search,
RefreshCw,
Plus,
ChevronDown,
ChevronRight,
Cpu,
SlidersHorizontal,
Scale,
Minimize2,
Maximize2,
} from 'lucide-react';
import {
useIntrospectionPanel,
usePanelStore,
} from '../../../hooks/usePanelStore';
import { useSpecStore } from '../../../hooks/useSpecStore';
interface FloatingIntrospectionPanelProps {
onClose: () => void;
}
// Reuse types from original IntrospectionPanel
interface Expression {
name: string;
value: number;
rhs?: string;
min?: number;
max?: number;
unit?: string;
units?: string;
type: string;
source?: string;
}
interface ExpressionsResult {
user: Expression[];
internal: Expression[];
total_count: number;
user_count: number;
}
interface IntrospectionResult {
solver_type?: string;
expressions?: ExpressionsResult;
// Allow other properties from the API response
file_deps?: unknown[];
fea_results?: unknown[];
fem_mesh?: unknown;
sim_solutions?: unknown[];
sim_bcs?: unknown[];
mass_properties?: {
total_mass?: number;
center_of_gravity?: { x: number; y: number; z: number };
[key: string]: unknown;
};
}
interface ModelFileInfo {
name: string;
stem: string;
type: string;
description?: string;
size_kb: number;
has_cache: boolean;
}
interface ModelFilesResponse {
files: {
sim: ModelFileInfo[];
afm: ModelFileInfo[];
fem: ModelFileInfo[];
idealized: ModelFileInfo[];
prt: ModelFileInfo[];
};
all_files: ModelFileInfo[];
}
export function FloatingIntrospectionPanel({ onClose }: FloatingIntrospectionPanelProps) {
const panel = useIntrospectionPanel();
const {
minimizePanel,
updateIntrospectionResult,
setIntrospectionLoading,
setIntrospectionError,
setIntrospectionFile,
} = usePanelStore();
const { addNode } = useSpecStore();
// Local UI state
const [expandedSections, setExpandedSections] = useState<Set<string>>(
new Set(['expressions', 'extractors', 'file_deps', 'fea_results', 'fem_mesh', 'sim_solutions', 'sim_bcs'])
);
const [searchTerm, setSearchTerm] = useState('');
const [modelFiles, setModelFiles] = useState<ModelFilesResponse | null>(null);
const [isLoadingFiles, setIsLoadingFiles] = useState(false);
const data = panel.data;
const result = data?.result as IntrospectionResult | undefined;
const isLoading = data?.isLoading || false;
const error = data?.error as string | null;
// Fetch available files when studyId changes
const fetchAvailableFiles = useCallback(async () => {
if (!data?.studyId) return;
setIsLoadingFiles(true);
try {
const res = await fetch(`/api/optimization/studies/${data.studyId}/nx/parts`);
if (res.ok) {
const filesData = await res.json();
setModelFiles(filesData);
}
} catch (e) {
console.error('Failed to fetch model files:', e);
} finally {
setIsLoadingFiles(false);
}
}, [data?.studyId]);
// Run introspection
const runIntrospection = useCallback(async (fileName?: string) => {
if (!data?.filePath && !data?.studyId) return;
setIntrospectionLoading(true);
setIntrospectionError(null);
try {
let res;
if (data?.studyId) {
const endpoint = fileName
? `/api/optimization/studies/${data.studyId}/nx/introspect/${encodeURIComponent(fileName)}`
: `/api/optimization/studies/${data.studyId}/nx/introspect`;
res = await fetch(endpoint);
} else {
res = await fetch('/api/nx/introspect', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ file_path: data?.filePath }),
});
}
if (!res.ok) {
const errData = await res.json().catch(() => ({}));
throw new Error(errData.detail || 'Introspection failed');
}
const responseData = await res.json();
updateIntrospectionResult(responseData.introspection || responseData);
} catch (e) {
const msg = e instanceof Error ? e.message : 'Failed to introspect model';
setIntrospectionError(msg);
console.error('Introspection error:', e);
}
}, [data?.filePath, data?.studyId, setIntrospectionLoading, setIntrospectionError, updateIntrospectionResult]);
// Fetch files list on mount
useEffect(() => {
fetchAvailableFiles();
}, [fetchAvailableFiles]);
// Run introspection when panel opens or selected file changes
useEffect(() => {
if (panel.open && data && !result && !isLoading) {
runIntrospection(data.selectedFile);
}
}, [panel.open, data?.selectedFile]); // eslint-disable-line react-hooks/exhaustive-deps
const handleFileChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const newFile = e.target.value;
setIntrospectionFile(newFile);
runIntrospection(newFile);
};
const toggleSection = (section: string) => {
setExpandedSections((prev) => {
const next = new Set(prev);
if (next.has(section)) next.delete(section);
else next.add(section);
return next;
});
};
// Handle both array format (old) and object format (new API)
const allExpressions: Expression[] = useMemo(() => {
if (!result?.expressions) return [];
if (Array.isArray(result.expressions)) {
return result.expressions as Expression[];
}
const exprObj = result.expressions as ExpressionsResult;
return [...(exprObj.user || []), ...(exprObj.internal || [])];
}, [result?.expressions]);
const filteredExpressions = allExpressions.filter((e) =>
e.name.toLowerCase().includes(searchTerm.toLowerCase())
);
const addExpressionAsDesignVar = (expr: Expression) => {
const minValue = expr.min ?? expr.value * 0.5;
const maxValue = expr.max ?? expr.value * 1.5;
addNode('designVar', {
name: expr.name,
expression_name: expr.name,
type: 'continuous',
bounds: { min: minValue, max: maxValue },
baseline: expr.value,
units: expr.unit || expr.units,
enabled: true,
});
};
if (!panel.open) return null;
// Minimized view
if (panel.minimized) {
return (
<div
className="bg-dark-850 border border-dark-700 rounded-lg shadow-xl flex items-center gap-2 px-3 py-2 cursor-pointer hover:bg-dark-800 transition-colors"
onClick={() => minimizePanel('introspection')}
>
<Search size={16} className="text-primary-400" />
<span className="text-sm text-white font-medium">
Model Introspection
{data?.selectedFile && <span className="text-dark-400 ml-1">({data.selectedFile})</span>}
</span>
<Maximize2 size={14} className="text-dark-400" />
</div>
);
}
return (
<div className="bg-dark-850 border border-dark-700 rounded-xl w-80 max-h-[70vh] flex flex-col shadow-xl">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-dark-700">
<div className="flex items-center gap-2">
<Search size={16} className="text-primary-400" />
<span className="font-medium text-white text-sm">
Model Introspection
{data?.selectedFile && <span className="text-primary-400 ml-1">({data.selectedFile})</span>}
</span>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => runIntrospection(data?.selectedFile)}
disabled={isLoading}
className="p-1.5 text-dark-400 hover:text-white hover:bg-dark-700 rounded transition-colors"
title="Refresh"
>
<RefreshCw size={14} className={isLoading ? 'animate-spin' : ''} />
</button>
<button
onClick={() => minimizePanel('introspection')}
className="p-1.5 text-dark-400 hover:text-white hover:bg-dark-700 rounded transition-colors"
title="Minimize"
>
<Minimize2 size={14} />
</button>
<button
onClick={onClose}
className="p-1.5 text-dark-400 hover:text-white hover:bg-dark-700 rounded transition-colors"
>
<X size={14} />
</button>
</div>
</div>
{/* File Selector + Search */}
<div className="px-4 py-2 border-b border-dark-700 space-y-2">
{data?.studyId && modelFiles && modelFiles.all_files.length > 0 && (
<div className="flex items-center gap-2">
<label className="text-xs text-dark-400 whitespace-nowrap">File:</label>
<select
value={data?.selectedFile || ''}
onChange={handleFileChange}
disabled={isLoading || isLoadingFiles}
className="flex-1 px-2 py-1.5 bg-dark-800 border border-dark-600 rounded-lg
text-sm text-white focus:outline-none focus:border-primary-500
disabled:opacity-50"
>
<option value="">Default (Assembly)</option>
{modelFiles.files.sim.length > 0 && (
<optgroup label="Simulation (.sim)">
{modelFiles.files.sim.map(f => (
<option key={f.name} value={f.name}>
{f.stem} ({f.size_kb > 1000 ? `${(f.size_kb/1024).toFixed(1)}MB` : `${f.size_kb}KB`})
</option>
))}
</optgroup>
)}
{modelFiles.files.afm.length > 0 && (
<optgroup label="Assembly FEM (.afm)">
{modelFiles.files.afm.map(f => (
<option key={f.name} value={f.name}>
{f.stem} ({f.size_kb > 1000 ? `${(f.size_kb/1024).toFixed(1)}MB` : `${f.size_kb}KB`})
</option>
))}
</optgroup>
)}
{modelFiles.files.fem.length > 0 && (
<optgroup label="FEM (.fem)">
{modelFiles.files.fem.map(f => (
<option key={f.name} value={f.name}>
{f.stem} ({f.size_kb > 1000 ? `${(f.size_kb/1024).toFixed(1)}MB` : `${f.size_kb}KB`})
</option>
))}
</optgroup>
)}
{modelFiles.files.prt.length > 0 && (
<optgroup label="Geometry (.prt)">
{modelFiles.files.prt.map(f => (
<option key={f.name} value={f.name}>
{f.stem} ({f.size_kb > 1000 ? `${(f.size_kb/1024).toFixed(1)}MB` : `${f.size_kb}KB`})
</option>
))}
</optgroup>
)}
{modelFiles.files.idealized.length > 0 && (
<optgroup label="Idealized (_i.prt)">
{modelFiles.files.idealized.map(f => (
<option key={f.name} value={f.name}>
{f.stem} ({f.size_kb > 1000 ? `${(f.size_kb/1024).toFixed(1)}MB` : `${f.size_kb}KB`})
</option>
))}
</optgroup>
)}
</select>
{isLoadingFiles && (
<RefreshCw size={12} className="animate-spin text-dark-400" />
)}
</div>
)}
<input
type="text"
placeholder="Filter expressions..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full px-3 py-1.5 bg-dark-800 border border-dark-600 rounded-lg
text-sm text-white placeholder-dark-500 focus:outline-none focus:border-primary-500"
/>
</div>
{/* Content */}
<div className="flex-1 overflow-auto">
{isLoading ? (
<div className="flex items-center justify-center h-32 text-dark-500">
<RefreshCw size={20} className="animate-spin mr-2" />
Analyzing model...
</div>
) : error ? (
<div className="p-4 text-red-400 text-sm">{error}</div>
) : result ? (
<div className="p-2 space-y-2">
{/* Solver Type */}
{result.solver_type && (
<div className="p-2 bg-dark-800 rounded-lg">
<div className="flex items-center gap-2 text-sm">
<Cpu size={14} className="text-violet-400" />
<span className="text-dark-300">Solver:</span>
<span className="text-white font-medium">{result.solver_type as string}</span>
</div>
</div>
)}
{/* Expressions Section */}
<div className="border border-dark-700 rounded-lg overflow-hidden">
<button
onClick={() => toggleSection('expressions')}
className="w-full flex items-center justify-between px-3 py-2 bg-dark-800 hover:bg-dark-750 transition-colors"
>
<div className="flex items-center gap-2">
<SlidersHorizontal size={14} className="text-emerald-400" />
<span className="text-sm font-medium text-white">
Expressions ({filteredExpressions.length})
</span>
</div>
{expandedSections.has('expressions') ? (
<ChevronDown size={14} className="text-dark-400" />
) : (
<ChevronRight size={14} className="text-dark-400" />
)}
</button>
{expandedSections.has('expressions') && (
<div className="p-2 space-y-1 max-h-48 overflow-y-auto">
{filteredExpressions.length === 0 ? (
<p className="text-xs text-dark-500 text-center py-2">
No expressions found
</p>
) : (
filteredExpressions.map((expr) => (
<div
key={expr.name}
className="flex items-center justify-between p-2 bg-dark-850 rounded hover:bg-dark-750 group transition-colors"
>
<div className="flex-1 min-w-0">
<p className="text-sm text-white truncate">{expr.name}</p>
<p className="text-xs text-dark-500">
{expr.value} {expr.units || expr.unit || ''}
{expr.source === 'inferred' && (
<span className="ml-1 text-amber-500">(inferred)</span>
)}
</p>
</div>
<button
onClick={() => addExpressionAsDesignVar(expr)}
className="p-1.5 text-dark-500 hover:text-primary-400 hover:bg-dark-700 rounded
opacity-0 group-hover:opacity-100 transition-all"
title="Add as Design Variable"
>
<Plus size={14} />
</button>
</div>
))
)}
</div>
)}
</div>
{/* Mass Properties Section */}
{result.mass_properties && (
<div className="border border-dark-700 rounded-lg overflow-hidden">
<button
onClick={() => toggleSection('mass')}
className="w-full flex items-center justify-between px-3 py-2 bg-dark-800 hover:bg-dark-750 transition-colors"
>
<div className="flex items-center gap-2">
<Scale size={14} className="text-blue-400" />
<span className="text-sm font-medium text-white">Mass Properties</span>
</div>
{expandedSections.has('mass') ? (
<ChevronDown size={14} className="text-dark-400" />
) : (
<ChevronRight size={14} className="text-dark-400" />
)}
</button>
{expandedSections.has('mass') && (
<div className="p-2 space-y-1">
{(result.mass_properties as Record<string, unknown>).mass_kg !== undefined && (
<div className="flex justify-between p-2 bg-dark-850 rounded text-xs">
<span className="text-dark-400">Mass</span>
<span className="text-white font-mono">
{((result.mass_properties as Record<string, unknown>).mass_kg as number).toFixed(4)} kg
</span>
</div>
)}
</div>
)}
</div>
)}
{/* More sections can be added here following the same pattern as the original IntrospectionPanel */}
</div>
) : (
<div className="p-4 text-center text-dark-500 text-sm">
Click refresh to analyze the model
</div>
)}
</div>
</div>
);
}
export default FloatingIntrospectionPanel;

View File

@@ -0,0 +1,922 @@
/**
* NodeConfigPanelV2 - Configuration panel for AtomizerSpec v2.0 nodes
*
* This component uses useSpecStore instead of the legacy useCanvasStore.
* It renders type-specific configuration forms based on the selected node.
*
* For custom extractors, integrates CodeEditorPanel with Claude AI generation.
*/
import { useState, useMemo, useCallback } from 'react';
import { Microscope, Trash2, X, AlertCircle, Code, FileCode } from 'lucide-react';
import { CodeEditorPanel } from './CodeEditorPanel';
import { generateExtractorCode, validateExtractorCode, streamExtractorCode, checkCodeDependencies, testExtractorCode } from '../../../lib/api/claude';
import {
useSpecStore,
useSpec,
useSelectedNodeId,
useSelectedNode,
} from '../../../hooks/useSpecStore';
import { usePanelStore } from '../../../hooks/usePanelStore';
import { FileBrowser } from './FileBrowser';
import {
DesignVariable,
Extractor,
Objective,
Constraint,
} from '../../../types/atomizer-spec';
// Common input class for dark theme
const inputClass = "w-full px-3 py-2 bg-dark-800 border border-dark-600 text-white placeholder-dark-400 rounded-lg focus:border-primary-500 focus:outline-none transition-colors";
const selectClass = "w-full px-3 py-2 bg-dark-800 border border-dark-600 text-white rounded-lg focus:border-primary-500 focus:outline-none transition-colors";
const labelClass = "block text-sm font-medium text-dark-300 mb-1";
interface NodeConfigPanelV2Props {
/** Called when panel should close */
onClose?: () => void;
}
export function NodeConfigPanelV2({ onClose }: NodeConfigPanelV2Props) {
const spec = useSpec();
const selectedNodeId = useSelectedNodeId();
const selectedNode = useSelectedNode();
const { updateNode, removeNode, clearSelection } = useSpecStore();
const [showFileBrowser, setShowFileBrowser] = useState(false);
const [isUpdating, setIsUpdating] = useState(false);
const [error, setError] = useState<string | null>(null);
// Determine node type from ID prefix or from the node itself
const nodeType = useMemo(() => {
if (!selectedNodeId) return null;
// Synthetic nodes have fixed IDs
if (selectedNodeId === 'model') return 'model';
if (selectedNodeId === 'solver') return 'solver';
if (selectedNodeId === 'algorithm') return 'algorithm';
if (selectedNodeId === 'surrogate') return 'surrogate';
// Real nodes have prefixed IDs
const prefix = selectedNodeId.split('_')[0];
switch (prefix) {
case 'dv': return 'designVar';
case 'ext': return 'extractor';
case 'obj': return 'objective';
case 'con': return 'constraint';
default: return null;
}
}, [selectedNodeId]);
// Get label for display
const nodeLabel = useMemo(() => {
if (!selectedNodeId || !spec) return 'Node';
switch (nodeType) {
case 'model': return spec.meta.study_name || 'Model';
case 'solver': return spec.model.sim?.solution_type || 'Solver';
case 'algorithm': return spec.optimization.algorithm?.type || 'Algorithm';
case 'surrogate': return 'Neural Surrogate';
default:
if (selectedNode) {
return (selectedNode as any).name || selectedNodeId;
}
return selectedNodeId;
}
}, [selectedNodeId, selectedNode, nodeType, spec]);
// Handle field changes
const handleChange = useCallback(async (field: string, value: unknown) => {
if (!selectedNodeId || !selectedNode) return;
setIsUpdating(true);
setError(null);
try {
await updateNode(selectedNodeId, { [field]: value });
} catch (err) {
console.error('Failed to update node:', err);
setError(err instanceof Error ? err.message : 'Update failed');
} finally {
setIsUpdating(false);
}
}, [selectedNodeId, selectedNode, updateNode]);
// Handle delete
const handleDelete = useCallback(async () => {
if (!selectedNodeId) return;
// Synthetic nodes can't be deleted
if (['model', 'solver', 'algorithm', 'surrogate'].includes(selectedNodeId)) {
setError('This node cannot be deleted');
return;
}
setIsUpdating(true);
setError(null);
try {
await removeNode(selectedNodeId);
clearSelection();
onClose?.();
} catch (err) {
console.error('Failed to delete node:', err);
setError(err instanceof Error ? err.message : 'Delete failed');
} finally {
setIsUpdating(false);
}
}, [selectedNodeId, removeNode, clearSelection, onClose]);
// Don't render if no node selected
if (!selectedNodeId || !spec) {
return null;
}
// Check if this is a synthetic node (model, solver, algorithm, surrogate)
const isSyntheticNode = ['model', 'solver', 'algorithm', 'surrogate'].includes(selectedNodeId);
return (
<div className="w-80 bg-dark-850 border-l border-dark-700 flex flex-col h-full">
{/* Header */}
<div className="flex justify-between items-center p-4 border-b border-dark-700">
<h3 className="font-semibold text-white truncate flex-1">
Configure {nodeLabel}
</h3>
<div className="flex items-center gap-2">
{!isSyntheticNode && (
<button
onClick={handleDelete}
disabled={isUpdating}
className="p-1.5 text-red-400 hover:text-red-300 hover:bg-red-500/10 rounded transition-colors"
title="Delete node"
>
<Trash2 size={16} />
</button>
)}
{onClose && (
<button
onClick={onClose}
className="p-1.5 text-dark-400 hover:text-white hover:bg-dark-700 rounded transition-colors"
title="Close panel"
>
<X size={16} />
</button>
)}
</div>
</div>
{/* Error display */}
{error && (
<div className="mx-4 mt-4 p-3 bg-red-500/10 border border-red-500/30 rounded-lg flex items-start gap-2">
<AlertCircle size={16} className="text-red-400 flex-shrink-0 mt-0.5" />
<p className="text-sm text-red-400 flex-1">{error}</p>
<button onClick={() => setError(null)} className="text-red-400 hover:text-red-300">
<X size={14} />
</button>
</div>
)}
{/* Content */}
<div className="flex-1 overflow-y-auto p-4">
<div className="space-y-4">
{/* Loading indicator */}
{isUpdating && (
<div className="text-xs text-primary-400 animate-pulse">Updating...</div>
)}
{/* Model node (synthetic) */}
{nodeType === 'model' && spec.model && (
<ModelNodeConfig spec={spec} />
)}
{/* Solver node (synthetic) */}
{nodeType === 'solver' && (
<SolverNodeConfig spec={spec} />
)}
{/* Algorithm node (synthetic) */}
{nodeType === 'algorithm' && (
<AlgorithmNodeConfig spec={spec} />
)}
{/* Surrogate node (synthetic) */}
{nodeType === 'surrogate' && (
<SurrogateNodeConfig spec={spec} />
)}
{/* Design Variable */}
{nodeType === 'designVar' && selectedNode && (
<DesignVarNodeConfig
node={selectedNode as DesignVariable}
onChange={handleChange}
/>
)}
{/* Extractor */}
{nodeType === 'extractor' && selectedNode && (
<ExtractorNodeConfig
node={selectedNode as Extractor}
onChange={handleChange}
/>
)}
{/* Objective */}
{nodeType === 'objective' && selectedNode && (
<ObjectiveNodeConfig
node={selectedNode as Objective}
onChange={handleChange}
/>
)}
{/* Constraint */}
{nodeType === 'constraint' && selectedNode && (
<ConstraintNodeConfig
node={selectedNode as Constraint}
onChange={handleChange}
/>
)}
</div>
</div>
{/* File Browser Modal */}
<FileBrowser
isOpen={showFileBrowser}
onClose={() => setShowFileBrowser(false)}
onSelect={() => {
// This would update the model path - but model is synthetic
setShowFileBrowser(false);
}}
fileTypes={['.sim', '.prt', '.fem', '.afem']}
/>
{/* Introspection is now handled by FloatingIntrospectionPanel via usePanelStore */}
</div>
);
}
// ============================================================================
// Type-specific configuration components
// ============================================================================
interface SpecConfigProps {
spec: NonNullable<ReturnType<typeof useSpec>>;
}
function ModelNodeConfig({ spec }: SpecConfigProps) {
const { setIntrospectionData, openPanel } = usePanelStore();
const handleOpenIntrospection = () => {
// Set up introspection data and open the panel
setIntrospectionData({
filePath: spec.model.sim?.path || '',
studyId: useSpecStore.getState().studyId || undefined,
});
openPanel('introspection');
};
return (
<>
<div>
<label className={labelClass}>Model File</label>
<input
type="text"
value={spec.model.sim?.path || ''}
readOnly
className={`${inputClass} font-mono text-sm bg-dark-900 cursor-not-allowed`}
title="Model path is read-only. Change via study configuration."
/>
<p className="text-xs text-dark-500 mt-1">Read-only. Set in study configuration.</p>
</div>
<div>
<label className={labelClass}>Solver Type</label>
<input
type="text"
value={spec.model.sim?.solution_type || 'Not detected'}
readOnly
className={`${inputClass} bg-dark-900 cursor-not-allowed`}
/>
</div>
{spec.model.sim?.path && (
<button
onClick={handleOpenIntrospection}
className="w-full flex items-center justify-center gap-2 px-3 py-2.5 bg-primary-500/20
hover:bg-primary-500/30 border border-primary-500/30 rounded-lg
text-primary-400 text-sm font-medium transition-colors"
>
<Microscope size={16} />
Introspect Model
</button>
)}
{/* Note: IntrospectionPanel is now rendered by PanelContainer, not here */}
</>
);
}
function SolverNodeConfig({ spec }: SpecConfigProps) {
const { patchSpec } = useSpecStore();
const [isUpdating, setIsUpdating] = useState(false);
const engine = spec.model.sim?.engine || 'nxnastran';
const solutionType = spec.model.sim?.solution_type || 'SOL101';
const scriptPath = spec.model.sim?.script_path || '';
const isPython = engine === 'python';
const handleEngineChange = async (newEngine: string) => {
setIsUpdating(true);
try {
await patchSpec('model.sim.engine', newEngine);
} catch (err) {
console.error('Failed to update engine:', err);
} finally {
setIsUpdating(false);
}
};
const handleSolutionTypeChange = async (newType: string) => {
setIsUpdating(true);
try {
await patchSpec('model.sim.solution_type', newType);
} catch (err) {
console.error('Failed to update solution type:', err);
} finally {
setIsUpdating(false);
}
};
const handleScriptPathChange = async (newPath: string) => {
setIsUpdating(true);
try {
await patchSpec('model.sim.script_path', newPath);
} catch (err) {
console.error('Failed to update script path:', err);
} finally {
setIsUpdating(false);
}
};
return (
<>
{isUpdating && (
<div className="text-xs text-primary-400 animate-pulse">Updating...</div>
)}
<div>
<label className={labelClass}>Solver Engine</label>
<select
value={engine}
onChange={(e) => handleEngineChange(e.target.value)}
className={selectClass}
>
<option value="nxnastran">NX Nastran (built-in)</option>
<option value="mscnastran">MSC Nastran (external)</option>
<option value="python">Python Script</option>
<option value="abaqus" disabled>Abaqus (coming soon)</option>
<option value="ansys" disabled>ANSYS (coming soon)</option>
</select>
<p className="text-xs text-dark-500 mt-1">
{isPython ? 'Run custom Python analysis script' : 'Select FEA solver software'}
</p>
</div>
{!isPython && (
<div>
<label className={labelClass}>Solution Type</label>
<select
value={solutionType}
onChange={(e) => handleSolutionTypeChange(e.target.value)}
className={selectClass}
>
<option value="SOL101">SOL101 - Linear Statics</option>
<option value="SOL103">SOL103 - Normal Modes</option>
<option value="SOL105">SOL105 - Buckling</option>
<option value="SOL106">SOL106 - Nonlinear Statics</option>
<option value="SOL111">SOL111 - Modal Frequency Response</option>
<option value="SOL112">SOL112 - Modal Transient Response</option>
<option value="SOL200">SOL200 - Design Optimization</option>
</select>
</div>
)}
{isPython && (
<div>
<label className={labelClass}>Script Path</label>
<input
type="text"
value={scriptPath}
onChange={(e) => handleScriptPathChange(e.target.value)}
placeholder="path/to/solver_script.py"
className={`${inputClass} font-mono text-sm`}
/>
<p className="text-xs text-dark-500 mt-1">
Python script must define solve(params) function
</p>
</div>
)}
</>
);
}
function AlgorithmNodeConfig({ spec }: SpecConfigProps) {
const algo = spec.optimization.algorithm;
return (
<>
<div>
<label className={labelClass}>Method</label>
<input
type="text"
value={algo?.type || 'TPE'}
readOnly
className={`${inputClass} bg-dark-900 cursor-not-allowed`}
/>
<p className="text-xs text-dark-500 mt-1">Edit in optimization settings.</p>
</div>
<div>
<label className={labelClass}>Max Trials</label>
<input
type="number"
value={spec.optimization.budget?.max_trials || 100}
readOnly
className={`${inputClass} bg-dark-900 cursor-not-allowed`}
/>
</div>
</>
);
}
function SurrogateNodeConfig({ spec }: SpecConfigProps) {
const surrogate = spec.optimization.surrogate;
return (
<>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="surrogate-enabled"
checked={surrogate?.enabled || false}
readOnly
className="w-4 h-4 rounded bg-dark-800 border-dark-600 text-primary-500 cursor-not-allowed"
/>
<label htmlFor="surrogate-enabled" className="text-sm font-medium text-dark-300">
Neural Surrogate {surrogate?.enabled ? 'Enabled' : 'Disabled'}
</label>
</div>
{surrogate?.enabled && (
<>
<div>
<label className={labelClass}>Model Type</label>
<input
type="text"
value={surrogate.type || 'MLP'}
readOnly
className={`${inputClass} bg-dark-900 cursor-not-allowed`}
/>
</div>
<div>
<label className={labelClass}>Min Training Samples</label>
<input
type="number"
value={surrogate.config?.min_training_samples || 20}
readOnly
className={`${inputClass} bg-dark-900 cursor-not-allowed`}
/>
</div>
</>
)}
<p className="text-xs text-dark-500">Edit in optimization settings.</p>
</>
);
}
// ============================================================================
// Editable node configs
// ============================================================================
interface DesignVarNodeConfigProps {
node: DesignVariable;
onChange: (field: string, value: unknown) => void;
}
function DesignVarNodeConfig({ node, onChange }: DesignVarNodeConfigProps) {
return (
<>
<div>
<label className={labelClass}>Name</label>
<input
type="text"
value={node.name}
onChange={(e) => onChange('name', e.target.value)}
className={inputClass}
/>
</div>
<div>
<label className={labelClass}>Expression Name</label>
<input
type="text"
value={node.expression_name}
onChange={(e) => onChange('expression_name', e.target.value)}
placeholder="NX expression name"
className={`${inputClass} font-mono text-sm`}
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className={labelClass}>Min</label>
<input
type="number"
value={node.bounds.min}
onChange={(e) => onChange('bounds', { ...node.bounds, min: parseFloat(e.target.value) })}
className={inputClass}
/>
</div>
<div>
<label className={labelClass}>Max</label>
<input
type="number"
value={node.bounds.max}
onChange={(e) => onChange('bounds', { ...node.bounds, max: parseFloat(e.target.value) })}
className={inputClass}
/>
</div>
</div>
{node.baseline !== undefined && (
<div>
<label className={labelClass}>Baseline</label>
<input
type="number"
value={node.baseline}
onChange={(e) => onChange('baseline', parseFloat(e.target.value))}
className={inputClass}
/>
</div>
)}
<div>
<label className={labelClass}>Units</label>
<input
type="text"
value={node.units || ''}
onChange={(e) => onChange('units', e.target.value)}
placeholder="mm"
className={inputClass}
/>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id={`${node.id}-enabled`}
checked={node.enabled !== false}
onChange={(e) => onChange('enabled', e.target.checked)}
className="w-4 h-4 rounded bg-dark-800 border-dark-600 text-primary-500 focus:ring-primary-500"
/>
<label htmlFor={`${node.id}-enabled`} className="text-sm text-dark-300">
Enabled
</label>
</div>
</>
);
}
interface ExtractorNodeConfigProps {
node: Extractor;
onChange: (field: string, value: unknown) => void;
}
// Default template for custom extractors
const DEFAULT_EXTRACTOR_TEMPLATE = `"""
Custom Extractor Function
This function is called after FEA simulation completes.
It receives the results and should return extracted values.
"""
from pyNastran.op2.op2 import OP2
import numpy as np
def extract(op2_path: str, fem_path: str, params: dict, subcase_id: int = 1) -> dict:
"""
Extract physics from FEA results.
Args:
op2_path: Path to OP2 results file
fem_path: Path to FEM file
params: Current design variable values
subcase_id: Subcase ID to analyze
Returns:
Dict with extracted values, e.g. {'max_stress': 150.5, 'mass': 2.3}
"""
# Load OP2 results
op2 = OP2()
op2.read_op2(op2_path)
# Example: Extract max displacement
if subcase_id in op2.displacements:
disp = op2.displacements[subcase_id]
magnitudes = np.sqrt(np.sum(disp.data[0, :, 1:4]**2, axis=1))
max_disp = float(np.max(magnitudes))
else:
max_disp = 0.0
return {
'max_displacement': max_disp,
}
`;
function ExtractorNodeConfig({ node, onChange }: ExtractorNodeConfigProps) {
const [showCodeEditor, setShowCodeEditor] = useState(false);
const studyId = useSpecStore(state => state.studyId);
const extractorOptions = [
{ id: 'E1', name: 'Displacement', type: 'displacement' },
{ id: 'E2', name: 'Frequency', type: 'frequency' },
{ id: 'E3', name: 'Solid Stress', type: 'solid_stress' },
{ id: 'E4', name: 'BDF Mass', type: 'mass_bdf' },
{ id: 'E5', name: 'CAD Mass', type: 'mass_expression' },
{ id: 'E8', name: 'Zernike (OP2)', type: 'zernike_op2' },
{ id: 'E9', name: 'Zernike (CSV)', type: 'zernike_csv' },
{ id: 'E10', name: 'Zernike (RMS)', type: 'zernike_rms' },
];
// Check if this is a custom function type
// Using string comparison to handle both 'custom_function' and potential legacy 'custom' values
const isCustomType = node.type === 'custom_function' || (node.type as string) === 'custom';
// Get current source code
const currentCode = node.function?.source_code || DEFAULT_EXTRACTOR_TEMPLATE;
// Handle Claude generation request (non-streaming fallback)
const handleRequestGeneration = useCallback(async (prompt: string): Promise<string> => {
const response = await generateExtractorCode({
prompt,
study_id: studyId || undefined,
existing_code: node.function?.source_code,
output_names: node.outputs?.map(o => o.name) || [],
});
return response.code;
}, [studyId, node.function?.source_code, node.outputs]);
// Handle streaming generation (preferred)
const handleStreamingGeneration = useCallback((
request: { prompt: string; study_id?: string; existing_code?: string; output_names?: string[] },
callbacks: { onToken: (t: string) => void; onComplete: (c: string, o: string[]) => void; onError: (e: string) => void }
) => {
return streamExtractorCode(request, callbacks);
}, []);
// Handle code change from editor
const handleCodeChange = useCallback((code: string) => {
onChange('function', {
...node.function,
name: node.function?.name || 'custom_extract',
source_code: code,
});
}, [node.function, onChange]);
// Handle code validation (includes syntax check and dependency check)
const handleValidateCode = useCallback(async (code: string) => {
// First check syntax
const syntaxResult = await validateExtractorCode(code);
if (!syntaxResult.valid) {
return {
success: false,
error: syntaxResult.error,
};
}
// Then check dependencies
const depResult = await checkCodeDependencies(code);
// Build combined result
const warnings: string[] = [...depResult.warnings];
if (depResult.missing.length > 0) {
warnings.push(`Missing packages: ${depResult.missing.join(', ')}`);
}
return {
success: true,
error: warnings.length > 0 ? `Warnings: ${warnings.join('; ')}` : undefined,
outputs: depResult.imports.length > 0 ? { imports: depResult.imports.join(', ') } : undefined,
};
}, []);
// Handle live testing against OP2 file
const handleTestCode = useCallback(async (code: string) => {
const result = await testExtractorCode({
code,
study_id: studyId || undefined,
subcase_id: 1,
});
return result;
}, [studyId]);
return (
<>
<div>
<label className={labelClass}>Name</label>
<input
type="text"
value={node.name}
onChange={(e) => onChange('name', e.target.value)}
className={inputClass}
/>
</div>
<div>
<label className={labelClass}>Extractor Type</label>
<select
value={node.type}
onChange={(e) => onChange('type', e.target.value)}
className={selectClass}
>
<option value="">Select...</option>
{extractorOptions.map(opt => (
<option key={opt.id} value={opt.type}>
{opt.id} - {opt.name}
</option>
))}
<option value="custom_function">Custom Function</option>
</select>
</div>
{/* Custom Code Editor Button */}
{isCustomType && (
<>
<button
onClick={() => setShowCodeEditor(true)}
className="w-full flex items-center justify-center gap-2 px-3 py-2.5
bg-violet-500/20 hover:bg-violet-500/30 border border-violet-500/30
rounded-lg text-violet-400 text-sm font-medium transition-colors"
>
<Code size={16} />
Edit Custom Code
</button>
{node.function?.source_code && (
<div className="text-xs text-dark-500 flex items-center gap-1.5">
<FileCode size={12} />
Custom code defined ({node.function.source_code.split('\n').length} lines)
</div>
)}
</>
)}
{/* Code Editor Modal */}
{showCodeEditor && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
<div className="w-[900px] h-[700px] bg-dark-850 rounded-xl overflow-hidden shadow-2xl border border-dark-600 flex flex-col">
{/* Code Editor with built-in header containing toolbar buttons */}
<CodeEditorPanel
initialCode={currentCode}
extractorName={`Custom Extractor: ${node.name}`}
outputs={node.outputs?.map(o => o.name) || []}
onChange={handleCodeChange}
onRequestGeneration={handleRequestGeneration}
onRequestStreamingGeneration={handleStreamingGeneration}
onRun={handleValidateCode}
onTest={handleTestCode}
onClose={() => setShowCodeEditor(false)}
showHeader={true}
height="100%"
studyId={studyId || undefined}
/>
</div>
</div>
)}
{/* Outputs */}
<div>
<label className={labelClass}>Outputs</label>
<input
type="text"
value={node.outputs?.map(o => o.name).join(', ') || ''}
readOnly
placeholder="value, unit"
className={`${inputClass} bg-dark-900 cursor-not-allowed`}
/>
<p className="text-xs text-dark-500 mt-1">
{isCustomType
? 'Detected from return statement in code.'
: 'Outputs are defined by extractor type.'}
</p>
</div>
</>
);
}
interface ObjectiveNodeConfigProps {
node: Objective;
onChange: (field: string, value: unknown) => void;
}
function ObjectiveNodeConfig({ node, onChange }: ObjectiveNodeConfigProps) {
return (
<>
<div>
<label className={labelClass}>Name</label>
<input
type="text"
value={node.name}
onChange={(e) => onChange('name', e.target.value)}
className={inputClass}
/>
</div>
<div>
<label className={labelClass}>Direction</label>
<select
value={node.direction}
onChange={(e) => onChange('direction', e.target.value)}
className={selectClass}
>
<option value="minimize">Minimize</option>
<option value="maximize">Maximize</option>
</select>
</div>
<div>
<label className={labelClass}>Weight</label>
<input
type="number"
step="0.1"
min="0"
value={node.weight ?? 1}
onChange={(e) => onChange('weight', parseFloat(e.target.value))}
className={inputClass}
/>
</div>
{node.target !== undefined && (
<div>
<label className={labelClass}>Target Value</label>
<input
type="number"
value={node.target}
onChange={(e) => onChange('target', parseFloat(e.target.value))}
className={inputClass}
/>
</div>
)}
</>
);
}
interface ConstraintNodeConfigProps {
node: Constraint;
onChange: (field: string, value: unknown) => void;
}
function ConstraintNodeConfig({ node, onChange }: ConstraintNodeConfigProps) {
return (
<>
<div>
<label className={labelClass}>Name</label>
<input
type="text"
value={node.name}
onChange={(e) => onChange('name', e.target.value)}
className={inputClass}
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className={labelClass}>Type</label>
<select
value={node.type}
onChange={(e) => onChange('type', e.target.value)}
className={selectClass}
>
<option value="less_than">&lt; Less than</option>
<option value="less_equal">&lt;= Less or equal</option>
<option value="greater_than">&gt; Greater than</option>
<option value="greater_equal">&gt;= Greater or equal</option>
<option value="equal">= Equal</option>
</select>
</div>
<div>
<label className={labelClass}>Threshold</label>
<input
type="number"
value={node.threshold}
onChange={(e) => onChange('threshold', parseFloat(e.target.value))}
className={inputClass}
/>
</div>
</div>
</>
);
}
export default NodeConfigPanelV2;

View File

@@ -0,0 +1,207 @@
/**
* PanelContainer - Orchestrates all floating panels in the canvas view
*
* This component renders floating panels (Introspection, Validation, Error, Results)
* in a portal, positioned absolutely within the canvas area.
*
* Features:
* - Draggable panels
* - Z-index management (click to bring to front)
* - Keyboard shortcuts (Escape to close all)
* - Position persistence via usePanelStore
*/
import { useState, useCallback, useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
import {
usePanelStore,
useIntrospectionPanel,
useValidationPanel,
useErrorPanel,
useResultsPanel,
PanelPosition,
} from '../../../hooks/usePanelStore';
import { FloatingIntrospectionPanel } from './FloatingIntrospectionPanel';
import { FloatingValidationPanel } from './ValidationPanel';
import { ErrorPanel } from './ErrorPanel';
import { ResultsPanel } from './ResultsPanel';
interface PanelContainerProps {
/** Container element to render panels into (defaults to document.body) */
container?: HTMLElement;
/** Callback when retry is requested from error panel */
onRetry?: (trial?: number) => void;
/** Callback when skip trial is requested */
onSkipTrial?: (trial: number) => void;
}
type PanelName = 'introspection' | 'validation' | 'error' | 'results';
export function PanelContainer({ container, onRetry, onSkipTrial }: PanelContainerProps) {
const { closePanel, setPanelPosition, closeAllPanels } = usePanelStore();
const introspectionPanel = useIntrospectionPanel();
const validationPanel = useValidationPanel();
const errorPanel = useErrorPanel();
const resultsPanel = useResultsPanel();
// Track which panel is on top (for z-index)
const [topPanel, setTopPanel] = useState<PanelName | null>(null);
// Dragging state
const [dragging, setDragging] = useState<{ panel: PanelName; offset: { x: number; y: number } } | null>(null);
const dragRef = useRef<{ panel: PanelName; offset: { x: number; y: number } } | null>(null);
// Escape key to close all panels
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
closeAllPanels();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [closeAllPanels]);
// Mouse move handler for dragging
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
if (!dragRef.current) return;
const { panel, offset } = dragRef.current;
const newPosition: PanelPosition = {
x: e.clientX - offset.x,
y: e.clientY - offset.y,
};
// Clamp to viewport
newPosition.x = Math.max(0, Math.min(window.innerWidth - 100, newPosition.x));
newPosition.y = Math.max(0, Math.min(window.innerHeight - 50, newPosition.y));
setPanelPosition(panel, newPosition);
};
const handleMouseUp = () => {
dragRef.current = null;
setDragging(null);
};
if (dragging) {
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseup', handleMouseUp);
}
return () => {
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp);
};
}, [dragging, setPanelPosition]);
// Start dragging a panel
const handleDragStart = useCallback((panel: PanelName, e: React.MouseEvent, position: PanelPosition) => {
const offset = {
x: e.clientX - position.x,
y: e.clientY - position.y,
};
dragRef.current = { panel, offset };
setDragging({ panel, offset });
setTopPanel(panel);
}, []);
// Click to bring panel to front
const handlePanelClick = useCallback((panel: PanelName) => {
setTopPanel(panel);
}, []);
// Get z-index for a panel
const getZIndex = (panel: PanelName) => {
const baseZ = 100;
if (panel === topPanel) return baseZ + 10;
return baseZ;
};
// Render a draggable wrapper
const renderDraggable = (
panel: PanelName,
position: PanelPosition,
isOpen: boolean,
children: React.ReactNode
) => {
if (!isOpen) return null;
return (
<div
key={panel}
className="fixed select-none"
style={{
left: position.x,
top: position.y,
zIndex: getZIndex(panel),
cursor: dragging?.panel === panel ? 'grabbing' : 'default',
}}
onClick={() => handlePanelClick(panel)}
>
{/* Drag handle - the header area */}
<div
className="absolute top-0 left-0 right-0 h-12 cursor-grab active:cursor-grabbing"
onMouseDown={(e) => handleDragStart(panel, e, position)}
style={{ zIndex: 1 }}
/>
{/* Panel content */}
<div className="relative" style={{ zIndex: 0 }}>
{children}
</div>
</div>
);
};
// Determine what to render
const panels = (
<>
{/* Introspection Panel */}
{renderDraggable(
'introspection',
introspectionPanel.position || { x: 100, y: 100 },
introspectionPanel.open,
<FloatingIntrospectionPanel onClose={() => closePanel('introspection')} />
)}
{/* Validation Panel */}
{renderDraggable(
'validation',
validationPanel.position || { x: 150, y: 150 },
validationPanel.open,
<FloatingValidationPanel onClose={() => closePanel('validation')} />
)}
{/* Error Panel */}
{renderDraggable(
'error',
errorPanel.position || { x: 200, y: 100 },
errorPanel.open,
<ErrorPanel
onClose={() => closePanel('error')}
onRetry={onRetry}
onSkipTrial={onSkipTrial}
/>
)}
{/* Results Panel */}
{renderDraggable(
'results',
resultsPanel.position || { x: 250, y: 150 },
resultsPanel.open,
<ResultsPanel onClose={() => closePanel('results')} />
)}
</>
);
// Use portal if container specified, otherwise render in place
if (container) {
return createPortal(panels, container);
}
return panels;
}
export default PanelContainer;

View File

@@ -0,0 +1,179 @@
/**
* ResultsPanel - Shows detailed trial results
*
* Displays the parameters, objectives, and constraints for a specific trial.
* Can be opened by clicking on result badges on nodes.
*/
import {
X,
Minimize2,
Maximize2,
CheckCircle,
XCircle,
Trophy,
SlidersHorizontal,
Target,
AlertTriangle,
Clock,
} from 'lucide-react';
import { useResultsPanel, usePanelStore } from '../../../hooks/usePanelStore';
interface ResultsPanelProps {
onClose: () => void;
}
export function ResultsPanel({ onClose }: ResultsPanelProps) {
const panel = useResultsPanel();
const { minimizePanel } = usePanelStore();
const data = panel.data;
if (!panel.open || !data) return null;
const timestamp = new Date(data.timestamp).toLocaleTimeString();
// Minimized view
if (panel.minimized) {
return (
<div
className="bg-dark-850 border border-dark-700 rounded-lg shadow-xl flex items-center gap-2 px-3 py-2 cursor-pointer hover:bg-dark-800 transition-colors"
onClick={() => minimizePanel('results')}
>
<Trophy size={16} className={data.isBest ? 'text-amber-400' : 'text-dark-400'} />
<span className="text-sm text-white font-medium">
Trial #{data.trialNumber}
</span>
<Maximize2 size={14} className="text-dark-400" />
</div>
);
}
return (
<div className="bg-dark-850 border border-dark-700 rounded-xl w-80 max-h-[500px] flex flex-col shadow-xl">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-dark-700">
<div className="flex items-center gap-2">
<Trophy size={18} className={data.isBest ? 'text-amber-400' : 'text-dark-400'} />
<span className="font-medium text-white">
Trial #{data.trialNumber}
</span>
{data.isBest && (
<span className="px-1.5 py-0.5 text-xs bg-amber-500/20 text-amber-400 rounded">
Best
</span>
)}
</div>
<div className="flex items-center gap-1">
<button
onClick={() => minimizePanel('results')}
className="p-1.5 text-dark-400 hover:text-white hover:bg-dark-700 rounded transition-colors"
title="Minimize"
>
<Minimize2 size={14} />
</button>
<button
onClick={onClose}
className="p-1.5 text-dark-400 hover:text-white hover:bg-dark-700 rounded transition-colors"
>
<X size={14} />
</button>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-3 space-y-4">
{/* Status */}
<div className="flex items-center gap-3">
{data.isFeasible ? (
<div className="flex items-center gap-1.5 text-green-400">
<CheckCircle size={16} />
<span className="text-sm font-medium">Feasible</span>
</div>
) : (
<div className="flex items-center gap-1.5 text-red-400">
<XCircle size={16} />
<span className="text-sm font-medium">Infeasible</span>
</div>
)}
<div className="flex items-center gap-1.5 text-dark-400 ml-auto">
<Clock size={14} />
<span className="text-xs">{timestamp}</span>
</div>
</div>
{/* Parameters */}
<div>
<h4 className="text-xs font-medium text-dark-400 uppercase tracking-wide mb-2 flex items-center gap-1.5">
<SlidersHorizontal size={12} />
Parameters
</h4>
<div className="space-y-1">
{Object.entries(data.params).map(([name, value]) => (
<div key={name} className="flex justify-between p-2 bg-dark-800 rounded text-sm">
<span className="text-dark-300">{name}</span>
<span className="text-white font-mono">{formatValue(value)}</span>
</div>
))}
</div>
</div>
{/* Objectives */}
<div>
<h4 className="text-xs font-medium text-dark-400 uppercase tracking-wide mb-2 flex items-center gap-1.5">
<Target size={12} />
Objectives
</h4>
<div className="space-y-1">
{Object.entries(data.objectives).map(([name, value]) => (
<div key={name} className="flex justify-between p-2 bg-dark-800 rounded text-sm">
<span className="text-dark-300">{name}</span>
<span className="text-primary-400 font-mono">{formatValue(value)}</span>
</div>
))}
</div>
</div>
{/* Constraints (if any) */}
{data.constraints && Object.keys(data.constraints).length > 0 && (
<div>
<h4 className="text-xs font-medium text-dark-400 uppercase tracking-wide mb-2 flex items-center gap-1.5">
<AlertTriangle size={12} />
Constraints
</h4>
<div className="space-y-1">
{Object.entries(data.constraints).map(([name, constraint]) => (
<div
key={name}
className={`flex justify-between p-2 rounded text-sm ${
constraint.feasible ? 'bg-dark-800' : 'bg-red-500/10 border border-red-500/20'
}`}
>
<span className="text-dark-300 flex items-center gap-1.5">
{constraint.feasible ? (
<CheckCircle size={12} className="text-green-400" />
) : (
<XCircle size={12} className="text-red-400" />
)}
{name}
</span>
<span className={`font-mono ${constraint.feasible ? 'text-white' : 'text-red-400'}`}>
{formatValue(constraint.value)}
</span>
</div>
))}
</div>
</div>
)}
</div>
</div>
);
}
function formatValue(value: number): string {
if (Math.abs(value) < 0.001 || Math.abs(value) >= 10000) {
return value.toExponential(3);
}
return value.toFixed(4).replace(/\.?0+$/, '');
}
export default ResultsPanel;

View File

@@ -1,10 +1,41 @@
/**
* ValidationPanel - Displays spec validation errors and warnings
*
* Shows a list of validation issues that need to be fixed before
* running an optimization. Supports auto-navigation to problematic nodes.
*
* Can be used in two modes:
* 1. Legacy mode: Pass validation prop directly (for backward compatibility)
* 2. Store mode: Uses usePanelStore for persistent state
*/
import { useMemo } from 'react';
import {
X,
AlertCircle,
AlertTriangle,
CheckCircle,
ChevronRight,
Minimize2,
Maximize2,
} from 'lucide-react';
import { useValidationPanel, usePanelStore, ValidationError as StoreValidationError } from '../../../hooks/usePanelStore';
import { useSpecStore } from '../../../hooks/useSpecStore';
import { ValidationResult } from '../../../lib/canvas/validation'; import { ValidationResult } from '../../../lib/canvas/validation';
interface ValidationPanelProps { // ============================================================================
// Legacy Props Interface (for backward compatibility)
// ============================================================================
interface LegacyValidationPanelProps {
validation: ValidationResult; validation: ValidationResult;
} }
export function ValidationPanel({ validation }: ValidationPanelProps) { /**
* Legacy ValidationPanel - Inline display for canvas overlay
* Kept for backward compatibility with AtomizerCanvas
*/
export function ValidationPanel({ validation }: LegacyValidationPanelProps) {
return ( return (
<div className="absolute top-4 left-1/2 transform -translate-x-1/2 max-w-md w-full z-10"> <div className="absolute top-4 left-1/2 transform -translate-x-1/2 max-w-md w-full z-10">
{validation.errors.length > 0 && ( {validation.errors.length > 0 && (
@@ -30,3 +61,199 @@ export function ValidationPanel({ validation }: ValidationPanelProps) {
</div> </div>
); );
} }
// ============================================================================
// New Floating Panel (uses store)
// ============================================================================
interface FloatingValidationPanelProps {
onClose: () => void;
}
export function FloatingValidationPanel({ onClose }: FloatingValidationPanelProps) {
const panel = useValidationPanel();
const { minimizePanel } = usePanelStore();
const { selectNode } = useSpecStore();
const { errors, warnings, valid } = useMemo(() => {
if (!panel.data) {
return { errors: [], warnings: [], valid: true };
}
return {
errors: panel.data.errors || [],
warnings: panel.data.warnings || [],
valid: panel.data.valid,
};
}, [panel.data]);
const handleNavigateToNode = (nodeId?: string) => {
if (nodeId) {
selectNode(nodeId);
}
};
if (!panel.open) return null;
// Minimized view
if (panel.minimized) {
return (
<div
className="bg-dark-850 border border-dark-700 rounded-lg shadow-xl flex items-center gap-2 px-3 py-2 cursor-pointer hover:bg-dark-800 transition-colors"
onClick={() => minimizePanel('validation')}
>
{valid ? (
<CheckCircle size={16} className="text-green-400" />
) : (
<AlertCircle size={16} className="text-red-400" />
)}
<span className="text-sm text-white font-medium">
Validation {valid ? 'Passed' : `(${errors.length} errors)`}
</span>
<Maximize2 size={14} className="text-dark-400" />
</div>
);
}
return (
<div className="bg-dark-850 border border-dark-700 rounded-xl w-96 max-h-[500px] flex flex-col shadow-xl">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-dark-700">
<div className="flex items-center gap-2">
{valid ? (
<CheckCircle size={18} className="text-green-400" />
) : (
<AlertCircle size={18} className="text-red-400" />
)}
<span className="font-medium text-white">
{valid ? 'Validation Passed' : 'Validation Issues'}
</span>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => minimizePanel('validation')}
className="p-1.5 text-dark-400 hover:text-white hover:bg-dark-700 rounded transition-colors"
title="Minimize"
>
<Minimize2 size={14} />
</button>
<button
onClick={onClose}
className="p-1.5 text-dark-400 hover:text-white hover:bg-dark-700 rounded transition-colors"
>
<X size={14} />
</button>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-3 space-y-2">
{valid && errors.length === 0 && warnings.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-center">
<CheckCircle size={40} className="text-green-400 mb-3" />
<p className="text-white font-medium">All checks passed!</p>
<p className="text-sm text-dark-400 mt-1">
Your spec is ready to run.
</p>
</div>
) : (
<>
{/* Errors */}
{errors.length > 0 && (
<div className="space-y-2">
<h4 className="text-xs font-medium text-red-400 uppercase tracking-wide flex items-center gap-1">
<AlertCircle size={12} />
Errors ({errors.length})
</h4>
{errors.map((error, idx) => (
<ValidationItem
key={`error-${idx}`}
item={error}
severity="error"
onNavigate={() => handleNavigateToNode(error.nodeId)}
/>
))}
</div>
)}
{/* Warnings */}
{warnings.length > 0 && (
<div className="space-y-2 mt-4">
<h4 className="text-xs font-medium text-amber-400 uppercase tracking-wide flex items-center gap-1">
<AlertTriangle size={12} />
Warnings ({warnings.length})
</h4>
{warnings.map((warning, idx) => (
<ValidationItem
key={`warning-${idx}`}
item={warning}
severity="warning"
onNavigate={() => handleNavigateToNode(warning.nodeId)}
/>
))}
</div>
)}
</>
)}
</div>
{/* Footer */}
{!valid && (
<div className="px-4 py-3 border-t border-dark-700 bg-dark-800/50">
<p className="text-xs text-dark-400">
Fix all errors before running the optimization.
Warnings can be ignored but may cause issues.
</p>
</div>
)}
</div>
);
}
// ============================================================================
// Validation Item Component
// ============================================================================
interface ValidationItemProps {
item: StoreValidationError;
severity: 'error' | 'warning';
onNavigate: () => void;
}
function ValidationItem({ item, severity, onNavigate }: ValidationItemProps) {
const isError = severity === 'error';
const bgColor = isError ? 'bg-red-500/10' : 'bg-amber-500/10';
const borderColor = isError ? 'border-red-500/30' : 'border-amber-500/30';
const iconColor = isError ? 'text-red-400' : 'text-amber-400';
return (
<div
className={`p-3 rounded-lg border ${bgColor} ${borderColor} group cursor-pointer hover:bg-opacity-20 transition-colors`}
onClick={onNavigate}
>
<div className="flex items-start gap-2">
{isError ? (
<AlertCircle size={16} className={`${iconColor} flex-shrink-0 mt-0.5`} />
) : (
<AlertTriangle size={16} className={`${iconColor} flex-shrink-0 mt-0.5`} />
)}
<div className="flex-1 min-w-0">
<p className="text-sm text-white">{item.message}</p>
{item.path && (
<p className="text-xs text-dark-400 mt-1 font-mono">{item.path}</p>
)}
{item.suggestion && (
<p className="text-xs text-dark-300 mt-2 italic">{item.suggestion}</p>
)}
</div>
{item.nodeId && (
<ChevronRight
size={16}
className="text-dark-500 group-hover:text-white transition-colors flex-shrink-0"
/>
)}
</div>
</div>
);
}
export default ValidationPanel;

View File

@@ -0,0 +1,240 @@
/**
* ConvergenceSparkline - Tiny SVG chart showing optimization convergence
*
* Displays the last N trial values as a mini line chart.
* Used on ObjectiveNode to show convergence trend.
*/
import { useMemo } from 'react';
interface ConvergenceSparklineProps {
/** Array of values (most recent last) */
values: number[];
/** Width in pixels */
width?: number;
/** Height in pixels */
height?: number;
/** Line color */
color?: string;
/** Best value line color */
bestColor?: string;
/** Whether to show the best value line */
showBest?: boolean;
/** Direction: minimize shows lower as better, maximize shows higher as better */
direction?: 'minimize' | 'maximize';
/** Show dots at each point */
showDots?: boolean;
/** Number of points to display */
maxPoints?: number;
}
export function ConvergenceSparkline({
values,
width = 80,
height = 24,
color = '#60a5fa',
bestColor = '#34d399',
showBest = true,
direction = 'minimize',
showDots = false,
maxPoints = 20,
}: ConvergenceSparklineProps) {
const { path, bestY, points } = useMemo(() => {
if (!values || values.length === 0) {
return { path: '', bestY: null, points: [], minVal: 0, maxVal: 1 };
}
// Take last N points
const data = values.slice(-maxPoints);
if (data.length === 0) {
return { path: '', bestY: null, points: [], minVal: 0, maxVal: 1 };
}
// Calculate bounds with padding
const minVal = Math.min(...data);
const maxVal = Math.max(...data);
const range = maxVal - minVal || 1;
const padding = range * 0.1;
const yMin = minVal - padding;
const yMax = maxVal + padding;
const yRange = yMax - yMin;
// Calculate best value
const bestVal = direction === 'minimize' ? Math.min(...data) : Math.max(...data);
// Map values to SVG coordinates
const xStep = width / Math.max(data.length - 1, 1);
const mapY = (v: number) => height - ((v - yMin) / yRange) * height;
// Build path
const points = data.map((v, i) => ({
x: i * xStep,
y: mapY(v),
value: v,
}));
const pathParts = points.map((p, i) =>
i === 0 ? `M ${p.x} ${p.y}` : `L ${p.x} ${p.y}`
);
return {
path: pathParts.join(' '),
bestY: mapY(bestVal),
points,
minVal,
maxVal,
};
}, [values, width, height, maxPoints, direction]);
if (!values || values.length === 0) {
return (
<div
className="flex items-center justify-center text-dark-500 text-xs"
style={{ width, height }}
>
No data
</div>
);
}
return (
<svg
width={width}
height={height}
className="overflow-visible"
viewBox={`0 0 ${width} ${height}`}
>
{/* Best value line */}
{showBest && bestY !== null && (
<line
x1={0}
y1={bestY}
x2={width}
y2={bestY}
stroke={bestColor}
strokeWidth={1}
strokeDasharray="2,2"
opacity={0.5}
/>
)}
{/* Main line */}
<path
d={path}
fill="none"
stroke={color}
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
{/* Gradient fill under the line */}
<defs>
<linearGradient id="sparkline-gradient" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stopColor={color} stopOpacity={0.3} />
<stop offset="100%" stopColor={color} stopOpacity={0} />
</linearGradient>
</defs>
{points.length > 1 && (
<path
d={`${path} L ${points[points.length - 1].x} ${height} L ${points[0].x} ${height} Z`}
fill="url(#sparkline-gradient)"
/>
)}
{/* Dots at each point */}
{showDots && points.map((p, i) => (
<circle
key={i}
cx={p.x}
cy={p.y}
r={2}
fill={color}
/>
))}
{/* Last point highlight */}
{points.length > 0 && (
<circle
cx={points[points.length - 1].x}
cy={points[points.length - 1].y}
r={3}
fill={color}
stroke="white"
strokeWidth={1}
/>
)}
</svg>
);
}
/**
* ProgressRing - Circular progress indicator
*/
interface ProgressRingProps {
/** Progress percentage (0-100) */
progress: number;
/** Size in pixels */
size?: number;
/** Stroke width */
strokeWidth?: number;
/** Progress color */
color?: string;
/** Background color */
bgColor?: string;
/** Show percentage text */
showText?: boolean;
}
export function ProgressRing({
progress,
size = 32,
strokeWidth = 3,
color = '#60a5fa',
bgColor = '#374151',
showText = true,
}: ProgressRingProps) {
const radius = (size - strokeWidth) / 2;
const circumference = radius * 2 * Math.PI;
const offset = circumference - (Math.min(100, Math.max(0, progress)) / 100) * circumference;
return (
<div className="relative inline-flex items-center justify-center" style={{ width: size, height: size }}>
<svg width={size} height={size} className="transform -rotate-90">
{/* Background circle */}
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke={bgColor}
strokeWidth={strokeWidth}
/>
{/* Progress circle */}
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke={color}
strokeWidth={strokeWidth}
strokeDasharray={circumference}
strokeDashoffset={offset}
strokeLinecap="round"
className="transition-all duration-300"
/>
</svg>
{showText && (
<span
className="absolute text-xs font-medium"
style={{ color, fontSize: size * 0.25 }}
>
{Math.round(progress)}%
</span>
)}
</div>
);
}
export default ConvergenceSparkline;

View File

@@ -5,7 +5,7 @@ import { ToolCallCard, ToolCall } from './ToolCallCard';
export interface Message { export interface Message {
id: string; id: string;
role: 'user' | 'assistant'; role: 'user' | 'assistant' | 'system';
content: string; content: string;
timestamp: Date; timestamp: Date;
isStreaming?: boolean; isStreaming?: boolean;
@@ -18,6 +18,18 @@ interface ChatMessageProps {
export const ChatMessage: React.FC<ChatMessageProps> = ({ message }) => { export const ChatMessage: React.FC<ChatMessageProps> = ({ message }) => {
const isAssistant = message.role === 'assistant'; const isAssistant = message.role === 'assistant';
const isSystem = message.role === 'system';
// System messages are displayed centered with special styling
if (isSystem) {
return (
<div className="flex justify-center my-2">
<div className="px-3 py-1 bg-dark-700/50 rounded-full text-xs text-dark-400 border border-dark-600">
{message.content}
</div>
</div>
);
}
return ( return (
<div <div

View File

@@ -1,4 +1,4 @@
import React, { useRef, useEffect, useState } from 'react'; import React, { useRef, useEffect, useState, useMemo } from 'react';
import { import {
MessageSquare, MessageSquare,
ChevronRight, ChevronRight,
@@ -13,8 +13,10 @@ import { ChatMessage } from './ChatMessage';
import { ChatInput } from './ChatInput'; import { ChatInput } from './ChatInput';
import { ThinkingIndicator } from './ThinkingIndicator'; import { ThinkingIndicator } from './ThinkingIndicator';
import { ModeToggle } from './ModeToggle'; import { ModeToggle } from './ModeToggle';
import { useChat } from '../../hooks/useChat'; import { useChat, CanvasState, CanvasModification } from '../../hooks/useChat';
import { useStudy } from '../../context/StudyContext'; import { useStudy } from '../../context/StudyContext';
import { useCanvasStore } from '../../hooks/useCanvasStore';
import { NodeType } from '../../lib/canvas/schema';
interface ChatPaneProps { interface ChatPaneProps {
isOpen: boolean; isOpen: boolean;
@@ -31,6 +33,76 @@ export const ChatPane: React.FC<ChatPaneProps> = ({
const messagesEndRef = useRef<HTMLDivElement>(null); const messagesEndRef = useRef<HTMLDivElement>(null);
const [isExpanded, setIsExpanded] = useState(false); const [isExpanded, setIsExpanded] = useState(false);
// Get canvas state and modification functions from the store
const { nodes, edges, addNode, updateNodeData, selectNode, deleteSelected } = useCanvasStore();
// Build canvas state for chat context
const canvasState: CanvasState | null = useMemo(() => {
if (nodes.length === 0) return null;
return {
nodes: nodes.map(n => ({
id: n.id,
type: n.type,
data: n.data,
position: n.position,
})),
edges: edges.map(e => ({
id: e.id,
source: e.source,
target: e.target,
})),
studyName: selectedStudy?.name || selectedStudy?.id,
};
}, [nodes, edges, selectedStudy]);
// Track position offset for multiple node additions
const nodeAddCountRef = useRef(0);
// Handle canvas modifications from the assistant
const handleCanvasModification = React.useCallback((modification: CanvasModification) => {
console.log('Canvas modification from assistant:', modification);
switch (modification.action) {
case 'add_node':
if (modification.nodeType) {
const nodeType = modification.nodeType as NodeType;
// Calculate position: offset each new node so they don't stack
const basePosition = modification.position || { x: 100, y: 100 };
const offset = nodeAddCountRef.current * 120;
const position = {
x: basePosition.x,
y: basePosition.y + offset,
};
nodeAddCountRef.current += 1;
// Reset counter after a delay (for batch operations)
setTimeout(() => { nodeAddCountRef.current = 0; }, 2000);
addNode(nodeType, position, modification.data);
console.log(`Added ${nodeType} node at position:`, position);
}
break;
case 'update_node':
if (modification.nodeId && modification.data) {
updateNodeData(modification.nodeId, modification.data);
}
break;
case 'remove_node':
if (modification.nodeId) {
selectNode(modification.nodeId);
deleteSelected();
}
break;
// Edge operations would need additional store methods
case 'add_edge':
case 'remove_edge':
console.warn('Edge modification not yet implemented:', modification);
break;
}
}, [addNode, updateNodeData, selectNode, deleteSelected]);
const { const {
messages, messages,
isThinking, isThinking,
@@ -41,22 +113,38 @@ export const ChatPane: React.FC<ChatPaneProps> = ({
sendMessage, sendMessage,
clearMessages, clearMessages,
switchMode, switchMode,
updateCanvasState,
} = useChat({ } = useChat({
studyId: selectedStudy?.id, studyId: selectedStudy?.id,
mode: 'user', mode: 'user',
useWebSocket: true, useWebSocket: true,
canvasState,
onError: (err) => console.error('Chat error:', err), onError: (err) => console.error('Chat error:', err),
onCanvasModification: handleCanvasModification,
}); });
// Keep canvas state synced with chat
useEffect(() => {
updateCanvasState(canvasState);
}, [canvasState, updateCanvasState]);
// Auto-scroll to bottom when new messages arrive // Auto-scroll to bottom when new messages arrive
useEffect(() => { useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages, isThinking]); }, [messages, isThinking]);
// Welcome message based on study context // Welcome message based on study and canvas context
const welcomeMessage = selectedStudy const welcomeMessage = useMemo(() => {
? `Ready to help with **${selectedStudy.name || selectedStudy.id}**. Ask me about optimization progress, results analysis, or how to improve your design.` if (selectedStudy) {
: 'Select a study to get started, or ask me to help you create a new one.'; return `Ready to help with **${selectedStudy.name || selectedStudy.id}**. Ask me about optimization progress, results analysis, or how to improve your design.`;
}
if (nodes.length > 0) {
const dvCount = nodes.filter(n => n.type === 'designVar').length;
const objCount = nodes.filter(n => n.type === 'objective').length;
return `I can see your canvas with ${dvCount} design variables and ${objCount} objectives. Ask me to analyze, validate, or create a study from this setup.`;
}
return 'Select a study to get started, or build an optimization in the Canvas Builder.';
}, [selectedStudy, nodes]);
// Collapsed state - just show toggle button // Collapsed state - just show toggle button
if (!isOpen) { if (!isOpen) {

View File

@@ -30,22 +30,25 @@ interface ToolCallCardProps {
} }
// Map tool names to friendly labels and icons // Map tool names to friendly labels and icons
const TOOL_INFO: Record<string, { label: string; icon: React.ComponentType<{ className?: string }> }> = { const TOOL_INFO: Record<string, { label: string; icon: React.ComponentType<{ className?: string }>; color?: string }> = {
// Study tools // Study tools
list_studies: { label: 'Listing Studies', icon: Database }, list_studies: { label: 'Listing Studies', icon: Database },
get_study_status: { label: 'Getting Status', icon: FileSearch }, get_study_status: { label: 'Getting Status', icon: FileSearch },
create_study: { label: 'Creating Study', icon: Settings }, create_study: { label: 'Creating Study', icon: Settings, color: 'text-green-400' },
// Optimization tools // Optimization tools
run_optimization: { label: 'Starting Optimization', icon: Play }, run_optimization: { label: 'Starting Optimization', icon: Play, color: 'text-blue-400' },
stop_optimization: { label: 'Stopping Optimization', icon: XCircle }, stop_optimization: { label: 'Stopping Optimization', icon: XCircle },
get_optimization_status: { label: 'Checking Progress', icon: BarChart2 }, get_optimization_status: { label: 'Checking Progress', icon: BarChart2 },
// Analysis tools // Analysis tools
get_trial_data: { label: 'Querying Trials', icon: Database }, get_trial_data: { label: 'Querying Trials', icon: Database },
query_trials: { label: 'Querying Trials', icon: Database },
get_trial_details: { label: 'Getting Trial Details', icon: FileSearch },
analyze_convergence: { label: 'Analyzing Convergence', icon: BarChart2 }, analyze_convergence: { label: 'Analyzing Convergence', icon: BarChart2 },
compare_trials: { label: 'Comparing Trials', icon: BarChart2 }, compare_trials: { label: 'Comparing Trials', icon: BarChart2 },
get_best_design: { label: 'Getting Best Design', icon: CheckCircle }, get_best_design: { label: 'Getting Best Design', icon: CheckCircle },
get_optimization_summary: { label: 'Getting Summary', icon: BarChart2 },
// Reporting tools // Reporting tools
generate_report: { label: 'Generating Report', icon: FileText }, generate_report: { label: 'Generating Report', icon: FileText },
@@ -56,6 +59,25 @@ const TOOL_INFO: Record<string, { label: string; icon: React.ComponentType<{ cla
recommend_method: { label: 'Recommending Method', icon: Settings }, recommend_method: { label: 'Recommending Method', icon: Settings },
query_extractors: { label: 'Listing Extractors', icon: Database }, query_extractors: { label: 'Listing Extractors', icon: Database },
// Config tools (read)
read_study_config: { label: 'Reading Config', icon: FileSearch },
read_study_readme: { label: 'Reading README', icon: FileText },
// === WRITE TOOLS (Power Mode) ===
add_design_variable: { label: 'Adding Design Variable', icon: Settings, color: 'text-amber-400' },
add_extractor: { label: 'Adding Extractor', icon: Settings, color: 'text-amber-400' },
add_objective: { label: 'Adding Objective', icon: Settings, color: 'text-amber-400' },
add_constraint: { label: 'Adding Constraint', icon: Settings, color: 'text-amber-400' },
update_spec_field: { label: 'Updating Field', icon: Settings, color: 'text-amber-400' },
remove_node: { label: 'Removing Node', icon: XCircle, color: 'text-red-400' },
// === INTERVIEW TOOLS ===
start_interview: { label: 'Starting Interview', icon: HelpCircle, color: 'text-purple-400' },
interview_record: { label: 'Recording Answer', icon: CheckCircle, color: 'text-purple-400' },
interview_advance: { label: 'Advancing Interview', icon: Play, color: 'text-purple-400' },
interview_status: { label: 'Checking Progress', icon: BarChart2, color: 'text-purple-400' },
interview_finalize: { label: 'Creating Study', icon: CheckCircle, color: 'text-green-400' },
// Admin tools (power mode) // Admin tools (power mode)
edit_file: { label: 'Editing File', icon: FileText }, edit_file: { label: 'Editing File', icon: FileText },
create_file: { label: 'Creating File', icon: FileText }, create_file: { label: 'Creating File', icon: FileText },
@@ -104,7 +126,7 @@ export const ToolCallCard: React.FC<ToolCallCardProps> = ({ toolCall }) => {
)} )}
{/* Tool icon */} {/* Tool icon */}
<Icon className="w-4 h-4 text-dark-400 flex-shrink-0" /> <Icon className={`w-4 h-4 flex-shrink-0 ${info.color || 'text-dark-400'}`} />
{/* Label */} {/* Label */}
<span className="flex-1 text-sm text-dark-200 truncate">{info.label}</span> <span className="flex-1 text-sm text-dark-200 truncate">{info.label}</span>

View File

@@ -1,260 +0,0 @@
/**
* PlotlyConvergencePlot - Interactive convergence plot using Plotly
*
* Features:
* - Line plot showing objective vs trial number
* - Best-so-far trace overlay
* - FEA vs NN trial differentiation
* - Hover tooltips with trial details
* - Range slider for zooming
* - Log scale toggle
* - Export to PNG/SVG
*/
import { useMemo, useState } from 'react';
import Plot from 'react-plotly.js';
interface Trial {
trial_number: number;
values: number[];
params: Record<string, number>;
user_attrs?: Record<string, any>;
source?: 'FEA' | 'NN' | 'V10_FEA';
constraint_satisfied?: boolean;
}
// Penalty threshold - objectives above this are considered failed/penalty trials
const PENALTY_THRESHOLD = 100000;
interface PlotlyConvergencePlotProps {
trials: Trial[];
objectiveIndex?: number;
objectiveName?: string;
direction?: 'minimize' | 'maximize';
height?: number;
showRangeSlider?: boolean;
showLogScaleToggle?: boolean;
}
export function PlotlyConvergencePlot({
trials,
objectiveIndex = 0,
objectiveName = 'Objective',
direction = 'minimize',
height = 400,
showRangeSlider = true,
showLogScaleToggle = true
}: PlotlyConvergencePlotProps) {
const [useLogScale, setUseLogScale] = useState(false);
// Process trials and calculate best-so-far
const { feaData, nnData, bestSoFar, allX, allY } = useMemo(() => {
if (!trials.length) return { feaData: { x: [], y: [], text: [] }, nnData: { x: [], y: [], text: [] }, bestSoFar: { x: [], y: [] }, allX: [], allY: [] };
// Sort by trial number
const sorted = [...trials].sort((a, b) => a.trial_number - b.trial_number);
const fea: { x: number[]; y: number[]; text: string[] } = { x: [], y: [], text: [] };
const nn: { x: number[]; y: number[]; text: string[] } = { x: [], y: [], text: [] };
const best: { x: number[]; y: number[] } = { x: [], y: [] };
const xs: number[] = [];
const ys: number[] = [];
let bestValue = direction === 'minimize' ? Infinity : -Infinity;
sorted.forEach(t => {
const val = t.values?.[objectiveIndex] ?? t.user_attrs?.[objectiveName] ?? null;
if (val === null || !isFinite(val)) return;
// Filter out failed/penalty trials:
// 1. Objective above penalty threshold (e.g., 1000000 = solver failure)
// 2. constraint_satisfied explicitly false
// 3. user_attrs indicates pruned/failed
const isPenalty = val >= PENALTY_THRESHOLD;
const isFailed = t.constraint_satisfied === false;
const isPruned = t.user_attrs?.pruned === true || t.user_attrs?.fail_reason;
if (isPenalty || isFailed || isPruned) return;
const source = t.source || t.user_attrs?.source || 'FEA';
const hoverText = `Trial #${t.trial_number}<br>${objectiveName}: ${val.toFixed(4)}<br>Source: ${source}`;
xs.push(t.trial_number);
ys.push(val);
if (source === 'NN') {
nn.x.push(t.trial_number);
nn.y.push(val);
nn.text.push(hoverText);
} else {
fea.x.push(t.trial_number);
fea.y.push(val);
fea.text.push(hoverText);
}
// Update best-so-far
if (direction === 'minimize') {
if (val < bestValue) bestValue = val;
} else {
if (val > bestValue) bestValue = val;
}
best.x.push(t.trial_number);
best.y.push(bestValue);
});
return { feaData: fea, nnData: nn, bestSoFar: best, allX: xs, allY: ys };
}, [trials, objectiveIndex, objectiveName, direction]);
if (!trials.length || allX.length === 0) {
return (
<div className="flex items-center justify-center h-64 text-gray-500">
No trial data available
</div>
);
}
const traces: any[] = [];
// FEA trials scatter
if (feaData.x.length > 0) {
traces.push({
type: 'scatter',
mode: 'markers',
name: `FEA (${feaData.x.length})`,
x: feaData.x,
y: feaData.y,
text: feaData.text,
hoverinfo: 'text',
marker: {
color: '#3B82F6',
size: 8,
opacity: 0.7,
line: { color: '#1E40AF', width: 1 }
}
});
}
// NN trials scatter
if (nnData.x.length > 0) {
traces.push({
type: 'scatter',
mode: 'markers',
name: `NN (${nnData.x.length})`,
x: nnData.x,
y: nnData.y,
text: nnData.text,
hoverinfo: 'text',
marker: {
color: '#F97316',
size: 6,
symbol: 'cross',
opacity: 0.6
}
});
}
// Best-so-far line
if (bestSoFar.x.length > 0) {
traces.push({
type: 'scatter',
mode: 'lines',
name: 'Best So Far',
x: bestSoFar.x,
y: bestSoFar.y,
line: {
color: '#10B981',
width: 3,
shape: 'hv' // Step line
},
hoverinfo: 'y'
});
}
const layout: any = {
height,
margin: { l: 60, r: 30, t: 30, b: showRangeSlider ? 80 : 50 },
paper_bgcolor: 'rgba(0,0,0,0)',
plot_bgcolor: 'rgba(0,0,0,0)',
xaxis: {
title: 'Trial Number',
gridcolor: '#E5E7EB',
zerolinecolor: '#D1D5DB',
rangeslider: showRangeSlider ? { visible: true } : undefined
},
yaxis: {
title: useLogScale ? `log₁₀(${objectiveName})` : objectiveName,
gridcolor: '#E5E7EB',
zerolinecolor: '#D1D5DB',
type: useLogScale ? 'log' : 'linear'
},
legend: {
x: 1,
y: 1,
xanchor: 'right',
bgcolor: 'rgba(255,255,255,0.8)',
bordercolor: '#E5E7EB',
borderwidth: 1
},
font: { family: 'Inter, system-ui, sans-serif' },
hovermode: 'closest'
};
// Best value annotation
const bestVal = direction === 'minimize'
? Math.min(...allY)
: Math.max(...allY);
const bestIdx = allY.indexOf(bestVal);
const bestTrial = allX[bestIdx];
return (
<div className="w-full">
{/* Summary stats and controls */}
<div className="flex items-center justify-between mb-3">
<div className="flex gap-6 text-sm">
<div className="text-gray-600">
Best: <span className="font-semibold text-green-600">{bestVal.toFixed(4)}</span>
<span className="text-gray-400 ml-1">(Trial #{bestTrial})</span>
</div>
<div className="text-gray-600">
Current: <span className="font-semibold">{allY[allY.length - 1].toFixed(4)}</span>
</div>
<div className="text-gray-600">
Trials: <span className="font-semibold">{allX.length}</span>
</div>
</div>
{/* Log scale toggle */}
{showLogScaleToggle && (
<button
onClick={() => setUseLogScale(!useLogScale)}
className={`px-3 py-1 text-xs rounded transition-colors ${
useLogScale
? 'bg-blue-600 text-white'
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
}`}
title="Toggle logarithmic scale - better for viewing early improvements"
>
{useLogScale ? 'Log Scale' : 'Linear Scale'}
</button>
)}
</div>
<Plot
data={traces}
layout={layout}
config={{
displayModeBar: true,
displaylogo: false,
modeBarButtonsToRemove: ['lasso2d', 'select2d'],
toImageButtonOptions: {
format: 'png',
filename: 'convergence_plot',
height: 600,
width: 1200,
scale: 2
}
}}
style={{ width: '100%' }}
/>
</div>
);
}

View File

@@ -1,161 +0,0 @@
import { useMemo } from 'react';
import Plot from 'react-plotly.js';
interface TrialData {
trial_number: number;
values: number[];
params: Record<string, number>;
}
interface PlotlyCorrelationHeatmapProps {
trials: TrialData[];
objectiveName?: string;
height?: number;
}
// Calculate Pearson correlation coefficient
function pearsonCorrelation(x: number[], y: number[]): number {
const n = x.length;
if (n === 0 || n !== y.length) return 0;
const meanX = x.reduce((a, b) => a + b, 0) / n;
const meanY = y.reduce((a, b) => a + b, 0) / n;
let numerator = 0;
let denomX = 0;
let denomY = 0;
for (let i = 0; i < n; i++) {
const dx = x[i] - meanX;
const dy = y[i] - meanY;
numerator += dx * dy;
denomX += dx * dx;
denomY += dy * dy;
}
const denominator = Math.sqrt(denomX) * Math.sqrt(denomY);
return denominator === 0 ? 0 : numerator / denominator;
}
export function PlotlyCorrelationHeatmap({
trials,
objectiveName = 'Objective',
height = 500
}: PlotlyCorrelationHeatmapProps) {
const { matrix, labels, annotations } = useMemo(() => {
if (trials.length < 3) {
return { matrix: [], labels: [], annotations: [] };
}
// Get parameter names
const paramNames = Object.keys(trials[0].params);
const allLabels = [...paramNames, objectiveName];
// Extract data columns
const columns: Record<string, number[]> = {};
paramNames.forEach(name => {
columns[name] = trials.map(t => t.params[name]).filter(v => v !== undefined && !isNaN(v));
});
columns[objectiveName] = trials.map(t => t.values[0]).filter(v => v !== undefined && !isNaN(v));
// Calculate correlation matrix
const n = allLabels.length;
const correlationMatrix: number[][] = [];
const annotationData: any[] = [];
for (let i = 0; i < n; i++) {
const row: number[] = [];
for (let j = 0; j < n; j++) {
const col1 = columns[allLabels[i]];
const col2 = columns[allLabels[j]];
// Ensure same length
const minLen = Math.min(col1.length, col2.length);
const corr = pearsonCorrelation(col1.slice(0, minLen), col2.slice(0, minLen));
row.push(corr);
// Add annotation
annotationData.push({
x: allLabels[j],
y: allLabels[i],
text: corr.toFixed(2),
showarrow: false,
font: {
color: Math.abs(corr) > 0.5 ? '#fff' : '#888',
size: 11
}
});
}
correlationMatrix.push(row);
}
return {
matrix: correlationMatrix,
labels: allLabels,
annotations: annotationData
};
}, [trials, objectiveName]);
if (trials.length < 3) {
return (
<div className="h-64 flex items-center justify-center text-dark-400">
<p>Need at least 3 trials to compute correlations</p>
</div>
);
}
return (
<Plot
data={[
{
z: matrix,
x: labels,
y: labels,
type: 'heatmap',
colorscale: [
[0, '#ef4444'], // -1: strong negative (red)
[0.25, '#f87171'], // -0.5: moderate negative
[0.5, '#1a1b26'], // 0: no correlation (dark)
[0.75, '#60a5fa'], // 0.5: moderate positive
[1, '#3b82f6'] // 1: strong positive (blue)
],
zmin: -1,
zmax: 1,
showscale: true,
colorbar: {
title: { text: 'Correlation', font: { color: '#888' } },
tickfont: { color: '#888' },
len: 0.8
},
hovertemplate: '%{y} vs %{x}<br>Correlation: %{z:.3f}<extra></extra>'
}
]}
layout={{
title: {
text: 'Parameter-Objective Correlation Matrix',
font: { color: '#fff', size: 14 }
},
height,
margin: { l: 120, r: 60, t: 60, b: 120 },
paper_bgcolor: 'transparent',
plot_bgcolor: 'transparent',
xaxis: {
tickangle: 45,
tickfont: { color: '#888', size: 10 },
gridcolor: 'rgba(255,255,255,0.05)'
},
yaxis: {
tickfont: { color: '#888', size: 10 },
gridcolor: 'rgba(255,255,255,0.05)'
},
annotations: annotations
}}
config={{
displayModeBar: true,
modeBarButtonsToRemove: ['lasso2d', 'select2d'],
displaylogo: false
}}
style={{ width: '100%' }}
/>
);
}

View File

@@ -1,120 +0,0 @@
import { useMemo } from 'react';
import Plot from 'react-plotly.js';
interface TrialData {
trial_number: number;
values: number[];
constraint_satisfied?: boolean;
}
interface PlotlyFeasibilityChartProps {
trials: TrialData[];
height?: number;
}
export function PlotlyFeasibilityChart({
trials,
height = 350
}: PlotlyFeasibilityChartProps) {
const { trialNumbers, cumulativeFeasibility, windowedFeasibility } = useMemo(() => {
if (trials.length === 0) {
return { trialNumbers: [], cumulativeFeasibility: [], windowedFeasibility: [] };
}
// Sort trials by number
const sorted = [...trials].sort((a, b) => a.trial_number - b.trial_number);
const numbers: number[] = [];
const cumulative: number[] = [];
const windowed: number[] = [];
let feasibleCount = 0;
const windowSize = Math.min(20, Math.floor(sorted.length / 5) || 1);
sorted.forEach((trial, idx) => {
numbers.push(trial.trial_number);
// Cumulative feasibility
if (trial.constraint_satisfied !== false) {
feasibleCount++;
}
cumulative.push((feasibleCount / (idx + 1)) * 100);
// Windowed (rolling) feasibility
const windowStart = Math.max(0, idx - windowSize + 1);
const windowTrials = sorted.slice(windowStart, idx + 1);
const windowFeasible = windowTrials.filter(t => t.constraint_satisfied !== false).length;
windowed.push((windowFeasible / windowTrials.length) * 100);
});
return { trialNumbers: numbers, cumulativeFeasibility: cumulative, windowedFeasibility: windowed };
}, [trials]);
if (trials.length === 0) {
return (
<div className="h-64 flex items-center justify-center text-dark-400">
<p>No trials to display</p>
</div>
);
}
return (
<Plot
data={[
{
x: trialNumbers,
y: cumulativeFeasibility,
type: 'scatter',
mode: 'lines',
name: 'Cumulative Feasibility',
line: { color: '#22c55e', width: 2 },
hovertemplate: 'Trial %{x}<br>Cumulative: %{y:.1f}%<extra></extra>'
},
{
x: trialNumbers,
y: windowedFeasibility,
type: 'scatter',
mode: 'lines',
name: 'Rolling (20-trial)',
line: { color: '#60a5fa', width: 2, dash: 'dot' },
hovertemplate: 'Trial %{x}<br>Rolling: %{y:.1f}%<extra></extra>'
}
]}
layout={{
height,
margin: { l: 60, r: 30, t: 30, b: 50 },
paper_bgcolor: 'transparent',
plot_bgcolor: 'transparent',
xaxis: {
title: { text: 'Trial Number', font: { color: '#888' } },
tickfont: { color: '#888' },
gridcolor: 'rgba(255,255,255,0.05)',
zeroline: false
},
yaxis: {
title: { text: 'Feasibility Rate (%)', font: { color: '#888' } },
tickfont: { color: '#888' },
gridcolor: 'rgba(255,255,255,0.1)',
zeroline: false,
range: [0, 105]
},
legend: {
font: { color: '#888' },
bgcolor: 'rgba(0,0,0,0.5)',
x: 0.02,
y: 0.98,
xanchor: 'left',
yanchor: 'top'
},
showlegend: true,
hovermode: 'x unified'
}}
config={{
displayModeBar: true,
modeBarButtonsToRemove: ['lasso2d', 'select2d'],
displaylogo: false
}}
style={{ width: '100%' }}
/>
);
}

View File

@@ -1,221 +0,0 @@
/**
* PlotlyParallelCoordinates - Interactive parallel coordinates plot using Plotly
*
* Features:
* - Native zoom, pan, and selection
* - Hover tooltips with trial details
* - Brush filtering on each axis
* - FEA vs NN color differentiation
* - Export to PNG/SVG
*/
import { useMemo } from 'react';
import Plot from 'react-plotly.js';
interface Trial {
trial_number: number;
values: number[];
params: Record<string, number>;
user_attrs?: Record<string, any>;
constraint_satisfied?: boolean;
source?: 'FEA' | 'NN' | 'V10_FEA';
}
interface Objective {
name: string;
direction?: 'minimize' | 'maximize';
unit?: string;
}
interface DesignVariable {
name: string;
unit?: string;
min?: number;
max?: number;
}
interface PlotlyParallelCoordinatesProps {
trials: Trial[];
objectives: Objective[];
designVariables: DesignVariable[];
paretoFront?: Trial[];
height?: number;
}
export function PlotlyParallelCoordinates({
trials,
objectives,
designVariables,
paretoFront = [],
height = 500
}: PlotlyParallelCoordinatesProps) {
// Create set of Pareto front trial numbers
const paretoSet = useMemo(() => new Set(paretoFront.map(t => t.trial_number)), [paretoFront]);
// Build dimensions array for parallel coordinates
const { dimensions, colorValues, colorScale } = useMemo(() => {
if (!trials.length) return { dimensions: [], colorValues: [], colorScale: [] };
const dims: any[] = [];
const colors: number[] = [];
// Get all design variable names
const dvNames = designVariables.map(dv => dv.name);
const objNames = objectives.map(obj => obj.name);
// Add design variable dimensions
dvNames.forEach((name, idx) => {
const dv = designVariables[idx];
const values = trials.map(t => t.params[name] ?? 0);
const validValues = values.filter(v => v !== null && v !== undefined && isFinite(v));
if (validValues.length === 0) return;
dims.push({
label: name,
values: values,
range: [
dv?.min ?? Math.min(...validValues),
dv?.max ?? Math.max(...validValues)
],
constraintrange: undefined
});
});
// Add objective dimensions
objNames.forEach((name, idx) => {
const obj = objectives[idx];
const values = trials.map(t => {
// Try to get from values array first, then user_attrs
if (t.values && t.values[idx] !== undefined) {
return t.values[idx];
}
return t.user_attrs?.[name] ?? 0;
});
const validValues = values.filter(v => v !== null && v !== undefined && isFinite(v));
if (validValues.length === 0) return;
dims.push({
label: `${name}${obj.unit ? ` (${obj.unit})` : ''}`,
values: values,
range: [Math.min(...validValues) * 0.95, Math.max(...validValues) * 1.05]
});
});
// Build color array: 0 = V10_FEA, 1 = FEA, 2 = NN, 3 = Pareto
trials.forEach(t => {
const source = t.source || t.user_attrs?.source || 'FEA';
const isPareto = paretoSet.has(t.trial_number);
if (isPareto) {
colors.push(3); // Pareto - special color
} else if (source === 'NN') {
colors.push(2); // NN trials
} else if (source === 'V10_FEA') {
colors.push(0); // V10 FEA
} else {
colors.push(1); // V11 FEA
}
});
// Color scale: V10_FEA (light blue), FEA (blue), NN (orange), Pareto (green)
const scale: [number, string][] = [
[0, '#93C5FD'], // V10_FEA - light blue
[0.33, '#2563EB'], // FEA - blue
[0.66, '#F97316'], // NN - orange
[1, '#10B981'] // Pareto - green
];
return { dimensions: dims, colorValues: colors, colorScale: scale };
}, [trials, objectives, designVariables, paretoSet]);
if (!trials.length || dimensions.length === 0) {
return (
<div className="flex items-center justify-center h-64 text-gray-500">
No trial data available for parallel coordinates
</div>
);
}
// Count trial types for legend
const feaCount = trials.filter(t => {
const source = t.source || t.user_attrs?.source || 'FEA';
return source === 'FEA' || source === 'V10_FEA';
}).length;
const nnCount = trials.filter(t => {
const source = t.source || t.user_attrs?.source || 'FEA';
return source === 'NN';
}).length;
return (
<div className="w-full">
{/* Legend */}
<div className="flex gap-4 justify-center mb-2 text-sm">
<div className="flex items-center gap-1.5">
<div className="w-4 h-1 rounded" style={{ backgroundColor: '#2563EB' }} />
<span className="text-gray-600">FEA ({feaCount})</span>
</div>
<div className="flex items-center gap-1.5">
<div className="w-4 h-1 rounded" style={{ backgroundColor: '#F97316' }} />
<span className="text-gray-600">NN ({nnCount})</span>
</div>
{paretoFront.length > 0 && (
<div className="flex items-center gap-1.5">
<div className="w-4 h-1 rounded" style={{ backgroundColor: '#10B981' }} />
<span className="text-gray-600">Pareto ({paretoFront.length})</span>
</div>
)}
</div>
<Plot
data={[
{
type: 'parcoords',
line: {
color: colorValues,
colorscale: colorScale as any,
showscale: false
},
dimensions: dimensions,
labelangle: -30,
labelfont: {
size: 11,
color: '#374151'
},
tickfont: {
size: 10,
color: '#6B7280'
}
} as any
]}
layout={{
height: height,
margin: { l: 80, r: 80, t: 30, b: 30 },
paper_bgcolor: 'rgba(0,0,0,0)',
plot_bgcolor: 'rgba(0,0,0,0)',
font: {
family: 'Inter, system-ui, sans-serif'
}
}}
config={{
displayModeBar: true,
displaylogo: false,
modeBarButtonsToRemove: ['lasso2d', 'select2d'],
toImageButtonOptions: {
format: 'png',
filename: 'parallel_coordinates',
height: 800,
width: 1400,
scale: 2
}
}}
style={{ width: '100%' }}
/>
<p className="text-xs text-gray-500 text-center mt-2">
Drag along axes to filter. Double-click to reset.
</p>
</div>
);
}

View File

@@ -1,209 +0,0 @@
/**
* PlotlyParameterImportance - Interactive parameter importance chart using Plotly
*
* Features:
* - Horizontal bar chart showing correlation/importance
* - Color coding by positive/negative correlation
* - Hover tooltips with details
* - Sortable by importance
*/
import { useMemo, useState } from 'react';
import Plot from 'react-plotly.js';
interface Trial {
trial_number: number;
values: number[];
params: Record<string, number>;
user_attrs?: Record<string, any>;
}
interface DesignVariable {
name: string;
unit?: string;
}
interface PlotlyParameterImportanceProps {
trials: Trial[];
designVariables: DesignVariable[];
objectiveIndex?: number;
objectiveName?: string;
height?: number;
}
// Calculate Pearson correlation coefficient
function pearsonCorrelation(x: number[], y: number[]): number {
const n = x.length;
if (n === 0) return 0;
const sumX = x.reduce((a, b) => a + b, 0);
const sumY = y.reduce((a, b) => a + b, 0);
const sumXY = x.reduce((acc, xi, i) => acc + xi * y[i], 0);
const sumX2 = x.reduce((acc, xi) => acc + xi * xi, 0);
const sumY2 = y.reduce((acc, yi) => acc + yi * yi, 0);
const numerator = n * sumXY - sumX * sumY;
const denominator = Math.sqrt((n * sumX2 - sumX * sumX) * (n * sumY2 - sumY * sumY));
if (denominator === 0) return 0;
return numerator / denominator;
}
export function PlotlyParameterImportance({
trials,
designVariables,
objectiveIndex = 0,
objectiveName = 'Objective',
height = 400
}: PlotlyParameterImportanceProps) {
const [sortBy, setSortBy] = useState<'importance' | 'name'>('importance');
// Calculate correlations for each parameter
const correlations = useMemo(() => {
if (!trials.length || !designVariables.length) return [];
// Get objective values
const objValues = trials.map(t => {
if (t.values && t.values[objectiveIndex] !== undefined) {
return t.values[objectiveIndex];
}
return t.user_attrs?.[objectiveName] ?? null;
}).filter((v): v is number => v !== null && isFinite(v));
if (objValues.length < 3) return []; // Need at least 3 points for correlation
const results: { name: string; correlation: number; absCorrelation: number }[] = [];
designVariables.forEach(dv => {
const paramValues = trials
.map((t) => {
const objVal = t.values?.[objectiveIndex] ?? t.user_attrs?.[objectiveName];
if (objVal === null || objVal === undefined || !isFinite(objVal)) return null;
return { param: t.params[dv.name], obj: objVal };
})
.filter((v): v is { param: number; obj: number } => v !== null && v.param !== undefined);
if (paramValues.length < 3) return;
const x = paramValues.map(v => v.param);
const y = paramValues.map(v => v.obj);
const corr = pearsonCorrelation(x, y);
results.push({
name: dv.name,
correlation: corr,
absCorrelation: Math.abs(corr)
});
});
// Sort by absolute correlation or name
if (sortBy === 'importance') {
results.sort((a, b) => b.absCorrelation - a.absCorrelation);
} else {
results.sort((a, b) => a.name.localeCompare(b.name));
}
return results;
}, [trials, designVariables, objectiveIndex, objectiveName, sortBy]);
if (!correlations.length) {
return (
<div className="flex items-center justify-center h-64 text-gray-500">
Not enough data to calculate parameter importance
</div>
);
}
// Build bar chart data
const names = correlations.map(c => c.name);
const values = correlations.map(c => c.correlation);
const colors = values.map(v => v > 0 ? '#EF4444' : '#22C55E'); // Red for positive (worse), Green for negative (better) when minimizing
const hoverTexts = correlations.map(c =>
`${c.name}<br>Correlation: ${c.correlation.toFixed(4)}<br>|r|: ${c.absCorrelation.toFixed(4)}<br>${c.correlation > 0 ? 'Higher → Higher objective' : 'Higher → Lower objective'}`
);
return (
<div className="w-full">
{/* Controls */}
<div className="flex justify-between items-center mb-3">
<div className="text-sm text-gray-600">
Correlation with <span className="font-semibold">{objectiveName}</span>
</div>
<div className="flex gap-2">
<button
onClick={() => setSortBy('importance')}
className={`px-3 py-1 text-xs rounded ${sortBy === 'importance' ? 'bg-blue-500 text-white' : 'bg-gray-100 text-gray-700'}`}
>
By Importance
</button>
<button
onClick={() => setSortBy('name')}
className={`px-3 py-1 text-xs rounded ${sortBy === 'name' ? 'bg-blue-500 text-white' : 'bg-gray-100 text-gray-700'}`}
>
By Name
</button>
</div>
</div>
<Plot
data={[
{
type: 'bar',
orientation: 'h',
y: names,
x: values,
text: hoverTexts,
hoverinfo: 'text',
marker: {
color: colors,
line: { color: '#fff', width: 1 }
}
}
]}
layout={{
height: Math.max(height, correlations.length * 30 + 80),
margin: { l: 150, r: 30, t: 10, b: 50 },
paper_bgcolor: 'rgba(0,0,0,0)',
plot_bgcolor: 'rgba(0,0,0,0)',
xaxis: {
title: { text: 'Correlation Coefficient' },
range: [-1, 1],
gridcolor: '#E5E7EB',
zerolinecolor: '#9CA3AF',
zerolinewidth: 2
},
yaxis: {
automargin: true
},
font: { family: 'Inter, system-ui, sans-serif', size: 11 },
bargap: 0.3
}}
config={{
displayModeBar: true,
displaylogo: false,
modeBarButtonsToRemove: ['lasso2d', 'select2d'],
toImageButtonOptions: {
format: 'png',
filename: 'parameter_importance',
height: 600,
width: 800,
scale: 2
}
}}
style={{ width: '100%' }}
/>
{/* Legend */}
<div className="flex gap-6 justify-center mt-3 text-xs">
<div className="flex items-center gap-1.5">
<div className="w-4 h-3 rounded" style={{ backgroundColor: '#EF4444' }} />
<span className="text-gray-600">Positive correlation (higher param higher objective)</span>
</div>
<div className="flex items-center gap-1.5">
<div className="w-4 h-3 rounded" style={{ backgroundColor: '#22C55E' }} />
<span className="text-gray-600">Negative correlation (higher param lower objective)</span>
</div>
</div>
</div>
);
}

View File

@@ -1,448 +0,0 @@
/**
* PlotlyParetoPlot - Interactive Pareto front visualization using Plotly
*
* Features:
* - 2D scatter with Pareto front highlighted
* - 3D scatter for 3-objective problems
* - Hover tooltips with trial details
* - Pareto front connection line
* - FEA vs NN differentiation
* - Constraint satisfaction highlighting
* - Dark mode styling
* - Zoom, pan, and export
*/
import { useMemo, useState } from 'react';
import Plot from 'react-plotly.js';
interface Trial {
trial_number: number;
values: number[];
params: Record<string, number>;
user_attrs?: Record<string, any>;
source?: 'FEA' | 'NN' | 'V10_FEA';
constraint_satisfied?: boolean;
}
interface Objective {
name: string;
direction?: 'minimize' | 'maximize';
unit?: string;
}
interface PlotlyParetoPlotProps {
trials: Trial[];
paretoFront: Trial[];
objectives: Objective[];
height?: number;
showParetoLine?: boolean;
showInfeasible?: boolean;
}
export function PlotlyParetoPlot({
trials,
paretoFront,
objectives,
height = 500,
showParetoLine = true,
showInfeasible = true
}: PlotlyParetoPlotProps) {
const [viewMode, setViewMode] = useState<'2d' | '3d'>(objectives.length >= 3 ? '3d' : '2d');
const [selectedObjectives, setSelectedObjectives] = useState<[number, number, number]>([0, 1, 2]);
const paretoSet = useMemo(() => new Set(paretoFront.map(t => t.trial_number)), [paretoFront]);
// Separate trials by source, Pareto status, and constraint satisfaction
const { feaTrials, nnTrials, paretoTrials, infeasibleTrials, stats } = useMemo(() => {
const fea: Trial[] = [];
const nn: Trial[] = [];
const pareto: Trial[] = [];
const infeasible: Trial[] = [];
trials.forEach(t => {
const source = t.source || t.user_attrs?.source || 'FEA';
const isFeasible = t.constraint_satisfied !== false && t.user_attrs?.constraint_satisfied !== false;
if (!isFeasible && showInfeasible) {
infeasible.push(t);
} else if (paretoSet.has(t.trial_number)) {
pareto.push(t);
} else if (source === 'NN') {
nn.push(t);
} else {
fea.push(t);
}
});
// Calculate statistics
const stats = {
totalTrials: trials.length,
paretoCount: pareto.length,
feaCount: fea.length + pareto.filter(t => (t.source || 'FEA') !== 'NN').length,
nnCount: nn.length + pareto.filter(t => t.source === 'NN').length,
infeasibleCount: infeasible.length,
hypervolume: 0 // Could calculate if needed
};
return { feaTrials: fea, nnTrials: nn, paretoTrials: pareto, infeasibleTrials: infeasible, stats };
}, [trials, paretoSet, showInfeasible]);
// Helper to get objective value
const getObjValue = (trial: Trial, idx: number): number => {
if (trial.values && trial.values[idx] !== undefined) {
return trial.values[idx];
}
const objName = objectives[idx]?.name;
return trial.user_attrs?.[objName] ?? 0;
};
// Build hover text
const buildHoverText = (trial: Trial): string => {
const lines = [`Trial #${trial.trial_number}`];
objectives.forEach((obj, i) => {
const val = getObjValue(trial, i);
lines.push(`${obj.name}: ${val.toFixed(4)}${obj.unit ? ` ${obj.unit}` : ''}`);
});
const source = trial.source || trial.user_attrs?.source || 'FEA';
lines.push(`Source: ${source}`);
return lines.join('<br>');
};
// Create trace data
const createTrace = (
trialList: Trial[],
name: string,
color: string,
symbol: string,
size: number,
opacity: number
) => {
const [i, j, k] = selectedObjectives;
if (viewMode === '3d' && objectives.length >= 3) {
return {
type: 'scatter3d' as const,
mode: 'markers' as const,
name,
x: trialList.map(t => getObjValue(t, i)),
y: trialList.map(t => getObjValue(t, j)),
z: trialList.map(t => getObjValue(t, k)),
text: trialList.map(buildHoverText),
hoverinfo: 'text' as const,
marker: {
color,
size,
symbol,
opacity,
line: { color: '#fff', width: 1 }
}
};
} else {
return {
type: 'scatter' as const,
mode: 'markers' as const,
name,
x: trialList.map(t => getObjValue(t, i)),
y: trialList.map(t => getObjValue(t, j)),
text: trialList.map(buildHoverText),
hoverinfo: 'text' as const,
marker: {
color,
size,
symbol,
opacity,
line: { color: '#fff', width: 1 }
}
};
}
};
// Sort Pareto trials by first objective for line connection
const sortedParetoTrials = useMemo(() => {
const [i] = selectedObjectives;
return [...paretoTrials].sort((a, b) => getObjValue(a, i) - getObjValue(b, i));
}, [paretoTrials, selectedObjectives]);
// Create Pareto front line trace (2D only)
const createParetoLine = () => {
if (!showParetoLine || viewMode === '3d' || sortedParetoTrials.length < 2) return null;
const [i, j] = selectedObjectives;
return {
type: 'scatter' as const,
mode: 'lines' as const,
name: 'Pareto Front',
x: sortedParetoTrials.map(t => getObjValue(t, i)),
y: sortedParetoTrials.map(t => getObjValue(t, j)),
line: {
color: '#10B981',
width: 2,
dash: 'dot'
},
hoverinfo: 'skip' as const,
showlegend: false
};
};
const traces = [
// Infeasible trials (background, red X)
...(showInfeasible && infeasibleTrials.length > 0 ? [
createTrace(infeasibleTrials, `Infeasible (${infeasibleTrials.length})`, '#EF4444', 'x', 7, 0.4)
] : []),
// FEA trials (blue circles)
createTrace(feaTrials, `FEA (${feaTrials.length})`, '#3B82F6', 'circle', 8, 0.6),
// NN trials (purple diamonds)
createTrace(nnTrials, `NN (${nnTrials.length})`, '#A855F7', 'diamond', 8, 0.5),
// Pareto front line (2D only)
createParetoLine(),
// Pareto front points (highlighted)
createTrace(sortedParetoTrials, `Pareto (${sortedParetoTrials.length})`, '#10B981', 'star', 14, 1.0)
].filter(trace => trace && (trace.x as number[]).length > 0);
const [i, j, k] = selectedObjectives;
// Dark mode color scheme
const colors = {
text: '#E5E7EB',
textMuted: '#9CA3AF',
grid: 'rgba(255,255,255,0.1)',
zeroline: 'rgba(255,255,255,0.2)',
legendBg: 'rgba(30,30,30,0.9)',
legendBorder: 'rgba(255,255,255,0.1)'
};
const layout: any = viewMode === '3d' && objectives.length >= 3
? {
height,
margin: { l: 50, r: 50, t: 30, b: 50 },
paper_bgcolor: 'transparent',
plot_bgcolor: 'transparent',
scene: {
xaxis: {
title: { text: objectives[i]?.name || 'Objective 1', font: { color: colors.text } },
gridcolor: colors.grid,
zerolinecolor: colors.zeroline,
tickfont: { color: colors.textMuted }
},
yaxis: {
title: { text: objectives[j]?.name || 'Objective 2', font: { color: colors.text } },
gridcolor: colors.grid,
zerolinecolor: colors.zeroline,
tickfont: { color: colors.textMuted }
},
zaxis: {
title: { text: objectives[k]?.name || 'Objective 3', font: { color: colors.text } },
gridcolor: colors.grid,
zerolinecolor: colors.zeroline,
tickfont: { color: colors.textMuted }
},
bgcolor: 'transparent'
},
legend: {
x: 1,
y: 1,
font: { color: colors.text },
bgcolor: colors.legendBg,
bordercolor: colors.legendBorder,
borderwidth: 1
},
font: { family: 'Inter, system-ui, sans-serif', color: colors.text }
}
: {
height,
margin: { l: 60, r: 30, t: 30, b: 60 },
paper_bgcolor: 'transparent',
plot_bgcolor: 'transparent',
xaxis: {
title: { text: objectives[i]?.name || 'Objective 1', font: { color: colors.text } },
gridcolor: colors.grid,
zerolinecolor: colors.zeroline,
tickfont: { color: colors.textMuted }
},
yaxis: {
title: { text: objectives[j]?.name || 'Objective 2', font: { color: colors.text } },
gridcolor: colors.grid,
zerolinecolor: colors.zeroline,
tickfont: { color: colors.textMuted }
},
legend: {
x: 1,
y: 1,
xanchor: 'right',
font: { color: colors.text },
bgcolor: colors.legendBg,
bordercolor: colors.legendBorder,
borderwidth: 1
},
font: { family: 'Inter, system-ui, sans-serif', color: colors.text },
hovermode: 'closest' as const
};
if (!trials.length) {
return (
<div className="flex items-center justify-center h-64 text-dark-400">
No trial data available
</div>
);
}
return (
<div className="w-full">
{/* Stats Bar */}
<div className="flex gap-4 mb-4 text-sm">
<div className="flex items-center gap-2 px-3 py-1.5 bg-dark-700 rounded-lg">
<div className="w-3 h-3 bg-green-500 rounded-full" />
<span className="text-dark-300">Pareto:</span>
<span className="text-green-400 font-medium">{stats.paretoCount}</span>
</div>
<div className="flex items-center gap-2 px-3 py-1.5 bg-dark-700 rounded-lg">
<div className="w-3 h-3 bg-blue-500 rounded-full" />
<span className="text-dark-300">FEA:</span>
<span className="text-blue-400 font-medium">{stats.feaCount}</span>
</div>
<div className="flex items-center gap-2 px-3 py-1.5 bg-dark-700 rounded-lg">
<div className="w-3 h-3 bg-purple-500 rounded-full" />
<span className="text-dark-300">NN:</span>
<span className="text-purple-400 font-medium">{stats.nnCount}</span>
</div>
{stats.infeasibleCount > 0 && (
<div className="flex items-center gap-2 px-3 py-1.5 bg-dark-700 rounded-lg">
<div className="w-3 h-3 bg-red-500 rounded-full" />
<span className="text-dark-300">Infeasible:</span>
<span className="text-red-400 font-medium">{stats.infeasibleCount}</span>
</div>
)}
</div>
{/* Controls */}
<div className="flex gap-4 items-center justify-between mb-3">
<div className="flex gap-2 items-center">
{objectives.length >= 3 && (
<div className="flex rounded-lg overflow-hidden border border-dark-600">
<button
onClick={() => setViewMode('2d')}
className={`px-3 py-1.5 text-sm font-medium transition-colors ${
viewMode === '2d'
? 'bg-primary-600 text-white'
: 'bg-dark-700 text-dark-300 hover:bg-dark-600 hover:text-white'
}`}
>
2D
</button>
<button
onClick={() => setViewMode('3d')}
className={`px-3 py-1.5 text-sm font-medium transition-colors ${
viewMode === '3d'
? 'bg-primary-600 text-white'
: 'bg-dark-700 text-dark-300 hover:bg-dark-600 hover:text-white'
}`}
>
3D
</button>
</div>
)}
</div>
{/* Objective selectors */}
<div className="flex gap-2 items-center text-sm">
<label className="text-dark-400">X:</label>
<select
value={selectedObjectives[0]}
onChange={(e) => setSelectedObjectives([parseInt(e.target.value), selectedObjectives[1], selectedObjectives[2]])}
className="px-2 py-1.5 bg-dark-700 border border-dark-600 rounded text-white text-sm"
>
{objectives.map((obj, idx) => (
<option key={idx} value={idx}>{obj.name}</option>
))}
</select>
<label className="text-dark-400 ml-2">Y:</label>
<select
value={selectedObjectives[1]}
onChange={(e) => setSelectedObjectives([selectedObjectives[0], parseInt(e.target.value), selectedObjectives[2]])}
className="px-2 py-1.5 bg-dark-700 border border-dark-600 rounded text-white text-sm"
>
{objectives.map((obj, idx) => (
<option key={idx} value={idx}>{obj.name}</option>
))}
</select>
{viewMode === '3d' && objectives.length >= 3 && (
<>
<label className="text-dark-400 ml-2">Z:</label>
<select
value={selectedObjectives[2]}
onChange={(e) => setSelectedObjectives([selectedObjectives[0], selectedObjectives[1], parseInt(e.target.value)])}
className="px-2 py-1.5 bg-dark-700 border border-dark-600 rounded text-white text-sm"
>
{objectives.map((obj, idx) => (
<option key={idx} value={idx}>{obj.name}</option>
))}
</select>
</>
)}
</div>
</div>
<Plot
data={traces as any}
layout={layout}
config={{
displayModeBar: true,
displaylogo: false,
modeBarButtonsToRemove: ['lasso2d', 'select2d'],
toImageButtonOptions: {
format: 'png',
filename: 'pareto_front',
height: 800,
width: 1200,
scale: 2
}
}}
style={{ width: '100%' }}
/>
{/* Pareto Front Table for 2D view */}
{viewMode === '2d' && sortedParetoTrials.length > 0 && (
<div className="mt-4 max-h-48 overflow-auto">
<table className="w-full text-sm">
<thead className="sticky top-0 bg-dark-800">
<tr className="border-b border-dark-600">
<th className="text-left py-2 px-3 text-dark-400 font-medium">Trial</th>
<th className="text-left py-2 px-3 text-dark-400 font-medium">{objectives[i]?.name || 'Obj 1'}</th>
<th className="text-left py-2 px-3 text-dark-400 font-medium">{objectives[j]?.name || 'Obj 2'}</th>
<th className="text-left py-2 px-3 text-dark-400 font-medium">Source</th>
</tr>
</thead>
<tbody>
{sortedParetoTrials.slice(0, 10).map(trial => (
<tr key={trial.trial_number} className="border-b border-dark-700 hover:bg-dark-750">
<td className="py-2 px-3 font-mono text-white">#{trial.trial_number}</td>
<td className="py-2 px-3 font-mono text-green-400">
{getObjValue(trial, i).toExponential(4)}
</td>
<td className="py-2 px-3 font-mono text-green-400">
{getObjValue(trial, j).toExponential(4)}
</td>
<td className="py-2 px-3">
<span className={`px-2 py-0.5 rounded text-xs ${
(trial.source || trial.user_attrs?.source) === 'NN'
? 'bg-purple-500/20 text-purple-400'
: 'bg-blue-500/20 text-blue-400'
}`}>
{trial.source || trial.user_attrs?.source || 'FEA'}
</span>
</td>
</tr>
))}
</tbody>
</table>
{sortedParetoTrials.length > 10 && (
<div className="text-center py-2 text-dark-500 text-xs">
Showing 10 of {sortedParetoTrials.length} Pareto-optimal solutions
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -1,247 +0,0 @@
import { useMemo } from 'react';
import Plot from 'react-plotly.js';
import { TrendingUp, TrendingDown, Minus } from 'lucide-react';
interface Run {
run_id: number;
name: string;
source: 'FEA' | 'NN';
trial_count: number;
best_value: number | null;
avg_value: number | null;
first_trial: string | null;
last_trial: string | null;
}
interface PlotlyRunComparisonProps {
runs: Run[];
height?: number;
}
export function PlotlyRunComparison({ runs, height = 400 }: PlotlyRunComparisonProps) {
const chartData = useMemo(() => {
if (runs.length === 0) return null;
// Separate FEA and NN runs
const feaRuns = runs.filter(r => r.source === 'FEA');
const nnRuns = runs.filter(r => r.source === 'NN');
// Create bar chart for trial counts
const trialCountData = {
x: runs.map(r => r.name),
y: runs.map(r => r.trial_count),
type: 'bar' as const,
name: 'Trial Count',
marker: {
color: runs.map(r => r.source === 'NN' ? 'rgba(147, 51, 234, 0.8)' : 'rgba(59, 130, 246, 0.8)'),
line: { color: runs.map(r => r.source === 'NN' ? 'rgb(147, 51, 234)' : 'rgb(59, 130, 246)'), width: 1 }
},
hovertemplate: '<b>%{x}</b><br>Trials: %{y}<extra></extra>'
};
// Create line chart for best values
const bestValueData = {
x: runs.map(r => r.name),
y: runs.map(r => r.best_value),
type: 'scatter' as const,
mode: 'lines+markers' as const,
name: 'Best Value',
yaxis: 'y2',
line: { color: 'rgba(16, 185, 129, 1)', width: 2 },
marker: { size: 8, color: 'rgba(16, 185, 129, 1)' },
hovertemplate: '<b>%{x}</b><br>Best: %{y:.4e}<extra></extra>'
};
return { trialCountData, bestValueData, feaRuns, nnRuns };
}, [runs]);
// Calculate statistics
const stats = useMemo(() => {
if (runs.length === 0) return null;
const totalTrials = runs.reduce((sum, r) => sum + r.trial_count, 0);
const feaTrials = runs.filter(r => r.source === 'FEA').reduce((sum, r) => sum + r.trial_count, 0);
const nnTrials = runs.filter(r => r.source === 'NN').reduce((sum, r) => sum + r.trial_count, 0);
const bestValues = runs.map(r => r.best_value).filter((v): v is number => v !== null);
const overallBest = bestValues.length > 0 ? Math.min(...bestValues) : null;
// Calculate improvement from first FEA run to overall best
const feaRuns = runs.filter(r => r.source === 'FEA');
const firstFEA = feaRuns.length > 0 ? feaRuns[0].best_value : null;
const improvement = firstFEA && overallBest ? ((firstFEA - overallBest) / Math.abs(firstFEA)) * 100 : null;
return {
totalTrials,
feaTrials,
nnTrials,
overallBest,
improvement,
totalRuns: runs.length,
feaRuns: runs.filter(r => r.source === 'FEA').length,
nnRuns: runs.filter(r => r.source === 'NN').length
};
}, [runs]);
if (!chartData || !stats) {
return (
<div className="flex items-center justify-center h-64 text-dark-400">
No run data available
</div>
);
}
return (
<div className="space-y-4">
{/* Stats Summary */}
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3">
<div className="bg-dark-750 rounded-lg p-3">
<div className="text-xs text-dark-400 mb-1">Total Runs</div>
<div className="text-xl font-bold text-white">{stats.totalRuns}</div>
</div>
<div className="bg-dark-750 rounded-lg p-3">
<div className="text-xs text-dark-400 mb-1">Total Trials</div>
<div className="text-xl font-bold text-white">{stats.totalTrials}</div>
</div>
<div className="bg-dark-750 rounded-lg p-3">
<div className="text-xs text-dark-400 mb-1">FEA Trials</div>
<div className="text-xl font-bold text-blue-400">{stats.feaTrials}</div>
</div>
<div className="bg-dark-750 rounded-lg p-3">
<div className="text-xs text-dark-400 mb-1">NN Trials</div>
<div className="text-xl font-bold text-purple-400">{stats.nnTrials}</div>
</div>
<div className="bg-dark-750 rounded-lg p-3">
<div className="text-xs text-dark-400 mb-1">Best Value</div>
<div className="text-xl font-bold text-green-400">
{stats.overallBest !== null ? stats.overallBest.toExponential(3) : 'N/A'}
</div>
</div>
<div className="bg-dark-750 rounded-lg p-3">
<div className="text-xs text-dark-400 mb-1">Improvement</div>
<div className="text-xl font-bold text-primary-400 flex items-center gap-1">
{stats.improvement !== null ? (
<>
{stats.improvement > 0 ? <TrendingDown className="w-4 h-4" /> :
stats.improvement < 0 ? <TrendingUp className="w-4 h-4" /> :
<Minus className="w-4 h-4" />}
{Math.abs(stats.improvement).toFixed(1)}%
</>
) : 'N/A'}
</div>
</div>
</div>
{/* Chart */}
<Plot
data={[chartData.trialCountData, chartData.bestValueData]}
layout={{
height,
margin: { l: 60, r: 60, t: 40, b: 100 },
paper_bgcolor: 'transparent',
plot_bgcolor: 'transparent',
font: { color: '#9ca3af', size: 11 },
showlegend: true,
legend: {
orientation: 'h',
y: 1.12,
x: 0.5,
xanchor: 'center',
bgcolor: 'transparent'
},
xaxis: {
tickangle: -45,
gridcolor: 'rgba(75, 85, 99, 0.3)',
linecolor: 'rgba(75, 85, 99, 0.5)',
tickfont: { size: 10 }
},
yaxis: {
title: { text: 'Trial Count' },
gridcolor: 'rgba(75, 85, 99, 0.3)',
linecolor: 'rgba(75, 85, 99, 0.5)',
zeroline: false
},
yaxis2: {
title: { text: 'Best Value' },
overlaying: 'y',
side: 'right',
gridcolor: 'rgba(75, 85, 99, 0.1)',
linecolor: 'rgba(75, 85, 99, 0.5)',
zeroline: false,
tickformat: '.2e'
},
bargap: 0.3,
hovermode: 'x unified'
}}
config={{
displayModeBar: true,
displaylogo: false,
modeBarButtonsToRemove: ['select2d', 'lasso2d', 'autoScale2d']
}}
className="w-full"
useResizeHandler
style={{ width: '100%' }}
/>
{/* Runs Table */}
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-dark-600">
<th className="text-left py-2 px-3 text-dark-400 font-medium">Run Name</th>
<th className="text-left py-2 px-3 text-dark-400 font-medium">Source</th>
<th className="text-right py-2 px-3 text-dark-400 font-medium">Trials</th>
<th className="text-right py-2 px-3 text-dark-400 font-medium">Best Value</th>
<th className="text-right py-2 px-3 text-dark-400 font-medium">Avg Value</th>
<th className="text-left py-2 px-3 text-dark-400 font-medium">Duration</th>
</tr>
</thead>
<tbody>
{runs.map((run) => {
// Calculate duration if times available
let duration = '-';
if (run.first_trial && run.last_trial) {
const start = new Date(run.first_trial);
const end = new Date(run.last_trial);
const diffMs = end.getTime() - start.getTime();
const diffMins = Math.round(diffMs / 60000);
if (diffMins < 60) {
duration = `${diffMins}m`;
} else {
const hours = Math.floor(diffMins / 60);
const mins = diffMins % 60;
duration = `${hours}h ${mins}m`;
}
}
return (
<tr key={run.run_id} className="border-b border-dark-700 hover:bg-dark-750">
<td className="py-2 px-3 font-mono text-white">{run.name}</td>
<td className="py-2 px-3">
<span className={`px-2 py-0.5 rounded text-xs ${
run.source === 'NN'
? 'bg-purple-500/20 text-purple-400'
: 'bg-blue-500/20 text-blue-400'
}`}>
{run.source}
</span>
</td>
<td className="py-2 px-3 text-right font-mono text-white">{run.trial_count}</td>
<td className="py-2 px-3 text-right font-mono text-green-400">
{run.best_value !== null ? run.best_value.toExponential(4) : '-'}
</td>
<td className="py-2 px-3 text-right font-mono text-dark-300">
{run.avg_value !== null ? run.avg_value.toExponential(4) : '-'}
</td>
<td className="py-2 px-3 text-dark-400">{duration}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
);
}
export default PlotlyRunComparison;

View File

@@ -1,202 +0,0 @@
import { useMemo } from 'react';
import Plot from 'react-plotly.js';
interface TrialData {
trial_number: number;
values: number[];
source?: 'FEA' | 'NN' | 'V10_FEA';
user_attrs?: Record<string, any>;
}
interface PlotlySurrogateQualityProps {
trials: TrialData[];
height?: number;
}
export function PlotlySurrogateQuality({
trials,
height = 400
}: PlotlySurrogateQualityProps) {
const { feaTrials, nnTrials, timeline } = useMemo(() => {
const fea = trials.filter(t => t.source === 'FEA' || t.source === 'V10_FEA');
const nn = trials.filter(t => t.source === 'NN');
// Sort by trial number for timeline
const sorted = [...trials].sort((a, b) => a.trial_number - b.trial_number);
// Calculate source distribution over time
const timeline: { trial: number; feaCount: number; nnCount: number }[] = [];
let feaCount = 0;
let nnCount = 0;
sorted.forEach(t => {
if (t.source === 'NN') nnCount++;
else feaCount++;
timeline.push({
trial: t.trial_number,
feaCount,
nnCount
});
});
return {
feaTrials: fea,
nnTrials: nn,
timeline
};
}, [trials]);
if (nnTrials.length === 0) {
return (
<div className="h-64 flex items-center justify-center text-dark-400">
<p>No neural network evaluations in this study</p>
</div>
);
}
// Objective distribution by source
const feaObjectives = feaTrials.map(t => t.values[0]).filter(v => v !== undefined && !isNaN(v));
const nnObjectives = nnTrials.map(t => t.values[0]).filter(v => v !== undefined && !isNaN(v));
return (
<div className="space-y-6">
{/* Source Distribution Over Time */}
<Plot
data={[
{
x: timeline.map(t => t.trial),
y: timeline.map(t => t.feaCount),
type: 'scatter',
mode: 'lines',
name: 'FEA Cumulative',
line: { color: '#3b82f6', width: 2 },
fill: 'tozeroy',
fillcolor: 'rgba(59, 130, 246, 0.2)'
},
{
x: timeline.map(t => t.trial),
y: timeline.map(t => t.nnCount),
type: 'scatter',
mode: 'lines',
name: 'NN Cumulative',
line: { color: '#a855f7', width: 2 },
fill: 'tozeroy',
fillcolor: 'rgba(168, 85, 247, 0.2)'
}
]}
layout={{
title: {
text: 'Evaluation Source Over Time',
font: { color: '#fff', size: 14 }
},
height: height * 0.6,
margin: { l: 60, r: 30, t: 50, b: 50 },
paper_bgcolor: 'transparent',
plot_bgcolor: 'transparent',
xaxis: {
title: { text: 'Trial Number', font: { color: '#888' } },
tickfont: { color: '#888' },
gridcolor: 'rgba(255,255,255,0.05)'
},
yaxis: {
title: { text: 'Cumulative Count', font: { color: '#888' } },
tickfont: { color: '#888' },
gridcolor: 'rgba(255,255,255,0.1)'
},
legend: {
font: { color: '#888' },
bgcolor: 'rgba(0,0,0,0.5)',
orientation: 'h',
y: 1.1
},
showlegend: true
}}
config={{
displayModeBar: true,
modeBarButtonsToRemove: ['lasso2d', 'select2d'],
displaylogo: false
}}
style={{ width: '100%' }}
/>
{/* Objective Distribution by Source */}
<Plot
data={[
{
x: feaObjectives,
type: 'histogram',
name: 'FEA',
marker: { color: 'rgba(59, 130, 246, 0.7)' },
opacity: 0.8
} as any,
{
x: nnObjectives,
type: 'histogram',
name: 'NN',
marker: { color: 'rgba(168, 85, 247, 0.7)' },
opacity: 0.8
} as any
]}
layout={{
title: {
text: 'Objective Distribution by Source',
font: { color: '#fff', size: 14 }
},
height: height * 0.5,
margin: { l: 60, r: 30, t: 50, b: 50 },
paper_bgcolor: 'transparent',
plot_bgcolor: 'transparent',
xaxis: {
title: { text: 'Objective Value', font: { color: '#888' } },
tickfont: { color: '#888' },
gridcolor: 'rgba(255,255,255,0.05)'
},
yaxis: {
title: { text: 'Count', font: { color: '#888' } },
tickfont: { color: '#888' },
gridcolor: 'rgba(255,255,255,0.1)'
},
barmode: 'overlay',
legend: {
font: { color: '#888' },
bgcolor: 'rgba(0,0,0,0.5)',
orientation: 'h',
y: 1.1
},
showlegend: true
}}
config={{
displayModeBar: true,
modeBarButtonsToRemove: ['lasso2d', 'select2d'],
displaylogo: false
}}
style={{ width: '100%' }}
/>
{/* FEA vs NN Best Values Comparison */}
{feaObjectives.length > 0 && nnObjectives.length > 0 && (
<div className="grid grid-cols-2 gap-4 mt-4">
<div className="bg-dark-750 rounded-lg p-4 border border-dark-600">
<div className="text-xs text-dark-400 uppercase mb-2">FEA Best</div>
<div className="text-xl font-mono text-blue-400">
{Math.min(...feaObjectives).toExponential(4)}
</div>
<div className="text-xs text-dark-500 mt-1">
from {feaObjectives.length} evaluations
</div>
</div>
<div className="bg-dark-750 rounded-lg p-4 border border-dark-600">
<div className="text-xs text-dark-400 uppercase mb-2">NN Best</div>
<div className="text-xl font-mono text-purple-400">
{Math.min(...nnObjectives).toExponential(4)}
</div>
<div className="text-xs text-dark-500 mt-1">
from {nnObjectives.length} predictions
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -1,217 +0,0 @@
# Plotly Chart Components
Interactive visualization components using Plotly.js for the Atomizer Dashboard.
## Overview
These components provide enhanced interactivity compared to Recharts:
- Native zoom, pan, and selection
- Export to PNG/SVG
- Hover tooltips with detailed information
- Brush filtering (parallel coordinates)
- 3D visualization support
## Components
### PlotlyParallelCoordinates
Multi-dimensional data visualization showing relationships between all variables.
```tsx
import { PlotlyParallelCoordinates } from '../components/plotly';
<PlotlyParallelCoordinates
trials={allTrials}
objectives={studyMetadata.objectives}
designVariables={studyMetadata.design_variables}
paretoFront={paretoFront}
height={450}
/>
```
**Props:**
| Prop | Type | Description |
|------|------|-------------|
| trials | Trial[] | All trial data |
| objectives | Objective[] | Objective definitions |
| designVariables | DesignVariable[] | Design variable definitions |
| paretoFront | Trial[] | Pareto-optimal trials (optional) |
| height | number | Chart height in pixels |
**Features:**
- Drag on axes to filter data
- Double-click to reset filters
- Color coding: FEA (blue), NN (orange), Pareto (green)
### PlotlyParetoPlot
2D/3D scatter plot for Pareto front visualization.
```tsx
<PlotlyParetoPlot
trials={allTrials}
paretoFront={paretoFront}
objectives={studyMetadata.objectives}
height={350}
/>
```
**Props:**
| Prop | Type | Description |
|------|------|-------------|
| trials | Trial[] | All trial data |
| paretoFront | Trial[] | Pareto-optimal trials |
| objectives | Objective[] | Objective definitions |
| height | number | Chart height in pixels |
**Features:**
- Toggle between 2D and 3D views
- Axis selector for multi-objective problems
- Click to select trials
- Hover for trial details
### PlotlyConvergencePlot
Optimization progress over trials.
```tsx
<PlotlyConvergencePlot
trials={allTrials}
objectiveIndex={0}
objectiveName="weighted_objective"
direction="minimize"
height={350}
/>
```
**Props:**
| Prop | Type | Description |
|------|------|-------------|
| trials | Trial[] | All trial data |
| objectiveIndex | number | Which objective to plot |
| objectiveName | string | Objective display name |
| direction | 'minimize' \| 'maximize' | Optimization direction |
| height | number | Chart height |
| showRangeSlider | boolean | Show zoom slider |
**Features:**
- Scatter points for each trial
- Best-so-far step line
- Range slider for zooming
- FEA vs NN differentiation
### PlotlyParameterImportance
Correlation-based parameter sensitivity analysis.
```tsx
<PlotlyParameterImportance
trials={allTrials}
designVariables={studyMetadata.design_variables}
objectiveIndex={0}
objectiveName="weighted_objective"
height={350}
/>
```
**Props:**
| Prop | Type | Description |
|------|------|-------------|
| trials | Trial[] | All trial data |
| designVariables | DesignVariable[] | Design variables |
| objectiveIndex | number | Which objective |
| objectiveName | string | Objective display name |
| height | number | Chart height |
**Features:**
- Horizontal bar chart of correlations
- Sort by importance or name
- Color: Red (positive), Green (negative)
- Pearson correlation coefficient
## Bundle Optimization
To minimize bundle size, we use:
1. **plotly.js-basic-dist**: Smaller bundle (~1MB vs 3.5MB)
- Includes: scatter, bar, parcoords
- Excludes: 3D plots, maps, animations
2. **Lazy Loading**: Components loaded on demand
```tsx
const PlotlyParetoPlot = lazy(() =>
import('./plotly/PlotlyParetoPlot')
.then(m => ({ default: m.PlotlyParetoPlot }))
);
```
3. **Code Splitting**: Vite config separates Plotly into its own chunk
```ts
manualChunks: {
plotly: ['plotly.js-basic-dist', 'react-plotly.js']
}
```
## Usage with Suspense
Always wrap Plotly components with Suspense:
```tsx
<Suspense fallback={<ChartLoading />}>
<PlotlyParetoPlot {...props} />
</Suspense>
```
## Type Definitions
```typescript
interface Trial {
trial_number: number;
values: number[];
params: Record<string, number>;
user_attrs?: Record<string, any>;
source?: 'FEA' | 'NN' | 'V10_FEA';
}
interface Objective {
name: string;
direction?: 'minimize' | 'maximize';
unit?: string;
}
interface DesignVariable {
name: string;
unit?: string;
min?: number;
max?: number;
}
```
## Styling
Components use transparent backgrounds for dark theme compatibility:
- `paper_bgcolor: 'rgba(0,0,0,0)'`
- `plot_bgcolor: 'rgba(0,0,0,0)'`
- Font: Inter, system-ui, sans-serif
- Grid colors: Tailwind gray palette
## Export Options
All Plotly charts include a mode bar with:
- Download PNG
- Download SVG (via menu)
- Zoom, Pan, Reset
- Auto-scale
Configure export in the `config` prop:
```tsx
config={{
toImageButtonOptions: {
format: 'png',
filename: 'my_chart',
height: 600,
width: 1200,
scale: 2
}
}}
```

View File

@@ -1,15 +0,0 @@
/**
* Plotly-based interactive chart components
*
* These components provide enhanced interactivity compared to Recharts:
* - Native zoom/pan
* - Brush selection on axes
* - 3D views for multi-objective problems
* - Export to PNG/SVG
* - Detailed hover tooltips
*/
export { PlotlyParallelCoordinates } from './PlotlyParallelCoordinates';
export { PlotlyParetoPlot } from './PlotlyParetoPlot';
export { PlotlyConvergencePlot } from './PlotlyConvergencePlot';
export { PlotlyParameterImportance } from './PlotlyParameterImportance';

View File

@@ -3,3 +3,27 @@ export { useCanvasStore } from './useCanvasStore';
export type { OptimizationConfig } from './useCanvasStore'; export type { OptimizationConfig } from './useCanvasStore';
export { useCanvasChat } from './useCanvasChat'; export { useCanvasChat } from './useCanvasChat';
export { useIntentParser } from './useIntentParser'; export { useIntentParser } from './useIntentParser';
// Spec Store (AtomizerSpec v2.0)
export {
useSpecStore,
useSpec,
useSpecLoading,
useSpecError,
useSpecValidation,
useSelectedNodeId,
useSelectedEdgeId,
useSpecHash,
useSpecIsDirty,
useDesignVariables,
useExtractors,
useObjectives,
useConstraints,
useCanvasEdges,
useSelectedNode,
} from './useSpecStore';
// WebSocket Sync
export { useSpecWebSocket } from './useSpecWebSocket';
export type { ConnectionStatus } from './useSpecWebSocket';
export { ConnectionStatusIndicator } from '../components/canvas/ConnectionStatusIndicator';

View File

@@ -1,3 +1,19 @@
/**
* @deprecated This store is deprecated as of January 2026.
* Use useSpecStore instead, which works with AtomizerSpec v2.0.
*
* Migration guide:
* - Import useSpecStore from '../hooks/useSpecStore' instead
* - Use spec.design_variables, spec.extractors, etc. instead of nodes/edges
* - Use addNode(), updateNode(), removeNode() instead of canvas mutations
* - Spec changes sync automatically via WebSocket
*
* This store is kept for emergency fallback only with AtomizerCanvas.
*
* @see useSpecStore for the new state management
* @see AtomizerSpec v2.0 documentation
*/
import { create } from 'zustand'; import { create } from 'zustand';
import { Node, Edge, addEdge, applyNodeChanges, applyEdgeChanges, Connection, NodeChange, EdgeChange } from 'reactflow'; import { Node, Edge, addEdge, applyNodeChanges, applyEdgeChanges, Connection, NodeChange, EdgeChange } from 'reactflow';
import { CanvasNodeData, NodeType } from '../lib/canvas/schema'; import { CanvasNodeData, NodeType } from '../lib/canvas/schema';

View File

@@ -11,12 +11,25 @@ export interface CanvasState {
studyPath?: string; studyPath?: string;
} }
export interface CanvasModification {
action: 'add_node' | 'update_node' | 'remove_node' | 'add_edge' | 'remove_edge';
nodeType?: string;
nodeId?: string;
edgeId?: string;
data?: Record<string, any>;
source?: string;
target?: string;
position?: { x: number; y: number };
}
interface UseChatOptions { interface UseChatOptions {
studyId?: string | null; studyId?: string | null;
mode?: ChatMode; mode?: ChatMode;
useWebSocket?: boolean; useWebSocket?: boolean;
canvasState?: CanvasState | null; canvasState?: CanvasState | null;
onError?: (error: string) => void; onError?: (error: string) => void;
onCanvasModification?: (modification: CanvasModification) => void;
onSpecUpdated?: (spec: any) => void; // Called when Claude modifies the spec
} }
interface ChatState { interface ChatState {
@@ -35,6 +48,8 @@ export function useChat({
useWebSocket = true, useWebSocket = true,
canvasState: initialCanvasState, canvasState: initialCanvasState,
onError, onError,
onCanvasModification,
onSpecUpdated,
}: UseChatOptions = {}) { }: UseChatOptions = {}) {
const [state, setState] = useState<ChatState>({ const [state, setState] = useState<ChatState>({
messages: [], messages: [],
@@ -49,6 +64,23 @@ export function useChat({
// Track canvas state for sending with messages // Track canvas state for sending with messages
const canvasStateRef = useRef<CanvasState | null>(initialCanvasState || null); const canvasStateRef = useRef<CanvasState | null>(initialCanvasState || null);
// Sync mode prop changes to internal state (triggers WebSocket reconnect)
useEffect(() => {
if (mode !== state.mode) {
console.log(`[useChat] Mode prop changed from ${state.mode} to ${mode}, triggering reconnect`);
// Close existing WebSocket
wsRef.current?.close();
wsRef.current = null;
// Update internal state to trigger reconnect
setState((prev) => ({
...prev,
mode,
sessionId: null,
isConnected: false,
}));
}
}, [mode]);
const abortControllerRef = useRef<AbortController | null>(null); const abortControllerRef = useRef<AbortController | null>(null);
const conversationHistoryRef = useRef<Array<{ role: string; content: string }>>([]); const conversationHistoryRef = useRef<Array<{ role: string; content: string }>>([]);
const wsRef = useRef<WebSocket | null>(null); const wsRef = useRef<WebSocket | null>(null);
@@ -82,9 +114,16 @@ export function useChat({
const data = await response.json(); const data = await response.json();
setState((prev) => ({ ...prev, sessionId: data.session_id })); setState((prev) => ({ ...prev, sessionId: data.session_id }));
// Connect WebSocket // Connect WebSocket - use backend directly in dev mode
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/api/claude/sessions/${data.session_id}/ws`; // Use port 8001 to match start-dashboard.bat
const backendHost = import.meta.env.DEV ? 'localhost:8001' : window.location.host;
// Both modes use the same WebSocket - mode is handled by session config
// Power mode uses --dangerously-skip-permissions in CLI
// User mode uses --allowedTools to restrict access
const wsPath = `/api/claude/sessions/${data.session_id}/ws`;
const wsUrl = `${protocol}//${backendHost}${wsPath}`;
console.log(`[useChat] Connecting to WebSocket (${state.mode} mode): ${wsUrl}`);
const ws = new WebSocket(wsUrl); const ws = new WebSocket(wsUrl);
ws.onopen = () => { ws.onopen = () => {
@@ -126,6 +165,9 @@ export function useChat({
// Handle WebSocket messages // Handle WebSocket messages
const handleWebSocketMessage = useCallback((data: any) => { const handleWebSocketMessage = useCallback((data: any) => {
// Debug: log all incoming WebSocket messages
console.log('[useChat] WebSocket message received:', data.type, data);
switch (data.type) { switch (data.type) {
case 'text': case 'text':
currentMessageRef.current += data.content || ''; currentMessageRef.current += data.content || '';
@@ -212,11 +254,51 @@ export function useChat({
// Canvas state was updated - could show notification // Canvas state was updated - could show notification
break; break;
case 'canvas_modification':
// Assistant wants to modify the canvas (from MCP tools in user mode)
console.log('[useChat] Received canvas_modification:', data.modification);
if (onCanvasModification && data.modification) {
console.log('[useChat] Calling onCanvasModification callback');
onCanvasModification(data.modification);
} else {
console.warn('[useChat] canvas_modification received but no handler or modification:', {
hasCallback: !!onCanvasModification,
modification: data.modification
});
}
break;
case 'spec_updated':
// Assistant modified the spec - we receive the full updated spec
console.log('[useChat] Spec updated by assistant:', data.tool, data.reason);
if (onSpecUpdated && data.spec) {
// Directly update the canvas with the new spec
onSpecUpdated(data.spec);
}
break;
case 'spec_modified':
// Legacy: Assistant modified the spec directly (from power mode write tools)
console.log('[useChat] Spec was modified by assistant (legacy):', data.tool, data.changes);
// Treat this as a canvas modification to trigger reload
if (onCanvasModification) {
// Create a synthetic modification event to trigger canvas refresh
onCanvasModification({
action: 'add_node', // Use add_node as it triggers refresh
data: {
_refresh: true,
tool: data.tool,
changes: data.changes,
},
});
}
break;
case 'pong': case 'pong':
// Heartbeat response - ignore // Heartbeat response - ignore
break; break;
} }
}, [onError]); }, [onError, onCanvasModification]);
// Switch mode (requires new session) // Switch mode (requires new session)
const switchMode = useCallback(async (newMode: ChatMode) => { const switchMode = useCallback(async (newMode: ChatMode) => {
@@ -462,6 +544,18 @@ export function useChat({
} }
}, [useWebSocket]); }, [useWebSocket]);
// Notify backend when user edits canvas (so Claude sees the changes)
const notifyCanvasEdit = useCallback((spec: any) => {
if (useWebSocket && wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(
JSON.stringify({
type: 'canvas_edit',
spec: spec,
})
);
}
}, [useWebSocket]);
return { return {
messages: state.messages, messages: state.messages,
isThinking: state.isThinking, isThinking: state.isThinking,
@@ -475,5 +569,6 @@ export function useChat({
cancelRequest, cancelRequest,
switchMode, switchMode,
updateCanvasState, updateCanvasState,
notifyCanvasEdit,
}; };
} }

View File

@@ -0,0 +1,349 @@
/**
* Hook for Claude Code CLI integration
*
* Connects to backend that spawns actual Claude Code CLI processes.
* This gives full power: file editing, command execution, etc.
*
* Unlike useChat (which uses MCP tools), this hook:
* - Spawns actual Claude Code CLI in the backend
* - Has full file system access
* - Can edit files directly (not just return instructions)
* - Uses Opus 4.5 model
* - Has all Claude Code capabilities
*/
import { useState, useCallback, useRef, useEffect } from 'react';
import { Message } from '../components/chat/ChatMessage';
import { useCanvasStore } from './useCanvasStore';
export interface CanvasState {
nodes: any[];
edges: any[];
studyName?: string;
studyPath?: string;
}
interface UseClaudeCodeOptions {
studyId?: string | null;
canvasState?: CanvasState | null;
onError?: (error: string) => void;
onCanvasRefresh?: (studyId: string) => void;
}
interface ClaudeCodeState {
messages: Message[];
isThinking: boolean;
error: string | null;
sessionId: string | null;
isConnected: boolean;
workingDir: string | null;
}
export function useClaudeCode({
studyId,
canvasState: initialCanvasState,
onError,
onCanvasRefresh,
}: UseClaudeCodeOptions = {}) {
const [state, setState] = useState<ClaudeCodeState>({
messages: [],
isThinking: false,
error: null,
sessionId: null,
isConnected: false,
workingDir: null,
});
// Track canvas state for sending with messages
const canvasStateRef = useRef<CanvasState | null>(initialCanvasState || null);
const wsRef = useRef<WebSocket | null>(null);
const currentMessageRef = useRef<string>('');
const reconnectAttempts = useRef(0);
const maxReconnectAttempts = 3;
// Keep canvas state in sync with prop changes
useEffect(() => {
if (initialCanvasState) {
canvasStateRef.current = initialCanvasState;
}
}, [initialCanvasState]);
// Get canvas store for auto-refresh
const { loadFromConfig } = useCanvasStore();
// Connect to Claude Code WebSocket
useEffect(() => {
const connect = () => {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
// In development, connect directly to backend (bypass Vite proxy for WebSockets)
// Use port 8001 to match start-dashboard.bat
const backendHost = import.meta.env.DEV ? 'localhost:8001' : window.location.host;
// Use study-specific endpoint if studyId provided
const wsUrl = studyId
? `${protocol}//${backendHost}/api/claude-code/ws/${encodeURIComponent(studyId)}`
: `${protocol}//${backendHost}/api/claude-code/ws`;
console.log('[ClaudeCode] Connecting to:', wsUrl);
const ws = new WebSocket(wsUrl);
ws.onopen = () => {
console.log('[ClaudeCode] Connected');
setState((prev) => ({ ...prev, isConnected: true, error: null }));
reconnectAttempts.current = 0;
// If no studyId in URL, send init message
if (!studyId) {
ws.send(JSON.stringify({ type: 'init', study_id: null }));
}
};
ws.onclose = () => {
console.log('[ClaudeCode] Disconnected');
setState((prev) => ({ ...prev, isConnected: false }));
// Attempt reconnection
if (reconnectAttempts.current < maxReconnectAttempts) {
reconnectAttempts.current++;
console.log(`[ClaudeCode] Reconnecting... attempt ${reconnectAttempts.current}`);
setTimeout(connect, 2000 * reconnectAttempts.current);
}
};
ws.onerror = (event) => {
console.error('[ClaudeCode] WebSocket error:', event);
setState((prev) => ({ ...prev, isConnected: false }));
onError?.('Claude Code connection error');
};
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
handleWebSocketMessage(data);
} catch (e) {
console.error('[ClaudeCode] Failed to parse message:', e);
}
};
wsRef.current = ws;
};
connect();
return () => {
reconnectAttempts.current = maxReconnectAttempts; // Prevent reconnection on unmount
wsRef.current?.close();
wsRef.current = null;
};
}, [studyId]);
// Handle WebSocket messages
const handleWebSocketMessage = useCallback(
(data: any) => {
switch (data.type) {
case 'initialized':
console.log('[ClaudeCode] Session initialized:', data.session_id);
setState((prev) => ({
...prev,
sessionId: data.session_id,
workingDir: data.working_dir || null,
}));
break;
case 'text':
currentMessageRef.current += data.content || '';
setState((prev) => ({
...prev,
messages: prev.messages.map((msg, idx) =>
idx === prev.messages.length - 1 && msg.role === 'assistant'
? { ...msg, content: currentMessageRef.current }
: msg
),
}));
break;
case 'done':
setState((prev) => ({
...prev,
isThinking: false,
messages: prev.messages.map((msg, idx) =>
idx === prev.messages.length - 1 && msg.role === 'assistant'
? { ...msg, isStreaming: false }
: msg
),
}));
currentMessageRef.current = '';
break;
case 'error':
console.error('[ClaudeCode] Error:', data.content);
setState((prev) => ({
...prev,
isThinking: false,
error: data.content || 'Unknown error',
}));
onError?.(data.content || 'Unknown error');
currentMessageRef.current = '';
break;
case 'refresh_canvas':
// Claude made file changes - trigger canvas refresh
console.log('[ClaudeCode] Canvas refresh requested:', data.reason);
if (data.study_id) {
onCanvasRefresh?.(data.study_id);
reloadCanvasFromStudy(data.study_id);
}
break;
case 'canvas_updated':
console.log('[ClaudeCode] Canvas state updated');
break;
case 'pong':
// Heartbeat response
break;
default:
console.log('[ClaudeCode] Unknown message type:', data.type);
}
},
[onError, onCanvasRefresh]
);
// Reload canvas from study config
const reloadCanvasFromStudy = useCallback(
async (studyIdToReload: string) => {
try {
console.log('[ClaudeCode] Reloading canvas for study:', studyIdToReload);
// Fetch fresh config from backend
const response = await fetch(`/api/optimization/studies/${encodeURIComponent(studyIdToReload)}/config`);
if (!response.ok) {
throw new Error(`Failed to fetch config: ${response.status}`);
}
const data = await response.json();
const config = data.config; // API returns { config: ..., path: ..., study_id: ... }
// Reload canvas with new config
loadFromConfig(config);
// Add system message about refresh
const refreshMessage: Message = {
id: `msg_${Date.now()}_refresh`,
role: 'system',
content: `Canvas refreshed with latest changes from ${studyIdToReload}`,
timestamp: new Date(),
};
setState((prev) => ({
...prev,
messages: [...prev.messages, refreshMessage],
}));
} catch (error) {
console.error('[ClaudeCode] Failed to reload canvas:', error);
}
},
[loadFromConfig]
);
const generateMessageId = () => {
return `msg_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
};
const sendMessage = useCallback(
async (content: string) => {
if (!content.trim() || state.isThinking) return;
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) {
onError?.('Not connected to Claude Code');
return;
}
// Add user message
const userMessage: Message = {
id: generateMessageId(),
role: 'user',
content: content.trim(),
timestamp: new Date(),
};
// Add assistant message placeholder
const assistantMessage: Message = {
id: generateMessageId(),
role: 'assistant',
content: '',
timestamp: new Date(),
isStreaming: true,
};
setState((prev) => ({
...prev,
messages: [...prev.messages, userMessage, assistantMessage],
isThinking: true,
error: null,
}));
// Reset current message tracking
currentMessageRef.current = '';
// Send message via WebSocket with canvas state
wsRef.current.send(
JSON.stringify({
type: 'message',
content: content.trim(),
canvas_state: canvasStateRef.current || undefined,
})
);
},
[state.isThinking, onError]
);
const clearMessages = useCallback(() => {
setState((prev) => ({
...prev,
messages: [],
error: null,
}));
currentMessageRef.current = '';
}, []);
// Update canvas state (call this when canvas changes)
const updateCanvasState = useCallback((newCanvasState: CanvasState | null) => {
canvasStateRef.current = newCanvasState;
// Also send to backend to update context
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(
JSON.stringify({
type: 'set_canvas',
canvas_state: newCanvasState,
})
);
}
}, []);
// Send ping to keep connection alive
useEffect(() => {
const pingInterval = setInterval(() => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ type: 'ping' }));
}
}, 30000); // Every 30 seconds
return () => clearInterval(pingInterval);
}, []);
return {
messages: state.messages,
isThinking: state.isThinking,
error: state.error,
sessionId: state.sessionId,
isConnected: state.isConnected,
workingDir: state.workingDir,
sendMessage,
clearMessages,
updateCanvasState,
reloadCanvasFromStudy,
};
}

View File

@@ -0,0 +1,335 @@
/**
* useOptimizationStream - Enhanced WebSocket hook for real-time optimization updates
*
* This hook provides:
* - Real-time trial updates (no polling needed)
* - Best trial tracking
* - Progress tracking
* - Error detection and reporting
* - Integration with panel store for error display
* - Automatic reconnection
*
* Usage:
* ```tsx
* const {
* isConnected,
* progress,
* bestTrial,
* recentTrials,
* status
* } = useOptimizationStream(studyId);
* ```
*/
import { useState, useEffect, useCallback, useRef } from 'react';
import useWebSocket, { ReadyState } from 'react-use-websocket';
import { usePanelStore } from './usePanelStore';
// ============================================================================
// Types
// ============================================================================
export interface TrialData {
trial_number: number;
trial_num: number;
objective: number | null;
values: number[];
params: Record<string, number>;
user_attrs: Record<string, unknown>;
source: 'FEA' | 'NN' | string;
start_time: string;
end_time: string;
study_name: string;
constraint_satisfied: boolean;
}
export interface ProgressData {
current: number;
total: number;
percentage: number;
fea_count: number;
nn_count: number;
timestamp: string;
}
export interface BestTrialData {
trial_number: number;
value: number;
params: Record<string, number>;
improvement: number;
}
export interface ParetoData {
pareto_front: Array<{
trial_number: number;
values: number[];
params: Record<string, number>;
constraint_satisfied: boolean;
source: string;
}>;
count: number;
}
export type OptimizationStatus = 'disconnected' | 'connecting' | 'connected' | 'running' | 'paused' | 'completed' | 'failed';
export interface OptimizationStreamState {
isConnected: boolean;
status: OptimizationStatus;
progress: ProgressData | null;
bestTrial: BestTrialData | null;
recentTrials: TrialData[];
paretoFront: ParetoData | null;
lastUpdate: number | null;
error: string | null;
}
// ============================================================================
// Hook
// ============================================================================
interface UseOptimizationStreamOptions {
/** Maximum number of recent trials to keep */
maxRecentTrials?: number;
/** Callback when a new trial completes */
onTrialComplete?: (trial: TrialData) => void;
/** Callback when a new best is found */
onNewBest?: (best: BestTrialData) => void;
/** Callback on progress update */
onProgress?: (progress: ProgressData) => void;
/** Whether to auto-report errors to the error panel */
autoReportErrors?: boolean;
}
export function useOptimizationStream(
studyId: string | null | undefined,
options: UseOptimizationStreamOptions = {}
) {
const {
maxRecentTrials = 20,
onTrialComplete,
onNewBest,
onProgress,
autoReportErrors = true,
} = options;
// Panel store for error reporting
const { addError } = usePanelStore();
// State
const [state, setState] = useState<OptimizationStreamState>({
isConnected: false,
status: 'disconnected',
progress: null,
bestTrial: null,
recentTrials: [],
paretoFront: null,
lastUpdate: null,
error: null,
});
// Track last error timestamp to avoid duplicates
const lastErrorTime = useRef<number>(0);
// Build WebSocket URL
const socketUrl = studyId
? `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${
import.meta.env.DEV ? 'localhost:8001' : window.location.host
}/api/ws/optimization/${encodeURIComponent(studyId)}`
: null;
// WebSocket connection
const { sendMessage, lastMessage, readyState } = useWebSocket(socketUrl, {
shouldReconnect: () => true,
reconnectAttempts: 10,
reconnectInterval: 3000,
onOpen: () => {
console.log('[OptStream] Connected to optimization stream');
setState(prev => ({ ...prev, isConnected: true, status: 'connected', error: null }));
},
onClose: () => {
console.log('[OptStream] Disconnected from optimization stream');
setState(prev => ({ ...prev, isConnected: false, status: 'disconnected' }));
},
onError: (event) => {
console.error('[OptStream] WebSocket error:', event);
setState(prev => ({ ...prev, error: 'WebSocket connection error' }));
},
});
// Update connection status
useEffect(() => {
const statusMap: Record<ReadyState, OptimizationStatus> = {
[ReadyState.CONNECTING]: 'connecting',
[ReadyState.OPEN]: 'connected',
[ReadyState.CLOSING]: 'disconnected',
[ReadyState.CLOSED]: 'disconnected',
[ReadyState.UNINSTANTIATED]: 'disconnected',
};
setState(prev => ({
...prev,
isConnected: readyState === ReadyState.OPEN,
status: prev.status === 'running' || prev.status === 'completed' || prev.status === 'failed'
? prev.status
: statusMap[readyState] || 'disconnected',
}));
}, [readyState]);
// Process incoming messages
useEffect(() => {
if (!lastMessage?.data) return;
try {
const message = JSON.parse(lastMessage.data);
const { type, data } = message;
switch (type) {
case 'connected':
console.log('[OptStream] Connection confirmed:', data.message);
break;
case 'trial_completed':
handleTrialComplete(data as TrialData);
break;
case 'new_best':
handleNewBest(data as BestTrialData);
break;
case 'progress':
handleProgress(data as ProgressData);
break;
case 'pareto_update':
handleParetoUpdate(data as ParetoData);
break;
case 'heartbeat':
case 'pong':
// Keep-alive messages
break;
case 'error':
handleError(data);
break;
default:
console.log('[OptStream] Unknown message type:', type, data);
}
} catch (e) {
console.error('[OptStream] Failed to parse message:', e);
}
}, [lastMessage]);
// Handler functions
const handleTrialComplete = useCallback((trial: TrialData) => {
setState(prev => {
const newTrials = [trial, ...prev.recentTrials].slice(0, maxRecentTrials);
return {
...prev,
recentTrials: newTrials,
lastUpdate: Date.now(),
status: 'running',
};
});
onTrialComplete?.(trial);
}, [maxRecentTrials, onTrialComplete]);
const handleNewBest = useCallback((best: BestTrialData) => {
setState(prev => ({
...prev,
bestTrial: best,
lastUpdate: Date.now(),
}));
onNewBest?.(best);
}, [onNewBest]);
const handleProgress = useCallback((progress: ProgressData) => {
setState(prev => {
// Determine status based on progress
let status: OptimizationStatus = prev.status;
if (progress.current > 0 && progress.current < progress.total) {
status = 'running';
} else if (progress.current >= progress.total) {
status = 'completed';
}
return {
...prev,
progress,
status,
lastUpdate: Date.now(),
};
});
onProgress?.(progress);
}, [onProgress]);
const handleParetoUpdate = useCallback((pareto: ParetoData) => {
setState(prev => ({
...prev,
paretoFront: pareto,
lastUpdate: Date.now(),
}));
}, []);
const handleError = useCallback((errorData: { message: string; details?: string; trial?: number }) => {
const now = Date.now();
// Avoid duplicate errors within 5 seconds
if (now - lastErrorTime.current < 5000) return;
lastErrorTime.current = now;
setState(prev => ({
...prev,
error: errorData.message,
status: 'failed',
}));
if (autoReportErrors) {
addError({
type: 'system_error',
message: errorData.message,
details: errorData.details,
trial: errorData.trial,
recoverable: true,
suggestions: ['Check the optimization logs', 'Try restarting the optimization'],
timestamp: now,
});
}
}, [autoReportErrors, addError]);
// Send ping to keep connection alive
useEffect(() => {
if (readyState !== ReadyState.OPEN) return;
const interval = setInterval(() => {
sendMessage(JSON.stringify({ type: 'ping' }));
}, 25000); // Ping every 25 seconds
return () => clearInterval(interval);
}, [readyState, sendMessage]);
// Reset state when study changes
useEffect(() => {
setState({
isConnected: false,
status: 'disconnected',
progress: null,
bestTrial: null,
recentTrials: [],
paretoFront: null,
lastUpdate: null,
error: null,
});
}, [studyId]);
return {
...state,
sendPing: () => sendMessage(JSON.stringify({ type: 'ping' })),
};
}
export default useOptimizationStream;

View File

@@ -0,0 +1,375 @@
/**
* usePanelStore - Centralized state management for canvas panels
*
* This store manages the visibility and state of all panels in the canvas view.
* Panels persist their state even when the user clicks elsewhere on the canvas.
*
* Panel Types:
* - introspection: Model introspection results (floating, draggable)
* - validation: Spec validation errors/warnings (floating)
* - results: Trial results details (floating)
* - error: Error display with recovery options (floating)
*/
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
// ============================================================================
// Types
// ============================================================================
export interface IntrospectionData {
filePath: string;
studyId?: string;
selectedFile?: string;
result?: Record<string, unknown>;
isLoading?: boolean;
error?: string | null;
}
export interface ValidationError {
code: string;
severity: 'error' | 'warning';
path: string;
message: string;
suggestion?: string;
nodeId?: string;
}
export interface ValidationData {
valid: boolean;
errors: ValidationError[];
warnings: ValidationError[];
checkedAt: number;
}
export interface OptimizationError {
type: 'nx_crash' | 'solver_fail' | 'extractor_error' | 'config_error' | 'system_error' | 'unknown';
trial?: number;
message: string;
details?: string;
recoverable: boolean;
suggestions: string[];
timestamp: number;
}
export interface TrialResultData {
trialNumber: number;
params: Record<string, number>;
objectives: Record<string, number>;
constraints?: Record<string, { value: number; feasible: boolean }>;
isFeasible: boolean;
isBest: boolean;
timestamp: number;
}
export interface PanelPosition {
x: number;
y: number;
}
export interface PanelState {
open: boolean;
position?: PanelPosition;
minimized?: boolean;
}
export interface IntrospectionPanelState extends PanelState {
data?: IntrospectionData;
}
export interface ValidationPanelState extends PanelState {
data?: ValidationData;
}
export interface ErrorPanelState extends PanelState {
errors: OptimizationError[];
}
export interface ResultsPanelState extends PanelState {
data?: TrialResultData;
}
// ============================================================================
// Store Interface
// ============================================================================
interface PanelStore {
// Panel states
introspection: IntrospectionPanelState;
validation: ValidationPanelState;
error: ErrorPanelState;
results: ResultsPanelState;
// Generic panel actions
openPanel: (panel: 'introspection' | 'validation' | 'error' | 'results') => void;
closePanel: (panel: 'introspection' | 'validation' | 'error' | 'results') => void;
togglePanel: (panel: 'introspection' | 'validation' | 'error' | 'results') => void;
minimizePanel: (panel: 'introspection' | 'validation' | 'error' | 'results') => void;
setPanelPosition: (panel: 'introspection' | 'validation' | 'error' | 'results', position: PanelPosition) => void;
// Introspection-specific actions
setIntrospectionData: (data: IntrospectionData) => void;
updateIntrospectionResult: (result: Record<string, unknown>) => void;
setIntrospectionLoading: (loading: boolean) => void;
setIntrospectionError: (error: string | null) => void;
setIntrospectionFile: (fileName: string) => void;
// Validation-specific actions
setValidationData: (data: ValidationData) => void;
clearValidation: () => void;
// Error-specific actions
addError: (error: OptimizationError) => void;
clearErrors: () => void;
dismissError: (timestamp: number) => void;
// Results-specific actions
setTrialResult: (data: TrialResultData) => void;
clearTrialResult: () => void;
// Utility
closeAllPanels: () => void;
hasOpenPanels: () => boolean;
}
// ============================================================================
// Default States
// ============================================================================
const defaultIntrospection: IntrospectionPanelState = {
open: false,
position: { x: 100, y: 100 },
minimized: false,
data: undefined,
};
const defaultValidation: ValidationPanelState = {
open: false,
position: { x: 150, y: 150 },
minimized: false,
data: undefined,
};
const defaultError: ErrorPanelState = {
open: false,
position: { x: 200, y: 100 },
minimized: false,
errors: [],
};
const defaultResults: ResultsPanelState = {
open: false,
position: { x: 250, y: 150 },
minimized: false,
data: undefined,
};
// ============================================================================
// Store Implementation
// ============================================================================
export const usePanelStore = create<PanelStore>()(
persist(
(set, get) => ({
// Initial states
introspection: defaultIntrospection,
validation: defaultValidation,
error: defaultError,
results: defaultResults,
// Generic panel actions
openPanel: (panel) => set((state) => ({
[panel]: { ...state[panel], open: true, minimized: false }
})),
closePanel: (panel) => set((state) => ({
[panel]: { ...state[panel], open: false }
})),
togglePanel: (panel) => set((state) => ({
[panel]: { ...state[panel], open: !state[panel].open, minimized: false }
})),
minimizePanel: (panel) => set((state) => ({
[panel]: { ...state[panel], minimized: !state[panel].minimized }
})),
setPanelPosition: (panel, position) => set((state) => ({
[panel]: { ...state[panel], position }
})),
// Introspection actions
setIntrospectionData: (data) => set((state) => ({
introspection: {
...state.introspection,
open: true,
data
}
})),
updateIntrospectionResult: (result) => set((state) => ({
introspection: {
...state.introspection,
data: state.introspection.data
? { ...state.introspection.data, result, isLoading: false, error: null }
: undefined
}
})),
setIntrospectionLoading: (loading) => set((state) => ({
introspection: {
...state.introspection,
data: state.introspection.data
? { ...state.introspection.data, isLoading: loading }
: undefined
}
})),
setIntrospectionError: (error) => set((state) => ({
introspection: {
...state.introspection,
data: state.introspection.data
? { ...state.introspection.data, error, isLoading: false }
: undefined
}
})),
setIntrospectionFile: (fileName) => set((state) => ({
introspection: {
...state.introspection,
data: state.introspection.data
? { ...state.introspection.data, selectedFile: fileName }
: undefined
}
})),
// Validation actions
setValidationData: (data) => set((state) => ({
validation: {
...state.validation,
open: true,
data
}
})),
clearValidation: () => set((state) => ({
validation: {
...state.validation,
data: undefined
}
})),
// Error actions
addError: (error) => set((state) => ({
error: {
...state.error,
open: true,
errors: [...state.error.errors, error]
}
})),
clearErrors: () => set((state) => ({
error: {
...state.error,
errors: [],
open: false
}
})),
dismissError: (timestamp) => set((state) => {
const newErrors = state.error.errors.filter(e => e.timestamp !== timestamp);
return {
error: {
...state.error,
errors: newErrors,
open: newErrors.length > 0
}
};
}),
// Results actions
setTrialResult: (data) => set((state) => ({
results: {
...state.results,
open: true,
data
}
})),
clearTrialResult: () => set((state) => ({
results: {
...state.results,
data: undefined,
open: false
}
})),
// Utility
closeAllPanels: () => set({
introspection: { ...get().introspection, open: false },
validation: { ...get().validation, open: false },
error: { ...get().error, open: false },
results: { ...get().results, open: false },
}),
hasOpenPanels: () => {
const state = get();
return state.introspection.open ||
state.validation.open ||
state.error.open ||
state.results.open;
},
}),
{
name: 'atomizer-panel-store',
// Only persist certain fields (not loading states or errors)
partialize: (state) => ({
introspection: {
position: state.introspection.position,
// Don't persist open state - start fresh each session
},
validation: {
position: state.validation.position,
},
error: {
position: state.error.position,
},
results: {
position: state.results.position,
},
}),
}
)
);
// ============================================================================
// Selector Hooks (for convenience)
// ============================================================================
export const useIntrospectionPanel = () => usePanelStore((state) => state.introspection);
export const useValidationPanel = () => usePanelStore((state) => state.validation);
export const useErrorPanel = () => usePanelStore((state) => state.error);
export const useResultsPanel = () => usePanelStore((state) => state.results);
// Actions
export const usePanelActions = () => usePanelStore((state) => ({
openPanel: state.openPanel,
closePanel: state.closePanel,
togglePanel: state.togglePanel,
minimizePanel: state.minimizePanel,
setPanelPosition: state.setPanelPosition,
setIntrospectionData: state.setIntrospectionData,
updateIntrospectionResult: state.updateIntrospectionResult,
setIntrospectionLoading: state.setIntrospectionLoading,
setIntrospectionError: state.setIntrospectionError,
setIntrospectionFile: state.setIntrospectionFile,
setValidationData: state.setValidationData,
clearValidation: state.clearValidation,
addError: state.addError,
clearErrors: state.clearErrors,
dismissError: state.dismissError,
setTrialResult: state.setTrialResult,
clearTrialResult: state.clearTrialResult,
closeAllPanels: state.closeAllPanels,
}));

View File

@@ -0,0 +1,156 @@
/**
* useResizablePanel - Hook for creating resizable panels with persistence
*
* Features:
* - Drag to resize
* - Min/max constraints
* - localStorage persistence
* - Double-click to reset to default
*/
import { useState, useCallback, useEffect, useRef } from 'react';
export interface ResizablePanelConfig {
/** Unique key for localStorage persistence */
storageKey: string;
/** Default width in pixels */
defaultWidth: number;
/** Minimum width in pixels */
minWidth: number;
/** Maximum width in pixels */
maxWidth: number;
/** Side of the panel ('left' or 'right') - affects resize direction */
side: 'left' | 'right';
}
export interface ResizablePanelState {
/** Current width in pixels */
width: number;
/** Whether user is currently dragging */
isDragging: boolean;
/** Start drag handler - attach to resize handle mousedown */
startDrag: (e: React.MouseEvent) => void;
/** Reset to default width */
resetWidth: () => void;
/** Set width programmatically */
setWidth: (width: number) => void;
}
const STORAGE_PREFIX = 'atomizer-panel-';
function getStoredWidth(key: string, defaultWidth: number): number {
if (typeof window === 'undefined') return defaultWidth;
try {
const stored = localStorage.getItem(STORAGE_PREFIX + key);
if (stored) {
const parsed = parseInt(stored, 10);
if (!isNaN(parsed)) return parsed;
}
} catch {
// localStorage not available
}
return defaultWidth;
}
function storeWidth(key: string, width: number): void {
if (typeof window === 'undefined') return;
try {
localStorage.setItem(STORAGE_PREFIX + key, String(width));
} catch {
// localStorage not available
}
}
export function useResizablePanel(config: ResizablePanelConfig): ResizablePanelState {
const { storageKey, defaultWidth, minWidth, maxWidth, side } = config;
// Initialize from localStorage
const [width, setWidthState] = useState(() => {
const stored = getStoredWidth(storageKey, defaultWidth);
return Math.max(minWidth, Math.min(maxWidth, stored));
});
const [isDragging, setIsDragging] = useState(false);
// Track initial position for drag calculation
const dragStartRef = useRef<{ x: number; width: number } | null>(null);
// Clamp width within bounds
const clampWidth = useCallback((w: number) => {
return Math.max(minWidth, Math.min(maxWidth, w));
}, [minWidth, maxWidth]);
// Set width with clamping and persistence
const setWidth = useCallback((newWidth: number) => {
const clamped = clampWidth(newWidth);
setWidthState(clamped);
storeWidth(storageKey, clamped);
}, [clampWidth, storageKey]);
// Reset to default
const resetWidth = useCallback(() => {
setWidth(defaultWidth);
}, [defaultWidth, setWidth]);
// Start drag handler
const startDrag = useCallback((e: React.MouseEvent) => {
e.preventDefault();
setIsDragging(true);
dragStartRef.current = { x: e.clientX, width };
}, [width]);
// Handle mouse move during drag
useEffect(() => {
if (!isDragging) return;
const handleMouseMove = (e: MouseEvent) => {
if (!dragStartRef.current) return;
const delta = e.clientX - dragStartRef.current.x;
// For left panels, positive delta increases width
// For right panels, negative delta increases width
const newWidth = side === 'left'
? dragStartRef.current.width + delta
: dragStartRef.current.width - delta;
setWidthState(clampWidth(newWidth));
};
const handleMouseUp = () => {
if (dragStartRef.current) {
// Persist the final width
storeWidth(storageKey, width);
}
setIsDragging(false);
dragStartRef.current = null;
};
// Add listeners to document for smooth dragging
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
// Change cursor globally during drag
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
document.body.style.cursor = '';
document.body.style.userSelect = '';
};
}, [isDragging, side, clampWidth, storageKey, width]);
return {
width,
isDragging,
startDrag,
resetWidth,
setWidth,
};
}
export default useResizablePanel;

View File

@@ -0,0 +1,209 @@
/**
* useSpecStore Unit Tests
*
* Tests for the AtomizerSpec v2.0 state management store.
*/
/// <reference types="vitest/globals" />
import { describe, it, expect, beforeEach } from 'vitest';
import { useSpecStore } from './useSpecStore';
import { createMockSpec, mockFetch } from '../test/utils';
// Type for global context
declare const global: typeof globalThis;
describe('useSpecStore', () => {
beforeEach(() => {
// Reset the store state before each test
useSpecStore.setState({
spec: null,
studyId: null,
hash: null,
isLoading: false,
error: null,
validation: null,
selectedNodeId: null,
selectedEdgeId: null,
isDirty: false,
pendingChanges: [],
});
});
describe('initial state', () => {
it('should have null spec initially', () => {
const { spec } = useSpecStore.getState();
expect(spec).toBeNull();
});
it('should not be loading initially', () => {
const { isLoading } = useSpecStore.getState();
expect(isLoading).toBe(false);
});
it('should have no selected node initially', () => {
const { selectedNodeId } = useSpecStore.getState();
expect(selectedNodeId).toBeNull();
});
});
describe('selection', () => {
it('should select a node', () => {
const { selectNode } = useSpecStore.getState();
selectNode('dv_001');
const { selectedNodeId, selectedEdgeId } = useSpecStore.getState();
expect(selectedNodeId).toBe('dv_001');
expect(selectedEdgeId).toBeNull();
});
it('should select an edge', () => {
const { selectEdge } = useSpecStore.getState();
selectEdge('edge_1');
const { selectedNodeId, selectedEdgeId } = useSpecStore.getState();
expect(selectedEdgeId).toBe('edge_1');
expect(selectedNodeId).toBeNull();
});
it('should clear selection', () => {
const { selectNode, clearSelection } = useSpecStore.getState();
selectNode('dv_001');
clearSelection();
const { selectedNodeId, selectedEdgeId } = useSpecStore.getState();
expect(selectedNodeId).toBeNull();
expect(selectedEdgeId).toBeNull();
});
it('should clear edge when selecting node', () => {
const { selectEdge, selectNode } = useSpecStore.getState();
selectEdge('edge_1');
selectNode('dv_001');
const { selectedNodeId, selectedEdgeId } = useSpecStore.getState();
expect(selectedNodeId).toBe('dv_001');
expect(selectedEdgeId).toBeNull();
});
});
describe('setSpecFromWebSocket', () => {
it('should set spec directly', () => {
const mockSpec = createMockSpec({ meta: { study_name: 'ws_test' } });
const { setSpecFromWebSocket } = useSpecStore.getState();
setSpecFromWebSocket(mockSpec, 'test_study');
const { spec, studyId, isLoading, error } = useSpecStore.getState();
expect(spec?.meta.study_name).toBe('ws_test');
expect(studyId).toBe('test_study');
expect(isLoading).toBe(false);
expect(error).toBeNull();
});
});
describe('loadSpec', () => {
it('should set loading state', async () => {
mockFetch({
'spec': createMockSpec(),
'hash': { hash: 'abc123' },
});
const { loadSpec } = useSpecStore.getState();
const loadPromise = loadSpec('test_study');
// Should be loading immediately
expect(useSpecStore.getState().isLoading).toBe(true);
await loadPromise;
// Should no longer be loading
expect(useSpecStore.getState().isLoading).toBe(false);
});
it('should handle errors', async () => {
(global.fetch as any).mockRejectedValueOnce(new Error('Network error'));
const { loadSpec } = useSpecStore.getState();
await loadSpec('test_study');
const { error, isLoading } = useSpecStore.getState();
expect(error).toContain('error');
expect(isLoading).toBe(false);
});
});
describe('getNodeById', () => {
beforeEach(() => {
const mockSpec = createMockSpec({
design_variables: [
{ id: 'dv_001', name: 'thickness', expression_name: 't', type: 'continuous', bounds: { min: 1, max: 10 } },
{ id: 'dv_002', name: 'width', expression_name: 'w', type: 'continuous', bounds: { min: 5, max: 20 } },
],
extractors: [
{ id: 'ext_001', name: 'displacement', type: 'displacement', outputs: ['d'] },
],
objectives: [
{ id: 'obj_001', name: 'mass', type: 'minimize', weight: 1.0 },
],
});
useSpecStore.setState({ spec: mockSpec });
});
it('should find design variable by id', () => {
const { getNodeById } = useSpecStore.getState();
const node = getNodeById('dv_001');
expect(node).not.toBeNull();
expect((node as any).name).toBe('thickness');
});
it('should find extractor by id', () => {
const { getNodeById } = useSpecStore.getState();
const node = getNodeById('ext_001');
expect(node).not.toBeNull();
expect((node as any).name).toBe('displacement');
});
it('should find objective by id', () => {
const { getNodeById } = useSpecStore.getState();
const node = getNodeById('obj_001');
expect(node).not.toBeNull();
expect((node as any).name).toBe('mass');
});
it('should return null for unknown id', () => {
const { getNodeById } = useSpecStore.getState();
const node = getNodeById('unknown_999');
expect(node).toBeNull();
});
});
describe('clearSpec', () => {
it('should reset all state', () => {
// Set up some state
useSpecStore.setState({
spec: createMockSpec(),
studyId: 'test',
hash: 'abc',
selectedNodeId: 'dv_001',
isDirty: true,
});
const { clearSpec } = useSpecStore.getState();
clearSpec();
const state = useSpecStore.getState();
expect(state.spec).toBeNull();
expect(state.studyId).toBeNull();
expect(state.hash).toBeNull();
expect(state.selectedNodeId).toBeNull();
expect(state.isDirty).toBe(false);
});
});
});

View File

@@ -0,0 +1,742 @@
/**
* useSpecStore - Zustand store for AtomizerSpec v2.0
*
* Central state management for the unified configuration system.
* All spec modifications flow through this store and sync with backend.
*
* Features:
* - Load spec from backend API
* - Optimistic updates with rollback on error
* - Patch operations via JSONPath
* - Node CRUD operations
* - Hash-based conflict detection
*/
import { create } from 'zustand';
import { devtools, subscribeWithSelector } from 'zustand/middleware';
import {
AtomizerSpec,
DesignVariable,
Extractor,
Objective,
Constraint,
CanvasPosition,
SpecValidationReport,
SpecModification,
} from '../types/atomizer-spec';
// API base URL
const API_BASE = '/api';
// ============================================================================
// Types
// ============================================================================
interface SpecStoreState {
// Spec data
spec: AtomizerSpec | null;
studyId: string | null;
hash: string | null;
// Loading state
isLoading: boolean;
error: string | null;
// Validation
validation: SpecValidationReport | null;
// Selection state (for canvas)
selectedNodeId: string | null;
selectedEdgeId: string | null;
// Dirty tracking
isDirty: boolean;
pendingChanges: SpecModification[];
}
interface SpecStoreActions {
// Loading
loadSpec: (studyId: string) => Promise<void>;
reloadSpec: () => Promise<void>;
clearSpec: () => void;
// WebSocket integration - set spec directly without API call
setSpecFromWebSocket: (spec: AtomizerSpec, studyId?: string) => void;
// Full spec operations
saveSpec: (spec: AtomizerSpec) => Promise<void>;
replaceSpec: (spec: AtomizerSpec) => Promise<void>;
// Patch operations
patchSpec: (path: string, value: unknown) => Promise<void>;
patchSpecOptimistic: (path: string, value: unknown) => void;
// Node operations
addNode: (
type: 'designVar' | 'extractor' | 'objective' | 'constraint',
data: Record<string, unknown>
) => Promise<string>;
updateNode: (nodeId: string, updates: Record<string, unknown>) => Promise<void>;
removeNode: (nodeId: string) => Promise<void>;
updateNodePosition: (nodeId: string, position: CanvasPosition) => Promise<void>;
// Edge operations
addEdge: (source: string, target: string) => Promise<void>;
removeEdge: (source: string, target: string) => Promise<void>;
// Custom function
addCustomFunction: (
name: string,
code: string,
outputs: string[],
description?: string
) => Promise<string>;
// Validation
validateSpec: () => Promise<SpecValidationReport>;
// Selection
selectNode: (nodeId: string | null) => void;
selectEdge: (edgeId: string | null) => void;
clearSelection: () => void;
// Utility
getNodeById: (nodeId: string) => DesignVariable | Extractor | Objective | Constraint | null;
setError: (error: string | null) => void;
}
type SpecStore = SpecStoreState & SpecStoreActions;
// ============================================================================
// API Functions
// ============================================================================
async function fetchSpec(studyId: string): Promise<{ spec: AtomizerSpec; hash: string }> {
const response = await fetch(`${API_BASE}/studies/${studyId}/spec`);
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: 'Failed to load spec' }));
throw new Error(error.detail || `HTTP ${response.status}`);
}
const spec = await response.json();
// Get hash
const hashResponse = await fetch(`${API_BASE}/studies/${studyId}/spec/hash`);
const { hash } = await hashResponse.json();
return { spec, hash };
}
async function patchSpecApi(
studyId: string,
path: string,
value: unknown
): Promise<{ hash: string; modified: string }> {
const response = await fetch(`${API_BASE}/studies/${studyId}/spec`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path, value, modified_by: 'canvas' }),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: 'Patch failed' }));
throw new Error(error.detail || `HTTP ${response.status}`);
}
return response.json();
}
async function addNodeApi(
studyId: string,
type: string,
data: Record<string, unknown>
): Promise<{ node_id: string }> {
const response = await fetch(`${API_BASE}/studies/${studyId}/spec/nodes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type, data, modified_by: 'canvas' }),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: 'Add node failed' }));
throw new Error(error.detail || `HTTP ${response.status}`);
}
return response.json();
}
async function updateNodeApi(
studyId: string,
nodeId: string,
updates: Record<string, unknown>
): Promise<void> {
const response = await fetch(`${API_BASE}/studies/${studyId}/spec/nodes/${nodeId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ updates, modified_by: 'canvas' }),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: 'Update node failed' }));
throw new Error(error.detail || `HTTP ${response.status}`);
}
}
async function deleteNodeApi(studyId: string, nodeId: string): Promise<void> {
const response = await fetch(`${API_BASE}/studies/${studyId}/spec/nodes/${nodeId}?modified_by=canvas`, {
method: 'DELETE',
});
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: 'Delete node failed' }));
throw new Error(error.detail || `HTTP ${response.status}`);
}
}
async function addEdgeApi(studyId: string, source: string, target: string): Promise<void> {
const response = await fetch(
`${API_BASE}/studies/${studyId}/spec/edges?source=${source}&target=${target}&modified_by=canvas`,
{ method: 'POST' }
);
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: 'Add edge failed' }));
throw new Error(error.detail || `HTTP ${response.status}`);
}
}
async function removeEdgeApi(studyId: string, source: string, target: string): Promise<void> {
const response = await fetch(
`${API_BASE}/studies/${studyId}/spec/edges?source=${source}&target=${target}&modified_by=canvas`,
{ method: 'DELETE' }
);
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: 'Remove edge failed' }));
throw new Error(error.detail || `HTTP ${response.status}`);
}
}
async function addCustomFunctionApi(
studyId: string,
name: string,
code: string,
outputs: string[],
description?: string
): Promise<{ node_id: string }> {
const response = await fetch(`${API_BASE}/studies/${studyId}/spec/custom-functions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, code, outputs, description, modified_by: 'claude' }),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: 'Add custom function failed' }));
throw new Error(error.detail || `HTTP ${response.status}`);
}
return response.json();
}
async function validateSpecApi(studyId: string): Promise<SpecValidationReport> {
const response = await fetch(`${API_BASE}/studies/${studyId}/spec/validate`, {
method: 'POST',
});
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: 'Validation failed' }));
throw new Error(error.detail || `HTTP ${response.status}`);
}
return response.json();
}
// ============================================================================
// Helper Functions
// ============================================================================
function applyPatchLocally(spec: AtomizerSpec, path: string, value: unknown): AtomizerSpec {
// Deep clone spec
const newSpec = JSON.parse(JSON.stringify(spec)) as AtomizerSpec;
// Parse path and apply value
const parts = path.split(/\.|\[|\]/).filter(Boolean);
let current: Record<string, unknown> = newSpec as unknown as Record<string, unknown>;
for (let i = 0; i < parts.length - 1; i++) {
const part = parts[i];
const index = parseInt(part, 10);
if (!isNaN(index)) {
current = (current as unknown as unknown[])[index] as Record<string, unknown>;
} else {
current = current[part] as Record<string, unknown>;
}
}
const finalKey = parts[parts.length - 1];
const index = parseInt(finalKey, 10);
if (!isNaN(index)) {
(current as unknown as unknown[])[index] = value;
} else {
current[finalKey] = value;
}
return newSpec;
}
function findNodeById(
spec: AtomizerSpec,
nodeId: string
): DesignVariable | Extractor | Objective | Constraint | null {
// Check design variables
const dv = spec.design_variables.find((d) => d.id === nodeId);
if (dv) return dv;
// Check extractors
const ext = spec.extractors.find((e) => e.id === nodeId);
if (ext) return ext;
// Check objectives
const obj = spec.objectives.find((o) => o.id === nodeId);
if (obj) return obj;
// Check constraints
const con = spec.constraints?.find((c) => c.id === nodeId);
if (con) return con;
return null;
}
// ============================================================================
// Store
// ============================================================================
export const useSpecStore = create<SpecStore>()(
devtools(
subscribeWithSelector((set, get) => ({
// Initial state
spec: null,
studyId: null,
hash: null,
isLoading: false,
error: null,
validation: null,
selectedNodeId: null,
selectedEdgeId: null,
isDirty: false,
pendingChanges: [],
// =====================================================================
// Loading Actions
// =====================================================================
loadSpec: async (studyId: string) => {
set({ isLoading: true, error: null, studyId });
try {
const { spec, hash } = await fetchSpec(studyId);
set({
spec,
hash,
isLoading: false,
isDirty: false,
pendingChanges: [],
});
} catch (error) {
set({
isLoading: false,
error: error instanceof Error ? error.message : 'Failed to load spec',
});
}
},
reloadSpec: async () => {
const { studyId } = get();
if (!studyId) return;
set({ isLoading: true, error: null });
try {
const { spec, hash } = await fetchSpec(studyId);
set({
spec,
hash,
isLoading: false,
isDirty: false,
pendingChanges: [],
});
} catch (error) {
set({
isLoading: false,
error: error instanceof Error ? error.message : 'Failed to reload spec',
});
}
},
clearSpec: () => {
set({
spec: null,
studyId: null,
hash: null,
isLoading: false,
error: null,
validation: null,
selectedNodeId: null,
selectedEdgeId: null,
isDirty: false,
pendingChanges: [],
});
},
// Set spec directly from WebSocket (no API call)
setSpecFromWebSocket: (spec: AtomizerSpec, studyId?: string) => {
const currentStudyId = studyId || get().studyId;
console.log('[useSpecStore] Setting spec from WebSocket:', spec.meta?.study_name);
set({
spec,
studyId: currentStudyId,
isLoading: false,
isDirty: false,
error: null,
});
},
// =====================================================================
// Full Spec Operations
// =====================================================================
saveSpec: async (spec: AtomizerSpec) => {
const { studyId, hash } = get();
if (!studyId) throw new Error('No study loaded');
set({ isLoading: true, error: null });
try {
const response = await fetch(
`${API_BASE}/studies/${studyId}/spec?modified_by=canvas${hash ? `&expected_hash=${hash}` : ''}`,
{
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(spec),
}
);
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: 'Save failed' }));
throw new Error(error.detail || `HTTP ${response.status}`);
}
const result = await response.json();
set({
spec,
hash: result.hash,
isLoading: false,
isDirty: false,
pendingChanges: [],
});
} catch (error) {
set({
isLoading: false,
error: error instanceof Error ? error.message : 'Save failed',
});
throw error;
}
},
replaceSpec: async (spec: AtomizerSpec) => {
await get().saveSpec(spec);
},
// =====================================================================
// Patch Operations
// =====================================================================
patchSpec: async (path: string, value: unknown) => {
const { studyId, spec } = get();
if (!studyId || !spec) throw new Error('No study loaded');
// Optimistic update
const oldSpec = spec;
const newSpec = applyPatchLocally(spec, path, value);
set({ spec: newSpec, isDirty: true });
try {
const result = await patchSpecApi(studyId, path, value);
set({ hash: result.hash, isDirty: false });
} catch (error) {
// Rollback on error
set({ spec: oldSpec, isDirty: false });
const message = error instanceof Error ? error.message : 'Patch failed';
set({ error: message });
throw error;
}
},
patchSpecOptimistic: (path: string, value: unknown) => {
const { spec, studyId } = get();
if (!spec) return;
// Apply locally immediately
const newSpec = applyPatchLocally(spec, path, value);
set({
spec: newSpec,
isDirty: true,
pendingChanges: [...get().pendingChanges, { operation: 'set', path, value }],
});
// Sync with backend (fire and forget, but handle errors)
if (studyId) {
patchSpecApi(studyId, path, value)
.then((result) => {
set({ hash: result.hash });
// Remove from pending
set({
pendingChanges: get().pendingChanges.filter(
(c) => !(c.path === path && c.value === value)
),
});
})
.catch((error) => {
console.error('Patch sync failed:', error);
set({ error: error.message });
});
}
},
// =====================================================================
// Node Operations
// =====================================================================
addNode: async (type, data) => {
const { studyId } = get();
if (!studyId) throw new Error('No study loaded');
set({ isLoading: true, error: null });
try {
const result = await addNodeApi(studyId, type, data);
// Reload spec to get new state
await get().reloadSpec();
return result.node_id;
} catch (error) {
set({
isLoading: false,
error: error instanceof Error ? error.message : 'Add node failed',
});
throw error;
}
},
updateNode: async (nodeId, updates) => {
const { studyId } = get();
if (!studyId) throw new Error('No study loaded');
try {
await updateNodeApi(studyId, nodeId, updates);
await get().reloadSpec();
} catch (error) {
const message = error instanceof Error ? error.message : 'Update failed';
set({ error: message });
throw error;
}
},
removeNode: async (nodeId) => {
const { studyId } = get();
if (!studyId) throw new Error('No study loaded');
set({ isLoading: true, error: null });
try {
await deleteNodeApi(studyId, nodeId);
await get().reloadSpec();
// Clear selection if deleted node was selected
if (get().selectedNodeId === nodeId) {
set({ selectedNodeId: null });
}
} catch (error) {
set({
isLoading: false,
error: error instanceof Error ? error.message : 'Delete failed',
});
throw error;
}
},
updateNodePosition: async (nodeId, position) => {
const { studyId, spec } = get();
if (!studyId || !spec) return;
// Find the node type and index
let path: string | null = null;
const dvIndex = spec.design_variables.findIndex((d) => d.id === nodeId);
if (dvIndex >= 0) {
path = `design_variables[${dvIndex}].canvas_position`;
}
if (!path) {
const extIndex = spec.extractors.findIndex((e) => e.id === nodeId);
if (extIndex >= 0) {
path = `extractors[${extIndex}].canvas_position`;
}
}
if (!path) {
const objIndex = spec.objectives.findIndex((o) => o.id === nodeId);
if (objIndex >= 0) {
path = `objectives[${objIndex}].canvas_position`;
}
}
if (!path && spec.constraints) {
const conIndex = spec.constraints.findIndex((c) => c.id === nodeId);
if (conIndex >= 0) {
path = `constraints[${conIndex}].canvas_position`;
}
}
if (path) {
// Use optimistic update for smooth dragging
get().patchSpecOptimistic(path, position);
}
},
// =====================================================================
// Edge Operations
// =====================================================================
addEdge: async (source, target) => {
const { studyId } = get();
if (!studyId) throw new Error('No study loaded');
try {
await addEdgeApi(studyId, source, target);
await get().reloadSpec();
} catch (error) {
const message = error instanceof Error ? error.message : 'Add edge failed';
set({ error: message });
throw error;
}
},
removeEdge: async (source, target) => {
const { studyId } = get();
if (!studyId) throw new Error('No study loaded');
try {
await removeEdgeApi(studyId, source, target);
await get().reloadSpec();
} catch (error) {
const message = error instanceof Error ? error.message : 'Remove edge failed';
set({ error: message });
throw error;
}
},
// =====================================================================
// Custom Function
// =====================================================================
addCustomFunction: async (name, code, outputs, description) => {
const { studyId } = get();
if (!studyId) throw new Error('No study loaded');
set({ isLoading: true, error: null });
try {
const result = await addCustomFunctionApi(studyId, name, code, outputs, description);
await get().reloadSpec();
return result.node_id;
} catch (error) {
set({
isLoading: false,
error: error instanceof Error ? error.message : 'Add custom function failed',
});
throw error;
}
},
// =====================================================================
// Validation
// =====================================================================
validateSpec: async () => {
const { studyId } = get();
if (!studyId) throw new Error('No study loaded');
try {
const validation = await validateSpecApi(studyId);
set({ validation });
return validation;
} catch (error) {
const message = error instanceof Error ? error.message : 'Validation failed';
set({ error: message });
throw error;
}
},
// =====================================================================
// Selection
// =====================================================================
selectNode: (nodeId) => {
set({ selectedNodeId: nodeId, selectedEdgeId: null });
},
selectEdge: (edgeId) => {
set({ selectedEdgeId: edgeId, selectedNodeId: null });
},
clearSelection: () => {
set({ selectedNodeId: null, selectedEdgeId: null });
},
// =====================================================================
// Utility
// =====================================================================
getNodeById: (nodeId) => {
const { spec } = get();
if (!spec) return null;
return findNodeById(spec, nodeId);
},
setError: (error) => {
set({ error });
},
})),
{ name: 'spec-store' }
)
);
// ============================================================================
// Selector Hooks
// ============================================================================
export const useSpec = () => useSpecStore((state) => state.spec);
export const useSpecLoading = () => useSpecStore((state) => state.isLoading);
export const useSpecError = () => useSpecStore((state) => state.error);
export const useSpecValidation = () => useSpecStore((state) => state.validation);
export const useSelectedNodeId = () => useSpecStore((state) => state.selectedNodeId);
export const useSelectedEdgeId = () => useSpecStore((state) => state.selectedEdgeId);
export const useSpecHash = () => useSpecStore((state) => state.hash);
export const useSpecIsDirty = () => useSpecStore((state) => state.isDirty);
// Computed selectors
export const useDesignVariables = () =>
useSpecStore((state) => state.spec?.design_variables ?? []);
export const useExtractors = () => useSpecStore((state) => state.spec?.extractors ?? []);
export const useObjectives = () => useSpecStore((state) => state.spec?.objectives ?? []);
export const useConstraints = () => useSpecStore((state) => state.spec?.constraints ?? []);
export const useCanvasEdges = () => useSpecStore((state) => state.spec?.canvas?.edges ?? []);
export const useSelectedNode = () =>
useSpecStore((state) => {
if (!state.spec || !state.selectedNodeId) return null;
return findNodeById(state.spec, state.selectedNodeId);
});

View File

@@ -0,0 +1,136 @@
/**
* useSpecUndoRedo - Undo/Redo for AtomizerSpec changes
*
* Integrates with useSpecStore to provide undo/redo functionality
* with localStorage persistence.
*
* Usage:
* ```tsx
* const { undo, redo, canUndo, canRedo } = useSpecUndoRedo();
*
* // In keyboard handler:
* if (e.ctrlKey && e.key === 'z') undo();
* if (e.ctrlKey && e.key === 'y') redo();
* ```
*/
import { useEffect, useRef } from 'react';
import { useUndoRedo, UndoRedoResult } from './useUndoRedo';
import { useSpecStore, useSpec } from './useSpecStore';
import { AtomizerSpec } from '../types/atomizer-spec';
const STORAGE_KEY_PREFIX = 'atomizer-spec-history-';
export interface SpecUndoRedoResult extends UndoRedoResult<AtomizerSpec | null> {
/** The current study ID */
studyId: string | null;
}
export function useSpecUndoRedo(): SpecUndoRedoResult {
const spec = useSpec();
const studyId = useSpecStore((state) => state.studyId);
const lastSpecRef = useRef<AtomizerSpec | null>(null);
// Storage key includes study ID for per-study history
const storageKey = studyId ? `${STORAGE_KEY_PREFIX}${studyId}` : undefined;
const undoRedo = useUndoRedo<AtomizerSpec | null>({
getState: () => useSpecStore.getState().spec,
setState: (state) => {
if (state) {
// Use setSpecFromWebSocket to avoid API call during undo/redo
useSpecStore.getState().setSpecFromWebSocket(state, studyId || undefined);
}
},
storageKey,
maxHistory: 30, // Keep 30 undo steps per study
debounceMs: 1000, // Wait 1s after last change before recording
isEqual: (a, b) => {
if (a === null && b === null) return true;
if (a === null || b === null) return false;
// Compare relevant parts of spec (ignore meta.modified timestamps)
const aClean = { ...a, meta: { ...a.meta, modified: undefined } };
const bClean = { ...b, meta: { ...b.meta, modified: undefined } };
return JSON.stringify(aClean) === JSON.stringify(bClean);
},
});
// Record snapshot when spec changes
// Note: We removed the isDirty check because with auto-save, isDirty is always false
// after the API call completes. Instead, we compare the spec directly.
useEffect(() => {
if (spec && spec !== lastSpecRef.current) {
// Deep compare to avoid recording duplicate snapshots
const specStr = JSON.stringify(spec);
const lastStr = lastSpecRef.current ? JSON.stringify(lastSpecRef.current) : '';
if (specStr !== lastStr) {
lastSpecRef.current = spec;
undoRedo.recordSnapshot();
}
}
}, [spec, undoRedo]);
// Clear history when study changes
useEffect(() => {
if (studyId) {
// Don't clear - we're loading per-study history from localStorage
// Just reset the ref
lastSpecRef.current = spec;
}
}, [studyId]);
return {
...undoRedo,
studyId,
};
}
/**
* Hook to handle keyboard shortcuts for undo/redo
*/
export function useUndoRedoKeyboard(
undoRedo: Pick<UndoRedoResult<unknown>, 'undo' | 'redo' | 'canUndo' | 'canRedo'>
) {
const { undo, redo, canUndo, canRedo } = undoRedo;
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Ignore if typing in an input
const target = e.target as HTMLElement;
if (
target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.isContentEditable
) {
return;
}
// Ctrl+Z or Cmd+Z for undo
if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) {
e.preventDefault();
if (canUndo) {
undo();
}
return;
}
// Ctrl+Y or Cmd+Shift+Z for redo
if (
((e.ctrlKey || e.metaKey) && e.key === 'y') ||
((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'z')
) {
e.preventDefault();
if (canRedo) {
redo();
}
return;
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [undo, redo, canUndo, canRedo]);
}
export default useSpecUndoRedo;

View File

@@ -0,0 +1,288 @@
/**
* useSpecWebSocket - WebSocket connection for real-time spec sync
*
* Connects to the backend WebSocket endpoint for live spec updates.
* Handles auto-reconnection, message parsing, and store updates.
*
* P2.11-P2.14: WebSocket sync implementation
*/
import { useEffect, useRef, useCallback, useState } from 'react';
import { useSpecStore } from './useSpecStore';
// ============================================================================
// Types
// ============================================================================
export type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'reconnecting';
interface SpecWebSocketMessage {
type: 'modification' | 'full_sync' | 'error' | 'ping';
payload: unknown;
}
interface ModificationPayload {
operation: 'set' | 'add' | 'remove';
path: string;
value?: unknown;
modified_by: string;
timestamp: string;
hash: string;
}
interface ErrorPayload {
message: string;
code?: string;
}
interface UseSpecWebSocketOptions {
/**
* Enable auto-reconnect on disconnect (default: true)
*/
autoReconnect?: boolean;
/**
* Reconnect delay in ms (default: 3000)
*/
reconnectDelay?: number;
/**
* Max reconnect attempts (default: 10)
*/
maxReconnectAttempts?: number;
/**
* Client identifier for tracking modifications (default: 'canvas')
*/
clientId?: string;
}
interface UseSpecWebSocketReturn {
/**
* Current connection status
*/
status: ConnectionStatus;
/**
* Manually disconnect
*/
disconnect: () => void;
/**
* Manually reconnect
*/
reconnect: () => void;
/**
* Send a message to the WebSocket (for future use)
*/
send: (message: SpecWebSocketMessage) => void;
/**
* Last error message if any
*/
lastError: string | null;
}
// ============================================================================
// Hook
// ============================================================================
export function useSpecWebSocket(
studyId: string | null,
options: UseSpecWebSocketOptions = {}
): UseSpecWebSocketReturn {
const {
autoReconnect = true,
reconnectDelay = 3000,
maxReconnectAttempts = 10,
clientId = 'canvas',
} = options;
const wsRef = useRef<WebSocket | null>(null);
const reconnectAttemptsRef = useRef(0);
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [status, setStatus] = useState<ConnectionStatus>('disconnected');
const [lastError, setLastError] = useState<string | null>(null);
// Get store actions
const reloadSpec = useSpecStore((s) => s.reloadSpec);
const setError = useSpecStore((s) => s.setError);
// Build WebSocket URL
const getWsUrl = useCallback((id: string): string => {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const host = window.location.host;
return `${protocol}//${host}/api/studies/${encodeURIComponent(id)}/spec/sync?client_id=${clientId}`;
}, [clientId]);
// Handle incoming messages
const handleMessage = useCallback((event: MessageEvent) => {
try {
const message: SpecWebSocketMessage = JSON.parse(event.data);
switch (message.type) {
case 'modification': {
const payload = message.payload as ModificationPayload;
// Skip if this is our own modification
if (payload.modified_by === clientId) {
return;
}
// Reload spec to get latest state
// In a more sophisticated implementation, we could apply the patch locally
reloadSpec().catch((err) => {
console.error('Failed to reload spec after modification:', err);
});
break;
}
case 'full_sync': {
// Full spec sync requested (e.g., after reconnect)
reloadSpec().catch((err) => {
console.error('Failed to reload spec during full_sync:', err);
});
break;
}
case 'error': {
const payload = message.payload as ErrorPayload;
console.error('WebSocket error:', payload.message);
setLastError(payload.message);
setError(payload.message);
break;
}
case 'ping': {
// Keep-alive ping, respond with pong
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ type: 'pong' }));
}
break;
}
default:
console.warn('Unknown WebSocket message type:', message.type);
}
} catch (error) {
console.error('Failed to parse WebSocket message:', error);
}
}, [clientId, reloadSpec, setError]);
// Connect to WebSocket
const connect = useCallback(() => {
if (!studyId) return;
// Clean up existing connection
if (wsRef.current) {
wsRef.current.close();
}
setStatus('connecting');
setLastError(null);
const url = getWsUrl(studyId);
const ws = new WebSocket(url);
ws.onopen = () => {
setStatus('connected');
reconnectAttemptsRef.current = 0;
};
ws.onmessage = handleMessage;
ws.onerror = (event) => {
console.error('WebSocket error:', event);
setLastError('WebSocket connection error');
};
ws.onclose = (_event) => {
setStatus('disconnected');
// Check if we should reconnect
if (autoReconnect && reconnectAttemptsRef.current < maxReconnectAttempts) {
reconnectAttemptsRef.current++;
setStatus('reconnecting');
// Clear any existing reconnect timeout
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
}
// Schedule reconnect with exponential backoff
const delay = reconnectDelay * Math.min(reconnectAttemptsRef.current, 5);
reconnectTimeoutRef.current = setTimeout(() => {
connect();
}, delay);
} else if (reconnectAttemptsRef.current >= maxReconnectAttempts) {
setLastError('Max reconnection attempts reached');
}
};
wsRef.current = ws;
}, [studyId, getWsUrl, handleMessage, autoReconnect, reconnectDelay, maxReconnectAttempts]);
// Disconnect
const disconnect = useCallback(() => {
// Clear reconnect timeout
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
reconnectTimeoutRef.current = null;
}
// Close WebSocket
if (wsRef.current) {
wsRef.current.close();
wsRef.current = null;
}
reconnectAttemptsRef.current = maxReconnectAttempts; // Prevent auto-reconnect
setStatus('disconnected');
}, [maxReconnectAttempts]);
// Reconnect
const reconnect = useCallback(() => {
reconnectAttemptsRef.current = 0;
connect();
}, [connect]);
// Send message
const send = useCallback((message: SpecWebSocketMessage) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify(message));
} else {
console.warn('WebSocket not connected, cannot send message');
}
}, []);
// Connect when studyId changes
useEffect(() => {
if (studyId) {
connect();
} else {
disconnect();
}
return () => {
// Cleanup on unmount or studyId change
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
}
if (wsRef.current) {
wsRef.current.close();
}
};
}, [studyId, connect, disconnect]);
return {
status,
disconnect,
reconnect,
send,
lastError,
};
}
export default useSpecWebSocket;

View File

@@ -0,0 +1,260 @@
/**
* useUndoRedo - Generic undo/redo hook for Zustand stores
*
* Features:
* - History tracking with configurable max size
* - Undo/redo operations
* - localStorage persistence (optional)
* - Debounced history recording
* - Clear history on demand
*
* Usage:
* ```tsx
* const { undo, redo, canUndo, canRedo, recordSnapshot } = useUndoRedo({
* getState: () => myStore.getState().data,
* setState: (state) => myStore.setState({ data: state }),
* storageKey: 'my-store-history',
* });
* ```
*/
import { useState, useCallback, useEffect, useRef } from 'react';
export interface UndoRedoOptions<T> {
/** Function to get current state snapshot */
getState: () => T;
/** Function to restore a state snapshot */
setState: (state: T) => void;
/** Maximum history size (default: 50) */
maxHistory?: number;
/** localStorage key for persistence (optional) */
storageKey?: string;
/** Debounce delay in ms for recording (default: 500) */
debounceMs?: number;
/** Custom equality check (default: JSON.stringify comparison) */
isEqual?: (a: T, b: T) => boolean;
}
export interface UndoRedoResult<T> {
/** Undo the last change */
undo: () => void;
/** Redo the last undone change */
redo: () => void;
/** Whether undo is available */
canUndo: boolean;
/** Whether redo is available */
canRedo: boolean;
/** Manually record a state snapshot */
recordSnapshot: () => void;
/** Clear all history */
clearHistory: () => void;
/** Current history length */
historyLength: number;
/** Current position in history */
historyPosition: number;
/** Get history for debugging */
getHistory: () => T[];
}
interface HistoryState<T> {
past: T[];
future: T[];
}
const DEFAULT_MAX_HISTORY = 50;
const DEFAULT_DEBOUNCE_MS = 500;
function defaultIsEqual<T>(a: T, b: T): boolean {
return JSON.stringify(a) === JSON.stringify(b);
}
export function useUndoRedo<T>(options: UndoRedoOptions<T>): UndoRedoResult<T> {
const {
getState,
setState,
maxHistory = DEFAULT_MAX_HISTORY,
storageKey,
debounceMs = DEFAULT_DEBOUNCE_MS,
isEqual = defaultIsEqual,
} = options;
// Initialize history from localStorage if available
const getInitialHistory = (): HistoryState<T> => {
if (storageKey) {
try {
const stored = localStorage.getItem(storageKey);
if (stored) {
const parsed = JSON.parse(stored);
// Validate structure
if (Array.isArray(parsed.past) && Array.isArray(parsed.future)) {
return parsed;
}
}
} catch (e) {
console.warn('Failed to load undo history from localStorage:', e);
}
}
return { past: [], future: [] };
};
const [history, setHistory] = useState<HistoryState<T>>(getInitialHistory);
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastRecordedStateRef = useRef<T | null>(null);
const isUndoRedoRef = useRef(false);
// Persist to localStorage when history changes
useEffect(() => {
if (storageKey) {
try {
localStorage.setItem(storageKey, JSON.stringify(history));
} catch (e) {
console.warn('Failed to save undo history to localStorage:', e);
}
}
}, [history, storageKey]);
// Record a snapshot to history
const recordSnapshot = useCallback(() => {
if (isUndoRedoRef.current) {
// Don't record during undo/redo operations
return;
}
const currentState = getState();
// Skip if state hasn't changed
if (lastRecordedStateRef.current !== null && isEqual(lastRecordedStateRef.current, currentState)) {
return;
}
lastRecordedStateRef.current = currentState;
setHistory((prev) => {
// Create a deep copy for history
const snapshot = JSON.parse(JSON.stringify(currentState)) as T;
const newPast = [...prev.past, snapshot];
// Trim history if too long
if (newPast.length > maxHistory) {
newPast.shift();
}
return {
past: newPast,
future: [], // Clear redo stack on new changes
};
});
}, [getState, maxHistory, isEqual]);
// Debounced recording
const recordSnapshotDebounced = useCallback(() => {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
debounceTimerRef.current = setTimeout(() => {
recordSnapshot();
}, debounceMs);
}, [recordSnapshot, debounceMs]);
// Undo operation
const undo = useCallback(() => {
setHistory((prev) => {
if (prev.past.length === 0) {
return prev;
}
const newPast = [...prev.past];
const previousState = newPast.pop()!;
// Save current state to future before undoing
const currentState = JSON.parse(JSON.stringify(getState())) as T;
isUndoRedoRef.current = true;
setState(previousState);
lastRecordedStateRef.current = previousState;
// Reset flag after a tick
setTimeout(() => {
isUndoRedoRef.current = false;
}, 0);
return {
past: newPast,
future: [currentState, ...prev.future],
};
});
}, [getState, setState]);
// Redo operation
const redo = useCallback(() => {
setHistory((prev) => {
if (prev.future.length === 0) {
return prev;
}
const newFuture = [...prev.future];
const nextState = newFuture.shift()!;
// Save current state to past before redoing
const currentState = JSON.parse(JSON.stringify(getState())) as T;
isUndoRedoRef.current = true;
setState(nextState);
lastRecordedStateRef.current = nextState;
// Reset flag after a tick
setTimeout(() => {
isUndoRedoRef.current = false;
}, 0);
return {
past: [...prev.past, currentState],
future: newFuture,
};
});
}, [getState, setState]);
// Clear history
const clearHistory = useCallback(() => {
setHistory({ past: [], future: [] });
lastRecordedStateRef.current = null;
if (storageKey) {
try {
localStorage.removeItem(storageKey);
} catch (e) {
console.warn('Failed to clear undo history from localStorage:', e);
}
}
}, [storageKey]);
// Get history for debugging
const getHistory = useCallback(() => {
return [...history.past];
}, [history.past]);
// Cleanup debounce timer on unmount
useEffect(() => {
return () => {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
};
}, []);
return {
undo,
redo,
canUndo: history.past.length > 0,
canRedo: history.future.length > 0,
recordSnapshot: recordSnapshotDebounced,
clearHistory,
historyLength: history.past.length,
historyPosition: history.past.length,
getHistory,
};
}
export default useUndoRedo;

View File

@@ -18,7 +18,8 @@ export const useOptimizationWebSocket = ({ studyId, onMessage }: UseOptimization
const host = window.location.host; // This will be localhost:3000 in dev const host = window.location.host; // This will be localhost:3000 in dev
// If using proxy in vite.config.ts, this works. // If using proxy in vite.config.ts, this works.
// If not, we might need to hardcode backend URL for dev: // If not, we might need to hardcode backend URL for dev:
const backendHost = import.meta.env.DEV ? 'localhost:8000' : host; // Use port 8001 to match start-dashboard.bat
const backendHost = import.meta.env.DEV ? 'localhost:8001' : host;
setSocketUrl(`${protocol}//${backendHost}/api/ws/optimization/${studyId}`); setSocketUrl(`${protocol}//${backendHost}/api/ws/optimization/${studyId}`);
} else { } else {

View File

@@ -0,0 +1,348 @@
/**
* Claude Code API Functions
*
* Provides typed API functions for interacting with Claude Code CLI
* through the backend endpoints.
*/
const API_BASE = '/api/claude-code';
// ============================================================================
// Types
// ============================================================================
export interface ExtractorGenerationRequest {
/** Description of what the extractor should do */
prompt: string;
/** Optional study ID for context */
study_id?: string;
/** Existing code to improve/modify */
existing_code?: string;
/** Expected output variable names */
output_names?: string[];
}
export interface ExtractorGenerationResponse {
/** Generated Python code */
code: string;
/** Detected output variable names */
outputs: string[];
/** Optional brief explanation */
explanation?: string;
}
export interface CodeValidationRequest {
/** Python code to validate */
code: string;
}
export interface CodeValidationResponse {
/** Whether the code is valid */
valid: boolean;
/** Error message if invalid */
error?: string;
}
export interface DependencyCheckResponse {
/** All imports found in code */
imports: string[];
/** Imports that are available */
available: string[];
/** Imports that are missing */
missing: string[];
/** Warnings about potentially problematic imports */
warnings: string[];
}
export interface TestExtractorRequest {
/** Python code to test */
code: string;
/** Optional study ID for finding OP2 files */
study_id?: string;
/** Subcase ID to test against (default 1) */
subcase_id?: number;
}
export interface TestExtractorResponse {
/** Whether the test succeeded */
success: boolean;
/** Extracted output values */
outputs?: Record<string, number>;
/** Error message if failed */
error?: string;
/** Execution time in milliseconds */
execution_time_ms?: number;
}
// ============================================================================
// API Functions
// ============================================================================
/**
* Generate Python extractor code using Claude Code CLI.
*
* @param request - Generation request with prompt and context
* @returns Promise with generated code and detected outputs
* @throws Error if generation fails
*
* @example
* ```typescript
* const result = await generateExtractorCode({
* prompt: "Extract maximum von Mises stress from solid elements",
* output_names: ["max_stress"],
* });
* console.log(result.code);
* ```
*/
export async function generateExtractorCode(
request: ExtractorGenerationRequest
): Promise<ExtractorGenerationResponse> {
const response = await fetch(`${API_BASE}/generate-extractor`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(request),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: 'Generation failed' }));
throw new Error(error.detail || `HTTP ${response.status}`);
}
return response.json();
}
/**
* Validate Python extractor code syntax.
*
* @param code - Python code to validate
* @returns Promise with validation result
*
* @example
* ```typescript
* const result = await validateExtractorCode("def extract(): pass");
* if (!result.valid) {
* console.error(result.error);
* }
* ```
*/
export async function validateExtractorCode(
code: string
): Promise<CodeValidationResponse> {
const response = await fetch(`${API_BASE}/validate-extractor`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code }),
});
if (!response.ok) {
// Even if HTTP fails, return as invalid
return {
valid: false,
error: `Validation request failed: HTTP ${response.status}`,
};
}
return response.json();
}
/**
* Check dependencies in Python code.
*
* @param code - Python code to analyze
* @returns Promise with dependency check results
*
* @example
* ```typescript
* const result = await checkCodeDependencies("import numpy as np");
* if (result.missing.length > 0) {
* console.warn("Missing:", result.missing);
* }
* ```
*/
export async function checkCodeDependencies(
code: string
): Promise<DependencyCheckResponse> {
const response = await fetch(`${API_BASE}/check-dependencies`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code }),
});
if (!response.ok) {
return {
imports: [],
available: [],
missing: [],
warnings: ['Dependency check failed'],
};
}
return response.json();
}
/**
* Test extractor code against a sample OP2 file.
*
* @param request - Test request with code and optional study context
* @returns Promise with test results
*
* @example
* ```typescript
* const result = await testExtractorCode({
* code: "def extract(...): ...",
* study_id: "bracket_v1",
* });
* if (result.success) {
* console.log("Outputs:", result.outputs);
* }
* ```
*/
export async function testExtractorCode(
request: TestExtractorRequest
): Promise<TestExtractorResponse> {
const response = await fetch(`${API_BASE}/test-extractor`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(request),
});
if (!response.ok) {
return {
success: false,
error: `Test request failed: HTTP ${response.status}`,
};
}
return response.json();
}
/**
* Check if Claude Code CLI is available.
*
* @returns Promise with availability status
*/
export async function checkClaudeStatus(): Promise<{
available: boolean;
message: string;
}> {
try {
const response = await fetch('/api/claude/status');
if (!response.ok) {
return { available: false, message: 'Status check failed' };
}
return response.json();
} catch {
return { available: false, message: 'Cannot reach backend' };
}
}
// ============================================================================
// Streaming Generation
// ============================================================================
export interface StreamingGenerationCallbacks {
/** Called when a new token is received */
onToken?: (token: string) => void;
/** Called when generation is complete */
onComplete?: (code: string, outputs: string[]) => void;
/** Called when an error occurs */
onError?: (error: string) => void;
}
/**
* Stream Python extractor code generation using Server-Sent Events.
*
* This provides real-time feedback as Claude generates the code,
* showing tokens as they arrive.
*
* @param request - Generation request with prompt and context
* @param callbacks - Callbacks for streaming events
* @returns AbortController to cancel the stream
*
* @example
* ```typescript
* const controller = streamExtractorCode(
* { prompt: "Extract maximum stress" },
* {
* onToken: (token) => setPartialCode(prev => prev + token),
* onComplete: (code, outputs) => {
* setCode(code);
* setIsGenerating(false);
* },
* onError: (error) => setError(error),
* }
* );
*
* // To cancel:
* controller.abort();
* ```
*/
export function streamExtractorCode(
request: ExtractorGenerationRequest,
callbacks: StreamingGenerationCallbacks
): AbortController {
const controller = new AbortController();
(async () => {
try {
const response = await fetch(`${API_BASE}/generate-extractor/stream`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(request),
signal: controller.signal,
});
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: 'Stream failed' }));
callbacks.onError?.(error.detail || `HTTP ${response.status}`);
return;
}
const reader = response.body?.getReader();
if (!reader) {
callbacks.onError?.('No response body');
return;
}
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// Parse SSE events from buffer
const lines = buffer.split('\n');
buffer = lines.pop() || ''; // Keep incomplete line in buffer
for (const line of lines) {
if (line.startsWith('data: ')) {
try {
const data = JSON.parse(line.slice(6));
if (data.type === 'token') {
callbacks.onToken?.(data.content);
} else if (data.type === 'done') {
callbacks.onComplete?.(data.code, data.outputs);
} else if (data.type === 'error') {
callbacks.onError?.(data.message);
}
} catch {
// Ignore parse errors for incomplete JSON
}
}
}
}
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
// User cancelled, don't report as error
return;
}
callbacks.onError?.(error instanceof Error ? error.message : 'Stream failed');
}
})();
return controller;
}

View File

@@ -16,6 +16,7 @@ export interface BaseNodeData {
label: string; label: string;
configured: boolean; configured: boolean;
errors?: string[]; errors?: string[];
resultValue?: number | string | null; // For Results Overlay
} }
export interface ModelNodeData extends BaseNodeData { export interface ModelNodeData extends BaseNodeData {
@@ -24,9 +25,17 @@ export interface ModelNodeData extends BaseNodeData {
fileType?: 'prt' | 'fem' | 'sim'; fileType?: 'prt' | 'fem' | 'sim';
} }
export type SolverEngine = 'nxnastran' | 'mscnastran' | 'python' | 'abaqus' | 'ansys';
export type NastranSolutionType = 'SOL101' | 'SOL103' | 'SOL105' | 'SOL106' | 'SOL111' | 'SOL112' | 'SOL200';
export interface SolverNodeData extends BaseNodeData { export interface SolverNodeData extends BaseNodeData {
type: 'solver'; type: 'solver';
solverType?: 'SOL101' | 'SOL103' | 'SOL105' | 'SOL106' | 'SOL111' | 'SOL112'; /** Solver engine (nxnastran, mscnastran, python, etc.) */
engine?: SolverEngine;
/** Solution type for Nastran solvers */
solverType?: NastranSolutionType;
/** Python script path (for python engine) */
scriptPath?: string;
} }
export interface DesignVarNodeData extends BaseNodeData { export interface DesignVarNodeData extends BaseNodeData {
@@ -98,6 +107,7 @@ export interface ObjectiveNodeData extends BaseNodeData {
extractorRef?: string; // Reference to extractor ID extractorRef?: string; // Reference to extractor ID
outputName?: string; // Which output from the extractor outputName?: string; // Which output from the extractor
penaltyWeight?: number; // For hard constraints (penalty method) penaltyWeight?: number; // For hard constraints (penalty method)
history?: number[]; // Recent values for sparkline visualization
} }
export interface ConstraintNodeData extends BaseNodeData { export interface ConstraintNodeData extends BaseNodeData {
@@ -105,6 +115,7 @@ export interface ConstraintNodeData extends BaseNodeData {
name?: string; name?: string;
operator?: '<' | '<=' | '>' | '>=' | '=='; operator?: '<' | '<=' | '>' | '>=' | '==';
value?: number; value?: number;
isFeasible?: boolean; // For Results Overlay
} }
export interface AlgorithmNodeData extends BaseNodeData { export interface AlgorithmNodeData extends BaseNodeData {

View File

@@ -0,0 +1,394 @@
/**
* Spec Validator - Validate AtomizerSpec v2.0 before running optimization
*
* This validator checks the spec for completeness and correctness,
* returning structured errors that can be displayed in the ValidationPanel.
*/
import { AtomizerSpec } from '../../types/atomizer-spec';
import { ValidationError, ValidationData } from '../../hooks/usePanelStore';
// ============================================================================
// Validation Rules
// ============================================================================
interface ValidationRule {
code: string;
check: (spec: AtomizerSpec) => ValidationError | null;
}
const validationRules: ValidationRule[] = [
// ---- Critical Errors (must fix) ----
{
code: 'NO_DESIGN_VARS',
check: (spec) => {
const enabledDVs = spec.design_variables.filter(dv => dv.enabled !== false);
if (enabledDVs.length === 0) {
return {
code: 'NO_DESIGN_VARS',
severity: 'error',
path: 'design_variables',
message: 'No design variables defined',
suggestion: 'Add at least one design variable from the introspection panel or drag from the palette.',
};
}
return null;
},
},
{
code: 'NO_OBJECTIVES',
check: (spec) => {
if (spec.objectives.length === 0) {
return {
code: 'NO_OBJECTIVES',
severity: 'error',
path: 'objectives',
message: 'No objectives defined',
suggestion: 'Add at least one objective to define what to optimize (minimize mass, maximize stiffness, etc.).',
};
}
return null;
},
},
{
code: 'NO_EXTRACTORS',
check: (spec) => {
if (spec.extractors.length === 0) {
return {
code: 'NO_EXTRACTORS',
severity: 'error',
path: 'extractors',
message: 'No extractors defined',
suggestion: 'Add extractors to pull physics values (displacement, stress, frequency) from FEA results.',
};
}
return null;
},
},
{
code: 'NO_MODEL',
check: (spec) => {
if (!spec.model.sim?.path) {
return {
code: 'NO_MODEL',
severity: 'error',
path: 'model.sim.path',
message: 'No simulation file configured',
suggestion: 'Select a .sim file in the study\'s model directory.',
};
}
return null;
},
},
// ---- Design Variable Validation ----
{
code: 'DV_INVALID_BOUNDS',
check: (spec) => {
for (const dv of spec.design_variables) {
if (dv.enabled === false) continue;
if (dv.bounds.min >= dv.bounds.max) {
return {
code: 'DV_INVALID_BOUNDS',
severity: 'error',
path: `design_variables.${dv.id}`,
message: `Design variable "${dv.name}" has invalid bounds (min >= max)`,
suggestion: `Set min (${dv.bounds.min}) to be less than max (${dv.bounds.max}).`,
nodeId: dv.id,
};
}
}
return null;
},
},
{
code: 'DV_NO_EXPRESSION',
check: (spec) => {
for (const dv of spec.design_variables) {
if (dv.enabled === false) continue;
if (!dv.expression_name || dv.expression_name.trim() === '') {
return {
code: 'DV_NO_EXPRESSION',
severity: 'error',
path: `design_variables.${dv.id}`,
message: `Design variable "${dv.name}" has no NX expression name`,
suggestion: 'Set the expression_name to match an NX expression in the model.',
nodeId: dv.id,
};
}
}
return null;
},
},
// ---- Extractor Validation ----
{
code: 'EXTRACTOR_NO_TYPE',
check: (spec) => {
for (const ext of spec.extractors) {
if (!ext.type || ext.type.trim() === '') {
return {
code: 'EXTRACTOR_NO_TYPE',
severity: 'error',
path: `extractors.${ext.id}`,
message: `Extractor "${ext.name}" has no type selected`,
suggestion: 'Select an extractor type (displacement, stress, frequency, etc.).',
nodeId: ext.id,
};
}
}
return null;
},
},
{
code: 'CUSTOM_EXTRACTOR_NO_CODE',
check: (spec) => {
for (const ext of spec.extractors) {
if (ext.type === 'custom_function' && (!ext.function?.source_code || ext.function.source_code.trim() === '')) {
return {
code: 'CUSTOM_EXTRACTOR_NO_CODE',
severity: 'error',
path: `extractors.${ext.id}`,
message: `Custom extractor "${ext.name}" has no code defined`,
suggestion: 'Open the code editor and write the extraction function.',
nodeId: ext.id,
};
}
}
return null;
},
},
// ---- Objective Validation ----
{
code: 'OBJECTIVE_NO_SOURCE',
check: (spec) => {
for (const obj of spec.objectives) {
// Check if objective is connected to an extractor via canvas edges
const hasSource = spec.canvas?.edges?.some(
edge => edge.target === obj.id && edge.source.startsWith('ext_')
);
// Also check if source.extractor_id is set
const hasDirectSource = obj.source?.extractor_id &&
spec.extractors.some(e => e.id === obj.source.extractor_id);
if (!hasSource && !hasDirectSource) {
return {
code: 'OBJECTIVE_NO_SOURCE',
severity: 'error',
path: `objectives.${obj.id}`,
message: `Objective "${obj.name}" has no connected extractor`,
suggestion: 'Connect an extractor to this objective or set source_extractor_id.',
nodeId: obj.id,
};
}
}
return null;
},
},
// ---- Constraint Validation ----
{
code: 'CONSTRAINT_NO_THRESHOLD',
check: (spec) => {
for (const con of spec.constraints || []) {
if (con.threshold === undefined || con.threshold === null) {
return {
code: 'CONSTRAINT_NO_THRESHOLD',
severity: 'error',
path: `constraints.${con.id}`,
message: `Constraint "${con.name}" has no threshold value`,
suggestion: 'Set a threshold value for the constraint.',
nodeId: con.id,
};
}
}
return null;
},
},
// ---- Warnings (can proceed but risky) ----
{
code: 'HIGH_TRIAL_COUNT',
check: (spec) => {
const maxTrials = spec.optimization.budget?.max_trials || 100;
if (maxTrials > 500) {
return {
code: 'HIGH_TRIAL_COUNT',
severity: 'warning',
path: 'optimization.budget.max_trials',
message: `High trial count (${maxTrials}) may take several hours to complete`,
suggestion: 'Consider starting with fewer trials (50-100) to validate the setup.',
};
}
return null;
},
},
{
code: 'SINGLE_TRIAL',
check: (spec) => {
const maxTrials = spec.optimization.budget?.max_trials || 100;
if (maxTrials === 1) {
return {
code: 'SINGLE_TRIAL',
severity: 'warning',
path: 'optimization.budget.max_trials',
message: 'Only 1 trial configured - this will just run a single evaluation',
suggestion: 'Increase max_trials to explore the design space.',
};
}
return null;
},
},
{
code: 'DV_NARROW_BOUNDS',
check: (spec) => {
for (const dv of spec.design_variables) {
if (dv.enabled === false) continue;
const range = dv.bounds.max - dv.bounds.min;
const baseline = dv.baseline || (dv.bounds.min + dv.bounds.max) / 2;
const relativeRange = range / Math.abs(baseline || 1);
if (relativeRange < 0.01) { // Less than 1% variation
return {
code: 'DV_NARROW_BOUNDS',
severity: 'warning',
path: `design_variables.${dv.id}`,
message: `Design variable "${dv.name}" has very narrow bounds (<1% range)`,
suggestion: 'Consider widening the bounds for more meaningful exploration.',
nodeId: dv.id,
};
}
}
return null;
},
},
{
code: 'MANY_DESIGN_VARS',
check: (spec) => {
const enabledDVs = spec.design_variables.filter(dv => dv.enabled !== false);
if (enabledDVs.length > 10) {
return {
code: 'MANY_DESIGN_VARS',
severity: 'warning',
path: 'design_variables',
message: `${enabledDVs.length} design variables - high-dimensional space may need more trials`,
suggestion: 'Consider enabling neural surrogate acceleration or increasing trial budget.',
};
}
return null;
},
},
{
code: 'MULTI_OBJECTIVE_NO_WEIGHTS',
check: (spec) => {
if (spec.objectives.length > 1) {
const hasWeights = spec.objectives.every(obj => obj.weight !== undefined && obj.weight !== null);
if (!hasWeights) {
return {
code: 'MULTI_OBJECTIVE_NO_WEIGHTS',
severity: 'warning',
path: 'objectives',
message: 'Multi-objective optimization without explicit weights',
suggestion: 'Consider setting weights to control the trade-off between objectives.',
};
}
}
return null;
},
},
];
// ============================================================================
// Main Validation Function
// ============================================================================
export function validateSpec(spec: AtomizerSpec): ValidationData {
const errors: ValidationError[] = [];
const warnings: ValidationError[] = [];
for (const rule of validationRules) {
const result = rule.check(spec);
if (result) {
if (result.severity === 'error') {
errors.push(result);
} else {
warnings.push(result);
}
}
}
return {
valid: errors.length === 0,
errors,
warnings,
checkedAt: Date.now(),
};
}
// ============================================================================
// Quick Validation (just checks if can run)
// ============================================================================
export function canRunOptimization(spec: AtomizerSpec): { canRun: boolean; reason?: string } {
// Check critical requirements only
if (!spec.model.sim?.path) {
return { canRun: false, reason: 'No simulation file configured' };
}
const enabledDVs = spec.design_variables.filter(dv => dv.enabled !== false);
if (enabledDVs.length === 0) {
return { canRun: false, reason: 'No design variables defined' };
}
if (spec.objectives.length === 0) {
return { canRun: false, reason: 'No objectives defined' };
}
if (spec.extractors.length === 0) {
return { canRun: false, reason: 'No extractors defined' };
}
// Check for invalid bounds
for (const dv of enabledDVs) {
if (dv.bounds.min >= dv.bounds.max) {
return { canRun: false, reason: `Invalid bounds for "${dv.name}"` };
}
}
return { canRun: true };
}
// ============================================================================
// Export validation result type for backward compatibility
// ============================================================================
export interface LegacyValidationResult {
valid: boolean;
errors: string[];
warnings: string[];
}
export function toLegacyValidationResult(data: ValidationData): LegacyValidationResult {
return {
valid: data.valid,
errors: data.errors.map(e => e.message),
warnings: data.warnings.map(w => w.message),
};
}

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, lazy, Suspense, useMemo } from 'react'; import { useState, useEffect, useMemo } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { import {
BarChart3, BarChart3,
@@ -14,25 +14,10 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import { useStudy } from '../context/StudyContext'; import { useStudy } from '../context/StudyContext';
import { Card } from '../components/common/Card'; import { Card } from '../components/common/Card';
import { ConvergencePlot } from '../components/ConvergencePlot';
// Lazy load charts import { ParameterImportanceChart } from '../components/ParameterImportanceChart';
const PlotlyParetoPlot = lazy(() => import('../components/plotly/PlotlyParetoPlot').then(m => ({ default: m.PlotlyParetoPlot }))); import { ParallelCoordinatesPlot } from '../components/ParallelCoordinatesPlot';
const PlotlyParallelCoordinates = lazy(() => import('../components/plotly/PlotlyParallelCoordinates').then(m => ({ default: m.PlotlyParallelCoordinates }))); import { ParetoPlot } from '../components/ParetoPlot';
const PlotlyParameterImportance = lazy(() => import('../components/plotly/PlotlyParameterImportance').then(m => ({ default: m.PlotlyParameterImportance })));
const PlotlyConvergencePlot = lazy(() => import('../components/plotly/PlotlyConvergencePlot').then(m => ({ default: m.PlotlyConvergencePlot })));
const PlotlyCorrelationHeatmap = lazy(() => import('../components/plotly/PlotlyCorrelationHeatmap').then(m => ({ default: m.PlotlyCorrelationHeatmap })));
const PlotlyFeasibilityChart = lazy(() => import('../components/plotly/PlotlyFeasibilityChart').then(m => ({ default: m.PlotlyFeasibilityChart })));
const PlotlySurrogateQuality = lazy(() => import('../components/plotly/PlotlySurrogateQuality').then(m => ({ default: m.PlotlySurrogateQuality })));
const PlotlyRunComparison = lazy(() => import('../components/plotly/PlotlyRunComparison').then(m => ({ default: m.PlotlyRunComparison })));
const ChartLoading = () => (
<div className="flex items-center justify-center h-64 text-dark-400">
<div className="flex flex-col items-center gap-2">
<div className="animate-spin w-6 h-6 border-2 border-primary-500 border-t-transparent rounded-full"></div>
<span className="text-sm animate-pulse">Loading chart...</span>
</div>
</div>
);
const NoData = ({ message = 'No data available' }: { message?: string }) => ( const NoData = ({ message = 'No data available' }: { message?: string }) => (
<div className="flex items-center justify-center h-64 text-dark-500"> <div className="flex items-center justify-center h-64 text-dark-500">
@@ -383,15 +368,12 @@ export default function Analysis() {
{/* Convergence Plot */} {/* Convergence Plot */}
{trials.length > 0 && ( {trials.length > 0 && (
<Card title="Convergence Plot"> <Card title="Convergence Plot">
<Suspense fallback={<ChartLoading />}> <ConvergencePlot
<PlotlyConvergencePlot trials={trials}
trials={trials} objectiveIndex={0}
objectiveIndex={0} objectiveName={metadata?.objectives?.[0]?.name || 'Objective'}
objectiveName={metadata?.objectives?.[0]?.name || 'Objective'} direction="minimize"
direction="minimize" />
height={350}
/>
</Suspense>
</Card> </Card>
)} )}
@@ -455,30 +437,24 @@ export default function Analysis() {
{/* Parameter Importance */} {/* Parameter Importance */}
{trials.length > 0 && metadata?.design_variables && ( {trials.length > 0 && metadata?.design_variables && (
<Card title="Parameter Importance"> <Card title="Parameter Importance">
<Suspense fallback={<ChartLoading />}> <ParameterImportanceChart
<PlotlyParameterImportance trials={trials}
trials={trials} designVariables={metadata.design_variables}
designVariables={metadata.design_variables} objectiveIndex={0}
objectiveIndex={0} objectiveName={metadata?.objectives?.[0]?.name || 'Objective'}
objectiveName={metadata?.objectives?.[0]?.name || 'Objective'} />
height={400}
/>
</Suspense>
</Card> </Card>
)} )}
{/* Parallel Coordinates */} {/* Parallel Coordinates */}
{trials.length > 0 && metadata && ( {trials.length > 0 && metadata && (
<Card title="Parallel Coordinates"> <Card title="Parallel Coordinates">
<Suspense fallback={<ChartLoading />}> <ParallelCoordinatesPlot
<PlotlyParallelCoordinates paretoData={trials}
trials={trials} objectives={metadata.objectives || []}
objectives={metadata.objectives || []} designVariables={metadata.design_variables || []}
designVariables={metadata.design_variables || []} paretoFront={paretoFront}
paretoFront={paretoFront} />
height={450}
/>
</Suspense>
</Card> </Card>
)} )}
</div> </div>
@@ -508,14 +484,11 @@ export default function Analysis() {
{/* Pareto Front Plot */} {/* Pareto Front Plot */}
{paretoFront.length > 0 && ( {paretoFront.length > 0 && (
<Card title="Pareto Front"> <Card title="Pareto Front">
<Suspense fallback={<ChartLoading />}> <ParetoPlot
<PlotlyParetoPlot paretoData={paretoFront}
trials={trials} objectives={metadata?.objectives || []}
paretoFront={paretoFront} allTrials={trials}
objectives={metadata?.objectives || []} />
height={500}
/>
</Suspense>
</Card> </Card>
)} )}
@@ -550,16 +523,10 @@ export default function Analysis() {
{/* Correlations Tab */} {/* Correlations Tab */}
{activeTab === 'correlations' && ( {activeTab === 'correlations' && (
<div className="space-y-6"> <div className="space-y-6">
{/* Correlation Heatmap */} {/* Correlation Analysis */}
{trials.length > 2 && ( {trials.length > 2 && (
<Card title="Parameter-Objective Correlation Matrix"> <Card title="Parameter-Objective Correlation Analysis">
<Suspense fallback={<ChartLoading />}> <CorrelationTable trials={trials} objectiveName={metadata?.objectives?.[0]?.name || 'Objective'} />
<PlotlyCorrelationHeatmap
trials={trials}
objectiveName={metadata?.objectives?.[0]?.name || 'Objective'}
height={Math.min(500, 100 + Object.keys(trials[0]?.params || {}).length * 40)}
/>
</Suspense>
</Card> </Card>
)} )}
@@ -612,11 +579,22 @@ export default function Analysis() {
</Card> </Card>
</div> </div>
{/* Feasibility Over Time Chart */} {/* Feasibility Summary */}
<Card title="Feasibility Rate Over Time"> <Card title="Feasibility Analysis">
<Suspense fallback={<ChartLoading />}> <div className="p-4">
<PlotlyFeasibilityChart trials={trials} height={350} /> <div className="flex items-center gap-4 mb-4">
</Suspense> <div className="flex-1 bg-dark-700 rounded-full h-4 overflow-hidden">
<div
className="h-full bg-green-500 transition-all duration-500"
style={{ width: `${stats.feasibilityRate}%` }}
/>
</div>
<span className="text-lg font-bold text-green-400">{stats.feasibilityRate.toFixed(1)}%</span>
</div>
<p className="text-dark-400 text-sm">
{stats.feasible} of {stats.total} trials satisfy all constraints
</p>
</div>
</Card> </Card>
{/* Infeasible Trials List */} {/* Infeasible Trials List */}
@@ -683,11 +661,38 @@ export default function Analysis() {
</Card> </Card>
</div> </div>
{/* Surrogate Quality Charts */} {/* Surrogate Performance Summary */}
<Card title="Surrogate Model Analysis"> <Card title="Surrogate Model Performance">
<Suspense fallback={<ChartLoading />}> <div className="grid grid-cols-2 gap-6 p-4">
<PlotlySurrogateQuality trials={trials} height={400} /> <div>
</Suspense> <h4 className="text-sm font-semibold text-dark-300 mb-3">Trial Distribution</h4>
<div className="space-y-2">
<div className="flex items-center gap-3">
<div className="w-3 h-3 bg-blue-500 rounded-full"></div>
<span className="text-dark-200">FEA: {stats.feaTrials} trials</span>
<span className="text-dark-400 ml-auto">
{((stats.feaTrials / stats.total) * 100).toFixed(0)}%
</span>
</div>
<div className="flex items-center gap-3">
<div className="w-3 h-3 bg-purple-500 rounded-full"></div>
<span className="text-dark-200">NN: {stats.nnTrials} trials</span>
<span className="text-dark-400 ml-auto">
{((stats.nnTrials / stats.total) * 100).toFixed(0)}%
</span>
</div>
</div>
</div>
<div>
<h4 className="text-sm font-semibold text-dark-300 mb-3">Efficiency Gains</h4>
<div className="text-center p-4 bg-dark-750 rounded-lg">
<div className="text-3xl font-bold text-primary-400">
{stats.feaTrials > 0 ? `${(stats.total / stats.feaTrials).toFixed(1)}x` : '1.0x'}
</div>
<div className="text-xs text-dark-400 mt-1">Effective Speedup</div>
</div>
</div>
</div>
</Card> </Card>
</div> </div>
)} )}
@@ -700,9 +705,36 @@ export default function Analysis() {
Compare different optimization runs within this study. Studies with adaptive optimization Compare different optimization runs within this study. Studies with adaptive optimization
may have multiple runs (e.g., initial FEA exploration, NN-accelerated iterations). may have multiple runs (e.g., initial FEA exploration, NN-accelerated iterations).
</p> </p>
<Suspense fallback={<ChartLoading />}> <div className="overflow-x-auto">
<PlotlyRunComparison runs={runs} height={400} /> <table className="w-full text-sm">
</Suspense> <thead>
<tr className="border-b border-dark-600">
<th className="text-left py-2 px-3 text-dark-400 font-medium">Run</th>
<th className="text-left py-2 px-3 text-dark-400 font-medium">Source</th>
<th className="text-left py-2 px-3 text-dark-400 font-medium">Trials</th>
<th className="text-left py-2 px-3 text-dark-400 font-medium">Best Value</th>
<th className="text-left py-2 px-3 text-dark-400 font-medium">Avg Value</th>
</tr>
</thead>
<tbody>
{runs.map((run) => (
<tr key={run.run_id} className="border-b border-dark-700">
<td className="py-2 px-3 font-mono text-white">{run.name || `Run ${run.run_id}`}</td>
<td className="py-2 px-3">
<span className={`px-2 py-0.5 rounded text-xs ${
run.source === 'NN' ? 'bg-purple-500/20 text-purple-400' : 'bg-blue-500/20 text-blue-400'
}`}>
{run.source}
</span>
</td>
<td className="py-2 px-3 text-dark-200">{run.trial_count}</td>
<td className="py-2 px-3 font-mono text-green-400">{run.best_value?.toExponential(4) || 'N/A'}</td>
<td className="py-2 px-3 font-mono text-dark-300">{run.avg_value?.toExponential(4) || 'N/A'}</td>
</tr>
))}
</tbody>
</table>
</div>
</Card> </Card>
</div> </div>
)} )}

View File

@@ -1,42 +1,265 @@
import { useState } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { ClipboardList, Download, Trash2, Layers, Home, ChevronRight } from 'lucide-react'; import { ClipboardList, Download, Trash2, Layers, Home, ChevronRight, Save, RefreshCw, Zap, MessageSquare, X, Folder, SlidersHorizontal, Undo2, Redo2 } from 'lucide-react';
import { AtomizerCanvas } from '../components/canvas/AtomizerCanvas'; import { AtomizerCanvas } from '../components/canvas/AtomizerCanvas';
import { SpecRenderer } from '../components/canvas/SpecRenderer';
import { NodePalette } from '../components/canvas/palette/NodePalette';
import { FileStructurePanel } from '../components/canvas/panels/FileStructurePanel';
import { TemplateSelector } from '../components/canvas/panels/TemplateSelector'; import { TemplateSelector } from '../components/canvas/panels/TemplateSelector';
import { ConfigImporter } from '../components/canvas/panels/ConfigImporter'; import { ConfigImporter } from '../components/canvas/panels/ConfigImporter';
import { NodeConfigPanel } from '../components/canvas/panels/NodeConfigPanel';
import { NodeConfigPanelV2 } from '../components/canvas/panels/NodeConfigPanelV2';
import { ChatPanel } from '../components/canvas/panels/ChatPanel';
import { PanelContainer } from '../components/canvas/panels/PanelContainer';
import { ResizeHandle } from '../components/canvas/ResizeHandle';
import { useCanvasStore } from '../hooks/useCanvasStore'; import { useCanvasStore } from '../hooks/useCanvasStore';
import { useSpecStore, useSpec, useSpecLoading, useSpecIsDirty, useSelectedNodeId } from '../hooks/useSpecStore';
import { useResizablePanel } from '../hooks/useResizablePanel';
// usePanelStore is now used by child components - PanelContainer handles panels
import { useSpecUndoRedo, useUndoRedoKeyboard } from '../hooks/useSpecUndoRedo';
import { useStudy } from '../context/StudyContext'; import { useStudy } from '../context/StudyContext';
import { useChat } from '../hooks/useChat';
import { CanvasTemplate } from '../lib/canvas/templates'; import { CanvasTemplate } from '../lib/canvas/templates';
export function CanvasView() { export function CanvasView() {
const [showTemplates, setShowTemplates] = useState(false); const [showTemplates, setShowTemplates] = useState(false);
const [showImporter, setShowImporter] = useState(false); const [showImporter, setShowImporter] = useState(false);
const [showChat, setShowChat] = useState(true);
const [chatPowerMode, setChatPowerMode] = useState(false);
const [notification, setNotification] = useState<string | null>(null); const [notification, setNotification] = useState<string | null>(null);
const [isSaving, setIsSaving] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
const [paletteCollapsed, setPaletteCollapsed] = useState(false);
const [leftSidebarTab, setLeftSidebarTab] = useState<'components' | 'files'>('components');
const navigate = useNavigate(); const navigate = useNavigate();
const { nodes, edges, clear } = useCanvasStore(); // Resizable panels
const { selectedStudy } = useStudy(); const leftPanel = useResizablePanel({
storageKey: 'left-sidebar',
defaultWidth: 240,
minWidth: 200,
maxWidth: 400,
side: 'left',
});
const rightPanel = useResizablePanel({
storageKey: 'right-panel',
defaultWidth: 384,
minWidth: 280,
maxWidth: 600,
side: 'right',
});
const [searchParams] = useSearchParams();
// Spec mode is the default (AtomizerSpec v2.0)
// Legacy mode can be enabled via:
// 1. VITE_USE_LEGACY_CANVAS=true environment variable
// 2. ?mode=legacy query param (for emergency fallback)
const legacyEnvEnabled = import.meta.env.VITE_USE_LEGACY_CANVAS === 'true';
const legacyQueryParam = searchParams.get('mode') === 'legacy';
const useSpecMode = !legacyEnvEnabled && !legacyQueryParam;
// Get study ID from URL params (supports nested paths like M1_Mirror/study_name)
const { '*': urlStudyId } = useParams<{ '*': string }>();
// Legacy canvas store (for backwards compatibility)
const { nodes, edges, clear, loadFromConfig, toIntent } = useCanvasStore();
// New spec store (AtomizerSpec v2.0)
const spec = useSpec();
const specLoading = useSpecLoading();
const specIsDirty = useSpecIsDirty();
const selectedNodeId = useSelectedNodeId();
const { loadSpec, saveSpec, reloadSpec } = useSpecStore();
const { setSelectedStudy, studies } = useStudy();
const { clearSpec, setSpecFromWebSocket } = useSpecStore();
// Undo/Redo for spec mode
const undoRedo = useSpecUndoRedo();
const { undo, redo, canUndo, canRedo, historyLength } = undoRedo;
// Enable keyboard shortcuts for undo/redo (Ctrl+Z, Ctrl+Y)
useUndoRedoKeyboard(undoRedo);
// Active study ID comes ONLY from URL - don't auto-load from context
// This ensures /canvas shows empty canvas, /canvas/{id} shows the study
const activeStudyId = urlStudyId;
// Chat hook for assistant panel
const { messages, isThinking, isConnected, sendMessage, notifyCanvasEdit } = useChat({
studyId: activeStudyId,
mode: chatPowerMode ? 'power' : 'user',
useWebSocket: true,
onCanvasModification: chatPowerMode ? (modification) => {
// Handle canvas modifications from Claude in power mode (legacy)
console.log('Canvas modification from Claude:', modification);
showNotification(`Claude: ${modification.action} ${modification.nodeType || modification.nodeId || ''}`);
// The actual modification is handled by the MCP tools on the backend
// which update atomizer_spec.json, then the canvas reloads via WebSocket
reloadSpec();
} : undefined,
onSpecUpdated: useSpecMode ? (newSpec) => {
// Direct spec update from Claude via WebSocket - no HTTP reload needed
console.log('Spec updated from Claude via WebSocket:', newSpec.meta?.study_name);
setSpecFromWebSocket(newSpec, activeStudyId);
showNotification('Canvas synced with Claude');
} : undefined,
});
// Load or clear spec based on URL study ID
useEffect(() => {
if (urlStudyId) {
if (useSpecMode) {
// Try to load spec first, fall back to legacy config
loadSpec(urlStudyId).catch(() => {
// If spec doesn't exist, try legacy config
loadStudyConfig(urlStudyId);
});
} else {
loadStudyConfig(urlStudyId);
}
} else {
// No study ID in URL - clear spec for empty canvas (new study creation)
if (useSpecMode) {
clearSpec();
} else {
clear();
}
}
}, [urlStudyId, useSpecMode]);
// Notify Claude when user edits the spec (bi-directional sync)
// This sends the updated spec to Claude so it knows what the user changed
useEffect(() => {
if (useSpecMode && spec && specIsDirty && chatPowerMode) {
// User made changes - notify Claude via WebSocket
notifyCanvasEdit(spec);
}
}, [spec, specIsDirty, useSpecMode, chatPowerMode, notifyCanvasEdit]);
// Track unsaved changes (legacy mode only)
useEffect(() => {
if (!useSpecMode && activeStudyId && nodes.length > 0) {
setHasUnsavedChanges(true);
}
}, [nodes, edges, useSpecMode]);
const loadStudyConfig = async (studyId: string) => {
setIsLoading(true);
try {
const response = await fetch(`/api/optimization/studies/${encodeURIComponent(studyId)}/config`);
if (!response.ok) {
throw new Error(`Failed to load study: ${response.status}`);
}
const data = await response.json();
loadFromConfig(data.config);
setHasUnsavedChanges(false);
// Also select the study in context
const study = studies.find(s => s.id === studyId);
if (study) {
setSelectedStudy(study);
}
showNotification(`Loaded: ${studyId}`);
} catch (error) {
console.error('Failed to load study config:', error);
showNotification('Failed to load study config');
} finally {
setIsLoading(false);
}
};
const saveToConfig = async () => {
if (!activeStudyId) {
showNotification('No study selected to save to');
return;
}
setIsSaving(true);
try {
if (useSpecMode && spec) {
// Save spec using new API
await saveSpec(spec);
showNotification('Saved to atomizer_spec.json');
} else {
// Legacy save
const intent = toIntent();
const response = await fetch(`/api/optimization/studies/${encodeURIComponent(activeStudyId)}/config`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ intent }),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Failed to save');
}
setHasUnsavedChanges(false);
showNotification('Saved to optimization_config.json');
}
} catch (error) {
console.error('Failed to save:', error);
showNotification('Failed to save: ' + (error instanceof Error ? error.message : 'Unknown error'));
} finally {
setIsSaving(false);
}
};
const handleTemplateSelect = (template: CanvasTemplate) => { const handleTemplateSelect = (template: CanvasTemplate) => {
setHasUnsavedChanges(true);
showNotification(`Loaded template: ${template.name}`); showNotification(`Loaded template: ${template.name}`);
}; };
const handleImport = (source: string) => { const handleImport = (source: string) => {
setHasUnsavedChanges(true);
showNotification(`Imported from ${source}`); showNotification(`Imported from ${source}`);
}; };
const handleClear = () => { const handleClear = () => {
if (useSpecMode) {
// In spec mode, clearing is not typically needed since changes sync automatically
showNotification('Use Reload to reset to saved state');
return;
}
if (nodes.length === 0 || window.confirm('Clear all nodes from the canvas?')) { if (nodes.length === 0 || window.confirm('Clear all nodes from the canvas?')) {
clear(); clear();
setHasUnsavedChanges(true);
showNotification('Canvas cleared'); showNotification('Canvas cleared');
} }
}; };
const handleReload = () => {
if (activeStudyId) {
const hasChanges = useSpecMode ? specIsDirty : hasUnsavedChanges;
if (hasChanges && !window.confirm('Reload will discard unsaved changes. Continue?')) {
return;
}
if (useSpecMode) {
reloadSpec();
showNotification('Reloaded from atomizer_spec.json');
} else {
loadStudyConfig(activeStudyId);
}
}
};
const showNotification = (message: string) => { const showNotification = (message: string) => {
setNotification(message); setNotification(message);
setTimeout(() => setNotification(null), 3000); setTimeout(() => setNotification(null), 3000);
}; };
// Navigate to canvas with study ID
const navigateToStudy = useCallback((studyId: string) => {
navigate(`/canvas/${studyId}`);
}, [navigate]);
return ( return (
<div className="h-screen flex flex-col bg-dark-900"> <div className="h-screen flex flex-col bg-dark-900">
{/* Minimal Header */} {/* Minimal Header */}
@@ -55,24 +278,125 @@ export function CanvasView() {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Layers size={18} className="text-primary-400" /> <Layers size={18} className="text-primary-400" />
<span className="text-sm font-medium text-white">Canvas Builder</span> <span className="text-sm font-medium text-white">Canvas Builder</span>
{selectedStudy && ( {activeStudyId && (
<> <>
<ChevronRight size={14} className="text-dark-500" /> <ChevronRight size={14} className="text-dark-500" />
<span className="text-sm text-primary-400 font-medium"> <span className="text-sm text-primary-400 font-medium">
{selectedStudy.name || selectedStudy.id} {activeStudyId}
</span> </span>
{hasUnsavedChanges && (
<span className="text-xs text-amber-400 ml-1" title="Unsaved changes"></span>
)}
</> </>
)} )}
</div> </div>
{/* Stats */} {/* Stats */}
<span className="text-xs text-dark-500 tabular-nums ml-2"> {useSpecMode && spec ? (
{nodes.length} node{nodes.length !== 1 ? 's' : ''} {edges.length} edge{edges.length !== 1 ? 's' : ''} <span className="text-xs text-dark-500 tabular-nums ml-2">
</span> {spec.design_variables.length} vars {spec.extractors.length} ext {spec.objectives.length} obj
</span>
) : (
<span className="text-xs text-dark-500 tabular-nums ml-2">
{nodes.length} node{nodes.length !== 1 ? 's' : ''} {edges.length} edge{edges.length !== 1 ? 's' : ''}
</span>
)}
{/* Mode indicator */}
{useSpecMode && (
<span className="ml-2 px-1.5 py-0.5 text-xs bg-primary-900/50 text-primary-400 rounded border border-primary-800 flex items-center gap-1">
<Zap size={10} />
v2.0
</span>
)}
{(isLoading || specLoading) && (
<RefreshCw size={14} className="text-primary-400 animate-spin ml-2" />
)}
</div> </div>
{/* Action Buttons */} {/* Action Buttons */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{/* Save Button - always show in spec mode with study, grayed when no changes */}
{useSpecMode && spec && (
<button
onClick={saveToConfig}
disabled={isSaving || !specIsDirty}
className={`px-3 py-1.5 text-sm rounded-lg transition-colors flex items-center gap-1.5 ${
specIsDirty
? 'bg-green-600 hover:bg-green-500 text-white'
: 'bg-dark-700 text-dark-400 cursor-not-allowed border border-dark-600'
}`}
title={specIsDirty ? 'Save changes to atomizer_spec.json' : 'No changes to save'}
>
<Save size={14} />
{isSaving ? 'Saving...' : 'Save'}
</button>
)}
{/* Legacy Save Button */}
{!useSpecMode && activeStudyId && (
<button
onClick={saveToConfig}
disabled={isSaving || !hasUnsavedChanges}
className={`px-3 py-1.5 text-sm rounded-lg transition-colors flex items-center gap-1.5 ${
hasUnsavedChanges
? 'bg-green-600 hover:bg-green-500 text-white'
: 'bg-dark-700 text-dark-400 cursor-not-allowed border border-dark-600'
}`}
title={hasUnsavedChanges ? 'Save changes to optimization_config.json' : 'No changes to save'}
>
<Save size={14} />
{isSaving ? 'Saving...' : 'Save'}
</button>
)}
{/* Reload Button */}
{(useSpecMode ? spec : activeStudyId) && (
<button
onClick={handleReload}
disabled={isLoading || specLoading}
className="px-3 py-1.5 bg-dark-700 text-dark-200 hover:bg-dark-600 hover:text-white text-sm rounded-lg transition-colors flex items-center gap-1.5 border border-dark-600"
title={`Reload from ${useSpecMode ? 'atomizer_spec.json' : 'optimization_config.json'}`}
>
<RefreshCw size={14} className={(isLoading || specLoading) ? 'animate-spin' : ''} />
Reload
</button>
)}
{/* Undo/Redo Buttons (spec mode only) */}
{useSpecMode && activeStudyId && (
<>
<div className="w-px h-6 bg-dark-600" />
<div className="flex items-center gap-1">
<button
onClick={undo}
disabled={!canUndo}
className={`p-1.5 rounded-lg transition-colors ${
canUndo
? 'text-dark-200 hover:bg-dark-700 hover:text-white'
: 'text-dark-600 cursor-not-allowed'
}`}
title={`Undo (Ctrl+Z)${historyLength > 0 ? ` - ${historyLength} steps` : ''}`}
>
<Undo2 size={16} />
</button>
<button
onClick={redo}
disabled={!canRedo}
className={`p-1.5 rounded-lg transition-colors ${
canRedo
? 'text-dark-200 hover:bg-dark-700 hover:text-white'
: 'text-dark-600 cursor-not-allowed'
}`}
title="Redo (Ctrl+Y)"
>
<Redo2 size={16} />
</button>
</div>
</>
)}
<button <button
onClick={() => setShowTemplates(true)} onClick={() => setShowTemplates(true)}
className="px-3 py-1.5 bg-primary-600 hover:bg-primary-500 text-white text-sm rounded-lg transition-colors flex items-center gap-1.5" className="px-3 py-1.5 bg-primary-600 hover:bg-primary-500 text-white text-sm rounded-lg transition-colors flex items-center gap-1.5"
@@ -94,12 +418,183 @@ export function CanvasView() {
<Trash2 size={14} /> <Trash2 size={14} />
Clear Clear
</button> </button>
{/* Divider */}
<div className="w-px h-6 bg-dark-600" />
{/* Chat Toggle */}
<button
onClick={() => setShowChat(!showChat)}
className={`px-3 py-1.5 text-sm rounded-lg transition-colors flex items-center gap-1.5 border ${
showChat
? 'bg-primary-600 text-white border-primary-500'
: 'bg-dark-700 text-dark-200 hover:bg-dark-600 hover:text-white border-dark-600'
}`}
title={showChat ? 'Hide Assistant' : 'Show Assistant'}
>
<MessageSquare size={14} />
Assistant
</button>
</div> </div>
</header> </header>
{/* Main Canvas */} {/* Main Canvas */}
<main className="flex-1 overflow-hidden"> <main className="flex-1 overflow-hidden flex">
<AtomizerCanvas /> {/* Left Sidebar with tabs (spec mode only - AtomizerCanvas has its own) */}
{useSpecMode && (
<div
className="relative bg-dark-850 border-r border-dark-700 flex flex-col"
style={{ width: paletteCollapsed ? 56 : leftPanel.width }}
>
{/* Tab buttons (only show when expanded) */}
{!paletteCollapsed && (
<div className="flex border-b border-dark-700">
<button
onClick={() => setLeftSidebarTab('components')}
className={`flex-1 flex items-center justify-center gap-2 px-3 py-2.5 text-xs font-medium transition-colors
${leftSidebarTab === 'components'
? 'text-primary-400 border-b-2 border-primary-400 -mb-px bg-dark-800/50'
: 'text-dark-400 hover:text-white'}`}
>
<SlidersHorizontal size={14} />
Components
</button>
<button
onClick={() => setLeftSidebarTab('files')}
className={`flex-1 flex items-center justify-center gap-2 px-3 py-2.5 text-xs font-medium transition-colors
${leftSidebarTab === 'files'
? 'text-primary-400 border-b-2 border-primary-400 -mb-px bg-dark-800/50'
: 'text-dark-400 hover:text-white'}`}
>
<Folder size={14} />
Files
</button>
</div>
)}
{/* Tab content */}
<div className="flex-1 overflow-hidden">
{leftSidebarTab === 'components' || paletteCollapsed ? (
<NodePalette
collapsed={paletteCollapsed}
onToggleCollapse={() => setPaletteCollapsed(!paletteCollapsed)}
showToggle={true}
/>
) : (
<FileStructurePanel
studyId={activeStudyId || null}
selectedModelPath={spec?.model?.sim?.path}
onModelSelect={(path, _type) => {
// TODO: Update model path in spec
showNotification(`Selected: ${path.split(/[/\\]/).pop()}`);
}}
/>
)}
</div>
{/* Resize handle (only when not collapsed) */}
{!paletteCollapsed && (
<ResizeHandle
onMouseDown={leftPanel.startDrag}
onDoubleClick={leftPanel.resetWidth}
isDragging={leftPanel.isDragging}
position="right"
/>
)}
</div>
)}
{/* Canvas area - must have explicit height for ReactFlow */}
<div className="flex-1 h-full">
{useSpecMode ? (
<SpecRenderer
studyId={activeStudyId}
onStudyChange={navigateToStudy}
enableWebSocket={true}
showConnectionStatus={true}
editable={true}
/>
) : (
<AtomizerCanvas
studyId={activeStudyId}
onStudyChange={navigateToStudy}
/>
)}
</div>
{/* Config Panel - use V2 for spec mode, legacy for AtomizerCanvas */}
{/* Shows INSTEAD of chat when a node is selected */}
{selectedNodeId ? (
useSpecMode ? (
<div
className="relative border-l border-dark-700 bg-dark-850 flex flex-col"
style={{ width: rightPanel.width }}
>
<ResizeHandle
onMouseDown={rightPanel.startDrag}
onDoubleClick={rightPanel.resetWidth}
isDragging={rightPanel.isDragging}
position="left"
/>
<NodeConfigPanelV2 onClose={() => useSpecStore.getState().clearSelection()} />
</div>
) : (
<div className="w-80 border-l border-dark-700 bg-dark-850 overflow-y-auto">
<NodeConfigPanel nodeId={selectedNodeId} />
</div>
)
) : showChat ? (
<div
className="relative border-l border-dark-700 bg-dark-850 flex flex-col"
style={{ width: rightPanel.width }}
>
{/* Resize handle */}
<ResizeHandle
onMouseDown={rightPanel.startDrag}
onDoubleClick={rightPanel.resetWidth}
isDragging={rightPanel.isDragging}
position="left"
/>
{/* Chat Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-dark-700">
<div className="flex items-center gap-2">
<MessageSquare size={16} className="text-primary-400" />
<span className="font-medium text-white">Assistant</span>
{isConnected && (
<span className="w-2 h-2 rounded-full bg-green-400" title="Connected" />
)}
</div>
<div className="flex items-center gap-2">
{/* Power Mode Toggle */}
<button
onClick={() => setChatPowerMode(!chatPowerMode)}
className={`px-2 py-1 text-xs rounded transition-colors flex items-center gap-1 ${
chatPowerMode
? 'bg-amber-600 text-white'
: 'bg-dark-700 text-dark-400 hover:text-white'
}`}
title={chatPowerMode ? 'Power Mode: Claude can modify the canvas' : 'User Mode: Read-only assistant'}
>
<Zap size={12} />
{chatPowerMode ? 'Power' : 'User'}
</button>
<button
onClick={() => setShowChat(false)}
className="p-1 rounded hover:bg-dark-700 text-dark-400 hover:text-white transition-colors"
>
<X size={16} />
</button>
</div>
</div>
{/* Chat Content */}
<ChatPanel
messages={messages}
isThinking={isThinking}
onSendMessage={sendMessage}
isConnected={isConnected}
/>
</div>
) : null}
</main> </main>
{/* Template Selector Modal */} {/* Template Selector Modal */}
@@ -116,6 +611,9 @@ export function CanvasView() {
onImport={handleImport} onImport={handleImport}
/> />
{/* Floating Panels (Introspection, Validation, Error, Results) */}
{useSpecMode && <PanelContainer />}
{/* Notification Toast */} {/* Notification Toast */}
{notification && ( {notification && (
<div <div

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, lazy, Suspense, useRef } from 'react'; import { useState, useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { Settings } from 'lucide-react'; import { Settings } from 'lucide-react';
import { useOptimizationWebSocket } from '../hooks/useWebSocket'; import { useOptimizationWebSocket } from '../hooks/useWebSocket';
@@ -21,19 +21,6 @@ import { CurrentTrialPanel, OptimizerStatePanel } from '../components/tracker';
import { NivoParallelCoordinates } from '../components/charts'; import { NivoParallelCoordinates } from '../components/charts';
import type { Trial } from '../types'; import type { Trial } from '../types';
// Lazy load Plotly components for better initial load performance
const PlotlyParallelCoordinates = lazy(() => import('../components/plotly/PlotlyParallelCoordinates').then(m => ({ default: m.PlotlyParallelCoordinates })));
const PlotlyParetoPlot = lazy(() => import('../components/plotly/PlotlyParetoPlot').then(m => ({ default: m.PlotlyParetoPlot })));
const PlotlyConvergencePlot = lazy(() => import('../components/plotly/PlotlyConvergencePlot').then(m => ({ default: m.PlotlyConvergencePlot })));
const PlotlyParameterImportance = lazy(() => import('../components/plotly/PlotlyParameterImportance').then(m => ({ default: m.PlotlyParameterImportance })));
// Loading placeholder for lazy components
const ChartLoading = () => (
<div className="flex items-center justify-center h-64 text-dark-400">
<div className="animate-pulse">Loading chart...</div>
</div>
);
export default function Dashboard() { export default function Dashboard() {
const navigate = useNavigate(); const navigate = useNavigate();
const { selectedStudy, refreshStudies, isInitialized } = useStudy(); const { selectedStudy, refreshStudies, isInitialized } = useStudy();
@@ -62,8 +49,8 @@ export default function Dashboard() {
const [paretoFront, setParetoFront] = useState<any[]>([]); const [paretoFront, setParetoFront] = useState<any[]>([]);
const [allTrialsRaw, setAllTrialsRaw] = useState<any[]>([]); // All trials for parallel coordinates const [allTrialsRaw, setAllTrialsRaw] = useState<any[]>([]); // All trials for parallel coordinates
// Chart library toggle: 'nivo' (dark theme, default), 'plotly' (more interactive), or 'recharts' (simple) // Chart library toggle: 'nivo' (dark theme, default) or 'recharts' (simple)
const [chartLibrary, setChartLibrary] = useState<'nivo' | 'plotly' | 'recharts'>('nivo'); const [chartLibrary, setChartLibrary] = useState<'nivo' | 'recharts'>('nivo');
// Process status for tracker panels // Process status for tracker panels
const [isRunning, setIsRunning] = useState(false); const [isRunning, setIsRunning] = useState(false);
@@ -464,18 +451,7 @@ export default function Dashboard() {
}`} }`}
title="Modern Nivo charts with dark theme (recommended)" title="Modern Nivo charts with dark theme (recommended)"
> >
Nivo Advanced
</button>
<button
onClick={() => setChartLibrary('plotly')}
className={`px-3 py-1.5 text-sm transition-colors ${
chartLibrary === 'plotly'
? 'bg-primary-500 text-white'
: 'bg-dark-600 text-dark-200 hover:bg-dark-500'
}`}
title="Interactive Plotly charts with zoom, pan, and export"
>
Plotly
</button> </button>
<button <button
onClick={() => setChartLibrary('recharts')} onClick={() => setChartLibrary('recharts')}
@@ -570,22 +546,11 @@ export default function Dashboard() {
title="Pareto Front" title="Pareto Front"
subtitle={`${paretoFront.length} Pareto-optimal solutions | ${studyMetadata.sampler || 'NSGA-II'} | ${studyMetadata.objectives?.length || 2} objectives`} subtitle={`${paretoFront.length} Pareto-optimal solutions | ${studyMetadata.sampler || 'NSGA-II'} | ${studyMetadata.objectives?.length || 2} objectives`}
> >
{chartLibrary === 'plotly' ? ( <ParetoPlot
<Suspense fallback={<ChartLoading />}> paretoData={paretoFront}
<PlotlyParetoPlot objectives={studyMetadata.objectives}
trials={allTrialsRaw} allTrials={allTrialsRaw}
paretoFront={paretoFront} />
objectives={studyMetadata.objectives}
height={300}
/>
</Suspense>
) : (
<ParetoPlot
paretoData={paretoFront}
objectives={studyMetadata.objectives}
allTrials={allTrialsRaw}
/>
)}
</ExpandableChart> </ExpandableChart>
</div> </div>
)} )}
@@ -605,16 +570,6 @@ export default function Dashboard() {
paretoFront={paretoFront} paretoFront={paretoFront}
height={380} height={380}
/> />
) : chartLibrary === 'plotly' ? (
<Suspense fallback={<ChartLoading />}>
<PlotlyParallelCoordinates
trials={allTrialsRaw}
objectives={studyMetadata.objectives}
designVariables={studyMetadata.design_variables}
paretoFront={paretoFront}
height={350}
/>
</Suspense>
) : ( ) : (
<ParallelCoordinatesPlot <ParallelCoordinatesPlot
paretoData={allTrialsRaw} paretoData={allTrialsRaw}
@@ -634,24 +589,12 @@ export default function Dashboard() {
title="Convergence" title="Convergence"
subtitle={`Best ${studyMetadata?.objectives?.[0]?.name || 'Objective'} over ${allTrialsRaw.length} trials`} subtitle={`Best ${studyMetadata?.objectives?.[0]?.name || 'Objective'} over ${allTrialsRaw.length} trials`}
> >
{chartLibrary === 'plotly' ? ( <ConvergencePlot
<Suspense fallback={<ChartLoading />}> trials={allTrialsRaw}
<PlotlyConvergencePlot objectiveIndex={0}
trials={allTrialsRaw} objectiveName={studyMetadata?.objectives?.[0]?.name || 'Objective'}
objectiveIndex={0} direction="minimize"
objectiveName={studyMetadata?.objectives?.[0]?.name || 'Objective'} />
direction="minimize"
height={280}
/>
</Suspense>
) : (
<ConvergencePlot
trials={allTrialsRaw}
objectiveIndex={0}
objectiveName={studyMetadata?.objectives?.[0]?.name || 'Objective'}
direction="minimize"
/>
)}
</ExpandableChart> </ExpandableChart>
</div> </div>
)} )}
@@ -663,32 +606,16 @@ export default function Dashboard() {
title="Parameter Importance" title="Parameter Importance"
subtitle={`Correlation with ${studyMetadata?.objectives?.[0]?.name || 'Objective'}`} subtitle={`Correlation with ${studyMetadata?.objectives?.[0]?.name || 'Objective'}`}
> >
{chartLibrary === 'plotly' ? ( <ParameterImportanceChart
<Suspense fallback={<ChartLoading />}> trials={allTrialsRaw}
<PlotlyParameterImportance designVariables={
trials={allTrialsRaw} studyMetadata?.design_variables?.length > 0
designVariables={ ? studyMetadata.design_variables
studyMetadata?.design_variables?.length > 0 : Object.keys(allTrialsRaw[0]?.params || {}).map(name => ({ name }))
? studyMetadata.design_variables }
: Object.keys(allTrialsRaw[0]?.params || {}).map(name => ({ name })) objectiveIndex={0}
} objectiveName={studyMetadata?.objectives?.[0]?.name || 'Objective'}
objectiveIndex={0} />
objectiveName={studyMetadata?.objectives?.[0]?.name || 'Objective'}
height={280}
/>
</Suspense>
) : (
<ParameterImportanceChart
trials={allTrialsRaw}
designVariables={
studyMetadata?.design_variables?.length > 0
? studyMetadata.design_variables
: Object.keys(allTrialsRaw[0]?.params || {}).map(name => ({ name }))
}
objectiveIndex={0}
objectiveName={studyMetadata?.objectives?.[0]?.name || 'Objective'}
/>
)}
</ExpandableChart> </ExpandableChart>
</div> </div>
)} )}

View File

@@ -394,18 +394,32 @@ const Home: React.FC = () => {
<p className="text-dark-400 text-sm">Study Documentation</p> <p className="text-dark-400 text-sm">Study Documentation</p>
</div> </div>
</div> </div>
<button <div className="flex items-center gap-2">
onClick={() => handleSelectStudy(selectedPreview)} <button
className="flex items-center gap-2 px-5 py-2.5 rounded-lg transition-all font-semibold whitespace-nowrap hover:-translate-y-0.5" onClick={() => navigate(`/canvas/${selectedPreview.id}`)}
style={{ className="flex items-center gap-2 px-4 py-2.5 rounded-lg transition-all font-medium whitespace-nowrap hover:-translate-y-0.5"
background: 'linear-gradient(135deg, #00d4e6 0%, #0891b2 100%)', style={{
color: '#000', background: 'rgba(8, 15, 26, 0.85)',
boxShadow: '0 4px 15px rgba(0, 212, 230, 0.3)' border: '1px solid rgba(0, 212, 230, 0.3)',
}} color: '#00d4e6'
> }}
Open >
<ArrowRight className="w-4 h-4" /> <Layers className="w-4 h-4" />
</button> Canvas
</button>
<button
onClick={() => handleSelectStudy(selectedPreview)}
className="flex items-center gap-2 px-5 py-2.5 rounded-lg transition-all font-semibold whitespace-nowrap hover:-translate-y-0.5"
style={{
background: 'linear-gradient(135deg, #00d4e6 0%, #0891b2 100%)',
color: '#000',
boxShadow: '0 4px 15px rgba(0, 212, 230, 0.3)'
}}
>
Open
<ArrowRight className="w-4 h-4" />
</button>
</div>
</div> </div>
{/* Study Quick Stats */} {/* Study Quick Stats */}

View File

@@ -20,11 +20,11 @@ import {
ExternalLink, ExternalLink,
Zap, Zap,
List, List,
LucideIcon LucideIcon,
FileText
} from 'lucide-react'; } from 'lucide-react';
import { useStudy } from '../context/StudyContext'; import { useStudy } from '../context/StudyContext';
import { Card } from '../components/common/Card'; import { Card } from '../components/common/Card';
import Plot from 'react-plotly.js';
// ============================================================================ // ============================================================================
// Types // Types
@@ -642,13 +642,15 @@ export default function Insights() {
Open Full View Open Full View
</button> </button>
)} )}
<button {activeInsight.html_path && (
onClick={() => setFullscreen(true)} <button
className="p-2 bg-dark-700 hover:bg-dark-600 text-white rounded-lg transition-colors" onClick={() => setFullscreen(true)}
title="Fullscreen" className="p-2 bg-dark-700 hover:bg-dark-600 text-white rounded-lg transition-colors"
> title="Fullscreen"
<Maximize2 className="w-5 h-5" /> >
</button> <Maximize2 className="w-5 h-5" />
</button>
)}
</div> </div>
</div> </div>
@@ -674,49 +676,43 @@ export default function Insights() {
</div> </div>
)} )}
{/* Plotly Figure */} {/* Insight Result */}
<Card className="p-0 overflow-hidden"> <Card className="p-0 overflow-hidden">
{activeInsight.plotly_figure ? ( <div className="flex flex-col items-center justify-center h-64 text-dark-400 p-8">
<div className="bg-dark-900" style={{ height: '600px' }}> <CheckCircle className="w-12 h-12 text-green-400 mb-4" />
<Plot <p className="text-lg font-medium text-white mb-2">Insight Generated Successfully</p>
data={activeInsight.plotly_figure.data} {activeInsight.html_path ? (
layout={{ <>
...activeInsight.plotly_figure.layout, <p className="text-sm text-center mb-4">
autosize: true, Click the button below to view the interactive visualization.
margin: { l: 60, r: 60, t: 60, b: 60 }, </p>
paper_bgcolor: '#111827', <button
plot_bgcolor: '#1f2937', onClick={() => window.open(`/api/insights/studies/${selectedStudy?.id}/view/${activeInsight.insight_type}`, '_blank')}
font: { color: 'white' } className="flex items-center gap-2 px-6 py-3 bg-primary-600 hover:bg-primary-500 text-white rounded-lg font-medium transition-colors"
}} >
config={{ <ExternalLink className="w-5 h-5" />
responsive: true, Open Interactive Visualization
displayModeBar: true, </button>
displaylogo: false </>
}} ) : (
style={{ width: '100%', height: '100%' }}
/>
</div>
) : (
<div className="flex flex-col items-center justify-center h-64 text-dark-400 p-8">
<CheckCircle className="w-12 h-12 text-green-400 mb-4" />
<p className="text-lg font-medium text-white mb-2">Insight Generated Successfully</p>
<p className="text-sm text-center"> <p className="text-sm text-center">
This insight generates HTML files. Click "Open Full View" to see the visualization. The visualization has been generated. Check the study's insights folder.
</p> </p>
{activeInsight.summary?.html_files && ( )}
<div className="mt-4 text-sm"> {activeInsight.summary?.html_files && (
<p className="text-dark-400 mb-2">Generated files:</p> <div className="mt-4 text-sm">
<ul className="space-y-1"> <p className="text-dark-400 mb-2">Generated files:</p>
{(activeInsight.summary.html_files as string[]).slice(0, 4).map((f: string, i: number) => ( <ul className="space-y-1">
<li key={i} className="text-dark-300"> {(activeInsight.summary.html_files as string[]).slice(0, 4).map((f: string, i: number) => (
{f.split(/[/\\]/).pop()} <li key={i} className="text-dark-300 flex items-center gap-2">
</li> <FileText className="w-3 h-3" />
))} {f.split(/[/\\]/).pop()}
</ul> </li>
</div> ))}
)} </ul>
</div> </div>
)} )}
</div>
</Card> </Card>
{/* Generate Another */} {/* Generate Another */}
@@ -736,8 +732,8 @@ export default function Insights() {
</div> </div>
)} )}
{/* Fullscreen Modal */} {/* Fullscreen Modal - now opens external HTML */}
{fullscreen && activeInsight?.plotly_figure && ( {fullscreen && activeInsight && (
<div className="fixed inset-0 z-50 bg-dark-900 flex flex-col"> <div className="fixed inset-0 z-50 bg-dark-900 flex flex-col">
<div className="flex items-center justify-between p-4 border-b border-dark-600"> <div className="flex items-center justify-between p-4 border-b border-dark-600">
<h2 className="text-xl font-bold text-white"> <h2 className="text-xl font-bold text-white">
@@ -750,23 +746,24 @@ export default function Insights() {
<X className="w-6 h-6" /> <X className="w-6 h-6" />
</button> </button>
</div> </div>
<div className="flex-1 p-4"> <div className="flex-1 p-4 flex items-center justify-center">
<Plot {activeInsight.html_path ? (
data={activeInsight.plotly_figure.data} <iframe
layout={{ src={`/api/insights/studies/${selectedStudy?.id}/view/${activeInsight.insight_type}`}
...activeInsight.plotly_figure.layout, className="w-full h-full border-0 rounded-lg"
autosize: true, title={activeInsight.insight_name || activeInsight.insight_type}
paper_bgcolor: '#111827', />
plot_bgcolor: '#1f2937', ) : (
font: { color: 'white' } <div className="text-center text-dark-400">
}} <p className="text-lg mb-4">No interactive visualization available for this insight.</p>
config={{ <button
responsive: true, onClick={() => setFullscreen(false)}
displayModeBar: true, className="px-4 py-2 bg-dark-700 hover:bg-dark-600 text-white rounded-lg"
displaylogo: false >
}} Close
style={{ width: '100%', height: '100%' }} </button>
/> </div>
)}
</div> </div>
</div> </div>
)} )}

View File

@@ -278,7 +278,7 @@ export default function Setup() {
Configuration Configuration
</button> </button>
<button <button
onClick={() => setActiveTab('canvas')} onClick={() => navigate(`/canvas/${selectedStudy?.id || ''}`)}
className="flex items-center gap-2 px-4 py-2 rounded-lg transition-colors bg-primary-600 text-white" className="flex items-center gap-2 px-4 py-2 rounded-lg transition-colors bg-primary-600 text-white"
> >
<Grid3X3 className="w-4 h-4" /> <Grid3X3 className="w-4 h-4" />
@@ -333,7 +333,7 @@ export default function Setup() {
Configuration Configuration
</button> </button>
<button <button
onClick={() => setActiveTab('canvas')} onClick={() => navigate(`/canvas/${selectedStudy?.id || ''}`)}
className="flex items-center gap-2 px-4 py-2 rounded-lg transition-colors bg-dark-800 text-dark-300 hover:text-white hover:bg-dark-700" className="flex items-center gap-2 px-4 py-2 rounded-lg transition-colors bg-dark-800 text-dark-300 hover:text-white hover:bg-dark-700"
> >
<Grid3X3 className="w-4 h-4" /> <Grid3X3 className="w-4 h-4" />

View File

@@ -0,0 +1,137 @@
/**
* Vitest Test Setup
*
* This file runs before each test file to set up the testing environment.
*/
/// <reference types="vitest/globals" />
import '@testing-library/jest-dom';
import { vi, beforeAll, afterAll, afterEach } from 'vitest';
// Type for global context
declare const global: typeof globalThis;
// ============================================================================
// Mock Browser APIs
// ============================================================================
// Mock ResizeObserver (used by ReactFlow)
global.ResizeObserver = vi.fn().mockImplementation(() => ({
observe: vi.fn(),
unobserve: vi.fn(),
disconnect: vi.fn(),
}));
// Mock IntersectionObserver
global.IntersectionObserver = vi.fn().mockImplementation(() => ({
observe: vi.fn(),
unobserve: vi.fn(),
disconnect: vi.fn(),
}));
// Mock matchMedia
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
// Mock scrollTo
Element.prototype.scrollTo = vi.fn();
window.scrollTo = vi.fn();
// Mock fetch for API calls
global.fetch = vi.fn();
// ============================================================================
// Mock localStorage
// ============================================================================
const localStorageMock = {
getItem: vi.fn(),
setItem: vi.fn(),
removeItem: vi.fn(),
clear: vi.fn(),
length: 0,
key: vi.fn(),
};
Object.defineProperty(window, 'localStorage', { value: localStorageMock });
// ============================================================================
// Mock WebSocket
// ============================================================================
class MockWebSocket {
static readonly CONNECTING = 0;
static readonly OPEN = 1;
static readonly CLOSING = 2;
static readonly CLOSED = 3;
readonly CONNECTING = 0;
readonly OPEN = 1;
readonly CLOSING = 2;
readonly CLOSED = 3;
url: string;
readyState: number = MockWebSocket.CONNECTING;
onopen: ((event: Event) => void) | null = null;
onclose: ((event: CloseEvent) => void) | null = null;
onmessage: ((event: MessageEvent) => void) | null = null;
onerror: ((event: Event) => void) | null = null;
constructor(url: string) {
this.url = url;
// Simulate connection after a tick
setTimeout(() => {
this.readyState = MockWebSocket.OPEN;
this.onopen?.(new Event('open'));
}, 0);
}
send = vi.fn();
close = vi.fn(() => {
this.readyState = MockWebSocket.CLOSED;
this.onclose?.(new CloseEvent('close'));
});
}
global.WebSocket = MockWebSocket as any;
// ============================================================================
// Console Suppression (optional)
// ============================================================================
// Suppress console.error for expected test warnings
const originalError = console.error;
beforeAll(() => {
console.error = (...args: any[]) => {
// Suppress React act() warnings
if (typeof args[0] === 'string' && args[0].includes('Warning: An update to')) {
return;
}
originalError.call(console, ...args);
};
});
afterAll(() => {
console.error = originalError;
});
// ============================================================================
// Cleanup
// ============================================================================
afterEach(() => {
vi.clearAllMocks();
localStorageMock.getItem.mockReset();
localStorageMock.setItem.mockReset();
});

View File

@@ -0,0 +1,142 @@
/**
* Test Utilities
*
* Provides custom render function with all necessary providers.
*/
/// <reference types="vitest/globals" />
import { ReactElement, ReactNode } from 'react';
import { render, RenderOptions } from '@testing-library/react';
import { BrowserRouter } from 'react-router-dom';
import { StudyProvider } from '../context/StudyContext';
// Type for global context
declare const global: typeof globalThis;
/**
* All providers needed for testing components
*/
function AllProviders({ children }: { children: ReactNode }) {
return (
<BrowserRouter>
<StudyProvider>
{children}
</StudyProvider>
</BrowserRouter>
);
}
/**
* Custom render function that wraps component with all providers
*/
const customRender = (
ui: ReactElement,
options?: Omit<RenderOptions, 'wrapper'>
) => render(ui, { wrapper: AllProviders, ...options });
// Re-export everything from RTL
export * from '@testing-library/react';
export { userEvent } from '@testing-library/user-event';
// Override render with our custom one
export { customRender as render };
/**
* Create a mock AtomizerSpec for testing
*/
export function createMockSpec(overrides: Partial<any> = {}): any {
return {
meta: {
version: '2.0',
study_name: 'test_study',
created_by: 'test',
created_at: new Date().toISOString(),
...overrides.meta,
},
model: {
sim: {
path: 'model.sim',
solver: 'nastran',
solution_type: 'SOL101',
},
...overrides.model,
},
design_variables: overrides.design_variables ?? [
{
id: 'dv_001',
name: 'thickness',
expression_name: 'wall_thickness',
type: 'continuous',
bounds: { min: 1, max: 10 },
baseline: 5,
enabled: true,
},
],
extractors: overrides.extractors ?? [
{
id: 'ext_001',
name: 'displacement',
type: 'displacement',
outputs: ['max_disp'],
enabled: true,
},
],
objectives: overrides.objectives ?? [
{
id: 'obj_001',
name: 'minimize_mass',
type: 'minimize',
source: { extractor_id: 'ext_001', output: 'max_disp' },
weight: 1.0,
enabled: true,
},
],
constraints: overrides.constraints ?? [],
optimization: {
algorithm: { type: 'TPE' },
budget: { max_trials: 100 },
...overrides.optimization,
},
canvas: {
edges: [],
layout_version: '2.0',
...overrides.canvas,
},
};
}
/**
* Create a mock API response
*/
export function mockFetch(responses: Record<string, any>) {
return (global.fetch as any).mockImplementation((url: string, options?: RequestInit) => {
const method = options?.method || 'GET';
const key = `${method} ${url}`;
// Find matching response
for (const [pattern, response] of Object.entries(responses)) {
if (key.includes(pattern) || url.includes(pattern)) {
return Promise.resolve({
ok: true,
json: () => Promise.resolve(response),
text: () => Promise.resolve(JSON.stringify(response)),
});
}
}
// Default 404
return Promise.resolve({
ok: false,
status: 404,
json: () => Promise.resolve({ detail: 'Not found' }),
});
});
}
/**
* Wait for async state updates
*/
export async function waitForStateUpdate() {
await new Promise(resolve => setTimeout(resolve, 0));
}

View File

@@ -0,0 +1,632 @@
/**
* AtomizerSpec v2.0 TypeScript Types
*
* These types match the JSON Schema at optimization_engine/schemas/atomizer_spec_v2.json
* This is the single source of truth for optimization configuration.
*/
// ============================================================================
// Position Types
// ============================================================================
export interface CanvasPosition {
x: number;
y: number;
}
// ============================================================================
// Meta Types
// ============================================================================
export type SpecCreatedBy = 'canvas' | 'claude' | 'api' | 'migration' | 'manual';
export interface SpecMeta {
/** Schema version (e.g., "2.0") */
version: string;
/** When the spec was created (ISO 8601) */
created?: string;
/** When the spec was last modified (ISO 8601) */
modified?: string;
/** Who/what created the spec */
created_by?: SpecCreatedBy;
/** Who/what last modified the spec */
modified_by?: string;
/** Unique study identifier (snake_case) */
study_name: string;
/** Human-readable description */
description?: string;
/** Tags for categorization */
tags?: string[];
/** Real-world engineering context */
engineering_context?: string;
/** Current workflow status */
status?: 'draft' | 'introspected' | 'configured' | 'validated' | 'ready' | 'running' | 'completed' | 'failed';
/** Topic/folder for organization */
topic?: string;
}
// ============================================================================
// Model Types
// ============================================================================
export interface NxPartConfig {
/** Path to .prt file */
path?: string;
/** File hash for change detection */
hash?: string;
/** Idealized part filename (_i.prt) */
idealized_part?: string;
}
export interface FemConfig {
/** Path to .fem file */
path?: string;
/** Number of elements */
element_count?: number;
/** Number of nodes */
node_count?: number;
}
export type SolverType = 'nastran' | 'NX_Nastran' | 'abaqus';
/**
* SolverEngine - The actual solver software used for analysis
* - nxnastran: NX Nastran (built into Siemens NX)
* - mscnastran: MSC Nastran (external)
* - python: Custom Python script
* - abaqus: Abaqus (future)
* - ansys: ANSYS (future)
*/
export type SolverEngine = 'nxnastran' | 'mscnastran' | 'python' | 'abaqus' | 'ansys';
/**
* NastranSolutionType - Common Nastran solution types
*/
export type NastranSolutionType =
| 'SOL101' // Linear Statics
| 'SOL103' // Normal Modes
| 'SOL105' // Buckling
| 'SOL106' // Nonlinear Statics
| 'SOL111' // Modal Frequency Response
| 'SOL112' // Modal Transient Response
| 'SOL200'; // Design Optimization
export type SubcaseType = 'static' | 'modal' | 'thermal' | 'buckling';
export interface Subcase {
id: number;
name?: string;
type?: SubcaseType;
}
export interface SimConfig {
/** Path to .sim file */
path: string;
/** Solver type (legacy, use engine instead) */
solver: SolverType;
/** Solver engine software */
engine?: SolverEngine;
/** Solution type (e.g., SOL101) */
solution_type?: NastranSolutionType | string;
/** Python script path (for python engine) */
script_path?: string;
/** Defined subcases */
subcases?: Subcase[];
}
export interface NxSettings {
nx_install_path?: string;
simulation_timeout_s?: number;
auto_start_nx?: boolean;
}
export interface IntrospectionExpression {
name: string;
value: number | null;
units: string | null;
formula: string | null;
is_candidate: boolean;
confidence: number;
}
export interface IntrospectionData {
timestamp: string;
solver_type: string | null;
mass_kg: number | null;
volume_mm3: number | null;
expressions: IntrospectionExpression[];
warnings: string[];
baseline: {
timestamp: string;
solve_time_seconds: number;
mass_kg: number | null;
max_displacement_mm: number | null;
max_stress_mpa: number | null;
success: boolean;
error: string | null;
} | null;
}
export interface ModelConfig {
nx_part?: NxPartConfig;
prt?: NxPartConfig;
fem?: FemConfig;
sim?: SimConfig;
nx_settings?: NxSettings;
introspection?: IntrospectionData;
}
// ============================================================================
// Design Variable Types
// ============================================================================
export type DesignVariableType = 'continuous' | 'integer' | 'categorical';
export interface DesignVariableBounds {
min: number;
max: number;
}
export interface DesignVariable {
/** Unique identifier (pattern: dv_XXX) */
id: string;
/** Human-readable name */
name: string;
/** NX expression name (must match model) */
expression_name: string;
/** Variable type */
type: DesignVariableType;
/** Value bounds */
bounds: DesignVariableBounds;
/** Current/initial value */
baseline?: number;
/** Physical units (mm, deg, etc.) */
units?: string;
/** Step size for integer/discrete */
step?: number;
/** Whether to include in optimization */
enabled?: boolean;
/** Description */
description?: string;
/** Canvas position */
canvas_position?: CanvasPosition;
}
// ============================================================================
// Extractor Types
// ============================================================================
export type ExtractorType =
| 'displacement'
| 'frequency'
| 'stress'
| 'mass'
| 'mass_expression'
| 'zernike_opd'
| 'zernike_csv'
| 'temperature'
| 'custom_function';
export interface ExtractorConfig {
/** Inner radius for Zernike (mm) */
inner_radius_mm?: number;
/** Outer radius for Zernike (mm) */
outer_radius_mm?: number;
/** Number of Zernike modes */
n_modes?: number;
/** Low-order modes to filter */
filter_low_orders?: number;
/** Displacement unit */
displacement_unit?: string;
/** Reference subcase ID */
reference_subcase?: number;
/** NX expression name (for mass_expression) */
expression_name?: string;
/** Mode number (for frequency) */
mode_number?: number;
/** Element type (for stress) */
element_type?: string;
/** Result type */
result_type?: string;
/** Metric type */
metric?: string;
/** Additional config properties */
[key: string]: unknown;
}
export interface CustomFunction {
/** Function name */
name?: string;
/** Python module path */
module?: string;
/** Function signature */
signature?: string;
/** Python source code */
source_code?: string;
}
export interface ExtractorOutput {
/** Output name (used by objectives/constraints) */
name: string;
/** Specific metric (max, total, rms, etc.) */
metric?: string;
/** Subcase ID for this output */
subcase?: number;
/** Units */
units?: string;
}
export interface Extractor {
/** Unique identifier (pattern: ext_XXX) */
id: string;
/** Human-readable name */
name: string;
/** Extractor type */
type: ExtractorType;
/** Whether this is a built-in extractor */
builtin?: boolean;
/** Type-specific configuration */
config?: ExtractorConfig;
/** Custom function definition (for custom_function type) */
function?: CustomFunction;
/** Output values this extractor produces */
outputs: ExtractorOutput[];
/** Canvas position */
canvas_position?: CanvasPosition;
}
// ============================================================================
// Objective Types
// ============================================================================
export type OptimizationDirection = 'minimize' | 'maximize';
export interface ObjectiveSource {
/** Reference to extractor */
extractor_id: string;
/** Which output from the extractor */
output_name: string;
}
export interface Objective {
/** Unique identifier (pattern: obj_XXX) */
id: string;
/** Human-readable name */
name: string;
/** Optimization direction */
direction: OptimizationDirection;
/** Weight for weighted sum (multi-objective) */
weight?: number;
/** Where the value comes from */
source: ObjectiveSource;
/** Target value (for goal programming) */
target?: number;
/** Units */
units?: string;
/** Description */
description?: string;
/** Canvas position */
canvas_position?: CanvasPosition;
}
// ============================================================================
// Constraint Types
// ============================================================================
export type ConstraintType = 'hard' | 'soft';
export type ConstraintOperator = '<=' | '>=' | '<' | '>' | '==';
export type PenaltyMethod = 'linear' | 'quadratic' | 'exponential';
export interface ConstraintSource {
extractor_id: string;
output_name: string;
}
export interface PenaltyConfig {
/** Penalty method */
method?: PenaltyMethod;
/** Penalty weight */
weight?: number;
/** Soft margin before penalty kicks in */
margin?: number;
}
export interface Constraint {
/** Unique identifier (pattern: con_XXX) */
id: string;
/** Human-readable name */
name: string;
/** Constraint type */
type: ConstraintType;
/** Comparison operator */
operator: ConstraintOperator;
/** Constraint threshold value */
threshold: number;
/** Where the value comes from */
source: ConstraintSource;
/** Penalty method configuration */
penalty_config?: PenaltyConfig;
/** Description */
description?: string;
/** Canvas position */
canvas_position?: CanvasPosition;
}
// ============================================================================
// Optimization Types
// ============================================================================
export type AlgorithmType = 'TPE' | 'CMA-ES' | 'NSGA-II' | 'RandomSearch' | 'SAT_v3' | 'GP-BO';
export type SurrogateType = 'MLP' | 'GNN' | 'ensemble';
export interface AlgorithmConfig {
/** Population size (evolutionary algorithms) */
population_size?: number;
/** Number of generations */
n_generations?: number;
/** Mutation probability */
mutation_prob?: number | null;
/** Crossover probability */
crossover_prob?: number;
/** Random seed */
seed?: number;
/** Number of startup trials (TPE) */
n_startup_trials?: number;
/** Initial sigma (CMA-ES) */
sigma0?: number;
/** Additional config properties */
[key: string]: unknown;
}
export interface Algorithm {
type: AlgorithmType;
config?: AlgorithmConfig;
}
export interface OptimizationBudget {
/** Maximum number of trials */
max_trials?: number;
/** Maximum time in hours */
max_time_hours?: number;
/** Stop if no improvement for N trials */
convergence_patience?: number;
}
export interface SurrogateConfig {
/** Number of models in ensemble */
n_models?: number;
/** Network architecture layers */
architecture?: number[];
/** Retrain every N trials */
train_every_n_trials?: number;
/** Minimum training samples */
min_training_samples?: number;
/** Acquisition function candidates */
acquisition_candidates?: number;
/** FEA validations per round */
fea_validations_per_round?: number;
}
export interface Surrogate {
enabled?: boolean;
type?: SurrogateType;
config?: SurrogateConfig;
}
export interface OptimizationConfig {
algorithm: Algorithm;
budget: OptimizationBudget;
surrogate?: Surrogate;
canvas_position?: CanvasPosition;
}
// ============================================================================
// Workflow Types
// ============================================================================
export interface WorkflowStage {
id: string;
name: string;
algorithm?: string;
trials?: number;
purpose?: string;
}
export interface WorkflowTransition {
from: string;
to: string;
condition?: string;
}
export interface Workflow {
stages?: WorkflowStage[];
transitions?: WorkflowTransition[];
}
// ============================================================================
// Reporting Types
// ============================================================================
export interface InsightConfig {
include_html?: boolean;
show_pareto_evolution?: boolean;
[key: string]: unknown;
}
export interface Insight {
type?: string;
for_trials?: string;
config?: InsightConfig;
}
export interface ReportingConfig {
auto_report?: boolean;
report_triggers?: string[];
insights?: Insight[];
}
// ============================================================================
// Canvas Types
// ============================================================================
export interface CanvasViewport {
x: number;
y: number;
zoom: number;
}
export interface CanvasEdge {
source: string;
target: string;
sourceHandle?: string;
targetHandle?: string;
}
export interface CanvasGroup {
id: string;
name: string;
node_ids: string[];
}
export interface CanvasConfig {
layout_version?: string;
viewport?: CanvasViewport;
edges?: CanvasEdge[];
groups?: CanvasGroup[];
}
// ============================================================================
// Main AtomizerSpec Type
// ============================================================================
/**
* AtomizerSpec v2.0 - The unified configuration schema for Atomizer optimization studies.
*
* This is the single source of truth used by:
* - Canvas UI (rendering and editing)
* - Backend API (validation and storage)
* - Claude Assistant (reading and modifying)
* - Optimization Engine (execution)
*/
export interface AtomizerSpec {
/** Metadata about the spec */
meta: SpecMeta;
/** NX model files and configuration */
model: ModelConfig;
/** Design variables (NX expressions) to optimize */
design_variables: DesignVariable[];
/** Physics extractors that compute outputs from FEA results */
extractors: Extractor[];
/** Optimization objectives (minimize/maximize) */
objectives: Objective[];
/** Hard and soft constraints */
constraints?: Constraint[];
/** Optimization algorithm configuration */
optimization: OptimizationConfig;
/** Multi-stage optimization workflow */
workflow?: Workflow;
/** Reporting configuration */
reporting?: ReportingConfig;
/** Canvas UI state (persisted for reconstruction) */
canvas?: CanvasConfig;
}
// ============================================================================
// Utility Types for API Responses
// ============================================================================
export interface SpecValidationError {
type: 'schema' | 'semantic' | 'reference';
path: string[];
message: string;
}
export interface SpecValidationWarning {
type: string;
path: string[];
message: string;
}
export interface SpecValidationReport {
valid: boolean;
errors: SpecValidationError[];
warnings: SpecValidationWarning[];
summary: {
design_variables: number;
extractors: number;
objectives: number;
constraints: number;
custom_functions: number;
};
}
export interface SpecModification {
operation: 'set' | 'add' | 'remove';
path: string;
value?: unknown;
}
export interface SpecUpdateResult {
success: boolean;
hash: string;
modified: string;
modified_by: string;
}
export interface SpecPatchRequest {
path: string;
value: unknown;
modified_by?: string;
}
// ============================================================================
// Node Types for Canvas
// ============================================================================
export type SpecNodeType =
| 'designVar'
| 'extractor'
| 'objective'
| 'constraint'
| 'model'
| 'solver'
| 'algorithm';
export interface SpecNodeBase {
id: string;
type: SpecNodeType;
position: CanvasPosition;
data: Record<string, unknown>;
}
// ============================================================================
// WebSocket Types
// ============================================================================
export type SpecSyncMessageType =
| 'spec_updated'
| 'validation_error'
| 'node_added'
| 'node_removed'
| 'connection_ack';
export interface SpecSyncMessage {
type: SpecSyncMessageType;
timestamp: string;
hash?: string;
modified_by?: string;
changes?: Array<{
path: string;
old: unknown;
new: unknown;
}>;
error?: string;
}
export interface SpecClientMessage {
type: 'subscribe' | 'patch_node' | 'add_node' | 'remove_node' | 'update_position';
study_id: string;
node_id?: string;
data?: Record<string, unknown>;
position?: CanvasPosition;
}

View File

@@ -1,3 +1,6 @@
// AtomizerSpec v2.0 types (unified configuration)
export * from './atomizer-spec';
// Study types // Study types
export interface Study { export interface Study {
id: string; id: string;

View File

@@ -17,18 +17,10 @@ export default defineConfig({
} }
} }
}, },
resolve: {
alias: {
// Use the smaller basic Plotly distribution
'plotly.js/dist/plotly': 'plotly.js-basic-dist'
}
},
build: { build: {
rollupOptions: { rollupOptions: {
output: { output: {
manualChunks: { manualChunks: {
// Separate Plotly into its own chunk for better caching
plotly: ['plotly.js-basic-dist', 'react-plotly.js'],
// Separate React and core libs // Separate React and core libs
vendor: ['react', 'react-dom', 'react-router-dom'], vendor: ['react', 'react-dom', 'react-router-dom'],
// Recharts in its own chunk // Recharts in its own chunk
@@ -37,8 +29,5 @@ export default defineConfig({
} }
}, },
chunkSizeWarningLimit: 600 chunkSizeWarningLimit: 600
},
optimizeDeps: {
include: ['plotly.js-basic-dist']
} }
}) })

View File

@@ -0,0 +1,31 @@
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./src/test/setup.ts'],
include: ['src/**/*.{test,spec}.{ts,tsx}'],
exclude: ['node_modules', 'dist', 'tests/e2e'],
coverage: {
provider: 'v8',
reporter: ['text', 'html', 'lcov'],
include: ['src/**/*.{ts,tsx}'],
exclude: [
'src/test/**',
'src/**/*.d.ts',
'src/vite-env.d.ts',
'src/main.tsx',
],
},
// Mock CSS imports
css: false,
},
resolve: {
alias: {
'@': '/src',
},
},
});

View File

@@ -25,6 +25,18 @@ if not exist "%CONDA_PATH%\Scripts\activate.bat" (
exit /b 1 exit /b 1
) )
:: Stop any existing dashboard processes first
echo [0/3] Stopping existing processes...
taskkill /F /FI "WINDOWTITLE eq Atomizer Backend*" >nul 2>&1
taskkill /F /FI "WINDOWTITLE eq Atomizer Frontend*" >nul 2>&1
for /f "tokens=5" %%a in ('netstat -ano ^| findstr :%BACKEND_PORT% ^| findstr LISTENING') do (
taskkill /F /PID %%a >nul 2>&1
)
for /f "tokens=5" %%a in ('netstat -ano ^| findstr :%FRONTEND_PORT% ^| findstr LISTENING') do (
taskkill /F /PID %%a >nul 2>&1
)
ping 127.0.0.1 -n 2 >nul
echo [1/3] Starting Backend Server (port %BACKEND_PORT%)... echo [1/3] Starting Backend Server (port %BACKEND_PORT%)...
start "Atomizer Backend" cmd /k "call %CONDA_PATH%\Scripts\activate.bat %CONDA_ENV% && cd /d %SCRIPT_DIR%backend && python -m uvicorn api.main:app --reload --port %BACKEND_PORT%" start "Atomizer Backend" cmd /k "call %CONDA_PATH%\Scripts\activate.bat %CONDA_ENV% && cd /d %SCRIPT_DIR%backend && python -m uvicorn api.main:app --reload --port %BACKEND_PORT%"

Some files were not shown because too many files have changed in this diff Show More