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:
2026-01-27 12:02:30 -05:00
parent 3193831340
commit a26914bbe8
56 changed files with 14173 additions and 646 deletions

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View 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

View File

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

View File

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

View File

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

View File

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

View 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;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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';

View File

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

View 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;

View File

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

View File

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

View File

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

View 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';

View File

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

View 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>
);
}

View 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;
}