Compare commits
3 Commits
ea437d360e
...
b05412f807
| Author | SHA1 | Date | |
|---|---|---|---|
| b05412f807 | |||
| ffd41e3a60 | |||
| c4a3cff91a |
894
atomizer-dashboard/backend/api/routes/claude_code.py
Normal file
894
atomizer-dashboard/backend/api/routes/claude_code.py
Normal 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)
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
|||||||
451
atomizer-dashboard/backend/api/services/claude_code_session.py
Normal file
451
atomizer-dashboard/backend/api/services/claude_code_session.py
Normal 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
|
||||||
2085
atomizer-dashboard/frontend/package-lock.json
generated
2085
atomizer-dashboard/frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
69
atomizer-dashboard/frontend/playwright.config.ts
Normal file
69
atomizer-dashboard/frontend/playwright.config.ts
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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);
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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">< Less than</option>
|
||||||
|
<option value="less_equal"><= Less or equal</option>
|
||||||
|
<option value="greater_than">> Greater than</option>
|
||||||
|
<option value="greater_equal">>= 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;
|
||||||
@@ -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';
|
||||||
|
|||||||
209
atomizer-dashboard/frontend/src/hooks/useSpecStore.test.ts
Normal file
209
atomizer-dashboard/frontend/src/hooks/useSpecStore.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
742
atomizer-dashboard/frontend/src/hooks/useSpecStore.ts
Normal file
742
atomizer-dashboard/frontend/src/hooks/useSpecStore.ts
Normal 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);
|
||||||
|
});
|
||||||
129
atomizer-dashboard/frontend/src/hooks/useSpecUndoRedo.ts
Normal file
129
atomizer-dashboard/frontend/src/hooks/useSpecUndoRedo.ts
Normal 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;
|
||||||
260
atomizer-dashboard/frontend/src/hooks/useUndoRedo.ts
Normal file
260
atomizer-dashboard/frontend/src/hooks/useUndoRedo.ts
Normal 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;
|
||||||
348
atomizer-dashboard/frontend/src/lib/api/claude.ts
Normal file
348
atomizer-dashboard/frontend/src/lib/api/claude.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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 */}
|
||||||
|
|||||||
137
atomizer-dashboard/frontend/src/test/setup.ts
Normal file
137
atomizer-dashboard/frontend/src/test/setup.ts
Normal 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();
|
||||||
|
});
|
||||||
142
atomizer-dashboard/frontend/src/test/utils.tsx
Normal file
142
atomizer-dashboard/frontend/src/test/utils.tsx
Normal 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));
|
||||||
|
}
|
||||||
31
atomizer-dashboard/frontend/vitest.config.ts
Normal file
31
atomizer-dashboard/frontend/vitest.config.ts
Normal 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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user