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:
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)
|
||||||
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
|
||||||
@@ -5,11 +5,12 @@
|
|||||||
* - Python syntax highlighting
|
* - Python syntax highlighting
|
||||||
* - Auto-completion for common patterns
|
* - Auto-completion for common patterns
|
||||||
* - Error display
|
* - Error display
|
||||||
* - Claude AI code generation button
|
* - Claude AI code generation with streaming support
|
||||||
* - Preview of extracted outputs
|
* - 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 Editor, { OnMount, OnChange } from '@monaco-editor/react';
|
||||||
import {
|
import {
|
||||||
Play,
|
Play,
|
||||||
@@ -23,19 +24,42 @@ import {
|
|||||||
ChevronRight,
|
ChevronRight,
|
||||||
FileCode,
|
FileCode,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
|
Square,
|
||||||
|
BookOpen,
|
||||||
|
FlaskConical,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
// Monaco editor types
|
// Monaco editor types
|
||||||
type Monaco = Parameters<OnMount>[1];
|
type Monaco = Parameters<OnMount>[1];
|
||||||
type EditorInstance = Parameters<OnMount>[0];
|
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 {
|
interface CodeEditorPanelProps {
|
||||||
/** Initial code content */
|
/** Initial code content */
|
||||||
initialCode?: string;
|
initialCode?: string;
|
||||||
/** Callback when code changes */
|
/** Callback when code changes */
|
||||||
onChange?: (code: string) => void;
|
onChange?: (code: string) => void;
|
||||||
/** Callback when user requests Claude generation */
|
/** Callback when user requests Claude generation (non-streaming) */
|
||||||
onRequestGeneration?: (prompt: string) => Promise<string>;
|
onRequestGeneration?: (prompt: string) => Promise<string>;
|
||||||
|
/** Callback for streaming generation (preferred over onRequestGeneration) */
|
||||||
|
onRequestStreamingGeneration?: (
|
||||||
|
request: StreamingGenerationRequest,
|
||||||
|
callbacks: StreamingCallbacks
|
||||||
|
) => AbortController;
|
||||||
/** Whether the panel is read-only */
|
/** Whether the panel is read-only */
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
/** Extractor name for context */
|
/** Extractor name for context */
|
||||||
@@ -48,8 +72,12 @@ interface CodeEditorPanelProps {
|
|||||||
showHeader?: boolean;
|
showHeader?: boolean;
|
||||||
/** Callback when running code (validation) */
|
/** Callback when running code (validation) */
|
||||||
onRun?: (code: string) => Promise<{ success: boolean; error?: string; outputs?: Record<string, unknown> }>;
|
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 */
|
/** Close button callback */
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
|
/** Study ID for context in generation */
|
||||||
|
studyId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default Python template for custom extractors
|
// 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({
|
export function CodeEditorPanel({
|
||||||
initialCode = DEFAULT_EXTRACTOR_TEMPLATE,
|
initialCode = DEFAULT_EXTRACTOR_TEMPLATE,
|
||||||
onChange,
|
onChange,
|
||||||
onRequestGeneration,
|
onRequestGeneration,
|
||||||
|
onRequestStreamingGeneration,
|
||||||
readOnly = false,
|
readOnly = false,
|
||||||
extractorName = 'custom_extractor',
|
extractorName = 'custom_extractor',
|
||||||
outputs = [],
|
outputs = [],
|
||||||
height = 400,
|
height = 400,
|
||||||
showHeader = true,
|
showHeader = true,
|
||||||
onRun,
|
onRun,
|
||||||
|
onTest,
|
||||||
onClose,
|
onClose,
|
||||||
|
studyId,
|
||||||
}: CodeEditorPanelProps) {
|
}: CodeEditorPanelProps) {
|
||||||
const [code, setCode] = useState(initialCode);
|
const [code, setCode] = useState(initialCode);
|
||||||
|
const [streamingCode, setStreamingCode] = useState(''); // Partial code during streaming
|
||||||
const [isGenerating, setIsGenerating] = useState(false);
|
const [isGenerating, setIsGenerating] = useState(false);
|
||||||
const [isRunning, setIsRunning] = useState(false);
|
const [isRunning, setIsRunning] = useState(false);
|
||||||
|
const [isTesting, setIsTesting] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [runResult, setRunResult] = useState<{ success: boolean; outputs?: Record<string, unknown> } | 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 [copied, setCopied] = useState(false);
|
||||||
const [showPromptInput, setShowPromptInput] = useState(false);
|
const [showPromptInput, setShowPromptInput] = useState(false);
|
||||||
const [generationPrompt, setGenerationPrompt] = useState('');
|
const [generationPrompt, setGenerationPrompt] = useState('');
|
||||||
const [showOutputs, setShowOutputs] = useState(true);
|
const [showOutputs, setShowOutputs] = useState(true);
|
||||||
|
const [showSnippets, setShowSnippets] = useState(false);
|
||||||
|
|
||||||
const editorRef = useRef<EditorInstance | null>(null);
|
const editorRef = useRef<EditorInstance | null>(null);
|
||||||
const monacoRef = useRef<Monaco | 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
|
// Handle editor mount
|
||||||
const handleEditorMount: OnMount = (editor, monaco) => {
|
const handleEditorMount: OnMount = (editor, monaco) => {
|
||||||
@@ -222,25 +451,65 @@ export function CodeEditorPanel({
|
|||||||
setTimeout(() => setCopied(false), 2000);
|
setTimeout(() => setCopied(false), 2000);
|
||||||
}, [code]);
|
}, [code]);
|
||||||
|
|
||||||
// Request Claude generation
|
// Request Claude generation (with streaming support)
|
||||||
const handleGenerate = useCallback(async () => {
|
const handleGenerate = useCallback(async () => {
|
||||||
if (!onRequestGeneration || !generationPrompt.trim()) return;
|
if ((!onRequestGeneration && !onRequestStreamingGeneration) || !generationPrompt.trim()) return;
|
||||||
|
|
||||||
setIsGenerating(true);
|
setIsGenerating(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setStreamingCode('');
|
||||||
|
|
||||||
try {
|
// Prefer streaming if available
|
||||||
const generatedCode = await onRequestGeneration(generationPrompt);
|
if (onRequestStreamingGeneration) {
|
||||||
setCode(generatedCode);
|
abortControllerRef.current = onRequestStreamingGeneration(
|
||||||
onChange?.(generatedCode);
|
{
|
||||||
setShowPromptInput(false);
|
prompt: generationPrompt,
|
||||||
setGenerationPrompt('');
|
study_id: studyId,
|
||||||
} catch (err) {
|
existing_code: code !== DEFAULT_EXTRACTOR_TEMPLATE ? code : undefined,
|
||||||
setError(err instanceof Error ? err.message : 'Generation failed');
|
output_names: outputs,
|
||||||
} finally {
|
},
|
||||||
setIsGenerating(false);
|
{
|
||||||
|
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
|
// Run/validate code
|
||||||
const handleRun = useCallback(async () => {
|
const handleRun = useCallback(async () => {
|
||||||
@@ -263,6 +532,27 @@ export function CodeEditorPanel({
|
|||||||
}
|
}
|
||||||
}, [code, onRun]);
|
}, [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 (
|
return (
|
||||||
<div className="flex flex-col h-full bg-dark-850 border-l border-dark-700">
|
<div className="flex flex-col h-full bg-dark-850 border-l border-dark-700">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -274,11 +564,20 @@ export function CodeEditorPanel({
|
|||||||
<span className="text-xs text-dark-500">.py</span>
|
<span className="text-xs text-dark-500">.py</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<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 */}
|
{/* Claude Generate Button */}
|
||||||
{onRequestGeneration && (
|
{(onRequestGeneration || onRequestStreamingGeneration) && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowPromptInput(!showPromptInput)}
|
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"
|
title="Generate with Claude"
|
||||||
>
|
>
|
||||||
<Sparkles size={16} />
|
<Sparkles size={16} />
|
||||||
@@ -298,9 +597,9 @@ export function CodeEditorPanel({
|
|||||||
{onRun && (
|
{onRun && (
|
||||||
<button
|
<button
|
||||||
onClick={handleRun}
|
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"
|
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 ? (
|
{isRunning ? (
|
||||||
<RefreshCw size={16} className="animate-spin" />
|
<RefreshCw size={16} className="animate-spin" />
|
||||||
@@ -310,6 +609,22 @@ export function CodeEditorPanel({
|
|||||||
</button>
|
</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 */}
|
{/* Close Button */}
|
||||||
{onClose && (
|
{onClose && (
|
||||||
<button
|
<button
|
||||||
@@ -340,28 +655,82 @@ export function CodeEditorPanel({
|
|||||||
<div className="flex justify-end gap-2 mt-2">
|
<div className="flex justify-end gap-2 mt-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowPromptInput(false)}
|
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
|
Cancel
|
||||||
</button>
|
</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
|
<button
|
||||||
onClick={handleGenerate}
|
onClick={() => setShowSnippets(false)}
|
||||||
disabled={isGenerating || !generationPrompt.trim()}
|
className="p-1 rounded hover:bg-dark-700 text-dark-400 hover:text-white"
|
||||||
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"
|
|
||||||
>
|
>
|
||||||
{isGenerating ? (
|
<X size={14} />
|
||||||
<>
|
|
||||||
<RefreshCw size={12} className="animate-spin" />
|
|
||||||
Generating...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Sparkles size={12} />
|
|
||||||
Generate
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -401,6 +770,32 @@ export function CodeEditorPanel({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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 Preview */}
|
||||||
{(outputs.length > 0 || runResult?.outputs) && (
|
{(outputs.length > 0 || runResult?.outputs) && (
|
||||||
<div className="border-t border-dark-700">
|
<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"
|
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} />}
|
{showOutputs ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
||||||
<span>Outputs</span>
|
<span>Expected Outputs</span>
|
||||||
<span className="ml-auto text-dark-500">
|
<span className="ml-auto text-dark-500">
|
||||||
{runResult?.outputs
|
{runResult?.outputs
|
||||||
? Object.keys(runResult.outputs).length
|
? Object.keys(runResult.outputs).length
|
||||||
|
|||||||
@@ -3,10 +3,14 @@
|
|||||||
*
|
*
|
||||||
* This component uses useSpecStore instead of the legacy useCanvasStore.
|
* This component uses useSpecStore instead of the legacy useCanvasStore.
|
||||||
* It renders type-specific configuration forms based on the selected node.
|
* 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 { 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 {
|
import {
|
||||||
useSpecStore,
|
useSpecStore,
|
||||||
useSpec,
|
useSpec,
|
||||||
@@ -507,7 +511,51 @@ interface ExtractorNodeConfigProps {
|
|||||||
onChange: (field: string, value: unknown) => void;
|
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) {
|
function ExtractorNodeConfig({ node, onChange }: ExtractorNodeConfigProps) {
|
||||||
|
const [showCodeEditor, setShowCodeEditor] = useState(false);
|
||||||
|
const studyId = useSpecStore(state => state.studyId);
|
||||||
|
|
||||||
const extractorOptions = [
|
const extractorOptions = [
|
||||||
{ id: 'E1', name: 'Displacement', type: 'displacement' },
|
{ id: 'E1', name: 'Displacement', type: 'displacement' },
|
||||||
{ id: 'E2', name: 'Frequency', type: 'frequency' },
|
{ id: 'E2', name: 'Frequency', type: 'frequency' },
|
||||||
@@ -519,6 +567,78 @@ function ExtractorNodeConfig({ node, onChange }: ExtractorNodeConfigProps) {
|
|||||||
{ id: 'E10', name: 'Zernike (RMS)', type: 'zernike_rms' },
|
{ 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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div>
|
<div>
|
||||||
@@ -544,23 +664,73 @@ function ExtractorNodeConfig({ node, onChange }: ExtractorNodeConfigProps) {
|
|||||||
{opt.id} - {opt.name}
|
{opt.id} - {opt.name}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
<option value="custom">Custom Function</option>
|
<option value="custom_function">Custom Function</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{node.type === 'custom_function' && node.function && (
|
{/* Custom Code Editor Button */}
|
||||||
<div>
|
{isCustomType && (
|
||||||
<label className={labelClass}>Custom Function</label>
|
<>
|
||||||
<input
|
<button
|
||||||
type="text"
|
onClick={() => setShowCodeEditor(true)}
|
||||||
value={node.function.name || ''}
|
className="w-full flex items-center justify-center gap-2 px-3 py-2.5
|
||||||
readOnly
|
bg-violet-500/20 hover:bg-violet-500/30 border border-violet-500/30
|
||||||
className={`${inputClass} bg-dark-900 cursor-not-allowed`}
|
rounded-lg text-violet-400 text-sm font-medium transition-colors"
|
||||||
/>
|
>
|
||||||
<p className="text-xs text-dark-500 mt-1">Edit custom code in dedicated editor.</p>
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Outputs */}
|
||||||
<div>
|
<div>
|
||||||
<label className={labelClass}>Outputs</label>
|
<label className={labelClass}>Outputs</label>
|
||||||
<input
|
<input
|
||||||
@@ -570,7 +740,11 @@ function ExtractorNodeConfig({ node, onChange }: ExtractorNodeConfigProps) {
|
|||||||
placeholder="value, unit"
|
placeholder="value, unit"
|
||||||
className={`${inputClass} bg-dark-900 cursor-not-allowed`}
|
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>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user