feat(canvas): Claude Code integration with streaming, snippets, and live preview

Backend:
- Add POST /generate-extractor for AI code generation via Claude CLI
- Add POST /generate-extractor/stream for SSE streaming generation
- Add POST /validate-extractor with enhanced syntax checking
- Add POST /check-dependencies for import analysis
- Add POST /test-extractor for live OP2 file testing
- Add ClaudeCodeSession service for managing CLI sessions

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

Uses Claude CLI (--print mode) to leverage Pro/Max subscription without API costs.
This commit is contained in:
2026-01-20 13:08:12 -05:00
parent ffd41e3a60
commit b05412f807
5 changed files with 2311 additions and 49 deletions

View File

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

View File

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

View File

@@ -5,11 +5,12 @@
* - Python syntax highlighting
* - Auto-completion for common patterns
* - Error display
* - Claude AI code generation button
* - Claude AI code generation with streaming support
* - Preview of extracted outputs
* - Code snippets library
*/
import { useState, useCallback, useRef } from 'react';
import { useState, useCallback, useRef, useEffect } from 'react';
import Editor, { OnMount, OnChange } from '@monaco-editor/react';
import {
Play,
@@ -23,19 +24,42 @@ import {
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 */
/** 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 */
@@ -48,8 +72,12 @@ interface CodeEditorPanelProps {
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
@@ -103,30 +131,231 @@ def extract(op2_path: str, fem_path: str, params: dict, subcase_id: int = 1) ->
}
`;
// 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) => {
@@ -222,25 +451,65 @@ export function CodeEditorPanel({
setTimeout(() => setCopied(false), 2000);
}, [code]);
// Request Claude generation
// Request Claude generation (with streaming support)
const handleGenerate = useCallback(async () => {
if (!onRequestGeneration || !generationPrompt.trim()) return;
if ((!onRequestGeneration && !onRequestStreamingGeneration) || !generationPrompt.trim()) return;
setIsGenerating(true);
setError(null);
setStreamingCode('');
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);
// 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, generationPrompt, onChange]);
}, [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 () => {
@@ -262,6 +531,27 @@ export function CodeEditorPanel({
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">
@@ -274,11 +564,20 @@ export function CodeEditorPanel({
<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 && (
{(onRequestGeneration || onRequestStreamingGeneration) && (
<button
onClick={() => setShowPromptInput(!showPromptInput)}
className="p-1.5 rounded text-violet-400 hover:bg-violet-500/20 transition-colors"
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} />
@@ -298,9 +597,9 @@ export function CodeEditorPanel({
{onRun && (
<button
onClick={handleRun}
disabled={isRunning}
disabled={isRunning || isTesting}
className="p-1.5 rounded text-emerald-400 hover:bg-emerald-500/20 transition-colors disabled:opacity-50"
title="Validate code"
title="Validate code syntax"
>
{isRunning ? (
<RefreshCw size={16} className="animate-spin" />
@@ -309,6 +608,22 @@ export function CodeEditorPanel({
)}
</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 && (
@@ -340,28 +655,82 @@ export function CodeEditorPanel({
<div className="flex justify-end gap-2 mt-2">
<button
onClick={() => setShowPromptInput(false)}
className="px-3 py-1.5 text-xs text-dark-400 hover:text-white transition-colors"
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={handleGenerate}
disabled={isGenerating || !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"
onClick={() => setShowSnippets(false)}
className="p-1 rounded hover:bg-dark-700 text-dark-400 hover:text-white"
>
{isGenerating ? (
<>
<RefreshCw size={12} className="animate-spin" />
Generating...
</>
) : (
<>
<Sparkles size={12} />
Generate
</>
)}
<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>
)}
@@ -401,6 +770,32 @@ export function CodeEditorPanel({
/>
</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">
@@ -409,7 +804,7 @@ export function CodeEditorPanel({
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>Outputs</span>
<span>Expected Outputs</span>
<span className="ml-auto text-dark-500">
{runResult?.outputs
? Object.keys(runResult.outputs).length

View File

@@ -3,10 +3,14 @@
*
* 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 } from 'lucide-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,
@@ -507,7 +511,51 @@ interface ExtractorNodeConfigProps {
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' },
@@ -519,6 +567,78 @@ function ExtractorNodeConfig({ node, onChange }: ExtractorNodeConfigProps) {
{ 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>
@@ -544,23 +664,73 @@ function ExtractorNodeConfig({ node, onChange }: ExtractorNodeConfigProps) {
{opt.id} - {opt.name}
</option>
))}
<option value="custom">Custom Function</option>
<option value="custom_function">Custom Function</option>
</select>
</div>
{node.type === 'custom_function' && node.function && (
<div>
<label className={labelClass}>Custom Function</label>
<input
type="text"
value={node.function.name || ''}
readOnly
className={`${inputClass} bg-dark-900 cursor-not-allowed`}
/>
<p className="text-xs text-dark-500 mt-1">Edit custom code in dedicated editor.</p>
{/* 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
@@ -570,7 +740,11 @@ function ExtractorNodeConfig({ node, onChange }: ExtractorNodeConfigProps) {
placeholder="value, unit"
className={`${inputClass} bg-dark-900 cursor-not-allowed`}
/>
<p className="text-xs text-dark-500 mt-1">Outputs are defined by extractor type.</p>
<p className="text-xs text-dark-500 mt-1">
{isCustomType
? 'Detected from return statement in code.'
: 'Outputs are defined by extractor type.'}
</p>
</div>
</>
);

View File

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