3 Commits

Author SHA1 Message Date
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
24 changed files with 9254 additions and 300 deletions

View File

@@ -0,0 +1,894 @@
"""
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.
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)}"
)
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, "name": entry.name,
"path": str(entry.relative_to(STUDIES_ROOT)).replace("\\", "/"), "path": str(entry.relative_to(STUDIES_ROOT)).replace("\\", "/"),
"isDirectory": True, "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, "name": entry.name,
"path": str(entry.relative_to(STUDIES_ROOT)).replace("\\", "/"), "path": str(entry.relative_to(STUDIES_ROOT)).replace("\\", "/"),
"isDirectory": False, "isDirectory": False,
"size": entry.stat().st_size, "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, "name": entry.name,
"path": str(entry.relative_to(STUDIES_ROOT)).replace("\\", "/"), "path": str(entry.relative_to(STUDIES_ROOT)).replace("\\", "/"),
"isDirectory": False, "isDirectory": False,
"size": entry.stat().st_size, "size": entry.stat().st_size,
}) }
)
except (PermissionError, OSError): except (PermissionError, OSError):
pass pass
@@ -190,9 +195,9 @@ 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 = [
@@ -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, "name": src_file.name,
"status": "skipped", "status": "skipped",
"reason": "Already exists", "reason": "Already exists",
"path": str(dest_file.relative_to(STUDIES_ROOT)).replace("\\", "/"), "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, "name": src_file.name,
"status": "imported", "status": "imported",
"path": str(dest_file.relative_to(STUDIES_ROOT)).replace("\\", "/"), "path": str(dest_file.relative_to(STUDIES_ROOT)).replace("\\", "/"),
"size": dest_file.stat().st_size, "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, "name": file.filename,
"status": "rejected", "status": "rejected",
"reason": f"Invalid file type: {suffix}", "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, "name": file.filename,
"status": "uploaded", "status": "uploaded",
"path": str(dest_file.relative_to(STUDIES_ROOT)).replace("\\", "/"), "path": str(dest_file.relative_to(STUDIES_ROOT)).replace("\\", "/"),
"size": len(content), "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,
}

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

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

@@ -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) => {
setIsExecuting(true);
try {
const intent = toIntent(); const intent = toIntent();
// For now, both modes use the same executeIntent - backend will handle the mode distinction
await executeIntent(intent, studyName, autoRun); // 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); setShowExecuteDialog(false);
setShowChat(true); }
}; };
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,521 @@
/**
* 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, DragEvent } from 'react';
import ReactFlow, {
Background,
Controls,
MiniMap,
ReactFlowProvider,
ReactFlowInstance,
Edge,
Node,
NodeChange,
EdgeChange,
Connection,
} 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 { ConnectionStatusIndicator } from './ConnectionStatusIndicator';
import { CanvasNodeData } from '../../lib/canvas/schema';
// ============================================================================
// Drag-Drop Helpers
// ============================================================================
/** Addable node types via drag-drop */
const ADDABLE_NODE_TYPES = ['designVar', 'extractor', 'objective', 'constraint'] as const;
type AddableNodeType = typeof ADDABLE_NODE_TYPES[number];
function isAddableNodeType(type: string): type is AddableNodeType {
return ADDABLE_NODE_TYPES.includes(type as AddableNodeType);
}
/** Maps canvas NodeType to spec API type */
function mapNodeTypeToSpecType(type: AddableNodeType): 'designVar' | 'extractor' | 'objective' | 'constraint' {
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 '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',
enabled: true,
canvas_position: position,
};
case 'objective':
return {
name: `objective_${timestamp}`,
direction: 'minimize',
weight: 1.0,
source_extractor_id: null,
source_output: null,
canvas_position: position,
};
case 'constraint':
return {
name: `constraint_${timestamp}`,
type: 'upper',
limit: 1.0,
source_extractor_id: null,
source_output: null,
enabled: true,
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);
// 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(() => {
return specToNodes(spec);
}, [spec]);
// 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]);
// Handle node position changes
const onNodesChange = useCallback(
(changes: NodeChange[]) => {
if (!editable) return;
// Handle position changes
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;
}
// 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);
try {
const nodeId = await addNode(specType, 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]
);
// 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={nodes}
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>
{/* 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>
</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

@@ -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

@@ -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,36 +19,184 @@ 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
// ============================================================================
export const PALETTE_ITEMS: PaletteItem[] = [
{
type: 'model',
label: 'Model',
icon: Box,
description: 'NX model file (.prt, .sim)',
color: 'text-blue-400',
canAdd: false, // Synthetic - derived from spec
},
{
type: 'solver',
label: 'Solver',
icon: Cpu,
description: 'Nastran solution type',
color: 'text-violet-400',
canAdd: false, // Synthetic - derived from model
},
{
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: false, // Synthetic - derived from spec.optimization
},
{
type: 'surrogate',
label: 'Surrogate',
icon: Rocket,
description: 'Neural acceleration',
color: 'text-pink-400',
canAdd: false, // Synthetic - derived from spec.optimization.surrogate
},
]; ];
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';
}; };
// Collapsed mode - icons only
if (collapsed) {
return ( return (
<div className="w-60 bg-dark-850 border-r border-dark-700 flex flex-col"> <div className={`w-14 bg-dark-850 border-r border-dark-700 flex flex-col ${className}`}>
<div className="p-4 border-b border-dark-700"> {/* Toggle Button */}
{showToggle && onToggleCollapse && (
<button
onClick={onToggleCollapse}
className="p-4 border-b border-dark-700 hover:bg-dark-800 transition-colors flex items-center justify-center"
title="Expand palette"
>
<ChevronRight size={18} className="text-dark-400" />
</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"> <h3 className="text-sm font-semibold text-dark-300 uppercase tracking-wider">
Components Components
</h3> </h3>
@@ -46,26 +204,52 @@ export function NodePalette() {
Drag to canvas Drag to canvas
</p> </p>
</div> </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"> <div className="flex-1 overflow-y-auto p-3 space-y-2">
{PALETTE_ITEMS.map((item) => ( {items.map((item) => {
const Icon = item.icon;
const isDraggable = item.canAdd;
return (
<div <div
key={item.type} key={item.type}
draggable draggable={isDraggable}
onDragStart={(e) => onDragStart(e, item.type)} onDragStart={(e) => onDragStart(e, item)}
className="flex items-center gap-3 px-3 py-3 bg-dark-800/50 rounded-lg border border-dark-700/50 className={`flex items-center gap-3 px-3 py-3 rounded-lg border transition-all group
cursor-grab hover:border-primary-500/50 hover:bg-dark-800 ${isDraggable
active:cursor-grabbing transition-all group" ? '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} opacity-90 group-hover:opacity-100 transition-opacity`}> <div className={`${item.color} ${isDraggable ? 'opacity-90 group-hover:opacity-100' : 'opacity-50'} transition-opacity`}>
{item.icon} <Icon size={18} />
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="font-semibold text-white text-sm leading-tight">{item.label}</div> <div className={`font-semibold text-sm leading-tight ${isDraggable ? 'text-white' : 'text-dark-400'}`}>
<div className="text-xs text-dark-400 truncate">{item.description}</div> {item.label}
</div>
<div className="text-xs text-dark-400 truncate">
{isDraggable ? item.description : 'Auto-created'}
</div> </div>
</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,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,858 @@
/**
* 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 { FileBrowser } from './FileBrowser';
import { IntrospectionPanel } from './IntrospectionPanel';
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 [showIntrospection, setShowIntrospection] = 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 Panel */}
{showIntrospection && spec.model.sim?.path && (
<div className="fixed top-20 right-96 z-40">
<IntrospectionPanel
filePath={spec.model.sim.path}
onClose={() => setShowIntrospection(false)}
/>
</div>
)}
</div>
);
}
// ============================================================================
// Type-specific configuration components
// ============================================================================
interface SpecConfigProps {
spec: NonNullable<ReturnType<typeof useSpec>>;
}
function ModelNodeConfig({ spec }: SpecConfigProps) {
const [showIntrospection, setShowIntrospection] = useState(false);
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={() => setShowIntrospection(true)}
className="w-full flex items-center justify-center gap-2 px-3 py-2.5 bg-primary-500/20
hover:bg-primary-500/30 border border-primary-500/30 rounded-lg
text-primary-400 text-sm font-medium transition-colors"
>
<Microscope size={16} />
Introspect Model
</button>
)}
{showIntrospection && spec.model.sim?.path && (
<div className="fixed top-20 right-96 z-40">
<IntrospectionPanel
filePath={spec.model.sim.path}
onClose={() => setShowIntrospection(false)}
/>
</div>
)}
</>
);
}
function SolverNodeConfig({ spec }: SpecConfigProps) {
return (
<div>
<label className={labelClass}>Solution Type</label>
<input
type="text"
value={spec.model.sim?.solution_type || 'Not configured'}
readOnly
className={`${inputClass} bg-dark-900 cursor-not-allowed`}
title="Solver type is determined by the model file."
/>
<p className="text-xs text-dark-500 mt-1">Detected from model file.</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">
{/* Modal Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-dark-700 bg-dark-900">
<div className="flex items-center gap-3">
<FileCode size={18} className="text-violet-400" />
<span className="font-medium text-white">Custom Extractor: {node.name}</span>
<span className="text-xs text-dark-500 bg-dark-800 px-2 py-0.5 rounded">.py</span>
</div>
<button
onClick={() => setShowCodeEditor(false)}
className="p-1.5 rounded hover:bg-dark-700 text-dark-400 hover:text-white transition-colors"
>
<X size={18} />
</button>
</div>
{/* Code Editor */}
<div className="flex-1">
<CodeEditorPanel
initialCode={currentCode}
extractorName={node.name}
outputs={node.outputs?.map(o => o.name) || []}
onChange={handleCodeChange}
onRequestGeneration={handleRequestGeneration}
onRequestStreamingGeneration={handleStreamingGeneration}
onRun={handleValidateCode}
onTest={handleTestCode}
onClose={() => setShowCodeEditor(false)}
showHeader={false}
height="100%"
studyId={studyId || undefined}
/>
</div>
</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

@@ -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

@@ -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,129 @@
/**
* 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, useSpecIsDirty } 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 isDirty = useSpecIsDirty();
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 (and is dirty)
useEffect(() => {
if (spec && isDirty && spec !== lastSpecRef.current) {
lastSpecRef.current = spec;
undoRedo.recordSnapshot();
}
}, [spec, isDirty, 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,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

@@ -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

@@ -1,42 +1,244 @@
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 { useCanvasStore } from '../hooks/useCanvasStore'; import { useCanvasStore } from '../hooks/useCanvasStore';
import { useSpecStore, useSpec, useSpecLoading, useSpecIsDirty, useSelectedNodeId } from '../hooks/useSpecStore';
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 [searchParams] = useSearchParams();
const { nodes, edges, clear } = useCanvasStore(); // Spec mode is the default (AtomizerSpec v2.0)
const { selectedStudy } = useStudy(); // 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 +257,108 @@ 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 */}
{useSpecMode && spec ? (
<span className="text-xs text-dark-500 tabular-nums ml-2">
{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"> <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' : ''} {nodes.length} node{nodes.length !== 1 ? 's' : ''} {edges.length} edge{edges.length !== 1 ? 's' : ''}
</span> </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 - only show when there's a study and changes */}
{activeStudyId && (
<button
onClick={saveToConfig}
disabled={isSaving || (useSpecMode ? !specIsDirty : !hasUnsavedChanges)}
className={`px-3 py-1.5 text-sm rounded-lg transition-colors flex items-center gap-1.5 ${
(useSpecMode ? specIsDirty : hasUnsavedChanges)
? 'bg-green-600 hover:bg-green-500 text-white'
: 'bg-dark-700 text-dark-400 cursor-not-allowed border border-dark-600'
}`}
title={(useSpecMode ? specIsDirty : hasUnsavedChanges) ? `Save changes to ${useSpecMode ? 'atomizer_spec.json' : 'optimization_config.json'}` : 'No changes to save'}
>
<Save size={14} />
{isSaving ? 'Saving...' : 'Save'}
</button>
)}
{/* Reload Button */}
{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 +380,151 @@ 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={`${paletteCollapsed ? 'w-14' : 'w-60'} bg-dark-850 border-r border-dark-700 flex flex-col transition-all duration-200`}>
{/* 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>
</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 */}
{selectedNodeId && !showChat && (
useSpecMode ? (
<NodeConfigPanelV2 onClose={() => useSpecStore.getState().clearSelection()} />
) : (
<div className="w-80 border-l border-dark-700 bg-dark-850 overflow-y-auto">
<NodeConfigPanel nodeId={selectedNodeId} />
</div>
)
)}
{/* Chat/Assistant Panel */}
{showChat && (
<div className="w-96 border-l border-dark-700 bg-dark-850 flex flex-col">
{/* 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>
)}
</main> </main>
{/* Template Selector Modal */} {/* Template Selector Modal */}

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,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',
},
},
});