feat: Add Studio UI, intake system, and extractor improvements
Dashboard: - Add Studio page with drag-drop model upload and Claude chat - Add intake system for study creation workflow - Improve session manager and context builder - Add intake API routes and frontend components Optimization Engine: - Add CLI module for command-line operations - Add intake module for study preprocessing - Add validation module with gate checks - Improve Zernike extractor documentation - Update spec models with better validation - Enhance solve_simulation robustness Documentation: - Add ATOMIZER_STUDIO.md planning doc - Add ATOMIZER_UX_SYSTEM.md for UX patterns - Update extractor library docs - Add study-readme-generator skill Tools: - Add test scripts for extraction validation - Add Zernike recentering test Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -13,7 +13,19 @@ import sys
|
||||
# Add parent directory to path to import optimization_engine
|
||||
sys.path.append(str(Path(__file__).parent.parent.parent.parent))
|
||||
|
||||
from api.routes import optimization, claude, terminal, insights, context, files, nx, claude_code, spec
|
||||
from api.routes import (
|
||||
optimization,
|
||||
claude,
|
||||
terminal,
|
||||
insights,
|
||||
context,
|
||||
files,
|
||||
nx,
|
||||
claude_code,
|
||||
spec,
|
||||
devloop,
|
||||
intake,
|
||||
)
|
||||
from api.websocket import optimization_stream
|
||||
|
||||
|
||||
@@ -23,6 +35,7 @@ async def lifespan(app: FastAPI):
|
||||
"""Manage application lifespan - start/stop session manager"""
|
||||
# Startup
|
||||
from api.routes.claude import get_session_manager
|
||||
|
||||
manager = get_session_manager()
|
||||
await manager.start()
|
||||
print("Session manager started")
|
||||
@@ -63,6 +76,9 @@ app.include_router(nx.router, prefix="/api/nx", tags=["nx"])
|
||||
app.include_router(claude_code.router, prefix="/api", tags=["claude-code"])
|
||||
app.include_router(spec.router, prefix="/api", tags=["spec"])
|
||||
app.include_router(spec.validate_router, prefix="/api", tags=["spec"])
|
||||
app.include_router(devloop.router, prefix="/api", tags=["devloop"])
|
||||
app.include_router(intake.router, prefix="/api", tags=["intake"])
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
@@ -70,11 +86,13 @@ async def root():
|
||||
dashboard_path = Path(__file__).parent.parent.parent / "dashboard-enhanced.html"
|
||||
return FileResponse(dashboard_path)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
"""Health check endpoint with database status"""
|
||||
try:
|
||||
from api.services.conversation_store import ConversationStore
|
||||
|
||||
store = ConversationStore()
|
||||
# Test database by creating/getting a health check session
|
||||
store.get_session("health_check")
|
||||
@@ -87,12 +105,8 @@ async def health_check():
|
||||
"database": db_status,
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(
|
||||
"main:app",
|
||||
host="0.0.0.0",
|
||||
port=8000,
|
||||
reload=True,
|
||||
log_level="info"
|
||||
)
|
||||
|
||||
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True, log_level="info")
|
||||
|
||||
1721
atomizer-dashboard/backend/api/routes/intake.py
Normal file
1721
atomizer-dashboard/backend/api/routes/intake.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -245,17 +245,45 @@ def _get_study_error_info(study_dir: Path, results_dir: Path) -> dict:
|
||||
|
||||
def _load_study_info(study_dir: Path, topic: Optional[str] = None) -> Optional[dict]:
|
||||
"""Load study info from a study directory. Returns None if not a valid study."""
|
||||
# Look for optimization config (check multiple locations)
|
||||
config_file = study_dir / "optimization_config.json"
|
||||
if not config_file.exists():
|
||||
config_file = study_dir / "1_setup" / "optimization_config.json"
|
||||
if not config_file.exists():
|
||||
# Look for config file - prefer atomizer_spec.json (v2.0), fall back to legacy optimization_config.json
|
||||
config_file = None
|
||||
is_atomizer_spec = False
|
||||
|
||||
# Check for AtomizerSpec v2.0 first
|
||||
for spec_path in [
|
||||
study_dir / "atomizer_spec.json",
|
||||
study_dir / "1_setup" / "atomizer_spec.json",
|
||||
]:
|
||||
if spec_path.exists():
|
||||
config_file = spec_path
|
||||
is_atomizer_spec = True
|
||||
break
|
||||
|
||||
# Fall back to legacy optimization_config.json
|
||||
if config_file is None:
|
||||
for legacy_path in [
|
||||
study_dir / "optimization_config.json",
|
||||
study_dir / "1_setup" / "optimization_config.json",
|
||||
]:
|
||||
if legacy_path.exists():
|
||||
config_file = legacy_path
|
||||
break
|
||||
|
||||
if config_file is None:
|
||||
return None
|
||||
|
||||
# Load config
|
||||
with open(config_file) as f:
|
||||
config = json.load(f)
|
||||
|
||||
# Normalize AtomizerSpec v2.0 to legacy format for compatibility
|
||||
if is_atomizer_spec and "meta" in config:
|
||||
# Extract study_name and description from meta
|
||||
meta = config.get("meta", {})
|
||||
config["study_name"] = meta.get("study_name", study_dir.name)
|
||||
config["description"] = meta.get("description", "")
|
||||
config["version"] = meta.get("version", "2.0")
|
||||
|
||||
# Check if results directory exists (support both 2_results and 3_results)
|
||||
results_dir = study_dir / "2_results"
|
||||
if not results_dir.exists():
|
||||
@@ -311,12 +339,21 @@ def _load_study_info(study_dir: Path, topic: Optional[str] = None) -> Optional[d
|
||||
best_trial = min(history, key=lambda x: x["objective"])
|
||||
best_value = best_trial["objective"]
|
||||
|
||||
# Get total trials from config (supports both formats)
|
||||
total_trials = (
|
||||
config.get("optimization_settings", {}).get("n_trials")
|
||||
or config.get("optimization", {}).get("n_trials")
|
||||
or config.get("trials", {}).get("n_trials", 50)
|
||||
)
|
||||
# Get total trials from config (supports AtomizerSpec v2.0 and legacy formats)
|
||||
total_trials = None
|
||||
|
||||
# AtomizerSpec v2.0: optimization.budget.max_trials
|
||||
if is_atomizer_spec:
|
||||
total_trials = config.get("optimization", {}).get("budget", {}).get("max_trials")
|
||||
|
||||
# Legacy formats
|
||||
if total_trials is None:
|
||||
total_trials = (
|
||||
config.get("optimization_settings", {}).get("n_trials")
|
||||
or config.get("optimization", {}).get("n_trials")
|
||||
or config.get("optimization", {}).get("max_trials")
|
||||
or config.get("trials", {}).get("n_trials", 100)
|
||||
)
|
||||
|
||||
# Get accurate status using process detection
|
||||
status = get_accurate_study_status(study_dir.name, trial_count, total_trials, has_db)
|
||||
@@ -380,7 +417,12 @@ async def list_studies():
|
||||
continue
|
||||
|
||||
# Check if this is a study (flat structure) or a topic folder (nested structure)
|
||||
is_study = (item / "1_setup").exists() or (item / "optimization_config.json").exists()
|
||||
# Support both AtomizerSpec v2.0 (atomizer_spec.json) and legacy (optimization_config.json)
|
||||
is_study = (
|
||||
(item / "1_setup").exists()
|
||||
or (item / "atomizer_spec.json").exists()
|
||||
or (item / "optimization_config.json").exists()
|
||||
)
|
||||
|
||||
if is_study:
|
||||
# Flat structure: study directly in studies/
|
||||
@@ -396,10 +438,12 @@ async def list_studies():
|
||||
if sub_item.name.startswith("."):
|
||||
continue
|
||||
|
||||
# Check if this subdirectory is a study
|
||||
sub_is_study = (sub_item / "1_setup").exists() or (
|
||||
sub_item / "optimization_config.json"
|
||||
).exists()
|
||||
# Check if this subdirectory is a study (AtomizerSpec v2.0 or legacy)
|
||||
sub_is_study = (
|
||||
(sub_item / "1_setup").exists()
|
||||
or (sub_item / "atomizer_spec.json").exists()
|
||||
or (sub_item / "optimization_config.json").exists()
|
||||
)
|
||||
if sub_is_study:
|
||||
study_info = _load_study_info(sub_item, topic=item.name)
|
||||
if study_info:
|
||||
|
||||
396
atomizer-dashboard/backend/api/services/claude_readme.py
Normal file
396
atomizer-dashboard/backend/api/services/claude_readme.py
Normal file
@@ -0,0 +1,396 @@
|
||||
"""
|
||||
Claude README Generator Service
|
||||
|
||||
Generates intelligent README.md files for optimization studies
|
||||
using Claude Code CLI (not API) with study context from AtomizerSpec.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import subprocess
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
# Base directory
|
||||
ATOMIZER_ROOT = Path(__file__).parent.parent.parent.parent.parent
|
||||
|
||||
# Load skill prompt
|
||||
SKILL_PATH = ATOMIZER_ROOT / ".claude" / "skills" / "modules" / "study-readme-generator.md"
|
||||
|
||||
|
||||
def load_skill_prompt() -> str:
|
||||
"""Load the README generator skill prompt."""
|
||||
if SKILL_PATH.exists():
|
||||
return SKILL_PATH.read_text(encoding="utf-8")
|
||||
return ""
|
||||
|
||||
|
||||
class ClaudeReadmeGenerator:
|
||||
"""Generate README.md files using Claude Code CLI."""
|
||||
|
||||
def __init__(self):
|
||||
self.skill_prompt = load_skill_prompt()
|
||||
|
||||
def generate_readme(
|
||||
self,
|
||||
study_name: str,
|
||||
spec: Dict[str, Any],
|
||||
context_files: Optional[Dict[str, str]] = None,
|
||||
topic: Optional[str] = None,
|
||||
) -> str:
|
||||
"""
|
||||
Generate a README.md for a study using Claude Code CLI.
|
||||
|
||||
Args:
|
||||
study_name: Name of the study
|
||||
spec: Full AtomizerSpec v2.0 dict
|
||||
context_files: Optional dict of {filename: content} for context
|
||||
topic: Optional topic folder name
|
||||
|
||||
Returns:
|
||||
Generated README.md content
|
||||
"""
|
||||
# Build context for Claude
|
||||
context = self._build_context(study_name, spec, context_files, topic)
|
||||
|
||||
# Build the prompt
|
||||
prompt = self._build_prompt(context)
|
||||
|
||||
try:
|
||||
# Run Claude Code CLI synchronously
|
||||
result = self._run_claude_cli(prompt)
|
||||
|
||||
# Extract markdown content from response
|
||||
readme_content = self._extract_markdown(result)
|
||||
|
||||
if readme_content:
|
||||
return readme_content
|
||||
|
||||
# If no markdown found, return the whole response
|
||||
return result if result else self._generate_fallback_readme(study_name, spec)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Claude CLI error: {e}")
|
||||
return self._generate_fallback_readme(study_name, spec)
|
||||
|
||||
async def generate_readme_async(
|
||||
self,
|
||||
study_name: str,
|
||||
spec: Dict[str, Any],
|
||||
context_files: Optional[Dict[str, str]] = None,
|
||||
topic: Optional[str] = None,
|
||||
) -> str:
|
||||
"""Async version of generate_readme."""
|
||||
# Run in thread pool to not block
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(
|
||||
None, lambda: self.generate_readme(study_name, spec, context_files, topic)
|
||||
)
|
||||
|
||||
def _run_claude_cli(self, prompt: str) -> str:
|
||||
"""Run Claude Code CLI and get response."""
|
||||
try:
|
||||
# Use claude CLI with --print flag for non-interactive output
|
||||
result = subprocess.run(
|
||||
["claude", "--print", prompt],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120, # 2 minute timeout
|
||||
cwd=str(ATOMIZER_ROOT),
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
error_msg = result.stderr or "Unknown error"
|
||||
raise Exception(f"Claude CLI error: {error_msg}")
|
||||
|
||||
return result.stdout.strip()
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
raise Exception("Request timed out")
|
||||
except FileNotFoundError:
|
||||
raise Exception("Claude CLI not found. Make sure 'claude' is in PATH.")
|
||||
|
||||
def _build_context(
|
||||
self,
|
||||
study_name: str,
|
||||
spec: Dict[str, Any],
|
||||
context_files: Optional[Dict[str, str]],
|
||||
topic: Optional[str],
|
||||
) -> Dict[str, Any]:
|
||||
"""Build the context object for Claude."""
|
||||
meta = spec.get("meta", {})
|
||||
model = spec.get("model", {})
|
||||
introspection = model.get("introspection", {}) or {}
|
||||
|
||||
context = {
|
||||
"study_name": study_name,
|
||||
"topic": topic or meta.get("topic", "Other"),
|
||||
"description": meta.get("description", ""),
|
||||
"created": meta.get("created", datetime.now().isoformat()),
|
||||
"status": meta.get("status", "draft"),
|
||||
"design_variables": spec.get("design_variables", []),
|
||||
"extractors": spec.get("extractors", []),
|
||||
"objectives": spec.get("objectives", []),
|
||||
"constraints": spec.get("constraints", []),
|
||||
"optimization": spec.get("optimization", {}),
|
||||
"introspection": {
|
||||
"mass_kg": introspection.get("mass_kg"),
|
||||
"volume_mm3": introspection.get("volume_mm3"),
|
||||
"solver_type": introspection.get("solver_type"),
|
||||
"expressions": introspection.get("expressions", []),
|
||||
"expressions_count": len(introspection.get("expressions", [])),
|
||||
},
|
||||
"model_files": {
|
||||
"sim": model.get("sim", {}).get("path") if model.get("sim") else None,
|
||||
"prt": model.get("prt", {}).get("path") if model.get("prt") else None,
|
||||
"fem": model.get("fem", {}).get("path") if model.get("fem") else None,
|
||||
},
|
||||
}
|
||||
|
||||
# Add context files if provided
|
||||
if context_files:
|
||||
context["context_files"] = context_files
|
||||
|
||||
return context
|
||||
|
||||
def _build_prompt(self, context: Dict[str, Any]) -> str:
|
||||
"""Build the prompt for Claude CLI."""
|
||||
|
||||
# Build context files section if available
|
||||
context_files_section = ""
|
||||
if context.get("context_files"):
|
||||
context_files_section = "\n\n## User-Provided Context Files\n\nIMPORTANT: Use this information to understand the optimization goals, design variables, objectives, and constraints:\n\n"
|
||||
for filename, content in context.get("context_files", {}).items():
|
||||
context_files_section += f"### {filename}\n```\n{content}\n```\n\n"
|
||||
|
||||
# Remove context_files from JSON dump to avoid duplication
|
||||
context_for_json = {k: v for k, v in context.items() if k != "context_files"}
|
||||
|
||||
prompt = f"""Generate a README.md for this FEA optimization study.
|
||||
|
||||
## Study Technical Data
|
||||
|
||||
```json
|
||||
{json.dumps(context_for_json, indent=2, default=str)}
|
||||
```
|
||||
{context_files_section}
|
||||
## Requirements
|
||||
|
||||
1. Use the EXACT values from the technical data - no placeholders
|
||||
2. If context files are provided, extract:
|
||||
- Design variable bounds (min/max)
|
||||
- Optimization objectives (minimize/maximize what)
|
||||
- Constraints (stress limits, etc.)
|
||||
- Any specific requirements mentioned
|
||||
|
||||
3. Format the README with these sections:
|
||||
- Title (# Study Name)
|
||||
- Overview (topic, date, status, description from context)
|
||||
- Engineering Problem (what we're optimizing and why - from context files)
|
||||
- Model Information (mass, solver, files)
|
||||
- Design Variables (if context specifies bounds, include them in a table)
|
||||
- Optimization Objectives (from context files)
|
||||
- Constraints (from context files)
|
||||
- Expressions Found (table of discovered expressions, highlight candidates)
|
||||
- Next Steps (what needs to be configured)
|
||||
|
||||
4. Keep it professional and concise
|
||||
5. Use proper markdown table formatting
|
||||
6. Include units where applicable
|
||||
7. For expressions table, show: name, value, units, is_candidate
|
||||
|
||||
Generate ONLY the README.md content in markdown format, no explanations:"""
|
||||
|
||||
return prompt
|
||||
|
||||
def _extract_markdown(self, response: str) -> Optional[str]:
|
||||
"""Extract markdown content from Claude response."""
|
||||
if not response:
|
||||
return None
|
||||
|
||||
# If response starts with #, it's already markdown
|
||||
if response.strip().startswith("#"):
|
||||
return response.strip()
|
||||
|
||||
# Try to find markdown block
|
||||
if "```markdown" in response:
|
||||
start = response.find("```markdown") + len("```markdown")
|
||||
end = response.find("```", start)
|
||||
if end > start:
|
||||
return response[start:end].strip()
|
||||
|
||||
if "```md" in response:
|
||||
start = response.find("```md") + len("```md")
|
||||
end = response.find("```", start)
|
||||
if end > start:
|
||||
return response[start:end].strip()
|
||||
|
||||
# Look for first # heading
|
||||
lines = response.split("\n")
|
||||
for i, line in enumerate(lines):
|
||||
if line.strip().startswith("# "):
|
||||
return "\n".join(lines[i:]).strip()
|
||||
|
||||
return None
|
||||
|
||||
def _generate_fallback_readme(self, study_name: str, spec: Dict[str, Any]) -> str:
|
||||
"""Generate a basic README if Claude fails."""
|
||||
meta = spec.get("meta", {})
|
||||
model = spec.get("model", {})
|
||||
introspection = model.get("introspection", {}) or {}
|
||||
dvs = spec.get("design_variables", [])
|
||||
objs = spec.get("objectives", [])
|
||||
cons = spec.get("constraints", [])
|
||||
opt = spec.get("optimization", {})
|
||||
expressions = introspection.get("expressions", [])
|
||||
|
||||
lines = [
|
||||
f"# {study_name.replace('_', ' ').title()}",
|
||||
"",
|
||||
f"**Topic**: {meta.get('topic', 'Other')}",
|
||||
f"**Created**: {meta.get('created', 'Unknown')[:10] if meta.get('created') else 'Unknown'}",
|
||||
f"**Status**: {meta.get('status', 'draft')}",
|
||||
"",
|
||||
]
|
||||
|
||||
if meta.get("description"):
|
||||
lines.extend([meta["description"], ""])
|
||||
|
||||
# Model Information
|
||||
lines.extend(
|
||||
[
|
||||
"## Model Information",
|
||||
"",
|
||||
]
|
||||
)
|
||||
|
||||
if introspection.get("mass_kg"):
|
||||
lines.append(f"- **Mass**: {introspection['mass_kg']:.2f} kg")
|
||||
|
||||
sim_path = model.get("sim", {}).get("path") if model.get("sim") else None
|
||||
if sim_path:
|
||||
lines.append(f"- **Simulation**: {sim_path}")
|
||||
|
||||
lines.append("")
|
||||
|
||||
# Expressions Found
|
||||
if expressions:
|
||||
lines.extend(
|
||||
[
|
||||
"## Expressions Found",
|
||||
"",
|
||||
"| Name | Value | Units | Candidate |",
|
||||
"|------|-------|-------|-----------|",
|
||||
]
|
||||
)
|
||||
for expr in expressions:
|
||||
is_candidate = "✓" if expr.get("is_candidate") else ""
|
||||
value = f"{expr.get('value', '-')}"
|
||||
units = expr.get("units", "-")
|
||||
lines.append(f"| {expr.get('name', '-')} | {value} | {units} | {is_candidate} |")
|
||||
lines.append("")
|
||||
|
||||
# Design Variables (if configured)
|
||||
if dvs:
|
||||
lines.extend(
|
||||
[
|
||||
"## Design Variables",
|
||||
"",
|
||||
"| Variable | Expression | Range | Units |",
|
||||
"|----------|------------|-------|-------|",
|
||||
]
|
||||
)
|
||||
for dv in dvs:
|
||||
bounds = dv.get("bounds", {})
|
||||
units = dv.get("units", "-")
|
||||
lines.append(
|
||||
f"| {dv.get('name', 'Unknown')} | "
|
||||
f"{dv.get('expression_name', '-')} | "
|
||||
f"[{bounds.get('min', '-')}, {bounds.get('max', '-')}] | "
|
||||
f"{units} |"
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
# Objectives
|
||||
if objs:
|
||||
lines.extend(
|
||||
[
|
||||
"## Objectives",
|
||||
"",
|
||||
"| Objective | Direction | Weight |",
|
||||
"|-----------|-----------|--------|",
|
||||
]
|
||||
)
|
||||
for obj in objs:
|
||||
lines.append(
|
||||
f"| {obj.get('name', 'Unknown')} | "
|
||||
f"{obj.get('direction', 'minimize')} | "
|
||||
f"{obj.get('weight', 1.0)} |"
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
# Constraints
|
||||
if cons:
|
||||
lines.extend(
|
||||
[
|
||||
"## Constraints",
|
||||
"",
|
||||
"| Constraint | Condition | Threshold |",
|
||||
"|------------|-----------|-----------|",
|
||||
]
|
||||
)
|
||||
for con in cons:
|
||||
lines.append(
|
||||
f"| {con.get('name', 'Unknown')} | "
|
||||
f"{con.get('operator', '<=')} | "
|
||||
f"{con.get('threshold', '-')} |"
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
# Algorithm
|
||||
algo = opt.get("algorithm", {})
|
||||
budget = opt.get("budget", {})
|
||||
lines.extend(
|
||||
[
|
||||
"## Methodology",
|
||||
"",
|
||||
f"- **Algorithm**: {algo.get('type', 'TPE')}",
|
||||
f"- **Max Trials**: {budget.get('max_trials', 100)}",
|
||||
"",
|
||||
]
|
||||
)
|
||||
|
||||
# Next Steps
|
||||
lines.extend(
|
||||
[
|
||||
"## Next Steps",
|
||||
"",
|
||||
]
|
||||
)
|
||||
|
||||
if not dvs:
|
||||
lines.append("- [ ] Configure design variables from discovered expressions")
|
||||
if not objs:
|
||||
lines.append("- [ ] Define optimization objectives")
|
||||
if not dvs and not objs:
|
||||
lines.append("- [ ] Open in Canvas Builder to complete configuration")
|
||||
else:
|
||||
lines.append("- [ ] Run baseline solve to validate setup")
|
||||
lines.append("- [ ] Finalize study to move to studies folder")
|
||||
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# Singleton instance
|
||||
_generator: Optional[ClaudeReadmeGenerator] = None
|
||||
|
||||
|
||||
def get_readme_generator() -> ClaudeReadmeGenerator:
|
||||
"""Get the singleton README generator instance."""
|
||||
global _generator
|
||||
if _generator is None:
|
||||
_generator = ClaudeReadmeGenerator()
|
||||
return _generator
|
||||
@@ -26,6 +26,7 @@ class ContextBuilder:
|
||||
study_id: Optional[str] = None,
|
||||
conversation_history: Optional[List[Dict[str, Any]]] = None,
|
||||
canvas_state: Optional[Dict[str, Any]] = None,
|
||||
spec_path: Optional[str] = None,
|
||||
) -> str:
|
||||
"""
|
||||
Build full system prompt with context.
|
||||
@@ -35,6 +36,7 @@ class ContextBuilder:
|
||||
study_id: Optional study name to provide context for
|
||||
conversation_history: Optional recent messages for continuity
|
||||
canvas_state: Optional canvas state (nodes, edges) from the UI
|
||||
spec_path: Optional path to the atomizer_spec.json file
|
||||
|
||||
Returns:
|
||||
Complete system prompt string
|
||||
@@ -45,7 +47,7 @@ class ContextBuilder:
|
||||
if canvas_state:
|
||||
node_count = len(canvas_state.get("nodes", []))
|
||||
print(f"[ContextBuilder] Including canvas context with {node_count} nodes")
|
||||
parts.append(self._canvas_context(canvas_state))
|
||||
parts.append(self._canvas_context(canvas_state, spec_path))
|
||||
else:
|
||||
print("[ContextBuilder] No canvas state provided")
|
||||
|
||||
@@ -57,7 +59,7 @@ class ContextBuilder:
|
||||
if conversation_history:
|
||||
parts.append(self._conversation_context(conversation_history))
|
||||
|
||||
parts.append(self._mode_instructions(mode))
|
||||
parts.append(self._mode_instructions(mode, spec_path))
|
||||
|
||||
return "\n\n---\n\n".join(parts)
|
||||
|
||||
@@ -298,7 +300,7 @@ Important guidelines:
|
||||
|
||||
return context
|
||||
|
||||
def _canvas_context(self, canvas_state: Dict[str, Any]) -> str:
|
||||
def _canvas_context(self, canvas_state: Dict[str, Any], spec_path: Optional[str] = None) -> str:
|
||||
"""
|
||||
Build context from canvas state (nodes and edges).
|
||||
|
||||
@@ -317,6 +319,8 @@ Important guidelines:
|
||||
context += f"**Study Name**: {study_name}\n"
|
||||
if study_path:
|
||||
context += f"**Study Path**: {study_path}\n"
|
||||
if spec_path:
|
||||
context += f"**Spec File**: `{spec_path}`\n"
|
||||
context += "\n"
|
||||
|
||||
# Group nodes by type
|
||||
@@ -438,61 +442,100 @@ Important guidelines:
|
||||
context += f"Total edges: {len(edges)}\n"
|
||||
context += "Flow: Design Variables → Model → Solver → Extractors → Objectives/Constraints → Algorithm\n\n"
|
||||
|
||||
# Canvas modification instructions
|
||||
context += """## Canvas Modification Tools
|
||||
|
||||
**For AtomizerSpec v2.0 studies (preferred):**
|
||||
Use spec tools when working with v2.0 studies (check if study uses `atomizer_spec.json`):
|
||||
- `spec_modify` - Modify spec values using JSONPath (e.g., "design_variables[0].bounds.min")
|
||||
- `spec_add_node` - Add design variables, extractors, objectives, or constraints
|
||||
- `spec_remove_node` - Remove nodes from the spec
|
||||
- `spec_add_custom_extractor` - Add a Python-based custom extractor function
|
||||
|
||||
**For Legacy Canvas (optimization_config.json):**
|
||||
- `canvas_add_node` - Add a new node (designVar, extractor, objective, constraint)
|
||||
- `canvas_update_node` - Update node properties (bounds, weights, names)
|
||||
- `canvas_remove_node` - Remove a node from the canvas
|
||||
- `canvas_connect_nodes` - Create an edge between nodes
|
||||
|
||||
**Example user requests you can handle:**
|
||||
- "Add a design variable called hole_diameter with range 5-15 mm" → Use spec_add_node or canvas_add_node
|
||||
- "Change the weight of wfe_40_20 to 8" → Use spec_modify or canvas_update_node
|
||||
- "Remove the constraint node" → Use spec_remove_node or canvas_remove_node
|
||||
- "Add a custom extractor that computes stress ratio" → Use spec_add_custom_extractor
|
||||
|
||||
Always respond with confirmation of changes made to the canvas/spec.
|
||||
"""
|
||||
|
||||
# Instructions will be in _mode_instructions based on spec_path
|
||||
return context
|
||||
|
||||
def _mode_instructions(self, mode: str) -> str:
|
||||
def _mode_instructions(self, mode: str, spec_path: Optional[str] = None) -> str:
|
||||
"""Mode-specific instructions"""
|
||||
if mode == "power":
|
||||
return """# Power Mode Instructions
|
||||
instructions = """# Power Mode Instructions
|
||||
|
||||
You have **FULL ACCESS** to modify Atomizer studies. **DO NOT ASK FOR PERMISSION** - just do it.
|
||||
|
||||
## Direct Actions (no confirmation needed):
|
||||
- **Add design variables**: Use `canvas_add_node` or `spec_add_node` with node_type="designVar"
|
||||
- **Add extractors**: Use `canvas_add_node` with node_type="extractor"
|
||||
- **Add objectives**: Use `canvas_add_node` with node_type="objective"
|
||||
- **Add constraints**: Use `canvas_add_node` with node_type="constraint"
|
||||
- **Update node properties**: Use `canvas_update_node` or `spec_modify`
|
||||
- **Remove nodes**: Use `canvas_remove_node`
|
||||
- **Edit atomizer_spec.json directly**: Use the Edit tool
|
||||
## CRITICAL: How to Modify the Spec
|
||||
|
||||
## For custom extractors with Python code:
|
||||
Use `spec_add_custom_extractor` to add a custom function.
|
||||
|
||||
## IMPORTANT:
|
||||
- You have --dangerously-skip-permissions enabled
|
||||
- The user has explicitly granted you power mode access
|
||||
- **ACT IMMEDIATELY** when asked to add/modify/remove things
|
||||
- Explain what you did AFTER doing it, not before
|
||||
- Do NOT say "I need permission" - you already have it
|
||||
|
||||
Example: If user says "add a volume extractor", immediately use canvas_add_node to add it.
|
||||
"""
|
||||
if spec_path:
|
||||
instructions += f"""**The spec file is at**: `{spec_path}`
|
||||
|
||||
When asked to add/modify/remove design variables, extractors, objectives, or constraints:
|
||||
1. **Read the spec file first** using the Read tool
|
||||
2. **Edit the spec file** using the Edit tool to make precise changes
|
||||
3. **Confirm what you changed** in your response
|
||||
|
||||
### AtomizerSpec v2.0 Structure
|
||||
|
||||
The spec has these main arrays you can modify:
|
||||
- `design_variables` - Parameters to optimize
|
||||
- `extractors` - Physics extraction functions
|
||||
- `objectives` - What to minimize/maximize
|
||||
- `constraints` - Limits that must be satisfied
|
||||
|
||||
### Example: Add a Design Variable
|
||||
|
||||
To add a design variable called "thickness" with bounds [1, 10]:
|
||||
|
||||
1. Read the spec: `Read({spec_path})`
|
||||
2. Find the `"design_variables": [...]` array
|
||||
3. Add a new entry like:
|
||||
```json
|
||||
{{
|
||||
"id": "dv_thickness",
|
||||
"name": "thickness",
|
||||
"expression_name": "thickness",
|
||||
"type": "continuous",
|
||||
"bounds": {{"min": 1, "max": 10}},
|
||||
"baseline": 5,
|
||||
"units": "mm",
|
||||
"enabled": true
|
||||
}}
|
||||
```
|
||||
4. Use Edit tool to insert this into the array
|
||||
|
||||
### Example: Add an Objective
|
||||
|
||||
To add a "minimize mass" objective:
|
||||
```json
|
||||
{{
|
||||
"id": "obj_mass",
|
||||
"name": "mass",
|
||||
"direction": "minimize",
|
||||
"weight": 1.0,
|
||||
"source": {{
|
||||
"extractor_id": "ext_mass",
|
||||
"output_name": "mass"
|
||||
}}
|
||||
}}
|
||||
```
|
||||
|
||||
### Example: Add an Extractor
|
||||
|
||||
To add a mass extractor:
|
||||
```json
|
||||
{{
|
||||
"id": "ext_mass",
|
||||
"name": "mass",
|
||||
"type": "mass",
|
||||
"builtin": true,
|
||||
"outputs": [{{"name": "mass", "units": "kg"}}]
|
||||
}}
|
||||
```
|
||||
|
||||
"""
|
||||
else:
|
||||
instructions += """No spec file is currently set. Ask the user which study they want to work with.
|
||||
|
||||
"""
|
||||
|
||||
instructions += """## IMPORTANT Rules:
|
||||
- You have --dangerously-skip-permissions enabled
|
||||
- **ACT IMMEDIATELY** when asked to add/modify/remove things
|
||||
- Use the **Edit** tool to modify the spec file directly
|
||||
- Generate unique IDs like `dv_<name>`, `ext_<name>`, `obj_<name>`, `con_<name>`
|
||||
- Explain what you changed AFTER doing it, not before
|
||||
- Do NOT say "I need permission" - you already have it
|
||||
"""
|
||||
return instructions
|
||||
else:
|
||||
return """# User Mode Instructions
|
||||
|
||||
@@ -503,29 +546,11 @@ You can help with optimization workflows:
|
||||
- Generate reports
|
||||
- Explain FEA concepts
|
||||
|
||||
**For code modifications**, suggest switching to Power Mode.
|
||||
**For modifying studies**, the user needs to switch to Power Mode.
|
||||
|
||||
Available tools:
|
||||
- `list_studies`, `get_study_status`, `create_study`
|
||||
- `run_optimization`, `stop_optimization`, `get_optimization_status`
|
||||
- `get_trial_data`, `analyze_convergence`, `compare_trials`, `get_best_design`
|
||||
- `generate_report`, `export_data`
|
||||
- `explain_physics`, `recommend_method`, `query_extractors`
|
||||
|
||||
**AtomizerSpec v2.0 Tools (preferred for new studies):**
|
||||
- `spec_get` - Get the full AtomizerSpec for a study
|
||||
- `spec_modify` - Modify spec values using JSONPath (e.g., "design_variables[0].bounds.min")
|
||||
- `spec_add_node` - Add design variables, extractors, objectives, or constraints
|
||||
- `spec_remove_node` - Remove nodes from the spec
|
||||
- `spec_validate` - Validate spec against JSON Schema
|
||||
- `spec_add_custom_extractor` - Add a Python-based custom extractor function
|
||||
- `spec_create_from_description` - Create a new study from natural language description
|
||||
|
||||
**Canvas Tools (for visual workflow builder):**
|
||||
- `validate_canvas_intent` - Validate a canvas-generated optimization intent
|
||||
- `execute_canvas_intent` - Create a study from a canvas intent
|
||||
- `interpret_canvas_intent` - Analyze intent and provide recommendations
|
||||
|
||||
When you receive a message containing "INTENT:" followed by JSON, this is from the Canvas UI.
|
||||
Parse the intent and use the appropriate canvas tool to process it.
|
||||
In user mode you can:
|
||||
- Read and explain study configurations
|
||||
- Analyze optimization results
|
||||
- Provide recommendations
|
||||
- Answer questions about FEA and optimization
|
||||
"""
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
"""
|
||||
Session Manager
|
||||
|
||||
Manages persistent Claude Code sessions with MCP integration.
|
||||
Manages persistent Claude Code sessions with direct file editing.
|
||||
Fixed for Windows compatibility - uses subprocess.Popen with ThreadPoolExecutor.
|
||||
|
||||
Strategy: Claude edits atomizer_spec.json directly using Edit/Write tools
|
||||
(no MCP dependency for reliability).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
@@ -26,6 +30,10 @@ MCP_SERVER_PATH = ATOMIZER_ROOT / "mcp-server" / "atomizer-tools"
|
||||
# Thread pool for subprocess operations (Windows compatible)
|
||||
_executor = ThreadPoolExecutor(max_workers=4)
|
||||
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ClaudeSession:
|
||||
@@ -130,6 +138,7 @@ class SessionManager:
|
||||
Send a message to a session and stream the response.
|
||||
|
||||
Uses synchronous subprocess.Popen via ThreadPoolExecutor for Windows compatibility.
|
||||
Claude edits atomizer_spec.json directly using Edit/Write tools (no MCP).
|
||||
|
||||
Args:
|
||||
session_id: The session ID
|
||||
@@ -147,45 +156,48 @@ class SessionManager:
|
||||
# Store user message
|
||||
self.store.add_message(session_id, "user", message)
|
||||
|
||||
# Get spec path and hash BEFORE Claude runs (to detect changes)
|
||||
spec_path = self._get_spec_path(session.study_id) if session.study_id else None
|
||||
spec_hash_before = self._get_file_hash(spec_path) if spec_path else None
|
||||
|
||||
# Build context with conversation history AND canvas state
|
||||
history = self.store.get_history(session_id, limit=10)
|
||||
full_prompt = self.context_builder.build(
|
||||
mode=session.mode,
|
||||
study_id=session.study_id,
|
||||
conversation_history=history[:-1],
|
||||
canvas_state=canvas_state, # Pass canvas state for context
|
||||
canvas_state=canvas_state,
|
||||
spec_path=str(spec_path) if spec_path else None, # Tell Claude where the spec is
|
||||
)
|
||||
full_prompt += f"\n\nUser: {message}\n\nRespond helpfully and concisely:"
|
||||
|
||||
# Build CLI arguments
|
||||
# Build CLI arguments - NO MCP for reliability
|
||||
cli_args = ["claude", "--print"]
|
||||
|
||||
# Ensure MCP config exists
|
||||
mcp_config_path = ATOMIZER_ROOT / f".claude-mcp-{session_id}.json"
|
||||
if not mcp_config_path.exists():
|
||||
mcp_config = self._build_mcp_config(session.mode)
|
||||
with open(mcp_config_path, "w") as f:
|
||||
json.dump(mcp_config, f)
|
||||
cli_args.extend(["--mcp-config", str(mcp_config_path)])
|
||||
|
||||
if session.mode == "user":
|
||||
cli_args.extend([
|
||||
"--allowedTools",
|
||||
"Read Write(**/STUDY_REPORT.md) Write(**/3_results/*.md) Bash(python:*) mcp__atomizer-tools__*"
|
||||
])
|
||||
# User mode: limited tools
|
||||
cli_args.extend(
|
||||
[
|
||||
"--allowedTools",
|
||||
"Read Bash(python:*)",
|
||||
]
|
||||
)
|
||||
else:
|
||||
# Power mode: full access to edit files
|
||||
cli_args.append("--dangerously-skip-permissions")
|
||||
|
||||
cli_args.append("-") # Read from stdin
|
||||
|
||||
full_response = ""
|
||||
tool_calls: List[Dict] = []
|
||||
process: Optional[subprocess.Popen] = None
|
||||
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
# Run subprocess in thread pool (Windows compatible)
|
||||
def run_claude():
|
||||
nonlocal process
|
||||
try:
|
||||
process = subprocess.Popen(
|
||||
cli_args,
|
||||
@@ -194,8 +206,8 @@ class SessionManager:
|
||||
stderr=subprocess.PIPE,
|
||||
cwd=str(ATOMIZER_ROOT),
|
||||
text=True,
|
||||
encoding='utf-8',
|
||||
errors='replace',
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
)
|
||||
stdout, stderr = process.communicate(input=full_prompt, timeout=300)
|
||||
return {
|
||||
@@ -204,10 +216,13 @@ class SessionManager:
|
||||
"returncode": process.returncode,
|
||||
}
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
if process:
|
||||
process.kill()
|
||||
return {"error": "Response timeout (5 minutes)"}
|
||||
except FileNotFoundError:
|
||||
return {"error": "Claude CLI not found in PATH. Install with: npm install -g @anthropic-ai/claude-code"}
|
||||
return {
|
||||
"error": "Claude CLI not found in PATH. Install with: npm install -g @anthropic-ai/claude-code"
|
||||
}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
@@ -219,24 +234,14 @@ class SessionManager:
|
||||
full_response = result["stdout"] or ""
|
||||
|
||||
if full_response:
|
||||
# Check if response contains canvas modifications (from MCP tools)
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
modifications = self._extract_canvas_modifications(full_response)
|
||||
logger.info(f"[SEND_MSG] Found {len(modifications)} canvas modifications to send")
|
||||
|
||||
for mod in modifications:
|
||||
logger.info(f"[SEND_MSG] Sending canvas_modification: {mod.get('action')} {mod.get('nodeType')}")
|
||||
yield {"type": "canvas_modification", "modification": mod}
|
||||
|
||||
# Always send the text response
|
||||
# Always send the text response first
|
||||
yield {"type": "text", "content": full_response}
|
||||
|
||||
if result["returncode"] != 0 and result["stderr"]:
|
||||
yield {"type": "error", "message": f"CLI error: {result['stderr']}"}
|
||||
logger.warning(f"[SEND_MSG] CLI stderr: {result['stderr']}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[SEND_MSG] Exception: {e}")
|
||||
yield {"type": "error", "message": str(e)}
|
||||
|
||||
# Store assistant response
|
||||
@@ -248,8 +253,46 @@ class SessionManager:
|
||||
tool_calls=tool_calls if tool_calls else None,
|
||||
)
|
||||
|
||||
# Check if spec was modified by comparing hashes
|
||||
if spec_path and session.mode == "power" and session.study_id:
|
||||
spec_hash_after = self._get_file_hash(spec_path)
|
||||
if spec_hash_before != spec_hash_after:
|
||||
logger.info(f"[SEND_MSG] Spec file was modified! Sending update.")
|
||||
spec_update = await self._check_spec_updated(session.study_id)
|
||||
if spec_update:
|
||||
yield {
|
||||
"type": "spec_updated",
|
||||
"spec": spec_update,
|
||||
"tool": "direct_edit",
|
||||
"reason": "Claude modified spec file directly",
|
||||
}
|
||||
|
||||
yield {"type": "done", "tool_calls": tool_calls}
|
||||
|
||||
def _get_spec_path(self, study_id: str) -> Optional[Path]:
|
||||
"""Get the atomizer_spec.json path for a study."""
|
||||
if not study_id:
|
||||
return None
|
||||
|
||||
if study_id.startswith("draft_"):
|
||||
spec_path = ATOMIZER_ROOT / "studies" / "_inbox" / study_id / "atomizer_spec.json"
|
||||
else:
|
||||
spec_path = ATOMIZER_ROOT / "studies" / study_id / "atomizer_spec.json"
|
||||
if not spec_path.exists():
|
||||
spec_path = ATOMIZER_ROOT / "studies" / study_id / "1_setup" / "atomizer_spec.json"
|
||||
|
||||
return spec_path if spec_path.exists() else None
|
||||
|
||||
def _get_file_hash(self, path: Optional[Path]) -> Optional[str]:
|
||||
"""Get MD5 hash of a file for change detection."""
|
||||
if not path or not path.exists():
|
||||
return None
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
return hashlib.md5(f.read()).hexdigest()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
async def switch_mode(
|
||||
self,
|
||||
session_id: str,
|
||||
@@ -313,6 +356,7 @@ class SessionManager:
|
||||
"""
|
||||
import re
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
modifications = []
|
||||
@@ -327,14 +371,16 @@ class SessionManager:
|
||||
|
||||
try:
|
||||
# Method 1: Look for JSON in code fences
|
||||
code_block_pattern = r'```(?:json)?\s*([\s\S]*?)```'
|
||||
code_block_pattern = r"```(?:json)?\s*([\s\S]*?)```"
|
||||
for match in re.finditer(code_block_pattern, response):
|
||||
block_content = match.group(1).strip()
|
||||
try:
|
||||
obj = json.loads(block_content)
|
||||
if isinstance(obj, dict) and 'modification' in obj:
|
||||
logger.info(f"[CANVAS_MOD] Found modification in code fence: {obj['modification']}")
|
||||
modifications.append(obj['modification'])
|
||||
if isinstance(obj, dict) and "modification" in obj:
|
||||
logger.info(
|
||||
f"[CANVAS_MOD] Found modification in code fence: {obj['modification']}"
|
||||
)
|
||||
modifications.append(obj["modification"])
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
@@ -342,7 +388,7 @@ class SessionManager:
|
||||
# This handles nested objects correctly
|
||||
i = 0
|
||||
while i < len(response):
|
||||
if response[i] == '{':
|
||||
if response[i] == "{":
|
||||
# Found a potential JSON start, find matching close
|
||||
brace_count = 1
|
||||
j = i + 1
|
||||
@@ -354,14 +400,14 @@ class SessionManager:
|
||||
|
||||
if escape_next:
|
||||
escape_next = False
|
||||
elif char == '\\':
|
||||
elif char == "\\":
|
||||
escape_next = True
|
||||
elif char == '"' and not escape_next:
|
||||
in_string = not in_string
|
||||
elif not in_string:
|
||||
if char == '{':
|
||||
if char == "{":
|
||||
brace_count += 1
|
||||
elif char == '}':
|
||||
elif char == "}":
|
||||
brace_count -= 1
|
||||
j += 1
|
||||
|
||||
@@ -369,11 +415,13 @@ class SessionManager:
|
||||
potential_json = response[i:j]
|
||||
try:
|
||||
obj = json.loads(potential_json)
|
||||
if isinstance(obj, dict) and 'modification' in obj:
|
||||
mod = obj['modification']
|
||||
if isinstance(obj, dict) and "modification" in obj:
|
||||
mod = obj["modification"]
|
||||
# Avoid duplicates
|
||||
if mod not in modifications:
|
||||
logger.info(f"[CANVAS_MOD] Found inline modification: action={mod.get('action')}, nodeType={mod.get('nodeType')}")
|
||||
logger.info(
|
||||
f"[CANVAS_MOD] Found inline modification: action={mod.get('action')}, nodeType={mod.get('nodeType')}"
|
||||
)
|
||||
modifications.append(mod)
|
||||
except json.JSONDecodeError as e:
|
||||
# Not valid JSON, skip
|
||||
@@ -388,6 +436,43 @@ class SessionManager:
|
||||
logger.info(f"[CANVAS_MOD] Extracted {len(modifications)} modification(s)")
|
||||
return modifications
|
||||
|
||||
async def _check_spec_updated(self, study_id: str) -> Optional[Dict]:
|
||||
"""
|
||||
Check if the atomizer_spec.json was modified and return the updated spec.
|
||||
|
||||
For drafts in _inbox/, we check the spec file directly.
|
||||
"""
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
# Determine spec path based on study_id
|
||||
if study_id.startswith("draft_"):
|
||||
spec_path = ATOMIZER_ROOT / "studies" / "_inbox" / study_id / "atomizer_spec.json"
|
||||
else:
|
||||
# Regular study path
|
||||
spec_path = ATOMIZER_ROOT / "studies" / study_id / "atomizer_spec.json"
|
||||
if not spec_path.exists():
|
||||
spec_path = (
|
||||
ATOMIZER_ROOT / "studies" / study_id / "1_setup" / "atomizer_spec.json"
|
||||
)
|
||||
|
||||
if not spec_path.exists():
|
||||
logger.debug(f"[SPEC_CHECK] Spec not found at {spec_path}")
|
||||
return None
|
||||
|
||||
# Read and return the spec
|
||||
with open(spec_path, "r", encoding="utf-8") as f:
|
||||
spec = json.load(f)
|
||||
|
||||
logger.info(f"[SPEC_CHECK] Loaded spec from {spec_path}")
|
||||
return spec
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[SPEC_CHECK] Error checking spec: {e}")
|
||||
return None
|
||||
|
||||
def _build_mcp_config(self, mode: Literal["user", "power"]) -> dict:
|
||||
"""Build MCP configuration for Claude"""
|
||||
return {
|
||||
|
||||
@@ -47,11 +47,13 @@ from optimization_engine.config.spec_validator import (
|
||||
|
||||
class SpecManagerError(Exception):
|
||||
"""Base error for SpecManager operations."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class SpecNotFoundError(SpecManagerError):
|
||||
"""Raised when spec file doesn't exist."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
@@ -118,7 +120,7 @@ class SpecManager:
|
||||
if not self.spec_path.exists():
|
||||
raise SpecNotFoundError(f"Spec not found: {self.spec_path}")
|
||||
|
||||
with open(self.spec_path, 'r', encoding='utf-8') as f:
|
||||
with open(self.spec_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
if validate:
|
||||
@@ -141,14 +143,15 @@ class SpecManager:
|
||||
if not self.spec_path.exists():
|
||||
raise SpecNotFoundError(f"Spec not found: {self.spec_path}")
|
||||
|
||||
with open(self.spec_path, 'r', encoding='utf-8') as f:
|
||||
with open(self.spec_path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
def save(
|
||||
self,
|
||||
spec: Union[AtomizerSpec, Dict[str, Any]],
|
||||
modified_by: str = "api",
|
||||
expected_hash: Optional[str] = None
|
||||
expected_hash: Optional[str] = None,
|
||||
skip_validation: bool = False,
|
||||
) -> str:
|
||||
"""
|
||||
Save spec with validation and broadcast.
|
||||
@@ -157,6 +160,7 @@ class SpecManager:
|
||||
spec: Spec to save (AtomizerSpec or dict)
|
||||
modified_by: Who/what is making the change
|
||||
expected_hash: If provided, verify current file hash matches
|
||||
skip_validation: If True, skip strict validation (for draft specs)
|
||||
|
||||
Returns:
|
||||
New spec hash
|
||||
@@ -167,7 +171,7 @@ class SpecManager:
|
||||
"""
|
||||
# Convert to dict if needed
|
||||
if isinstance(spec, AtomizerSpec):
|
||||
data = spec.model_dump(mode='json')
|
||||
data = spec.model_dump(mode="json")
|
||||
else:
|
||||
data = spec
|
||||
|
||||
@@ -176,24 +180,30 @@ class SpecManager:
|
||||
current_hash = self.get_hash()
|
||||
if current_hash != expected_hash:
|
||||
raise SpecConflictError(
|
||||
"Spec was modified by another client",
|
||||
current_hash=current_hash
|
||||
"Spec was modified by another client", current_hash=current_hash
|
||||
)
|
||||
|
||||
# Update metadata
|
||||
now = datetime.now(timezone.utc).isoformat().replace('+00:00', 'Z')
|
||||
now = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
data["meta"]["modified"] = now
|
||||
data["meta"]["modified_by"] = modified_by
|
||||
|
||||
# Validate
|
||||
self.validator.validate(data, strict=True)
|
||||
# Validate (skip for draft specs or when explicitly requested)
|
||||
status = data.get("meta", {}).get("status", "draft")
|
||||
is_draft = status in ("draft", "introspected", "configured")
|
||||
|
||||
if not skip_validation and not is_draft:
|
||||
self.validator.validate(data, strict=True)
|
||||
elif not skip_validation:
|
||||
# For draft specs, just validate non-strictly (collect warnings only)
|
||||
self.validator.validate(data, strict=False)
|
||||
|
||||
# Compute new hash
|
||||
new_hash = self._compute_hash(data)
|
||||
|
||||
# Atomic write (write to temp, then rename)
|
||||
temp_path = self.spec_path.with_suffix('.tmp')
|
||||
with open(temp_path, 'w', encoding='utf-8') as f:
|
||||
temp_path = self.spec_path.with_suffix(".tmp")
|
||||
with open(temp_path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
|
||||
temp_path.replace(self.spec_path)
|
||||
@@ -202,12 +212,9 @@ class SpecManager:
|
||||
self._last_hash = new_hash
|
||||
|
||||
# Broadcast to subscribers
|
||||
self._broadcast({
|
||||
"type": "spec_updated",
|
||||
"hash": new_hash,
|
||||
"modified_by": modified_by,
|
||||
"timestamp": now
|
||||
})
|
||||
self._broadcast(
|
||||
{"type": "spec_updated", "hash": new_hash, "modified_by": modified_by, "timestamp": now}
|
||||
)
|
||||
|
||||
return new_hash
|
||||
|
||||
@@ -219,7 +226,7 @@ class SpecManager:
|
||||
"""Get current spec hash."""
|
||||
if not self.spec_path.exists():
|
||||
return ""
|
||||
with open(self.spec_path, 'r', encoding='utf-8') as f:
|
||||
with open(self.spec_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
return self._compute_hash(data)
|
||||
|
||||
@@ -240,12 +247,7 @@ class SpecManager:
|
||||
# Patch Operations
|
||||
# =========================================================================
|
||||
|
||||
def patch(
|
||||
self,
|
||||
path: str,
|
||||
value: Any,
|
||||
modified_by: str = "api"
|
||||
) -> AtomizerSpec:
|
||||
def patch(self, path: str, value: Any, modified_by: str = "api") -> AtomizerSpec:
|
||||
"""
|
||||
Apply a JSONPath-style modification.
|
||||
|
||||
@@ -306,7 +308,7 @@ class SpecManager:
|
||||
"""Parse JSONPath into parts."""
|
||||
# Handle both dot notation and bracket notation
|
||||
parts = []
|
||||
for part in re.split(r'\.|\[|\]', path):
|
||||
for part in re.split(r"\.|\[|\]", path):
|
||||
if part:
|
||||
parts.append(part)
|
||||
return parts
|
||||
@@ -316,10 +318,7 @@ class SpecManager:
|
||||
# =========================================================================
|
||||
|
||||
def add_node(
|
||||
self,
|
||||
node_type: str,
|
||||
node_data: Dict[str, Any],
|
||||
modified_by: str = "canvas"
|
||||
self, node_type: str, node_data: Dict[str, Any], modified_by: str = "canvas"
|
||||
) -> str:
|
||||
"""
|
||||
Add a new node (design var, extractor, objective, constraint).
|
||||
@@ -353,20 +352,19 @@ class SpecManager:
|
||||
self.save(data, modified_by)
|
||||
|
||||
# Broadcast node addition
|
||||
self._broadcast({
|
||||
"type": "node_added",
|
||||
"node_type": node_type,
|
||||
"node_id": node_id,
|
||||
"modified_by": modified_by
|
||||
})
|
||||
self._broadcast(
|
||||
{
|
||||
"type": "node_added",
|
||||
"node_type": node_type,
|
||||
"node_id": node_id,
|
||||
"modified_by": modified_by,
|
||||
}
|
||||
)
|
||||
|
||||
return node_id
|
||||
|
||||
def update_node(
|
||||
self,
|
||||
node_id: str,
|
||||
updates: Dict[str, Any],
|
||||
modified_by: str = "canvas"
|
||||
self, node_id: str, updates: Dict[str, Any], modified_by: str = "canvas"
|
||||
) -> None:
|
||||
"""
|
||||
Update an existing node.
|
||||
@@ -396,11 +394,7 @@ class SpecManager:
|
||||
|
||||
self.save(data, modified_by)
|
||||
|
||||
def remove_node(
|
||||
self,
|
||||
node_id: str,
|
||||
modified_by: str = "canvas"
|
||||
) -> None:
|
||||
def remove_node(self, node_id: str, modified_by: str = "canvas") -> None:
|
||||
"""
|
||||
Remove a node and all edges referencing it.
|
||||
|
||||
@@ -427,24 +421,18 @@ class SpecManager:
|
||||
# Remove edges referencing this node
|
||||
if "canvas" in data and data["canvas"] and "edges" in data["canvas"]:
|
||||
data["canvas"]["edges"] = [
|
||||
e for e in data["canvas"]["edges"]
|
||||
e
|
||||
for e in data["canvas"]["edges"]
|
||||
if e.get("source") != node_id and e.get("target") != node_id
|
||||
]
|
||||
|
||||
self.save(data, modified_by)
|
||||
|
||||
# Broadcast node removal
|
||||
self._broadcast({
|
||||
"type": "node_removed",
|
||||
"node_id": node_id,
|
||||
"modified_by": modified_by
|
||||
})
|
||||
self._broadcast({"type": "node_removed", "node_id": node_id, "modified_by": modified_by})
|
||||
|
||||
def update_node_position(
|
||||
self,
|
||||
node_id: str,
|
||||
position: Dict[str, float],
|
||||
modified_by: str = "canvas"
|
||||
self, node_id: str, position: Dict[str, float], modified_by: str = "canvas"
|
||||
) -> None:
|
||||
"""
|
||||
Update a node's canvas position.
|
||||
@@ -456,12 +444,7 @@ class SpecManager:
|
||||
"""
|
||||
self.update_node(node_id, {"canvas_position": position}, modified_by)
|
||||
|
||||
def add_edge(
|
||||
self,
|
||||
source: str,
|
||||
target: str,
|
||||
modified_by: str = "canvas"
|
||||
) -> None:
|
||||
def add_edge(self, source: str, target: str, modified_by: str = "canvas") -> None:
|
||||
"""
|
||||
Add a canvas edge between nodes.
|
||||
|
||||
@@ -483,19 +466,11 @@ class SpecManager:
|
||||
if edge.get("source") == source and edge.get("target") == target:
|
||||
return # Already exists
|
||||
|
||||
data["canvas"]["edges"].append({
|
||||
"source": source,
|
||||
"target": target
|
||||
})
|
||||
data["canvas"]["edges"].append({"source": source, "target": target})
|
||||
|
||||
self.save(data, modified_by)
|
||||
|
||||
def remove_edge(
|
||||
self,
|
||||
source: str,
|
||||
target: str,
|
||||
modified_by: str = "canvas"
|
||||
) -> None:
|
||||
def remove_edge(self, source: str, target: str, modified_by: str = "canvas") -> None:
|
||||
"""
|
||||
Remove a canvas edge.
|
||||
|
||||
@@ -508,7 +483,8 @@ class SpecManager:
|
||||
|
||||
if "canvas" in data and data["canvas"] and "edges" in data["canvas"]:
|
||||
data["canvas"]["edges"] = [
|
||||
e for e in data["canvas"]["edges"]
|
||||
e
|
||||
for e in data["canvas"]["edges"]
|
||||
if not (e.get("source") == source and e.get("target") == target)
|
||||
]
|
||||
|
||||
@@ -524,7 +500,7 @@ class SpecManager:
|
||||
code: str,
|
||||
outputs: List[str],
|
||||
description: Optional[str] = None,
|
||||
modified_by: str = "claude"
|
||||
modified_by: str = "claude",
|
||||
) -> str:
|
||||
"""
|
||||
Add a custom extractor function.
|
||||
@@ -546,9 +522,7 @@ class SpecManager:
|
||||
try:
|
||||
compile(code, f"<custom:{name}>", "exec")
|
||||
except SyntaxError as e:
|
||||
raise SpecValidationError(
|
||||
f"Invalid Python syntax: {e.msg} at line {e.lineno}"
|
||||
)
|
||||
raise SpecValidationError(f"Invalid Python syntax: {e.msg} at line {e.lineno}")
|
||||
|
||||
data = self.load_raw()
|
||||
|
||||
@@ -561,13 +535,9 @@ class SpecManager:
|
||||
"name": description or f"Custom: {name}",
|
||||
"type": "custom_function",
|
||||
"builtin": False,
|
||||
"function": {
|
||||
"name": name,
|
||||
"module": "custom_extractors.dynamic",
|
||||
"source_code": code
|
||||
},
|
||||
"function": {"name": name, "module": "custom_extractors.dynamic", "source_code": code},
|
||||
"outputs": [{"name": o, "metric": "custom"} for o in outputs],
|
||||
"canvas_position": self._auto_position("extractor", data)
|
||||
"canvas_position": self._auto_position("extractor", data),
|
||||
}
|
||||
|
||||
data["extractors"].append(extractor)
|
||||
@@ -580,7 +550,7 @@ class SpecManager:
|
||||
extractor_id: str,
|
||||
code: Optional[str] = None,
|
||||
outputs: Optional[List[str]] = None,
|
||||
modified_by: str = "claude"
|
||||
modified_by: str = "claude",
|
||||
) -> None:
|
||||
"""
|
||||
Update an existing custom function.
|
||||
@@ -611,9 +581,7 @@ class SpecManager:
|
||||
try:
|
||||
compile(code, f"<custom:{extractor_id}>", "exec")
|
||||
except SyntaxError as e:
|
||||
raise SpecValidationError(
|
||||
f"Invalid Python syntax: {e.msg} at line {e.lineno}"
|
||||
)
|
||||
raise SpecValidationError(f"Invalid Python syntax: {e.msg} at line {e.lineno}")
|
||||
if "function" not in extractor:
|
||||
extractor["function"] = {}
|
||||
extractor["function"]["source_code"] = code
|
||||
@@ -672,7 +640,7 @@ class SpecManager:
|
||||
"design_variable": "dv",
|
||||
"extractor": "ext",
|
||||
"objective": "obj",
|
||||
"constraint": "con"
|
||||
"constraint": "con",
|
||||
}
|
||||
prefix = prefix_map.get(node_type, node_type[:3])
|
||||
|
||||
@@ -697,7 +665,7 @@ class SpecManager:
|
||||
"design_variable": "design_variables",
|
||||
"extractor": "extractors",
|
||||
"objective": "objectives",
|
||||
"constraint": "constraints"
|
||||
"constraint": "constraints",
|
||||
}
|
||||
return section_map.get(node_type, node_type + "s")
|
||||
|
||||
@@ -709,7 +677,7 @@ class SpecManager:
|
||||
"design_variable": 50,
|
||||
"extractor": 740,
|
||||
"objective": 1020,
|
||||
"constraint": 1020
|
||||
"constraint": 1020,
|
||||
}
|
||||
|
||||
x = x_positions.get(node_type, 400)
|
||||
@@ -729,11 +697,123 @@ class SpecManager:
|
||||
|
||||
return {"x": x, "y": y}
|
||||
|
||||
# =========================================================================
|
||||
# Intake Workflow Methods
|
||||
# =========================================================================
|
||||
|
||||
def update_status(self, status: str, modified_by: str = "api") -> None:
|
||||
"""
|
||||
Update the spec status field.
|
||||
|
||||
Args:
|
||||
status: New status (draft, introspected, configured, validated, ready, running, completed, failed)
|
||||
modified_by: Who/what is making the change
|
||||
"""
|
||||
data = self.load_raw()
|
||||
data["meta"]["status"] = status
|
||||
self.save(data, modified_by)
|
||||
|
||||
def get_status(self) -> str:
|
||||
"""
|
||||
Get the current spec status.
|
||||
|
||||
Returns:
|
||||
Current status string
|
||||
"""
|
||||
if not self.exists():
|
||||
return "unknown"
|
||||
data = self.load_raw()
|
||||
return data.get("meta", {}).get("status", "draft")
|
||||
|
||||
def add_introspection(
|
||||
self, introspection_data: Dict[str, Any], modified_by: str = "introspection"
|
||||
) -> None:
|
||||
"""
|
||||
Add introspection data to the spec's model section.
|
||||
|
||||
Args:
|
||||
introspection_data: Dict with timestamp, expressions, mass_kg, etc.
|
||||
modified_by: Who/what is making the change
|
||||
"""
|
||||
data = self.load_raw()
|
||||
|
||||
if "model" not in data:
|
||||
data["model"] = {}
|
||||
|
||||
data["model"]["introspection"] = introspection_data
|
||||
data["meta"]["status"] = "introspected"
|
||||
|
||||
self.save(data, modified_by)
|
||||
|
||||
def add_baseline(
|
||||
self, baseline_data: Dict[str, Any], modified_by: str = "baseline_solve"
|
||||
) -> None:
|
||||
"""
|
||||
Add baseline solve results to introspection data.
|
||||
|
||||
Args:
|
||||
baseline_data: Dict with timestamp, solve_time_seconds, mass_kg, etc.
|
||||
modified_by: Who/what is making the change
|
||||
"""
|
||||
data = self.load_raw()
|
||||
|
||||
if "model" not in data:
|
||||
data["model"] = {}
|
||||
if "introspection" not in data["model"] or data["model"]["introspection"] is None:
|
||||
data["model"]["introspection"] = {}
|
||||
|
||||
data["model"]["introspection"]["baseline"] = baseline_data
|
||||
|
||||
# Update status based on baseline success
|
||||
if baseline_data.get("success", False):
|
||||
data["meta"]["status"] = "validated"
|
||||
|
||||
self.save(data, modified_by)
|
||||
|
||||
def set_topic(self, topic: str, modified_by: str = "api") -> None:
|
||||
"""
|
||||
Set the spec's topic field.
|
||||
|
||||
Args:
|
||||
topic: Topic folder name
|
||||
modified_by: Who/what is making the change
|
||||
"""
|
||||
data = self.load_raw()
|
||||
data["meta"]["topic"] = topic
|
||||
self.save(data, modified_by)
|
||||
|
||||
def get_introspection(self) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get introspection data from spec.
|
||||
|
||||
Returns:
|
||||
Introspection dict or None if not present
|
||||
"""
|
||||
if not self.exists():
|
||||
return None
|
||||
data = self.load_raw()
|
||||
return data.get("model", {}).get("introspection")
|
||||
|
||||
def get_design_candidates(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get expressions marked as design variable candidates.
|
||||
|
||||
Returns:
|
||||
List of expression dicts where is_candidate=True
|
||||
"""
|
||||
introspection = self.get_introspection()
|
||||
if not introspection:
|
||||
return []
|
||||
|
||||
expressions = introspection.get("expressions", [])
|
||||
return [e for e in expressions if e.get("is_candidate", False)]
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Factory Function
|
||||
# =========================================================================
|
||||
|
||||
|
||||
def get_spec_manager(study_path: Union[str, Path]) -> SpecManager:
|
||||
"""
|
||||
Get a SpecManager instance for a study.
|
||||
|
||||
@@ -9,6 +9,7 @@ import Analysis from './pages/Analysis';
|
||||
import Insights from './pages/Insights';
|
||||
import Results from './pages/Results';
|
||||
import CanvasView from './pages/CanvasView';
|
||||
import Studio from './pages/Studio';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
@@ -32,6 +33,10 @@ function App() {
|
||||
<Route path="canvas" element={<CanvasView />} />
|
||||
<Route path="canvas/*" element={<CanvasView />} />
|
||||
|
||||
{/* Studio - unified study creation environment */}
|
||||
<Route path="studio" element={<Studio />} />
|
||||
<Route path="studio/:draftId" element={<Studio />} />
|
||||
|
||||
{/* Study pages - with sidebar layout */}
|
||||
<Route element={<MainLayout />}>
|
||||
<Route path="setup" element={<Setup />} />
|
||||
|
||||
411
atomizer-dashboard/frontend/src/api/intake.ts
Normal file
411
atomizer-dashboard/frontend/src/api/intake.ts
Normal file
@@ -0,0 +1,411 @@
|
||||
/**
|
||||
* Intake API Client
|
||||
*
|
||||
* API client methods for the study intake workflow.
|
||||
*/
|
||||
|
||||
import {
|
||||
CreateInboxRequest,
|
||||
CreateInboxResponse,
|
||||
IntrospectRequest,
|
||||
IntrospectResponse,
|
||||
ListInboxResponse,
|
||||
ListTopicsResponse,
|
||||
InboxStudyDetail,
|
||||
GenerateReadmeResponse,
|
||||
FinalizeRequest,
|
||||
FinalizeResponse,
|
||||
UploadFilesResponse,
|
||||
} from '../types/intake';
|
||||
|
||||
const API_BASE = '/api';
|
||||
|
||||
/**
|
||||
* Intake API client for study creation workflow.
|
||||
*/
|
||||
export const intakeApi = {
|
||||
/**
|
||||
* Create a new inbox study folder with initial spec.
|
||||
*/
|
||||
async createInbox(request: CreateInboxRequest): Promise<CreateInboxResponse> {
|
||||
const response = await fetch(`${API_BASE}/intake/create`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || 'Failed to create inbox study');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
|
||||
/**
|
||||
* Run NX introspection on an inbox study.
|
||||
*/
|
||||
async introspect(request: IntrospectRequest): Promise<IntrospectResponse> {
|
||||
const response = await fetch(`${API_BASE}/intake/introspect`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || 'Introspection failed');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
|
||||
/**
|
||||
* List all studies in the inbox.
|
||||
*/
|
||||
async listInbox(): Promise<ListInboxResponse> {
|
||||
const response = await fetch(`${API_BASE}/intake/list`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch inbox studies');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
|
||||
/**
|
||||
* List existing topic folders.
|
||||
*/
|
||||
async listTopics(): Promise<ListTopicsResponse> {
|
||||
const response = await fetch(`${API_BASE}/intake/topics`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch topics');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
|
||||
/**
|
||||
* Get detailed information about an inbox study.
|
||||
*/
|
||||
async getInboxStudy(studyName: string): Promise<InboxStudyDetail> {
|
||||
const response = await fetch(`${API_BASE}/intake/${encodeURIComponent(studyName)}`);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || 'Failed to fetch inbox study');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete an inbox study.
|
||||
*/
|
||||
async deleteInboxStudy(studyName: string): Promise<{ success: boolean; deleted: string }> {
|
||||
const response = await fetch(`${API_BASE}/intake/${encodeURIComponent(studyName)}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || 'Failed to delete inbox study');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
|
||||
/**
|
||||
* Generate README for an inbox study using Claude AI.
|
||||
*/
|
||||
async generateReadme(studyName: string): Promise<GenerateReadmeResponse> {
|
||||
const response = await fetch(
|
||||
`${API_BASE}/intake/${encodeURIComponent(studyName)}/readme`,
|
||||
{ method: 'POST' }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || 'README generation failed');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
|
||||
/**
|
||||
* Finalize an inbox study and move to studies directory.
|
||||
*/
|
||||
async finalize(studyName: string, request: FinalizeRequest): Promise<FinalizeResponse> {
|
||||
const response = await fetch(
|
||||
`${API_BASE}/intake/${encodeURIComponent(studyName)}/finalize`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(request),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || 'Finalization failed');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
|
||||
/**
|
||||
* Upload model files to an inbox study.
|
||||
*/
|
||||
async uploadFiles(studyName: string, files: File[]): Promise<UploadFilesResponse> {
|
||||
const formData = new FormData();
|
||||
files.forEach((file) => {
|
||||
formData.append('files', file);
|
||||
});
|
||||
|
||||
const response = await fetch(
|
||||
`${API_BASE}/intake/${encodeURIComponent(studyName)}/upload`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || 'File upload failed');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
|
||||
/**
|
||||
* Upload context files to an inbox study.
|
||||
* Context files help Claude understand optimization goals.
|
||||
*/
|
||||
async uploadContextFiles(studyName: string, files: File[]): Promise<UploadFilesResponse> {
|
||||
const formData = new FormData();
|
||||
files.forEach((file) => {
|
||||
formData.append('files', file);
|
||||
});
|
||||
|
||||
const response = await fetch(
|
||||
`${API_BASE}/intake/${encodeURIComponent(studyName)}/context`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || 'Context file upload failed');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
|
||||
/**
|
||||
* List context files for an inbox study.
|
||||
*/
|
||||
async listContextFiles(studyName: string): Promise<{
|
||||
study_name: string;
|
||||
context_files: Array<{ name: string; path: string; size: number; extension: string }>;
|
||||
total: number;
|
||||
}> {
|
||||
const response = await fetch(
|
||||
`${API_BASE}/intake/${encodeURIComponent(studyName)}/context`
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to list context files');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete a context file from an inbox study.
|
||||
*/
|
||||
async deleteContextFile(studyName: string, filename: string): Promise<{ success: boolean; deleted: string }> {
|
||||
const response = await fetch(
|
||||
`${API_BASE}/intake/${encodeURIComponent(studyName)}/context/${encodeURIComponent(filename)}`,
|
||||
{ method: 'DELETE' }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || 'Failed to delete context file');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
|
||||
/**
|
||||
* Create design variables from selected expressions.
|
||||
*/
|
||||
async createDesignVariables(
|
||||
studyName: string,
|
||||
expressionNames: string[],
|
||||
options?: { autoBounds?: boolean; boundFactor?: number }
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
study_name: string;
|
||||
created: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
expression_name: string;
|
||||
bounds_min: number;
|
||||
bounds_max: number;
|
||||
baseline: number;
|
||||
units: string | null;
|
||||
}>;
|
||||
total_created: number;
|
||||
}> {
|
||||
const response = await fetch(
|
||||
`${API_BASE}/intake/${encodeURIComponent(studyName)}/design-variables`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
expression_names: expressionNames,
|
||||
auto_bounds: options?.autoBounds ?? true,
|
||||
bound_factor: options?.boundFactor ?? 0.5,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || 'Failed to create design variables');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
|
||||
// ===========================================================================
|
||||
// Studio Endpoints (Atomizer Studio - Unified Creation Environment)
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* Create an anonymous draft study for Studio workflow.
|
||||
* Returns a temporary draft_id that can be renamed during finalization.
|
||||
*/
|
||||
async createDraft(): Promise<{
|
||||
success: boolean;
|
||||
draft_id: string;
|
||||
inbox_path: string;
|
||||
spec_path: string;
|
||||
status: string;
|
||||
}> {
|
||||
const response = await fetch(`${API_BASE}/intake/draft`, {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || 'Failed to create draft');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
|
||||
/**
|
||||
* Get extracted text content from context files.
|
||||
* Used for AI context injection.
|
||||
*/
|
||||
async getContextContent(studyName: string): Promise<{
|
||||
success: boolean;
|
||||
study_name: string;
|
||||
content: string;
|
||||
files_read: Array<{
|
||||
name: string;
|
||||
extension: string;
|
||||
size: number;
|
||||
status: string;
|
||||
characters?: number;
|
||||
error?: string;
|
||||
}>;
|
||||
total_characters: number;
|
||||
}> {
|
||||
const response = await fetch(
|
||||
`${API_BASE}/intake/${encodeURIComponent(studyName)}/context/content`
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || 'Failed to get context content');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
|
||||
/**
|
||||
* Finalize a Studio draft with rename support.
|
||||
* Enhanced version that supports renaming draft_xxx to proper names.
|
||||
*/
|
||||
async finalizeStudio(
|
||||
studyName: string,
|
||||
request: {
|
||||
topic: string;
|
||||
newName?: string;
|
||||
runBaseline?: boolean;
|
||||
}
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
original_name: string;
|
||||
final_name: string;
|
||||
final_path: string;
|
||||
status: string;
|
||||
baseline_success: boolean | null;
|
||||
readme_generated: boolean;
|
||||
}> {
|
||||
const response = await fetch(
|
||||
`${API_BASE}/intake/${encodeURIComponent(studyName)}/finalize/studio`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
topic: request.topic,
|
||||
new_name: request.newName,
|
||||
run_baseline: request.runBaseline ?? false,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || 'Studio finalization failed');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
|
||||
/**
|
||||
* Get complete draft information for Studio UI.
|
||||
* Convenience endpoint that returns everything the Studio needs.
|
||||
*/
|
||||
async getStudioDraft(studyName: string): Promise<{
|
||||
success: boolean;
|
||||
draft_id: string;
|
||||
spec: Record<string, unknown>;
|
||||
model_files: string[];
|
||||
context_files: string[];
|
||||
introspection_available: boolean;
|
||||
design_variable_count: number;
|
||||
objective_count: number;
|
||||
}> {
|
||||
const response = await fetch(
|
||||
`${API_BASE}/intake/${encodeURIComponent(studyName)}/studio`
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || 'Failed to get studio draft');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
};
|
||||
|
||||
export default intakeApi;
|
||||
@@ -777,6 +777,8 @@ function SpecRendererInner({
|
||||
onConnect={onConnect}
|
||||
onInit={(instance) => {
|
||||
reactFlowInstance.current = instance;
|
||||
// Auto-fit view on init with padding
|
||||
setTimeout(() => instance.fitView({ padding: 0.2, duration: 300 }), 100);
|
||||
}}
|
||||
onDragOver={onDragOver}
|
||||
onDrop={onDrop}
|
||||
@@ -785,6 +787,7 @@ function SpecRendererInner({
|
||||
onPaneClick={onPaneClick}
|
||||
nodeTypes={nodeTypes}
|
||||
fitView
|
||||
fitViewOptions={{ padding: 0.2, includeHiddenNodes: false }}
|
||||
deleteKeyCode={null} // We handle delete ourselves
|
||||
nodesDraggable={editable}
|
||||
nodesConnectable={editable}
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
/**
|
||||
* ContextFileUpload - Upload context files for study configuration
|
||||
*
|
||||
* Allows uploading markdown, text, PDF, and image files that help
|
||||
* Claude understand optimization goals and generate better documentation.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { Upload, FileText, X, Loader2, AlertCircle, CheckCircle, Trash2, BookOpen } from 'lucide-react';
|
||||
import { intakeApi } from '../../api/intake';
|
||||
|
||||
interface ContextFileUploadProps {
|
||||
studyName: string;
|
||||
onUploadComplete: () => void;
|
||||
}
|
||||
|
||||
interface ContextFile {
|
||||
name: string;
|
||||
path: string;
|
||||
size: number;
|
||||
extension: string;
|
||||
}
|
||||
|
||||
interface FileStatus {
|
||||
file: File;
|
||||
status: 'pending' | 'uploading' | 'success' | 'error';
|
||||
message?: string;
|
||||
}
|
||||
|
||||
const VALID_EXTENSIONS = ['.md', '.txt', '.pdf', '.png', '.jpg', '.jpeg', '.json', '.csv'];
|
||||
|
||||
export const ContextFileUpload: React.FC<ContextFileUploadProps> = ({
|
||||
studyName,
|
||||
onUploadComplete,
|
||||
}) => {
|
||||
const [contextFiles, setContextFiles] = useState<ContextFile[]>([]);
|
||||
const [pendingFiles, setPendingFiles] = useState<FileStatus[]>([]);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Load existing context files
|
||||
const loadContextFiles = useCallback(async () => {
|
||||
try {
|
||||
const response = await intakeApi.listContextFiles(studyName);
|
||||
setContextFiles(response.context_files);
|
||||
} catch (err) {
|
||||
console.error('Failed to load context files:', err);
|
||||
}
|
||||
}, [studyName]);
|
||||
|
||||
useEffect(() => {
|
||||
loadContextFiles();
|
||||
}, [loadContextFiles]);
|
||||
|
||||
const validateFile = (file: File): { valid: boolean; reason?: string } => {
|
||||
const ext = '.' + file.name.split('.').pop()?.toLowerCase();
|
||||
if (!VALID_EXTENSIONS.includes(ext)) {
|
||||
return { valid: false, reason: `Invalid type: ${ext}` };
|
||||
}
|
||||
// Max 10MB per file
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
return { valid: false, reason: 'File too large (max 10MB)' };
|
||||
}
|
||||
return { valid: true };
|
||||
};
|
||||
|
||||
const addFiles = useCallback((newFiles: File[]) => {
|
||||
const validFiles: FileStatus[] = [];
|
||||
|
||||
for (const file of newFiles) {
|
||||
// Skip duplicates
|
||||
if (pendingFiles.some(f => f.file.name === file.name)) {
|
||||
continue;
|
||||
}
|
||||
if (contextFiles.some(f => f.name === file.name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const validation = validateFile(file);
|
||||
if (validation.valid) {
|
||||
validFiles.push({ file, status: 'pending' });
|
||||
} else {
|
||||
validFiles.push({ file, status: 'error', message: validation.reason });
|
||||
}
|
||||
}
|
||||
|
||||
setPendingFiles(prev => [...prev, ...validFiles]);
|
||||
}, [pendingFiles, contextFiles]);
|
||||
|
||||
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const selectedFiles = Array.from(e.target.files || []);
|
||||
addFiles(selectedFiles);
|
||||
e.target.value = '';
|
||||
}, [addFiles]);
|
||||
|
||||
const removeFile = (index: number) => {
|
||||
setPendingFiles(prev => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleUpload = async () => {
|
||||
const filesToUpload = pendingFiles.filter(f => f.status === 'pending');
|
||||
if (filesToUpload.length === 0) return;
|
||||
|
||||
setIsUploading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await intakeApi.uploadContextFiles(
|
||||
studyName,
|
||||
filesToUpload.map(f => f.file)
|
||||
);
|
||||
|
||||
// Update pending file statuses
|
||||
const uploadResults = new Map(
|
||||
response.uploaded_files.map(f => [f.name, f.status === 'uploaded'])
|
||||
);
|
||||
|
||||
setPendingFiles(prev => prev.map(f => {
|
||||
if (f.status !== 'pending') return f;
|
||||
const success = uploadResults.get(f.file.name);
|
||||
return {
|
||||
...f,
|
||||
status: success ? 'success' : 'error',
|
||||
message: success ? undefined : 'Upload failed',
|
||||
};
|
||||
}));
|
||||
|
||||
// Refresh and clear after a moment
|
||||
setTimeout(() => {
|
||||
setPendingFiles(prev => prev.filter(f => f.status !== 'success'));
|
||||
loadContextFiles();
|
||||
onUploadComplete();
|
||||
}, 1500);
|
||||
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Upload failed');
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteFile = async (filename: string) => {
|
||||
try {
|
||||
await intakeApi.deleteContextFile(studyName, filename);
|
||||
loadContextFiles();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Delete failed');
|
||||
}
|
||||
};
|
||||
|
||||
const pendingCount = pendingFiles.filter(f => f.status === 'pending').length;
|
||||
|
||||
const formatSize = (bytes: number) => {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h5 className="text-sm font-medium text-dark-300 flex items-center gap-2">
|
||||
<BookOpen className="w-4 h-4 text-purple-400" />
|
||||
Context Files
|
||||
</h5>
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="flex items-center gap-1.5 px-2 py-1 rounded text-xs font-medium
|
||||
bg-purple-500/10 text-purple-400 hover:bg-purple-500/20
|
||||
transition-colors"
|
||||
>
|
||||
<Upload className="w-3 h-3" />
|
||||
Add Context
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-dark-500">
|
||||
Add .md, .txt, or .pdf files describing your optimization goals. Claude will use these to generate documentation.
|
||||
</p>
|
||||
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<div className="p-2 rounded-lg bg-red-500/10 border border-red-500/30 text-red-400 text-xs flex items-center gap-2">
|
||||
<AlertCircle className="w-3 h-3 flex-shrink-0" />
|
||||
{error}
|
||||
<button onClick={() => setError(null)} className="ml-auto hover:text-white">
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Existing Context Files */}
|
||||
{contextFiles.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{contextFiles.map((file) => (
|
||||
<div
|
||||
key={file.name}
|
||||
className="flex items-center justify-between p-2 rounded-lg bg-purple-500/5 border border-purple-500/20"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="w-4 h-4 text-purple-400" />
|
||||
<span className="text-sm text-white">{file.name}</span>
|
||||
<span className="text-xs text-dark-500">{formatSize(file.size)}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleDeleteFile(file.name)}
|
||||
className="p-1 hover:bg-white/10 rounded text-dark-400 hover:text-red-400"
|
||||
title="Delete file"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pending Files */}
|
||||
{pendingFiles.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{pendingFiles.map((f, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`flex items-center justify-between p-2 rounded-lg
|
||||
${f.status === 'error' ? 'bg-red-500/10' :
|
||||
f.status === 'success' ? 'bg-green-500/10' :
|
||||
'bg-dark-700'}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{f.status === 'pending' && <FileText className="w-4 h-4 text-dark-400" />}
|
||||
{f.status === 'uploading' && <Loader2 className="w-4 h-4 text-purple-400 animate-spin" />}
|
||||
{f.status === 'success' && <CheckCircle className="w-4 h-4 text-green-400" />}
|
||||
{f.status === 'error' && <AlertCircle className="w-4 h-4 text-red-400" />}
|
||||
<span className={`text-sm ${f.status === 'error' ? 'text-red-400' :
|
||||
f.status === 'success' ? 'text-green-400' :
|
||||
'text-white'}`}>
|
||||
{f.file.name}
|
||||
</span>
|
||||
{f.message && (
|
||||
<span className="text-xs text-red-400">({f.message})</span>
|
||||
)}
|
||||
</div>
|
||||
{f.status === 'pending' && (
|
||||
<button
|
||||
onClick={() => removeFile(i)}
|
||||
className="p-1 hover:bg-white/10 rounded text-dark-400 hover:text-white"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Upload Button */}
|
||||
{pendingCount > 0 && (
|
||||
<button
|
||||
onClick={handleUpload}
|
||||
disabled={isUploading}
|
||||
className="w-full flex items-center justify-center gap-2 px-3 py-2 rounded-lg
|
||||
bg-purple-500 text-white text-sm font-medium
|
||||
hover:bg-purple-400 disabled:opacity-50 disabled:cursor-not-allowed
|
||||
transition-colors"
|
||||
>
|
||||
{isUploading ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Uploading...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="w-4 h-4" />
|
||||
Upload {pendingCount} {pendingCount === 1 ? 'File' : 'Files'}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept={VALID_EXTENSIONS.join(',')}
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContextFileUpload;
|
||||
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* CreateStudyCard - Card for initiating new study creation
|
||||
*
|
||||
* Displays a prominent card on the Home page that allows users to
|
||||
* create a new study through the intake workflow.
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Plus, Loader2 } from 'lucide-react';
|
||||
import { intakeApi } from '../../api/intake';
|
||||
import { TopicInfo } from '../../types/intake';
|
||||
|
||||
interface CreateStudyCardProps {
|
||||
topics: TopicInfo[];
|
||||
onStudyCreated: (studyName: string) => void;
|
||||
}
|
||||
|
||||
export const CreateStudyCard: React.FC<CreateStudyCardProps> = ({
|
||||
topics,
|
||||
onStudyCreated,
|
||||
}) => {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [studyName, setStudyName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [selectedTopic, setSelectedTopic] = useState('');
|
||||
const [newTopic, setNewTopic] = useState('');
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!studyName.trim()) {
|
||||
setError('Study name is required');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate study name format
|
||||
const nameRegex = /^[a-z0-9_]+$/;
|
||||
if (!nameRegex.test(studyName)) {
|
||||
setError('Study name must be lowercase with underscores only (e.g., my_study_name)');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsCreating(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const topic = newTopic.trim() || selectedTopic || undefined;
|
||||
await intakeApi.createInbox({
|
||||
study_name: studyName.trim(),
|
||||
description: description.trim() || undefined,
|
||||
topic,
|
||||
});
|
||||
|
||||
// Reset form
|
||||
setStudyName('');
|
||||
setDescription('');
|
||||
setSelectedTopic('');
|
||||
setNewTopic('');
|
||||
setIsExpanded(false);
|
||||
|
||||
onStudyCreated(studyName.trim());
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to create study');
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isExpanded) {
|
||||
return (
|
||||
<button
|
||||
onClick={() => setIsExpanded(true)}
|
||||
className="w-full glass rounded-xl p-6 border border-dashed border-primary-400/30
|
||||
hover:border-primary-400/60 hover:bg-primary-400/5 transition-all
|
||||
flex items-center justify-center gap-3 group"
|
||||
>
|
||||
<div className="w-12 h-12 rounded-xl bg-primary-400/10 flex items-center justify-center
|
||||
group-hover:bg-primary-400/20 transition-colors">
|
||||
<Plus className="w-6 h-6 text-primary-400" />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<h3 className="text-lg font-semibold text-white">Create New Study</h3>
|
||||
<p className="text-sm text-dark-400">Set up a new optimization study</p>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="glass-strong rounded-xl border border-primary-400/20 overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="px-6 py-4 border-b border-primary-400/10 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-primary-400/10 flex items-center justify-center">
|
||||
<Plus className="w-5 h-5 text-primary-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-white">Create New Study</h3>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsExpanded(false)}
|
||||
className="text-dark-400 hover:text-white transition-colors text-sm"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<div className="p-6 space-y-4">
|
||||
{/* Study Name */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-dark-300 mb-2">
|
||||
Study Name <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={studyName}
|
||||
onChange={(e) => setStudyName(e.target.value.toLowerCase().replace(/[^a-z0-9_]/g, '_'))}
|
||||
placeholder="my_optimization_study"
|
||||
className="w-full px-4 py-2.5 rounded-lg bg-dark-800 border border-dark-600
|
||||
text-white placeholder-dark-500 focus:border-primary-400
|
||||
focus:outline-none focus:ring-1 focus:ring-primary-400/50"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-dark-500">
|
||||
Lowercase letters, numbers, and underscores only
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-dark-300 mb-2">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Brief description of the optimization goal..."
|
||||
rows={2}
|
||||
className="w-full px-4 py-2.5 rounded-lg bg-dark-800 border border-dark-600
|
||||
text-white placeholder-dark-500 focus:border-primary-400
|
||||
focus:outline-none focus:ring-1 focus:ring-primary-400/50 resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Topic Selection */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-dark-300 mb-2">
|
||||
Topic Folder
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<select
|
||||
value={selectedTopic}
|
||||
onChange={(e) => {
|
||||
setSelectedTopic(e.target.value);
|
||||
setNewTopic('');
|
||||
}}
|
||||
className="flex-1 px-4 py-2.5 rounded-lg bg-dark-800 border border-dark-600
|
||||
text-white focus:border-primary-400 focus:outline-none
|
||||
focus:ring-1 focus:ring-primary-400/50"
|
||||
>
|
||||
<option value="">Select existing topic...</option>
|
||||
{topics.map((topic) => (
|
||||
<option key={topic.name} value={topic.name}>
|
||||
{topic.name} ({topic.study_count} studies)
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="text-dark-500 self-center">or</span>
|
||||
<input
|
||||
type="text"
|
||||
value={newTopic}
|
||||
onChange={(e) => {
|
||||
setNewTopic(e.target.value.replace(/[^A-Za-z0-9_]/g, '_'));
|
||||
setSelectedTopic('');
|
||||
}}
|
||||
placeholder="New_Topic"
|
||||
className="flex-1 px-4 py-2.5 rounded-lg bg-dark-800 border border-dark-600
|
||||
text-white placeholder-dark-500 focus:border-primary-400
|
||||
focus:outline-none focus:ring-1 focus:ring-primary-400/50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="p-3 rounded-lg bg-red-500/10 border border-red-500/30 text-red-400 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<button
|
||||
onClick={() => setIsExpanded(false)}
|
||||
className="px-4 py-2 rounded-lg border border-dark-600 text-dark-300
|
||||
hover:border-dark-500 hover:text-white transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
disabled={isCreating || !studyName.trim()}
|
||||
className="px-6 py-2 rounded-lg font-medium transition-all disabled:opacity-50
|
||||
flex items-center gap-2"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #00d4e6 0%, #0891b2 100%)',
|
||||
color: '#000',
|
||||
}}
|
||||
>
|
||||
{isCreating ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Plus className="w-4 h-4" />
|
||||
Create Study
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateStudyCard;
|
||||
@@ -0,0 +1,270 @@
|
||||
/**
|
||||
* ExpressionList - Display discovered expressions with selection capability
|
||||
*
|
||||
* Shows expressions from NX introspection, allowing users to:
|
||||
* - View all discovered expressions
|
||||
* - See which are design variable candidates (auto-detected)
|
||||
* - Select/deselect expressions to use as design variables
|
||||
* - View expression values and units
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Check,
|
||||
Search,
|
||||
AlertTriangle,
|
||||
Sparkles,
|
||||
Info,
|
||||
Variable,
|
||||
} from 'lucide-react';
|
||||
import { ExpressionInfo } from '../../types/intake';
|
||||
|
||||
interface ExpressionListProps {
|
||||
/** Expression data from introspection */
|
||||
expressions: ExpressionInfo[];
|
||||
/** Mass from introspection (kg) */
|
||||
massKg?: number | null;
|
||||
/** Currently selected expressions (to become DVs) */
|
||||
selectedExpressions: string[];
|
||||
/** Callback when selection changes */
|
||||
onSelectionChange: (selected: string[]) => void;
|
||||
/** Whether in read-only mode */
|
||||
readOnly?: boolean;
|
||||
/** Compact display mode */
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
export const ExpressionList: React.FC<ExpressionListProps> = ({
|
||||
expressions,
|
||||
massKg,
|
||||
selectedExpressions,
|
||||
onSelectionChange,
|
||||
readOnly = false,
|
||||
compact = false,
|
||||
}) => {
|
||||
const [filter, setFilter] = useState('');
|
||||
const [showCandidatesOnly, setShowCandidatesOnly] = useState(true);
|
||||
|
||||
// Filter expressions based on search and candidate toggle
|
||||
const filteredExpressions = expressions.filter((expr) => {
|
||||
const matchesSearch = filter === '' ||
|
||||
expr.name.toLowerCase().includes(filter.toLowerCase());
|
||||
const matchesCandidate = !showCandidatesOnly || expr.is_candidate;
|
||||
return matchesSearch && matchesCandidate;
|
||||
});
|
||||
|
||||
// Sort: candidates first, then by confidence, then alphabetically
|
||||
const sortedExpressions = [...filteredExpressions].sort((a, b) => {
|
||||
if (a.is_candidate !== b.is_candidate) {
|
||||
return a.is_candidate ? -1 : 1;
|
||||
}
|
||||
if (a.confidence !== b.confidence) {
|
||||
return b.confidence - a.confidence;
|
||||
}
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
const toggleExpression = (name: string) => {
|
||||
if (readOnly) return;
|
||||
|
||||
if (selectedExpressions.includes(name)) {
|
||||
onSelectionChange(selectedExpressions.filter(n => n !== name));
|
||||
} else {
|
||||
onSelectionChange([...selectedExpressions, name]);
|
||||
}
|
||||
};
|
||||
|
||||
const selectAllCandidates = () => {
|
||||
const candidateNames = expressions
|
||||
.filter(e => e.is_candidate)
|
||||
.map(e => e.name);
|
||||
onSelectionChange(candidateNames);
|
||||
};
|
||||
|
||||
const clearSelection = () => {
|
||||
onSelectionChange([]);
|
||||
};
|
||||
|
||||
const candidateCount = expressions.filter(e => e.is_candidate).length;
|
||||
|
||||
if (expressions.length === 0) {
|
||||
return (
|
||||
<div className="p-4 rounded-lg bg-dark-700/50 border border-dark-600">
|
||||
<div className="flex items-center gap-2 text-dark-400">
|
||||
<AlertTriangle className="w-4 h-4" />
|
||||
<span>No expressions found. Run introspection to discover model parameters.</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Header with stats */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<h5 className="text-sm font-medium text-dark-300 flex items-center gap-2">
|
||||
<Variable className="w-4 h-4" />
|
||||
Discovered Expressions
|
||||
</h5>
|
||||
<span className="text-xs text-dark-500">
|
||||
{expressions.length} total, {candidateCount} candidates
|
||||
</span>
|
||||
{massKg && (
|
||||
<span className="text-xs text-primary-400">
|
||||
Mass: {massKg.toFixed(3)} kg
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{!readOnly && selectedExpressions.length > 0 && (
|
||||
<span className="text-xs text-green-400">
|
||||
{selectedExpressions.length} selected
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
{!compact && (
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Search */}
|
||||
<div className="relative flex-1 max-w-xs">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-dark-500" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search expressions..."
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
className="w-full pl-8 pr-3 py-1.5 text-sm rounded-lg bg-dark-700 border border-dark-600
|
||||
text-white placeholder-dark-500 focus:border-primary-500/50 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Show candidates only toggle */}
|
||||
<label className="flex items-center gap-2 text-xs text-dark-400 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showCandidatesOnly}
|
||||
onChange={(e) => setShowCandidatesOnly(e.target.checked)}
|
||||
className="w-4 h-4 rounded border-dark-500 bg-dark-700 text-primary-500
|
||||
focus:ring-primary-500/30"
|
||||
/>
|
||||
Candidates only
|
||||
</label>
|
||||
|
||||
{/* Quick actions */}
|
||||
{!readOnly && (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={selectAllCandidates}
|
||||
className="px-2 py-1 text-xs rounded bg-primary-500/10 text-primary-400
|
||||
hover:bg-primary-500/20 transition-colors"
|
||||
>
|
||||
Select all candidates
|
||||
</button>
|
||||
<button
|
||||
onClick={clearSelection}
|
||||
className="px-2 py-1 text-xs rounded bg-dark-600 text-dark-400
|
||||
hover:bg-dark-500 transition-colors"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Expression list */}
|
||||
<div className={`rounded-lg border border-dark-600 overflow-hidden ${
|
||||
compact ? 'max-h-48' : 'max-h-72'
|
||||
} overflow-y-auto`}>
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-dark-700 sticky top-0">
|
||||
<tr>
|
||||
{!readOnly && (
|
||||
<th className="w-8 px-2 py-2"></th>
|
||||
)}
|
||||
<th className="px-3 py-2 text-left text-dark-400 font-medium">Name</th>
|
||||
<th className="px-3 py-2 text-right text-dark-400 font-medium w-24">Value</th>
|
||||
<th className="px-3 py-2 text-left text-dark-400 font-medium w-16">Units</th>
|
||||
<th className="px-3 py-2 text-center text-dark-400 font-medium w-20">Candidate</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-dark-700">
|
||||
{sortedExpressions.map((expr) => {
|
||||
const isSelected = selectedExpressions.includes(expr.name);
|
||||
return (
|
||||
<tr
|
||||
key={expr.name}
|
||||
onClick={() => toggleExpression(expr.name)}
|
||||
className={`
|
||||
${readOnly ? '' : 'cursor-pointer hover:bg-dark-700/50'}
|
||||
${isSelected ? 'bg-primary-500/10' : ''}
|
||||
transition-colors
|
||||
`}
|
||||
>
|
||||
{!readOnly && (
|
||||
<td className="px-2 py-2">
|
||||
<div className={`w-5 h-5 rounded border flex items-center justify-center
|
||||
${isSelected
|
||||
? 'bg-primary-500 border-primary-500'
|
||||
: 'border-dark-500 bg-dark-700'
|
||||
}`}
|
||||
>
|
||||
{isSelected && <Check className="w-3 h-3 text-white" />}
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<code className={`text-xs ${isSelected ? 'text-primary-300' : 'text-white'}`}>
|
||||
{expr.name}
|
||||
</code>
|
||||
{expr.formula && (
|
||||
<span className="text-xs text-dark-500" title={expr.formula}>
|
||||
<Info className="w-3 h-3" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right font-mono text-xs text-dark-300">
|
||||
{expr.value !== null ? expr.value.toFixed(3) : '-'}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-xs text-dark-400">
|
||||
{expr.units || '-'}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
{expr.is_candidate ? (
|
||||
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs
|
||||
bg-green-500/10 text-green-400">
|
||||
<Sparkles className="w-3 h-3" />
|
||||
{Math.round(expr.confidence * 100)}%
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-dark-500">-</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{sortedExpressions.length === 0 && (
|
||||
<div className="px-4 py-8 text-center text-dark-500">
|
||||
No expressions match your filter
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Help text */}
|
||||
{!readOnly && !compact && (
|
||||
<p className="text-xs text-dark-500">
|
||||
Select expressions to use as design variables. Candidates (marked with %) are
|
||||
automatically identified based on naming patterns and units.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExpressionList;
|
||||
@@ -0,0 +1,348 @@
|
||||
/**
|
||||
* FileDropzone - Drag and drop file upload component
|
||||
*
|
||||
* Supports drag-and-drop or click-to-browse for model files.
|
||||
* Accepts .prt, .sim, .fem, .afem files.
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useRef } from 'react';
|
||||
import { Upload, FileText, X, Loader2, AlertCircle, CheckCircle } from 'lucide-react';
|
||||
import { intakeApi } from '../../api/intake';
|
||||
|
||||
interface FileDropzoneProps {
|
||||
studyName: string;
|
||||
onUploadComplete: () => void;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
interface FileStatus {
|
||||
file: File;
|
||||
status: 'pending' | 'uploading' | 'success' | 'error';
|
||||
message?: string;
|
||||
}
|
||||
|
||||
const VALID_EXTENSIONS = ['.prt', '.sim', '.fem', '.afem'];
|
||||
|
||||
export const FileDropzone: React.FC<FileDropzoneProps> = ({
|
||||
studyName,
|
||||
onUploadComplete,
|
||||
compact = false,
|
||||
}) => {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [files, setFiles] = useState<FileStatus[]>([]);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const validateFile = (file: File): { valid: boolean; reason?: string } => {
|
||||
const ext = '.' + file.name.split('.').pop()?.toLowerCase();
|
||||
if (!VALID_EXTENSIONS.includes(ext)) {
|
||||
return { valid: false, reason: `Invalid type: ${ext}` };
|
||||
}
|
||||
// Max 500MB per file
|
||||
if (file.size > 500 * 1024 * 1024) {
|
||||
return { valid: false, reason: 'File too large (max 500MB)' };
|
||||
}
|
||||
return { valid: true };
|
||||
};
|
||||
|
||||
const handleDragEnter = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(true);
|
||||
}, []);
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
}, []);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}, []);
|
||||
|
||||
const addFiles = useCallback((newFiles: File[]) => {
|
||||
const validFiles: FileStatus[] = [];
|
||||
|
||||
for (const file of newFiles) {
|
||||
// Skip duplicates
|
||||
if (files.some(f => f.file.name === file.name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const validation = validateFile(file);
|
||||
if (validation.valid) {
|
||||
validFiles.push({ file, status: 'pending' });
|
||||
} else {
|
||||
validFiles.push({ file, status: 'error', message: validation.reason });
|
||||
}
|
||||
}
|
||||
|
||||
setFiles(prev => [...prev, ...validFiles]);
|
||||
}, [files]);
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
|
||||
const droppedFiles = Array.from(e.dataTransfer.files);
|
||||
addFiles(droppedFiles);
|
||||
}, [addFiles]);
|
||||
|
||||
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const selectedFiles = Array.from(e.target.files || []);
|
||||
addFiles(selectedFiles);
|
||||
// Reset input so the same file can be selected again
|
||||
e.target.value = '';
|
||||
}, [addFiles]);
|
||||
|
||||
const removeFile = (index: number) => {
|
||||
setFiles(prev => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleUpload = async () => {
|
||||
const pendingFiles = files.filter(f => f.status === 'pending');
|
||||
if (pendingFiles.length === 0) return;
|
||||
|
||||
setIsUploading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Upload files
|
||||
const response = await intakeApi.uploadFiles(
|
||||
studyName,
|
||||
pendingFiles.map(f => f.file)
|
||||
);
|
||||
|
||||
// Update file statuses based on response
|
||||
const uploadResults = new Map(
|
||||
response.uploaded_files.map(f => [f.name, f.status === 'uploaded'])
|
||||
);
|
||||
|
||||
setFiles(prev => prev.map(f => {
|
||||
if (f.status !== 'pending') return f;
|
||||
const success = uploadResults.get(f.file.name);
|
||||
return {
|
||||
...f,
|
||||
status: success ? 'success' : 'error',
|
||||
message: success ? undefined : 'Upload failed',
|
||||
};
|
||||
}));
|
||||
|
||||
// Clear successful uploads after a moment and refresh
|
||||
setTimeout(() => {
|
||||
setFiles(prev => prev.filter(f => f.status !== 'success'));
|
||||
onUploadComplete();
|
||||
}, 1500);
|
||||
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Upload failed');
|
||||
setFiles(prev => prev.map(f =>
|
||||
f.status === 'pending'
|
||||
? { ...f, status: 'error', message: 'Upload failed' }
|
||||
: f
|
||||
));
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const pendingCount = files.filter(f => f.status === 'pending').length;
|
||||
|
||||
if (compact) {
|
||||
// Compact inline version
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm font-medium
|
||||
bg-dark-700 text-dark-300 hover:bg-dark-600 hover:text-white
|
||||
transition-colors"
|
||||
>
|
||||
<Upload className="w-4 h-4" />
|
||||
Add Files
|
||||
</button>
|
||||
{pendingCount > 0 && (
|
||||
<button
|
||||
onClick={handleUpload}
|
||||
disabled={isUploading}
|
||||
className="flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm font-medium
|
||||
bg-primary-500/10 text-primary-400 hover:bg-primary-500/20
|
||||
disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{isUploading ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Upload className="w-4 h-4" />
|
||||
)}
|
||||
Upload {pendingCount} {pendingCount === 1 ? 'File' : 'Files'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* File list */}
|
||||
{files.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{files.map((f, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={`inline-flex items-center gap-1.5 px-2 py-1 rounded text-xs
|
||||
${f.status === 'error' ? 'bg-red-500/10 text-red-400' :
|
||||
f.status === 'success' ? 'bg-green-500/10 text-green-400' :
|
||||
'bg-dark-700 text-dark-300'}`}
|
||||
>
|
||||
{f.status === 'uploading' && <Loader2 className="w-3 h-3 animate-spin" />}
|
||||
{f.status === 'success' && <CheckCircle className="w-3 h-3" />}
|
||||
{f.status === 'error' && <AlertCircle className="w-3 h-3" />}
|
||||
{f.file.name}
|
||||
{f.status === 'pending' && (
|
||||
<button onClick={() => removeFile(i)} className="hover:text-white">
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept={VALID_EXTENSIONS.join(',')}
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Full dropzone version
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Dropzone */}
|
||||
<div
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className={`
|
||||
relative border-2 border-dashed rounded-xl p-6 cursor-pointer
|
||||
transition-all duration-200
|
||||
${isDragging
|
||||
? 'border-primary-400 bg-primary-400/5'
|
||||
: 'border-dark-600 hover:border-primary-400/50 hover:bg-white/5'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<div className={`w-12 h-12 rounded-full flex items-center justify-center mb-3
|
||||
${isDragging ? 'bg-primary-400/20 text-primary-400' : 'bg-dark-700 text-dark-400'}`}>
|
||||
<Upload className="w-6 h-6" />
|
||||
</div>
|
||||
<p className="text-white font-medium mb-1">
|
||||
{isDragging ? 'Drop files here' : 'Drop model files here'}
|
||||
</p>
|
||||
<p className="text-sm text-dark-400">
|
||||
or <span className="text-primary-400">click to browse</span>
|
||||
</p>
|
||||
<p className="text-xs text-dark-500 mt-2">
|
||||
Accepts: {VALID_EXTENSIONS.join(', ')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="p-3 rounded-lg bg-red-500/10 border border-red-500/30 text-red-400 text-sm flex items-center gap-2">
|
||||
<AlertCircle className="w-4 h-4 flex-shrink-0" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* File List */}
|
||||
{files.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h5 className="text-sm font-medium text-dark-300">Files to Upload</h5>
|
||||
<div className="space-y-1">
|
||||
{files.map((f, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`flex items-center justify-between p-2 rounded-lg
|
||||
${f.status === 'error' ? 'bg-red-500/10' :
|
||||
f.status === 'success' ? 'bg-green-500/10' :
|
||||
'bg-dark-700'}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{f.status === 'pending' && <FileText className="w-4 h-4 text-dark-400" />}
|
||||
{f.status === 'uploading' && <Loader2 className="w-4 h-4 text-primary-400 animate-spin" />}
|
||||
{f.status === 'success' && <CheckCircle className="w-4 h-4 text-green-400" />}
|
||||
{f.status === 'error' && <AlertCircle className="w-4 h-4 text-red-400" />}
|
||||
<span className={`text-sm ${f.status === 'error' ? 'text-red-400' :
|
||||
f.status === 'success' ? 'text-green-400' :
|
||||
'text-white'}`}>
|
||||
{f.file.name}
|
||||
</span>
|
||||
{f.message && (
|
||||
<span className="text-xs text-red-400">({f.message})</span>
|
||||
)}
|
||||
</div>
|
||||
{f.status === 'pending' && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeFile(i);
|
||||
}}
|
||||
className="p-1 hover:bg-white/10 rounded text-dark-400 hover:text-white"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Upload Button */}
|
||||
{pendingCount > 0 && (
|
||||
<button
|
||||
onClick={handleUpload}
|
||||
disabled={isUploading}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-2 rounded-lg
|
||||
bg-primary-500 text-white font-medium
|
||||
hover:bg-primary-400 disabled:opacity-50 disabled:cursor-not-allowed
|
||||
transition-colors"
|
||||
>
|
||||
{isUploading ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Uploading...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="w-4 h-4" />
|
||||
Upload {pendingCount} {pendingCount === 1 ? 'File' : 'Files'}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept={VALID_EXTENSIONS.join(',')}
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FileDropzone;
|
||||
@@ -0,0 +1,272 @@
|
||||
/**
|
||||
* FinalizeModal - Modal for finalizing an inbox study
|
||||
*
|
||||
* Allows user to:
|
||||
* - Select/create topic folder
|
||||
* - Choose whether to run baseline FEA
|
||||
* - See progress during finalization
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
X,
|
||||
Folder,
|
||||
CheckCircle,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
} from 'lucide-react';
|
||||
import { intakeApi } from '../../api/intake';
|
||||
import { TopicInfo, InboxStudyDetail } from '../../types/intake';
|
||||
|
||||
interface FinalizeModalProps {
|
||||
studyName: string;
|
||||
topics: TopicInfo[];
|
||||
onClose: () => void;
|
||||
onFinalized: (finalPath: string) => void;
|
||||
}
|
||||
|
||||
export const FinalizeModal: React.FC<FinalizeModalProps> = ({
|
||||
studyName,
|
||||
topics,
|
||||
onClose,
|
||||
onFinalized,
|
||||
}) => {
|
||||
const [studyDetail, setStudyDetail] = useState<InboxStudyDetail | null>(null);
|
||||
const [selectedTopic, setSelectedTopic] = useState('');
|
||||
const [newTopic, setNewTopic] = useState('');
|
||||
const [runBaseline, setRunBaseline] = useState(true);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isFinalizing, setIsFinalizing] = useState(false);
|
||||
const [progress, setProgress] = useState<string>('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Load study detail
|
||||
useEffect(() => {
|
||||
const loadStudy = async () => {
|
||||
try {
|
||||
const detail = await intakeApi.getInboxStudy(studyName);
|
||||
setStudyDetail(detail);
|
||||
// Pre-select topic if set in spec
|
||||
if (detail.spec.meta.topic) {
|
||||
setSelectedTopic(detail.spec.meta.topic);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load study');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
loadStudy();
|
||||
}, [studyName]);
|
||||
|
||||
const handleFinalize = async () => {
|
||||
const topic = newTopic.trim() || selectedTopic;
|
||||
if (!topic) {
|
||||
setError('Please select or create a topic folder');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsFinalizing(true);
|
||||
setError(null);
|
||||
setProgress('Starting finalization...');
|
||||
|
||||
try {
|
||||
setProgress('Validating study configuration...');
|
||||
await new Promise((r) => setTimeout(r, 500)); // Visual feedback
|
||||
|
||||
if (runBaseline) {
|
||||
setProgress('Running baseline FEA solve...');
|
||||
}
|
||||
|
||||
const result = await intakeApi.finalize(studyName, {
|
||||
topic,
|
||||
run_baseline: runBaseline,
|
||||
});
|
||||
|
||||
setProgress('Finalization complete!');
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
|
||||
onFinalized(result.final_path);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Finalization failed');
|
||||
setIsFinalizing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-dark-900/80 backdrop-blur-sm">
|
||||
<div className="w-full max-w-lg glass-strong rounded-xl border border-primary-400/20 overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="px-6 py-4 border-b border-primary-400/10 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-primary-400/10 flex items-center justify-center">
|
||||
<Folder className="w-5 h-5 text-primary-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white">Finalize Study</h3>
|
||||
<p className="text-sm text-dark-400">{studyName}</p>
|
||||
</div>
|
||||
</div>
|
||||
{!isFinalizing && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 hover:bg-white/5 rounded-lg transition-colors text-dark-400 hover:text-white"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 space-y-6">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-primary-400" />
|
||||
</div>
|
||||
) : isFinalizing ? (
|
||||
/* Progress View */
|
||||
<div className="text-center py-8 space-y-4">
|
||||
<Loader2 className="w-12 h-12 animate-spin text-primary-400 mx-auto" />
|
||||
<p className="text-white font-medium">{progress}</p>
|
||||
<p className="text-sm text-dark-400">
|
||||
Please wait while your study is being finalized...
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<div className="p-3 rounded-lg bg-red-500/10 border border-red-500/30 text-red-400 text-sm flex items-center gap-2">
|
||||
<AlertCircle className="w-4 h-4 flex-shrink-0" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Study Summary */}
|
||||
{studyDetail && (
|
||||
<div className="p-4 rounded-lg bg-dark-800 space-y-2">
|
||||
<h4 className="text-sm font-medium text-dark-300">Study Summary</h4>
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-dark-500">Status:</span>
|
||||
<span className="ml-2 text-white capitalize">
|
||||
{studyDetail.spec.meta.status}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-dark-500">Model Files:</span>
|
||||
<span className="ml-2 text-white">
|
||||
{studyDetail.files.sim.length + studyDetail.files.prt.length + studyDetail.files.fem.length}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-dark-500">Design Variables:</span>
|
||||
<span className="ml-2 text-white">
|
||||
{studyDetail.spec.design_variables?.length || 0}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-dark-500">Objectives:</span>
|
||||
<span className="ml-2 text-white">
|
||||
{studyDetail.spec.objectives?.length || 0}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Topic Selection */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-dark-300 mb-2">
|
||||
Topic Folder <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<select
|
||||
value={selectedTopic}
|
||||
onChange={(e) => {
|
||||
setSelectedTopic(e.target.value);
|
||||
setNewTopic('');
|
||||
}}
|
||||
className="flex-1 px-4 py-2.5 rounded-lg bg-dark-800 border border-dark-600
|
||||
text-white focus:border-primary-400 focus:outline-none
|
||||
focus:ring-1 focus:ring-primary-400/50"
|
||||
>
|
||||
<option value="">Select existing topic...</option>
|
||||
{topics.map((topic) => (
|
||||
<option key={topic.name} value={topic.name}>
|
||||
{topic.name} ({topic.study_count} studies)
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="text-dark-500 self-center">or</span>
|
||||
<input
|
||||
type="text"
|
||||
value={newTopic}
|
||||
onChange={(e) => {
|
||||
setNewTopic(e.target.value.replace(/[^A-Za-z0-9_]/g, '_'));
|
||||
setSelectedTopic('');
|
||||
}}
|
||||
placeholder="New_Topic"
|
||||
className="flex-1 px-4 py-2.5 rounded-lg bg-dark-800 border border-dark-600
|
||||
text-white placeholder-dark-500 focus:border-primary-400
|
||||
focus:outline-none focus:ring-1 focus:ring-primary-400/50"
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-dark-500">
|
||||
Study will be created at: studies/{newTopic || selectedTopic || '<topic>'}/{studyName}/
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Baseline Option */}
|
||||
<div>
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={runBaseline}
|
||||
onChange={(e) => setRunBaseline(e.target.checked)}
|
||||
className="w-4 h-4 rounded border-dark-600 bg-dark-800 text-primary-400
|
||||
focus:ring-primary-400/50"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-white font-medium">Run baseline FEA solve</span>
|
||||
<p className="text-xs text-dark-500">
|
||||
Validates the model and captures baseline performance metrics
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
{!isLoading && !isFinalizing && (
|
||||
<div className="px-6 py-4 border-t border-primary-400/10 flex justify-end gap-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 rounded-lg border border-dark-600 text-dark-300
|
||||
hover:border-dark-500 hover:text-white transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleFinalize}
|
||||
disabled={!selectedTopic && !newTopic.trim()}
|
||||
className="px-6 py-2 rounded-lg font-medium transition-all disabled:opacity-50
|
||||
flex items-center gap-2"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #00d4e6 0%, #0891b2 100%)',
|
||||
color: '#000',
|
||||
}}
|
||||
>
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
Finalize Study
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FinalizeModal;
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* InboxSection - Section displaying inbox studies on Home page
|
||||
*
|
||||
* Shows the "Create New Study" card and lists all inbox studies
|
||||
* with their current status and available actions.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Inbox, RefreshCw, ChevronDown, ChevronRight } from 'lucide-react';
|
||||
import { intakeApi } from '../../api/intake';
|
||||
import { InboxStudy, TopicInfo } from '../../types/intake';
|
||||
import { CreateStudyCard } from './CreateStudyCard';
|
||||
import { InboxStudyCard } from './InboxStudyCard';
|
||||
import { FinalizeModal } from './FinalizeModal';
|
||||
|
||||
interface InboxSectionProps {
|
||||
onStudyFinalized?: () => void;
|
||||
}
|
||||
|
||||
export const InboxSection: React.FC<InboxSectionProps> = ({ onStudyFinalized }) => {
|
||||
const [inboxStudies, setInboxStudies] = useState<InboxStudy[]>([]);
|
||||
const [topics, setTopics] = useState<TopicInfo[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isExpanded, setIsExpanded] = useState(true);
|
||||
const [selectedStudyForFinalize, setSelectedStudyForFinalize] = useState<string | null>(null);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const [inboxResponse, topicsResponse] = await Promise.all([
|
||||
intakeApi.listInbox(),
|
||||
intakeApi.listTopics(),
|
||||
]);
|
||||
setInboxStudies(inboxResponse.studies);
|
||||
setTopics(topicsResponse.topics);
|
||||
} catch (err) {
|
||||
console.error('Failed to load inbox data:', err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
const handleStudyCreated = (_studyName: string) => {
|
||||
loadData();
|
||||
};
|
||||
|
||||
const handleStudyFinalized = (_finalPath: string) => {
|
||||
setSelectedStudyForFinalize(null);
|
||||
loadData();
|
||||
onStudyFinalized?.();
|
||||
};
|
||||
|
||||
const pendingStudies = inboxStudies.filter(
|
||||
(s) => !['ready', 'running', 'completed'].includes(s.status)
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Section Header */}
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="w-full flex items-center justify-between px-2 py-1 hover:bg-white/5 rounded-lg transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-primary-400/10 flex items-center justify-center">
|
||||
<Inbox className="w-4 h-4 text-primary-400" />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<h2 className="text-lg font-semibold text-white">Study Inbox</h2>
|
||||
<p className="text-sm text-dark-400">
|
||||
{pendingStudies.length} pending studies
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
loadData();
|
||||
}}
|
||||
className="p-2 hover:bg-white/5 rounded-lg transition-colors text-dark-400 hover:text-primary-400"
|
||||
title="Refresh"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="w-5 h-5 text-dark-400" />
|
||||
) : (
|
||||
<ChevronRight className="w-5 h-5 text-dark-400" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Content */}
|
||||
{isExpanded && (
|
||||
<div className="space-y-4">
|
||||
{/* Create Study Card */}
|
||||
<CreateStudyCard topics={topics} onStudyCreated={handleStudyCreated} />
|
||||
|
||||
{/* Inbox Studies List */}
|
||||
{inboxStudies.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-medium text-dark-400 px-2">
|
||||
Inbox Studies ({inboxStudies.length})
|
||||
</h3>
|
||||
{inboxStudies.map((study) => (
|
||||
<InboxStudyCard
|
||||
key={study.study_name}
|
||||
study={study}
|
||||
onRefresh={loadData}
|
||||
onSelect={setSelectedStudyForFinalize}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{!isLoading && inboxStudies.length === 0 && (
|
||||
<div className="text-center py-8 text-dark-400">
|
||||
<Inbox className="w-12 h-12 mx-auto mb-3 opacity-30" />
|
||||
<p>No studies in inbox</p>
|
||||
<p className="text-sm text-dark-500">
|
||||
Create a new study to get started
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Finalize Modal */}
|
||||
{selectedStudyForFinalize && (
|
||||
<FinalizeModal
|
||||
studyName={selectedStudyForFinalize}
|
||||
topics={topics}
|
||||
onClose={() => setSelectedStudyForFinalize(null)}
|
||||
onFinalized={handleStudyFinalized}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default InboxSection;
|
||||
@@ -0,0 +1,455 @@
|
||||
/**
|
||||
* InboxStudyCard - Card displaying an inbox study with actions
|
||||
*
|
||||
* Shows study status, files, and provides actions for:
|
||||
* - Running introspection
|
||||
* - Generating README
|
||||
* - Finalizing the study
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
FileText,
|
||||
Folder,
|
||||
Trash2,
|
||||
Play,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
AlertCircle,
|
||||
Loader2,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Sparkles,
|
||||
ArrowRight,
|
||||
Eye,
|
||||
Save,
|
||||
} from 'lucide-react';
|
||||
import { InboxStudy, SpecStatus, ExpressionInfo, InboxStudyDetail } from '../../types/intake';
|
||||
import { intakeApi } from '../../api/intake';
|
||||
import { FileDropzone } from './FileDropzone';
|
||||
import { ContextFileUpload } from './ContextFileUpload';
|
||||
import { ExpressionList } from './ExpressionList';
|
||||
|
||||
interface InboxStudyCardProps {
|
||||
study: InboxStudy;
|
||||
onRefresh: () => void;
|
||||
onSelect: (studyName: string) => void;
|
||||
}
|
||||
|
||||
const statusConfig: Record<SpecStatus, { icon: React.ReactNode; color: string; label: string }> = {
|
||||
draft: {
|
||||
icon: <Clock className="w-4 h-4" />,
|
||||
color: 'text-dark-400 bg-dark-600',
|
||||
label: 'Draft',
|
||||
},
|
||||
introspected: {
|
||||
icon: <CheckCircle className="w-4 h-4" />,
|
||||
color: 'text-blue-400 bg-blue-500/10',
|
||||
label: 'Introspected',
|
||||
},
|
||||
configured: {
|
||||
icon: <CheckCircle className="w-4 h-4" />,
|
||||
color: 'text-green-400 bg-green-500/10',
|
||||
label: 'Configured',
|
||||
},
|
||||
validated: {
|
||||
icon: <CheckCircle className="w-4 h-4" />,
|
||||
color: 'text-green-400 bg-green-500/10',
|
||||
label: 'Validated',
|
||||
},
|
||||
ready: {
|
||||
icon: <CheckCircle className="w-4 h-4" />,
|
||||
color: 'text-primary-400 bg-primary-500/10',
|
||||
label: 'Ready',
|
||||
},
|
||||
running: {
|
||||
icon: <Play className="w-4 h-4" />,
|
||||
color: 'text-yellow-400 bg-yellow-500/10',
|
||||
label: 'Running',
|
||||
},
|
||||
completed: {
|
||||
icon: <CheckCircle className="w-4 h-4" />,
|
||||
color: 'text-green-400 bg-green-500/10',
|
||||
label: 'Completed',
|
||||
},
|
||||
failed: {
|
||||
icon: <AlertCircle className="w-4 h-4" />,
|
||||
color: 'text-red-400 bg-red-500/10',
|
||||
label: 'Failed',
|
||||
},
|
||||
};
|
||||
|
||||
export const InboxStudyCard: React.FC<InboxStudyCardProps> = ({
|
||||
study,
|
||||
onRefresh,
|
||||
onSelect,
|
||||
}) => {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [isIntrospecting, setIsIntrospecting] = useState(false);
|
||||
const [isGeneratingReadme, setIsGeneratingReadme] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Introspection data (fetched when expanded)
|
||||
const [studyDetail, setStudyDetail] = useState<InboxStudyDetail | null>(null);
|
||||
const [isLoadingDetail, setIsLoadingDetail] = useState(false);
|
||||
const [selectedExpressions, setSelectedExpressions] = useState<string[]>([]);
|
||||
const [showReadme, setShowReadme] = useState(false);
|
||||
const [readmeContent, setReadmeContent] = useState<string | null>(null);
|
||||
const [isSavingDVs, setIsSavingDVs] = useState(false);
|
||||
const [dvSaveMessage, setDvSaveMessage] = useState<string | null>(null);
|
||||
|
||||
const status = statusConfig[study.status] || statusConfig.draft;
|
||||
|
||||
// Fetch study details when expanded for the first time
|
||||
useEffect(() => {
|
||||
if (isExpanded && !studyDetail && !isLoadingDetail) {
|
||||
loadStudyDetail();
|
||||
}
|
||||
}, [isExpanded]);
|
||||
|
||||
const loadStudyDetail = async () => {
|
||||
setIsLoadingDetail(true);
|
||||
try {
|
||||
const detail = await intakeApi.getInboxStudy(study.study_name);
|
||||
setStudyDetail(detail);
|
||||
|
||||
// Auto-select candidate expressions
|
||||
const introspection = detail.spec?.model?.introspection;
|
||||
if (introspection?.expressions) {
|
||||
const candidates = introspection.expressions
|
||||
.filter((e: ExpressionInfo) => e.is_candidate)
|
||||
.map((e: ExpressionInfo) => e.name);
|
||||
setSelectedExpressions(candidates);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load study detail:', err);
|
||||
} finally {
|
||||
setIsLoadingDetail(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleIntrospect = async () => {
|
||||
setIsIntrospecting(true);
|
||||
setError(null);
|
||||
try {
|
||||
await intakeApi.introspect({ study_name: study.study_name });
|
||||
// Reload study detail to get new introspection data
|
||||
await loadStudyDetail();
|
||||
onRefresh();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Introspection failed');
|
||||
} finally {
|
||||
setIsIntrospecting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerateReadme = async () => {
|
||||
setIsGeneratingReadme(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await intakeApi.generateReadme(study.study_name);
|
||||
setReadmeContent(response.content);
|
||||
setShowReadme(true);
|
||||
onRefresh();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'README generation failed');
|
||||
} finally {
|
||||
setIsGeneratingReadme(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!confirm(`Delete inbox study "${study.study_name}"? This cannot be undone.`)) {
|
||||
return;
|
||||
}
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
await intakeApi.deleteInboxStudy(study.study_name);
|
||||
onRefresh();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Delete failed');
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveDesignVariables = async () => {
|
||||
if (selectedExpressions.length === 0) {
|
||||
setError('Please select at least one expression to use as a design variable');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSavingDVs(true);
|
||||
setError(null);
|
||||
setDvSaveMessage(null);
|
||||
|
||||
try {
|
||||
const result = await intakeApi.createDesignVariables(study.study_name, selectedExpressions);
|
||||
setDvSaveMessage(`Created ${result.total_created} design variable(s)`);
|
||||
// Reload study detail to see updated spec
|
||||
await loadStudyDetail();
|
||||
onRefresh();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to save design variables');
|
||||
} finally {
|
||||
setIsSavingDVs(false);
|
||||
}
|
||||
};
|
||||
|
||||
const canIntrospect = study.status === 'draft' && study.model_files.length > 0;
|
||||
const canGenerateReadme = study.status === 'introspected';
|
||||
const canFinalize = ['introspected', 'configured'].includes(study.status);
|
||||
const canSaveDVs = study.status === 'introspected' && selectedExpressions.length > 0;
|
||||
|
||||
return (
|
||||
<div className="glass rounded-xl border border-primary-400/10 overflow-hidden">
|
||||
{/* Header - Always visible */}
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="w-full px-4 py-3 flex items-center justify-between hover:bg-white/5 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-dark-700 flex items-center justify-center">
|
||||
<Folder className="w-5 h-5 text-primary-400" />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<h4 className="text-white font-medium">{study.study_name}</h4>
|
||||
{study.description && (
|
||||
<p className="text-sm text-dark-400 truncate max-w-[300px]">
|
||||
{study.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Status Badge */}
|
||||
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium ${status.color}`}>
|
||||
{status.icon}
|
||||
{status.label}
|
||||
</span>
|
||||
{/* File Count */}
|
||||
<span className="text-dark-500 text-sm">
|
||||
{study.model_files.length} files
|
||||
</span>
|
||||
{/* Expand Icon */}
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="w-4 h-4 text-dark-400" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4 text-dark-400" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Expanded Content */}
|
||||
{isExpanded && (
|
||||
<div className="px-4 pb-4 space-y-4 border-t border-primary-400/10 pt-4">
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<div className="p-3 rounded-lg bg-red-500/10 border border-red-500/30 text-red-400 text-sm flex items-center gap-2">
|
||||
<AlertCircle className="w-4 h-4 flex-shrink-0" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Success Message */}
|
||||
{dvSaveMessage && (
|
||||
<div className="p-3 rounded-lg bg-green-500/10 border border-green-500/30 text-green-400 text-sm flex items-center gap-2">
|
||||
<CheckCircle className="w-4 h-4 flex-shrink-0" />
|
||||
{dvSaveMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Files Section */}
|
||||
{study.model_files.length > 0 && (
|
||||
<div>
|
||||
<h5 className="text-sm font-medium text-dark-300 mb-2">Model Files</h5>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{study.model_files.map((file) => (
|
||||
<span
|
||||
key={file}
|
||||
className="inline-flex items-center gap-1.5 px-2 py-1 rounded bg-dark-700 text-dark-300 text-xs"
|
||||
>
|
||||
<FileText className="w-3 h-3" />
|
||||
{file}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Model File Upload Section */}
|
||||
<div>
|
||||
<h5 className="text-sm font-medium text-dark-300 mb-2">Upload Model Files</h5>
|
||||
<FileDropzone
|
||||
studyName={study.study_name}
|
||||
onUploadComplete={onRefresh}
|
||||
compact={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Context File Upload Section */}
|
||||
<ContextFileUpload
|
||||
studyName={study.study_name}
|
||||
onUploadComplete={onRefresh}
|
||||
/>
|
||||
|
||||
{/* Introspection Results - Expressions */}
|
||||
{isLoadingDetail && (
|
||||
<div className="flex items-center gap-2 text-dark-400 text-sm py-4">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Loading introspection data...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{studyDetail?.spec?.model?.introspection?.expressions &&
|
||||
studyDetail.spec.model.introspection.expressions.length > 0 && (
|
||||
<ExpressionList
|
||||
expressions={studyDetail.spec.model.introspection.expressions}
|
||||
massKg={studyDetail.spec.model.introspection.mass_kg}
|
||||
selectedExpressions={selectedExpressions}
|
||||
onSelectionChange={setSelectedExpressions}
|
||||
readOnly={study.status === 'configured'}
|
||||
compact={true}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* README Preview Section */}
|
||||
{(readmeContent || study.status === 'configured') && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<h5 className="text-sm font-medium text-dark-300 flex items-center gap-2">
|
||||
<FileText className="w-4 h-4" />
|
||||
README.md
|
||||
</h5>
|
||||
<button
|
||||
onClick={() => setShowReadme(!showReadme)}
|
||||
className="flex items-center gap-1 px-2 py-1 text-xs rounded bg-dark-600
|
||||
text-dark-300 hover:bg-dark-500 transition-colors"
|
||||
>
|
||||
<Eye className="w-3 h-3" />
|
||||
{showReadme ? 'Hide' : 'Preview'}
|
||||
</button>
|
||||
</div>
|
||||
{showReadme && readmeContent && (
|
||||
<div className="max-h-64 overflow-y-auto rounded-lg border border-dark-600
|
||||
bg-dark-800 p-4">
|
||||
<pre className="text-xs text-dark-300 whitespace-pre-wrap font-mono">
|
||||
{readmeContent}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No Files Warning */}
|
||||
{study.model_files.length === 0 && (
|
||||
<div className="p-3 rounded-lg bg-yellow-500/10 border border-yellow-500/30 text-yellow-400 text-sm flex items-center gap-2">
|
||||
<AlertCircle className="w-4 h-4 flex-shrink-0" />
|
||||
No model files found. Upload .prt, .sim, or .fem files to continue.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{/* Introspect */}
|
||||
{canIntrospect && (
|
||||
<button
|
||||
onClick={handleIntrospect}
|
||||
disabled={isIntrospecting}
|
||||
className="flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm font-medium
|
||||
bg-blue-500/10 text-blue-400 hover:bg-blue-500/20
|
||||
disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{isIntrospecting ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Play className="w-4 h-4" />
|
||||
)}
|
||||
Introspect Model
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Save Design Variables */}
|
||||
{canSaveDVs && (
|
||||
<button
|
||||
onClick={handleSaveDesignVariables}
|
||||
disabled={isSavingDVs}
|
||||
className="flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm font-medium
|
||||
bg-green-500/10 text-green-400 hover:bg-green-500/20
|
||||
disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{isSavingDVs ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="w-4 h-4" />
|
||||
)}
|
||||
Save as DVs ({selectedExpressions.length})
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Generate README */}
|
||||
{canGenerateReadme && (
|
||||
<button
|
||||
onClick={handleGenerateReadme}
|
||||
disabled={isGeneratingReadme}
|
||||
className="flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm font-medium
|
||||
bg-purple-500/10 text-purple-400 hover:bg-purple-500/20
|
||||
disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{isGeneratingReadme ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Sparkles className="w-4 h-4" />
|
||||
)}
|
||||
Generate README
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Finalize */}
|
||||
{canFinalize && (
|
||||
<button
|
||||
onClick={() => onSelect(study.study_name)}
|
||||
className="flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm font-medium
|
||||
bg-primary-500/10 text-primary-400 hover:bg-primary-500/20
|
||||
transition-colors"
|
||||
>
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
Finalize Study
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Delete */}
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={isDeleting}
|
||||
className="flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm font-medium
|
||||
bg-red-500/10 text-red-400 hover:bg-red-500/20
|
||||
disabled:opacity-50 transition-colors ml-auto"
|
||||
>
|
||||
{isDeleting ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="w-4 h-4" />
|
||||
)}
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Workflow Hint */}
|
||||
{study.status === 'draft' && study.model_files.length > 0 && (
|
||||
<p className="text-xs text-dark-500">
|
||||
Next step: Run introspection to discover expressions and model properties.
|
||||
</p>
|
||||
)}
|
||||
{study.status === 'introspected' && (
|
||||
<p className="text-xs text-dark-500">
|
||||
Next step: Generate README with Claude AI, then finalize to create the study.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default InboxStudyCard;
|
||||
13
atomizer-dashboard/frontend/src/components/intake/index.ts
Normal file
13
atomizer-dashboard/frontend/src/components/intake/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Intake Components Index
|
||||
*
|
||||
* Export all intake workflow components.
|
||||
*/
|
||||
|
||||
export { CreateStudyCard } from './CreateStudyCard';
|
||||
export { InboxStudyCard } from './InboxStudyCard';
|
||||
export { FinalizeModal } from './FinalizeModal';
|
||||
export { InboxSection } from './InboxSection';
|
||||
export { FileDropzone } from './FileDropzone';
|
||||
export { ContextFileUpload } from './ContextFileUpload';
|
||||
export { ExpressionList } from './ExpressionList';
|
||||
@@ -0,0 +1,254 @@
|
||||
/**
|
||||
* StudioBuildDialog - Final dialog to name and build the study
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { X, Loader2, FolderOpen, AlertCircle, CheckCircle, Sparkles, Play } from 'lucide-react';
|
||||
import { intakeApi } from '../../api/intake';
|
||||
|
||||
interface StudioBuildDialogProps {
|
||||
draftId: string;
|
||||
onClose: () => void;
|
||||
onBuildComplete: (finalPath: string, finalName: string) => void;
|
||||
}
|
||||
|
||||
interface Topic {
|
||||
name: string;
|
||||
study_count: number;
|
||||
}
|
||||
|
||||
export const StudioBuildDialog: React.FC<StudioBuildDialogProps> = ({
|
||||
draftId,
|
||||
onClose,
|
||||
onBuildComplete,
|
||||
}) => {
|
||||
const [studyName, setStudyName] = useState('');
|
||||
const [topic, setTopic] = useState('');
|
||||
const [newTopic, setNewTopic] = useState('');
|
||||
const [useNewTopic, setUseNewTopic] = useState(false);
|
||||
const [topics, setTopics] = useState<Topic[]>([]);
|
||||
const [isBuilding, setIsBuilding] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [validationErrors, setValidationErrors] = useState<string[]>([]);
|
||||
|
||||
// Load topics
|
||||
useEffect(() => {
|
||||
loadTopics();
|
||||
}, []);
|
||||
|
||||
const loadTopics = async () => {
|
||||
try {
|
||||
const response = await intakeApi.listTopics();
|
||||
setTopics(response.topics);
|
||||
if (response.topics.length > 0) {
|
||||
setTopic(response.topics[0].name);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load topics:', err);
|
||||
}
|
||||
};
|
||||
|
||||
// Validate study name
|
||||
useEffect(() => {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (studyName.length > 0) {
|
||||
if (studyName.length < 3) {
|
||||
errors.push('Name must be at least 3 characters');
|
||||
}
|
||||
if (!/^[a-z0-9_]+$/.test(studyName)) {
|
||||
errors.push('Use only lowercase letters, numbers, and underscores');
|
||||
}
|
||||
if (studyName.startsWith('draft_')) {
|
||||
errors.push('Name cannot start with "draft_"');
|
||||
}
|
||||
}
|
||||
|
||||
setValidationErrors(errors);
|
||||
}, [studyName]);
|
||||
|
||||
const handleBuild = async () => {
|
||||
const finalTopic = useNewTopic ? newTopic : topic;
|
||||
|
||||
if (!studyName || !finalTopic) {
|
||||
setError('Please provide both a study name and topic');
|
||||
return;
|
||||
}
|
||||
|
||||
if (validationErrors.length > 0) {
|
||||
setError('Please fix validation errors');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsBuilding(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await intakeApi.finalizeStudio(draftId, {
|
||||
topic: finalTopic,
|
||||
newName: studyName,
|
||||
runBaseline: false,
|
||||
});
|
||||
|
||||
onBuildComplete(response.final_path, response.final_name);
|
||||
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Build failed');
|
||||
} finally {
|
||||
setIsBuilding(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isValid = studyName.length >= 3 &&
|
||||
validationErrors.length === 0 &&
|
||||
(topic || (useNewTopic && newTopic));
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-dark-850 border border-dark-700 rounded-xl shadow-xl w-full max-w-lg mx-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-dark-700">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="w-5 h-5 text-primary-400" />
|
||||
<h2 className="text-lg font-semibold text-white">Build Study</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 hover:bg-dark-700 rounded text-dark-400 hover:text-white transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Study Name */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-dark-300 mb-2">
|
||||
Study Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={studyName}
|
||||
onChange={(e) => setStudyName(e.target.value.toLowerCase().replace(/[^a-z0-9_]/g, '_'))}
|
||||
placeholder="my_optimization_study"
|
||||
className="w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-white placeholder-dark-500 focus:outline-none focus:border-primary-400"
|
||||
/>
|
||||
{validationErrors.length > 0 && (
|
||||
<div className="mt-2 space-y-1">
|
||||
{validationErrors.map((err, i) => (
|
||||
<p key={i} className="text-xs text-red-400 flex items-center gap-1">
|
||||
<AlertCircle className="w-3 h-3" />
|
||||
{err}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{studyName.length >= 3 && validationErrors.length === 0 && (
|
||||
<p className="mt-2 text-xs text-green-400 flex items-center gap-1">
|
||||
<CheckCircle className="w-3 h-3" />
|
||||
Name is valid
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Topic Selection */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-dark-300 mb-2">
|
||||
Topic Folder
|
||||
</label>
|
||||
|
||||
{!useNewTopic && topics.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<select
|
||||
value={topic}
|
||||
onChange={(e) => setTopic(e.target.value)}
|
||||
className="w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-white focus:outline-none focus:border-primary-400"
|
||||
>
|
||||
{topics.map((t) => (
|
||||
<option key={t.name} value={t.name}>
|
||||
{t.name} ({t.study_count} studies)
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
onClick={() => setUseNewTopic(true)}
|
||||
className="text-sm text-primary-400 hover:text-primary-300"
|
||||
>
|
||||
+ Create new topic
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(useNewTopic || topics.length === 0) && (
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newTopic}
|
||||
onChange={(e) => setNewTopic(e.target.value.replace(/[^A-Za-z0-9_]/g, '_'))}
|
||||
placeholder="NewTopic"
|
||||
className="w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-white placeholder-dark-500 focus:outline-none focus:border-primary-400"
|
||||
/>
|
||||
{topics.length > 0 && (
|
||||
<button
|
||||
onClick={() => setUseNewTopic(false)}
|
||||
className="text-sm text-dark-400 hover:text-white"
|
||||
>
|
||||
Use existing topic
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Preview */}
|
||||
<div className="p-3 bg-dark-700/50 rounded-lg">
|
||||
<p className="text-xs text-dark-400 mb-1">Study will be created at:</p>
|
||||
<p className="text-sm text-white font-mono flex items-center gap-2">
|
||||
<FolderOpen className="w-4 h-4 text-primary-400" />
|
||||
studies/{useNewTopic ? newTopic || '...' : topic}/{studyName || '...'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="p-3 rounded-lg bg-red-500/10 border border-red-500/30 text-red-400 text-sm flex items-center gap-2">
|
||||
<AlertCircle className="w-4 h-4 flex-shrink-0" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-3 p-4 border-t border-dark-700">
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={isBuilding}
|
||||
className="px-4 py-2 text-sm text-dark-300 hover:text-white hover:bg-dark-700 rounded-lg transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleBuild}
|
||||
disabled={!isValid || isBuilding}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm font-medium bg-primary-500 text-white rounded-lg hover:bg-primary-400 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{isBuilding ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Building...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="w-4 h-4" />
|
||||
Build Study
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StudioBuildDialog;
|
||||
375
atomizer-dashboard/frontend/src/components/studio/StudioChat.tsx
Normal file
375
atomizer-dashboard/frontend/src/components/studio/StudioChat.tsx
Normal file
@@ -0,0 +1,375 @@
|
||||
/**
|
||||
* StudioChat - Context-aware AI chat for Studio
|
||||
*
|
||||
* Uses the existing useChat hook to communicate with Claude via WebSocket.
|
||||
* Injects model files and context documents into the conversation.
|
||||
*/
|
||||
|
||||
import React, { useRef, useEffect, useState, useMemo } from 'react';
|
||||
import { Send, Loader2, Sparkles, FileText, Wifi, WifiOff, Bot, User, File, AlertCircle } from 'lucide-react';
|
||||
import { useChat } from '../../hooks/useChat';
|
||||
import { useSpecStore, useSpec } from '../../hooks/useSpecStore';
|
||||
import { MarkdownRenderer } from '../MarkdownRenderer';
|
||||
import { ToolCallCard } from '../chat/ToolCallCard';
|
||||
|
||||
interface StudioChatProps {
|
||||
draftId: string;
|
||||
contextFiles: string[];
|
||||
contextContent: string;
|
||||
modelFiles: string[];
|
||||
onSpecUpdated: () => void;
|
||||
}
|
||||
|
||||
export const StudioChat: React.FC<StudioChatProps> = ({
|
||||
draftId,
|
||||
contextFiles,
|
||||
contextContent,
|
||||
modelFiles,
|
||||
onSpecUpdated,
|
||||
}) => {
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const [input, setInput] = useState('');
|
||||
const [hasInjectedContext, setHasInjectedContext] = useState(false);
|
||||
|
||||
// Get spec store for canvas updates
|
||||
const spec = useSpec();
|
||||
const { reloadSpec, setSpecFromWebSocket } = useSpecStore();
|
||||
|
||||
// Build canvas state with full context for Claude
|
||||
const canvasState = useMemo(() => ({
|
||||
nodes: [],
|
||||
edges: [],
|
||||
studyName: draftId,
|
||||
studyPath: `_inbox/${draftId}`,
|
||||
// Include file info for Claude context
|
||||
modelFiles,
|
||||
contextFiles,
|
||||
contextContent: contextContent.substring(0, 50000), // Limit context size
|
||||
}), [draftId, modelFiles, contextFiles, contextContent]);
|
||||
|
||||
// Use the chat hook with WebSocket
|
||||
// Power mode gives Claude write permissions to modify the spec
|
||||
const {
|
||||
messages,
|
||||
isThinking,
|
||||
error,
|
||||
isConnected,
|
||||
sendMessage,
|
||||
updateCanvasState,
|
||||
} = useChat({
|
||||
studyId: draftId,
|
||||
mode: 'power', // Power mode = --dangerously-skip-permissions = can write files
|
||||
useWebSocket: true,
|
||||
canvasState,
|
||||
onError: (err) => console.error('[StudioChat] Error:', err),
|
||||
onSpecUpdated: (newSpec) => {
|
||||
// Claude modified the spec - update the store directly
|
||||
console.log('[StudioChat] Spec updated by Claude');
|
||||
setSpecFromWebSocket(newSpec, draftId);
|
||||
onSpecUpdated();
|
||||
},
|
||||
onCanvasModification: (modification) => {
|
||||
// Claude wants to modify canvas - reload the spec
|
||||
console.log('[StudioChat] Canvas modification:', modification);
|
||||
reloadSpec();
|
||||
onSpecUpdated();
|
||||
},
|
||||
});
|
||||
|
||||
// Update canvas state when context changes
|
||||
useEffect(() => {
|
||||
updateCanvasState(canvasState);
|
||||
}, [canvasState, updateCanvasState]);
|
||||
|
||||
// Scroll to bottom when messages change
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages]);
|
||||
|
||||
// Auto-focus input
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
// Build context summary for display
|
||||
const contextSummary = useMemo(() => {
|
||||
const parts: string[] = [];
|
||||
if (modelFiles.length > 0) {
|
||||
parts.push(`${modelFiles.length} model file${modelFiles.length > 1 ? 's' : ''}`);
|
||||
}
|
||||
if (contextFiles.length > 0) {
|
||||
parts.push(`${contextFiles.length} context doc${contextFiles.length > 1 ? 's' : ''}`);
|
||||
}
|
||||
if (contextContent) {
|
||||
parts.push(`${contextContent.length.toLocaleString()} chars context`);
|
||||
}
|
||||
return parts.join(', ');
|
||||
}, [modelFiles, contextFiles, contextContent]);
|
||||
|
||||
const handleSend = () => {
|
||||
if (!input.trim() || isThinking) return;
|
||||
|
||||
let messageToSend = input.trim();
|
||||
|
||||
// On first message, inject full context so Claude has everything it needs
|
||||
if (!hasInjectedContext && (modelFiles.length > 0 || contextContent)) {
|
||||
const contextParts: string[] = [];
|
||||
|
||||
// Add model files info
|
||||
if (modelFiles.length > 0) {
|
||||
contextParts.push(`**Model Files Uploaded:**\n${modelFiles.map(f => `- ${f}`).join('\n')}`);
|
||||
}
|
||||
|
||||
// Add context document content (full text)
|
||||
if (contextContent) {
|
||||
contextParts.push(`**Context Documents Content:**\n\`\`\`\n${contextContent.substring(0, 30000)}\n\`\`\``);
|
||||
}
|
||||
|
||||
// Add current spec state
|
||||
if (spec) {
|
||||
const dvCount = spec.design_variables?.length || 0;
|
||||
const objCount = spec.objectives?.length || 0;
|
||||
const extCount = spec.extractors?.length || 0;
|
||||
if (dvCount > 0 || objCount > 0 || extCount > 0) {
|
||||
contextParts.push(`**Current Configuration:** ${dvCount} design variables, ${objCount} objectives, ${extCount} extractors`);
|
||||
}
|
||||
}
|
||||
|
||||
if (contextParts.length > 0) {
|
||||
messageToSend = `${contextParts.join('\n\n')}\n\n---\n\n**User Request:** ${messageToSend}`;
|
||||
}
|
||||
|
||||
setHasInjectedContext(true);
|
||||
}
|
||||
|
||||
sendMessage(messageToSend);
|
||||
setInput('');
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
};
|
||||
|
||||
// Welcome message for empty state
|
||||
const showWelcome = messages.length === 0;
|
||||
|
||||
// Check if we have any context
|
||||
const hasContext = modelFiles.length > 0 || contextContent.length > 0;
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="p-3 border-b border-dark-700 flex-shrink-0">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="w-5 h-5 text-primary-400" />
|
||||
<span className="font-medium text-white">Studio Assistant</span>
|
||||
</div>
|
||||
<span className={`flex items-center gap-1 text-xs px-2 py-0.5 rounded ${
|
||||
isConnected
|
||||
? 'text-green-400 bg-green-400/10'
|
||||
: 'text-red-400 bg-red-400/10'
|
||||
}`}>
|
||||
{isConnected ? <Wifi className="w-3 h-3" /> : <WifiOff className="w-3 h-3" />}
|
||||
{isConnected ? 'Connected' : 'Disconnected'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Context indicator */}
|
||||
{contextSummary && (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<div className="flex items-center gap-1 text-amber-400 bg-amber-400/10 px-2 py-1 rounded">
|
||||
<FileText className="w-3 h-3" />
|
||||
<span>{contextSummary}</span>
|
||||
</div>
|
||||
{hasContext && !hasInjectedContext && (
|
||||
<span className="text-dark-500">Will be sent with first message</span>
|
||||
)}
|
||||
{hasInjectedContext && (
|
||||
<span className="text-green-500">Context sent</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div className="flex-1 overflow-y-auto p-3 space-y-4">
|
||||
{/* Welcome message with context awareness */}
|
||||
{showWelcome && (
|
||||
<div className="flex gap-3">
|
||||
<div className="flex-shrink-0 w-8 h-8 rounded-lg flex items-center justify-center bg-primary-500/20 text-primary-400">
|
||||
<Bot className="w-4 h-4" />
|
||||
</div>
|
||||
<div className="flex-1 bg-dark-700 rounded-lg px-4 py-3 text-sm text-dark-100">
|
||||
<MarkdownRenderer content={hasContext
|
||||
? `I can see you've uploaded files. Here's what I have access to:
|
||||
|
||||
${modelFiles.length > 0 ? `**Model Files:** ${modelFiles.join(', ')}` : ''}
|
||||
${contextContent ? `\n**Context Document:** ${contextContent.substring(0, 200)}...` : ''}
|
||||
|
||||
Tell me what you want to optimize and I'll help you configure the study!`
|
||||
: `Welcome to Atomizer Studio! I'm here to help you configure your optimization study.
|
||||
|
||||
**What I can do:**
|
||||
- Read your uploaded context documents
|
||||
- Help set up design variables, objectives, and constraints
|
||||
- Create extractors for physics outputs
|
||||
- Suggest optimization strategies
|
||||
|
||||
Upload your model files and any requirements documents, then tell me what you want to optimize!`} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* File context display (only if we have files but no messages yet) */}
|
||||
{showWelcome && modelFiles.length > 0 && (
|
||||
<div className="bg-dark-800/50 rounded-lg p-3 border border-dark-700">
|
||||
<p className="text-xs text-dark-400 mb-2 font-medium">Loaded Files:</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{modelFiles.map((file, idx) => (
|
||||
<span key={idx} className="flex items-center gap-1 text-xs bg-blue-500/10 text-blue-400 px-2 py-1 rounded">
|
||||
<File className="w-3 h-3" />
|
||||
{file}
|
||||
</span>
|
||||
))}
|
||||
{contextFiles.map((file, idx) => (
|
||||
<span key={idx} className="flex items-center gap-1 text-xs bg-amber-500/10 text-amber-400 px-2 py-1 rounded">
|
||||
<FileText className="w-3 h-3" />
|
||||
{file}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Chat messages */}
|
||||
{messages.map((msg) => {
|
||||
const isAssistant = msg.role === 'assistant';
|
||||
const isSystem = msg.role === 'system';
|
||||
|
||||
// System messages
|
||||
if (isSystem) {
|
||||
return (
|
||||
<div key={msg.id} className="flex justify-center my-2">
|
||||
<div className="px-3 py-1 bg-dark-700/50 rounded-full text-xs text-dark-400 border border-dark-600">
|
||||
{msg.content}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={msg.id}
|
||||
className={`flex gap-3 ${isAssistant ? '' : 'flex-row-reverse'}`}
|
||||
>
|
||||
{/* Avatar */}
|
||||
<div
|
||||
className={`flex-shrink-0 w-8 h-8 rounded-lg flex items-center justify-center ${
|
||||
isAssistant
|
||||
? 'bg-primary-500/20 text-primary-400'
|
||||
: 'bg-dark-600 text-dark-300'
|
||||
}`}
|
||||
>
|
||||
{isAssistant ? <Bot className="w-4 h-4" /> : <User className="w-4 h-4" />}
|
||||
</div>
|
||||
|
||||
{/* Message content */}
|
||||
<div
|
||||
className={`flex-1 max-w-[85%] rounded-lg px-4 py-3 text-sm ${
|
||||
isAssistant
|
||||
? 'bg-dark-700 text-dark-100'
|
||||
: 'bg-primary-500 text-white ml-auto'
|
||||
}`}
|
||||
>
|
||||
{isAssistant ? (
|
||||
<>
|
||||
{msg.content && <MarkdownRenderer content={msg.content} />}
|
||||
{msg.isStreaming && !msg.content && (
|
||||
<span className="text-dark-400">Thinking...</span>
|
||||
)}
|
||||
{/* Tool calls */}
|
||||
{msg.toolCalls && msg.toolCalls.length > 0 && (
|
||||
<div className="mt-3 space-y-2">
|
||||
{msg.toolCalls.map((tool, idx) => (
|
||||
<ToolCallCard key={idx} toolCall={tool} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className="whitespace-pre-wrap">{msg.content}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Thinking indicator */}
|
||||
{isThinking && messages.length > 0 && !messages[messages.length - 1]?.isStreaming && (
|
||||
<div className="flex gap-3">
|
||||
<div className="flex-shrink-0 w-8 h-8 rounded-lg flex items-center justify-center bg-primary-500/20 text-primary-400">
|
||||
<Bot className="w-4 h-4" />
|
||||
</div>
|
||||
<div className="bg-dark-700 rounded-lg px-4 py-3 flex items-center gap-2">
|
||||
<Loader2 className="w-4 h-4 text-primary-400 animate-spin" />
|
||||
<span className="text-sm text-dark-300">Thinking...</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error display */}
|
||||
{error && (
|
||||
<div className="flex gap-3">
|
||||
<div className="flex-shrink-0 w-8 h-8 rounded-lg flex items-center justify-center bg-red-500/20 text-red-400">
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
</div>
|
||||
<div className="flex-1 px-4 py-3 bg-red-500/10 rounded-lg text-sm text-red-400 border border-red-500/30">
|
||||
{error}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<div className="p-3 border-t border-dark-700 flex-shrink-0">
|
||||
<div className="flex gap-2">
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={isConnected ? "Ask about your optimization..." : "Connecting..."}
|
||||
disabled={!isConnected}
|
||||
rows={1}
|
||||
className="flex-1 bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-sm text-white placeholder-dark-400 resize-none focus:outline-none focus:border-primary-400 disabled:opacity-50"
|
||||
/>
|
||||
<button
|
||||
onClick={handleSend}
|
||||
disabled={!input.trim() || isThinking || !isConnected}
|
||||
className="p-2 bg-primary-500 text-white rounded-lg hover:bg-primary-400 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{isThinking ? (
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
) : (
|
||||
<Send className="w-5 h-5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{!isConnected && (
|
||||
<p className="text-xs text-dark-500 mt-1">
|
||||
Waiting for connection to Claude...
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StudioChat;
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* StudioContextFiles - Context document upload and display
|
||||
*/
|
||||
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { FileText, Upload, Trash2, Loader2 } from 'lucide-react';
|
||||
import { intakeApi } from '../../api/intake';
|
||||
|
||||
interface StudioContextFilesProps {
|
||||
draftId: string;
|
||||
files: string[];
|
||||
onUploadComplete: () => void;
|
||||
}
|
||||
|
||||
export const StudioContextFiles: React.FC<StudioContextFilesProps> = ({
|
||||
draftId,
|
||||
files,
|
||||
onUploadComplete,
|
||||
}) => {
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [deleting, setDeleting] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const VALID_EXTENSIONS = ['.md', '.txt', '.pdf', '.json', '.csv', '.docx'];
|
||||
|
||||
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const selectedFiles = Array.from(e.target.files || []);
|
||||
if (selectedFiles.length === 0) return;
|
||||
|
||||
e.target.value = '';
|
||||
setIsUploading(true);
|
||||
|
||||
try {
|
||||
await intakeApi.uploadContextFiles(draftId, selectedFiles);
|
||||
onUploadComplete();
|
||||
} catch (err) {
|
||||
console.error('Failed to upload context files:', err);
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteFile = async (filename: string) => {
|
||||
setDeleting(filename);
|
||||
|
||||
try {
|
||||
await intakeApi.deleteContextFile(draftId, filename);
|
||||
onUploadComplete();
|
||||
} catch (err) {
|
||||
console.error('Failed to delete context file:', err);
|
||||
} finally {
|
||||
setDeleting(null);
|
||||
}
|
||||
};
|
||||
|
||||
const getFileIcon = (_filename: string) => {
|
||||
return <FileText className="w-3.5 h-3.5 text-amber-400" />;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{/* File List */}
|
||||
{files.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{files.map((name) => (
|
||||
<div
|
||||
key={name}
|
||||
className="flex items-center gap-2 px-2 py-1.5 rounded bg-dark-700/50 text-sm group"
|
||||
>
|
||||
{getFileIcon(name)}
|
||||
<span className="text-dark-200 truncate flex-1">{name}</span>
|
||||
<button
|
||||
onClick={() => deleteFile(name)}
|
||||
disabled={deleting === name}
|
||||
className="p-1 opacity-0 group-hover:opacity-100 hover:bg-red-500/20 rounded text-red-400 transition-all"
|
||||
>
|
||||
{deleting === name ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="w-3 h-3" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Upload Button */}
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={isUploading}
|
||||
className="w-full flex items-center justify-center gap-2 px-3 py-2 rounded-lg
|
||||
border border-dashed border-dark-600 text-dark-400 text-sm
|
||||
hover:border-primary-400/50 hover:text-primary-400 hover:bg-primary-400/5
|
||||
disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{isUploading ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Upload className="w-4 h-4" />
|
||||
)}
|
||||
{isUploading ? 'Uploading...' : 'Add context files'}
|
||||
</button>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept={VALID_EXTENSIONS.join(',')}
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StudioContextFiles;
|
||||
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* StudioDropZone - Smart file drop zone for Studio
|
||||
*
|
||||
* Handles both model files (.sim, .prt, .fem) and context files (.pdf, .md, .txt)
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useRef } from 'react';
|
||||
import { Upload, X, Loader2, AlertCircle, CheckCircle, File } from 'lucide-react';
|
||||
import { intakeApi } from '../../api/intake';
|
||||
|
||||
interface StudioDropZoneProps {
|
||||
draftId: string;
|
||||
type: 'model' | 'context';
|
||||
files: string[];
|
||||
onUploadComplete: () => void;
|
||||
}
|
||||
|
||||
interface FileStatus {
|
||||
file: File;
|
||||
status: 'pending' | 'uploading' | 'success' | 'error';
|
||||
message?: string;
|
||||
}
|
||||
|
||||
const MODEL_EXTENSIONS = ['.prt', '.sim', '.fem', '.afem'];
|
||||
const CONTEXT_EXTENSIONS = ['.md', '.txt', '.pdf', '.json', '.csv', '.docx'];
|
||||
|
||||
export const StudioDropZone: React.FC<StudioDropZoneProps> = ({
|
||||
draftId,
|
||||
type,
|
||||
files,
|
||||
onUploadComplete,
|
||||
}) => {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [pendingFiles, setPendingFiles] = useState<FileStatus[]>([]);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const validExtensions = type === 'model' ? MODEL_EXTENSIONS : CONTEXT_EXTENSIONS;
|
||||
|
||||
const validateFile = (file: File): { valid: boolean; reason?: string } => {
|
||||
const ext = '.' + file.name.split('.').pop()?.toLowerCase();
|
||||
if (!validExtensions.includes(ext)) {
|
||||
return { valid: false, reason: `Invalid type: ${ext}` };
|
||||
}
|
||||
if (file.size > 500 * 1024 * 1024) {
|
||||
return { valid: false, reason: 'File too large (max 500MB)' };
|
||||
}
|
||||
return { valid: true };
|
||||
};
|
||||
|
||||
const handleDragEnter = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(true);
|
||||
}, []);
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
}, []);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}, []);
|
||||
|
||||
const addFiles = useCallback((newFiles: File[]) => {
|
||||
const validFiles: FileStatus[] = [];
|
||||
|
||||
for (const file of newFiles) {
|
||||
if (pendingFiles.some(f => f.file.name === file.name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const validation = validateFile(file);
|
||||
validFiles.push({
|
||||
file,
|
||||
status: validation.valid ? 'pending' : 'error',
|
||||
message: validation.reason,
|
||||
});
|
||||
}
|
||||
|
||||
setPendingFiles(prev => [...prev, ...validFiles]);
|
||||
}, [pendingFiles, validExtensions]);
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
addFiles(Array.from(e.dataTransfer.files));
|
||||
}, [addFiles]);
|
||||
|
||||
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
addFiles(Array.from(e.target.files || []));
|
||||
e.target.value = '';
|
||||
}, [addFiles]);
|
||||
|
||||
const removeFile = (index: number) => {
|
||||
setPendingFiles(prev => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const uploadFiles = async () => {
|
||||
const toUpload = pendingFiles.filter(f => f.status === 'pending');
|
||||
if (toUpload.length === 0) return;
|
||||
|
||||
setIsUploading(true);
|
||||
|
||||
try {
|
||||
const uploadFn = type === 'model'
|
||||
? intakeApi.uploadFiles
|
||||
: intakeApi.uploadContextFiles;
|
||||
|
||||
const response = await uploadFn(draftId, toUpload.map(f => f.file));
|
||||
|
||||
const results = new Map(
|
||||
response.uploaded_files.map(f => [f.name, f.status === 'uploaded'])
|
||||
);
|
||||
|
||||
setPendingFiles(prev => prev.map(f => {
|
||||
if (f.status !== 'pending') return f;
|
||||
const success = results.get(f.file.name);
|
||||
return {
|
||||
...f,
|
||||
status: success ? 'success' : 'error',
|
||||
message: success ? undefined : 'Upload failed',
|
||||
};
|
||||
}));
|
||||
|
||||
setTimeout(() => {
|
||||
setPendingFiles(prev => prev.filter(f => f.status !== 'success'));
|
||||
onUploadComplete();
|
||||
}, 1000);
|
||||
|
||||
} catch (err) {
|
||||
setPendingFiles(prev => prev.map(f =>
|
||||
f.status === 'pending'
|
||||
? { ...f, status: 'error', message: 'Upload failed' }
|
||||
: f
|
||||
));
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Auto-upload when files are added
|
||||
React.useEffect(() => {
|
||||
const pending = pendingFiles.filter(f => f.status === 'pending');
|
||||
if (pending.length > 0 && !isUploading) {
|
||||
uploadFiles();
|
||||
}
|
||||
}, [pendingFiles, isUploading]);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{/* Drop Zone */}
|
||||
<div
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className={`
|
||||
relative border-2 border-dashed rounded-lg p-4 cursor-pointer
|
||||
transition-all duration-200 text-center
|
||||
${isDragging
|
||||
? 'border-primary-400 bg-primary-400/5'
|
||||
: 'border-dark-600 hover:border-primary-400/50 hover:bg-white/5'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center mx-auto mb-2
|
||||
${isDragging ? 'bg-primary-400/20 text-primary-400' : 'bg-dark-700 text-dark-400'}`}>
|
||||
<Upload className="w-4 h-4" />
|
||||
</div>
|
||||
<p className="text-sm text-dark-300">
|
||||
{isDragging ? 'Drop files here' : 'Drop or click to add'}
|
||||
</p>
|
||||
<p className="text-xs text-dark-500 mt-1">
|
||||
{validExtensions.join(', ')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Existing Files */}
|
||||
{files.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{files.map((name, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center gap-2 px-2 py-1.5 rounded bg-dark-700/50 text-sm"
|
||||
>
|
||||
<File className="w-3.5 h-3.5 text-dark-400" />
|
||||
<span className="text-dark-200 truncate flex-1">{name}</span>
|
||||
<CheckCircle className="w-3.5 h-3.5 text-green-400" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pending Files */}
|
||||
{pendingFiles.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{pendingFiles.map((f, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`flex items-center gap-2 px-2 py-1.5 rounded text-sm
|
||||
${f.status === 'error' ? 'bg-red-500/10' :
|
||||
f.status === 'success' ? 'bg-green-500/10' : 'bg-dark-700'}`}
|
||||
>
|
||||
{f.status === 'pending' && <Loader2 className="w-3.5 h-3.5 text-primary-400 animate-spin" />}
|
||||
{f.status === 'uploading' && <Loader2 className="w-3.5 h-3.5 text-primary-400 animate-spin" />}
|
||||
{f.status === 'success' && <CheckCircle className="w-3.5 h-3.5 text-green-400" />}
|
||||
{f.status === 'error' && <AlertCircle className="w-3.5 h-3.5 text-red-400" />}
|
||||
<span className={`truncate flex-1 ${f.status === 'error' ? 'text-red-400' : 'text-dark-200'}`}>
|
||||
{f.file.name}
|
||||
</span>
|
||||
{f.message && (
|
||||
<span className="text-xs text-red-400">({f.message})</span>
|
||||
)}
|
||||
{f.status === 'pending' && (
|
||||
<button onClick={(e) => { e.stopPropagation(); removeFile(i); }} className="p-0.5 hover:bg-white/10 rounded">
|
||||
<X className="w-3 h-3 text-dark-400" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept={validExtensions.join(',')}
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StudioDropZone;
|
||||
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* StudioParameterList - Display and add discovered parameters as design variables
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Plus, Check, SlidersHorizontal, Loader2 } from 'lucide-react';
|
||||
import { intakeApi } from '../../api/intake';
|
||||
|
||||
interface Expression {
|
||||
name: string;
|
||||
value: number | null;
|
||||
units: string | null;
|
||||
is_candidate: boolean;
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
interface StudioParameterListProps {
|
||||
draftId: string;
|
||||
onParameterAdded: () => void;
|
||||
}
|
||||
|
||||
export const StudioParameterList: React.FC<StudioParameterListProps> = ({
|
||||
draftId,
|
||||
onParameterAdded,
|
||||
}) => {
|
||||
const [expressions, setExpressions] = useState<Expression[]>([]);
|
||||
const [addedParams, setAddedParams] = useState<Set<string>>(new Set());
|
||||
const [adding, setAdding] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Load expressions from spec introspection
|
||||
useEffect(() => {
|
||||
loadExpressions();
|
||||
}, [draftId]);
|
||||
|
||||
const loadExpressions = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await intakeApi.getStudioDraft(draftId);
|
||||
const introspection = (data.spec as any)?.model?.introspection;
|
||||
|
||||
if (introspection?.expressions) {
|
||||
setExpressions(introspection.expressions);
|
||||
|
||||
// Check which are already added as DVs
|
||||
const existingDVs = new Set<string>(
|
||||
((data.spec as any)?.design_variables || []).map((dv: any) => dv.expression_name as string)
|
||||
);
|
||||
setAddedParams(existingDVs);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load expressions:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const addAsDesignVariable = async (expressionName: string) => {
|
||||
setAdding(expressionName);
|
||||
|
||||
try {
|
||||
await intakeApi.createDesignVariables(draftId, [expressionName]);
|
||||
setAddedParams(prev => new Set([...prev, expressionName]));
|
||||
onParameterAdded();
|
||||
} catch (err) {
|
||||
console.error('Failed to add design variable:', err);
|
||||
} finally {
|
||||
setAdding(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Sort: candidates first, then by confidence
|
||||
const sortedExpressions = [...expressions].sort((a, b) => {
|
||||
if (a.is_candidate !== b.is_candidate) {
|
||||
return b.is_candidate ? 1 : -1;
|
||||
}
|
||||
return (b.confidence || 0) - (a.confidence || 0);
|
||||
});
|
||||
|
||||
// Show only candidates by default, with option to show all
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
const displayExpressions = showAll
|
||||
? sortedExpressions
|
||||
: sortedExpressions.filter(e => e.is_candidate);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<Loader2 className="w-5 h-5 text-primary-400 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (expressions.length === 0) {
|
||||
return (
|
||||
<p className="text-xs text-dark-500 italic py-2">
|
||||
No expressions found. Try running introspection.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const candidateCount = expressions.filter(e => e.is_candidate).length;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{/* Header with toggle */}
|
||||
<div className="flex items-center justify-between text-xs text-dark-400">
|
||||
<span>{candidateCount} candidates</span>
|
||||
<button
|
||||
onClick={() => setShowAll(!showAll)}
|
||||
className="hover:text-primary-400 transition-colors"
|
||||
>
|
||||
{showAll ? 'Show candidates only' : `Show all (${expressions.length})`}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Parameter List */}
|
||||
<div className="space-y-1 max-h-48 overflow-y-auto">
|
||||
{displayExpressions.map((expr) => {
|
||||
const isAdded = addedParams.has(expr.name);
|
||||
const isAdding = adding === expr.name;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={expr.name}
|
||||
className={`flex items-center gap-2 px-2 py-1.5 rounded text-sm
|
||||
${isAdded ? 'bg-green-500/10' : 'bg-dark-700/50 hover:bg-dark-700'}
|
||||
transition-colors`}
|
||||
>
|
||||
<SlidersHorizontal className="w-3.5 h-3.5 text-dark-400 flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className={`block truncate ${isAdded ? 'text-green-400' : 'text-dark-200'}`}>
|
||||
{expr.name}
|
||||
</span>
|
||||
{expr.value !== null && (
|
||||
<span className="text-xs text-dark-500">
|
||||
= {expr.value}{expr.units ? ` ${expr.units}` : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isAdded ? (
|
||||
<Check className="w-4 h-4 text-green-400 flex-shrink-0" />
|
||||
) : (
|
||||
<button
|
||||
onClick={() => addAsDesignVariable(expr.name)}
|
||||
disabled={isAdding}
|
||||
className="p-1 hover:bg-primary-400/20 rounded text-primary-400 transition-colors disabled:opacity-50"
|
||||
title="Add as design variable"
|
||||
>
|
||||
{isAdding ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{displayExpressions.length === 0 && (
|
||||
<p className="text-xs text-dark-500 italic py-2">
|
||||
No candidate parameters found. Click "Show all" to see all expressions.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StudioParameterList;
|
||||
11
atomizer-dashboard/frontend/src/components/studio/index.ts
Normal file
11
atomizer-dashboard/frontend/src/components/studio/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Studio Components Index
|
||||
*
|
||||
* Export all Studio-related components.
|
||||
*/
|
||||
|
||||
export { StudioDropZone } from './StudioDropZone';
|
||||
export { StudioParameterList } from './StudioParameterList';
|
||||
export { StudioContextFiles } from './StudioContextFiles';
|
||||
export { StudioChat } from './StudioChat';
|
||||
export { StudioBuildDialog } from './StudioBuildDialog';
|
||||
@@ -18,12 +18,15 @@ import {
|
||||
FolderOpen,
|
||||
Maximize2,
|
||||
X,
|
||||
Layers
|
||||
Layers,
|
||||
Sparkles,
|
||||
Settings2
|
||||
} from 'lucide-react';
|
||||
import { useStudy } from '../context/StudyContext';
|
||||
import { Study } from '../types';
|
||||
import { apiClient } from '../api/client';
|
||||
import { MarkdownRenderer } from '../components/MarkdownRenderer';
|
||||
import { InboxSection } from '../components/intake';
|
||||
|
||||
const Home: React.FC = () => {
|
||||
const { studies, setSelectedStudy, refreshStudies, isLoading } = useStudy();
|
||||
@@ -174,6 +177,18 @@ const Home: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => navigate('/studio')}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg transition-all font-medium hover:-translate-y-0.5"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)',
|
||||
color: '#000',
|
||||
boxShadow: '0 4px 15px rgba(245, 158, 11, 0.3)'
|
||||
}}
|
||||
>
|
||||
<Sparkles className="w-4 h-4" />
|
||||
New Study
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigate('/canvas')}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg transition-all font-medium hover:-translate-y-0.5"
|
||||
@@ -250,6 +265,11 @@ const Home: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Inbox Section - Study Creation Workflow */}
|
||||
<div className="mb-8">
|
||||
<InboxSection onStudyFinalized={refreshStudies} />
|
||||
</div>
|
||||
|
||||
{/* Two-column layout: Table + Preview */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Study Table */}
|
||||
@@ -407,6 +427,19 @@ const Home: React.FC = () => {
|
||||
<Layers className="w-4 h-4" />
|
||||
Canvas
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigate(`/studio/${selectedPreview.id}`)}
|
||||
className="flex items-center gap-2 px-4 py-2.5 rounded-lg transition-all font-medium whitespace-nowrap hover:-translate-y-0.5"
|
||||
style={{
|
||||
background: 'rgba(8, 15, 26, 0.85)',
|
||||
border: '1px solid rgba(245, 158, 11, 0.3)',
|
||||
color: '#f59e0b'
|
||||
}}
|
||||
title="Edit study configuration with AI assistant"
|
||||
>
|
||||
<Settings2 className="w-4 h-4" />
|
||||
Studio
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleSelectStudy(selectedPreview)}
|
||||
className="flex items-center gap-2 px-5 py-2.5 rounded-lg transition-all font-semibold whitespace-nowrap hover:-translate-y-0.5"
|
||||
|
||||
672
atomizer-dashboard/frontend/src/pages/Studio.tsx
Normal file
672
atomizer-dashboard/frontend/src/pages/Studio.tsx
Normal file
@@ -0,0 +1,672 @@
|
||||
/**
|
||||
* Atomizer Studio - Unified Study Creation Environment
|
||||
*
|
||||
* A drag-and-drop workspace for creating optimization studies with:
|
||||
* - File upload (models + context documents)
|
||||
* - Visual canvas configuration
|
||||
* - AI-powered assistance
|
||||
* - One-click build to final study
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import {
|
||||
Home,
|
||||
ChevronRight,
|
||||
Upload,
|
||||
FileText,
|
||||
Settings,
|
||||
Sparkles,
|
||||
Save,
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
MessageSquare,
|
||||
Layers,
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
Loader2,
|
||||
X,
|
||||
ChevronLeft,
|
||||
ChevronRight as ChevronRightIcon,
|
||||
GripVertical,
|
||||
} from 'lucide-react';
|
||||
import { intakeApi } from '../api/intake';
|
||||
import { SpecRenderer } from '../components/canvas/SpecRenderer';
|
||||
import { NodePalette } from '../components/canvas/palette/NodePalette';
|
||||
import { NodeConfigPanelV2 } from '../components/canvas/panels/NodeConfigPanelV2';
|
||||
import { useSpecStore, useSpec, useSpecLoading } from '../hooks/useSpecStore';
|
||||
import { StudioDropZone } from '../components/studio/StudioDropZone';
|
||||
import { StudioParameterList } from '../components/studio/StudioParameterList';
|
||||
import { StudioContextFiles } from '../components/studio/StudioContextFiles';
|
||||
import { StudioChat } from '../components/studio/StudioChat';
|
||||
import { StudioBuildDialog } from '../components/studio/StudioBuildDialog';
|
||||
|
||||
interface DraftState {
|
||||
draftId: string | null;
|
||||
status: 'idle' | 'creating' | 'ready' | 'error';
|
||||
error: string | null;
|
||||
modelFiles: string[];
|
||||
contextFiles: string[];
|
||||
contextContent: string;
|
||||
introspectionAvailable: boolean;
|
||||
designVariableCount: number;
|
||||
objectiveCount: number;
|
||||
}
|
||||
|
||||
export default function Studio() {
|
||||
const navigate = useNavigate();
|
||||
const { draftId: urlDraftId } = useParams<{ draftId: string }>();
|
||||
|
||||
// Draft state
|
||||
const [draft, setDraft] = useState<DraftState>({
|
||||
draftId: null,
|
||||
status: 'idle',
|
||||
error: null,
|
||||
modelFiles: [],
|
||||
contextFiles: [],
|
||||
contextContent: '',
|
||||
introspectionAvailable: false,
|
||||
designVariableCount: 0,
|
||||
objectiveCount: 0,
|
||||
});
|
||||
|
||||
// UI state
|
||||
const [leftPanelWidth, setLeftPanelWidth] = useState(320);
|
||||
const [rightPanelCollapsed, setRightPanelCollapsed] = useState(false);
|
||||
const [showBuildDialog, setShowBuildDialog] = useState(false);
|
||||
const [isIntrospecting, setIsIntrospecting] = useState(false);
|
||||
const [notification, setNotification] = useState<{ type: 'success' | 'error' | 'info'; message: string } | null>(null);
|
||||
|
||||
// Resize state
|
||||
const isResizing = useRef(false);
|
||||
const minPanelWidth = 280;
|
||||
const maxPanelWidth = 500;
|
||||
|
||||
// Spec store for canvas
|
||||
const spec = useSpec();
|
||||
const specLoading = useSpecLoading();
|
||||
const { loadSpec, clearSpec } = useSpecStore();
|
||||
|
||||
// Handle panel resize
|
||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
isResizing.current = true;
|
||||
document.body.style.cursor = 'col-resize';
|
||||
document.body.style.userSelect = 'none';
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!isResizing.current) return;
|
||||
const newWidth = Math.min(maxPanelWidth, Math.max(minPanelWidth, e.clientX));
|
||||
setLeftPanelWidth(newWidth);
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
isResizing.current = false;
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Initialize or load draft on mount
|
||||
useEffect(() => {
|
||||
if (urlDraftId) {
|
||||
loadDraft(urlDraftId);
|
||||
} else {
|
||||
createNewDraft();
|
||||
}
|
||||
|
||||
return () => {
|
||||
// Cleanup: clear spec when leaving Studio
|
||||
clearSpec();
|
||||
};
|
||||
}, [urlDraftId]);
|
||||
|
||||
// Create a new draft
|
||||
const createNewDraft = async () => {
|
||||
setDraft(prev => ({ ...prev, status: 'creating', error: null }));
|
||||
|
||||
try {
|
||||
const response = await intakeApi.createDraft();
|
||||
|
||||
setDraft({
|
||||
draftId: response.draft_id,
|
||||
status: 'ready',
|
||||
error: null,
|
||||
modelFiles: [],
|
||||
contextFiles: [],
|
||||
contextContent: '',
|
||||
introspectionAvailable: false,
|
||||
designVariableCount: 0,
|
||||
objectiveCount: 0,
|
||||
});
|
||||
|
||||
// Update URL without navigation
|
||||
window.history.replaceState(null, '', `/studio/${response.draft_id}`);
|
||||
|
||||
// Load the empty spec for this draft
|
||||
await loadSpec(response.draft_id);
|
||||
|
||||
showNotification('info', 'New studio session started. Drop your files to begin.');
|
||||
} catch (err) {
|
||||
setDraft(prev => ({
|
||||
...prev,
|
||||
status: 'error',
|
||||
error: err instanceof Error ? err.message : 'Failed to create draft',
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
// Load existing draft or study
|
||||
const loadDraft = async (studyId: string) => {
|
||||
setDraft(prev => ({ ...prev, status: 'creating', error: null }));
|
||||
|
||||
// Check if this is a draft (in _inbox) or an existing study
|
||||
const isDraft = studyId.startsWith('draft_');
|
||||
|
||||
if (isDraft) {
|
||||
// Load from intake API
|
||||
try {
|
||||
const response = await intakeApi.getStudioDraft(studyId);
|
||||
|
||||
// Also load context content if there are context files
|
||||
let contextContent = '';
|
||||
if (response.context_files.length > 0) {
|
||||
try {
|
||||
const contextResponse = await intakeApi.getContextContent(studyId);
|
||||
contextContent = contextResponse.content;
|
||||
} catch {
|
||||
// Ignore context loading errors
|
||||
}
|
||||
}
|
||||
|
||||
setDraft({
|
||||
draftId: response.draft_id,
|
||||
status: 'ready',
|
||||
error: null,
|
||||
modelFiles: response.model_files,
|
||||
contextFiles: response.context_files,
|
||||
contextContent,
|
||||
introspectionAvailable: response.introspection_available,
|
||||
designVariableCount: response.design_variable_count,
|
||||
objectiveCount: response.objective_count,
|
||||
});
|
||||
|
||||
// Load the spec
|
||||
await loadSpec(studyId);
|
||||
|
||||
showNotification('info', `Resuming draft: ${studyId}`);
|
||||
} catch (err) {
|
||||
// Draft doesn't exist, create new one
|
||||
createNewDraft();
|
||||
}
|
||||
} else {
|
||||
// Load existing study directly via spec store
|
||||
try {
|
||||
await loadSpec(studyId);
|
||||
|
||||
// Get counts from loaded spec
|
||||
const loadedSpec = useSpecStore.getState().spec;
|
||||
|
||||
setDraft({
|
||||
draftId: studyId,
|
||||
status: 'ready',
|
||||
error: null,
|
||||
modelFiles: [], // Existing studies don't track files separately
|
||||
contextFiles: [],
|
||||
contextContent: '',
|
||||
introspectionAvailable: true, // Assume introspection was done
|
||||
designVariableCount: loadedSpec?.design_variables?.length || 0,
|
||||
objectiveCount: loadedSpec?.objectives?.length || 0,
|
||||
});
|
||||
|
||||
showNotification('info', `Editing study: ${studyId}`);
|
||||
} catch (err) {
|
||||
setDraft(prev => ({
|
||||
...prev,
|
||||
status: 'error',
|
||||
error: err instanceof Error ? err.message : 'Failed to load study',
|
||||
}));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Refresh draft data
|
||||
const refreshDraft = async () => {
|
||||
if (!draft.draftId) return;
|
||||
|
||||
const isDraft = draft.draftId.startsWith('draft_');
|
||||
|
||||
if (isDraft) {
|
||||
try {
|
||||
const response = await intakeApi.getStudioDraft(draft.draftId);
|
||||
|
||||
// Also refresh context content
|
||||
let contextContent = draft.contextContent;
|
||||
if (response.context_files.length > 0) {
|
||||
try {
|
||||
const contextResponse = await intakeApi.getContextContent(draft.draftId);
|
||||
contextContent = contextResponse.content;
|
||||
} catch {
|
||||
// Keep existing content
|
||||
}
|
||||
}
|
||||
|
||||
setDraft(prev => ({
|
||||
...prev,
|
||||
modelFiles: response.model_files,
|
||||
contextFiles: response.context_files,
|
||||
contextContent,
|
||||
introspectionAvailable: response.introspection_available,
|
||||
designVariableCount: response.design_variable_count,
|
||||
objectiveCount: response.objective_count,
|
||||
}));
|
||||
|
||||
// Reload spec
|
||||
await loadSpec(draft.draftId);
|
||||
} catch (err) {
|
||||
showNotification('error', 'Failed to refresh draft');
|
||||
}
|
||||
} else {
|
||||
// For existing studies, just reload the spec
|
||||
try {
|
||||
await loadSpec(draft.draftId);
|
||||
|
||||
const loadedSpec = useSpecStore.getState().spec;
|
||||
setDraft(prev => ({
|
||||
...prev,
|
||||
designVariableCount: loadedSpec?.design_variables?.length || 0,
|
||||
objectiveCount: loadedSpec?.objectives?.length || 0,
|
||||
}));
|
||||
} catch (err) {
|
||||
showNotification('error', 'Failed to refresh study');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Run introspection
|
||||
const runIntrospection = async () => {
|
||||
if (!draft.draftId || draft.modelFiles.length === 0) {
|
||||
showNotification('error', 'Please upload model files first');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsIntrospecting(true);
|
||||
|
||||
try {
|
||||
const response = await intakeApi.introspect({ study_name: draft.draftId });
|
||||
|
||||
showNotification('success', `Found ${response.expressions_count} expressions (${response.candidates_count} candidates)`);
|
||||
|
||||
// Refresh draft state
|
||||
await refreshDraft();
|
||||
} catch (err) {
|
||||
showNotification('error', err instanceof Error ? err.message : 'Introspection failed');
|
||||
} finally {
|
||||
setIsIntrospecting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle file upload complete
|
||||
const handleUploadComplete = useCallback(() => {
|
||||
refreshDraft();
|
||||
showNotification('success', 'Files uploaded successfully');
|
||||
}, [draft.draftId]);
|
||||
|
||||
// Handle build complete
|
||||
const handleBuildComplete = (finalPath: string, finalName: string) => {
|
||||
setShowBuildDialog(false);
|
||||
showNotification('success', `Study "${finalName}" created successfully!`);
|
||||
|
||||
// Navigate to the new study
|
||||
setTimeout(() => {
|
||||
navigate(`/canvas/${finalPath.replace('studies/', '')}`);
|
||||
}, 1500);
|
||||
};
|
||||
|
||||
// Reset draft
|
||||
const resetDraft = async () => {
|
||||
if (!draft.draftId) return;
|
||||
|
||||
if (!confirm('Are you sure you want to reset? This will delete all uploaded files and configurations.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await intakeApi.deleteInboxStudy(draft.draftId);
|
||||
await createNewDraft();
|
||||
} catch (err) {
|
||||
showNotification('error', 'Failed to reset draft');
|
||||
}
|
||||
};
|
||||
|
||||
// Show notification
|
||||
const showNotification = (type: 'success' | 'error' | 'info', message: string) => {
|
||||
setNotification({ type, message });
|
||||
setTimeout(() => setNotification(null), 4000);
|
||||
};
|
||||
|
||||
// Can always save/build - even empty studies can be saved for later
|
||||
const canBuild = draft.draftId !== null;
|
||||
|
||||
// Loading state
|
||||
if (draft.status === 'creating') {
|
||||
return (
|
||||
<div className="min-h-screen bg-dark-900 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<Loader2 className="w-12 h-12 text-primary-400 animate-spin mx-auto mb-4" />
|
||||
<p className="text-dark-300">Initializing Studio...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (draft.status === 'error') {
|
||||
return (
|
||||
<div className="min-h-screen bg-dark-900 flex items-center justify-center">
|
||||
<div className="text-center max-w-md">
|
||||
<AlertCircle className="w-12 h-12 text-red-400 mx-auto mb-4" />
|
||||
<h2 className="text-xl font-semibold text-white mb-2">Failed to Initialize</h2>
|
||||
<p className="text-dark-400 mb-4">{draft.error}</p>
|
||||
<button
|
||||
onClick={createNewDraft}
|
||||
className="px-4 py-2 bg-primary-500 text-white rounded-lg hover:bg-primary-400 transition-colors"
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-dark-900 flex flex-col">
|
||||
{/* Header */}
|
||||
<header className="h-14 bg-dark-850 border-b border-dark-700 flex items-center justify-between px-4 flex-shrink-0">
|
||||
{/* Left: Navigation */}
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => navigate('/')}
|
||||
className="p-2 hover:bg-dark-700 rounded-lg text-dark-400 hover:text-white transition-colors"
|
||||
>
|
||||
<Home className="w-5 h-5" />
|
||||
</button>
|
||||
<ChevronRight className="w-4 h-4 text-dark-600" />
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="w-5 h-5 text-primary-400" />
|
||||
<span className="text-white font-medium">Atomizer Studio</span>
|
||||
</div>
|
||||
{draft.draftId && (
|
||||
<>
|
||||
<ChevronRight className="w-4 h-4 text-dark-600" />
|
||||
<span className="text-dark-400 text-sm font-mono">{draft.draftId}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: Actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={resetDraft}
|
||||
className="flex items-center gap-2 px-3 py-1.5 text-sm text-dark-400 hover:text-white hover:bg-dark-700 rounded-lg transition-colors"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
Reset
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowBuildDialog(true)}
|
||||
disabled={!canBuild}
|
||||
className="flex items-center gap-2 px-4 py-1.5 text-sm font-medium bg-primary-500 text-white rounded-lg hover:bg-primary-400 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
Save & Name Study
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* Left Panel: Resources (Resizable) */}
|
||||
<div
|
||||
className="bg-dark-850 border-r border-dark-700 flex flex-col flex-shrink-0 relative"
|
||||
style={{ width: leftPanelWidth }}
|
||||
>
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-6">
|
||||
{/* Drop Zone */}
|
||||
<section>
|
||||
<h3 className="text-sm font-medium text-dark-300 mb-3 flex items-center gap-2">
|
||||
<Upload className="w-4 h-4" />
|
||||
Model Files
|
||||
</h3>
|
||||
{draft.draftId && (
|
||||
<StudioDropZone
|
||||
draftId={draft.draftId}
|
||||
type="model"
|
||||
files={draft.modelFiles}
|
||||
onUploadComplete={handleUploadComplete}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Introspection */}
|
||||
{draft.modelFiles.length > 0 && (
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-medium text-dark-300 flex items-center gap-2">
|
||||
<Settings className="w-4 h-4" />
|
||||
Parameters
|
||||
</h3>
|
||||
<button
|
||||
onClick={runIntrospection}
|
||||
disabled={isIntrospecting}
|
||||
className="flex items-center gap-1 px-2 py-1 text-xs text-primary-400 hover:bg-primary-400/10 rounded transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isIntrospecting ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="w-3 h-3" />
|
||||
)}
|
||||
{isIntrospecting ? 'Scanning...' : 'Scan'}
|
||||
</button>
|
||||
</div>
|
||||
{draft.draftId && draft.introspectionAvailable && (
|
||||
<StudioParameterList
|
||||
draftId={draft.draftId}
|
||||
onParameterAdded={refreshDraft}
|
||||
/>
|
||||
)}
|
||||
{!draft.introspectionAvailable && (
|
||||
<p className="text-xs text-dark-500 italic">
|
||||
Click "Scan" to discover parameters from your model.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Context Files */}
|
||||
<section>
|
||||
<h3 className="text-sm font-medium text-dark-300 mb-3 flex items-center gap-2">
|
||||
<FileText className="w-4 h-4" />
|
||||
Context Documents
|
||||
</h3>
|
||||
{draft.draftId && (
|
||||
<StudioContextFiles
|
||||
draftId={draft.draftId}
|
||||
files={draft.contextFiles}
|
||||
onUploadComplete={handleUploadComplete}
|
||||
/>
|
||||
)}
|
||||
<p className="text-xs text-dark-500 mt-2">
|
||||
Upload requirements, goals, or specs. The AI will read these.
|
||||
</p>
|
||||
|
||||
{/* Show context preview if loaded */}
|
||||
{draft.contextContent && (
|
||||
<div className="mt-3 p-2 bg-dark-700/50 rounded-lg border border-dark-600">
|
||||
<p className="text-xs text-amber-400 mb-1 font-medium">Context Loaded:</p>
|
||||
<p className="text-xs text-dark-400 line-clamp-3">
|
||||
{draft.contextContent.substring(0, 200)}...
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Node Palette - EXPANDED, not collapsed */}
|
||||
<section>
|
||||
<h3 className="text-sm font-medium text-dark-300 mb-3 flex items-center gap-2">
|
||||
<Layers className="w-4 h-4" />
|
||||
Components
|
||||
</h3>
|
||||
<NodePalette
|
||||
collapsed={false}
|
||||
showToggle={false}
|
||||
className="!w-full !border-0 !bg-transparent"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* Resize Handle */}
|
||||
<div
|
||||
className="absolute right-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-primary-500/50 transition-colors group"
|
||||
onMouseDown={handleMouseDown}
|
||||
>
|
||||
<div className="absolute right-0 top-1/2 -translate-y-1/2 w-4 h-8 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<GripVertical className="w-3 h-3 text-dark-400" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Center: Canvas */}
|
||||
<div className="flex-1 relative bg-dark-900">
|
||||
{draft.draftId && (
|
||||
<SpecRenderer
|
||||
studyId={draft.draftId}
|
||||
editable={true}
|
||||
showLoadingOverlay={false}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{!specLoading && (!spec || Object.keys(spec).length === 0) && (
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
<div className="text-center max-w-md p-8">
|
||||
<div className="w-20 h-20 rounded-full bg-dark-800 flex items-center justify-center mx-auto mb-6">
|
||||
<Sparkles className="w-10 h-10 text-primary-400" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-semibold text-white mb-3">
|
||||
Welcome to Atomizer Studio
|
||||
</h2>
|
||||
<p className="text-dark-400 mb-6">
|
||||
Drop your model files on the left, or drag components from the palette to start building your optimization study.
|
||||
</p>
|
||||
<div className="flex flex-col gap-2 text-sm text-dark-500">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle className="w-4 h-4 text-green-400" />
|
||||
<span>Upload .sim, .prt, .fem files</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle className="w-4 h-4 text-green-400" />
|
||||
<span>Add context documents (PDF, MD, TXT)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle className="w-4 h-4 text-green-400" />
|
||||
<span>Configure with AI assistance</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right Panel: Assistant + Config - wider for better chat UX */}
|
||||
<div
|
||||
className={`bg-dark-850 border-l border-dark-700 flex flex-col transition-all duration-300 flex-shrink-0 ${
|
||||
rightPanelCollapsed ? 'w-12' : 'w-[480px]'
|
||||
}`}
|
||||
>
|
||||
{/* Collapse toggle */}
|
||||
<button
|
||||
onClick={() => setRightPanelCollapsed(!rightPanelCollapsed)}
|
||||
className="absolute right-0 top-1/2 -translate-y-1/2 z-10 p-1 bg-dark-700 border border-dark-600 rounded-l-lg hover:bg-dark-600 transition-colors"
|
||||
style={{ marginRight: rightPanelCollapsed ? '48px' : '480px' }}
|
||||
>
|
||||
{rightPanelCollapsed ? (
|
||||
<ChevronLeft className="w-4 h-4 text-dark-400" />
|
||||
) : (
|
||||
<ChevronRightIcon className="w-4 h-4 text-dark-400" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{!rightPanelCollapsed && (
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* Chat */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{draft.draftId && (
|
||||
<StudioChat
|
||||
draftId={draft.draftId}
|
||||
contextFiles={draft.contextFiles}
|
||||
contextContent={draft.contextContent}
|
||||
modelFiles={draft.modelFiles}
|
||||
onSpecUpdated={refreshDraft}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Config Panel (when node selected) */}
|
||||
<NodeConfigPanelV2 />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rightPanelCollapsed && (
|
||||
<div className="flex flex-col items-center py-4 gap-4">
|
||||
<MessageSquare className="w-5 h-5 text-dark-400" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notification Toast */}
|
||||
{notification && (
|
||||
<div
|
||||
className={`fixed bottom-4 right-4 flex items-center gap-3 px-4 py-3 rounded-lg shadow-lg z-50 animate-slide-up ${
|
||||
notification.type === 'success'
|
||||
? 'bg-green-500/10 border border-green-500/30 text-green-400'
|
||||
: notification.type === 'error'
|
||||
? 'bg-red-500/10 border border-red-500/30 text-red-400'
|
||||
: 'bg-primary-500/10 border border-primary-500/30 text-primary-400'
|
||||
}`}
|
||||
>
|
||||
{notification.type === 'success' && <CheckCircle className="w-5 h-5" />}
|
||||
{notification.type === 'error' && <AlertCircle className="w-5 h-5" />}
|
||||
{notification.type === 'info' && <Sparkles className="w-5 h-5" />}
|
||||
<span>{notification.message}</span>
|
||||
<button
|
||||
onClick={() => setNotification(null)}
|
||||
className="p-1 hover:bg-white/10 rounded"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Build Dialog */}
|
||||
{showBuildDialog && draft.draftId && (
|
||||
<StudioBuildDialog
|
||||
draftId={draft.draftId}
|
||||
onClose={() => setShowBuildDialog(false)}
|
||||
onBuildComplete={handleBuildComplete}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
201
atomizer-dashboard/frontend/src/types/intake.ts
Normal file
201
atomizer-dashboard/frontend/src/types/intake.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* Intake Workflow TypeScript Types
|
||||
*
|
||||
* Types for the study intake/creation workflow.
|
||||
*/
|
||||
|
||||
// ============================================================================
|
||||
// Status Types
|
||||
// ============================================================================
|
||||
|
||||
export type SpecStatus =
|
||||
| 'draft'
|
||||
| 'introspected'
|
||||
| 'configured'
|
||||
| 'validated'
|
||||
| 'ready'
|
||||
| 'running'
|
||||
| 'completed'
|
||||
| 'failed';
|
||||
|
||||
// ============================================================================
|
||||
// Expression/Introspection Types
|
||||
// ============================================================================
|
||||
|
||||
export interface ExpressionInfo {
|
||||
/** Expression name in NX */
|
||||
name: string;
|
||||
/** Current value */
|
||||
value: number | null;
|
||||
/** Physical units */
|
||||
units: string | null;
|
||||
/** Expression formula if any */
|
||||
formula: string | null;
|
||||
/** Whether this is a design variable candidate */
|
||||
is_candidate: boolean;
|
||||
/** Confidence that this is a DV (0-1) */
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
export interface BaselineData {
|
||||
/** When baseline was run */
|
||||
timestamp: string;
|
||||
/** How long the solve took */
|
||||
solve_time_seconds: number;
|
||||
/** Computed mass from BDF/FEM */
|
||||
mass_kg: number | null;
|
||||
/** Max displacement result */
|
||||
max_displacement_mm: number | null;
|
||||
/** Max von Mises stress */
|
||||
max_stress_mpa: number | null;
|
||||
/** Whether baseline solve succeeded */
|
||||
success: boolean;
|
||||
/** Error message if failed */
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface IntrospectionData {
|
||||
/** When introspection was run */
|
||||
timestamp: string;
|
||||
/** Detected solver type */
|
||||
solver_type: string | null;
|
||||
/** Mass from expressions or properties */
|
||||
mass_kg: number | null;
|
||||
/** Volume from mass properties */
|
||||
volume_mm3: number | null;
|
||||
/** Discovered expressions */
|
||||
expressions: ExpressionInfo[];
|
||||
/** Baseline solve results */
|
||||
baseline: BaselineData | null;
|
||||
/** Warnings from introspection */
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Request/Response Types
|
||||
// ============================================================================
|
||||
|
||||
export interface CreateInboxRequest {
|
||||
study_name: string;
|
||||
description?: string;
|
||||
topic?: string;
|
||||
}
|
||||
|
||||
export interface CreateInboxResponse {
|
||||
success: boolean;
|
||||
study_name: string;
|
||||
inbox_path: string;
|
||||
spec_path: string;
|
||||
status: SpecStatus;
|
||||
}
|
||||
|
||||
export interface IntrospectRequest {
|
||||
study_name: string;
|
||||
model_file?: string;
|
||||
}
|
||||
|
||||
export interface IntrospectResponse {
|
||||
success: boolean;
|
||||
study_name: string;
|
||||
status: SpecStatus;
|
||||
expressions_count: number;
|
||||
candidates_count: number;
|
||||
mass_kg: number | null;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export interface InboxStudy {
|
||||
study_name: string;
|
||||
status: SpecStatus;
|
||||
description: string | null;
|
||||
topic: string | null;
|
||||
created: string | null;
|
||||
modified: string | null;
|
||||
model_files: string[];
|
||||
has_context: boolean;
|
||||
}
|
||||
|
||||
export interface ListInboxResponse {
|
||||
studies: InboxStudy[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface TopicInfo {
|
||||
name: string;
|
||||
study_count: number;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface ListTopicsResponse {
|
||||
topics: TopicInfo[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface InboxStudyDetail {
|
||||
study_name: string;
|
||||
inbox_path: string;
|
||||
spec: import('./atomizer-spec').AtomizerSpec;
|
||||
files: {
|
||||
sim: string[];
|
||||
prt: string[];
|
||||
fem: string[];
|
||||
};
|
||||
context_files: string[];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Finalize Types
|
||||
// ============================================================================
|
||||
|
||||
export interface FinalizeRequest {
|
||||
topic: string;
|
||||
run_baseline?: boolean;
|
||||
}
|
||||
|
||||
export interface FinalizeProgress {
|
||||
step: string;
|
||||
progress: number;
|
||||
message: string;
|
||||
completed: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface FinalizeResponse {
|
||||
success: boolean;
|
||||
study_name: string;
|
||||
final_path: string;
|
||||
status: SpecStatus;
|
||||
baseline?: BaselineData;
|
||||
readme_generated: boolean;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// README Generation Types
|
||||
// ============================================================================
|
||||
|
||||
export interface GenerateReadmeRequest {
|
||||
study_name: string;
|
||||
}
|
||||
|
||||
export interface GenerateReadmeResponse {
|
||||
success: boolean;
|
||||
content: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Upload Types
|
||||
// ============================================================================
|
||||
|
||||
export interface UploadFilesResponse {
|
||||
success: boolean;
|
||||
study_name: string;
|
||||
uploaded_files: Array<{
|
||||
name: string;
|
||||
status: 'uploaded' | 'rejected' | 'skipped';
|
||||
path?: string;
|
||||
size?: number;
|
||||
reason?: string;
|
||||
}>;
|
||||
total_uploaded: number;
|
||||
}
|
||||
Reference in New Issue
Block a user