Compare commits
199 Commits
ea437d360e
...
v1-final
| Author | SHA1 | Date | |
|---|---|---|---|
| 1f58bb8016 | |||
| 31d21ec551 | |||
| 2b976cf872 | |||
| 39212aaf81 | |||
| 7acda7f55f | |||
| c59072eff2 | |||
| 176b75328f | |||
| 7eb3d11f02 | |||
| 6658de02f4 | |||
| a9c40368d3 | |||
| 98774453b3 | |||
| d8570eaa2d | |||
| 68a6b4763b | |||
| 8efa8ba0d1 | |||
| 6ed074dbbf | |||
| 5c63d877f0 | |||
| 906037f974 | |||
| 78f56a68b0 | |||
| 5cf994ec4b | |||
| 9bc3b12745 | |||
| 45d4c197ba | |||
| 8b9fc31bcd | |||
| fbbd3e7277 | |||
| 1a14f7c420 | |||
| 139a355ef3 | |||
| 7d5bd33bb5 | |||
| 18a8347765 | |||
| 856ff239d6 | |||
| 732e41ec3a | |||
| 39a3420a8e | |||
| 03232be7b1 | |||
| 44a5b4aac5 | |||
| 1badc370ab | |||
| 0bc0c24c1c | |||
| f61616d76a | |||
| e07c26c6fe | |||
| 68ebee7432 | |||
| dc34b7f6d5 | |||
| b6dc15e19e | |||
| b411eaac25 | |||
| e3a79d4888 | |||
| 6d443df3ec | |||
| d954b2b816 | |||
| 43aea01fb5 | |||
| 709612ece4 | |||
| b38194c4d9 | |||
| 634bf611c9 | |||
| 612a21f561 | |||
| abc7d5f013 | |||
| c3125b458b | |||
| cd7f7e8aa9 | |||
| fbdafb9a37 | |||
| fc1c1dc142 | |||
| 97fe055b8d | |||
| 89e0ffbbf2 | |||
| 20d035205a | |||
| e6f98ac921 | |||
| 9a5f086684 | |||
| 070a211c69 | |||
| 4c3457c17c | |||
| ecba40f189 | |||
| 515eef145f | |||
| c4d98ee97c | |||
| 1bfc747cf9 | |||
| c5226084fe | |||
| 98e4b2be02 | |||
| 379801c8aa | |||
| 1021f57abc | |||
| 4f051aa7e1 | |||
| 239e2f01a9 | |||
| 30981fa066 | |||
| da9b579bcf | |||
| fdcafe96a9 | |||
| fbdbf6b362 | |||
| 4e0c9cd24d | |||
| c93239c9c6 | |||
| 61dcefb5ea | |||
| 8143da96e9 | |||
| 9534ba9ed9 | |||
| 4fc129e35b | |||
| bf1f461e2b | |||
| 7a2c002672 | |||
| bf4e84d45a | |||
| ef8801a5cd | |||
| f4cfc9b1b7 | |||
| 23b6fe855b | |||
| 98d510154d | |||
| 851a8d3df0 | |||
| 1166741ffd | |||
| afaa925da8 | |||
| 6251787ca5 | |||
| 40213578ad | |||
| 26100a9624 | |||
| ed6874092f | |||
| bb83bb9cab | |||
| fa9193b809 | |||
| 3184eb0d0e | |||
| 85d40898f0 | |||
| 7086f9fbdf | |||
| e4651c9a40 | |||
| 9d4c37234a | |||
| 4bec4063a5 | |||
| cf82de4f06 | |||
| 3289a76e19 | |||
| d6a1d6eee1 | |||
| 6218355dbf | |||
| 0795cccc97 | |||
| 580ed65a26 | |||
| 57130ccfbc | |||
| 6f3325d86f | |||
| 04f06766a0 | |||
| b419510b1a | |||
| 2fde08daab | |||
| 93a5508c07 | |||
| 0229ce53bb | |||
| 80104d2467 | |||
| 55f0f917c7 | |||
| 3718a8d5c8 | |||
| 815db0fb8d | |||
| 04fdae26ab | |||
| e8877429f8 | |||
| 4243a332a3 | |||
| 60dbf5b172 | |||
| 686ec2ac6c | |||
| 0e459028fe | |||
| 126f0bb2e0 | |||
| 135698d96a | |||
| e8b4d37667 | |||
| 390ffed450 | |||
| 33180d66c9 | |||
| 017b90f11e | |||
| 94bff37a67 | |||
| 3e5180485c | |||
| 15a457d2be | |||
| b88657b00c | |||
| 3ab1cad4e1 | |||
| 857c01e7ca | |||
| 8d9d55356c | |||
| 9541958eae | |||
| ca4101dcb0 | |||
| 65711cdbf1 | |||
| a5059dd64a | |||
| 38d0994d29 | |||
| 5f5d55d107 | |||
| 27d9dbee5b | |||
| 12afd0c54f | |||
| a1000052cb | |||
| eeacfbe41a | |||
| 487ecf67dc | |||
| faab234d05 | |||
| c6427f3c6e | |||
| 34b52f9543 | |||
| 7df18324b1 | |||
| abdbe9a708 | |||
| b62605a736 | |||
| f80b5d64a8 | |||
| af195c3a75 | |||
| 5d69b3bd10 | |||
| 5dec327988 | |||
| 99be370fad | |||
| d7986922d5 | |||
| a7039c5875 | |||
| b3f3329c79 | |||
| f47b390ed7 | |||
| 993c1ff17f | |||
| e2cfa0a3d9 | |||
| 00dd88599e | |||
| 4a7422c620 | |||
| bb27f3fb00 | |||
| a26914bbe8 | |||
| 3193831340 | |||
| a3f18dc377 | |||
| 2cb8dccc3a | |||
| c224b16ac3 | |||
| e1c59a51c1 | |||
| f725e75164 | |||
| e954b130f5 | |||
| 5b22439357 | |||
| 0c252e3a65 | |||
| 4749944a48 | |||
| 3229c31349 | |||
| 14354a2606 | |||
| abbc7b1b50 | |||
| 1cdcc17ffd | |||
| 5c419e2358 | |||
| 89694088a2 | |||
| 91cf9ca1fd | |||
| ced79b8d39 | |||
| 2f0f45de86 | |||
| 47f8b50112 | |||
| cf8c57fdac | |||
| 6c30224341 | |||
| 27e78d3d56 | |||
| cb6b130908 | |||
| f067497e08 | |||
| ba0b9a1fae | |||
| b05412f807 | |||
| ffd41e3a60 | |||
| c4a3cff91a |
@@ -1 +0,0 @@
|
||||
{"mcpServers": {"atomizer": {"command": "node", "args": ["C:\\Users\\antoi\\Atomizer\\mcp-server\\atomizer-tools\\dist\\index.js"], "env": {"ATOMIZER_MODE": "user", "ATOMIZER_ROOT": "C:\\Users\\antoi\\Atomizer"}}}}
|
||||
@@ -1 +0,0 @@
|
||||
{"mcpServers": {"atomizer": {"command": "node", "args": ["C:\\Users\\antoi\\Atomizer\\mcp-server\\atomizer-tools\\dist\\index.js"], "env": {"ATOMIZER_MODE": "user", "ATOMIZER_ROOT": "C:\\Users\\antoi\\Atomizer"}}}}
|
||||
@@ -1,45 +0,0 @@
|
||||
# Atomizer Assistant
|
||||
|
||||
You are the Atomizer Assistant - an expert system for structural optimization using FEA.
|
||||
|
||||
**Current Mode**: USER
|
||||
|
||||
Your role:
|
||||
- Help engineers with FEA optimization workflows
|
||||
- Create, configure, and run optimization studies
|
||||
- Analyze results and provide insights
|
||||
- Explain FEA concepts and methodology
|
||||
|
||||
Important guidelines:
|
||||
- Be concise and professional
|
||||
- Use technical language appropriate for engineers
|
||||
- You are "Atomizer Assistant", not a generic AI
|
||||
- Use the available MCP tools to perform actions
|
||||
- When asked about studies, use the appropriate tools to get real data
|
||||
|
||||
|
||||
---
|
||||
|
||||
# Current Study: m1_mirror_flatback_lateral
|
||||
|
||||
**Status**: Study directory not found.
|
||||
|
||||
---
|
||||
|
||||
# User Mode Instructions
|
||||
|
||||
You can help with optimization workflows:
|
||||
- Create and configure studies
|
||||
- Run optimizations
|
||||
- Analyze results
|
||||
- Generate reports
|
||||
- Explain FEA concepts
|
||||
|
||||
**For code modifications**, suggest switching 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`
|
||||
@@ -1,45 +0,0 @@
|
||||
# Atomizer Assistant
|
||||
|
||||
You are the Atomizer Assistant - an expert system for structural optimization using FEA.
|
||||
|
||||
**Current Mode**: USER
|
||||
|
||||
Your role:
|
||||
- Help engineers with FEA optimization workflows
|
||||
- Create, configure, and run optimization studies
|
||||
- Analyze results and provide insights
|
||||
- Explain FEA concepts and methodology
|
||||
|
||||
Important guidelines:
|
||||
- Be concise and professional
|
||||
- Use technical language appropriate for engineers
|
||||
- You are "Atomizer Assistant", not a generic AI
|
||||
- Use the available MCP tools to perform actions
|
||||
- When asked about studies, use the appropriate tools to get real data
|
||||
|
||||
|
||||
---
|
||||
|
||||
# Current Study: m1_mirror_flatback_lateral
|
||||
|
||||
**Status**: Study directory not found.
|
||||
|
||||
---
|
||||
|
||||
# User Mode Instructions
|
||||
|
||||
You can help with optimization workflows:
|
||||
- Create and configure studies
|
||||
- Run optimizations
|
||||
- Analyze results
|
||||
- Generate reports
|
||||
- Explain FEA concepts
|
||||
|
||||
**For code modifications**, suggest switching 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`
|
||||
@@ -62,7 +62,26 @@
|
||||
"Bash(xargs -I{} git ls-tree -r -l HEAD {})",
|
||||
"Bash(sort:*)",
|
||||
"Bash(C:Usersantoianaconda3envsatomizerpython.exe introspect_model.py)",
|
||||
"Bash(xargs:*)"
|
||||
"Bash(xargs:*)",
|
||||
"Bash(ping:*)",
|
||||
"Bash(C:Usersantoianaconda3envsatomizerpython.exe -c \"import requests; r = requests.post\\(''http://127.0.0.1:8001/api/claude/sessions'', json={''mode'': ''user''}\\); print\\(f''Status: {r.status_code}''\\); print\\(f''Response: {r.text}''\\)\")",
|
||||
"Bash(start \"Atomizer Backend\" cmd /k C:UsersantoiAtomizerrestart_backend.bat)",
|
||||
"Bash(start \"Test Backend\" cmd /c \"cd /d C:\\\\Users\\\\antoi\\\\Atomizer\\\\atomizer-dashboard\\\\backend && C:\\\\Users\\\\antoi\\\\anaconda3\\\\Scripts\\\\activate.bat atomizer && python -m uvicorn api.main:app --port 8002\")",
|
||||
"Bash(C:Usersantoianaconda3envsatomizerpython.exe C:UsersantoiAtomizertest_backend.py)",
|
||||
"Bash(start \"Backend 8002\" C:UsersantoiAtomizerstart_backend_8002.bat)",
|
||||
"Bash(C:Usersantoianaconda3envsatomizerpython.exe -c \"from api.main import app; print\\(''Import OK''\\)\")",
|
||||
"Bash(find:*)",
|
||||
"Bash(npx tailwindcss:*)",
|
||||
"Bash(C:Usersantoianaconda3envsatomizerpython.exe -c \"from pathlib import Path; p = Path\\(''C:/Users/antoi/Atomizer/studies''\\) / ''M1_Mirror/m1_mirror_cost_reduction_lateral''; print\\(''exists:'', p.exists\\(\\), ''path:'', p\\)\")",
|
||||
"Bash(C:Usersantoianaconda3envsatomizerpython.exe -c \"import sys, json; d=json.load\\(sys.stdin\\); print\\(''Study:'', d.get\\(''meta'',{}\\).get\\(''study_name'',''N/A''\\)\\); print\\(''Design Variables:''\\); [print\\(f'' - {dv[\"\"name\"\"]} \\({dv[\"\"expression_name\"\"]}\\)''\\) for dv in d.get\\(''design_variables'',[]\\)]\")",
|
||||
"Bash(C:Usersantoianaconda3envsatomizerpython.exe -m py_compile:*)",
|
||||
"Skill(ralph-loop:ralph-loop)",
|
||||
"Skill(ralph-loop:ralph-loop:*)",
|
||||
"mcp__Claude_in_Chrome__computer",
|
||||
"mcp__Claude_in_Chrome__navigate",
|
||||
"Bash(/c/Users/antoi/anaconda3/envs/atomizer/python.exe -m pip install:*)",
|
||||
"Bash(/c/Users/antoi/anaconda3/envs/atomizer/python.exe tests/compare_triangle_vs_gmsh.py)",
|
||||
"Bash(/c/Users/antoi/anaconda3/envs/atomizer/python.exe:*)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
skill_id: SKILL_001
|
||||
version: 2.4
|
||||
last_updated: 2025-12-31
|
||||
version: 2.5
|
||||
last_updated: 2026-01-22
|
||||
type: reference
|
||||
code_dependencies:
|
||||
- optimization_engine/extractors/__init__.py
|
||||
@@ -14,8 +14,8 @@ requires_skills:
|
||||
|
||||
# Atomizer Quick Reference Cheatsheet
|
||||
|
||||
**Version**: 2.4
|
||||
**Updated**: 2025-12-31
|
||||
**Version**: 2.5
|
||||
**Updated**: 2026-01-22
|
||||
**Purpose**: Rapid lookup for common operations. "I want X → Use Y"
|
||||
|
||||
---
|
||||
@@ -37,6 +37,8 @@ requires_skills:
|
||||
| **Use SAT (Self-Aware Turbo)** | **SYS_16** | SAT v3 for high-efficiency neural-accelerated optimization |
|
||||
| Generate physics insight | SYS_17 | `python -m optimization_engine.insights generate <study>` |
|
||||
| **Manage knowledge/playbook** | **SYS_18** | `from optimization_engine.context import AtomizerPlaybook` |
|
||||
| **Automate dev tasks** | **DevLoop** | `python tools/devloop_cli.py start "task"` |
|
||||
| **Test dashboard UI** | **DevLoop** | `python tools/devloop_cli.py browser --level full` |
|
||||
|
||||
---
|
||||
|
||||
@@ -678,6 +680,67 @@ feedback.process_trial_result(
|
||||
|
||||
---
|
||||
|
||||
## DevLoop Quick Reference
|
||||
|
||||
Closed-loop development system using AI agents + Playwright testing.
|
||||
|
||||
### CLI Commands
|
||||
|
||||
| Task | Command |
|
||||
|------|---------|
|
||||
| Full dev cycle | `python tools/devloop_cli.py start "Create new study"` |
|
||||
| Plan only | `python tools/devloop_cli.py plan "Fix validation"` |
|
||||
| Implement plan | `python tools/devloop_cli.py implement` |
|
||||
| Test study files | `python tools/devloop_cli.py test --study support_arm` |
|
||||
| Analyze failures | `python tools/devloop_cli.py analyze` |
|
||||
| Browser smoke test | `python tools/devloop_cli.py browser` |
|
||||
| Browser full tests | `python tools/devloop_cli.py browser --level full` |
|
||||
| Check status | `python tools/devloop_cli.py status` |
|
||||
| Quick test | `python tools/devloop_cli.py quick` |
|
||||
|
||||
### Browser Test Levels
|
||||
|
||||
| Level | Description | Tests |
|
||||
|-------|-------------|-------|
|
||||
| `quick` | Smoke test (page loads) | 1 |
|
||||
| `home` | Home page verification | 2 |
|
||||
| `full` | All UI + study tests | 5+ |
|
||||
| `study` | Canvas/dashboard for specific study | 3 |
|
||||
|
||||
### State Files (`.devloop/`)
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `current_plan.json` | Current implementation plan |
|
||||
| `test_results.json` | Filesystem/API test results |
|
||||
| `browser_test_results.json` | Playwright test results |
|
||||
| `analysis.json` | Failure analysis |
|
||||
|
||||
### Prerequisites
|
||||
|
||||
```bash
|
||||
# Start backend
|
||||
cd atomizer-dashboard/backend && python -m uvicorn api.main:app --reload --port 8000
|
||||
|
||||
# Start frontend
|
||||
cd atomizer-dashboard/frontend && npm run dev
|
||||
|
||||
# Install Playwright (once)
|
||||
cd atomizer-dashboard/frontend && npx playwright install chromium
|
||||
```
|
||||
|
||||
### Standalone Playwright Tests
|
||||
|
||||
```bash
|
||||
cd atomizer-dashboard/frontend
|
||||
npm run test:e2e # Run all E2E tests
|
||||
npm run test:e2e:ui # Playwright UI mode
|
||||
```
|
||||
|
||||
**Full documentation**: `docs/guides/DEVLOOP.md`
|
||||
|
||||
---
|
||||
|
||||
## Report Generation Quick Reference (OP_08)
|
||||
|
||||
Generate comprehensive study reports from optimization data.
|
||||
|
||||
206
.claude/skills/modules/study-readme-generator.md
Normal file
206
.claude/skills/modules/study-readme-generator.md
Normal file
@@ -0,0 +1,206 @@
|
||||
# Study README Generator Skill
|
||||
|
||||
**Skill ID**: STUDY_README_GENERATOR
|
||||
**Version**: 1.0
|
||||
**Purpose**: Generate intelligent, context-aware README.md files for optimization studies
|
||||
|
||||
## When to Use
|
||||
|
||||
This skill is invoked automatically during the study intake workflow when:
|
||||
1. A study moves from `introspected` to `configured` status
|
||||
2. User explicitly requests README generation
|
||||
3. Finalizing a study from the inbox
|
||||
|
||||
## Input Context
|
||||
|
||||
The README generator receives:
|
||||
|
||||
```json
|
||||
{
|
||||
"study_name": "bracket_mass_opt_v1",
|
||||
"topic": "Brackets",
|
||||
"description": "User's description from intake form",
|
||||
"spec": { /* Full AtomizerSpec v2.0 */ },
|
||||
"introspection": {
|
||||
"expressions": [...],
|
||||
"mass_kg": 1.234,
|
||||
"solver_type": "NX_Nastran"
|
||||
},
|
||||
"context_files": {
|
||||
"goals.md": "User's goals markdown content",
|
||||
"notes.txt": "Any additional notes"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Output Format
|
||||
|
||||
Generate a README.md with these sections:
|
||||
|
||||
### 1. Title & Overview
|
||||
```markdown
|
||||
# {Study Name}
|
||||
|
||||
**Topic**: {Topic}
|
||||
**Created**: {Date}
|
||||
**Status**: {Status}
|
||||
|
||||
{One paragraph executive summary of the optimization goal}
|
||||
```
|
||||
|
||||
### 2. Engineering Problem
|
||||
```markdown
|
||||
## Engineering Problem
|
||||
|
||||
{Describe the physical problem being solved}
|
||||
|
||||
### Model Description
|
||||
- **Geometry**: {Describe the part/assembly}
|
||||
- **Material**: {If known from introspection}
|
||||
- **Baseline Mass**: {mass_kg} kg
|
||||
|
||||
### Loading Conditions
|
||||
{Describe loads and boundary conditions if available}
|
||||
```
|
||||
|
||||
### 3. Optimization Formulation
|
||||
```markdown
|
||||
## Optimization Formulation
|
||||
|
||||
### Design Variables ({count})
|
||||
| Variable | Expression | Range | Units |
|
||||
|----------|------------|-------|-------|
|
||||
| {name} | {expr_name} | [{min}, {max}] | {units} |
|
||||
|
||||
### Objectives ({count})
|
||||
| Objective | Direction | Weight | Source |
|
||||
|-----------|-----------|--------|--------|
|
||||
| {name} | {direction} | {weight} | {extractor} |
|
||||
|
||||
### Constraints ({count})
|
||||
| Constraint | Condition | Threshold | Type |
|
||||
|------------|-----------|-----------|------|
|
||||
| {name} | {operator} | {threshold} | {type} |
|
||||
```
|
||||
|
||||
### 4. Methodology
|
||||
```markdown
|
||||
## Methodology
|
||||
|
||||
### Algorithm
|
||||
- **Primary**: {algorithm_type}
|
||||
- **Max Trials**: {max_trials}
|
||||
- **Surrogate**: {if enabled}
|
||||
|
||||
### Physics Extraction
|
||||
{Describe extractors used}
|
||||
|
||||
### Convergence Criteria
|
||||
{Describe stopping conditions}
|
||||
```
|
||||
|
||||
### 5. Expected Outcomes
|
||||
```markdown
|
||||
## Expected Outcomes
|
||||
|
||||
Based on the optimization setup:
|
||||
- Expected improvement: {estimate if baseline available}
|
||||
- Key trade-offs: {identify from objectives/constraints}
|
||||
- Risk factors: {any warnings from validation}
|
||||
```
|
||||
|
||||
## Generation Guidelines
|
||||
|
||||
1. **Be Specific**: Use actual values from the spec, not placeholders
|
||||
2. **Be Concise**: Engineers don't want to read novels
|
||||
3. **Be Accurate**: Only state facts that can be verified from input
|
||||
4. **Be Helpful**: Include insights that aid understanding
|
||||
5. **No Fluff**: Avoid marketing language or excessive praise
|
||||
|
||||
## Claude Prompt Template
|
||||
|
||||
```
|
||||
You are generating a README.md for an FEA optimization study.
|
||||
|
||||
CONTEXT:
|
||||
{json_context}
|
||||
|
||||
RULES:
|
||||
1. Use the actual data provided - never use placeholder values
|
||||
2. Write in technical engineering language appropriate for structural engineers
|
||||
3. Keep each section concise but complete
|
||||
4. If information is missing, note it as "TBD" or skip the section
|
||||
5. Include physical units wherever applicable
|
||||
6. Format tables properly with alignment
|
||||
|
||||
Generate the README.md content:
|
||||
```
|
||||
|
||||
## Example Output
|
||||
|
||||
```markdown
|
||||
# Bracket Mass Optimization V1
|
||||
|
||||
**Topic**: Simple_Bracket
|
||||
**Created**: 2026-01-22
|
||||
**Status**: Configured
|
||||
|
||||
Optimize the mass of a structural L-bracket while maintaining stress below yield and displacement within tolerance.
|
||||
|
||||
## Engineering Problem
|
||||
|
||||
### Model Description
|
||||
- **Geometry**: L-shaped mounting bracket with web and flange
|
||||
- **Material**: Steel (assumed based on typical applications)
|
||||
- **Baseline Mass**: 0.847 kg
|
||||
|
||||
### Loading Conditions
|
||||
Static loading with force applied at mounting holes. Fixed constraints at base.
|
||||
|
||||
## Optimization Formulation
|
||||
|
||||
### Design Variables (3)
|
||||
| Variable | Expression | Range | Units |
|
||||
|----------|------------|-------|-------|
|
||||
| Web Thickness | web_thickness | [2.0, 10.0] | mm |
|
||||
| Flange Width | flange_width | [15.0, 40.0] | mm |
|
||||
| Fillet Radius | fillet_radius | [2.0, 8.0] | mm |
|
||||
|
||||
### Objectives (1)
|
||||
| Objective | Direction | Weight | Source |
|
||||
|-----------|-----------|--------|--------|
|
||||
| Total Mass | minimize | 1.0 | mass_extractor |
|
||||
|
||||
### Constraints (1)
|
||||
| Constraint | Condition | Threshold | Type |
|
||||
|------------|-----------|-----------|------|
|
||||
| Max Stress | <= | 250 MPa | hard |
|
||||
|
||||
## Methodology
|
||||
|
||||
### Algorithm
|
||||
- **Primary**: TPE (Tree-structured Parzen Estimator)
|
||||
- **Max Trials**: 100
|
||||
- **Surrogate**: Disabled
|
||||
|
||||
### Physics Extraction
|
||||
- Mass: Extracted from NX expression `total_mass`
|
||||
- Stress: Von Mises stress from SOL101 static analysis
|
||||
|
||||
### Convergence Criteria
|
||||
- Max trials: 100
|
||||
- Early stopping: 20 trials without improvement
|
||||
|
||||
## Expected Outcomes
|
||||
|
||||
Based on the optimization setup:
|
||||
- Expected improvement: 15-30% mass reduction (typical for thickness optimization)
|
||||
- Key trade-offs: Mass vs. stress margin
|
||||
- Risk factors: None identified
|
||||
```
|
||||
|
||||
## Integration Points
|
||||
|
||||
- **Backend**: `api/services/claude_readme.py` calls Claude API with this prompt
|
||||
- **Endpoint**: `POST /api/intake/{study_name}/readme`
|
||||
- **Trigger**: Automatic on status transition to `configured`
|
||||
33
.devloop/browser_test_results.json
Normal file
33
.devloop/browser_test_results.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"timestamp": "2026-01-22T18:13:30.884945",
|
||||
"scenarios": [
|
||||
{
|
||||
"scenario_id": "browser_home_stats",
|
||||
"scenario_name": "Home page shows statistics",
|
||||
"passed": true,
|
||||
"duration_ms": 1413.166,
|
||||
"error": null,
|
||||
"details": {
|
||||
"navigated_to": "http://localhost:3003/",
|
||||
"found_selector": "text=Total Trials"
|
||||
}
|
||||
},
|
||||
{
|
||||
"scenario_id": "browser_expand_folder",
|
||||
"scenario_name": "Topic folder expands on click",
|
||||
"passed": true,
|
||||
"duration_ms": 2785.3219999999997,
|
||||
"error": null,
|
||||
"details": {
|
||||
"navigated_to": "http://localhost:3003/",
|
||||
"found_selector": "span:has-text('completed'), span:has-text('running'), span:has-text('paused')",
|
||||
"clicked": "button:has-text('trials')"
|
||||
}
|
||||
}
|
||||
],
|
||||
"summary": {
|
||||
"passed": 2,
|
||||
"failed": 0,
|
||||
"total": 2
|
||||
}
|
||||
}
|
||||
16
.devloop/current_plan.json
Normal file
16
.devloop/current_plan.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"objective": "Implement Dashboard Intake & AtomizerSpec Integration: Phase 1 - Create backend intake API routes (create, introspect, list, topics endpoints) and spec_manager service. The spec_models.py and JSON schema have already been updated with SpecStatus, IntrospectionData, BaselineData, and ExpressionInfo models. Now need to create: 1) backend/api/services/spec_manager.py for centralized spec CRUD, 2) backend/api/routes/intake.py with endpoints for creating inbox folders, running introspection, listing inbox contents, and listing topics, 3) Register the intake router in main.py. Reference the plan at docs/plans/DASHBOARD_INTAKE_ATOMIZERSPEC_INTEGRATION.md",
|
||||
"approach": "Fallback plan - manual implementation",
|
||||
"tasks": [
|
||||
{
|
||||
"id": "task_001",
|
||||
"description": "Implement: Implement Dashboard Intake & AtomizerSpec Integration: Phase 1 - Create backend intake API routes (create, introspect, list, topics endpoints) and spec_manager service. The spec_models.py and JSON schema have already been updated with SpecStatus, IntrospectionData, BaselineData, and ExpressionInfo models. Now need to create: 1) backend/api/services/spec_manager.py for centralized spec CRUD, 2) backend/api/routes/intake.py with endpoints for creating inbox folders, running introspection, listing inbox contents, and listing topics, 3) Register the intake router in main.py. Reference the plan at docs/plans/DASHBOARD_INTAKE_ATOMIZERSPEC_INTEGRATION.md",
|
||||
"file": "TBD",
|
||||
"priority": "high"
|
||||
}
|
||||
],
|
||||
"test_scenarios": [],
|
||||
"acceptance_criteria": [
|
||||
"Implement Dashboard Intake & AtomizerSpec Integration: Phase 1 - Create backend intake API routes (create, introspect, list, topics endpoints) and spec_manager service. The spec_models.py and JSON schema have already been updated with SpecStatus, IntrospectionData, BaselineData, and ExpressionInfo models. Now need to create: 1) backend/api/services/spec_manager.py for centralized spec CRUD, 2) backend/api/routes/intake.py with endpoints for creating inbox folders, running introspection, listing inbox contents, and listing topics, 3) Register the intake router in main.py. Reference the plan at docs/plans/DASHBOARD_INTAKE_ATOMIZERSPEC_INTEGRATION.md"
|
||||
]
|
||||
}
|
||||
64
.devloop/test_results.json
Normal file
64
.devloop/test_results.json
Normal file
@@ -0,0 +1,64 @@
|
||||
{
|
||||
"timestamp": "2026-01-22T21:10:54.742272",
|
||||
"scenarios": [
|
||||
{
|
||||
"scenario_id": "test_study_dir",
|
||||
"scenario_name": "Study directory exists: stage_3_arm",
|
||||
"passed": true,
|
||||
"duration_ms": 0.0,
|
||||
"error": null,
|
||||
"details": {
|
||||
"path": "C:\\Users\\antoi\\Atomizer\\studies\\Stage3\\stage_3_arm",
|
||||
"exists": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"scenario_id": "test_spec",
|
||||
"scenario_name": "AtomizerSpec is valid JSON",
|
||||
"passed": true,
|
||||
"duration_ms": 1.045,
|
||||
"error": null,
|
||||
"details": {
|
||||
"valid_json": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"scenario_id": "test_readme",
|
||||
"scenario_name": "README exists",
|
||||
"passed": true,
|
||||
"duration_ms": 0.0,
|
||||
"error": null,
|
||||
"details": {
|
||||
"path": "C:\\Users\\antoi\\Atomizer\\studies\\Stage3\\stage_3_arm\\README.md",
|
||||
"exists": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"scenario_id": "test_run_script",
|
||||
"scenario_name": "run_optimization.py exists",
|
||||
"passed": true,
|
||||
"duration_ms": 0.0,
|
||||
"error": null,
|
||||
"details": {
|
||||
"path": "C:\\Users\\antoi\\Atomizer\\studies\\Stage3\\stage_3_arm\\run_optimization.py",
|
||||
"exists": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"scenario_id": "test_model_dir",
|
||||
"scenario_name": "Model directory exists",
|
||||
"passed": true,
|
||||
"duration_ms": 0.0,
|
||||
"error": null,
|
||||
"details": {
|
||||
"path": "C:\\Users\\antoi\\Atomizer\\studies\\Stage3\\stage_3_arm\\1_setup\\model",
|
||||
"exists": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"summary": {
|
||||
"passed": 5,
|
||||
"failed": 0,
|
||||
"total": 5
|
||||
}
|
||||
}
|
||||
30
.gitignore
vendored
30
.gitignore
vendored
@@ -15,6 +15,11 @@ lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
|
||||
# NOTE: This repo includes a React frontend that legitimately uses src/lib/.
|
||||
# The broad Python ignore `lib/` would ignore that. Re-include it:
|
||||
!atomizer-dashboard/frontend/src/lib/
|
||||
!atomizer-dashboard/frontend/src/lib/**
|
||||
wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
@@ -110,5 +115,30 @@ _dat_run*.dat
|
||||
.claude-mcp-*.json
|
||||
.claude-prompt-*.md
|
||||
|
||||
# Backend logs
|
||||
backend_stdout.log
|
||||
backend_stderr.log
|
||||
*.log.bak
|
||||
|
||||
# Linter/formatter caches
|
||||
.ruff_cache/
|
||||
.mypy_cache/
|
||||
|
||||
# Auto-generated documentation (regenerate with: python -m optimization_engine.auto_doc all)
|
||||
docs/generated/
|
||||
|
||||
# NX model introspection caches (generated)
|
||||
**/_introspection_*.json
|
||||
**/_introspection_cache.json
|
||||
**/_temp_introspection.json
|
||||
**/params.exp
|
||||
|
||||
# Insight outputs (generated)
|
||||
**/3_insights/
|
||||
|
||||
# Malformed filenames (Windows path used as filename)
|
||||
C:*
|
||||
*.gitmodules
|
||||
|
||||
# project-context-sync (auto-generated, local only)
|
||||
PROJECT_STATE.md
|
||||
|
||||
@@ -7,6 +7,10 @@
|
||||
"ATOMIZER_MODE": "user",
|
||||
"ATOMIZER_ROOT": "C:/Users/antoi/Atomizer"
|
||||
}
|
||||
},
|
||||
"nxopen-docs": {
|
||||
"command": "C:/Users/antoi/CADtomaste/Atomaste-NXOpen-MCP/.venv/Scripts/python.exe",
|
||||
"args": ["-m", "nxopen_mcp.server", "--data-dir", "C:/Users/antoi/CADtomaste/Atomaste-NXOpen-MCP/data"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
21
.project-context.yml
Normal file
21
.project-context.yml
Normal file
@@ -0,0 +1,21 @@
|
||||
# project-context-sync configuration
|
||||
# See: https://github.com/clawdbot/skills/project-context-sync
|
||||
|
||||
project_context:
|
||||
# Use AI to generate smart summaries
|
||||
# true: Rich context with inferred focus and suggestions (uses tokens)
|
||||
# false: Raw git info only (fast, free)
|
||||
ai_summary: true
|
||||
|
||||
# How many recent commits to show
|
||||
recent_commits: 5
|
||||
|
||||
# Include file change stats in output
|
||||
include_diff_stats: true
|
||||
|
||||
# Sections to include in PROJECT_STATE.md
|
||||
sections:
|
||||
- last_commit # Always included
|
||||
- recent_changes # Recent commit list
|
||||
- current_focus # AI-generated (requires ai_summary: true)
|
||||
- suggested_next # AI-generated (requires ai_summary: true)
|
||||
58
CHANGELOG.md
58
CHANGELOG.md
@@ -6,6 +6,64 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.5.0] - 2025-01-24
|
||||
|
||||
### Project Cleanup & Organization
|
||||
- Deleted 102+ orphaned MCP session temp files
|
||||
- Removed build artifacts (htmlcov, dist, __pycache__)
|
||||
- Archived superseded plan documents (RALPH_LOOP V2/V3, CANVAS V3, etc.)
|
||||
- Moved debug/analysis scripts from tests/ to tools/analysis/
|
||||
- Updated .gitignore with missing patterns
|
||||
- Cleaned empty directories
|
||||
|
||||
## [0.4.0] - 2025-01-22
|
||||
|
||||
### Canvas UX Improvements (Phases 7-9)
|
||||
- **Resizable Panels**: Left sidebar (200-400px) and right panel (280-600px) with localStorage persistence
|
||||
- **All Palette Items Enabled**: All 8 node types now draggable (model, solver, designVar, extractor, objective, constraint, algorithm, surrogate)
|
||||
- **Solver Configuration**: Engine selection (NX Nastran, MSC Nastran, Python Script) with solution type dropdowns (SOL101-SOL200)
|
||||
|
||||
### AtomizerSpec v2.0
|
||||
- Unified JSON configuration schema for all studies
|
||||
- Added SolverEngine and NastranSolutionType types
|
||||
- Canvas position persistence for all nodes
|
||||
- Migration support from legacy optimization_config.json
|
||||
|
||||
## [0.3.0] - 2025-01-18
|
||||
|
||||
### Dashboard V3.1 - Canvas Builder
|
||||
- Visual workflow builder with 9 node types
|
||||
- Spec ↔ ReactFlow bidirectional converter
|
||||
- WebSocket real-time synchronization
|
||||
- Claude chat integration
|
||||
- Custom extractors with in-canvas code editor
|
||||
- Model introspection panel
|
||||
|
||||
### Learning Atomizer Core (LAC)
|
||||
- Persistent memory system for accumulated knowledge
|
||||
- Session insights recording (failures, workarounds, patterns)
|
||||
- Optimization outcome tracking
|
||||
|
||||
## [0.2.5] - 2025-01-16
|
||||
|
||||
### GNN Surrogate for Zernike Optimization
|
||||
- PolarMirrorGraph with fixed 3000-node polar grid
|
||||
- ZernikeGNN model with design-conditioned convolutions
|
||||
- Differentiable GPU-accelerated Zernike fitting
|
||||
- Training pipeline with multi-task loss
|
||||
|
||||
### DevLoop Automation
|
||||
- Closed-loop development system with AI agents
|
||||
- Gemini planning, Claude implementation
|
||||
- Playwright browser testing for dashboard UI
|
||||
|
||||
## [0.2.1] - 2025-01-07
|
||||
|
||||
### Optimization Engine v2.0 Restructure
|
||||
- Reorganized into modular subpackages (core/, nx/, study/, config/)
|
||||
- SpecManager for AtomizerSpec handling
|
||||
- Deprecation warnings for old import paths
|
||||
|
||||
### Phase 3.3 - Dashboard & Multi-Solution Support (November 23, 2025)
|
||||
|
||||
#### Added
|
||||
|
||||
43
CLAUDE.md
43
CLAUDE.md
@@ -55,6 +55,49 @@ If working directory is inside a study (`studies/*/`):
|
||||
- If no study context: Offer to create one or list available studies
|
||||
- After code changes: Update documentation proactively (SYS_12, cheatsheet)
|
||||
|
||||
### Step 5: Use DevLoop for Multi-Step Development Tasks
|
||||
|
||||
**CRITICAL: For any development task with 3+ steps, USE DEVLOOP instead of manual work.**
|
||||
|
||||
DevLoop is the closed-loop development system that coordinates AI agents for autonomous development:
|
||||
|
||||
```bash
|
||||
# Plan a task with Gemini
|
||||
python tools/devloop_cli.py plan "fix extractor exports"
|
||||
|
||||
# Implement with Claude
|
||||
python tools/devloop_cli.py implement
|
||||
|
||||
# Test filesystem/API
|
||||
python tools/devloop_cli.py test --study support_arm
|
||||
|
||||
# Test dashboard UI with Playwright
|
||||
python tools/devloop_cli.py browser --level full
|
||||
|
||||
# Analyze failures
|
||||
python tools/devloop_cli.py analyze
|
||||
|
||||
# Full autonomous cycle
|
||||
python tools/devloop_cli.py start "add new stress extractor"
|
||||
```
|
||||
|
||||
**When to use DevLoop:**
|
||||
- Fixing bugs that require multiple file changes
|
||||
- Adding new features or extractors
|
||||
- Debugging optimization failures
|
||||
- Testing dashboard UI changes
|
||||
- Any task that would take 3+ manual steps
|
||||
|
||||
**Browser test levels:**
|
||||
- `quick` - Smoke test (1 test)
|
||||
- `home` - Home page verification (2 tests)
|
||||
- `full` - All UI tests (5+ tests)
|
||||
- `study` - Canvas/dashboard for specific study
|
||||
|
||||
**DO NOT default to manual debugging** - use the automation we built!
|
||||
|
||||
**Full documentation**: `docs/guides/DEVLOOP.md`
|
||||
|
||||
---
|
||||
|
||||
## Quick Start - Protocol Operating System
|
||||
|
||||
1
CUsersantoiAtomizeropenapi_dump.json
Normal file
1
CUsersantoiAtomizeropenapi_dump.json
Normal file
File diff suppressed because one or more lines are too long
619
DEVELOPMENT.md
619
DEVELOPMENT.md
@@ -1,619 +0,0 @@
|
||||
# Atomizer Development Guide
|
||||
|
||||
**Last Updated**: 2025-11-21
|
||||
**Current Phase**: Phase 3.2 - Integration Sprint + Documentation
|
||||
**Status**: 🟢 Core Complete (100%) | ✅ Protocols 10/11/13 Active (100%) | 🎯 Dashboard Live (95%) | 📚 Documentation Reorganized
|
||||
|
||||
📘 **Quick Links**:
|
||||
- [Protocol Specifications](docs/PROTOCOLS.md) - All active protocols consolidated
|
||||
- [Documentation Index](docs/00_INDEX.md) - Complete documentation navigation
|
||||
- [README](README.md) - Project overview and quick start
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Current Phase](#current-phase)
|
||||
2. [Completed Features](#completed-features)
|
||||
3. [Active Development](#active-development)
|
||||
4. [Known Issues](#known-issues)
|
||||
5. [Testing Status](#testing-status)
|
||||
6. [Phase-by-Phase Progress](#phase-by-phase-progress)
|
||||
|
||||
---
|
||||
|
||||
## Current Phase
|
||||
|
||||
### Phase 3.2: Integration Sprint (🎯 TOP PRIORITY)
|
||||
|
||||
**Goal**: Connect LLM intelligence components to production workflow
|
||||
|
||||
**Timeline**: 2-4 weeks (Started 2025-11-17)
|
||||
|
||||
**Status**: LLM components built and tested individually (85% complete). Need to wire them into production runner.
|
||||
|
||||
📋 **Detailed Plan**: [docs/PHASE_3_2_INTEGRATION_PLAN.md](docs/PHASE_3_2_INTEGRATION_PLAN.md)
|
||||
|
||||
**Critical Path**:
|
||||
|
||||
#### Week 1: Make LLM Mode Accessible (16 hours)
|
||||
- [ ] **1.1** Create unified entry point `optimization_engine/run_optimization.py` (4h)
|
||||
- Add `--llm` flag for natural language mode
|
||||
- Add `--request` parameter for natural language input
|
||||
- Support both LLM and traditional JSON modes
|
||||
- Preserve backward compatibility
|
||||
|
||||
- [ ] **1.2** Wire LLMOptimizationRunner to production (8h)
|
||||
- Connect LLMWorkflowAnalyzer to entry point
|
||||
- Bridge LLMOptimizationRunner → OptimizationRunner
|
||||
- Pass model updater and simulation runner callables
|
||||
- Integrate with existing hook system
|
||||
|
||||
- [ ] **1.3** Create minimal example (2h)
|
||||
- Create `examples/llm_mode_demo.py`
|
||||
- Show natural language → optimization results
|
||||
- Compare traditional (100 lines) vs LLM (3 lines)
|
||||
|
||||
- [ ] **1.4** End-to-end integration test (2h)
|
||||
- Test with simple_beam_optimization study
|
||||
- Verify extractors generated correctly
|
||||
- Validate output matches manual mode
|
||||
|
||||
#### Week 2: Robustness & Safety (16 hours)
|
||||
- [ ] **2.1** Code validation pipeline (6h)
|
||||
- Create `optimization_engine/code_validator.py`
|
||||
- Implement syntax validation (ast.parse)
|
||||
- Implement security scanning (whitelist imports)
|
||||
- Implement test execution on example OP2
|
||||
- Add retry with LLM feedback on failure
|
||||
|
||||
- [ ] **2.2** Graceful fallback mechanisms (4h)
|
||||
- Wrap all LLM calls in try/except
|
||||
- Provide clear error messages
|
||||
- Offer fallback to manual mode
|
||||
- Never crash on LLM failure
|
||||
|
||||
- [ ] **2.3** LLM audit trail (3h)
|
||||
- Create `optimization_engine/llm_audit.py`
|
||||
- Log all LLM requests and responses
|
||||
- Log generated code with prompts
|
||||
- Create `llm_audit.json` in study output
|
||||
|
||||
- [ ] **2.4** Failure scenario testing (3h)
|
||||
- Test invalid natural language request
|
||||
- Test LLM unavailable
|
||||
- Test generated code syntax errors
|
||||
- Test validation failures
|
||||
|
||||
#### Week 3: Learning System (12 hours)
|
||||
- [ ] **3.1** Knowledge base implementation (4h)
|
||||
- Create `optimization_engine/knowledge_base.py`
|
||||
- Implement `save_session()` - Save successful workflows
|
||||
- Implement `search_templates()` - Find similar patterns
|
||||
- Add confidence scoring
|
||||
|
||||
- [ ] **3.2** Template extraction (4h)
|
||||
- Extract reusable patterns from generated code
|
||||
- Parameterize variable parts
|
||||
- Save templates with usage examples
|
||||
- Implement template application to new requests
|
||||
|
||||
- [ ] **3.3** ResearchAgent integration (4h)
|
||||
- Complete ResearchAgent implementation
|
||||
- Integrate into ExtractorOrchestrator error handling
|
||||
- Add user example collection workflow
|
||||
- Save learned knowledge to knowledge base
|
||||
|
||||
#### Week 4: Documentation & Discoverability (8 hours)
|
||||
- [ ] **4.1** Update README (2h)
|
||||
- Add "🤖 LLM-Powered Mode" section
|
||||
- Show example command with natural language
|
||||
- Link to detailed docs
|
||||
|
||||
- [ ] **4.2** Create LLM mode documentation (3h)
|
||||
- Create `docs/LLM_MODE.md`
|
||||
- Explain how LLM mode works
|
||||
- Provide usage examples
|
||||
- Add troubleshooting guide
|
||||
|
||||
- [ ] **4.3** Create demo video/GIF (1h)
|
||||
- Record terminal session
|
||||
- Show before/after (100 lines → 3 lines)
|
||||
- Create animated GIF for README
|
||||
|
||||
- [ ] **4.4** Update all planning docs (2h)
|
||||
- Update DEVELOPMENT.md status
|
||||
- Update DEVELOPMENT_GUIDANCE.md (80-90% → 90-95%)
|
||||
- Mark Phase 3.2 as ✅ Complete
|
||||
|
||||
---
|
||||
|
||||
## Completed Features
|
||||
|
||||
### ✅ Live Dashboard System (Completed 2025-11-21)
|
||||
|
||||
#### Backend (FastAPI + WebSocket)
|
||||
- [x] **FastAPI Backend** ([atomizer-dashboard/backend/](atomizer-dashboard/backend/))
|
||||
- REST API endpoints for study management
|
||||
- WebSocket streaming with file watching (Watchdog)
|
||||
- Real-time updates (<100ms latency)
|
||||
- CORS configured for local development
|
||||
|
||||
- [x] **REST API Endpoints** ([backend/api/routes/optimization.py](atomizer-dashboard/backend/api/routes/optimization.py))
|
||||
- `GET /api/optimization/studies` - List all studies
|
||||
- `GET /api/optimization/studies/{id}/status` - Get study status
|
||||
- `GET /api/optimization/studies/{id}/history` - Get trial history
|
||||
- `GET /api/optimization/studies/{id}/pruning` - Get pruning diagnostics
|
||||
|
||||
- [x] **WebSocket Streaming** ([backend/api/websocket/optimization_stream.py](atomizer-dashboard/backend/api/websocket/optimization_stream.py))
|
||||
- File watching on `optimization_history_incremental.json`
|
||||
- Real-time trial updates via WebSocket
|
||||
- Pruning alerts and progress updates
|
||||
- Automatic observer lifecycle management
|
||||
|
||||
#### Frontend (HTML + Chart.js)
|
||||
- [x] **Enhanced Live Dashboard** ([atomizer-dashboard/dashboard-enhanced.html](atomizer-dashboard/dashboard-enhanced.html))
|
||||
- Real-time WebSocket updates
|
||||
- Interactive convergence chart (Chart.js)
|
||||
- Parameter space scatter plot
|
||||
- Pruning alerts (toast notifications)
|
||||
- Data export (JSON/CSV)
|
||||
- Study auto-discovery and selection
|
||||
- Metric dashboard (trials, best value, pruned count)
|
||||
|
||||
#### React Frontend (In Progress)
|
||||
- [x] **Project Configuration** ([atomizer-dashboard/frontend/](atomizer-dashboard/frontend/))
|
||||
- React 18 + Vite 5 + TypeScript 5.2
|
||||
- TailwindCSS 3.3 for styling
|
||||
- Recharts 2.10 for charts
|
||||
- Complete build configuration
|
||||
|
||||
- [x] **TypeScript Types** ([frontend/src/types/](atomizer-dashboard/frontend/src/types/))
|
||||
- Complete type definitions for API data
|
||||
- WebSocket message types
|
||||
- Chart data structures
|
||||
|
||||
- [x] **Custom Hooks** ([frontend/src/hooks/useWebSocket.ts](atomizer-dashboard/frontend/src/hooks/useWebSocket.ts))
|
||||
- WebSocket connection management
|
||||
- Auto-reconnection with exponential backoff
|
||||
- Type-safe message routing
|
||||
|
||||
- [x] **Reusable Components** ([frontend/src/components/](atomizer-dashboard/frontend/src/components/))
|
||||
- Card, MetricCard, Badge, StudyCard components
|
||||
- TailwindCSS styling with dark theme
|
||||
|
||||
- [ ] **Dashboard Page** (Pending manual completion)
|
||||
- Need to run `npm install`
|
||||
- Create main.tsx, App.tsx, Dashboard.tsx
|
||||
- Integrate Recharts for charts
|
||||
- Test end-to-end
|
||||
|
||||
#### Documentation
|
||||
- [x] **Dashboard Master Plan** ([docs/DASHBOARD_MASTER_PLAN.md](docs/DASHBOARD_MASTER_PLAN.md))
|
||||
- Complete 3-page architecture (Configurator, Live Dashboard, Results Viewer)
|
||||
- Tech stack recommendations
|
||||
- Implementation phases
|
||||
|
||||
- [x] **Implementation Status** ([docs/DASHBOARD_IMPLEMENTATION_STATUS.md](docs/DASHBOARD_IMPLEMENTATION_STATUS.md))
|
||||
- Current progress tracking
|
||||
- Testing instructions
|
||||
- Next steps
|
||||
|
||||
- [x] **React Implementation Guide** ([docs/DASHBOARD_REACT_IMPLEMENTATION.md](docs/DASHBOARD_REACT_IMPLEMENTATION.md))
|
||||
- Complete templates for remaining components
|
||||
- Recharts integration examples
|
||||
- Troubleshooting guide
|
||||
|
||||
- [x] **Session Summary** ([docs/DASHBOARD_SESSION_SUMMARY.md](docs/DASHBOARD_SESSION_SUMMARY.md))
|
||||
- Features demonstrated
|
||||
- How to use the dashboard
|
||||
- Architecture explanation
|
||||
|
||||
### ✅ Phase 1: Plugin System & Infrastructure (Completed 2025-01-16)
|
||||
|
||||
#### Core Architecture
|
||||
- [x] **Hook Manager** ([optimization_engine/plugins/hook_manager.py](optimization_engine/plugins/hook_manager.py))
|
||||
- Hook registration with priority-based execution
|
||||
- Auto-discovery from plugin directories
|
||||
- Context passing to all hooks
|
||||
- Execution history tracking
|
||||
|
||||
- [x] **Lifecycle Hooks**
|
||||
- `pre_solve`: Execute before solver launch
|
||||
- `post_solve`: Execute after solve, before extraction
|
||||
- `post_extraction`: Execute after result extraction
|
||||
|
||||
#### Logging Infrastructure
|
||||
- [x] **Detailed Trial Logs** ([detailed_logger.py](optimization_engine/plugins/pre_solve/detailed_logger.py))
|
||||
- Per-trial log files in `optimization_results/trial_logs/`
|
||||
- Complete iteration trace with timestamps
|
||||
- Design variables, configuration, timeline
|
||||
- Extracted results and constraint evaluations
|
||||
|
||||
- [x] **High-Level Optimization Log** ([optimization_logger.py](optimization_engine/plugins/pre_solve/optimization_logger.py))
|
||||
- `optimization.log` file tracking overall progress
|
||||
- Configuration summary header
|
||||
- Compact START/COMPLETE entries per trial
|
||||
- Easy to scan format for monitoring
|
||||
|
||||
- [x] **Result Appenders**
|
||||
- [log_solve_complete.py](optimization_engine/plugins/post_solve/log_solve_complete.py) - Appends solve completion to trial logs
|
||||
- [log_results.py](optimization_engine/plugins/post_extraction/log_results.py) - Appends extracted results to trial logs
|
||||
- [optimization_logger_results.py](optimization_engine/plugins/post_extraction/optimization_logger_results.py) - Appends results to optimization.log
|
||||
|
||||
#### Project Organization
|
||||
- [x] **Studies Structure** ([studies/](studies/))
|
||||
- Standardized folder layout with `model/`, `optimization_results/`, `analysis/`
|
||||
- Comprehensive documentation in [studies/README.md](studies/README.md)
|
||||
- Example study: [bracket_stress_minimization/](studies/bracket_stress_minimization/)
|
||||
- Template structure for future studies
|
||||
|
||||
- [x] **Path Resolution** ([atomizer_paths.py](atomizer_paths.py))
|
||||
- Intelligent project root detection using marker files
|
||||
- Helper functions: `root()`, `optimization_engine()`, `studies()`, `tests()`
|
||||
- `ensure_imports()` for robust module imports
|
||||
- Works regardless of script location
|
||||
|
||||
#### Testing
|
||||
- [x] **Hook Validation Test** ([test_hooks_with_bracket.py](tests/test_hooks_with_bracket.py))
|
||||
- Verifies hook loading and execution
|
||||
- Tests 3 trials with dummy data
|
||||
- Checks hook execution history
|
||||
|
||||
- [x] **Integration Tests**
|
||||
- [run_5trial_test.py](tests/run_5trial_test.py) - Quick 5-trial optimization
|
||||
- [test_journal_optimization.py](tests/test_journal_optimization.py) - Full optimization test
|
||||
|
||||
#### Runner Enhancements
|
||||
- [x] **Context Passing** ([runner.py:332,365,412](optimization_engine/runner.py))
|
||||
- `output_dir` passed to all hook contexts
|
||||
- Trial number, design variables, extracted results
|
||||
- Configuration dictionary available to hooks
|
||||
|
||||
### ✅ Core Engine (Pre-Phase 1)
|
||||
- [x] Optuna integration with TPE sampler
|
||||
- [x] Multi-objective optimization support
|
||||
- [x] NX journal execution ([nx_solver.py](optimization_engine/nx_solver.py))
|
||||
- [x] Expression updates ([nx_updater.py](optimization_engine/nx_updater.py))
|
||||
- [x] OP2 result extraction (stress, displacement)
|
||||
- [x] Study management with resume capability
|
||||
- [x] Web dashboard (real-time monitoring)
|
||||
- [x] Precision control (4-decimal rounding)
|
||||
|
||||
---
|
||||
|
||||
## Active Development
|
||||
|
||||
### In Progress - Dashboard (High Priority)
|
||||
- [x] Backend API complete (FastAPI + WebSocket)
|
||||
- [x] HTML dashboard with Chart.js complete
|
||||
- [x] React project structure and configuration complete
|
||||
- [ ] **Complete React frontend** (Awaiting manual npm install)
|
||||
- [ ] Run `npm install` in frontend directory
|
||||
- [ ] Create main.tsx and App.tsx
|
||||
- [ ] Create Dashboard.tsx with Recharts
|
||||
- [ ] Test end-to-end with live optimization
|
||||
|
||||
### Up Next - Dashboard (Next Session)
|
||||
- [ ] Study Configurator page (React)
|
||||
- [ ] Results Report Viewer page (React)
|
||||
- [ ] LLM chat interface integration (future)
|
||||
- [ ] Docker deployment configuration
|
||||
|
||||
### In Progress - Phase 3.2 Integration
|
||||
- [ ] Feature registry creation (Phase 2, Week 1)
|
||||
- [ ] Claude skill definition (Phase 2, Week 1)
|
||||
|
||||
### Up Next (Phase 2, Week 2)
|
||||
- [ ] Natural language parser
|
||||
- [ ] Intent classification system
|
||||
- [ ] Entity extraction for optimization parameters
|
||||
- [ ] Conversational workflow manager
|
||||
|
||||
### Backlog (Phase 3+)
|
||||
- [ ] Custom function generator (RSS, weighted objectives)
|
||||
- [ ] Journal script generator
|
||||
- [ ] Code validation pipeline
|
||||
- [ ] Result analyzer with statistical analysis
|
||||
- [ ] Surrogate quality checker
|
||||
- [ ] HTML/PDF report generator
|
||||
|
||||
---
|
||||
|
||||
## Known Issues
|
||||
|
||||
### Critical
|
||||
- None currently
|
||||
|
||||
### Minor
|
||||
- [ ] `.claude/settings.local.json` modified during development (contains user-specific settings)
|
||||
- [ ] Some old bash background processes still running from previous tests
|
||||
|
||||
### Documentation
|
||||
- [ ] Need to add examples of custom hooks to studies/README.md
|
||||
- [ ] Missing API documentation for hook_manager methods
|
||||
- [ ] No developer guide for creating new plugins
|
||||
|
||||
---
|
||||
|
||||
## Testing Status
|
||||
|
||||
### Automated Tests
|
||||
- ✅ **Hook system** - `test_hooks_with_bracket.py` passing
|
||||
- ✅ **5-trial integration** - `run_5trial_test.py` working
|
||||
- ✅ **Full optimization** - `test_journal_optimization.py` functional
|
||||
- ⏳ **Unit tests** - Need to create for individual modules
|
||||
- ⏳ **CI/CD pipeline** - Not yet set up
|
||||
|
||||
### Manual Testing
|
||||
- ✅ Bracket optimization (50 trials)
|
||||
- ✅ Log file generation in correct locations
|
||||
- ✅ Hook execution at all lifecycle points
|
||||
- ✅ Path resolution across different script locations
|
||||
- ✅ **Dashboard backend** - REST API and WebSocket tested successfully
|
||||
- ✅ **HTML dashboard** - Live updates working with Chart.js
|
||||
- ⏳ **React dashboard** - Pending npm install and completion
|
||||
- ⏳ Resume functionality with config validation
|
||||
|
||||
### Test Coverage
|
||||
- Hook manager: ~80% (core functionality tested)
|
||||
- Logging plugins: 100% (tested via integration tests)
|
||||
- Path resolution: 100% (tested in all scripts)
|
||||
- Result extractors: ~70% (basic tests exist)
|
||||
- **Dashboard backend**: ~90% (REST endpoints and WebSocket tested)
|
||||
- **Dashboard frontend**: ~60% (HTML version tested, React pending)
|
||||
- Overall: ~65% estimated
|
||||
|
||||
---
|
||||
|
||||
## Phase-by-Phase Progress
|
||||
|
||||
### Phase 1: Plugin System ✅ (100% Complete)
|
||||
|
||||
**Completed** (2025-01-16):
|
||||
- [x] Hook system for optimization lifecycle
|
||||
- [x] Plugin auto-discovery and registration
|
||||
- [x] Hook manager with priority-based execution
|
||||
- [x] Detailed per-trial logs (`trial_logs/`)
|
||||
- [x] High-level optimization log (`optimization.log`)
|
||||
- [x] Context passing system for hooks
|
||||
- [x] Studies folder structure
|
||||
- [x] Comprehensive studies documentation
|
||||
- [x] Model file organization (`model/` folder)
|
||||
- [x] Intelligent path resolution
|
||||
- [x] Test suite for hook system
|
||||
|
||||
**Deferred to Future Phases**:
|
||||
- Feature registry → Phase 2 (with LLM interface)
|
||||
- `pre_mesh` and `post_mesh` hooks → Future (not needed for current workflow)
|
||||
- Custom objective/constraint registration → Phase 3 (Code Generation)
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: LLM Integration 🟡 (0% Complete)
|
||||
|
||||
**Target**: 2 weeks (Started 2025-01-16)
|
||||
|
||||
#### Week 1 Todos (Feature Registry & Claude Skill)
|
||||
- [ ] Create `optimization_engine/feature_registry.json`
|
||||
- [ ] Extract all current capabilities
|
||||
- [ ] Draft `.claude/skills/atomizer.md`
|
||||
- [ ] Test LLM's ability to navigate codebase
|
||||
|
||||
#### Week 2 Todos (Natural Language Interface)
|
||||
- [ ] Implement intent classifier
|
||||
- [ ] Build entity extractor
|
||||
- [ ] Create workflow manager
|
||||
- [ ] Test end-to-end: "Create a stress minimization study"
|
||||
|
||||
**Success Criteria**:
|
||||
- [ ] LLM can create optimization from natural language in <5 turns
|
||||
- [ ] 90% of user requests understood correctly
|
||||
- [ ] Zero manual JSON editing required
|
||||
|
||||
---
|
||||
|
||||
### Phase 3: Code Generation ⏳ (Not Started)
|
||||
|
||||
**Target**: 3 weeks
|
||||
|
||||
**Key Deliverables**:
|
||||
- [ ] Custom function generator
|
||||
- [ ] RSS (Root Sum Square) template
|
||||
- [ ] Weighted objectives template
|
||||
- [ ] Custom constraints template
|
||||
- [ ] Journal script generator
|
||||
- [ ] Code validation pipeline
|
||||
- [ ] Safe execution environment
|
||||
|
||||
**Success Criteria**:
|
||||
- [ ] LLM generates 10+ custom functions with zero errors
|
||||
- [ ] All generated code passes safety validation
|
||||
- [ ] Users save 50% time vs. manual coding
|
||||
|
||||
---
|
||||
|
||||
### Phase 4: Analysis & Decision Support ⏳ (Not Started)
|
||||
|
||||
**Target**: 3 weeks
|
||||
|
||||
**Key Deliverables**:
|
||||
- [ ] Result analyzer (convergence, sensitivity, outliers)
|
||||
- [ ] Surrogate model quality checker (R², CV score, confidence intervals)
|
||||
- [ ] Decision assistant (trade-offs, what-if analysis, recommendations)
|
||||
|
||||
**Success Criteria**:
|
||||
- [ ] Surrogate quality detection 95% accurate
|
||||
- [ ] Recommendations lead to 30% faster convergence
|
||||
- [ ] Users report higher confidence in results
|
||||
|
||||
---
|
||||
|
||||
### Phase 5: Automated Reporting ⏳ (Not Started)
|
||||
|
||||
**Target**: 2 weeks
|
||||
|
||||
**Key Deliverables**:
|
||||
- [ ] Report generator with Jinja2 templates
|
||||
- [ ] Multi-format export (HTML, PDF, Markdown, JSON)
|
||||
- [ ] LLM-written narrative explanations
|
||||
|
||||
**Success Criteria**:
|
||||
- [ ] Reports generated in <30 seconds
|
||||
- [ ] Narrative quality rated 4/5 by engineers
|
||||
- [ ] 80% of reports used without manual editing
|
||||
|
||||
---
|
||||
|
||||
### Phase 6: NX MCP Enhancement ⏳ (Not Started)
|
||||
|
||||
**Target**: 4 weeks
|
||||
|
||||
**Key Deliverables**:
|
||||
- [ ] NX documentation MCP server
|
||||
- [ ] Advanced NX operations library
|
||||
- [ ] Feature bank with 50+ pre-built operations
|
||||
|
||||
**Success Criteria**:
|
||||
- [ ] NX MCP answers 95% of API questions correctly
|
||||
- [ ] Feature bank covers 80% of common workflows
|
||||
- [ ] Users write 50% less manual journal code
|
||||
|
||||
---
|
||||
|
||||
### Phase 7: Self-Improving System ⏳ (Not Started)
|
||||
|
||||
**Target**: 4 weeks
|
||||
|
||||
**Key Deliverables**:
|
||||
- [ ] Feature learning system
|
||||
- [ ] Best practices database
|
||||
- [ ] Continuous documentation generation
|
||||
|
||||
**Success Criteria**:
|
||||
- [ ] 20+ user-contributed features in library
|
||||
- [ ] Pattern recognition identifies 10+ best practices
|
||||
- [ ] Documentation auto-updates with zero manual effort
|
||||
|
||||
---
|
||||
|
||||
## Development Commands
|
||||
|
||||
### Running Dashboard
|
||||
```bash
|
||||
# Start backend server
|
||||
cd atomizer-dashboard/backend
|
||||
python -m uvicorn api.main:app --reload --host 0.0.0.0 --port 8000
|
||||
|
||||
# Access HTML dashboard (current)
|
||||
# Open browser: http://localhost:8000
|
||||
|
||||
# Start React frontend (when ready)
|
||||
cd atomizer-dashboard/frontend
|
||||
npm install # First time only
|
||||
npm run dev # Starts on http://localhost:3000
|
||||
```
|
||||
|
||||
### Running Tests
|
||||
```bash
|
||||
# Hook validation (3 trials, fast)
|
||||
python tests/test_hooks_with_bracket.py
|
||||
|
||||
# Quick integration test (5 trials)
|
||||
python tests/run_5trial_test.py
|
||||
|
||||
# Full optimization test
|
||||
python tests/test_journal_optimization.py
|
||||
```
|
||||
|
||||
### Code Quality
|
||||
```bash
|
||||
# Run linter (when available)
|
||||
# pylint optimization_engine/
|
||||
|
||||
# Run type checker (when available)
|
||||
# mypy optimization_engine/
|
||||
|
||||
# Run all tests (when test suite is complete)
|
||||
# pytest tests/
|
||||
```
|
||||
|
||||
### Git Workflow
|
||||
```bash
|
||||
# Stage all changes
|
||||
git add .
|
||||
|
||||
# Commit with conventional commits format
|
||||
git commit -m "feat: description" # New feature
|
||||
git commit -m "fix: description" # Bug fix
|
||||
git commit -m "docs: description" # Documentation
|
||||
git commit -m "test: description" # Tests
|
||||
git commit -m "refactor: description" # Code refactoring
|
||||
|
||||
# Push to GitHub
|
||||
git push origin main
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Documentation
|
||||
|
||||
### For Developers
|
||||
- [DEVELOPMENT_ROADMAP.md](DEVELOPMENT_ROADMAP.md) - Strategic vision and phases
|
||||
- [studies/README.md](studies/README.md) - Studies folder organization
|
||||
- [CHANGELOG.md](CHANGELOG.md) - Version history
|
||||
|
||||
### Dashboard Documentation
|
||||
- [docs/DASHBOARD_MASTER_PLAN.md](docs/DASHBOARD_MASTER_PLAN.md) - Complete architecture blueprint
|
||||
- [docs/DASHBOARD_IMPLEMENTATION_STATUS.md](docs/DASHBOARD_IMPLEMENTATION_STATUS.md) - Current progress
|
||||
- [docs/DASHBOARD_REACT_IMPLEMENTATION.md](docs/DASHBOARD_REACT_IMPLEMENTATION.md) - React implementation guide
|
||||
- [docs/DASHBOARD_SESSION_SUMMARY.md](docs/DASHBOARD_SESSION_SUMMARY.md) - Session summary
|
||||
- [atomizer-dashboard/README.md](atomizer-dashboard/README.md) - Dashboard quick start
|
||||
- [atomizer-dashboard/backend/README.md](atomizer-dashboard/backend/README.md) - Backend API docs
|
||||
- [atomizer-dashboard/frontend/README.md](atomizer-dashboard/frontend/README.md) - Frontend setup guide
|
||||
|
||||
### For Users
|
||||
- [README.md](README.md) - Project overview and quick start
|
||||
- [docs/INDEX.md](docs/INDEX.md) - Complete documentation index
|
||||
- [docs/](docs/) - Additional documentation
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
### Architecture Decisions
|
||||
- **Hook system**: Chose priority-based execution to allow precise control of plugin order
|
||||
- **Path resolution**: Used marker files instead of environment variables for simplicity
|
||||
- **Logging**: Two-tier system (detailed trial logs + high-level optimization.log) for different use cases
|
||||
|
||||
### Performance Considerations
|
||||
- Hook execution adds <1s overhead per trial (acceptable for FEA simulations)
|
||||
- Path resolution caching could improve startup time (future optimization)
|
||||
- Log file sizes grow linearly with trials (~10KB per trial)
|
||||
|
||||
### Future Considerations
|
||||
- Consider moving to structured logging (JSON) for easier parsing
|
||||
- May need database for storing hook execution history (currently in-memory)
|
||||
- Dashboard integration will require WebSocket for real-time log streaming
|
||||
|
||||
---
|
||||
|
||||
**Last Updated**: 2025-11-21
|
||||
**Maintained by**: Antoine Polvé (antoine@atomaste.com)
|
||||
**Repository**: [GitHub - Atomizer](https://github.com/yourusername/Atomizer)
|
||||
|
||||
---
|
||||
|
||||
## Recent Updates (November 21, 2025)
|
||||
|
||||
### Dashboard System Implementation ✅
|
||||
- **Backend**: FastAPI + WebSocket with real-time file watching complete
|
||||
- **HTML Dashboard**: Functional dashboard with Chart.js, data export, pruning alerts
|
||||
- **React Setup**: Complete project configuration, types, hooks, components
|
||||
- **Documentation**: 5 comprehensive markdown documents covering architecture, implementation, and usage
|
||||
|
||||
### Next Immediate Steps
|
||||
1. Run `npm install` in `atomizer-dashboard/frontend`
|
||||
2. Create `main.tsx`, `App.tsx`, and `Dashboard.tsx` using provided templates
|
||||
3. Test React dashboard with live optimization
|
||||
4. Build Study Configurator page (next major feature)
|
||||
@@ -1,63 +0,0 @@
|
||||
# Atomizer Installation Guide
|
||||
|
||||
## Step 1: Install Miniconda (Recommended)
|
||||
|
||||
1. Download Miniconda from: https://docs.conda.io/en/latest/miniconda.html
|
||||
- Choose: **Miniconda3 Windows 64-bit**
|
||||
|
||||
2. Run the installer:
|
||||
- Check "Add Miniconda3 to my PATH environment variable"
|
||||
- Check "Register Miniconda3 as my default Python"
|
||||
|
||||
3. Restart your terminal/VSCode after installation
|
||||
|
||||
## Step 2: Create Atomizer Environment
|
||||
|
||||
Open **Anaconda Prompt** (or any terminal after restart) and run:
|
||||
|
||||
```bash
|
||||
cd C:\Users\Antoine\Atomizer
|
||||
conda env create -f environment.yml
|
||||
conda activate atomizer
|
||||
```
|
||||
|
||||
## Step 3: Install PyTorch with GPU Support (Optional but Recommended)
|
||||
|
||||
If you have an NVIDIA GPU:
|
||||
|
||||
```bash
|
||||
conda activate atomizer
|
||||
pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu121
|
||||
pip install torch-geometric
|
||||
```
|
||||
|
||||
## Step 4: Verify Installation
|
||||
|
||||
```bash
|
||||
conda activate atomizer
|
||||
python -c "import torch; import optuna; import pyNastran; print('All imports OK!')"
|
||||
python -c "import torch; print(f'CUDA available: {torch.cuda.is_available()}')"
|
||||
```
|
||||
|
||||
## Step 5: Train Neural Network
|
||||
|
||||
```bash
|
||||
conda activate atomizer
|
||||
cd C:\Users\Antoine\Atomizer\atomizer-field
|
||||
python train_parametric.py --train_dir ../atomizer_field_training_data/bracket_stiffness_optimization_atomizerfield --epochs 100 --output_dir runs/bracket_model
|
||||
```
|
||||
|
||||
## Quick Commands Reference
|
||||
|
||||
```bash
|
||||
# Activate environment (do this every time you open a new terminal)
|
||||
conda activate atomizer
|
||||
|
||||
# Train neural network
|
||||
cd C:\Users\Antoine\Atomizer\atomizer-field
|
||||
python train_parametric.py --train_dir ../atomizer_field_training_data/bracket_stiffness_optimization_atomizerfield --epochs 100
|
||||
|
||||
# Run optimization with neural acceleration
|
||||
cd C:\Users\Antoine\Atomizer\studies\bracket_stiffness_optimization_atomizerfield
|
||||
python run_optimization.py --run --trials 100 --enable-nn
|
||||
```
|
||||
111
PROJECT_STATUS.md
Normal file
111
PROJECT_STATUS.md
Normal file
@@ -0,0 +1,111 @@
|
||||
# PROJECT_STATUS.md
|
||||
|
||||
> **Bridge document for Mario (Clawdbot) ↔ Claude Code coordination**
|
||||
>
|
||||
> Both AIs should read this at session start. Update when priorities change.
|
||||
|
||||
*Last updated: 2026-01-27 by Mario*
|
||||
|
||||
---
|
||||
|
||||
## Current Focus
|
||||
|
||||
**Phase**: Foundation (Phase 1)
|
||||
**Sprint**: 2026-01-27 to 2026-02-03
|
||||
|
||||
### This Week's Priorities
|
||||
|
||||
**Now (Sprint 1.5): Draft + Publish (S2)**
|
||||
1. 🔴 Implement DraftManager (local autosave draft per study)
|
||||
2. 🔴 Add Draft vs Published banner + Publish button
|
||||
3. 🔴 Restore/discard draft prompt on load
|
||||
|
||||
**Next (Sprint 2): Create Wizard v1 shell**
|
||||
4. 🟡 /create route + stepper
|
||||
5. 🟡 Files step (dependency tree + _i.prt warnings)
|
||||
6. 🟡 Introspection step (expressions + DV selection)
|
||||
|
||||
### Completed recently
|
||||
- Spec/Canvas wiring sync foundation (converters, connect/delete wiring, output picker, panel rewiring, edge projection)
|
||||
|
||||
### Blocked
|
||||
- None (but local npm install on this server fails due to peer deps; run builds/tests on Windows dev env)
|
||||
|
||||
---
|
||||
|
||||
## Active Decisions
|
||||
|
||||
| Decision | Summary | Date |
|
||||
|----------|---------|------|
|
||||
| Full Partnership | Mario = PM, reviewer, architect. Antoine = developer, NX. | 2026-01-27 |
|
||||
| Dashboard on Windows | Keep simple for now, hybrid architecture later | 2026-01-27 |
|
||||
| Adopt Clawdbot Patterns | MEMORY.md, QUICK_REF.md, simplified CLAUDE.md | 2026-01-27 |
|
||||
|
||||
---
|
||||
|
||||
## For Claude Code
|
||||
|
||||
When starting a session:
|
||||
|
||||
1. ✅ Read CLAUDE.md (system instructions)
|
||||
2. ✅ Read PROJECT_STATUS.md (this file — current priorities)
|
||||
3. ✅ Read `knowledge_base/lac/session_insights/failure.jsonl` (critical lessons)
|
||||
4. 🔲 After session: Commit any new LAC insights to Git
|
||||
|
||||
### LAC Commit Protocol (NEW)
|
||||
|
||||
After each significant session, commit LAC changes:
|
||||
|
||||
```bash
|
||||
cd Atomizer
|
||||
git add knowledge_base/lac/
|
||||
git commit -m "lac: Session insights from YYYY-MM-DD"
|
||||
git push origin main && git push github main
|
||||
```
|
||||
|
||||
This ensures Mario can see what Claude Code learned.
|
||||
|
||||
---
|
||||
|
||||
## For Mario (Clawdbot)
|
||||
|
||||
When checking on Atomizer:
|
||||
|
||||
1. Pull latest from Gitea: `cd /home/papa/repos/Atomizer && git pull`
|
||||
2. Check `knowledge_base/lac/session_insights/` for new learnings
|
||||
3. Update tracking files in `/home/papa/clawd/memory/atomizer/`
|
||||
4. Update this file if priorities change
|
||||
|
||||
### Heartbeat Check (Add to HEARTBEAT.md)
|
||||
|
||||
```markdown
|
||||
### Atomizer Check (weekly)
|
||||
- git pull Atomizer repo
|
||||
- Check for new LAC insights
|
||||
- Review recent commits
|
||||
- Update roadmap if needed
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Recent Activity
|
||||
|
||||
| Date | Activity | Who |
|
||||
|------|----------|-----|
|
||||
| 2026-01-27 | Created master plan in PKM | Mario |
|
||||
| 2026-01-27 | Created tracking files | Mario |
|
||||
| 2026-01-27 | ACKed Atomizer project | Mario |
|
||||
| 2026-01-27 | Canvas V3.1 improvements | Claude Code (prior) |
|
||||
|
||||
---
|
||||
|
||||
## Links
|
||||
|
||||
- **Master Plan**: `/home/papa/obsidian-vault/2-Projects/Atomizer-AtomasteAI/Development/ATOMIZER-NEXT-LEVEL-MASTERPLAN.md`
|
||||
- **Mario's Tracking**: `/home/papa/clawd/memory/atomizer/`
|
||||
- **LAC Insights**: `knowledge_base/lac/session_insights/`
|
||||
- **Full Roadmap**: See Master Plan in PKM
|
||||
|
||||
---
|
||||
|
||||
*This file lives in the repo. Both AIs can read it. Only update when priorities change.*
|
||||
@@ -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
|
||||
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")
|
||||
@@ -60,6 +73,12 @@ app.include_router(insights.router, prefix="/api/insights", tags=["insights"])
|
||||
app.include_router(context.router, prefix="/api/context", tags=["context"])
|
||||
app.include_router(files.router, prefix="/api/files", tags=["files"])
|
||||
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():
|
||||
@@ -67,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")
|
||||
@@ -84,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")
|
||||
|
||||
@@ -187,7 +187,15 @@ async def session_websocket(websocket: WebSocket, session_id: str):
|
||||
continue
|
||||
|
||||
# Get canvas state from message or use stored state
|
||||
canvas_state = data.get("canvas_state") or current_canvas_state
|
||||
msg_canvas = data.get("canvas_state")
|
||||
canvas_state = msg_canvas if msg_canvas is not None else current_canvas_state
|
||||
|
||||
# Debug logging
|
||||
if canvas_state:
|
||||
node_count = len(canvas_state.get("nodes", []))
|
||||
print(f"[Claude WS] Sending message with canvas state: {node_count} nodes")
|
||||
else:
|
||||
print("[Claude WS] Sending message WITHOUT canvas state")
|
||||
|
||||
async for chunk in manager.send_message(
|
||||
session_id,
|
||||
@@ -401,6 +409,175 @@ async def websocket_chat(websocket: WebSocket):
|
||||
pass
|
||||
|
||||
|
||||
# ========== POWER MODE: Direct API with Write Tools ==========
|
||||
|
||||
@router.websocket("/sessions/{session_id}/ws/power")
|
||||
async def power_mode_websocket(websocket: WebSocket, session_id: str):
|
||||
"""
|
||||
WebSocket for power mode chat using direct Anthropic API with write tools.
|
||||
|
||||
Unlike the regular /ws endpoint which uses Claude CLI + MCP,
|
||||
this uses AtomizerClaudeAgent directly with built-in write tools.
|
||||
This allows immediate modifications without permission prompts.
|
||||
|
||||
Message formats (client -> server):
|
||||
{"type": "message", "content": "user message"}
|
||||
{"type": "set_study", "study_id": "study_name"}
|
||||
{"type": "ping"}
|
||||
|
||||
Message formats (server -> client):
|
||||
{"type": "text", "content": "..."}
|
||||
{"type": "tool_call", "tool": "...", "input": {...}}
|
||||
{"type": "tool_result", "result": "..."}
|
||||
{"type": "done", "tool_calls": [...]}
|
||||
{"type": "error", "message": "..."}
|
||||
{"type": "spec_modified", "changes": [...]}
|
||||
{"type": "pong"}
|
||||
"""
|
||||
await websocket.accept()
|
||||
|
||||
manager = get_session_manager()
|
||||
session = manager.get_session(session_id)
|
||||
|
||||
if not session:
|
||||
await websocket.send_json({"type": "error", "message": "Session not found"})
|
||||
await websocket.close()
|
||||
return
|
||||
|
||||
# Import AtomizerClaudeAgent for direct API access
|
||||
from api.services.claude_agent import AtomizerClaudeAgent
|
||||
|
||||
# Create agent with study context
|
||||
agent = AtomizerClaudeAgent(study_id=session.study_id)
|
||||
conversation_history: List[Dict[str, Any]] = []
|
||||
|
||||
# Load initial spec and set canvas state so Claude sees current canvas
|
||||
initial_spec = agent.load_current_spec()
|
||||
if initial_spec:
|
||||
# Send initial spec to frontend
|
||||
await websocket.send_json({
|
||||
"type": "spec_updated",
|
||||
"spec": initial_spec,
|
||||
"reason": "initial_load"
|
||||
})
|
||||
|
||||
try:
|
||||
while True:
|
||||
data = await websocket.receive_json()
|
||||
|
||||
if data.get("type") == "message":
|
||||
content = data.get("content", "")
|
||||
if not content:
|
||||
continue
|
||||
|
||||
try:
|
||||
# Use streaming API with tool support for real-time response
|
||||
last_tool_calls = []
|
||||
async for event in agent.chat_stream_with_tools(content, conversation_history):
|
||||
event_type = event.get("type")
|
||||
|
||||
if event_type == "text":
|
||||
# Stream text tokens to frontend immediately
|
||||
await websocket.send_json({
|
||||
"type": "text",
|
||||
"content": event.get("content", ""),
|
||||
})
|
||||
|
||||
elif event_type == "tool_call":
|
||||
# Tool is being called
|
||||
tool_info = event.get("tool", {})
|
||||
await websocket.send_json({
|
||||
"type": "tool_call",
|
||||
"tool": tool_info,
|
||||
})
|
||||
|
||||
elif event_type == "tool_result":
|
||||
# Tool finished executing
|
||||
tool_name = event.get("tool", "")
|
||||
await websocket.send_json({
|
||||
"type": "tool_result",
|
||||
"tool": tool_name,
|
||||
"result": event.get("result", ""),
|
||||
})
|
||||
|
||||
# If it was a write tool, send full updated spec
|
||||
if tool_name in ["add_design_variable", "add_extractor",
|
||||
"add_objective", "add_constraint",
|
||||
"update_spec_field", "remove_node",
|
||||
"create_study"]:
|
||||
# Load updated spec and update agent's canvas state
|
||||
updated_spec = agent.load_current_spec()
|
||||
if updated_spec:
|
||||
await websocket.send_json({
|
||||
"type": "spec_updated",
|
||||
"tool": tool_name,
|
||||
"spec": updated_spec, # Full spec for direct canvas update
|
||||
})
|
||||
|
||||
elif event_type == "done":
|
||||
# Streaming complete
|
||||
last_tool_calls = event.get("tool_calls", [])
|
||||
await websocket.send_json({
|
||||
"type": "done",
|
||||
"tool_calls": last_tool_calls,
|
||||
})
|
||||
|
||||
# Update conversation history for next message
|
||||
# Note: For proper history tracking, we'd need to store messages properly
|
||||
# For now, we append the user message and response
|
||||
conversation_history.append({"role": "user", "content": content})
|
||||
conversation_history.append({"role": "assistant", "content": event.get("response", "")})
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
await websocket.send_json({
|
||||
"type": "error",
|
||||
"message": str(e),
|
||||
})
|
||||
|
||||
elif data.get("type") == "canvas_edit":
|
||||
# User made a manual edit to the canvas - update Claude's context
|
||||
spec = data.get("spec")
|
||||
if spec:
|
||||
agent.set_canvas_state(spec)
|
||||
await websocket.send_json({
|
||||
"type": "canvas_edit_received",
|
||||
"acknowledged": True
|
||||
})
|
||||
|
||||
elif data.get("type") == "set_study":
|
||||
study_id = data.get("study_id")
|
||||
if study_id:
|
||||
await manager.set_study_context(session_id, study_id)
|
||||
# Recreate agent with new study context
|
||||
agent = AtomizerClaudeAgent(study_id=study_id)
|
||||
conversation_history = [] # Clear history on study change
|
||||
# Load spec for new study
|
||||
new_spec = agent.load_current_spec()
|
||||
await websocket.send_json({
|
||||
"type": "context_updated",
|
||||
"study_id": study_id,
|
||||
})
|
||||
if new_spec:
|
||||
await websocket.send_json({
|
||||
"type": "spec_updated",
|
||||
"spec": new_spec,
|
||||
"reason": "study_change"
|
||||
})
|
||||
|
||||
elif data.get("type") == "ping":
|
||||
await websocket.send_json({"type": "pong"})
|
||||
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
except Exception as e:
|
||||
try:
|
||||
await websocket.send_json({"type": "error", "message": str(e)})
|
||||
except:
|
||||
pass
|
||||
|
||||
|
||||
@router.get("/suggestions")
|
||||
async def get_chat_suggestions(study_id: Optional[str] = None):
|
||||
"""
|
||||
|
||||
920
atomizer-dashboard/backend/api/routes/claude_code.py
Normal file
920
atomizer-dashboard/backend/api/routes/claude_code.py
Normal file
@@ -0,0 +1,920 @@
|
||||
"""
|
||||
Claude Code WebSocket Routes
|
||||
|
||||
Provides WebSocket endpoint that connects to actual Claude Code CLI.
|
||||
This gives dashboard users the same power as terminal Claude Code users.
|
||||
|
||||
Unlike the MCP-based approach in claude.py:
|
||||
- Spawns actual Claude Code CLI processes
|
||||
- Full file editing capabilities
|
||||
- Full command execution
|
||||
- Opus 4.5 model with unlimited tool use
|
||||
|
||||
Also provides single-shot endpoints for code generation:
|
||||
- POST /generate-extractor: Generate Python extractor code
|
||||
- POST /validate-extractor: Validate Python syntax
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, HTTPException, Body
|
||||
from pydantic import BaseModel
|
||||
from typing import Dict, Optional, List
|
||||
import json
|
||||
import asyncio
|
||||
import re
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from api.services.claude_code_session import (
|
||||
get_claude_code_manager,
|
||||
ClaudeCodeSession,
|
||||
ATOMIZER_ROOT,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/claude-code", tags=["Claude Code"])
|
||||
|
||||
|
||||
# ==================== Extractor Code Generation ====================
|
||||
|
||||
|
||||
class ExtractorGenerationRequest(BaseModel):
|
||||
"""Request model for extractor code generation"""
|
||||
|
||||
prompt: str # User's description
|
||||
study_id: Optional[str] = None # Study context
|
||||
existing_code: Optional[str] = None # Current code to improve
|
||||
output_names: List[str] = [] # Expected outputs
|
||||
|
||||
|
||||
class ExtractorGenerationResponse(BaseModel):
|
||||
"""Response model for generated code"""
|
||||
|
||||
code: str # Generated Python code
|
||||
outputs: List[str] # Detected output names
|
||||
explanation: Optional[str] = None # Brief explanation
|
||||
|
||||
|
||||
class CodeValidationRequest(BaseModel):
|
||||
"""Request model for code validation"""
|
||||
|
||||
code: str
|
||||
|
||||
|
||||
class CodeValidationResponse(BaseModel):
|
||||
"""Response model for validation result"""
|
||||
|
||||
valid: bool
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
@router.post("/generate-extractor", response_model=ExtractorGenerationResponse)
|
||||
async def generate_extractor_code(request: ExtractorGenerationRequest):
|
||||
"""
|
||||
Generate Python extractor code using Claude Code CLI.
|
||||
|
||||
Uses --print mode for single-shot generation (no session state).
|
||||
Focused system prompt for fast, accurate results.
|
||||
|
||||
Args:
|
||||
request: ExtractorGenerationRequest with prompt and context
|
||||
|
||||
Returns:
|
||||
ExtractorGenerationResponse with generated code and detected outputs
|
||||
"""
|
||||
# Build focused system prompt for extractor generation
|
||||
system_prompt = """You are generating a Python custom extractor function for Atomizer FEA optimization.
|
||||
|
||||
IMPORTANT: Choose the appropriate function signature based on what data is needed:
|
||||
|
||||
## Option 1: FEA Results (OP2) - Use for stresses, displacements, frequencies, forces
|
||||
```python
|
||||
def extract(op2_path: str, fem_path: str, params: dict, subcase_id: int = 1) -> dict:
|
||||
from pyNastran.op2.op2 import OP2
|
||||
op2 = OP2()
|
||||
op2.read_op2(op2_path)
|
||||
# Access: op2.displacements[subcase_id], op2.cquad4_stress[subcase_id], etc.
|
||||
return {"max_stress": value}
|
||||
```
|
||||
|
||||
## Option 2: Expression/Computed Values (no FEA needed) - Use for dimensions, volumes, derived values
|
||||
```python
|
||||
def extract(trial_dir: str, config: dict, context: dict) -> dict:
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
# Read mass properties (if available from model introspection)
|
||||
mass_file = Path(trial_dir) / "mass_properties.json"
|
||||
if mass_file.exists():
|
||||
with open(mass_file) as f:
|
||||
props = json.load(f)
|
||||
mass = props.get("mass_kg", 0)
|
||||
|
||||
# Or use config values directly (e.g., expression values)
|
||||
length_mm = config.get("length_expression", 100)
|
||||
|
||||
# context has results from other extractors
|
||||
other_value = context.get("other_extractor_output", 0)
|
||||
|
||||
return {"computed_value": length_mm * 2}
|
||||
```
|
||||
|
||||
Available imports: pyNastran.op2.op2.OP2, numpy, pathlib.Path, json
|
||||
|
||||
Common OP2 patterns:
|
||||
- Displacement: op2.displacements[subcase_id].data[0, :, 1:4] (x,y,z)
|
||||
- Stress: op2.cquad4_stress[subcase_id] or op2.ctria3_stress[subcase_id]
|
||||
- Eigenvalues: op2.eigenvalues[subcase_id]
|
||||
- Mass: op2.grid_point_weight (if available)
|
||||
|
||||
Return ONLY the complete Python code wrapped in ```python ... ```. No explanations."""
|
||||
|
||||
# Build user prompt with context
|
||||
user_prompt = f"Generate a custom extractor that: {request.prompt}"
|
||||
|
||||
if request.existing_code:
|
||||
user_prompt += (
|
||||
f"\n\nImprove or modify this existing code:\n```python\n{request.existing_code}\n```"
|
||||
)
|
||||
|
||||
if request.output_names:
|
||||
user_prompt += (
|
||||
f"\n\nThe function should output these keys: {', '.join(request.output_names)}"
|
||||
)
|
||||
|
||||
try:
|
||||
# Call Claude CLI with focused prompt (single-shot, no session)
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
"claude",
|
||||
"--print",
|
||||
"--system-prompt",
|
||||
system_prompt,
|
||||
stdin=asyncio.subprocess.PIPE,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
cwd=str(ATOMIZER_ROOT),
|
||||
env={
|
||||
**os.environ,
|
||||
"ATOMIZER_ROOT": str(ATOMIZER_ROOT),
|
||||
},
|
||||
)
|
||||
|
||||
# Send prompt and wait for response (60 second timeout)
|
||||
stdout, stderr = await asyncio.wait_for(
|
||||
process.communicate(user_prompt.encode("utf-8")), timeout=60.0
|
||||
)
|
||||
|
||||
if process.returncode != 0:
|
||||
error_text = stderr.decode("utf-8", errors="replace")
|
||||
raise HTTPException(status_code=500, detail=f"Claude CLI error: {error_text[:500]}")
|
||||
|
||||
output = stdout.decode("utf-8", errors="replace")
|
||||
|
||||
# Extract Python code from markdown code block
|
||||
code_match = re.search(r"```python\s*(.*?)\s*```", output, re.DOTALL)
|
||||
if code_match:
|
||||
code = code_match.group(1).strip()
|
||||
else:
|
||||
# Try to find def extract( directly (Claude might not use code blocks)
|
||||
if "def extract(" in output:
|
||||
# Extract from def extract to end of function
|
||||
code = output.strip()
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Failed to parse generated code - no Python code block found",
|
||||
)
|
||||
|
||||
# Detect output names from return statement
|
||||
detected_outputs: List[str] = []
|
||||
return_match = re.search(r"return\s*\{([^}]+)\}", code)
|
||||
if return_match:
|
||||
# Parse dict keys like 'max_stress': ... or "mass": ...
|
||||
key_matches = re.findall(r"['\"]([^'\"]+)['\"]:", return_match.group(1))
|
||||
detected_outputs = key_matches
|
||||
|
||||
# Use detected outputs or fall back to requested ones
|
||||
final_outputs = detected_outputs if detected_outputs else request.output_names
|
||||
|
||||
# Extract any explanation text before the code block
|
||||
explanation = None
|
||||
parts = output.split("```python")
|
||||
if len(parts) > 1 and parts[0].strip():
|
||||
explanation = parts[0].strip()[:300] # First 300 chars max
|
||||
|
||||
return ExtractorGenerationResponse(
|
||||
code=code, outputs=final_outputs, explanation=explanation
|
||||
)
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
raise HTTPException(
|
||||
status_code=504, detail="Code generation timed out (60s limit). Try a simpler prompt."
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Generation failed: {str(e)}")
|
||||
|
||||
|
||||
class DependencyCheckResponse(BaseModel):
|
||||
"""Response model for dependency check"""
|
||||
|
||||
imports: List[str]
|
||||
available: List[str]
|
||||
missing: List[str]
|
||||
warnings: List[str]
|
||||
|
||||
|
||||
# Known available packages in the atomizer environment
|
||||
KNOWN_PACKAGES = {
|
||||
"pyNastran": ["pyNastran", "pyNastran.op2", "pyNastran.bdf"],
|
||||
"numpy": ["numpy", "np"],
|
||||
"scipy": ["scipy"],
|
||||
"pandas": ["pandas", "pd"],
|
||||
"pathlib": ["pathlib", "Path"],
|
||||
"json": ["json"],
|
||||
"os": ["os"],
|
||||
"re": ["re"],
|
||||
"math": ["math"],
|
||||
"typing": ["typing"],
|
||||
"collections": ["collections"],
|
||||
"itertools": ["itertools"],
|
||||
"functools": ["functools"],
|
||||
}
|
||||
|
||||
|
||||
def extract_imports(code: str) -> List[str]:
|
||||
"""Extract import statements from Python code using AST"""
|
||||
import ast
|
||||
|
||||
imports = []
|
||||
try:
|
||||
tree = ast.parse(code)
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Import):
|
||||
for alias in node.names:
|
||||
imports.append(alias.name.split(".")[0])
|
||||
elif isinstance(node, ast.ImportFrom):
|
||||
if node.module:
|
||||
imports.append(node.module.split(".")[0])
|
||||
except SyntaxError:
|
||||
# Fall back to regex if AST fails
|
||||
import re
|
||||
|
||||
import_pattern = r"^(?:from\s+(\w+)|import\s+(\w+))"
|
||||
for line in code.split("\n"):
|
||||
match = re.match(import_pattern, line.strip())
|
||||
if match:
|
||||
imports.append(match.group(1) or match.group(2))
|
||||
|
||||
return list(set(imports))
|
||||
|
||||
|
||||
@router.post("/check-dependencies", response_model=DependencyCheckResponse)
|
||||
async def check_code_dependencies(request: CodeValidationRequest):
|
||||
"""
|
||||
Check which imports in the code are available in the atomizer environment.
|
||||
|
||||
Args:
|
||||
request: CodeValidationRequest with code to check
|
||||
|
||||
Returns:
|
||||
DependencyCheckResponse with available and missing packages
|
||||
"""
|
||||
imports = extract_imports(request.code)
|
||||
|
||||
available = []
|
||||
missing = []
|
||||
warnings = []
|
||||
|
||||
# Known available in atomizer
|
||||
known_available = set()
|
||||
for pkg, aliases in KNOWN_PACKAGES.items():
|
||||
known_available.update([a.split(".")[0] for a in aliases])
|
||||
|
||||
for imp in imports:
|
||||
if imp in known_available:
|
||||
available.append(imp)
|
||||
else:
|
||||
# Check if it's a standard library module
|
||||
try:
|
||||
import importlib.util
|
||||
|
||||
spec = importlib.util.find_spec(imp)
|
||||
if spec is not None:
|
||||
available.append(imp)
|
||||
else:
|
||||
missing.append(imp)
|
||||
except (ImportError, ModuleNotFoundError):
|
||||
missing.append(imp)
|
||||
|
||||
# Add warnings for potentially problematic imports
|
||||
if "matplotlib" in imports:
|
||||
warnings.append("matplotlib may cause issues in headless NX environment")
|
||||
if "tensorflow" in imports or "torch" in imports:
|
||||
warnings.append("Deep learning frameworks may cause memory issues during optimization")
|
||||
|
||||
return DependencyCheckResponse(
|
||||
imports=imports, available=available, missing=missing, warnings=warnings
|
||||
)
|
||||
|
||||
|
||||
@router.post("/validate-extractor", response_model=CodeValidationResponse)
|
||||
async def validate_extractor_code(request: CodeValidationRequest):
|
||||
"""
|
||||
Validate Python extractor code syntax and structure.
|
||||
|
||||
Args:
|
||||
request: CodeValidationRequest with code to validate
|
||||
|
||||
Returns:
|
||||
CodeValidationResponse with valid flag and optional error message
|
||||
"""
|
||||
import ast
|
||||
|
||||
try:
|
||||
tree = ast.parse(request.code)
|
||||
|
||||
# Check for extract function
|
||||
has_extract = False
|
||||
extract_returns_dict = False
|
||||
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.FunctionDef) and node.name == "extract":
|
||||
has_extract = True
|
||||
# Check if it has a return statement
|
||||
for child in ast.walk(node):
|
||||
if isinstance(child, ast.Return) and child.value:
|
||||
if isinstance(child.value, ast.Dict):
|
||||
extract_returns_dict = True
|
||||
elif isinstance(child.value, ast.Name):
|
||||
# Variable return, could be a dict
|
||||
extract_returns_dict = True
|
||||
|
||||
if not has_extract:
|
||||
return CodeValidationResponse(
|
||||
valid=False, error="Code must define a function named 'extract'"
|
||||
)
|
||||
|
||||
if not extract_returns_dict:
|
||||
return CodeValidationResponse(
|
||||
valid=False, error="extract() function should return a dict"
|
||||
)
|
||||
|
||||
return CodeValidationResponse(valid=True, error=None)
|
||||
|
||||
except SyntaxError as e:
|
||||
return CodeValidationResponse(valid=False, error=f"Line {e.lineno}: {e.msg}")
|
||||
except Exception as e:
|
||||
return CodeValidationResponse(valid=False, error=str(e))
|
||||
|
||||
|
||||
# ==================== Live Preview / Test Execution ====================
|
||||
|
||||
|
||||
class TestExtractorRequest(BaseModel):
|
||||
"""Request model for testing extractor code"""
|
||||
|
||||
code: str
|
||||
study_id: Optional[str] = None
|
||||
subcase_id: int = 1
|
||||
|
||||
|
||||
class TestExtractorResponse(BaseModel):
|
||||
"""Response model for extractor test"""
|
||||
|
||||
success: bool
|
||||
outputs: Optional[Dict[str, float]] = None
|
||||
error: Optional[str] = None
|
||||
execution_time_ms: Optional[float] = None
|
||||
|
||||
|
||||
@router.post("/test-extractor", response_model=TestExtractorResponse)
|
||||
async def test_extractor_code(request: TestExtractorRequest):
|
||||
"""
|
||||
Test extractor code against a sample or study OP2 file.
|
||||
|
||||
This executes the code in a sandboxed environment and returns the results.
|
||||
If a study_id is provided, it uses the most recent trial's OP2 file.
|
||||
Otherwise, it uses mock data for testing.
|
||||
|
||||
Args:
|
||||
request: TestExtractorRequest with code and optional study context
|
||||
|
||||
Returns:
|
||||
TestExtractorResponse with extracted outputs or error
|
||||
"""
|
||||
import time
|
||||
import tempfile
|
||||
import traceback
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
# Find OP2 file to test against
|
||||
op2_path = None
|
||||
fem_path = None
|
||||
|
||||
if request.study_id:
|
||||
# Look for the most recent trial's OP2 file
|
||||
from pathlib import Path
|
||||
|
||||
study_path = ATOMIZER_ROOT / "studies" / request.study_id
|
||||
|
||||
if not study_path.exists():
|
||||
# Try nested path
|
||||
for parent in (ATOMIZER_ROOT / "studies").iterdir():
|
||||
if parent.is_dir():
|
||||
nested = parent / request.study_id
|
||||
if nested.exists():
|
||||
study_path = nested
|
||||
break
|
||||
|
||||
if study_path.exists():
|
||||
# Look in 2_iterations for trial folders
|
||||
iterations_dir = study_path / "2_iterations"
|
||||
if iterations_dir.exists():
|
||||
# Find the latest trial folder with an OP2 file
|
||||
trial_folders = sorted(
|
||||
[
|
||||
d
|
||||
for d in iterations_dir.iterdir()
|
||||
if d.is_dir() and d.name.startswith("trial_")
|
||||
],
|
||||
reverse=True,
|
||||
)
|
||||
for trial_dir in trial_folders:
|
||||
op2_files = list(trial_dir.glob("*.op2"))
|
||||
fem_files = list(trial_dir.glob("*.fem"))
|
||||
if op2_files:
|
||||
op2_path = str(op2_files[0])
|
||||
if fem_files:
|
||||
fem_path = str(fem_files[0])
|
||||
break
|
||||
|
||||
if not op2_path:
|
||||
# No OP2 file available - run in "dry run" mode with mock
|
||||
return TestExtractorResponse(
|
||||
success=False,
|
||||
error="No OP2 file available for testing. Run at least one optimization trial first.",
|
||||
execution_time_ms=(time.time() - start_time) * 1000,
|
||||
)
|
||||
|
||||
# Execute the code in a sandboxed way
|
||||
try:
|
||||
# Create a temporary module
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as f:
|
||||
f.write(request.code)
|
||||
temp_file = f.name
|
||||
|
||||
try:
|
||||
# Import the module
|
||||
import importlib.util
|
||||
|
||||
spec = importlib.util.spec_from_file_location("temp_extractor", temp_file)
|
||||
if spec is None or spec.loader is None:
|
||||
return TestExtractorResponse(
|
||||
success=False,
|
||||
error="Failed to load code as module",
|
||||
execution_time_ms=(time.time() - start_time) * 1000,
|
||||
)
|
||||
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
# Check for extract function
|
||||
if not hasattr(module, "extract"):
|
||||
return TestExtractorResponse(
|
||||
success=False,
|
||||
error="Code does not define an 'extract' function",
|
||||
execution_time_ms=(time.time() - start_time) * 1000,
|
||||
)
|
||||
|
||||
# Call the extract function
|
||||
extract_fn = module.extract
|
||||
result = extract_fn(
|
||||
op2_path=op2_path,
|
||||
fem_path=fem_path or "",
|
||||
params={}, # Empty params for testing
|
||||
subcase_id=request.subcase_id,
|
||||
)
|
||||
|
||||
if not isinstance(result, dict):
|
||||
return TestExtractorResponse(
|
||||
success=False,
|
||||
error=f"extract() returned {type(result).__name__}, expected dict",
|
||||
execution_time_ms=(time.time() - start_time) * 1000,
|
||||
)
|
||||
|
||||
# Convert all values to float for JSON serialization
|
||||
outputs = {}
|
||||
for k, v in result.items():
|
||||
try:
|
||||
outputs[k] = float(v)
|
||||
except (TypeError, ValueError):
|
||||
outputs[k] = 0.0 # Can't convert, use 0
|
||||
|
||||
return TestExtractorResponse(
|
||||
success=True, outputs=outputs, execution_time_ms=(time.time() - start_time) * 1000
|
||||
)
|
||||
|
||||
finally:
|
||||
# Clean up temp file
|
||||
import os
|
||||
|
||||
try:
|
||||
os.unlink(temp_file)
|
||||
except:
|
||||
pass
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"{type(e).__name__}: {str(e)}"
|
||||
tb = traceback.format_exc()
|
||||
# Include relevant part of traceback
|
||||
if "temp_extractor.py" in tb:
|
||||
lines = tb.split("\n")
|
||||
relevant = [l for l in lines if "temp_extractor.py" in l or "line" in l.lower()]
|
||||
if relevant:
|
||||
error_msg += f"\n{relevant[-1]}"
|
||||
|
||||
return TestExtractorResponse(
|
||||
success=False, error=error_msg, execution_time_ms=(time.time() - start_time) * 1000
|
||||
)
|
||||
|
||||
|
||||
# ==================== Streaming Generation ====================
|
||||
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
|
||||
@router.post("/generate-extractor/stream")
|
||||
async def generate_extractor_code_stream(request: ExtractorGenerationRequest):
|
||||
"""
|
||||
Stream Python extractor code generation using Claude Code CLI.
|
||||
|
||||
Uses Server-Sent Events (SSE) to stream tokens as they arrive.
|
||||
|
||||
Event types:
|
||||
- data: {"type": "token", "content": "..."} - Partial code token
|
||||
- data: {"type": "done", "code": "...", "outputs": [...]} - Final result
|
||||
- data: {"type": "error", "message": "..."} - Error occurred
|
||||
|
||||
Args:
|
||||
request: ExtractorGenerationRequest with prompt and context
|
||||
|
||||
Returns:
|
||||
StreamingResponse with text/event-stream content type
|
||||
"""
|
||||
# Build focused system prompt for extractor generation
|
||||
system_prompt = """You are generating a Python custom extractor function for Atomizer FEA optimization.
|
||||
|
||||
The function MUST:
|
||||
1. Have signature: def extract(op2_path: str, fem_path: str, params: dict, subcase_id: int = 1) -> dict
|
||||
2. Return a dict with extracted values (e.g., {"max_stress": 150.5, "mass": 2.3})
|
||||
3. Use pyNastran.op2.op2.OP2 for reading OP2 results
|
||||
4. Handle missing data gracefully with try/except blocks
|
||||
|
||||
Available imports (already available, just use them):
|
||||
- from pyNastran.op2.op2 import OP2
|
||||
- import numpy as np
|
||||
- from pathlib import Path
|
||||
|
||||
Common patterns:
|
||||
- Displacement: op2.displacements[subcase_id].data[0, :, 1:4] (x,y,z components)
|
||||
- Stress: op2.cquad4_stress[subcase_id] or op2.ctria3_stress[subcase_id]
|
||||
- Eigenvalues: op2.eigenvalues[subcase_id]
|
||||
|
||||
Return ONLY the complete Python code wrapped in ```python ... ```. No explanations outside the code block."""
|
||||
|
||||
# Build user prompt with context
|
||||
user_prompt = f"Generate a custom extractor that: {request.prompt}"
|
||||
|
||||
if request.existing_code:
|
||||
user_prompt += (
|
||||
f"\n\nImprove or modify this existing code:\n```python\n{request.existing_code}\n```"
|
||||
)
|
||||
|
||||
if request.output_names:
|
||||
user_prompt += (
|
||||
f"\n\nThe function should output these keys: {', '.join(request.output_names)}"
|
||||
)
|
||||
|
||||
async def generate():
|
||||
full_output = ""
|
||||
|
||||
try:
|
||||
# Call Claude CLI with streaming output
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
"claude",
|
||||
"--print",
|
||||
"--system-prompt",
|
||||
system_prompt,
|
||||
stdin=asyncio.subprocess.PIPE,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
cwd=str(ATOMIZER_ROOT),
|
||||
env={
|
||||
**os.environ,
|
||||
"ATOMIZER_ROOT": str(ATOMIZER_ROOT),
|
||||
},
|
||||
)
|
||||
|
||||
# Write prompt to stdin and close
|
||||
process.stdin.write(user_prompt.encode("utf-8"))
|
||||
await process.stdin.drain()
|
||||
process.stdin.close()
|
||||
|
||||
# Stream stdout chunks as they arrive
|
||||
while True:
|
||||
chunk = await asyncio.wait_for(
|
||||
process.stdout.read(256), # Read in small chunks for responsiveness
|
||||
timeout=60.0,
|
||||
)
|
||||
if not chunk:
|
||||
break
|
||||
|
||||
decoded = chunk.decode("utf-8", errors="replace")
|
||||
full_output += decoded
|
||||
|
||||
# Send token event
|
||||
yield f"data: {json.dumps({'type': 'token', 'content': decoded})}\n\n"
|
||||
|
||||
# Wait for process to complete
|
||||
await process.wait()
|
||||
|
||||
# Check for errors
|
||||
if process.returncode != 0:
|
||||
stderr = await process.stderr.read()
|
||||
error_text = stderr.decode("utf-8", errors="replace")
|
||||
yield f"data: {json.dumps({'type': 'error', 'message': f'Claude CLI error: {error_text[:500]}'})}\n\n"
|
||||
return
|
||||
|
||||
# Parse the complete output to extract code
|
||||
code_match = re.search(r"```python\s*(.*?)\s*```", full_output, re.DOTALL)
|
||||
if code_match:
|
||||
code = code_match.group(1).strip()
|
||||
elif "def extract(" in full_output:
|
||||
code = full_output.strip()
|
||||
else:
|
||||
yield f"data: {json.dumps({'type': 'error', 'message': 'Failed to parse generated code'})}\n\n"
|
||||
return
|
||||
|
||||
# Detect output names
|
||||
detected_outputs: List[str] = []
|
||||
return_match = re.search(r"return\s*\{([^}]+)\}", code)
|
||||
if return_match:
|
||||
key_matches = re.findall(r"['\"]([^'\"]+)['\"]:", return_match.group(1))
|
||||
detected_outputs = key_matches
|
||||
|
||||
final_outputs = detected_outputs if detected_outputs else request.output_names
|
||||
|
||||
# Send completion event with parsed code
|
||||
yield f"data: {json.dumps({'type': 'done', 'code': code, 'outputs': final_outputs})}\n\n"
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
yield f"data: {json.dumps({'type': 'error', 'message': 'Generation timed out (60s limit)'})}\n\n"
|
||||
except Exception as e:
|
||||
yield f"data: {json.dumps({'type': 'error', 'message': str(e)})}\n\n"
|
||||
|
||||
return StreamingResponse(
|
||||
generate(),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"X-Accel-Buffering": "no", # Disable nginx buffering
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# ==================== Session Management ====================
|
||||
|
||||
# Store active WebSocket connections
|
||||
_active_connections: Dict[str, WebSocket] = {}
|
||||
|
||||
|
||||
@router.post("/sessions")
|
||||
async def create_claude_code_session(study_id: Optional[str] = None):
|
||||
"""
|
||||
Create a new Claude Code session.
|
||||
|
||||
Args:
|
||||
study_id: Optional study to provide context
|
||||
|
||||
Returns:
|
||||
Session info including session_id
|
||||
"""
|
||||
try:
|
||||
manager = get_claude_code_manager()
|
||||
session = manager.create_session(study_id)
|
||||
|
||||
return {
|
||||
"session_id": session.session_id,
|
||||
"study_id": session.study_id,
|
||||
"working_dir": str(session.working_dir),
|
||||
"message": "Claude Code session created. Connect via WebSocket to chat.",
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/sessions/{session_id}")
|
||||
async def get_claude_code_session(session_id: str):
|
||||
"""Get session info"""
|
||||
manager = get_claude_code_manager()
|
||||
session = manager.get_session(session_id)
|
||||
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
|
||||
return {
|
||||
"session_id": session.session_id,
|
||||
"study_id": session.study_id,
|
||||
"working_dir": str(session.working_dir),
|
||||
"has_canvas_state": session.canvas_state is not None,
|
||||
"conversation_length": len(session.conversation_history),
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/sessions/{session_id}")
|
||||
async def delete_claude_code_session(session_id: str):
|
||||
"""Delete a session"""
|
||||
manager = get_claude_code_manager()
|
||||
manager.remove_session(session_id)
|
||||
return {"message": "Session deleted"}
|
||||
|
||||
|
||||
@router.websocket("/ws")
|
||||
async def claude_code_websocket(websocket: WebSocket):
|
||||
"""
|
||||
WebSocket for full Claude Code CLI access (no session required).
|
||||
|
||||
This is a simplified endpoint that creates a session per connection.
|
||||
|
||||
Message formats (client -> server):
|
||||
{"type": "init", "study_id": "optional_study_name"}
|
||||
{"type": "message", "content": "user message"}
|
||||
{"type": "set_canvas", "canvas_state": {...}}
|
||||
{"type": "ping"}
|
||||
|
||||
Message formats (server -> client):
|
||||
{"type": "initialized", "session_id": "...", "study_id": "..."}
|
||||
{"type": "text", "content": "..."}
|
||||
{"type": "done"}
|
||||
{"type": "refresh_canvas", "study_id": "...", "reason": "..."}
|
||||
{"type": "error", "content": "..."}
|
||||
{"type": "pong"}
|
||||
"""
|
||||
print("[ClaudeCode WS] Connection attempt received")
|
||||
await websocket.accept()
|
||||
print("[ClaudeCode WS] WebSocket accepted")
|
||||
|
||||
manager = get_claude_code_manager()
|
||||
session: Optional[ClaudeCodeSession] = None
|
||||
|
||||
try:
|
||||
while True:
|
||||
data = await websocket.receive_json()
|
||||
msg_type = data.get("type")
|
||||
|
||||
if msg_type == "init":
|
||||
# Create or reinitialize session
|
||||
study_id = data.get("study_id")
|
||||
session = manager.create_session(study_id)
|
||||
_active_connections[session.session_id] = websocket
|
||||
|
||||
await websocket.send_json(
|
||||
{
|
||||
"type": "initialized",
|
||||
"session_id": session.session_id,
|
||||
"study_id": session.study_id,
|
||||
"working_dir": str(session.working_dir),
|
||||
}
|
||||
)
|
||||
|
||||
elif msg_type == "message":
|
||||
if not session:
|
||||
# Auto-create session if not initialized
|
||||
session = manager.create_session()
|
||||
_active_connections[session.session_id] = websocket
|
||||
|
||||
content = data.get("content", "")
|
||||
if not content:
|
||||
continue
|
||||
|
||||
# Update canvas state if provided with message
|
||||
if data.get("canvas_state"):
|
||||
session.set_canvas_state(data["canvas_state"])
|
||||
|
||||
# Stream response from Claude Code CLI
|
||||
async for chunk in session.send_message(content):
|
||||
await websocket.send_json(chunk)
|
||||
|
||||
elif msg_type == "set_canvas":
|
||||
if session:
|
||||
session.set_canvas_state(data.get("canvas_state", {}))
|
||||
await websocket.send_json(
|
||||
{
|
||||
"type": "canvas_updated",
|
||||
}
|
||||
)
|
||||
|
||||
elif msg_type == "ping":
|
||||
await websocket.send_json({"type": "pong"})
|
||||
|
||||
except WebSocketDisconnect:
|
||||
# Clean up on disconnect
|
||||
if session:
|
||||
_active_connections.pop(session.session_id, None)
|
||||
# Keep session in manager for potential reconnect
|
||||
except Exception as e:
|
||||
try:
|
||||
await websocket.send_json(
|
||||
{
|
||||
"type": "error",
|
||||
"content": str(e),
|
||||
}
|
||||
)
|
||||
except:
|
||||
pass
|
||||
if session:
|
||||
_active_connections.pop(session.session_id, None)
|
||||
|
||||
|
||||
@router.websocket("/ws/{study_id:path}")
|
||||
async def claude_code_websocket_with_study(websocket: WebSocket, study_id: str):
|
||||
"""
|
||||
WebSocket for Claude Code CLI with study context.
|
||||
|
||||
Same as /ws but automatically initializes with the given study.
|
||||
|
||||
Message formats (client -> server):
|
||||
{"type": "message", "content": "user message"}
|
||||
{"type": "set_canvas", "canvas_state": {...}}
|
||||
{"type": "ping"}
|
||||
|
||||
Message formats (server -> client):
|
||||
{"type": "initialized", "session_id": "...", "study_id": "..."}
|
||||
{"type": "text", "content": "..."}
|
||||
{"type": "done"}
|
||||
{"type": "refresh_canvas", "study_id": "...", "reason": "..."}
|
||||
{"type": "error", "content": "..."}
|
||||
{"type": "pong"}
|
||||
"""
|
||||
print(f"[ClaudeCode WS] Connection attempt received for study: {study_id}")
|
||||
await websocket.accept()
|
||||
print(f"[ClaudeCode WS] WebSocket accepted for study: {study_id}")
|
||||
|
||||
manager = get_claude_code_manager()
|
||||
session = manager.create_session(study_id)
|
||||
_active_connections[session.session_id] = websocket
|
||||
|
||||
# Send initialization message
|
||||
await websocket.send_json(
|
||||
{
|
||||
"type": "initialized",
|
||||
"session_id": session.session_id,
|
||||
"study_id": session.study_id,
|
||||
"working_dir": str(session.working_dir),
|
||||
}
|
||||
)
|
||||
|
||||
try:
|
||||
while True:
|
||||
data = await websocket.receive_json()
|
||||
msg_type = data.get("type")
|
||||
|
||||
if msg_type == "message":
|
||||
content = data.get("content", "")
|
||||
if not content:
|
||||
continue
|
||||
|
||||
# Update canvas state if provided with message
|
||||
if data.get("canvas_state"):
|
||||
session.set_canvas_state(data["canvas_state"])
|
||||
|
||||
# Stream response from Claude Code CLI
|
||||
async for chunk in session.send_message(content):
|
||||
await websocket.send_json(chunk)
|
||||
|
||||
elif msg_type == "set_canvas":
|
||||
session.set_canvas_state(data.get("canvas_state", {}))
|
||||
await websocket.send_json(
|
||||
{
|
||||
"type": "canvas_updated",
|
||||
}
|
||||
)
|
||||
|
||||
elif msg_type == "ping":
|
||||
await websocket.send_json({"type": "pong"})
|
||||
|
||||
except WebSocketDisconnect:
|
||||
_active_connections.pop(session.session_id, None)
|
||||
except Exception as e:
|
||||
try:
|
||||
await websocket.send_json(
|
||||
{
|
||||
"type": "error",
|
||||
"content": str(e),
|
||||
}
|
||||
)
|
||||
except:
|
||||
pass
|
||||
_active_connections.pop(session.session_id, None)
|
||||
416
atomizer-dashboard/backend/api/routes/devloop.py
Normal file
416
atomizer-dashboard/backend/api/routes/devloop.py
Normal file
@@ -0,0 +1,416 @@
|
||||
"""
|
||||
DevLoop API Endpoints - Closed-loop development orchestration.
|
||||
|
||||
Provides REST API and WebSocket for:
|
||||
- Starting/stopping development cycles
|
||||
- Monitoring progress
|
||||
- Executing single phases
|
||||
- Viewing history and learnings
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, WebSocket, WebSocketDisconnect, BackgroundTasks
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Any, Dict, List, Optional
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
|
||||
# Add project root to path
|
||||
sys.path.append(str(Path(__file__).parent.parent.parent.parent.parent))
|
||||
|
||||
router = APIRouter(prefix="/devloop", tags=["devloop"])
|
||||
|
||||
# Global orchestrator instance
|
||||
_orchestrator = None
|
||||
_active_cycle = None
|
||||
_websocket_clients: List[WebSocket] = []
|
||||
|
||||
|
||||
def get_orchestrator():
|
||||
"""Get or create the DevLoop orchestrator."""
|
||||
global _orchestrator
|
||||
if _orchestrator is None:
|
||||
from optimization_engine.devloop import DevLoopOrchestrator
|
||||
|
||||
_orchestrator = DevLoopOrchestrator(
|
||||
{
|
||||
"dashboard_url": "http://localhost:8000",
|
||||
"websocket_url": "ws://localhost:8000",
|
||||
"studies_dir": str(Path(__file__).parent.parent.parent.parent.parent / "studies"),
|
||||
"learning_enabled": True,
|
||||
}
|
||||
)
|
||||
|
||||
# Subscribe to state updates
|
||||
_orchestrator.subscribe(_broadcast_state_update)
|
||||
|
||||
return _orchestrator
|
||||
|
||||
|
||||
def _broadcast_state_update(state):
|
||||
"""Broadcast state updates to all WebSocket clients."""
|
||||
asyncio.create_task(
|
||||
_send_to_all_clients(
|
||||
{
|
||||
"type": "state_update",
|
||||
"state": {
|
||||
"phase": state.phase.value,
|
||||
"iteration": state.iteration,
|
||||
"current_task": state.current_task,
|
||||
"last_update": state.last_update,
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def _send_to_all_clients(message: Dict):
|
||||
"""Send message to all connected WebSocket clients."""
|
||||
disconnected = []
|
||||
for client in _websocket_clients:
|
||||
try:
|
||||
await client.send_json(message)
|
||||
except Exception:
|
||||
disconnected.append(client)
|
||||
|
||||
# Clean up disconnected clients
|
||||
for client in disconnected:
|
||||
if client in _websocket_clients:
|
||||
_websocket_clients.remove(client)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Request/Response Models
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class StartCycleRequest(BaseModel):
|
||||
"""Request to start a development cycle."""
|
||||
|
||||
objective: str = Field(..., description="What to achieve")
|
||||
context: Optional[Dict[str, Any]] = Field(default=None, description="Additional context")
|
||||
max_iterations: Optional[int] = Field(default=10, description="Maximum iterations")
|
||||
|
||||
|
||||
class StepRequest(BaseModel):
|
||||
"""Request to execute a single step."""
|
||||
|
||||
phase: str = Field(..., description="Phase to execute: plan, implement, test, analyze")
|
||||
data: Optional[Dict[str, Any]] = Field(default=None, description="Phase-specific data")
|
||||
|
||||
|
||||
class CycleStatusResponse(BaseModel):
|
||||
"""Response with cycle status."""
|
||||
|
||||
active: bool
|
||||
phase: str
|
||||
iteration: int
|
||||
current_task: Optional[str]
|
||||
last_update: str
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# REST Endpoints
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
async def get_status() -> CycleStatusResponse:
|
||||
"""Get current DevLoop status."""
|
||||
orchestrator = get_orchestrator()
|
||||
state = orchestrator.get_state()
|
||||
|
||||
return CycleStatusResponse(
|
||||
active=state["phase"] != "idle",
|
||||
phase=state["phase"],
|
||||
iteration=state["iteration"],
|
||||
current_task=state.get("current_task"),
|
||||
last_update=state["last_update"],
|
||||
)
|
||||
|
||||
|
||||
@router.post("/start")
|
||||
async def start_cycle(request: StartCycleRequest, background_tasks: BackgroundTasks):
|
||||
"""
|
||||
Start a new development cycle.
|
||||
|
||||
The cycle runs in the background and broadcasts progress via WebSocket.
|
||||
"""
|
||||
global _active_cycle
|
||||
|
||||
orchestrator = get_orchestrator()
|
||||
|
||||
# Check if already running
|
||||
if orchestrator.state.phase.value != "idle":
|
||||
raise HTTPException(status_code=409, detail="A development cycle is already running")
|
||||
|
||||
# Start cycle in background
|
||||
async def run_cycle():
|
||||
global _active_cycle
|
||||
try:
|
||||
result = await orchestrator.run_development_cycle(
|
||||
objective=request.objective,
|
||||
context=request.context,
|
||||
max_iterations=request.max_iterations,
|
||||
)
|
||||
_active_cycle = result
|
||||
|
||||
# Broadcast completion
|
||||
await _send_to_all_clients(
|
||||
{
|
||||
"type": "cycle_complete",
|
||||
"result": {
|
||||
"objective": result.objective,
|
||||
"status": result.status,
|
||||
"iterations": len(result.iterations),
|
||||
"duration_seconds": result.total_duration_seconds,
|
||||
},
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
await _send_to_all_clients({"type": "cycle_error", "error": str(e)})
|
||||
|
||||
background_tasks.add_task(run_cycle)
|
||||
|
||||
return {
|
||||
"message": "Development cycle started",
|
||||
"objective": request.objective,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/stop")
|
||||
async def stop_cycle():
|
||||
"""Stop the current development cycle."""
|
||||
orchestrator = get_orchestrator()
|
||||
|
||||
if orchestrator.state.phase.value == "idle":
|
||||
raise HTTPException(status_code=400, detail="No active cycle to stop")
|
||||
|
||||
# Set state to idle (will stop at next phase boundary)
|
||||
orchestrator._update_state(phase=orchestrator.state.phase.__class__.IDLE, task="Stopping...")
|
||||
|
||||
return {"message": "Cycle stop requested"}
|
||||
|
||||
|
||||
@router.post("/step")
|
||||
async def execute_step(request: StepRequest):
|
||||
"""
|
||||
Execute a single phase step.
|
||||
|
||||
Useful for manual control or debugging.
|
||||
"""
|
||||
orchestrator = get_orchestrator()
|
||||
|
||||
if request.phase == "plan":
|
||||
objective = request.data.get("objective", "") if request.data else ""
|
||||
context = request.data.get("context") if request.data else None
|
||||
result = await orchestrator.step_plan(objective, context)
|
||||
|
||||
elif request.phase == "implement":
|
||||
plan = request.data if request.data else {}
|
||||
result = await orchestrator.step_implement(plan)
|
||||
|
||||
elif request.phase == "test":
|
||||
scenarios = request.data.get("scenarios", []) if request.data else []
|
||||
result = await orchestrator.step_test(scenarios)
|
||||
|
||||
elif request.phase == "analyze":
|
||||
test_results = request.data if request.data else {}
|
||||
result = await orchestrator.step_analyze(test_results)
|
||||
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Unknown phase: {request.phase}. Valid: plan, implement, test, analyze",
|
||||
)
|
||||
|
||||
return {"phase": request.phase, "result": result}
|
||||
|
||||
|
||||
@router.get("/history")
|
||||
async def get_history():
|
||||
"""Get history of past development cycles."""
|
||||
orchestrator = get_orchestrator()
|
||||
return orchestrator.export_history()
|
||||
|
||||
|
||||
@router.get("/last-cycle")
|
||||
async def get_last_cycle():
|
||||
"""Get details of the most recent cycle."""
|
||||
global _active_cycle
|
||||
|
||||
if _active_cycle is None:
|
||||
raise HTTPException(status_code=404, detail="No cycle has been run yet")
|
||||
|
||||
return {
|
||||
"objective": _active_cycle.objective,
|
||||
"status": _active_cycle.status,
|
||||
"start_time": _active_cycle.start_time,
|
||||
"end_time": _active_cycle.end_time,
|
||||
"iterations": [
|
||||
{
|
||||
"iteration": it.iteration,
|
||||
"success": it.success,
|
||||
"duration_seconds": it.duration_seconds,
|
||||
"has_plan": it.plan is not None,
|
||||
"has_tests": it.test_results is not None,
|
||||
"has_fixes": it.fixes is not None,
|
||||
}
|
||||
for it in _active_cycle.iterations
|
||||
],
|
||||
"total_duration_seconds": _active_cycle.total_duration_seconds,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def health_check():
|
||||
"""Check DevLoop system health."""
|
||||
orchestrator = get_orchestrator()
|
||||
|
||||
# Check dashboard connection
|
||||
from optimization_engine.devloop import DashboardTestRunner
|
||||
|
||||
runner = DashboardTestRunner()
|
||||
dashboard_health = await runner.run_health_check()
|
||||
|
||||
return {
|
||||
"devloop": "healthy",
|
||||
"orchestrator_state": orchestrator.get_state()["phase"],
|
||||
"dashboard": dashboard_health,
|
||||
}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# WebSocket Endpoint
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@router.websocket("/ws")
|
||||
async def websocket_endpoint(websocket: WebSocket):
|
||||
"""
|
||||
WebSocket endpoint for real-time DevLoop updates.
|
||||
|
||||
Messages sent:
|
||||
- state_update: Phase/iteration changes
|
||||
- cycle_complete: Cycle finished
|
||||
- cycle_error: Cycle failed
|
||||
- test_progress: Individual test results
|
||||
"""
|
||||
await websocket.accept()
|
||||
_websocket_clients.append(websocket)
|
||||
|
||||
orchestrator = get_orchestrator()
|
||||
|
||||
try:
|
||||
# Send initial state
|
||||
await websocket.send_json(
|
||||
{
|
||||
"type": "connection_ack",
|
||||
"state": orchestrator.get_state(),
|
||||
}
|
||||
)
|
||||
|
||||
# Handle incoming messages
|
||||
while True:
|
||||
try:
|
||||
data = await asyncio.wait_for(websocket.receive_json(), timeout=30.0)
|
||||
|
||||
msg_type = data.get("type")
|
||||
|
||||
if msg_type == "ping":
|
||||
await websocket.send_json({"type": "pong"})
|
||||
|
||||
elif msg_type == "get_state":
|
||||
await websocket.send_json(
|
||||
{
|
||||
"type": "state",
|
||||
"state": orchestrator.get_state(),
|
||||
}
|
||||
)
|
||||
|
||||
elif msg_type == "start_cycle":
|
||||
# Allow starting cycle via WebSocket
|
||||
objective = data.get("objective", "")
|
||||
context = data.get("context")
|
||||
|
||||
asyncio.create_task(orchestrator.run_development_cycle(objective, context))
|
||||
|
||||
await websocket.send_json(
|
||||
{
|
||||
"type": "cycle_started",
|
||||
"objective": objective,
|
||||
}
|
||||
)
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
# Send heartbeat
|
||||
await websocket.send_json({"type": "heartbeat"})
|
||||
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
finally:
|
||||
if websocket in _websocket_clients:
|
||||
_websocket_clients.remove(websocket)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Convenience Endpoints for Common Tasks
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@router.post("/create-study")
|
||||
async def create_study_cycle(
|
||||
study_name: str,
|
||||
problem_statement: Optional[str] = None,
|
||||
background_tasks: BackgroundTasks = None,
|
||||
):
|
||||
"""
|
||||
Convenience endpoint to start a study creation cycle.
|
||||
|
||||
This is a common workflow that combines planning, implementation, and testing.
|
||||
"""
|
||||
orchestrator = get_orchestrator()
|
||||
|
||||
context = {
|
||||
"study_name": study_name,
|
||||
"task_type": "create_study",
|
||||
}
|
||||
|
||||
if problem_statement:
|
||||
context["problem_statement"] = problem_statement
|
||||
|
||||
# Start the cycle
|
||||
async def run_cycle():
|
||||
result = await orchestrator.run_development_cycle(
|
||||
objective=f"Create optimization study: {study_name}",
|
||||
context=context,
|
||||
)
|
||||
return result
|
||||
|
||||
if background_tasks:
|
||||
background_tasks.add_task(run_cycle)
|
||||
return {"message": f"Study creation cycle started for '{study_name}'"}
|
||||
else:
|
||||
result = await run_cycle()
|
||||
return {
|
||||
"message": f"Study '{study_name}' creation completed",
|
||||
"status": result.status,
|
||||
"iterations": len(result.iterations),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/run-tests")
|
||||
async def run_tests(scenarios: List[Dict[str, Any]]):
|
||||
"""
|
||||
Run a set of test scenarios directly.
|
||||
|
||||
Useful for testing specific features without a full cycle.
|
||||
"""
|
||||
from optimization_engine.devloop import DashboardTestRunner
|
||||
|
||||
runner = DashboardTestRunner()
|
||||
results = await runner.run_test_suite(scenarios)
|
||||
|
||||
return results
|
||||
@@ -19,23 +19,26 @@ router = APIRouter()
|
||||
|
||||
class ImportRequest(BaseModel):
|
||||
"""Request to import a file from a Windows path"""
|
||||
|
||||
source_path: str
|
||||
study_name: str
|
||||
copy_related: bool = True
|
||||
|
||||
|
||||
# Path to studies root (go up 5 levels from this file)
|
||||
_file_path = os.path.abspath(__file__)
|
||||
ATOMIZER_ROOT = Path(os.path.normpath(os.path.dirname(os.path.dirname(os.path.dirname(
|
||||
os.path.dirname(os.path.dirname(_file_path))
|
||||
)))))
|
||||
ATOMIZER_ROOT = Path(
|
||||
os.path.normpath(
|
||||
os.path.dirname(
|
||||
os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(_file_path))))
|
||||
)
|
||||
)
|
||||
)
|
||||
STUDIES_ROOT = ATOMIZER_ROOT / "studies"
|
||||
|
||||
|
||||
@router.get("/list")
|
||||
async def list_files(
|
||||
path: str = "",
|
||||
types: str = ".sim,.prt,.fem,.afem"
|
||||
):
|
||||
async def list_files(path: str = "", types: str = ".sim,.prt,.fem,.afem"):
|
||||
"""
|
||||
List files in a directory, filtered by type.
|
||||
|
||||
@@ -46,7 +49,7 @@ async def list_files(
|
||||
Returns:
|
||||
List of files and directories with their paths
|
||||
"""
|
||||
allowed_types = [t.strip().lower() for t in types.split(',') if t.strip()]
|
||||
allowed_types = [t.strip().lower() for t in types.split(",") if t.strip()]
|
||||
|
||||
base_path = STUDIES_ROOT / path if path else STUDIES_ROOT
|
||||
|
||||
@@ -58,26 +61,30 @@ async def list_files(
|
||||
try:
|
||||
for entry in sorted(base_path.iterdir(), key=lambda x: (not x.is_dir(), x.name.lower())):
|
||||
# Skip hidden files and directories
|
||||
if entry.name.startswith('.'):
|
||||
if entry.name.startswith("."):
|
||||
continue
|
||||
|
||||
if entry.is_dir():
|
||||
# Include directories
|
||||
files.append({
|
||||
"name": entry.name,
|
||||
"path": str(entry.relative_to(STUDIES_ROOT)).replace("\\", "/"),
|
||||
"isDirectory": True,
|
||||
})
|
||||
files.append(
|
||||
{
|
||||
"name": entry.name,
|
||||
"path": str(entry.relative_to(STUDIES_ROOT)).replace("\\", "/"),
|
||||
"isDirectory": True,
|
||||
}
|
||||
)
|
||||
else:
|
||||
# Include files matching type filter
|
||||
suffix = entry.suffix.lower()
|
||||
if suffix in allowed_types:
|
||||
files.append({
|
||||
"name": entry.name,
|
||||
"path": str(entry.relative_to(STUDIES_ROOT)).replace("\\", "/"),
|
||||
"isDirectory": False,
|
||||
"size": entry.stat().st_size,
|
||||
})
|
||||
files.append(
|
||||
{
|
||||
"name": entry.name,
|
||||
"path": str(entry.relative_to(STUDIES_ROOT)).replace("\\", "/"),
|
||||
"isDirectory": False,
|
||||
"size": entry.stat().st_size,
|
||||
}
|
||||
)
|
||||
except PermissionError:
|
||||
return {"files": [], "path": path, "error": "Permission denied"}
|
||||
except Exception as e:
|
||||
@@ -87,11 +94,7 @@ async def list_files(
|
||||
|
||||
|
||||
@router.get("/search")
|
||||
async def search_files(
|
||||
query: str,
|
||||
types: str = ".sim,.prt,.fem,.afem",
|
||||
max_results: int = 50
|
||||
):
|
||||
async def search_files(query: str, types: str = ".sim,.prt,.fem,.afem", max_results: int = 50):
|
||||
"""
|
||||
Search for files by name pattern.
|
||||
|
||||
@@ -103,7 +106,7 @@ async def search_files(
|
||||
Returns:
|
||||
List of matching files with their paths
|
||||
"""
|
||||
allowed_types = [t.strip().lower() for t in types.split(',') if t.strip()]
|
||||
allowed_types = [t.strip().lower() for t in types.split(",") if t.strip()]
|
||||
query_lower = query.lower()
|
||||
|
||||
files = []
|
||||
@@ -118,19 +121,21 @@ async def search_files(
|
||||
if len(files) >= max_results:
|
||||
return
|
||||
|
||||
if entry.name.startswith('.'):
|
||||
if entry.name.startswith("."):
|
||||
continue
|
||||
|
||||
if entry.is_dir():
|
||||
search_recursive(entry, depth + 1)
|
||||
elif entry.suffix.lower() in allowed_types:
|
||||
if query_lower in entry.name.lower():
|
||||
files.append({
|
||||
"name": entry.name,
|
||||
"path": str(entry.relative_to(STUDIES_ROOT)).replace("\\", "/"),
|
||||
"isDirectory": False,
|
||||
"size": entry.stat().st_size,
|
||||
})
|
||||
files.append(
|
||||
{
|
||||
"name": entry.name,
|
||||
"path": str(entry.relative_to(STUDIES_ROOT)).replace("\\", "/"),
|
||||
"isDirectory": False,
|
||||
"size": entry.stat().st_size,
|
||||
}
|
||||
)
|
||||
except (PermissionError, OSError):
|
||||
pass
|
||||
|
||||
@@ -190,18 +195,18 @@ def find_related_nx_files(source_path: Path) -> List[Path]:
|
||||
|
||||
# Extract base name by removing _sim1, _fem1, _i suffixes
|
||||
base_name = stem
|
||||
base_name = re.sub(r'_sim\d*$', '', base_name)
|
||||
base_name = re.sub(r'_fem\d*$', '', base_name)
|
||||
base_name = re.sub(r'_i$', '', base_name)
|
||||
base_name = re.sub(r"_sim\d*$", "", base_name)
|
||||
base_name = re.sub(r"_fem\d*$", "", base_name)
|
||||
base_name = re.sub(r"_i$", "", base_name)
|
||||
|
||||
# Define patterns to search for
|
||||
patterns = [
|
||||
f"{base_name}.prt", # Main geometry
|
||||
f"{base_name}_i.prt", # Idealized part
|
||||
f"{base_name}_fem*.fem", # FEM files
|
||||
f"{base_name}_fem*_i.prt", # Idealized FEM parts
|
||||
f"{base_name}_sim*.sim", # Simulation files
|
||||
f"{base_name}.afem", # Assembled FEM
|
||||
f"{base_name}.prt", # Main geometry
|
||||
f"{base_name}_i.prt", # Idealized part
|
||||
f"{base_name}_fem*.fem", # FEM files
|
||||
f"{base_name}_fem*_i.prt", # Idealized FEM parts
|
||||
f"{base_name}_sim*.sim", # Simulation files
|
||||
f"{base_name}.afem", # Assembled FEM
|
||||
]
|
||||
|
||||
# Search for matching files
|
||||
@@ -244,7 +249,7 @@ async def validate_external_path(path: str):
|
||||
}
|
||||
|
||||
# Check if it's a valid NX file type
|
||||
valid_extensions = ['.prt', '.sim', '.fem', '.afem']
|
||||
valid_extensions = [".prt", ".sim", ".fem", ".afem"]
|
||||
if source_path.suffix.lower() not in valid_extensions:
|
||||
return {
|
||||
"valid": False,
|
||||
@@ -297,7 +302,9 @@ async def import_from_path(request: ImportRequest):
|
||||
source_path = Path(request.source_path)
|
||||
|
||||
if not source_path.exists():
|
||||
raise HTTPException(status_code=404, detail=f"Source file not found: {request.source_path}")
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"Source file not found: {request.source_path}"
|
||||
)
|
||||
|
||||
# Create study folder structure
|
||||
study_dir = STUDIES_ROOT / request.study_name
|
||||
@@ -316,22 +323,26 @@ async def import_from_path(request: ImportRequest):
|
||||
|
||||
# Skip if already exists (avoid overwrite)
|
||||
if dest_file.exists():
|
||||
imported.append({
|
||||
"name": src_file.name,
|
||||
"status": "skipped",
|
||||
"reason": "Already exists",
|
||||
"path": str(dest_file.relative_to(STUDIES_ROOT)).replace("\\", "/"),
|
||||
})
|
||||
imported.append(
|
||||
{
|
||||
"name": src_file.name,
|
||||
"status": "skipped",
|
||||
"reason": "Already exists",
|
||||
"path": str(dest_file.relative_to(STUDIES_ROOT)).replace("\\", "/"),
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
# Copy file
|
||||
shutil.copy2(src_file, dest_file)
|
||||
imported.append({
|
||||
"name": src_file.name,
|
||||
"status": "imported",
|
||||
"path": str(dest_file.relative_to(STUDIES_ROOT)).replace("\\", "/"),
|
||||
"size": dest_file.stat().st_size,
|
||||
})
|
||||
imported.append(
|
||||
{
|
||||
"name": src_file.name,
|
||||
"status": "imported",
|
||||
"path": str(dest_file.relative_to(STUDIES_ROOT)).replace("\\", "/"),
|
||||
"size": dest_file.stat().st_size,
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
@@ -371,27 +382,31 @@ async def upload_files(
|
||||
for file in files:
|
||||
# Validate file type
|
||||
suffix = Path(file.filename).suffix.lower()
|
||||
if suffix not in ['.prt', '.sim', '.fem', '.afem']:
|
||||
uploaded.append({
|
||||
"name": file.filename,
|
||||
"status": "rejected",
|
||||
"reason": f"Invalid file type: {suffix}",
|
||||
})
|
||||
if suffix not in [".prt", ".sim", ".fem", ".afem"]:
|
||||
uploaded.append(
|
||||
{
|
||||
"name": file.filename,
|
||||
"status": "rejected",
|
||||
"reason": f"Invalid file type: {suffix}",
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
dest_file = model_dir / file.filename
|
||||
|
||||
# Save file
|
||||
content = await file.read()
|
||||
with open(dest_file, 'wb') as f:
|
||||
with open(dest_file, "wb") as f:
|
||||
f.write(content)
|
||||
|
||||
uploaded.append({
|
||||
"name": file.filename,
|
||||
"status": "uploaded",
|
||||
"path": str(dest_file.relative_to(STUDIES_ROOT)).replace("\\", "/"),
|
||||
"size": len(content),
|
||||
})
|
||||
uploaded.append(
|
||||
{
|
||||
"name": file.filename,
|
||||
"status": "uploaded",
|
||||
"path": str(dest_file.relative_to(STUDIES_ROOT)).replace("\\", "/"),
|
||||
"size": len(content),
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
@@ -402,3 +417,96 @@ async def upload_files(
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/structure/{study_id:path}")
|
||||
async def get_study_structure(study_id: str):
|
||||
"""
|
||||
Get the file structure tree for a study.
|
||||
|
||||
Args:
|
||||
study_id: Study ID (can include path separators like M1_Mirror/m1_mirror_flatback)
|
||||
|
||||
Returns:
|
||||
Hierarchical file tree with type information
|
||||
"""
|
||||
# Resolve study path
|
||||
study_path = STUDIES_ROOT / study_id
|
||||
|
||||
if not study_path.exists():
|
||||
raise HTTPException(status_code=404, detail=f"Study not found: {study_id}")
|
||||
|
||||
if not study_path.is_dir():
|
||||
raise HTTPException(status_code=400, detail=f"Not a directory: {study_id}")
|
||||
|
||||
# File extensions to highlight as model files
|
||||
model_extensions = {".prt", ".sim", ".fem", ".afem"}
|
||||
result_extensions = {".op2", ".f06", ".dat", ".bdf", ".csv", ".json"}
|
||||
|
||||
def build_tree(directory: Path, depth: int = 0) -> List[dict]:
|
||||
"""Recursively build file tree."""
|
||||
if depth > 5: # Limit depth to prevent infinite recursion
|
||||
return []
|
||||
|
||||
entries = []
|
||||
|
||||
try:
|
||||
items = sorted(directory.iterdir(), key=lambda x: (not x.is_dir(), x.name.lower()))
|
||||
|
||||
for item in items:
|
||||
# Skip hidden files/dirs and __pycache__
|
||||
if item.name.startswith(".") or item.name == "__pycache__":
|
||||
continue
|
||||
|
||||
# Skip very large directories (e.g., trial folders with many iterations)
|
||||
if item.is_dir() and item.name.startswith("trial_"):
|
||||
# Just count trials, don't recurse into each
|
||||
entries.append(
|
||||
{
|
||||
"name": item.name,
|
||||
"path": str(item.relative_to(STUDIES_ROOT)).replace("\\", "/"),
|
||||
"type": "directory",
|
||||
"children": [], # Empty children for trial folders
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
if item.is_dir():
|
||||
children = build_tree(item, depth + 1)
|
||||
entries.append(
|
||||
{
|
||||
"name": item.name,
|
||||
"path": str(item.relative_to(STUDIES_ROOT)).replace("\\", "/"),
|
||||
"type": "directory",
|
||||
"children": children,
|
||||
}
|
||||
)
|
||||
else:
|
||||
ext = item.suffix.lower()
|
||||
entries.append(
|
||||
{
|
||||
"name": item.name,
|
||||
"path": str(item.relative_to(STUDIES_ROOT)).replace("\\", "/"),
|
||||
"type": "file",
|
||||
"extension": ext,
|
||||
"size": item.stat().st_size,
|
||||
"isModelFile": ext in model_extensions,
|
||||
"isResultFile": ext in result_extensions,
|
||||
}
|
||||
)
|
||||
|
||||
except PermissionError:
|
||||
pass
|
||||
except Exception as e:
|
||||
print(f"Error reading directory {directory}: {e}")
|
||||
|
||||
return entries
|
||||
|
||||
# Build the tree starting from study root
|
||||
files = build_tree(study_path)
|
||||
|
||||
return {
|
||||
"study_id": study_id,
|
||||
"path": str(study_path),
|
||||
"files": files,
|
||||
}
|
||||
|
||||
1721
atomizer-dashboard/backend/api/routes/intake.py
Normal file
1721
atomizer-dashboard/backend/api/routes/intake.py
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
646
atomizer-dashboard/backend/api/routes/spec.py
Normal file
646
atomizer-dashboard/backend/api/routes/spec.py
Normal file
@@ -0,0 +1,646 @@
|
||||
"""
|
||||
AtomizerSpec v2.0 API Endpoints
|
||||
|
||||
REST API for managing AtomizerSpec configurations.
|
||||
All spec modifications flow through these endpoints.
|
||||
|
||||
Endpoints:
|
||||
- GET /studies/{study_id}/spec - Get full spec
|
||||
- PUT /studies/{study_id}/spec - Replace entire spec
|
||||
- PATCH /studies/{study_id}/spec - Partial update
|
||||
- POST /studies/{study_id}/spec/validate - Validate spec
|
||||
- POST /studies/{study_id}/spec/nodes - Add node
|
||||
- PATCH /studies/{study_id}/spec/nodes/{node_id} - Update node
|
||||
- DELETE /studies/{study_id}/spec/nodes/{node_id} - Delete node
|
||||
- POST /studies/{study_id}/spec/custom-functions - Add custom extractor
|
||||
- WebSocket /studies/{study_id}/spec/sync - Real-time sync
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, WebSocket, WebSocketDisconnect, Query
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel, Field
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
import json
|
||||
import sys
|
||||
import asyncio
|
||||
|
||||
# Add project root to path
|
||||
sys.path.append(str(Path(__file__).parent.parent.parent.parent.parent))
|
||||
|
||||
from api.services.spec_manager import (
|
||||
SpecManager,
|
||||
SpecManagerError,
|
||||
SpecNotFoundError,
|
||||
SpecConflictError,
|
||||
get_spec_manager,
|
||||
)
|
||||
from optimization_engine.config.spec_models import (
|
||||
AtomizerSpec,
|
||||
ValidationReport,
|
||||
)
|
||||
from optimization_engine.config.spec_validator import SpecValidationError
|
||||
|
||||
router = APIRouter(prefix="/studies/{study_id:path}/spec", tags=["spec"])
|
||||
|
||||
# Base studies directory
|
||||
STUDIES_DIR = Path(__file__).parent.parent.parent.parent.parent / "studies"
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Request/Response Models
|
||||
# ============================================================================
|
||||
|
||||
class SpecPatchRequest(BaseModel):
|
||||
"""Request for patching a spec field."""
|
||||
path: str = Field(..., description="JSONPath to the field (e.g., 'objectives[0].weight')")
|
||||
value: Any = Field(..., description="New value")
|
||||
modified_by: str = Field(default="api", description="Who is making the change")
|
||||
|
||||
|
||||
class NodeAddRequest(BaseModel):
|
||||
"""Request for adding a node."""
|
||||
type: str = Field(..., description="Node type: designVar, extractor, objective, constraint")
|
||||
data: Dict[str, Any] = Field(..., description="Node data")
|
||||
modified_by: str = Field(default="canvas", description="Who is making the change")
|
||||
|
||||
|
||||
class NodeUpdateRequest(BaseModel):
|
||||
"""Request for updating a node."""
|
||||
updates: Dict[str, Any] = Field(..., description="Fields to update")
|
||||
modified_by: str = Field(default="canvas", description="Who is making the change")
|
||||
|
||||
|
||||
class CustomFunctionRequest(BaseModel):
|
||||
"""Request for adding a custom extractor function."""
|
||||
name: str = Field(..., description="Function name")
|
||||
code: str = Field(..., description="Python source code")
|
||||
outputs: List[str] = Field(..., description="Output names")
|
||||
description: Optional[str] = Field(default=None, description="Human-readable description")
|
||||
modified_by: str = Field(default="claude", description="Who is making the change")
|
||||
|
||||
|
||||
class ExtractorValidationRequest(BaseModel):
|
||||
"""Request for validating custom extractor code."""
|
||||
function_name: str = Field(default="extract", description="Expected function name")
|
||||
source: str = Field(..., description="Python source code to validate")
|
||||
|
||||
|
||||
class SpecUpdateResponse(BaseModel):
|
||||
"""Response for spec modification operations."""
|
||||
success: bool
|
||||
hash: str
|
||||
modified: str
|
||||
modified_by: str
|
||||
|
||||
|
||||
class NodeAddResponse(BaseModel):
|
||||
"""Response for node add operation."""
|
||||
success: bool
|
||||
node_id: str
|
||||
message: str
|
||||
|
||||
|
||||
class ValidationResponse(BaseModel):
|
||||
"""Response for validation endpoint."""
|
||||
valid: bool
|
||||
errors: List[Dict[str, Any]]
|
||||
warnings: List[Dict[str, Any]]
|
||||
summary: Dict[str, int]
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Helper Functions
|
||||
# ============================================================================
|
||||
|
||||
def resolve_study_path(study_id: str) -> Path:
|
||||
"""Find study folder by scanning all topic directories.
|
||||
|
||||
Supports both formats:
|
||||
- "study_name" - Will scan topic folders to find it
|
||||
- "Topic/study_name" - Direct nested path (e.g., "M1_Mirror/m1_mirror_v1")
|
||||
"""
|
||||
# Handle nested paths (e.g., "M1_Mirror/m1_mirror_cost_reduction_lateral")
|
||||
if "/" in study_id:
|
||||
nested_path = STUDIES_DIR / study_id.replace("/", "\\") # Handle Windows paths
|
||||
if nested_path.exists() and nested_path.is_dir():
|
||||
return nested_path
|
||||
# Also try with forward slashes (Path handles both)
|
||||
nested_path = STUDIES_DIR / study_id
|
||||
if nested_path.exists() and nested_path.is_dir():
|
||||
return nested_path
|
||||
|
||||
# Direct path (flat structure)
|
||||
direct_path = STUDIES_DIR / study_id
|
||||
if direct_path.exists() and direct_path.is_dir():
|
||||
return direct_path
|
||||
|
||||
# Scan topic folders (nested structure)
|
||||
for topic_dir in STUDIES_DIR.iterdir():
|
||||
if topic_dir.is_dir() and not topic_dir.name.startswith('.'):
|
||||
study_dir = topic_dir / study_id
|
||||
if study_dir.exists() and study_dir.is_dir():
|
||||
return study_dir
|
||||
|
||||
raise HTTPException(status_code=404, detail=f"Study not found: {study_id}")
|
||||
|
||||
|
||||
def get_manager(study_id: str) -> SpecManager:
|
||||
"""Get SpecManager for a study."""
|
||||
study_path = resolve_study_path(study_id)
|
||||
return get_spec_manager(study_path)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# REST Endpoints
|
||||
# ============================================================================
|
||||
|
||||
@router.get("", response_model=None)
|
||||
async def get_spec(study_id: str):
|
||||
"""
|
||||
Get the full AtomizerSpec for a study.
|
||||
|
||||
Returns the complete spec JSON with all design variables, extractors,
|
||||
objectives, constraints, and canvas state.
|
||||
"""
|
||||
manager = get_manager(study_id)
|
||||
|
||||
if not manager.exists():
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"No AtomizerSpec found for study '{study_id}'. Use migration or create new spec."
|
||||
)
|
||||
|
||||
try:
|
||||
spec = manager.load()
|
||||
return spec.model_dump(mode='json')
|
||||
except SpecValidationError as e:
|
||||
# Return spec even if invalid, but include validation info
|
||||
raw = manager.load_raw()
|
||||
return JSONResponse(
|
||||
status_code=200,
|
||||
content={
|
||||
**raw,
|
||||
"_validation_error": str(e)
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/raw")
|
||||
async def get_spec_raw(study_id: str):
|
||||
"""
|
||||
Get the raw spec JSON without validation.
|
||||
|
||||
Useful for debugging or when spec is invalid.
|
||||
"""
|
||||
manager = get_manager(study_id)
|
||||
|
||||
if not manager.exists():
|
||||
raise HTTPException(status_code=404, detail=f"No spec found for study '{study_id}'")
|
||||
|
||||
try:
|
||||
return manager.load_raw()
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/hash")
|
||||
async def get_spec_hash(study_id: str):
|
||||
"""Get the current spec hash for conflict detection."""
|
||||
manager = get_manager(study_id)
|
||||
|
||||
if not manager.exists():
|
||||
raise HTTPException(status_code=404, detail=f"No spec found for study '{study_id}'")
|
||||
|
||||
return {"hash": manager.get_hash()}
|
||||
|
||||
|
||||
@router.put("", response_model=SpecUpdateResponse)
|
||||
async def replace_spec(
|
||||
study_id: str,
|
||||
spec: Dict[str, Any],
|
||||
modified_by: str = Query(default="api"),
|
||||
expected_hash: Optional[str] = Query(default=None)
|
||||
):
|
||||
"""
|
||||
Replace the entire spec.
|
||||
|
||||
Validates the new spec before saving. Optionally check for conflicts
|
||||
using expected_hash parameter.
|
||||
"""
|
||||
manager = get_manager(study_id)
|
||||
|
||||
try:
|
||||
new_hash = manager.save(spec, modified_by=modified_by, expected_hash=expected_hash)
|
||||
reloaded = manager.load()
|
||||
return SpecUpdateResponse(
|
||||
success=True,
|
||||
hash=new_hash,
|
||||
modified=reloaded.meta.modified or "",
|
||||
modified_by=modified_by
|
||||
)
|
||||
except SpecConflictError as e:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={
|
||||
"message": str(e),
|
||||
"current_hash": e.current_hash
|
||||
}
|
||||
)
|
||||
except SpecValidationError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.patch("", response_model=SpecUpdateResponse)
|
||||
async def patch_spec(study_id: str, request: SpecPatchRequest):
|
||||
"""
|
||||
Partial update to spec using JSONPath.
|
||||
|
||||
Example paths:
|
||||
- "objectives[0].weight" - Update objective weight
|
||||
- "design_variables[1].bounds.max" - Update DV bound
|
||||
- "meta.description" - Update description
|
||||
"""
|
||||
manager = get_manager(study_id)
|
||||
|
||||
if not manager.exists():
|
||||
raise HTTPException(status_code=404, detail=f"No spec found for study '{study_id}'")
|
||||
|
||||
try:
|
||||
spec = manager.patch(request.path, request.value, modified_by=request.modified_by)
|
||||
return SpecUpdateResponse(
|
||||
success=True,
|
||||
hash=manager.get_hash(),
|
||||
modified=spec.meta.modified or "",
|
||||
modified_by=request.modified_by
|
||||
)
|
||||
except SpecValidationError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/validate", response_model=ValidationResponse)
|
||||
async def validate_spec(study_id: str):
|
||||
"""
|
||||
Validate the spec and return detailed report.
|
||||
|
||||
Returns errors, warnings, and summary of the spec contents.
|
||||
"""
|
||||
manager = get_manager(study_id)
|
||||
|
||||
if not manager.exists():
|
||||
raise HTTPException(status_code=404, detail=f"No spec found for study '{study_id}'")
|
||||
|
||||
try:
|
||||
report = manager.validate_and_report()
|
||||
return ValidationResponse(
|
||||
valid=report.valid,
|
||||
errors=[e.model_dump() for e in report.errors],
|
||||
warnings=[w.model_dump() for w in report.warnings],
|
||||
summary=report.summary.model_dump()
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Node CRUD Endpoints
|
||||
# ============================================================================
|
||||
|
||||
@router.post("/nodes", response_model=NodeAddResponse)
|
||||
async def add_node(study_id: str, request: NodeAddRequest):
|
||||
"""
|
||||
Add a new node to the spec.
|
||||
|
||||
Supported types: designVar, extractor, objective, constraint
|
||||
"""
|
||||
manager = get_manager(study_id)
|
||||
|
||||
if not manager.exists():
|
||||
raise HTTPException(status_code=404, detail=f"No spec found for study '{study_id}'")
|
||||
|
||||
valid_types = ["designVar", "extractor", "objective", "constraint"]
|
||||
if request.type not in valid_types:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid node type '{request.type}'. Valid: {valid_types}"
|
||||
)
|
||||
|
||||
try:
|
||||
node_id = manager.add_node(request.type, request.data, modified_by=request.modified_by)
|
||||
return NodeAddResponse(
|
||||
success=True,
|
||||
node_id=node_id,
|
||||
message=f"Added {request.type} node: {node_id}"
|
||||
)
|
||||
except SpecValidationError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.patch("/nodes/{node_id}")
|
||||
async def update_node(study_id: str, node_id: str, request: NodeUpdateRequest):
|
||||
"""Update an existing node's properties."""
|
||||
manager = get_manager(study_id)
|
||||
|
||||
if not manager.exists():
|
||||
raise HTTPException(status_code=404, detail=f"No spec found for study '{study_id}'")
|
||||
|
||||
try:
|
||||
manager.update_node(node_id, request.updates, modified_by=request.modified_by)
|
||||
return {"success": True, "message": f"Updated node {node_id}"}
|
||||
except SpecManagerError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except SpecValidationError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.delete("/nodes/{node_id}")
|
||||
async def delete_node(
|
||||
study_id: str,
|
||||
node_id: str,
|
||||
modified_by: str = Query(default="canvas")
|
||||
):
|
||||
"""
|
||||
Delete a node and all edges referencing it.
|
||||
|
||||
Use with caution - this will also remove any objectives or constraints
|
||||
that reference a deleted extractor.
|
||||
"""
|
||||
manager = get_manager(study_id)
|
||||
|
||||
if not manager.exists():
|
||||
raise HTTPException(status_code=404, detail=f"No spec found for study '{study_id}'")
|
||||
|
||||
try:
|
||||
manager.remove_node(node_id, modified_by=modified_by)
|
||||
return {"success": True, "message": f"Removed node {node_id}"}
|
||||
except SpecManagerError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except SpecValidationError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Custom Function Endpoint
|
||||
# ============================================================================
|
||||
|
||||
@router.post("/custom-functions", response_model=NodeAddResponse)
|
||||
async def add_custom_function(study_id: str, request: CustomFunctionRequest):
|
||||
"""
|
||||
Add a custom Python function as an extractor.
|
||||
|
||||
The function will be available in the optimization workflow.
|
||||
Claude can use this to add new physics extraction logic.
|
||||
"""
|
||||
manager = get_manager(study_id)
|
||||
|
||||
if not manager.exists():
|
||||
raise HTTPException(status_code=404, detail=f"No spec found for study '{study_id}'")
|
||||
|
||||
try:
|
||||
extractor_id = manager.add_custom_function(
|
||||
name=request.name,
|
||||
code=request.code,
|
||||
outputs=request.outputs,
|
||||
description=request.description,
|
||||
modified_by=request.modified_by
|
||||
)
|
||||
return NodeAddResponse(
|
||||
success=True,
|
||||
node_id=extractor_id,
|
||||
message=f"Added custom extractor: {request.name}"
|
||||
)
|
||||
except SpecValidationError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# Separate router for non-study-specific endpoints
|
||||
validate_router = APIRouter(prefix="/spec", tags=["spec"])
|
||||
|
||||
|
||||
@validate_router.post("/validate-extractor")
|
||||
async def validate_custom_extractor(request: ExtractorValidationRequest):
|
||||
"""
|
||||
Validate custom extractor Python code.
|
||||
|
||||
Checks syntax, security patterns, and function signature.
|
||||
Does not require a study - can be used before adding to spec.
|
||||
"""
|
||||
try:
|
||||
from optimization_engine.extractors.custom_extractor_loader import (
|
||||
validate_extractor_code,
|
||||
ExtractorSecurityError,
|
||||
)
|
||||
|
||||
try:
|
||||
is_valid, errors = validate_extractor_code(request.source, request.function_name)
|
||||
return {
|
||||
"valid": is_valid,
|
||||
"errors": errors
|
||||
}
|
||||
except ExtractorSecurityError as e:
|
||||
return {
|
||||
"valid": False,
|
||||
"errors": [str(e)]
|
||||
}
|
||||
|
||||
except ImportError as e:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Custom extractor loader not available: {e}"
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Edge Endpoints
|
||||
# ============================================================================
|
||||
|
||||
@router.post("/edges")
|
||||
async def add_edge(
|
||||
study_id: str,
|
||||
source: str = Query(..., description="Source node ID"),
|
||||
target: str = Query(..., description="Target node ID"),
|
||||
modified_by: str = Query(default="canvas")
|
||||
):
|
||||
"""Add a canvas edge between two nodes."""
|
||||
manager = get_manager(study_id)
|
||||
|
||||
if not manager.exists():
|
||||
raise HTTPException(status_code=404, detail=f"No spec found for study '{study_id}'")
|
||||
|
||||
try:
|
||||
manager.add_edge(source, target, modified_by=modified_by)
|
||||
return {"success": True, "message": f"Added edge {source} -> {target}"}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.delete("/edges")
|
||||
async def delete_edge(
|
||||
study_id: str,
|
||||
source: str = Query(..., description="Source node ID"),
|
||||
target: str = Query(..., description="Target node ID"),
|
||||
modified_by: str = Query(default="canvas")
|
||||
):
|
||||
"""Remove a canvas edge."""
|
||||
manager = get_manager(study_id)
|
||||
|
||||
if not manager.exists():
|
||||
raise HTTPException(status_code=404, detail=f"No spec found for study '{study_id}'")
|
||||
|
||||
try:
|
||||
manager.remove_edge(source, target, modified_by=modified_by)
|
||||
return {"success": True, "message": f"Removed edge {source} -> {target}"}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# WebSocket Sync Endpoint
|
||||
# ============================================================================
|
||||
|
||||
class WebSocketSubscriber:
|
||||
"""WebSocket subscriber adapter."""
|
||||
|
||||
def __init__(self, websocket: WebSocket):
|
||||
self.websocket = websocket
|
||||
|
||||
async def send_json(self, data: Dict[str, Any]) -> None:
|
||||
await self.websocket.send_json(data)
|
||||
|
||||
|
||||
@router.websocket("/sync")
|
||||
async def websocket_sync(websocket: WebSocket, study_id: str):
|
||||
"""
|
||||
WebSocket endpoint for real-time spec sync.
|
||||
|
||||
Clients receive notifications when spec changes:
|
||||
- spec_updated: Spec was modified
|
||||
- node_added: New node added
|
||||
- node_removed: Node removed
|
||||
- validation_error: Validation failed
|
||||
"""
|
||||
await websocket.accept()
|
||||
|
||||
manager = get_manager(study_id)
|
||||
subscriber = WebSocketSubscriber(websocket)
|
||||
|
||||
# Subscribe to updates
|
||||
manager.subscribe(subscriber)
|
||||
|
||||
try:
|
||||
# Send initial connection ack
|
||||
await websocket.send_json({
|
||||
"type": "connection_ack",
|
||||
"study_id": study_id,
|
||||
"hash": manager.get_hash() if manager.exists() else None,
|
||||
"message": "Connected to spec sync"
|
||||
})
|
||||
|
||||
# Keep connection alive and handle client messages
|
||||
while True:
|
||||
try:
|
||||
data = await asyncio.wait_for(
|
||||
websocket.receive_json(),
|
||||
timeout=30.0 # Heartbeat interval
|
||||
)
|
||||
|
||||
# Handle client messages
|
||||
msg_type = data.get("type")
|
||||
|
||||
if msg_type == "ping":
|
||||
await websocket.send_json({"type": "pong"})
|
||||
|
||||
elif msg_type == "patch_node":
|
||||
# Client requests node update
|
||||
try:
|
||||
manager.update_node(
|
||||
data["node_id"],
|
||||
data.get("data", {}),
|
||||
modified_by=data.get("modified_by", "canvas")
|
||||
)
|
||||
except Exception as e:
|
||||
await websocket.send_json({
|
||||
"type": "error",
|
||||
"message": str(e)
|
||||
})
|
||||
|
||||
elif msg_type == "update_position":
|
||||
# Client updates node position
|
||||
try:
|
||||
manager.update_node_position(
|
||||
data["node_id"],
|
||||
data["position"],
|
||||
modified_by=data.get("modified_by", "canvas")
|
||||
)
|
||||
except Exception as e:
|
||||
await websocket.send_json({
|
||||
"type": "error",
|
||||
"message": str(e)
|
||||
})
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
# Send heartbeat
|
||||
await websocket.send_json({"type": "heartbeat"})
|
||||
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
finally:
|
||||
manager.unsubscribe(subscriber)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Create/Initialize Spec
|
||||
# ============================================================================
|
||||
|
||||
@router.post("/create")
|
||||
async def create_spec(
|
||||
study_id: str,
|
||||
spec: Dict[str, Any],
|
||||
modified_by: str = Query(default="api")
|
||||
):
|
||||
"""
|
||||
Create a new spec for a study.
|
||||
|
||||
Use this when migrating from old config or creating a new study.
|
||||
Will fail if spec already exists (use PUT to replace).
|
||||
"""
|
||||
manager = get_manager(study_id)
|
||||
|
||||
if manager.exists():
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=f"Spec already exists for '{study_id}'. Use PUT to replace."
|
||||
)
|
||||
|
||||
try:
|
||||
# Ensure meta fields are set
|
||||
if "meta" not in spec:
|
||||
spec["meta"] = {}
|
||||
spec["meta"]["created_by"] = modified_by
|
||||
|
||||
new_hash = manager.save(spec, modified_by=modified_by)
|
||||
return {
|
||||
"success": True,
|
||||
"hash": new_hash,
|
||||
"message": f"Created spec for {study_id}"
|
||||
}
|
||||
except SpecValidationError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
@@ -3,5 +3,13 @@ Atomizer Dashboard Services
|
||||
"""
|
||||
|
||||
from .claude_agent import AtomizerClaudeAgent
|
||||
from .spec_manager import SpecManager, SpecManagerError, SpecNotFoundError, SpecConflictError, get_spec_manager
|
||||
|
||||
__all__ = ['AtomizerClaudeAgent']
|
||||
__all__ = [
|
||||
'AtomizerClaudeAgent',
|
||||
'SpecManager',
|
||||
'SpecManagerError',
|
||||
'SpecNotFoundError',
|
||||
'SpecConflictError',
|
||||
'get_spec_manager',
|
||||
]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
451
atomizer-dashboard/backend/api/services/claude_code_session.py
Normal file
451
atomizer-dashboard/backend/api/services/claude_code_session.py
Normal file
@@ -0,0 +1,451 @@
|
||||
"""
|
||||
Claude Code CLI Session Manager
|
||||
|
||||
Spawns actual Claude Code CLI processes with full Atomizer access.
|
||||
This gives dashboard users the same power as terminal users.
|
||||
|
||||
Unlike the MCP-based approach:
|
||||
- Claude can actually edit files (not just return instructions)
|
||||
- Claude can run Python scripts
|
||||
- Claude can execute git commands
|
||||
- Full Opus 4.5 capabilities
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import AsyncGenerator, Dict, Optional, Any
|
||||
|
||||
# Atomizer paths
|
||||
ATOMIZER_ROOT = Path(__file__).parent.parent.parent.parent.parent
|
||||
STUDIES_DIR = ATOMIZER_ROOT / "studies"
|
||||
|
||||
|
||||
class ClaudeCodeSession:
|
||||
"""
|
||||
Manages a Claude Code CLI session with full capabilities.
|
||||
|
||||
Unlike MCP tools, this spawns the actual claude CLI which has:
|
||||
- Full file system access
|
||||
- Full command execution
|
||||
- Opus 4.5 model
|
||||
- All Claude Code capabilities
|
||||
"""
|
||||
|
||||
def __init__(self, session_id: str, study_id: Optional[str] = None):
|
||||
self.session_id = session_id
|
||||
self.study_id = study_id
|
||||
self.canvas_state: Optional[Dict] = None
|
||||
self.conversation_history: list = []
|
||||
|
||||
# Determine working directory
|
||||
self.working_dir = ATOMIZER_ROOT
|
||||
if study_id:
|
||||
# Handle nested study paths like "M1_Mirror/m1_mirror_flatback_lateral"
|
||||
study_path = STUDIES_DIR / study_id
|
||||
if study_path.exists():
|
||||
self.working_dir = study_path
|
||||
else:
|
||||
# Try finding it in subdirectories
|
||||
for parent in STUDIES_DIR.iterdir():
|
||||
if parent.is_dir():
|
||||
nested_path = parent / study_id
|
||||
if nested_path.exists():
|
||||
self.working_dir = nested_path
|
||||
break
|
||||
|
||||
def set_canvas_state(self, canvas_state: Dict):
|
||||
"""Update canvas state from frontend"""
|
||||
self.canvas_state = canvas_state
|
||||
|
||||
async def send_message(self, message: str) -> AsyncGenerator[Dict[str, Any], None]:
|
||||
"""
|
||||
Send message to Claude Code CLI and stream response.
|
||||
|
||||
Uses claude CLI with:
|
||||
- --print for output
|
||||
- --dangerously-skip-permissions for full access (controlled environment)
|
||||
- Runs from Atomizer root to get CLAUDE.md context automatically
|
||||
- Study-specific context injected into prompt
|
||||
|
||||
Yields:
|
||||
Dict messages: {"type": "text", "content": "..."} or {"type": "done"}
|
||||
"""
|
||||
# Build comprehensive prompt with all context
|
||||
full_prompt = self._build_full_prompt(message)
|
||||
|
||||
# Create MCP config file for the session
|
||||
mcp_config_file = ATOMIZER_ROOT / f".claude-mcp-{self.session_id}.json"
|
||||
mcp_config = {
|
||||
"mcpServers": {
|
||||
"atomizer-tools": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "ts-node", str(ATOMIZER_ROOT / "atomizer-dashboard" / "mcp-server" / "src" / "index.ts")],
|
||||
"cwd": str(ATOMIZER_ROOT / "atomizer-dashboard" / "mcp-server"),
|
||||
"env": {
|
||||
"ATOMIZER_ROOT": str(ATOMIZER_ROOT),
|
||||
"STUDIES_DIR": str(STUDIES_DIR),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
mcp_config_file.write_text(json.dumps(mcp_config, indent=2), encoding='utf-8')
|
||||
|
||||
try:
|
||||
# Spawn claude CLI from ATOMIZER_ROOT so it picks up CLAUDE.md
|
||||
# This gives it full Atomizer context automatically
|
||||
# Note: prompt is passed via stdin for complex multi-line prompts
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
"claude",
|
||||
"--print",
|
||||
"--dangerously-skip-permissions",
|
||||
"--mcp-config", str(mcp_config_file),
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
stdin=asyncio.subprocess.PIPE,
|
||||
cwd=str(ATOMIZER_ROOT),
|
||||
env={
|
||||
**os.environ,
|
||||
"ATOMIZER_STUDY": self.study_id or "",
|
||||
"ATOMIZER_STUDY_PATH": str(self.working_dir),
|
||||
"ATOMIZER_ROOT": str(ATOMIZER_ROOT),
|
||||
}
|
||||
)
|
||||
|
||||
# Write prompt to stdin
|
||||
process.stdin.write(full_prompt.encode('utf-8'))
|
||||
await process.stdin.drain()
|
||||
process.stdin.close()
|
||||
|
||||
# Read and yield output as it comes
|
||||
full_output = ""
|
||||
|
||||
# Stream stdout
|
||||
while True:
|
||||
chunk = await process.stdout.read(512)
|
||||
if not chunk:
|
||||
break
|
||||
decoded = chunk.decode('utf-8', errors='replace')
|
||||
full_output += decoded
|
||||
yield {"type": "text", "content": decoded}
|
||||
|
||||
# Wait for process to complete
|
||||
await process.wait()
|
||||
|
||||
# Check for errors
|
||||
stderr = await process.stderr.read()
|
||||
if stderr and process.returncode != 0:
|
||||
error_text = stderr.decode('utf-8', errors='replace')
|
||||
yield {"type": "error", "content": f"\n[Error]: {error_text}"}
|
||||
|
||||
# Update conversation history
|
||||
self.conversation_history.append({"role": "user", "content": message})
|
||||
self.conversation_history.append({"role": "assistant", "content": full_output})
|
||||
|
||||
# Signal completion
|
||||
yield {"type": "done"}
|
||||
|
||||
# Check if any files were modified and signal canvas refresh
|
||||
if self._output_indicates_file_changes(full_output):
|
||||
yield {
|
||||
"type": "refresh_canvas",
|
||||
"study_id": self.study_id,
|
||||
"reason": "Claude modified study files"
|
||||
}
|
||||
|
||||
finally:
|
||||
# Clean up temp files
|
||||
if mcp_config_file.exists():
|
||||
try:
|
||||
mcp_config_file.unlink()
|
||||
except:
|
||||
pass
|
||||
|
||||
def _build_full_prompt(self, message: str) -> str:
|
||||
"""Build comprehensive prompt with all context"""
|
||||
parts = []
|
||||
|
||||
# Study context
|
||||
study_context = self._build_study_context() if self.study_id else ""
|
||||
if study_context:
|
||||
parts.append("## Current Study Context")
|
||||
parts.append(study_context)
|
||||
|
||||
# Canvas context
|
||||
if self.canvas_state:
|
||||
canvas_context = self._build_canvas_context()
|
||||
if canvas_context:
|
||||
parts.append("## Current Canvas State")
|
||||
parts.append(canvas_context)
|
||||
|
||||
# Conversation history (last few exchanges)
|
||||
if self.conversation_history:
|
||||
parts.append("## Recent Conversation")
|
||||
for msg in self.conversation_history[-6:]:
|
||||
role = "User" if msg["role"] == "user" else "Assistant"
|
||||
# Truncate long messages
|
||||
content = msg["content"][:500] + "..." if len(msg["content"]) > 500 else msg["content"]
|
||||
parts.append(f"**{role}:** {content}")
|
||||
parts.append("")
|
||||
|
||||
# User's actual request
|
||||
parts.append("## User Request")
|
||||
parts.append(message)
|
||||
parts.append("")
|
||||
|
||||
# Critical instruction
|
||||
parts.append("## Important")
|
||||
parts.append("You have FULL power to edit files in this environment. When asked to make changes:")
|
||||
parts.append("1. Use the Edit or Write tools to ACTUALLY MODIFY the files")
|
||||
parts.append("2. Show a brief summary of what you changed")
|
||||
parts.append("3. Do not just describe changes - MAKE THEM")
|
||||
parts.append("")
|
||||
parts.append("After making changes to optimization_config.json, the dashboard canvas will auto-refresh.")
|
||||
|
||||
return "\n".join(parts)
|
||||
|
||||
def _build_study_context(self) -> str:
|
||||
"""Build detailed context for the active study"""
|
||||
if not self.study_id:
|
||||
return ""
|
||||
|
||||
context_parts = [f"**Study ID:** `{self.study_id}`"]
|
||||
context_parts.append(f"**Study Path:** `{self.working_dir}`")
|
||||
context_parts.append("")
|
||||
|
||||
# Find and read optimization_config.json
|
||||
config_path = self.working_dir / "1_setup" / "optimization_config.json"
|
||||
if not config_path.exists():
|
||||
config_path = self.working_dir / "optimization_config.json"
|
||||
|
||||
if config_path.exists():
|
||||
try:
|
||||
config = json.loads(config_path.read_text(encoding='utf-8'))
|
||||
context_parts.append(f"**Config File:** `{config_path.relative_to(ATOMIZER_ROOT)}`")
|
||||
context_parts.append("")
|
||||
|
||||
# Design variables summary
|
||||
dvs = config.get("design_variables", [])
|
||||
if dvs:
|
||||
context_parts.append("### Design Variables")
|
||||
context_parts.append("")
|
||||
context_parts.append("| Name | Min | Max | Baseline | Unit |")
|
||||
context_parts.append("|------|-----|-----|----------|------|")
|
||||
for dv in dvs[:15]:
|
||||
name = dv.get("name", dv.get("expression_name", "?"))
|
||||
min_v = dv.get("min", dv.get("lower", "?"))
|
||||
max_v = dv.get("max", dv.get("upper", "?"))
|
||||
baseline = dv.get("baseline", "-")
|
||||
unit = dv.get("units", dv.get("unit", "-"))
|
||||
context_parts.append(f"| {name} | {min_v} | {max_v} | {baseline} | {unit} |")
|
||||
if len(dvs) > 15:
|
||||
context_parts.append(f"\n*... and {len(dvs) - 15} more*")
|
||||
context_parts.append("")
|
||||
|
||||
# Objectives
|
||||
objs = config.get("objectives", [])
|
||||
if objs:
|
||||
context_parts.append("### Objectives")
|
||||
context_parts.append("")
|
||||
for obj in objs:
|
||||
name = obj.get("name", "?")
|
||||
direction = obj.get("direction", "minimize")
|
||||
weight = obj.get("weight", 1)
|
||||
context_parts.append(f"- **{name}**: {direction} (weight: {weight})")
|
||||
context_parts.append("")
|
||||
|
||||
# Extraction method (for Zernike)
|
||||
ext_method = config.get("extraction_method", {})
|
||||
if ext_method:
|
||||
context_parts.append("### Extraction Method")
|
||||
context_parts.append("")
|
||||
context_parts.append(f"- Type: `{ext_method.get('type', '?')}`")
|
||||
context_parts.append(f"- Class: `{ext_method.get('class', '?')}`")
|
||||
if ext_method.get("inner_radius"):
|
||||
context_parts.append(f"- Inner Radius: `{ext_method.get('inner_radius')}`")
|
||||
context_parts.append("")
|
||||
|
||||
# Zernike settings
|
||||
zernike = config.get("zernike_settings", {})
|
||||
if zernike:
|
||||
context_parts.append("### Zernike Settings")
|
||||
context_parts.append("")
|
||||
context_parts.append(f"- Modes: `{zernike.get('n_modes', '?')}`")
|
||||
context_parts.append(f"- Filter Low Orders: `{zernike.get('filter_low_orders', '?')}`")
|
||||
context_parts.append(f"- Subcases: `{zernike.get('subcases', [])}`")
|
||||
context_parts.append("")
|
||||
|
||||
# Algorithm
|
||||
method = config.get("method", config.get("optimization", {}).get("sampler", "TPE"))
|
||||
max_trials = config.get("max_trials", config.get("optimization", {}).get("n_trials", 100))
|
||||
context_parts.append("### Algorithm")
|
||||
context_parts.append("")
|
||||
context_parts.append(f"- Method: `{method}`")
|
||||
context_parts.append(f"- Max Trials: `{max_trials}`")
|
||||
context_parts.append("")
|
||||
|
||||
except Exception as e:
|
||||
context_parts.append(f"*Error reading config: {e}*")
|
||||
context_parts.append("")
|
||||
else:
|
||||
context_parts.append("*No optimization_config.json found*")
|
||||
context_parts.append("")
|
||||
|
||||
# Check for run_optimization.py
|
||||
run_opt_path = self.working_dir / "run_optimization.py"
|
||||
if run_opt_path.exists():
|
||||
context_parts.append(f"**Run Script:** `{run_opt_path.relative_to(ATOMIZER_ROOT)}` (exists)")
|
||||
else:
|
||||
context_parts.append("**Run Script:** not found")
|
||||
context_parts.append("")
|
||||
|
||||
# Check results
|
||||
db_path = self.working_dir / "3_results" / "study.db"
|
||||
if not db_path.exists():
|
||||
db_path = self.working_dir / "2_results" / "study.db"
|
||||
|
||||
if db_path.exists():
|
||||
context_parts.append("**Results Database:** exists")
|
||||
# Could query trial count here
|
||||
else:
|
||||
context_parts.append("**Results Database:** not found (no optimization run yet)")
|
||||
|
||||
return "\n".join(context_parts)
|
||||
|
||||
def _build_canvas_context(self) -> str:
|
||||
"""Build markdown context from canvas state"""
|
||||
if not self.canvas_state:
|
||||
return ""
|
||||
|
||||
parts = []
|
||||
|
||||
nodes = self.canvas_state.get("nodes", [])
|
||||
edges = self.canvas_state.get("edges", [])
|
||||
|
||||
if not nodes:
|
||||
return "*Canvas is empty*"
|
||||
|
||||
# Group nodes by type
|
||||
design_vars = [n for n in nodes if n.get("type") == "designVar"]
|
||||
objectives = [n for n in nodes if n.get("type") == "objective"]
|
||||
extractors = [n for n in nodes if n.get("type") == "extractor"]
|
||||
models = [n for n in nodes if n.get("type") == "nxModel"]
|
||||
algorithms = [n for n in nodes if n.get("type") == "algorithm"]
|
||||
|
||||
if models:
|
||||
parts.append("### NX Model")
|
||||
for m in models:
|
||||
data = m.get("data", {})
|
||||
parts.append(f"- File: `{data.get('filePath', 'Not set')}`")
|
||||
parts.append("")
|
||||
|
||||
if design_vars:
|
||||
parts.append("### Design Variables (Canvas)")
|
||||
parts.append("")
|
||||
parts.append("| Name | Min | Max | Baseline |")
|
||||
parts.append("|------|-----|-----|----------|")
|
||||
for dv in design_vars[:20]:
|
||||
data = dv.get("data", {})
|
||||
name = data.get("expressionName") or data.get("label", "?")
|
||||
min_v = data.get("minValue", "?")
|
||||
max_v = data.get("maxValue", "?")
|
||||
baseline = data.get("baseline", "-")
|
||||
parts.append(f"| {name} | {min_v} | {max_v} | {baseline} |")
|
||||
if len(design_vars) > 20:
|
||||
parts.append(f"\n*... and {len(design_vars) - 20} more*")
|
||||
parts.append("")
|
||||
|
||||
if extractors:
|
||||
parts.append("### Extractors (Canvas)")
|
||||
parts.append("")
|
||||
for ext in extractors:
|
||||
data = ext.get("data", {})
|
||||
ext_type = data.get("extractorType") or data.get("extractorId", "?")
|
||||
label = data.get("label", "?")
|
||||
parts.append(f"- **{label}**: `{ext_type}`")
|
||||
parts.append("")
|
||||
|
||||
if objectives:
|
||||
parts.append("### Objectives (Canvas)")
|
||||
parts.append("")
|
||||
for obj in objectives:
|
||||
data = obj.get("data", {})
|
||||
name = data.get("objectiveName") or data.get("label", "?")
|
||||
direction = data.get("direction", "minimize")
|
||||
weight = data.get("weight", 1)
|
||||
parts.append(f"- **{name}**: {direction} (weight: {weight})")
|
||||
parts.append("")
|
||||
|
||||
if algorithms:
|
||||
parts.append("### Algorithm (Canvas)")
|
||||
for alg in algorithms:
|
||||
data = alg.get("data", {})
|
||||
method = data.get("method", "?")
|
||||
trials = data.get("maxTrials", "?")
|
||||
parts.append(f"- Method: `{method}`")
|
||||
parts.append(f"- Max Trials: `{trials}`")
|
||||
parts.append("")
|
||||
|
||||
return "\n".join(parts)
|
||||
|
||||
def _output_indicates_file_changes(self, output: str) -> bool:
|
||||
"""Check if Claude's output indicates file modifications"""
|
||||
indicators = [
|
||||
"✓ Edited",
|
||||
"✓ Wrote",
|
||||
"Successfully wrote",
|
||||
"Successfully edited",
|
||||
"Modified:",
|
||||
"Updated:",
|
||||
"Added to file",
|
||||
"optimization_config.json", # Common target
|
||||
"run_optimization.py", # Common target
|
||||
]
|
||||
output_lower = output.lower()
|
||||
return any(indicator.lower() in output_lower for indicator in indicators)
|
||||
|
||||
|
||||
class ClaudeCodeSessionManager:
|
||||
"""
|
||||
Manages multiple Claude Code sessions.
|
||||
|
||||
Each session is independent and can have different study contexts.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.sessions: Dict[str, ClaudeCodeSession] = {}
|
||||
|
||||
def create_session(self, study_id: Optional[str] = None) -> ClaudeCodeSession:
|
||||
"""Create a new Claude Code session"""
|
||||
session_id = str(uuid.uuid4())[:8]
|
||||
session = ClaudeCodeSession(session_id, study_id)
|
||||
self.sessions[session_id] = session
|
||||
return session
|
||||
|
||||
def get_session(self, session_id: str) -> Optional[ClaudeCodeSession]:
|
||||
"""Get an existing session"""
|
||||
return self.sessions.get(session_id)
|
||||
|
||||
def remove_session(self, session_id: str):
|
||||
"""Remove a session"""
|
||||
self.sessions.pop(session_id, None)
|
||||
|
||||
def set_canvas_state(self, session_id: str, canvas_state: Dict):
|
||||
"""Update canvas state for a session"""
|
||||
session = self.sessions.get(session_id)
|
||||
if session:
|
||||
session.set_canvas_state(canvas_state)
|
||||
|
||||
|
||||
# Global session manager instance
|
||||
_session_manager: Optional[ClaudeCodeSessionManager] = None
|
||||
|
||||
|
||||
def get_claude_code_manager() -> ClaudeCodeSessionManager:
|
||||
"""Get the global session manager"""
|
||||
global _session_manager
|
||||
if _session_manager is None:
|
||||
_session_manager = ClaudeCodeSessionManager()
|
||||
return _session_manager
|
||||
396
atomizer-dashboard/backend/api/services/claude_readme.py
Normal file
396
atomizer-dashboard/backend/api/services/claude_readme.py
Normal file
@@ -0,0 +1,396 @@
|
||||
"""
|
||||
Claude README Generator Service
|
||||
|
||||
Generates intelligent README.md files for optimization studies
|
||||
using Claude Code CLI (not API) with study context from AtomizerSpec.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import subprocess
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
# Base directory
|
||||
ATOMIZER_ROOT = Path(__file__).parent.parent.parent.parent.parent
|
||||
|
||||
# Load skill prompt
|
||||
SKILL_PATH = ATOMIZER_ROOT / ".claude" / "skills" / "modules" / "study-readme-generator.md"
|
||||
|
||||
|
||||
def load_skill_prompt() -> str:
|
||||
"""Load the README generator skill prompt."""
|
||||
if SKILL_PATH.exists():
|
||||
return SKILL_PATH.read_text(encoding="utf-8")
|
||||
return ""
|
||||
|
||||
|
||||
class ClaudeReadmeGenerator:
|
||||
"""Generate README.md files using Claude Code CLI."""
|
||||
|
||||
def __init__(self):
|
||||
self.skill_prompt = load_skill_prompt()
|
||||
|
||||
def generate_readme(
|
||||
self,
|
||||
study_name: str,
|
||||
spec: Dict[str, Any],
|
||||
context_files: Optional[Dict[str, str]] = None,
|
||||
topic: Optional[str] = None,
|
||||
) -> str:
|
||||
"""
|
||||
Generate a README.md for a study using Claude Code CLI.
|
||||
|
||||
Args:
|
||||
study_name: Name of the study
|
||||
spec: Full AtomizerSpec v2.0 dict
|
||||
context_files: Optional dict of {filename: content} for context
|
||||
topic: Optional topic folder name
|
||||
|
||||
Returns:
|
||||
Generated README.md content
|
||||
"""
|
||||
# Build context for Claude
|
||||
context = self._build_context(study_name, spec, context_files, topic)
|
||||
|
||||
# Build the prompt
|
||||
prompt = self._build_prompt(context)
|
||||
|
||||
try:
|
||||
# Run Claude Code CLI synchronously
|
||||
result = self._run_claude_cli(prompt)
|
||||
|
||||
# Extract markdown content from response
|
||||
readme_content = self._extract_markdown(result)
|
||||
|
||||
if readme_content:
|
||||
return readme_content
|
||||
|
||||
# If no markdown found, return the whole response
|
||||
return result if result else self._generate_fallback_readme(study_name, spec)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Claude CLI error: {e}")
|
||||
return self._generate_fallback_readme(study_name, spec)
|
||||
|
||||
async def generate_readme_async(
|
||||
self,
|
||||
study_name: str,
|
||||
spec: Dict[str, Any],
|
||||
context_files: Optional[Dict[str, str]] = None,
|
||||
topic: Optional[str] = None,
|
||||
) -> str:
|
||||
"""Async version of generate_readme."""
|
||||
# Run in thread pool to not block
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(
|
||||
None, lambda: self.generate_readme(study_name, spec, context_files, topic)
|
||||
)
|
||||
|
||||
def _run_claude_cli(self, prompt: str) -> str:
|
||||
"""Run Claude Code CLI and get response."""
|
||||
try:
|
||||
# Use claude CLI with --print flag for non-interactive output
|
||||
result = subprocess.run(
|
||||
["claude", "--print", prompt],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120, # 2 minute timeout
|
||||
cwd=str(ATOMIZER_ROOT),
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
error_msg = result.stderr or "Unknown error"
|
||||
raise Exception(f"Claude CLI error: {error_msg}")
|
||||
|
||||
return result.stdout.strip()
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
raise Exception("Request timed out")
|
||||
except FileNotFoundError:
|
||||
raise Exception("Claude CLI not found. Make sure 'claude' is in PATH.")
|
||||
|
||||
def _build_context(
|
||||
self,
|
||||
study_name: str,
|
||||
spec: Dict[str, Any],
|
||||
context_files: Optional[Dict[str, str]],
|
||||
topic: Optional[str],
|
||||
) -> Dict[str, Any]:
|
||||
"""Build the context object for Claude."""
|
||||
meta = spec.get("meta", {})
|
||||
model = spec.get("model", {})
|
||||
introspection = model.get("introspection", {}) or {}
|
||||
|
||||
context = {
|
||||
"study_name": study_name,
|
||||
"topic": topic or meta.get("topic", "Other"),
|
||||
"description": meta.get("description", ""),
|
||||
"created": meta.get("created", datetime.now().isoformat()),
|
||||
"status": meta.get("status", "draft"),
|
||||
"design_variables": spec.get("design_variables", []),
|
||||
"extractors": spec.get("extractors", []),
|
||||
"objectives": spec.get("objectives", []),
|
||||
"constraints": spec.get("constraints", []),
|
||||
"optimization": spec.get("optimization", {}),
|
||||
"introspection": {
|
||||
"mass_kg": introspection.get("mass_kg"),
|
||||
"volume_mm3": introspection.get("volume_mm3"),
|
||||
"solver_type": introspection.get("solver_type"),
|
||||
"expressions": introspection.get("expressions", []),
|
||||
"expressions_count": len(introspection.get("expressions", [])),
|
||||
},
|
||||
"model_files": {
|
||||
"sim": model.get("sim", {}).get("path") if model.get("sim") else None,
|
||||
"prt": model.get("prt", {}).get("path") if model.get("prt") else None,
|
||||
"fem": model.get("fem", {}).get("path") if model.get("fem") else None,
|
||||
},
|
||||
}
|
||||
|
||||
# Add context files if provided
|
||||
if context_files:
|
||||
context["context_files"] = context_files
|
||||
|
||||
return context
|
||||
|
||||
def _build_prompt(self, context: Dict[str, Any]) -> str:
|
||||
"""Build the prompt for Claude CLI."""
|
||||
|
||||
# Build context files section if available
|
||||
context_files_section = ""
|
||||
if context.get("context_files"):
|
||||
context_files_section = "\n\n## User-Provided Context Files\n\nIMPORTANT: Use this information to understand the optimization goals, design variables, objectives, and constraints:\n\n"
|
||||
for filename, content in context.get("context_files", {}).items():
|
||||
context_files_section += f"### {filename}\n```\n{content}\n```\n\n"
|
||||
|
||||
# Remove context_files from JSON dump to avoid duplication
|
||||
context_for_json = {k: v for k, v in context.items() if k != "context_files"}
|
||||
|
||||
prompt = f"""Generate a README.md for this FEA optimization study.
|
||||
|
||||
## Study Technical Data
|
||||
|
||||
```json
|
||||
{json.dumps(context_for_json, indent=2, default=str)}
|
||||
```
|
||||
{context_files_section}
|
||||
## Requirements
|
||||
|
||||
1. Use the EXACT values from the technical data - no placeholders
|
||||
2. If context files are provided, extract:
|
||||
- Design variable bounds (min/max)
|
||||
- Optimization objectives (minimize/maximize what)
|
||||
- Constraints (stress limits, etc.)
|
||||
- Any specific requirements mentioned
|
||||
|
||||
3. Format the README with these sections:
|
||||
- Title (# Study Name)
|
||||
- Overview (topic, date, status, description from context)
|
||||
- Engineering Problem (what we're optimizing and why - from context files)
|
||||
- Model Information (mass, solver, files)
|
||||
- Design Variables (if context specifies bounds, include them in a table)
|
||||
- Optimization Objectives (from context files)
|
||||
- Constraints (from context files)
|
||||
- Expressions Found (table of discovered expressions, highlight candidates)
|
||||
- Next Steps (what needs to be configured)
|
||||
|
||||
4. Keep it professional and concise
|
||||
5. Use proper markdown table formatting
|
||||
6. Include units where applicable
|
||||
7. For expressions table, show: name, value, units, is_candidate
|
||||
|
||||
Generate ONLY the README.md content in markdown format, no explanations:"""
|
||||
|
||||
return prompt
|
||||
|
||||
def _extract_markdown(self, response: str) -> Optional[str]:
|
||||
"""Extract markdown content from Claude response."""
|
||||
if not response:
|
||||
return None
|
||||
|
||||
# If response starts with #, it's already markdown
|
||||
if response.strip().startswith("#"):
|
||||
return response.strip()
|
||||
|
||||
# Try to find markdown block
|
||||
if "```markdown" in response:
|
||||
start = response.find("```markdown") + len("```markdown")
|
||||
end = response.find("```", start)
|
||||
if end > start:
|
||||
return response[start:end].strip()
|
||||
|
||||
if "```md" in response:
|
||||
start = response.find("```md") + len("```md")
|
||||
end = response.find("```", start)
|
||||
if end > start:
|
||||
return response[start:end].strip()
|
||||
|
||||
# Look for first # heading
|
||||
lines = response.split("\n")
|
||||
for i, line in enumerate(lines):
|
||||
if line.strip().startswith("# "):
|
||||
return "\n".join(lines[i:]).strip()
|
||||
|
||||
return None
|
||||
|
||||
def _generate_fallback_readme(self, study_name: str, spec: Dict[str, Any]) -> str:
|
||||
"""Generate a basic README if Claude fails."""
|
||||
meta = spec.get("meta", {})
|
||||
model = spec.get("model", {})
|
||||
introspection = model.get("introspection", {}) or {}
|
||||
dvs = spec.get("design_variables", [])
|
||||
objs = spec.get("objectives", [])
|
||||
cons = spec.get("constraints", [])
|
||||
opt = spec.get("optimization", {})
|
||||
expressions = introspection.get("expressions", [])
|
||||
|
||||
lines = [
|
||||
f"# {study_name.replace('_', ' ').title()}",
|
||||
"",
|
||||
f"**Topic**: {meta.get('topic', 'Other')}",
|
||||
f"**Created**: {meta.get('created', 'Unknown')[:10] if meta.get('created') else 'Unknown'}",
|
||||
f"**Status**: {meta.get('status', 'draft')}",
|
||||
"",
|
||||
]
|
||||
|
||||
if meta.get("description"):
|
||||
lines.extend([meta["description"], ""])
|
||||
|
||||
# Model Information
|
||||
lines.extend(
|
||||
[
|
||||
"## Model Information",
|
||||
"",
|
||||
]
|
||||
)
|
||||
|
||||
if introspection.get("mass_kg"):
|
||||
lines.append(f"- **Mass**: {introspection['mass_kg']:.2f} kg")
|
||||
|
||||
sim_path = model.get("sim", {}).get("path") if model.get("sim") else None
|
||||
if sim_path:
|
||||
lines.append(f"- **Simulation**: {sim_path}")
|
||||
|
||||
lines.append("")
|
||||
|
||||
# Expressions Found
|
||||
if expressions:
|
||||
lines.extend(
|
||||
[
|
||||
"## Expressions Found",
|
||||
"",
|
||||
"| Name | Value | Units | Candidate |",
|
||||
"|------|-------|-------|-----------|",
|
||||
]
|
||||
)
|
||||
for expr in expressions:
|
||||
is_candidate = "✓" if expr.get("is_candidate") else ""
|
||||
value = f"{expr.get('value', '-')}"
|
||||
units = expr.get("units", "-")
|
||||
lines.append(f"| {expr.get('name', '-')} | {value} | {units} | {is_candidate} |")
|
||||
lines.append("")
|
||||
|
||||
# Design Variables (if configured)
|
||||
if dvs:
|
||||
lines.extend(
|
||||
[
|
||||
"## Design Variables",
|
||||
"",
|
||||
"| Variable | Expression | Range | Units |",
|
||||
"|----------|------------|-------|-------|",
|
||||
]
|
||||
)
|
||||
for dv in dvs:
|
||||
bounds = dv.get("bounds", {})
|
||||
units = dv.get("units", "-")
|
||||
lines.append(
|
||||
f"| {dv.get('name', 'Unknown')} | "
|
||||
f"{dv.get('expression_name', '-')} | "
|
||||
f"[{bounds.get('min', '-')}, {bounds.get('max', '-')}] | "
|
||||
f"{units} |"
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
# Objectives
|
||||
if objs:
|
||||
lines.extend(
|
||||
[
|
||||
"## Objectives",
|
||||
"",
|
||||
"| Objective | Direction | Weight |",
|
||||
"|-----------|-----------|--------|",
|
||||
]
|
||||
)
|
||||
for obj in objs:
|
||||
lines.append(
|
||||
f"| {obj.get('name', 'Unknown')} | "
|
||||
f"{obj.get('direction', 'minimize')} | "
|
||||
f"{obj.get('weight', 1.0)} |"
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
# Constraints
|
||||
if cons:
|
||||
lines.extend(
|
||||
[
|
||||
"## Constraints",
|
||||
"",
|
||||
"| Constraint | Condition | Threshold |",
|
||||
"|------------|-----------|-----------|",
|
||||
]
|
||||
)
|
||||
for con in cons:
|
||||
lines.append(
|
||||
f"| {con.get('name', 'Unknown')} | "
|
||||
f"{con.get('operator', '<=')} | "
|
||||
f"{con.get('threshold', '-')} |"
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
# Algorithm
|
||||
algo = opt.get("algorithm", {})
|
||||
budget = opt.get("budget", {})
|
||||
lines.extend(
|
||||
[
|
||||
"## Methodology",
|
||||
"",
|
||||
f"- **Algorithm**: {algo.get('type', 'TPE')}",
|
||||
f"- **Max Trials**: {budget.get('max_trials', 100)}",
|
||||
"",
|
||||
]
|
||||
)
|
||||
|
||||
# Next Steps
|
||||
lines.extend(
|
||||
[
|
||||
"## Next Steps",
|
||||
"",
|
||||
]
|
||||
)
|
||||
|
||||
if not dvs:
|
||||
lines.append("- [ ] Configure design variables from discovered expressions")
|
||||
if not objs:
|
||||
lines.append("- [ ] Define optimization objectives")
|
||||
if not dvs and not objs:
|
||||
lines.append("- [ ] Open in Canvas Builder to complete configuration")
|
||||
else:
|
||||
lines.append("- [ ] Run baseline solve to validate setup")
|
||||
lines.append("- [ ] Finalize study to move to studies folder")
|
||||
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# Singleton instance
|
||||
_generator: Optional[ClaudeReadmeGenerator] = None
|
||||
|
||||
|
||||
def get_readme_generator() -> ClaudeReadmeGenerator:
|
||||
"""Get the singleton README generator instance."""
|
||||
global _generator
|
||||
if _generator is None:
|
||||
_generator = ClaudeReadmeGenerator()
|
||||
return _generator
|
||||
@@ -26,6 +26,7 @@ class ContextBuilder:
|
||||
study_id: Optional[str] = None,
|
||||
conversation_history: Optional[List[Dict[str, Any]]] = None,
|
||||
canvas_state: Optional[Dict[str, Any]] = None,
|
||||
spec_path: Optional[str] = None,
|
||||
) -> str:
|
||||
"""
|
||||
Build full system prompt with context.
|
||||
@@ -35,6 +36,7 @@ class ContextBuilder:
|
||||
study_id: Optional study name to provide context for
|
||||
conversation_history: Optional recent messages for continuity
|
||||
canvas_state: Optional canvas state (nodes, edges) from the UI
|
||||
spec_path: Optional path to the atomizer_spec.json file
|
||||
|
||||
Returns:
|
||||
Complete system prompt string
|
||||
@@ -43,7 +45,11 @@ class ContextBuilder:
|
||||
|
||||
# Canvas context takes priority - if user is working on a canvas, include it
|
||||
if canvas_state:
|
||||
parts.append(self._canvas_context(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, spec_path))
|
||||
else:
|
||||
print("[ContextBuilder] No canvas state provided")
|
||||
|
||||
if study_id:
|
||||
parts.append(self._study_context(study_id))
|
||||
@@ -53,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)
|
||||
|
||||
@@ -91,7 +97,117 @@ Important guidelines:
|
||||
|
||||
context = f"# Current Study: {study_id}\n\n"
|
||||
|
||||
# Load configuration
|
||||
# Check for AtomizerSpec v2.0 first (preferred)
|
||||
spec_path = study_dir / "1_setup" / "atomizer_spec.json"
|
||||
if not spec_path.exists():
|
||||
spec_path = study_dir / "atomizer_spec.json"
|
||||
|
||||
if spec_path.exists():
|
||||
context += self._spec_context(spec_path)
|
||||
else:
|
||||
# Fall back to legacy optimization_config.json
|
||||
context += self._legacy_config_context(study_dir)
|
||||
|
||||
# Check for results
|
||||
db_path = study_dir / "3_results" / "study.db"
|
||||
if db_path.exists():
|
||||
try:
|
||||
conn = sqlite3.connect(db_path)
|
||||
count = conn.execute(
|
||||
"SELECT COUNT(*) FROM trials WHERE state = 'COMPLETE'"
|
||||
).fetchone()[0]
|
||||
|
||||
best = conn.execute("""
|
||||
SELECT MIN(tv.value) FROM trial_values tv
|
||||
JOIN trials t ON tv.trial_id = t.trial_id
|
||||
WHERE t.state = 'COMPLETE'
|
||||
""").fetchone()[0]
|
||||
|
||||
context += f"\n## Results Status\n\n"
|
||||
context += f"- **Trials completed**: {count}\n"
|
||||
if best is not None:
|
||||
context += f"- **Best objective**: {best:.6f}\n"
|
||||
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return context
|
||||
|
||||
def _spec_context(self, spec_path: Path) -> str:
|
||||
"""Build context from AtomizerSpec v2.0 file"""
|
||||
context = "**Format**: AtomizerSpec v2.0\n\n"
|
||||
|
||||
try:
|
||||
with open(spec_path) as f:
|
||||
spec = json.load(f)
|
||||
|
||||
context += "## Configuration\n\n"
|
||||
|
||||
# Design variables
|
||||
dvs = spec.get("design_variables", [])
|
||||
if dvs:
|
||||
context += "**Design Variables:**\n"
|
||||
for dv in dvs[:10]:
|
||||
bounds = dv.get("bounds", {})
|
||||
bound_str = f"[{bounds.get('min', '?')}, {bounds.get('max', '?')}]"
|
||||
enabled = "✓" if dv.get("enabled", True) else "✗"
|
||||
context += f"- {dv.get('name', 'unnamed')}: {bound_str} {enabled}\n"
|
||||
if len(dvs) > 10:
|
||||
context += f"- ... and {len(dvs) - 10} more\n"
|
||||
|
||||
# Extractors
|
||||
extractors = spec.get("extractors", [])
|
||||
if extractors:
|
||||
context += "\n**Extractors:**\n"
|
||||
for ext in extractors:
|
||||
ext_type = ext.get("type", "unknown")
|
||||
outputs = ext.get("outputs", [])
|
||||
output_names = [o.get("name", "?") for o in outputs[:3]]
|
||||
builtin = "builtin" if ext.get("builtin", True) else "custom"
|
||||
context += f"- {ext.get('name', 'unnamed')} ({ext_type}, {builtin}): outputs {output_names}\n"
|
||||
|
||||
# Objectives
|
||||
objs = spec.get("objectives", [])
|
||||
if objs:
|
||||
context += "\n**Objectives:**\n"
|
||||
for obj in objs:
|
||||
direction = obj.get("direction", "minimize")
|
||||
weight = obj.get("weight", 1.0)
|
||||
context += f"- {obj.get('name', 'unnamed')} ({direction}, weight={weight})\n"
|
||||
|
||||
# Constraints
|
||||
constraints = spec.get("constraints", [])
|
||||
if constraints:
|
||||
context += "\n**Constraints:**\n"
|
||||
for c in constraints:
|
||||
op = c.get("operator", "<=")
|
||||
thresh = c.get("threshold", "?")
|
||||
context += f"- {c.get('name', 'unnamed')}: {op} {thresh}\n"
|
||||
|
||||
# Optimization settings
|
||||
opt = spec.get("optimization", {})
|
||||
algo = opt.get("algorithm", {})
|
||||
budget = opt.get("budget", {})
|
||||
method = algo.get("type", "TPE")
|
||||
max_trials = budget.get("max_trials", "not set")
|
||||
context += f"\n**Optimization**: {method}, max_trials: {max_trials}\n"
|
||||
|
||||
# Surrogate
|
||||
surrogate = opt.get("surrogate", {})
|
||||
if surrogate.get("enabled"):
|
||||
sur_type = surrogate.get("type", "gaussian_process")
|
||||
context += f"**Surrogate**: {sur_type} enabled\n"
|
||||
|
||||
except (json.JSONDecodeError, IOError) as e:
|
||||
context += f"\n*Spec file exists but could not be parsed: {e}*\n"
|
||||
|
||||
return context
|
||||
|
||||
def _legacy_config_context(self, study_dir: Path) -> str:
|
||||
"""Build context from legacy optimization_config.json"""
|
||||
context = "**Format**: Legacy optimization_config.json\n\n"
|
||||
|
||||
config_path = study_dir / "1_setup" / "optimization_config.json"
|
||||
if not config_path.exists():
|
||||
config_path = study_dir / "optimization_config.json"
|
||||
@@ -135,30 +251,8 @@ Important guidelines:
|
||||
|
||||
except (json.JSONDecodeError, IOError) as e:
|
||||
context += f"\n*Config file exists but could not be parsed: {e}*\n"
|
||||
|
||||
# Check for results
|
||||
db_path = study_dir / "3_results" / "study.db"
|
||||
if db_path.exists():
|
||||
try:
|
||||
conn = sqlite3.connect(db_path)
|
||||
count = conn.execute(
|
||||
"SELECT COUNT(*) FROM trials WHERE state = 'COMPLETE'"
|
||||
).fetchone()[0]
|
||||
|
||||
best = conn.execute("""
|
||||
SELECT MIN(tv.value) FROM trial_values tv
|
||||
JOIN trials t ON tv.trial_id = t.trial_id
|
||||
WHERE t.state = 'COMPLETE'
|
||||
""").fetchone()[0]
|
||||
|
||||
context += f"\n## Results Status\n\n"
|
||||
context += f"- **Trials completed**: {count}\n"
|
||||
if best is not None:
|
||||
context += f"- **Best objective**: {best:.6f}\n"
|
||||
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
context += "*No configuration file found.*\n"
|
||||
|
||||
return context
|
||||
|
||||
@@ -206,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).
|
||||
|
||||
@@ -225,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
|
||||
@@ -346,43 +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
|
||||
|
||||
When the user asks to modify the canvas (add/remove nodes, change values), use these MCP tools:
|
||||
- `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 canvas_add_node
|
||||
- "Change the weight of wfe_40_20 to 8" → Use canvas_update_node
|
||||
- "Remove the constraint node" → Use canvas_remove_node
|
||||
- "Connect the new extractor to the objective" → Use canvas_connect_nodes
|
||||
|
||||
Always respond with confirmation of changes made to the canvas.
|
||||
"""
|
||||
|
||||
# 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 Atomizer's codebase. You can:
|
||||
- Edit any file using `edit_file` tool
|
||||
- Create new files with `create_file` tool
|
||||
- Create new extractors with `create_extractor` tool
|
||||
- Run shell commands with `run_shell_command` tool
|
||||
- Search codebase with `search_codebase` tool
|
||||
- Commit and push changes
|
||||
You have **FULL ACCESS** to modify Atomizer studies. **DO NOT ASK FOR PERMISSION** - just do it.
|
||||
|
||||
**Use these powers responsibly.** Always explain what you're doing and why.
|
||||
## CRITICAL: How to Modify the Spec
|
||||
|
||||
For routine operations (list, status, run, analyze), use the standard tools.
|
||||
"""
|
||||
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
|
||||
|
||||
@@ -393,20 +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`
|
||||
|
||||
**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
|
||||
"""
|
||||
|
||||
454
atomizer-dashboard/backend/api/services/interview_engine.py
Normal file
454
atomizer-dashboard/backend/api/services/interview_engine.py
Normal file
@@ -0,0 +1,454 @@
|
||||
"""
|
||||
Interview Engine - Guided Study Creation through Conversation
|
||||
|
||||
Provides a structured interview flow for creating optimization studies.
|
||||
Claude uses this to gather information step-by-step, building a complete
|
||||
atomizer_spec.json through natural conversation.
|
||||
"""
|
||||
|
||||
from typing import Dict, Any, List, Optional, Literal
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from datetime import datetime
|
||||
import json
|
||||
|
||||
|
||||
class InterviewState(str, Enum):
|
||||
"""Current phase of the interview"""
|
||||
NOT_STARTED = "not_started"
|
||||
GATHERING_BASICS = "gathering_basics" # Name, description, goals
|
||||
GATHERING_MODEL = "gathering_model" # Model file, solver type
|
||||
GATHERING_VARIABLES = "gathering_variables" # Design variables
|
||||
GATHERING_EXTRACTORS = "gathering_extractors" # Physics extractors
|
||||
GATHERING_OBJECTIVES = "gathering_objectives" # Objectives
|
||||
GATHERING_CONSTRAINTS = "gathering_constraints" # Constraints
|
||||
GATHERING_SETTINGS = "gathering_settings" # Algorithm, trials
|
||||
REVIEW = "review" # Review before creation
|
||||
COMPLETED = "completed"
|
||||
|
||||
|
||||
@dataclass
|
||||
class InterviewData:
|
||||
"""Accumulated data from the interview"""
|
||||
# Basics
|
||||
study_name: Optional[str] = None
|
||||
category: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
goals: List[str] = field(default_factory=list)
|
||||
|
||||
# Model
|
||||
sim_file: Optional[str] = None
|
||||
prt_file: Optional[str] = None
|
||||
solver_type: str = "nastran"
|
||||
|
||||
# Design variables
|
||||
design_variables: List[Dict[str, Any]] = field(default_factory=list)
|
||||
|
||||
# Extractors
|
||||
extractors: List[Dict[str, Any]] = field(default_factory=list)
|
||||
|
||||
# Objectives
|
||||
objectives: List[Dict[str, Any]] = field(default_factory=list)
|
||||
|
||||
# Constraints
|
||||
constraints: List[Dict[str, Any]] = field(default_factory=list)
|
||||
|
||||
# Settings
|
||||
algorithm: str = "TPE"
|
||||
max_trials: int = 100
|
||||
|
||||
def to_spec(self) -> Dict[str, Any]:
|
||||
"""Convert interview data to atomizer_spec.json format"""
|
||||
# Generate IDs for each element
|
||||
dvs_with_ids = []
|
||||
for i, dv in enumerate(self.design_variables):
|
||||
dv_copy = dv.copy()
|
||||
dv_copy['id'] = f"dv_{i+1:03d}"
|
||||
dv_copy['canvas_position'] = {'x': 50, 'y': 100 + i * 80}
|
||||
dvs_with_ids.append(dv_copy)
|
||||
|
||||
exts_with_ids = []
|
||||
for i, ext in enumerate(self.extractors):
|
||||
ext_copy = ext.copy()
|
||||
ext_copy['id'] = f"ext_{i+1:03d}"
|
||||
ext_copy['canvas_position'] = {'x': 400, 'y': 100 + i * 80}
|
||||
exts_with_ids.append(ext_copy)
|
||||
|
||||
objs_with_ids = []
|
||||
for i, obj in enumerate(self.objectives):
|
||||
obj_copy = obj.copy()
|
||||
obj_copy['id'] = f"obj_{i+1:03d}"
|
||||
obj_copy['canvas_position'] = {'x': 750, 'y': 100 + i * 80}
|
||||
objs_with_ids.append(obj_copy)
|
||||
|
||||
cons_with_ids = []
|
||||
for i, con in enumerate(self.constraints):
|
||||
con_copy = con.copy()
|
||||
con_copy['id'] = f"con_{i+1:03d}"
|
||||
con_copy['canvas_position'] = {'x': 750, 'y': 400 + i * 80}
|
||||
cons_with_ids.append(con_copy)
|
||||
|
||||
return {
|
||||
"meta": {
|
||||
"version": "2.0",
|
||||
"study_name": self.study_name or "untitled_study",
|
||||
"description": self.description or "",
|
||||
"created_at": datetime.now().isoformat(),
|
||||
"created_by": "interview",
|
||||
"modified_at": datetime.now().isoformat(),
|
||||
"modified_by": "interview"
|
||||
},
|
||||
"model": {
|
||||
"sim": {
|
||||
"path": self.sim_file or "",
|
||||
"solver": self.solver_type
|
||||
}
|
||||
},
|
||||
"design_variables": dvs_with_ids,
|
||||
"extractors": exts_with_ids,
|
||||
"objectives": objs_with_ids,
|
||||
"constraints": cons_with_ids,
|
||||
"optimization": {
|
||||
"algorithm": {
|
||||
"type": self.algorithm
|
||||
},
|
||||
"budget": {
|
||||
"max_trials": self.max_trials
|
||||
}
|
||||
},
|
||||
"canvas": {
|
||||
"edges": [],
|
||||
"layout_version": "2.0"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class InterviewEngine:
|
||||
"""
|
||||
Manages the interview flow for study creation.
|
||||
|
||||
Usage:
|
||||
1. Create engine: engine = InterviewEngine()
|
||||
2. Start interview: engine.start()
|
||||
3. Record answers: engine.record_answer("study_name", "bracket_opt")
|
||||
4. Check progress: engine.get_progress()
|
||||
5. Generate spec: engine.finalize()
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.state = InterviewState.NOT_STARTED
|
||||
self.data = InterviewData()
|
||||
self.questions_asked: List[str] = []
|
||||
self.errors: List[str] = []
|
||||
|
||||
def start(self) -> Dict[str, Any]:
|
||||
"""Start the interview process"""
|
||||
self.state = InterviewState.GATHERING_BASICS
|
||||
return {
|
||||
"state": self.state.value,
|
||||
"message": "Let's create a new optimization study! I'll guide you through the process.",
|
||||
"next_questions": self.get_current_questions()
|
||||
}
|
||||
|
||||
def get_current_questions(self) -> List[Dict[str, Any]]:
|
||||
"""Get the questions for the current interview state"""
|
||||
questions = {
|
||||
InterviewState.GATHERING_BASICS: [
|
||||
{
|
||||
"field": "study_name",
|
||||
"question": "What would you like to name this study?",
|
||||
"hint": "Use snake_case, e.g., 'bracket_mass_optimization'",
|
||||
"required": True
|
||||
},
|
||||
{
|
||||
"field": "category",
|
||||
"question": "What category should this study be in?",
|
||||
"hint": "e.g., 'Simple_Bracket', 'M1_Mirror', or leave blank for root",
|
||||
"required": False
|
||||
},
|
||||
{
|
||||
"field": "description",
|
||||
"question": "Briefly describe what you're trying to optimize",
|
||||
"hint": "e.g., 'Minimize bracket mass while maintaining stiffness'",
|
||||
"required": True
|
||||
}
|
||||
],
|
||||
InterviewState.GATHERING_MODEL: [
|
||||
{
|
||||
"field": "sim_file",
|
||||
"question": "What is the path to your simulation (.sim) file?",
|
||||
"hint": "Relative path from the study folder, e.g., '1_setup/Model_sim1.sim'",
|
||||
"required": True
|
||||
}
|
||||
],
|
||||
InterviewState.GATHERING_VARIABLES: [
|
||||
{
|
||||
"field": "design_variable",
|
||||
"question": "What parameters do you want to optimize?",
|
||||
"hint": "Tell me the NX expression names and their bounds",
|
||||
"required": True,
|
||||
"multi": True
|
||||
}
|
||||
],
|
||||
InterviewState.GATHERING_EXTRACTORS: [
|
||||
{
|
||||
"field": "extractor",
|
||||
"question": "What physics quantities do you want to extract from FEA?",
|
||||
"hint": "e.g., mass, max displacement, max stress, frequency, Zernike WFE",
|
||||
"required": True,
|
||||
"multi": True
|
||||
}
|
||||
],
|
||||
InterviewState.GATHERING_OBJECTIVES: [
|
||||
{
|
||||
"field": "objective",
|
||||
"question": "What do you want to optimize?",
|
||||
"hint": "Tell me which extracted quantities to minimize or maximize",
|
||||
"required": True,
|
||||
"multi": True
|
||||
}
|
||||
],
|
||||
InterviewState.GATHERING_CONSTRAINTS: [
|
||||
{
|
||||
"field": "constraint",
|
||||
"question": "Do you have any constraints? (e.g., max stress, min frequency)",
|
||||
"hint": "You can say 'none' if you don't have any",
|
||||
"required": False,
|
||||
"multi": True
|
||||
}
|
||||
],
|
||||
InterviewState.GATHERING_SETTINGS: [
|
||||
{
|
||||
"field": "algorithm",
|
||||
"question": "Which optimization algorithm would you like to use?",
|
||||
"hint": "Options: TPE (default), CMA-ES, NSGA-II, RandomSearch",
|
||||
"required": False
|
||||
},
|
||||
{
|
||||
"field": "max_trials",
|
||||
"question": "How many trials (FEA evaluations) should we run?",
|
||||
"hint": "Default is 100. More trials = better results but longer runtime",
|
||||
"required": False
|
||||
}
|
||||
],
|
||||
InterviewState.REVIEW: [
|
||||
{
|
||||
"field": "confirm",
|
||||
"question": "Does this configuration look correct? (yes/no)",
|
||||
"required": True
|
||||
}
|
||||
]
|
||||
}
|
||||
return questions.get(self.state, [])
|
||||
|
||||
def record_answer(self, field: str, value: Any) -> Dict[str, Any]:
|
||||
"""Record an answer and potentially advance the state"""
|
||||
self.questions_asked.append(field)
|
||||
|
||||
# Handle different field types
|
||||
if field == "study_name":
|
||||
self.data.study_name = value
|
||||
elif field == "category":
|
||||
self.data.category = value if value else None
|
||||
elif field == "description":
|
||||
self.data.description = value
|
||||
elif field == "sim_file":
|
||||
self.data.sim_file = value
|
||||
elif field == "design_variable":
|
||||
# Value should be a dict with name, min, max, etc.
|
||||
if isinstance(value, dict):
|
||||
self.data.design_variables.append(value)
|
||||
elif isinstance(value, list):
|
||||
self.data.design_variables.extend(value)
|
||||
elif field == "extractor":
|
||||
if isinstance(value, dict):
|
||||
self.data.extractors.append(value)
|
||||
elif isinstance(value, list):
|
||||
self.data.extractors.extend(value)
|
||||
elif field == "objective":
|
||||
if isinstance(value, dict):
|
||||
self.data.objectives.append(value)
|
||||
elif isinstance(value, list):
|
||||
self.data.objectives.extend(value)
|
||||
elif field == "constraint":
|
||||
if value and value.lower() not in ["none", "no", "skip"]:
|
||||
if isinstance(value, dict):
|
||||
self.data.constraints.append(value)
|
||||
elif isinstance(value, list):
|
||||
self.data.constraints.extend(value)
|
||||
elif field == "algorithm":
|
||||
if value in ["TPE", "CMA-ES", "NSGA-II", "RandomSearch"]:
|
||||
self.data.algorithm = value
|
||||
elif field == "max_trials":
|
||||
try:
|
||||
self.data.max_trials = int(value)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
elif field == "confirm":
|
||||
if value.lower() in ["yes", "y", "confirm", "ok"]:
|
||||
self.state = InterviewState.COMPLETED
|
||||
|
||||
return {
|
||||
"state": self.state.value,
|
||||
"recorded": {field: value},
|
||||
"data_so_far": self.get_summary()
|
||||
}
|
||||
|
||||
def advance_state(self) -> Dict[str, Any]:
|
||||
"""Advance to the next interview state"""
|
||||
state_order = [
|
||||
InterviewState.NOT_STARTED,
|
||||
InterviewState.GATHERING_BASICS,
|
||||
InterviewState.GATHERING_MODEL,
|
||||
InterviewState.GATHERING_VARIABLES,
|
||||
InterviewState.GATHERING_EXTRACTORS,
|
||||
InterviewState.GATHERING_OBJECTIVES,
|
||||
InterviewState.GATHERING_CONSTRAINTS,
|
||||
InterviewState.GATHERING_SETTINGS,
|
||||
InterviewState.REVIEW,
|
||||
InterviewState.COMPLETED
|
||||
]
|
||||
|
||||
current_idx = state_order.index(self.state)
|
||||
if current_idx < len(state_order) - 1:
|
||||
self.state = state_order[current_idx + 1]
|
||||
|
||||
return {
|
||||
"state": self.state.value,
|
||||
"next_questions": self.get_current_questions()
|
||||
}
|
||||
|
||||
def get_summary(self) -> Dict[str, Any]:
|
||||
"""Get a summary of collected data"""
|
||||
return {
|
||||
"study_name": self.data.study_name,
|
||||
"category": self.data.category,
|
||||
"description": self.data.description,
|
||||
"model": self.data.sim_file,
|
||||
"design_variables": len(self.data.design_variables),
|
||||
"extractors": len(self.data.extractors),
|
||||
"objectives": len(self.data.objectives),
|
||||
"constraints": len(self.data.constraints),
|
||||
"algorithm": self.data.algorithm,
|
||||
"max_trials": self.data.max_trials
|
||||
}
|
||||
|
||||
def get_progress(self) -> Dict[str, Any]:
|
||||
"""Get interview progress information"""
|
||||
state_progress = {
|
||||
InterviewState.NOT_STARTED: 0,
|
||||
InterviewState.GATHERING_BASICS: 15,
|
||||
InterviewState.GATHERING_MODEL: 25,
|
||||
InterviewState.GATHERING_VARIABLES: 40,
|
||||
InterviewState.GATHERING_EXTRACTORS: 55,
|
||||
InterviewState.GATHERING_OBJECTIVES: 70,
|
||||
InterviewState.GATHERING_CONSTRAINTS: 80,
|
||||
InterviewState.GATHERING_SETTINGS: 90,
|
||||
InterviewState.REVIEW: 95,
|
||||
InterviewState.COMPLETED: 100
|
||||
}
|
||||
|
||||
return {
|
||||
"state": self.state.value,
|
||||
"progress_percent": state_progress.get(self.state, 0),
|
||||
"summary": self.get_summary(),
|
||||
"current_questions": self.get_current_questions()
|
||||
}
|
||||
|
||||
def validate(self) -> Dict[str, Any]:
|
||||
"""Validate the collected data before finalizing"""
|
||||
errors = []
|
||||
warnings = []
|
||||
|
||||
# Required fields
|
||||
if not self.data.study_name:
|
||||
errors.append("Study name is required")
|
||||
|
||||
if not self.data.design_variables:
|
||||
errors.append("At least one design variable is required")
|
||||
|
||||
if not self.data.extractors:
|
||||
errors.append("At least one extractor is required")
|
||||
|
||||
if not self.data.objectives:
|
||||
errors.append("At least one objective is required")
|
||||
|
||||
# Warnings
|
||||
if not self.data.sim_file:
|
||||
warnings.append("No simulation file specified - you'll need to add one manually")
|
||||
|
||||
if not self.data.constraints:
|
||||
warnings.append("No constraints defined - optimization will be unconstrained")
|
||||
|
||||
return {
|
||||
"valid": len(errors) == 0,
|
||||
"errors": errors,
|
||||
"warnings": warnings
|
||||
}
|
||||
|
||||
def finalize(self) -> Dict[str, Any]:
|
||||
"""Generate the final atomizer_spec.json"""
|
||||
validation = self.validate()
|
||||
|
||||
if not validation["valid"]:
|
||||
return {
|
||||
"success": False,
|
||||
"errors": validation["errors"]
|
||||
}
|
||||
|
||||
spec = self.data.to_spec()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"spec": spec,
|
||||
"warnings": validation.get("warnings", [])
|
||||
}
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Serialize engine state for persistence"""
|
||||
return {
|
||||
"state": self.state.value,
|
||||
"data": {
|
||||
"study_name": self.data.study_name,
|
||||
"category": self.data.category,
|
||||
"description": self.data.description,
|
||||
"goals": self.data.goals,
|
||||
"sim_file": self.data.sim_file,
|
||||
"prt_file": self.data.prt_file,
|
||||
"solver_type": self.data.solver_type,
|
||||
"design_variables": self.data.design_variables,
|
||||
"extractors": self.data.extractors,
|
||||
"objectives": self.data.objectives,
|
||||
"constraints": self.data.constraints,
|
||||
"algorithm": self.data.algorithm,
|
||||
"max_trials": self.data.max_trials
|
||||
},
|
||||
"questions_asked": self.questions_asked,
|
||||
"errors": self.errors
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "InterviewEngine":
|
||||
"""Restore engine from serialized state"""
|
||||
engine = cls()
|
||||
engine.state = InterviewState(data.get("state", "not_started"))
|
||||
|
||||
d = data.get("data", {})
|
||||
engine.data.study_name = d.get("study_name")
|
||||
engine.data.category = d.get("category")
|
||||
engine.data.description = d.get("description")
|
||||
engine.data.goals = d.get("goals", [])
|
||||
engine.data.sim_file = d.get("sim_file")
|
||||
engine.data.prt_file = d.get("prt_file")
|
||||
engine.data.solver_type = d.get("solver_type", "nastran")
|
||||
engine.data.design_variables = d.get("design_variables", [])
|
||||
engine.data.extractors = d.get("extractors", [])
|
||||
engine.data.objectives = d.get("objectives", [])
|
||||
engine.data.constraints = d.get("constraints", [])
|
||||
engine.data.algorithm = d.get("algorithm", "TPE")
|
||||
engine.data.max_trials = d.get("max_trials", 100)
|
||||
|
||||
engine.questions_asked = data.get("questions_asked", [])
|
||||
engine.errors = data.get("errors", [])
|
||||
|
||||
return engine
|
||||
@@ -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,12 +234,14 @@ class SessionManager:
|
||||
full_response = result["stdout"] or ""
|
||||
|
||||
if full_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
|
||||
@@ -236,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,
|
||||
@@ -292,6 +347,132 @@ class SessionManager:
|
||||
**({} if not db_record else {"db_record": db_record}),
|
||||
}
|
||||
|
||||
def _extract_canvas_modifications(self, response: str) -> List[Dict]:
|
||||
"""
|
||||
Extract canvas modification objects from Claude's response.
|
||||
|
||||
MCP tools like canvas_add_node return JSON with a 'modification' field.
|
||||
This method finds and extracts those modifications so the frontend can apply them.
|
||||
"""
|
||||
import re
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
modifications = []
|
||||
|
||||
# Debug: log what we're searching
|
||||
logger.info(f"[CANVAS_MOD] Searching response ({len(response)} chars) for modifications")
|
||||
|
||||
# Check if "modification" even exists in the response
|
||||
if '"modification"' not in response:
|
||||
logger.info("[CANVAS_MOD] No 'modification' key found in response")
|
||||
return modifications
|
||||
|
||||
try:
|
||||
# Method 1: Look for JSON in code fences
|
||||
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"])
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
# Method 2: Find JSON objects using proper brace matching
|
||||
# This handles nested objects correctly
|
||||
i = 0
|
||||
while i < len(response):
|
||||
if response[i] == "{":
|
||||
# Found a potential JSON start, find matching close
|
||||
brace_count = 1
|
||||
j = i + 1
|
||||
in_string = False
|
||||
escape_next = False
|
||||
|
||||
while j < len(response) and brace_count > 0:
|
||||
char = response[j]
|
||||
|
||||
if escape_next:
|
||||
escape_next = False
|
||||
elif char == "\\":
|
||||
escape_next = True
|
||||
elif char == '"' and not escape_next:
|
||||
in_string = not in_string
|
||||
elif not in_string:
|
||||
if char == "{":
|
||||
brace_count += 1
|
||||
elif char == "}":
|
||||
brace_count -= 1
|
||||
j += 1
|
||||
|
||||
if brace_count == 0:
|
||||
potential_json = response[i:j]
|
||||
try:
|
||||
obj = json.loads(potential_json)
|
||||
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')}"
|
||||
)
|
||||
modifications.append(mod)
|
||||
except json.JSONDecodeError as e:
|
||||
# Not valid JSON, skip
|
||||
pass
|
||||
i = j
|
||||
else:
|
||||
i += 1
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[CANVAS_MOD] Error extracting modifications: {e}")
|
||||
|
||||
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 {
|
||||
|
||||
827
atomizer-dashboard/backend/api/services/spec_manager.py
Normal file
827
atomizer-dashboard/backend/api/services/spec_manager.py
Normal file
@@ -0,0 +1,827 @@
|
||||
"""
|
||||
SpecManager Service
|
||||
|
||||
Central service for managing AtomizerSpec v2.0.
|
||||
All spec modifications flow through this service.
|
||||
|
||||
Features:
|
||||
- Load/save specs with validation
|
||||
- Atomic writes with conflict detection
|
||||
- Patch operations with JSONPath support
|
||||
- Node CRUD operations
|
||||
- Custom function support
|
||||
- WebSocket broadcast integration
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, List, Optional, Set, Tuple, Union
|
||||
|
||||
# Add optimization_engine to path if needed
|
||||
ATOMIZER_ROOT = Path(__file__).parent.parent.parent.parent.parent
|
||||
if str(ATOMIZER_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ATOMIZER_ROOT))
|
||||
|
||||
from optimization_engine.config.spec_models import (
|
||||
AtomizerSpec,
|
||||
DesignVariable,
|
||||
Extractor,
|
||||
Objective,
|
||||
Constraint,
|
||||
CanvasPosition,
|
||||
CanvasEdge,
|
||||
ExtractorType,
|
||||
CustomFunction,
|
||||
ExtractorOutput,
|
||||
ValidationReport,
|
||||
)
|
||||
from optimization_engine.config.spec_validator import (
|
||||
SpecValidator,
|
||||
SpecValidationError,
|
||||
)
|
||||
|
||||
|
||||
class SpecManagerError(Exception):
|
||||
"""Base error for SpecManager operations."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class SpecNotFoundError(SpecManagerError):
|
||||
"""Raised when spec file doesn't exist."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class SpecConflictError(SpecManagerError):
|
||||
"""Raised when spec has been modified by another client."""
|
||||
|
||||
def __init__(self, message: str, current_hash: str):
|
||||
super().__init__(message)
|
||||
self.current_hash = current_hash
|
||||
|
||||
|
||||
class WebSocketSubscriber:
|
||||
"""Protocol for WebSocket subscribers."""
|
||||
|
||||
async def send_json(self, data: Dict[str, Any]) -> None:
|
||||
"""Send JSON data to subscriber."""
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class SpecManager:
|
||||
"""
|
||||
Central service for managing AtomizerSpec.
|
||||
|
||||
All modifications go through this service to ensure:
|
||||
- Validation on every change
|
||||
- Atomic file writes
|
||||
- Conflict detection via hashing
|
||||
- WebSocket broadcast to all clients
|
||||
"""
|
||||
|
||||
SPEC_FILENAME = "atomizer_spec.json"
|
||||
|
||||
def __init__(self, study_path: Union[str, Path]):
|
||||
"""
|
||||
Initialize SpecManager for a study.
|
||||
|
||||
Args:
|
||||
study_path: Path to the study directory
|
||||
"""
|
||||
self.study_path = Path(study_path)
|
||||
self.spec_path = self.study_path / self.SPEC_FILENAME
|
||||
self.validator = SpecValidator()
|
||||
self._subscribers: List[WebSocketSubscriber] = []
|
||||
self._last_hash: Optional[str] = None
|
||||
|
||||
# =========================================================================
|
||||
# Core CRUD Operations
|
||||
# =========================================================================
|
||||
|
||||
def load(self, validate: bool = True) -> AtomizerSpec:
|
||||
"""
|
||||
Load and optionally validate the spec.
|
||||
|
||||
Args:
|
||||
validate: Whether to validate the spec
|
||||
|
||||
Returns:
|
||||
AtomizerSpec instance
|
||||
|
||||
Raises:
|
||||
SpecNotFoundError: If spec file doesn't exist
|
||||
SpecValidationError: If validation fails
|
||||
"""
|
||||
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:
|
||||
data = json.load(f)
|
||||
|
||||
if validate:
|
||||
self.validator.validate(data, strict=True)
|
||||
|
||||
spec = AtomizerSpec.model_validate(data)
|
||||
self._last_hash = self._compute_hash(data)
|
||||
return spec
|
||||
|
||||
def load_raw(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Load spec as raw dict without parsing.
|
||||
|
||||
Returns:
|
||||
Raw spec dict
|
||||
|
||||
Raises:
|
||||
SpecNotFoundError: If spec file doesn't exist
|
||||
"""
|
||||
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:
|
||||
return json.load(f)
|
||||
|
||||
def save(
|
||||
self,
|
||||
spec: Union[AtomizerSpec, Dict[str, Any]],
|
||||
modified_by: str = "api",
|
||||
expected_hash: Optional[str] = None,
|
||||
skip_validation: bool = False,
|
||||
) -> str:
|
||||
"""
|
||||
Save spec with validation and broadcast.
|
||||
|
||||
Args:
|
||||
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
|
||||
|
||||
Raises:
|
||||
SpecValidationError: If validation fails
|
||||
SpecConflictError: If expected_hash doesn't match current
|
||||
"""
|
||||
# Convert to dict if needed
|
||||
if isinstance(spec, AtomizerSpec):
|
||||
data = spec.model_dump(mode="json")
|
||||
else:
|
||||
data = spec
|
||||
|
||||
# Check for conflicts if expected_hash provided
|
||||
if expected_hash and self.spec_path.exists():
|
||||
current_hash = self.get_hash()
|
||||
if current_hash != expected_hash:
|
||||
raise SpecConflictError(
|
||||
"Spec was modified by another client", current_hash=current_hash
|
||||
)
|
||||
|
||||
# Update metadata
|
||||
now = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
data["meta"]["modified"] = now
|
||||
data["meta"]["modified_by"] = modified_by
|
||||
|
||||
# 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:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
|
||||
temp_path.replace(self.spec_path)
|
||||
|
||||
# Update cached hash
|
||||
self._last_hash = new_hash
|
||||
|
||||
# Broadcast to subscribers
|
||||
self._broadcast(
|
||||
{"type": "spec_updated", "hash": new_hash, "modified_by": modified_by, "timestamp": now}
|
||||
)
|
||||
|
||||
return new_hash
|
||||
|
||||
def exists(self) -> bool:
|
||||
"""Check if spec file exists."""
|
||||
return self.spec_path.exists()
|
||||
|
||||
def get_hash(self) -> str:
|
||||
"""Get current spec hash."""
|
||||
if not self.spec_path.exists():
|
||||
return ""
|
||||
with open(self.spec_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
return self._compute_hash(data)
|
||||
|
||||
def validate_and_report(self) -> ValidationReport:
|
||||
"""
|
||||
Run full validation and return detailed report.
|
||||
|
||||
Returns:
|
||||
ValidationReport with errors, warnings, summary
|
||||
"""
|
||||
if not self.spec_path.exists():
|
||||
raise SpecNotFoundError(f"Spec not found: {self.spec_path}")
|
||||
|
||||
data = self.load_raw()
|
||||
return self.validator.validate(data, strict=False)
|
||||
|
||||
# =========================================================================
|
||||
# Patch Operations
|
||||
# =========================================================================
|
||||
|
||||
def patch(self, path: str, value: Any, modified_by: str = "api") -> AtomizerSpec:
|
||||
"""
|
||||
Apply a JSONPath-style modification.
|
||||
|
||||
Args:
|
||||
path: JSONPath like "design_variables[0].bounds.max"
|
||||
value: New value to set
|
||||
modified_by: Who/what is making the change
|
||||
|
||||
Returns:
|
||||
Updated AtomizerSpec
|
||||
"""
|
||||
data = self.load_raw()
|
||||
|
||||
# Validate the partial update
|
||||
spec = AtomizerSpec.model_validate(data)
|
||||
is_valid, errors = self.validator.validate_partial(path, value, spec)
|
||||
if not is_valid:
|
||||
raise SpecValidationError(f"Invalid update: {'; '.join(errors)}")
|
||||
|
||||
# Apply the patch
|
||||
self._apply_patch(data, path, value)
|
||||
|
||||
# Save and return
|
||||
self.save(data, modified_by)
|
||||
return self.load(validate=False)
|
||||
|
||||
def _apply_patch(self, data: Dict, path: str, value: Any) -> None:
|
||||
"""
|
||||
Apply a patch to the data dict.
|
||||
|
||||
Supports paths like:
|
||||
- "meta.description"
|
||||
- "design_variables[0].bounds.max"
|
||||
- "objectives[1].weight"
|
||||
"""
|
||||
parts = self._parse_path(path)
|
||||
if not parts:
|
||||
raise ValueError(f"Invalid path: {path}")
|
||||
|
||||
# Navigate to parent
|
||||
current = data
|
||||
for part in parts[:-1]:
|
||||
if isinstance(current, list):
|
||||
idx = int(part)
|
||||
current = current[idx]
|
||||
else:
|
||||
current = current[part]
|
||||
|
||||
# Set final value
|
||||
final_key = parts[-1]
|
||||
if isinstance(current, list):
|
||||
idx = int(final_key)
|
||||
current[idx] = value
|
||||
else:
|
||||
current[final_key] = value
|
||||
|
||||
def _parse_path(self, path: str) -> List[str]:
|
||||
"""Parse JSONPath into parts."""
|
||||
# Handle both dot notation and bracket notation
|
||||
parts = []
|
||||
for part in re.split(r"\.|\[|\]", path):
|
||||
if part:
|
||||
parts.append(part)
|
||||
return parts
|
||||
|
||||
# =========================================================================
|
||||
# Node Operations
|
||||
# =========================================================================
|
||||
|
||||
def add_node(
|
||||
self, node_type: str, node_data: Dict[str, Any], modified_by: str = "canvas"
|
||||
) -> str:
|
||||
"""
|
||||
Add a new node (design var, extractor, objective, constraint).
|
||||
|
||||
Args:
|
||||
node_type: One of 'designVar', 'extractor', 'objective', 'constraint'
|
||||
node_data: Node data without ID
|
||||
modified_by: Who/what is making the change
|
||||
|
||||
Returns:
|
||||
Generated node ID
|
||||
"""
|
||||
data = self.load_raw()
|
||||
|
||||
# Generate ID
|
||||
node_id = self._generate_id(node_type, data)
|
||||
node_data["id"] = node_id
|
||||
|
||||
# Add canvas position if not provided
|
||||
if "canvas_position" not in node_data:
|
||||
node_data["canvas_position"] = self._auto_position(node_type, data)
|
||||
|
||||
# Add to appropriate section
|
||||
section = self._get_section_for_type(node_type)
|
||||
|
||||
if section not in data or data[section] is None:
|
||||
data[section] = []
|
||||
|
||||
data[section].append(node_data)
|
||||
|
||||
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,
|
||||
}
|
||||
)
|
||||
|
||||
return node_id
|
||||
|
||||
def update_node(
|
||||
self, node_id: str, updates: Dict[str, Any], modified_by: str = "canvas"
|
||||
) -> None:
|
||||
"""
|
||||
Update an existing node.
|
||||
|
||||
Args:
|
||||
node_id: ID of the node to update
|
||||
updates: Dict of fields to update
|
||||
modified_by: Who/what is making the change
|
||||
"""
|
||||
data = self.load_raw()
|
||||
|
||||
# Find and update the node
|
||||
found = False
|
||||
for section in ["design_variables", "extractors", "objectives", "constraints"]:
|
||||
if section not in data or data[section] is None:
|
||||
continue
|
||||
for node in data[section]:
|
||||
if node.get("id") == node_id:
|
||||
node.update(updates)
|
||||
found = True
|
||||
break
|
||||
if found:
|
||||
break
|
||||
|
||||
if not found:
|
||||
raise SpecManagerError(f"Node not found: {node_id}")
|
||||
|
||||
self.save(data, modified_by)
|
||||
|
||||
def remove_node(self, node_id: str, modified_by: str = "canvas") -> None:
|
||||
"""
|
||||
Remove a node and all edges referencing it.
|
||||
|
||||
Args:
|
||||
node_id: ID of the node to remove
|
||||
modified_by: Who/what is making the change
|
||||
"""
|
||||
data = self.load_raw()
|
||||
|
||||
# Find and remove node
|
||||
removed = False
|
||||
for section in ["design_variables", "extractors", "objectives", "constraints"]:
|
||||
if section not in data or data[section] is None:
|
||||
continue
|
||||
original_len = len(data[section])
|
||||
data[section] = [n for n in data[section] if n.get("id") != node_id]
|
||||
if len(data[section]) < original_len:
|
||||
removed = True
|
||||
break
|
||||
|
||||
if not removed:
|
||||
raise SpecManagerError(f"Node not found: {node_id}")
|
||||
|
||||
# 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"]
|
||||
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})
|
||||
|
||||
def update_node_position(
|
||||
self, node_id: str, position: Dict[str, float], modified_by: str = "canvas"
|
||||
) -> None:
|
||||
"""
|
||||
Update a node's canvas position.
|
||||
|
||||
Args:
|
||||
node_id: ID of the node
|
||||
position: Dict with x, y coordinates
|
||||
modified_by: Who/what is making the change
|
||||
"""
|
||||
self.update_node(node_id, {"canvas_position": position}, modified_by)
|
||||
|
||||
def add_edge(self, source: str, target: str, modified_by: str = "canvas") -> None:
|
||||
"""
|
||||
Add a canvas edge between nodes.
|
||||
|
||||
Args:
|
||||
source: Source node ID
|
||||
target: Target node ID
|
||||
modified_by: Who/what is making the change
|
||||
"""
|
||||
data = self.load_raw()
|
||||
|
||||
# Initialize canvas section if needed
|
||||
if "canvas" not in data or data["canvas"] is None:
|
||||
data["canvas"] = {}
|
||||
if "edges" not in data["canvas"] or data["canvas"]["edges"] is None:
|
||||
data["canvas"]["edges"] = []
|
||||
|
||||
# Check for duplicate
|
||||
for edge in data["canvas"]["edges"]:
|
||||
if edge.get("source") == source and edge.get("target") == target:
|
||||
return # Already exists
|
||||
|
||||
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:
|
||||
"""
|
||||
Remove a canvas edge.
|
||||
|
||||
Args:
|
||||
source: Source node ID
|
||||
target: Target node ID
|
||||
modified_by: Who/what is making the change
|
||||
"""
|
||||
data = self.load_raw()
|
||||
|
||||
if "canvas" in data and data["canvas"] and "edges" in data["canvas"]:
|
||||
data["canvas"]["edges"] = [
|
||||
e
|
||||
for e in data["canvas"]["edges"]
|
||||
if not (e.get("source") == source and e.get("target") == target)
|
||||
]
|
||||
|
||||
self.save(data, modified_by)
|
||||
|
||||
# =========================================================================
|
||||
# Custom Function Support
|
||||
# =========================================================================
|
||||
|
||||
def add_custom_function(
|
||||
self,
|
||||
name: str,
|
||||
code: str,
|
||||
outputs: List[str],
|
||||
description: Optional[str] = None,
|
||||
modified_by: str = "claude",
|
||||
) -> str:
|
||||
"""
|
||||
Add a custom extractor function.
|
||||
|
||||
Args:
|
||||
name: Function name
|
||||
code: Python source code
|
||||
outputs: List of output names
|
||||
description: Optional description
|
||||
modified_by: Who/what is making the change
|
||||
|
||||
Returns:
|
||||
Generated extractor ID
|
||||
|
||||
Raises:
|
||||
SpecValidationError: If Python syntax is invalid
|
||||
"""
|
||||
# Validate Python syntax
|
||||
try:
|
||||
compile(code, f"<custom:{name}>", "exec")
|
||||
except SyntaxError as e:
|
||||
raise SpecValidationError(f"Invalid Python syntax: {e.msg} at line {e.lineno}")
|
||||
|
||||
data = self.load_raw()
|
||||
|
||||
# Generate extractor ID
|
||||
ext_id = self._generate_id("extractor", data)
|
||||
|
||||
# Create extractor
|
||||
extractor = {
|
||||
"id": ext_id,
|
||||
"name": description or f"Custom: {name}",
|
||||
"type": "custom_function",
|
||||
"builtin": False,
|
||||
"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),
|
||||
}
|
||||
|
||||
data["extractors"].append(extractor)
|
||||
self.save(data, modified_by)
|
||||
|
||||
return ext_id
|
||||
|
||||
def update_custom_function(
|
||||
self,
|
||||
extractor_id: str,
|
||||
code: Optional[str] = None,
|
||||
outputs: Optional[List[str]] = None,
|
||||
modified_by: str = "claude",
|
||||
) -> None:
|
||||
"""
|
||||
Update an existing custom function.
|
||||
|
||||
Args:
|
||||
extractor_id: ID of the custom extractor
|
||||
code: New Python code (optional)
|
||||
outputs: New outputs (optional)
|
||||
modified_by: Who/what is making the change
|
||||
"""
|
||||
data = self.load_raw()
|
||||
|
||||
# Find the extractor
|
||||
extractor = None
|
||||
for ext in data.get("extractors", []):
|
||||
if ext.get("id") == extractor_id:
|
||||
extractor = ext
|
||||
break
|
||||
|
||||
if not extractor:
|
||||
raise SpecManagerError(f"Extractor not found: {extractor_id}")
|
||||
|
||||
if extractor.get("type") != "custom_function":
|
||||
raise SpecManagerError(f"Extractor {extractor_id} is not a custom function")
|
||||
|
||||
# Update code
|
||||
if code is not None:
|
||||
try:
|
||||
compile(code, f"<custom:{extractor_id}>", "exec")
|
||||
except SyntaxError as e:
|
||||
raise SpecValidationError(f"Invalid Python syntax: {e.msg} at line {e.lineno}")
|
||||
if "function" not in extractor:
|
||||
extractor["function"] = {}
|
||||
extractor["function"]["source_code"] = code
|
||||
|
||||
# Update outputs
|
||||
if outputs is not None:
|
||||
extractor["outputs"] = [{"name": o, "metric": "custom"} for o in outputs]
|
||||
|
||||
self.save(data, modified_by)
|
||||
|
||||
# =========================================================================
|
||||
# WebSocket Subscription
|
||||
# =========================================================================
|
||||
|
||||
def subscribe(self, subscriber: WebSocketSubscriber) -> None:
|
||||
"""Subscribe to spec changes."""
|
||||
if subscriber not in self._subscribers:
|
||||
self._subscribers.append(subscriber)
|
||||
|
||||
def unsubscribe(self, subscriber: WebSocketSubscriber) -> None:
|
||||
"""Unsubscribe from spec changes."""
|
||||
if subscriber in self._subscribers:
|
||||
self._subscribers.remove(subscriber)
|
||||
|
||||
def _broadcast(self, message: Dict[str, Any]) -> None:
|
||||
"""Broadcast message to all subscribers."""
|
||||
import asyncio
|
||||
|
||||
for subscriber in self._subscribers:
|
||||
try:
|
||||
# Handle both sync and async contexts
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
loop.create_task(subscriber.send_json(message))
|
||||
except RuntimeError:
|
||||
# No running loop, try direct call if possible
|
||||
pass
|
||||
except Exception:
|
||||
# Subscriber may have disconnected
|
||||
pass
|
||||
|
||||
# =========================================================================
|
||||
# Helper Methods
|
||||
# =========================================================================
|
||||
|
||||
def _compute_hash(self, data: Dict) -> str:
|
||||
"""Compute hash of spec data for conflict detection."""
|
||||
# Sort keys for consistent hashing
|
||||
json_str = json.dumps(data, sort_keys=True, ensure_ascii=False)
|
||||
return hashlib.sha256(json_str.encode()).hexdigest()[:16]
|
||||
|
||||
def _generate_id(self, node_type: str, data: Dict) -> str:
|
||||
"""Generate unique ID for a node type."""
|
||||
prefix_map = {
|
||||
"designVar": "dv",
|
||||
"design_variable": "dv",
|
||||
"extractor": "ext",
|
||||
"objective": "obj",
|
||||
"constraint": "con",
|
||||
}
|
||||
prefix = prefix_map.get(node_type, node_type[:3])
|
||||
|
||||
# Find existing IDs
|
||||
section = self._get_section_for_type(node_type)
|
||||
existing_ids: Set[str] = set()
|
||||
if section in data and data[section]:
|
||||
existing_ids = {n.get("id", "") for n in data[section]}
|
||||
|
||||
# Generate next available ID
|
||||
for i in range(1, 1000):
|
||||
new_id = f"{prefix}_{i:03d}"
|
||||
if new_id not in existing_ids:
|
||||
return new_id
|
||||
|
||||
raise SpecManagerError(f"Cannot generate ID for {node_type}: too many nodes")
|
||||
|
||||
def _get_section_for_type(self, node_type: str) -> str:
|
||||
"""Map node type to spec section name."""
|
||||
section_map = {
|
||||
"designVar": "design_variables",
|
||||
"design_variable": "design_variables",
|
||||
"extractor": "extractors",
|
||||
"objective": "objectives",
|
||||
"constraint": "constraints",
|
||||
}
|
||||
return section_map.get(node_type, node_type + "s")
|
||||
|
||||
def _auto_position(self, node_type: str, data: Dict) -> Dict[str, float]:
|
||||
"""Calculate auto position for a new node."""
|
||||
# Default x positions by type
|
||||
x_positions = {
|
||||
"designVar": 50,
|
||||
"design_variable": 50,
|
||||
"extractor": 740,
|
||||
"objective": 1020,
|
||||
"constraint": 1020,
|
||||
}
|
||||
|
||||
x = x_positions.get(node_type, 400)
|
||||
|
||||
# Find max y position for this type
|
||||
section = self._get_section_for_type(node_type)
|
||||
max_y = 0
|
||||
if section in data and data[section]:
|
||||
for node in data[section]:
|
||||
pos = node.get("canvas_position", {})
|
||||
y = pos.get("y", 0)
|
||||
if y > max_y:
|
||||
max_y = y
|
||||
|
||||
# Place below existing nodes
|
||||
y = max_y + 100 if max_y > 0 else 100
|
||||
|
||||
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.
|
||||
|
||||
Args:
|
||||
study_path: Path to the study directory
|
||||
|
||||
Returns:
|
||||
SpecManager instance
|
||||
"""
|
||||
return SpecManager(study_path)
|
||||
Binary file not shown.
2085
atomizer-dashboard/frontend/package-lock.json
generated
2085
atomizer-dashboard/frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -7,15 +7,20 @@
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test": "vitest",
|
||||
"test:ui": "vitest --ui",
|
||||
"test:coverage": "vitest --coverage",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:ui": "playwright test --ui"
|
||||
},
|
||||
"dependencies": {
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@nivo/core": "^0.99.0",
|
||||
"@nivo/parallel-coordinates": "^0.99.0",
|
||||
"@react-three/drei": "^10.7.7",
|
||||
"@react-three/fiber": "^9.4.0",
|
||||
"@tanstack/react-query": "^5.90.10",
|
||||
"@types/react-plotly.js": "^2.6.3",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@types/three": "^0.181.0",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
@@ -23,11 +28,9 @@
|
||||
"clsx": "^2.1.1",
|
||||
"katex": "^0.16.25",
|
||||
"lucide-react": "^0.554.0",
|
||||
"plotly.js-basic-dist": "^3.3.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-plotly.js": "^2.6.0",
|
||||
"react-router-dom": "^6.20.0",
|
||||
"react-syntax-highlighter": "^16.1.0",
|
||||
"react-use-websocket": "^4.13.0",
|
||||
@@ -42,18 +45,27 @@
|
||||
"zustand": "^5.0.10"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.57.0",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/react": "^18.2.43",
|
||||
"@types/react-dom": "^18.2.17",
|
||||
"@typescript-eslint/eslint-plugin": "^6.14.0",
|
||||
"@typescript-eslint/parser": "^6.14.0",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"@vitest/coverage-v8": "^4.0.17",
|
||||
"@vitest/ui": "^4.0.17",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"eslint": "^8.55.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.5",
|
||||
"jsdom": "^27.4.0",
|
||||
"postcss": "^8.4.32",
|
||||
"tailwindcss": "^3.3.6",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^5.0.8"
|
||||
"vite": "^5.0.8",
|
||||
"vitest": "^4.0.17"
|
||||
}
|
||||
}
|
||||
|
||||
69
atomizer-dashboard/frontend/playwright.config.ts
Normal file
69
atomizer-dashboard/frontend/playwright.config.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Playwright E2E Test Configuration
|
||||
*
|
||||
* Run with: npm run test:e2e
|
||||
* UI mode: npm run test:e2e:ui
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: './tests/e2e',
|
||||
|
||||
// Run tests in parallel
|
||||
fullyParallel: true,
|
||||
|
||||
// Fail CI if test.only is left in code
|
||||
forbidOnly: !!process.env.CI,
|
||||
|
||||
// Retry on CI only
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
|
||||
// Parallel workers
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
|
||||
// Reporter configuration
|
||||
reporter: [
|
||||
['html', { outputFolder: 'playwright-report' }],
|
||||
['list'],
|
||||
],
|
||||
|
||||
// Global settings
|
||||
use: {
|
||||
// Base URL for navigation
|
||||
baseURL: 'http://localhost:3003',
|
||||
|
||||
// Collect trace on first retry
|
||||
trace: 'on-first-retry',
|
||||
|
||||
// Screenshot on failure
|
||||
screenshot: 'only-on-failure',
|
||||
|
||||
// Video on failure
|
||||
video: 'on-first-retry',
|
||||
},
|
||||
|
||||
// Browser projects
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
// Uncomment to test on more browsers
|
||||
// {
|
||||
// name: 'firefox',
|
||||
// use: { ...devices['Desktop Firefox'] },
|
||||
// },
|
||||
// {
|
||||
// name: 'webkit',
|
||||
// use: { ...devices['Desktop Safari'] },
|
||||
// },
|
||||
],
|
||||
|
||||
// Start dev server before tests
|
||||
webServer: {
|
||||
command: 'npm run dev',
|
||||
url: 'http://localhost:3003',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 120000,
|
||||
},
|
||||
});
|
||||
@@ -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: {
|
||||
@@ -30,6 +31,11 @@ function App() {
|
||||
|
||||
{/* Canvas page - full screen, no sidebar */}
|
||||
<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 />}>
|
||||
|
||||
411
atomizer-dashboard/frontend/src/api/intake.ts
Normal file
411
atomizer-dashboard/frontend/src/api/intake.ts
Normal file
@@ -0,0 +1,411 @@
|
||||
/**
|
||||
* Intake API Client
|
||||
*
|
||||
* API client methods for the study intake workflow.
|
||||
*/
|
||||
|
||||
import {
|
||||
CreateInboxRequest,
|
||||
CreateInboxResponse,
|
||||
IntrospectRequest,
|
||||
IntrospectResponse,
|
||||
ListInboxResponse,
|
||||
ListTopicsResponse,
|
||||
InboxStudyDetail,
|
||||
GenerateReadmeResponse,
|
||||
FinalizeRequest,
|
||||
FinalizeResponse,
|
||||
UploadFilesResponse,
|
||||
} from '../types/intake';
|
||||
|
||||
const API_BASE = '/api';
|
||||
|
||||
/**
|
||||
* Intake API client for study creation workflow.
|
||||
*/
|
||||
export const intakeApi = {
|
||||
/**
|
||||
* Create a new inbox study folder with initial spec.
|
||||
*/
|
||||
async createInbox(request: CreateInboxRequest): Promise<CreateInboxResponse> {
|
||||
const response = await fetch(`${API_BASE}/intake/create`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || 'Failed to create inbox study');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
|
||||
/**
|
||||
* Run NX introspection on an inbox study.
|
||||
*/
|
||||
async introspect(request: IntrospectRequest): Promise<IntrospectResponse> {
|
||||
const response = await fetch(`${API_BASE}/intake/introspect`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || 'Introspection failed');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
|
||||
/**
|
||||
* List all studies in the inbox.
|
||||
*/
|
||||
async listInbox(): Promise<ListInboxResponse> {
|
||||
const response = await fetch(`${API_BASE}/intake/list`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch inbox studies');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
|
||||
/**
|
||||
* List existing topic folders.
|
||||
*/
|
||||
async listTopics(): Promise<ListTopicsResponse> {
|
||||
const response = await fetch(`${API_BASE}/intake/topics`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch topics');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
|
||||
/**
|
||||
* Get detailed information about an inbox study.
|
||||
*/
|
||||
async getInboxStudy(studyName: string): Promise<InboxStudyDetail> {
|
||||
const response = await fetch(`${API_BASE}/intake/${encodeURIComponent(studyName)}`);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || 'Failed to fetch inbox study');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete an inbox study.
|
||||
*/
|
||||
async deleteInboxStudy(studyName: string): Promise<{ success: boolean; deleted: string }> {
|
||||
const response = await fetch(`${API_BASE}/intake/${encodeURIComponent(studyName)}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || 'Failed to delete inbox study');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
|
||||
/**
|
||||
* Generate README for an inbox study using Claude AI.
|
||||
*/
|
||||
async generateReadme(studyName: string): Promise<GenerateReadmeResponse> {
|
||||
const response = await fetch(
|
||||
`${API_BASE}/intake/${encodeURIComponent(studyName)}/readme`,
|
||||
{ method: 'POST' }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || 'README generation failed');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
|
||||
/**
|
||||
* Finalize an inbox study and move to studies directory.
|
||||
*/
|
||||
async finalize(studyName: string, request: FinalizeRequest): Promise<FinalizeResponse> {
|
||||
const response = await fetch(
|
||||
`${API_BASE}/intake/${encodeURIComponent(studyName)}/finalize`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(request),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || 'Finalization failed');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
|
||||
/**
|
||||
* Upload model files to an inbox study.
|
||||
*/
|
||||
async uploadFiles(studyName: string, files: File[]): Promise<UploadFilesResponse> {
|
||||
const formData = new FormData();
|
||||
files.forEach((file) => {
|
||||
formData.append('files', file);
|
||||
});
|
||||
|
||||
const response = await fetch(
|
||||
`${API_BASE}/intake/${encodeURIComponent(studyName)}/upload`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || 'File upload failed');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
|
||||
/**
|
||||
* Upload context files to an inbox study.
|
||||
* Context files help Claude understand optimization goals.
|
||||
*/
|
||||
async uploadContextFiles(studyName: string, files: File[]): Promise<UploadFilesResponse> {
|
||||
const formData = new FormData();
|
||||
files.forEach((file) => {
|
||||
formData.append('files', file);
|
||||
});
|
||||
|
||||
const response = await fetch(
|
||||
`${API_BASE}/intake/${encodeURIComponent(studyName)}/context`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || 'Context file upload failed');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
|
||||
/**
|
||||
* List context files for an inbox study.
|
||||
*/
|
||||
async listContextFiles(studyName: string): Promise<{
|
||||
study_name: string;
|
||||
context_files: Array<{ name: string; path: string; size: number; extension: string }>;
|
||||
total: number;
|
||||
}> {
|
||||
const response = await fetch(
|
||||
`${API_BASE}/intake/${encodeURIComponent(studyName)}/context`
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to list context files');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete a context file from an inbox study.
|
||||
*/
|
||||
async deleteContextFile(studyName: string, filename: string): Promise<{ success: boolean; deleted: string }> {
|
||||
const response = await fetch(
|
||||
`${API_BASE}/intake/${encodeURIComponent(studyName)}/context/${encodeURIComponent(filename)}`,
|
||||
{ method: 'DELETE' }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || 'Failed to delete context file');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
|
||||
/**
|
||||
* Create design variables from selected expressions.
|
||||
*/
|
||||
async createDesignVariables(
|
||||
studyName: string,
|
||||
expressionNames: string[],
|
||||
options?: { autoBounds?: boolean; boundFactor?: number }
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
study_name: string;
|
||||
created: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
expression_name: string;
|
||||
bounds_min: number;
|
||||
bounds_max: number;
|
||||
baseline: number;
|
||||
units: string | null;
|
||||
}>;
|
||||
total_created: number;
|
||||
}> {
|
||||
const response = await fetch(
|
||||
`${API_BASE}/intake/${encodeURIComponent(studyName)}/design-variables`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
expression_names: expressionNames,
|
||||
auto_bounds: options?.autoBounds ?? true,
|
||||
bound_factor: options?.boundFactor ?? 0.5,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || 'Failed to create design variables');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
|
||||
// ===========================================================================
|
||||
// Studio Endpoints (Atomizer Studio - Unified Creation Environment)
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* Create an anonymous draft study for Studio workflow.
|
||||
* Returns a temporary draft_id that can be renamed during finalization.
|
||||
*/
|
||||
async createDraft(): Promise<{
|
||||
success: boolean;
|
||||
draft_id: string;
|
||||
inbox_path: string;
|
||||
spec_path: string;
|
||||
status: string;
|
||||
}> {
|
||||
const response = await fetch(`${API_BASE}/intake/draft`, {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || 'Failed to create draft');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
|
||||
/**
|
||||
* Get extracted text content from context files.
|
||||
* Used for AI context injection.
|
||||
*/
|
||||
async getContextContent(studyName: string): Promise<{
|
||||
success: boolean;
|
||||
study_name: string;
|
||||
content: string;
|
||||
files_read: Array<{
|
||||
name: string;
|
||||
extension: string;
|
||||
size: number;
|
||||
status: string;
|
||||
characters?: number;
|
||||
error?: string;
|
||||
}>;
|
||||
total_characters: number;
|
||||
}> {
|
||||
const response = await fetch(
|
||||
`${API_BASE}/intake/${encodeURIComponent(studyName)}/context/content`
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || 'Failed to get context content');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
|
||||
/**
|
||||
* Finalize a Studio draft with rename support.
|
||||
* Enhanced version that supports renaming draft_xxx to proper names.
|
||||
*/
|
||||
async finalizeStudio(
|
||||
studyName: string,
|
||||
request: {
|
||||
topic: string;
|
||||
newName?: string;
|
||||
runBaseline?: boolean;
|
||||
}
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
original_name: string;
|
||||
final_name: string;
|
||||
final_path: string;
|
||||
status: string;
|
||||
baseline_success: boolean | null;
|
||||
readme_generated: boolean;
|
||||
}> {
|
||||
const response = await fetch(
|
||||
`${API_BASE}/intake/${encodeURIComponent(studyName)}/finalize/studio`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
topic: request.topic,
|
||||
new_name: request.newName,
|
||||
run_baseline: request.runBaseline ?? false,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || 'Studio finalization failed');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
|
||||
/**
|
||||
* Get complete draft information for Studio UI.
|
||||
* Convenience endpoint that returns everything the Studio needs.
|
||||
*/
|
||||
async getStudioDraft(studyName: string): Promise<{
|
||||
success: boolean;
|
||||
draft_id: string;
|
||||
spec: Record<string, unknown>;
|
||||
model_files: string[];
|
||||
context_files: string[];
|
||||
introspection_available: boolean;
|
||||
design_variable_count: number;
|
||||
objective_count: number;
|
||||
}> {
|
||||
const response = await fetch(
|
||||
`${API_BASE}/intake/${encodeURIComponent(studyName)}/studio`
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || 'Failed to get studio draft');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
};
|
||||
|
||||
export default intakeApi;
|
||||
@@ -26,8 +26,8 @@ interface DesignVariable {
|
||||
name: string;
|
||||
parameter?: string; // Optional: the actual parameter name if different from name
|
||||
unit?: string;
|
||||
min: number;
|
||||
max: number;
|
||||
min?: number;
|
||||
max?: number;
|
||||
}
|
||||
|
||||
interface Constraint {
|
||||
|
||||
@@ -8,14 +8,15 @@ import { ScatterChart, Scatter, Line, XAxis, YAxis, CartesianGrid, Tooltip, Cell
|
||||
|
||||
interface ParetoTrial {
|
||||
trial_number: number;
|
||||
values: [number, number];
|
||||
values: number[]; // Support variable number of objectives
|
||||
params: Record<string, number>;
|
||||
constraint_satisfied?: boolean;
|
||||
}
|
||||
|
||||
interface Objective {
|
||||
name: string;
|
||||
type: 'minimize' | 'maximize';
|
||||
type?: 'minimize' | 'maximize';
|
||||
direction?: 'minimize' | 'maximize'; // Alternative field used by some configs
|
||||
unit?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,19 @@
|
||||
/**
|
||||
* @deprecated This component is deprecated as of January 2026.
|
||||
* Use SpecRenderer instead, which works with AtomizerSpec v2.0.
|
||||
*
|
||||
* Migration guide:
|
||||
* - Replace <AtomizerCanvas studyId="..." /> with <SpecRenderer studyId="..." />
|
||||
* - Use useSpecStore instead of useCanvasStore for state management
|
||||
* - Spec mode uses atomizer_spec.json instead of optimization_config.json
|
||||
*
|
||||
* This component is kept for emergency fallback only. Enable legacy mode
|
||||
* by setting VITE_USE_LEGACY_CANVAS=true in your environment.
|
||||
*
|
||||
* @see SpecRenderer for the new implementation
|
||||
* @see useSpecStore for the new state management
|
||||
*/
|
||||
|
||||
import { useCallback, useRef, useState, useEffect, DragEvent } from 'react';
|
||||
import ReactFlow, {
|
||||
Background,
|
||||
@@ -8,7 +24,6 @@ import ReactFlow, {
|
||||
Edge,
|
||||
} from 'reactflow';
|
||||
import 'reactflow/dist/style.css';
|
||||
import { MessageCircle, Plug, X, AlertCircle, RefreshCw } from 'lucide-react';
|
||||
|
||||
import { nodeTypes } from './nodes';
|
||||
import { NodePalette } from './palette/NodePalette';
|
||||
@@ -16,15 +31,21 @@ import { NodeConfigPanel } from './panels/NodeConfigPanel';
|
||||
import { ValidationPanel } from './panels/ValidationPanel';
|
||||
import { ExecuteDialog } from './panels/ExecuteDialog';
|
||||
import { useCanvasStore } from '../../hooks/useCanvasStore';
|
||||
import { useCanvasChat } from '../../hooks/useCanvasChat';
|
||||
import { NodeType } from '../../lib/canvas/schema';
|
||||
import { ChatPanel } from './panels/ChatPanel';
|
||||
|
||||
function CanvasFlow() {
|
||||
interface CanvasFlowProps {
|
||||
initialStudyId?: string;
|
||||
initialStudyPath?: string;
|
||||
onStudyChange?: (studyId: string) => void;
|
||||
}
|
||||
|
||||
function CanvasFlow({ initialStudyId, initialStudyPath, onStudyChange }: CanvasFlowProps) {
|
||||
const reactFlowWrapper = useRef<HTMLDivElement>(null);
|
||||
const reactFlowInstance = useRef<ReactFlowInstance | null>(null);
|
||||
const [showExecuteDialog, setShowExecuteDialog] = useState(false);
|
||||
const [showChat, setShowChat] = useState(false);
|
||||
const [studyId, setStudyId] = useState<string | null>(initialStudyId || null);
|
||||
const [studyPath, setStudyPath] = useState<string | null>(initialStudyPath || null);
|
||||
const [isExecuting, setIsExecuting] = useState(false);
|
||||
|
||||
const {
|
||||
nodes,
|
||||
@@ -41,32 +62,38 @@ function CanvasFlow() {
|
||||
validation,
|
||||
validate,
|
||||
toIntent,
|
||||
loadFromConfig,
|
||||
} = useCanvasStore();
|
||||
|
||||
const [chatError, setChatError] = useState<string | null>(null);
|
||||
const [isLoadingStudy, setIsLoadingStudy] = useState(false);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
|
||||
const {
|
||||
messages,
|
||||
isThinking,
|
||||
isExecuting,
|
||||
isConnected,
|
||||
executeIntent,
|
||||
validateIntent,
|
||||
analyzeIntent,
|
||||
sendMessage,
|
||||
} = useCanvasChat({
|
||||
onError: (error) => {
|
||||
console.error('Canvas chat error:', error);
|
||||
setChatError(error);
|
||||
},
|
||||
});
|
||||
// Load a study config into the canvas
|
||||
const handleLoadStudy = async () => {
|
||||
if (!studyId) return;
|
||||
|
||||
const handleReconnect = useCallback(() => {
|
||||
setChatError(null);
|
||||
// Force refresh chat connection by toggling panel
|
||||
setShowChat(false);
|
||||
setTimeout(() => setShowChat(true), 100);
|
||||
}, []);
|
||||
setIsLoadingStudy(true);
|
||||
setLoadError(null);
|
||||
try {
|
||||
const response = await fetch(`/api/optimization/studies/${encodeURIComponent(studyId)}/config`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load study: ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
loadFromConfig(data.config);
|
||||
setStudyPath(data.path);
|
||||
|
||||
// Notify parent of study change (for URL updates)
|
||||
if (onStudyChange) {
|
||||
onStudyChange(studyId);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load study:', error);
|
||||
setLoadError(error instanceof Error ? error.message : 'Failed to load study');
|
||||
} finally {
|
||||
setIsLoadingStudy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onDragOver = useCallback((event: DragEvent) => {
|
||||
event.preventDefault();
|
||||
@@ -80,7 +107,6 @@ function CanvasFlow() {
|
||||
const type = event.dataTransfer.getData('application/reactflow') as NodeType;
|
||||
if (!type || !reactFlowInstance.current) return;
|
||||
|
||||
// screenToFlowPosition expects screen coordinates directly
|
||||
const position = reactFlowInstance.current.screenToFlowPosition({
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
@@ -114,7 +140,6 @@ function CanvasFlow() {
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Delete' || event.key === 'Backspace') {
|
||||
// Don't delete if focus is on an input
|
||||
const target = event.target as HTMLElement;
|
||||
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
|
||||
return;
|
||||
@@ -128,22 +153,7 @@ function CanvasFlow() {
|
||||
}, [deleteSelected]);
|
||||
|
||||
const handleValidate = () => {
|
||||
const result = validate();
|
||||
if (result.valid) {
|
||||
// Also send to Claude for intelligent feedback
|
||||
const intent = toIntent();
|
||||
validateIntent(intent);
|
||||
setShowChat(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAnalyze = () => {
|
||||
const result = validate();
|
||||
if (result.valid) {
|
||||
const intent = toIntent();
|
||||
analyzeIntent(intent);
|
||||
setShowChat(true);
|
||||
}
|
||||
validate();
|
||||
};
|
||||
|
||||
const handleExecuteClick = () => {
|
||||
@@ -153,12 +163,43 @@ function CanvasFlow() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleExecute = async (studyName: string, autoRun: boolean, _mode: 'create' | 'update', _existingStudyId?: string) => {
|
||||
const intent = toIntent();
|
||||
// For now, both modes use the same executeIntent - backend will handle the mode distinction
|
||||
await executeIntent(intent, studyName, autoRun);
|
||||
setShowExecuteDialog(false);
|
||||
setShowChat(true);
|
||||
const handleExecute = async (studyName: string, autoRun: boolean, mode: 'create' | 'update', existingStudyId?: string) => {
|
||||
setIsExecuting(true);
|
||||
try {
|
||||
const intent = toIntent();
|
||||
|
||||
// Call API to create/update study from intent
|
||||
const endpoint = mode === 'update' && existingStudyId
|
||||
? `/api/optimization/studies/${encodeURIComponent(existingStudyId)}/update-from-intent`
|
||||
: '/api/optimization/studies/create-from-intent';
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
study_name: studyName,
|
||||
intent,
|
||||
auto_run: autoRun,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || `Failed to ${mode} study`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
setStudyId(studyName);
|
||||
setStudyPath(result.path);
|
||||
|
||||
console.log(`Study ${mode}d:`, result);
|
||||
} catch (error) {
|
||||
console.error(`Failed to ${mode} study:`, error);
|
||||
setLoadError(error instanceof Error ? error.message : `Failed to ${mode} study`);
|
||||
} finally {
|
||||
setIsExecuting(false);
|
||||
setShowExecuteDialog(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -168,6 +209,37 @@ function CanvasFlow() {
|
||||
|
||||
{/* Center: Canvas */}
|
||||
<div className="flex-1 relative" ref={reactFlowWrapper}>
|
||||
{/* Study Context Bar */}
|
||||
<div className="absolute top-4 left-4 right-4 z-10 flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={studyId || ''}
|
||||
onChange={(e) => setStudyId(e.target.value || null)}
|
||||
placeholder="Study ID (e.g., M1_Mirror/m1_mirror_flatback)"
|
||||
className="flex-1 max-w-md px-3 py-2 bg-dark-800/90 backdrop-blur border border-dark-600 text-white placeholder-dark-500 rounded-lg text-sm focus:border-primary-500 focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
onClick={handleLoadStudy}
|
||||
disabled={!studyId || isLoadingStudy}
|
||||
className="px-4 py-2 bg-primary-600 text-white rounded-lg text-sm hover:bg-primary-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{isLoadingStudy ? 'Loading...' : 'Load Study'}
|
||||
</button>
|
||||
{studyPath && (
|
||||
<span className="text-xs text-dark-400 truncate max-w-xs" title={studyPath}>
|
||||
{studyPath.split(/[/\\]/).slice(-2).join('/')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Error Banner */}
|
||||
{loadError && (
|
||||
<div className="absolute top-16 left-4 right-4 z-10 px-4 py-2 bg-red-900/90 backdrop-blur border border-red-700 text-red-200 rounded-lg text-sm flex justify-between items-center">
|
||||
<span>{loadError}</span>
|
||||
<button onClick={() => setLoadError(null)} className="text-red-400 hover:text-red-200">×</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges.map(e => ({
|
||||
@@ -203,44 +275,22 @@ function CanvasFlow() {
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="absolute bottom-4 right-4 flex gap-2 z-10">
|
||||
<button
|
||||
onClick={() => setShowChat(!showChat)}
|
||||
className={`px-3 py-2 rounded-lg transition-colors ${
|
||||
showChat
|
||||
? 'bg-primary-600/20 text-primary-400 border border-primary-500/50'
|
||||
: 'bg-dark-800 text-dark-300 hover:bg-dark-700 border border-dark-600'
|
||||
}`}
|
||||
title="Toggle Chat"
|
||||
>
|
||||
{isConnected ? <MessageCircle size={18} /> : <Plug size={18} />}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleValidate}
|
||||
className="px-4 py-2 bg-dark-700 text-white rounded-lg hover:bg-dark-600 border border-dark-600 transition-colors"
|
||||
>
|
||||
Validate
|
||||
</button>
|
||||
<button
|
||||
onClick={handleAnalyze}
|
||||
disabled={!validation.valid}
|
||||
className={`px-4 py-2 rounded-lg transition-colors border ${
|
||||
validation.valid
|
||||
? 'bg-purple-600 text-white hover:bg-purple-500 border-purple-500'
|
||||
: 'bg-dark-800 text-dark-500 cursor-not-allowed border-dark-700'
|
||||
}`}
|
||||
>
|
||||
Analyze
|
||||
</button>
|
||||
<button
|
||||
onClick={handleExecuteClick}
|
||||
disabled={!validation.valid}
|
||||
disabled={!validation.valid || isExecuting}
|
||||
className={`px-4 py-2 rounded-lg transition-colors border ${
|
||||
validation.valid
|
||||
validation.valid && !isExecuting
|
||||
? 'bg-primary-600 text-white hover:bg-primary-500 border-primary-500'
|
||||
: 'bg-dark-800 text-dark-500 cursor-not-allowed border-dark-700'
|
||||
}`}
|
||||
>
|
||||
Execute with Claude
|
||||
{isExecuting ? 'Creating...' : 'Create Study'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -250,43 +300,8 @@ function CanvasFlow() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: Config Panel or Chat */}
|
||||
{showChat ? (
|
||||
<div className="w-96 border-l border-dark-700 flex flex-col bg-dark-850">
|
||||
<div className="p-3 border-b border-dark-700 flex justify-between items-center">
|
||||
<h3 className="font-semibold text-white">Claude Assistant</h3>
|
||||
<button
|
||||
onClick={() => setShowChat(false)}
|
||||
className="text-dark-400 hover:text-white transition-colors"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
{chatError ? (
|
||||
<div className="flex-1 flex flex-col items-center justify-center p-6 text-center">
|
||||
<AlertCircle size={32} className="text-red-400 mb-3" />
|
||||
<p className="text-white font-medium mb-1">Connection Error</p>
|
||||
<p className="text-sm text-dark-400 mb-4">{chatError}</p>
|
||||
<button
|
||||
onClick={handleReconnect}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-primary-600 hover:bg-primary-500 text-white rounded-lg transition-colors"
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
Reconnect
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<ChatPanel
|
||||
messages={messages}
|
||||
isThinking={isThinking || isExecuting}
|
||||
onSendMessage={sendMessage}
|
||||
isConnected={isConnected}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : selectedNode ? (
|
||||
<NodeConfigPanel nodeId={selectedNode} />
|
||||
) : null}
|
||||
{/* Right: Config Panel */}
|
||||
{selectedNode && <NodeConfigPanel nodeId={selectedNode} />}
|
||||
|
||||
{/* Execute Dialog */}
|
||||
<ExecuteDialog
|
||||
@@ -299,10 +314,20 @@ function CanvasFlow() {
|
||||
);
|
||||
}
|
||||
|
||||
export function AtomizerCanvas() {
|
||||
interface AtomizerCanvasProps {
|
||||
studyId?: string;
|
||||
studyPath?: string;
|
||||
onStudyChange?: (studyId: string) => void;
|
||||
}
|
||||
|
||||
export function AtomizerCanvas({ studyId, studyPath, onStudyChange }: AtomizerCanvasProps = {}) {
|
||||
return (
|
||||
<ReactFlowProvider>
|
||||
<CanvasFlow />
|
||||
<CanvasFlow
|
||||
initialStudyId={studyId}
|
||||
initialStudyPath={studyPath}
|
||||
onStudyChange={onStudyChange}
|
||||
/>
|
||||
</ReactFlowProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* ConnectionStatusIndicator - Visual indicator for WebSocket connection status.
|
||||
*/
|
||||
|
||||
import { ConnectionStatus } from '../../hooks/useSpecWebSocket';
|
||||
|
||||
interface ConnectionStatusIndicatorProps {
|
||||
status: ConnectionStatus;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Visual indicator for WebSocket connection status.
|
||||
* Can be used in the canvas UI to show sync state.
|
||||
*/
|
||||
export function ConnectionStatusIndicator({
|
||||
status,
|
||||
className = '',
|
||||
}: ConnectionStatusIndicatorProps) {
|
||||
const statusConfig = {
|
||||
disconnected: {
|
||||
color: 'bg-gray-500',
|
||||
label: 'Disconnected',
|
||||
},
|
||||
connecting: {
|
||||
color: 'bg-yellow-500 animate-pulse',
|
||||
label: 'Connecting...',
|
||||
},
|
||||
connected: {
|
||||
color: 'bg-green-500',
|
||||
label: 'Connected',
|
||||
},
|
||||
reconnecting: {
|
||||
color: 'bg-yellow-500 animate-pulse',
|
||||
label: 'Reconnecting...',
|
||||
},
|
||||
};
|
||||
|
||||
const config = statusConfig[status];
|
||||
|
||||
return (
|
||||
<div className={`flex items-center gap-2 ${className}`}>
|
||||
<div className={`w-2 h-2 rounded-full ${config.color}`} />
|
||||
<span className="text-xs text-dark-400">{config.label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ConnectionStatusIndicator;
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* ResizeHandle - Visual drag handle for resizable panels
|
||||
*
|
||||
* A thin vertical bar that can be dragged to resize panels.
|
||||
* Shows visual feedback on hover and during drag.
|
||||
*/
|
||||
|
||||
import { memo } from 'react';
|
||||
|
||||
interface ResizeHandleProps {
|
||||
/** Mouse down handler to start dragging */
|
||||
onMouseDown: (e: React.MouseEvent) => void;
|
||||
/** Double click handler to reset size */
|
||||
onDoubleClick?: () => void;
|
||||
/** Whether panel is currently being dragged */
|
||||
isDragging?: boolean;
|
||||
/** Position of the handle ('left' or 'right' edge of the panel) */
|
||||
position?: 'left' | 'right';
|
||||
}
|
||||
|
||||
function ResizeHandleComponent({
|
||||
onMouseDown,
|
||||
onDoubleClick,
|
||||
isDragging = false,
|
||||
position = 'right',
|
||||
}: ResizeHandleProps) {
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
absolute top-0 bottom-0 w-1 z-30
|
||||
cursor-col-resize
|
||||
transition-colors duration-150
|
||||
${position === 'right' ? 'right-0' : 'left-0'}
|
||||
${isDragging
|
||||
? 'bg-primary-500'
|
||||
: 'bg-transparent hover:bg-primary-500/50'
|
||||
}
|
||||
`}
|
||||
onMouseDown={onMouseDown}
|
||||
onDoubleClick={onDoubleClick}
|
||||
title="Drag to resize, double-click to reset"
|
||||
>
|
||||
{/* Wider hit area for easier grabbing */}
|
||||
<div
|
||||
className={`
|
||||
absolute top-0 bottom-0 w-3
|
||||
${position === 'right' ? '-left-1' : '-right-1'}
|
||||
`}
|
||||
/>
|
||||
|
||||
{/* Visual indicator dots (shown on hover via CSS) */}
|
||||
<div className={`
|
||||
absolute top-1/2 -translate-y-1/2
|
||||
${position === 'right' ? '-left-0.5' : '-right-0.5'}
|
||||
flex flex-col gap-1 opacity-0 hover:opacity-100 transition-opacity
|
||||
${isDragging ? 'opacity-100' : ''}
|
||||
`}>
|
||||
<div className="w-1 h-1 rounded-full bg-dark-400" />
|
||||
<div className="w-1 h-1 rounded-full bg-dark-400" />
|
||||
<div className="w-1 h-1 rounded-full bg-dark-400" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const ResizeHandle = memo(ResizeHandleComponent);
|
||||
export default ResizeHandle;
|
||||
1186
atomizer-dashboard/frontend/src/components/canvas/SpecRenderer.tsx
Normal file
1186
atomizer-dashboard/frontend/src/components/canvas/SpecRenderer.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
||||
// Main Canvas Component
|
||||
export { AtomizerCanvas } from './AtomizerCanvas';
|
||||
export { SpecRenderer } from './SpecRenderer';
|
||||
|
||||
// Palette
|
||||
export { NodePalette } from './palette/NodePalette';
|
||||
|
||||
@@ -2,12 +2,14 @@ import { memo } from 'react';
|
||||
import { NodeProps } from 'reactflow';
|
||||
import { ShieldAlert } from 'lucide-react';
|
||||
import { BaseNode } from './BaseNode';
|
||||
import { ResultBadge } from './ResultBadge';
|
||||
import { ConstraintNodeData } from '../../../lib/canvas/schema';
|
||||
|
||||
function ConstraintNodeComponent(props: NodeProps<ConstraintNodeData>) {
|
||||
const { data } = props;
|
||||
return (
|
||||
<BaseNode {...props} icon={<ShieldAlert size={16} />} iconColor="text-amber-400">
|
||||
<ResultBadge value={data.resultValue} isFeasible={data.isFeasible} />
|
||||
{data.name && data.operator && data.value !== undefined
|
||||
? `${data.name} ${data.operator} ${data.value}`
|
||||
: 'Set constraint'}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* CustomExtractorNode - Canvas node for custom Python extractors
|
||||
*
|
||||
* Displays custom extractors defined with inline Python code.
|
||||
* Visually distinct from builtin extractors with a code icon.
|
||||
*
|
||||
* P3.11: Custom extractor UI component
|
||||
*/
|
||||
|
||||
import { memo } from 'react';
|
||||
import { NodeProps } from 'reactflow';
|
||||
import { Code2 } from 'lucide-react';
|
||||
import { BaseNode } from './BaseNode';
|
||||
|
||||
export interface CustomExtractorNodeData {
|
||||
type: 'customExtractor';
|
||||
label: string;
|
||||
configured: boolean;
|
||||
extractorId?: string;
|
||||
extractorName?: string;
|
||||
functionName?: string;
|
||||
functionSource?: string;
|
||||
outputs?: Array<{ name: string; units?: string }>;
|
||||
dependencies?: string[];
|
||||
}
|
||||
|
||||
function CustomExtractorNodeComponent(props: NodeProps<CustomExtractorNodeData>) {
|
||||
const { data } = props;
|
||||
|
||||
// Show validation status
|
||||
const hasCode = !!data.functionSource?.trim();
|
||||
const hasOutputs = (data.outputs?.length ?? 0) > 0;
|
||||
const isConfigured = hasCode && hasOutputs;
|
||||
|
||||
return (
|
||||
<BaseNode
|
||||
{...props}
|
||||
icon={<Code2 size={16} />}
|
||||
iconColor={isConfigured ? 'text-violet-400' : 'text-dark-500'}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<span className={isConfigured ? 'text-white' : 'text-dark-400'}>
|
||||
{data.extractorName || data.functionName || 'Custom Extractor'}
|
||||
</span>
|
||||
{!isConfigured && (
|
||||
<span className="text-xs text-amber-400">Needs configuration</span>
|
||||
)}
|
||||
{isConfigured && data.outputs && (
|
||||
<span className="text-xs text-dark-400">
|
||||
{data.outputs.length} output{data.outputs.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</BaseNode>
|
||||
);
|
||||
}
|
||||
|
||||
export const CustomExtractorNode = memo(CustomExtractorNodeComponent);
|
||||
@@ -2,12 +2,14 @@ import { memo } from 'react';
|
||||
import { NodeProps } from 'reactflow';
|
||||
import { SlidersHorizontal } from 'lucide-react';
|
||||
import { BaseNode } from './BaseNode';
|
||||
import { ResultBadge } from './ResultBadge';
|
||||
import { DesignVarNodeData } from '../../../lib/canvas/schema';
|
||||
|
||||
function DesignVarNodeComponent(props: NodeProps<DesignVarNodeData>) {
|
||||
const { data } = props;
|
||||
return (
|
||||
<BaseNode {...props} icon={<SlidersHorizontal size={16} />} iconColor="text-emerald-400" inputs={0} outputs={1}>
|
||||
<ResultBadge value={data.resultValue} unit={data.unit} />
|
||||
{data.expressionName ? (
|
||||
<span className="font-mono">{data.expressionName}</span>
|
||||
) : (
|
||||
|
||||
@@ -2,12 +2,14 @@ import { memo } from 'react';
|
||||
import { NodeProps } from 'reactflow';
|
||||
import { FlaskConical } from 'lucide-react';
|
||||
import { BaseNode } from './BaseNode';
|
||||
import { ResultBadge } from './ResultBadge';
|
||||
import { ExtractorNodeData } from '../../../lib/canvas/schema';
|
||||
|
||||
function ExtractorNodeComponent(props: NodeProps<ExtractorNodeData>) {
|
||||
const { data } = props;
|
||||
return (
|
||||
<BaseNode {...props} icon={<FlaskConical size={16} />} iconColor="text-cyan-400">
|
||||
<ResultBadge value={data.resultValue} />
|
||||
{data.extractorName || 'Select extractor'}
|
||||
</BaseNode>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* ModelNodeV2 - Enhanced model node with collapsible file dependencies
|
||||
*
|
||||
* Features:
|
||||
* - Shows main model file (.sim)
|
||||
* - Collapsible section showing related files (.prt, .fem, _i.prt)
|
||||
* - Hover to reveal file path
|
||||
* - Click to introspect model
|
||||
* - Shows solver type badge
|
||||
*/
|
||||
|
||||
import { memo, useState, useCallback, useEffect } from 'react';
|
||||
import { NodeProps, Handle, Position } from 'reactflow';
|
||||
import {
|
||||
Box,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
FileBox,
|
||||
FileCode,
|
||||
Cpu,
|
||||
RefreshCw,
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
} from 'lucide-react';
|
||||
import { ModelNodeData } from '../../../lib/canvas/schema';
|
||||
|
||||
interface DependentFile {
|
||||
name: string;
|
||||
path: string;
|
||||
type: 'prt' | 'fem' | 'sim' | 'idealized' | 'other';
|
||||
exists: boolean;
|
||||
}
|
||||
|
||||
interface IntrospectionResult {
|
||||
expressions: Array<{
|
||||
name: string;
|
||||
value: number | string;
|
||||
units?: string;
|
||||
formula?: string;
|
||||
}>;
|
||||
solver_type?: string;
|
||||
dependent_files?: string[];
|
||||
}
|
||||
|
||||
function ModelNodeV2Component(props: NodeProps<ModelNodeData>) {
|
||||
const { data, selected } = props;
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [dependencies, setDependencies] = useState<DependentFile[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [introspection, setIntrospection] = useState<IntrospectionResult | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Extract filename from path
|
||||
const fileName = data.filePath ? data.filePath.split(/[/\\]/).pop() : 'No file selected';
|
||||
|
||||
// Load dependencies when expanded
|
||||
const loadDependencies = useCallback(async () => {
|
||||
if (!data.filePath) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Call introspection API to get dependent files
|
||||
const response = await fetch(
|
||||
`/api/nx/introspect`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ file_path: data.filePath }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to introspect model');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
setIntrospection(result);
|
||||
|
||||
// Parse dependent files
|
||||
const deps: DependentFile[] = [];
|
||||
|
||||
if (result.dependent_files) {
|
||||
for (const filePath of result.dependent_files) {
|
||||
const name = filePath.split(/[/\\]/).pop() || filePath;
|
||||
const ext = name.split('.').pop()?.toLowerCase();
|
||||
|
||||
let type: DependentFile['type'] = 'other';
|
||||
if (name.includes('_i.prt')) {
|
||||
type = 'idealized';
|
||||
} else if (ext === 'prt') {
|
||||
type = 'prt';
|
||||
} else if (ext === 'fem' || ext === 'afem') {
|
||||
type = 'fem';
|
||||
} else if (ext === 'sim') {
|
||||
type = 'sim';
|
||||
}
|
||||
|
||||
deps.push({
|
||||
name,
|
||||
path: filePath,
|
||||
type,
|
||||
exists: true, // Assume exists from introspection
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setDependencies(deps);
|
||||
} catch (err) {
|
||||
console.error('Failed to load model dependencies:', err);
|
||||
setError('Failed to introspect');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [data.filePath]);
|
||||
|
||||
// Load on first expand
|
||||
useEffect(() => {
|
||||
if (isExpanded && dependencies.length === 0 && !isLoading && data.filePath) {
|
||||
loadDependencies();
|
||||
}
|
||||
}, [isExpanded, dependencies.length, isLoading, data.filePath, loadDependencies]);
|
||||
|
||||
// Get icon for file type
|
||||
const getFileIcon = (type: DependentFile['type']) => {
|
||||
switch (type) {
|
||||
case 'prt':
|
||||
return <Box size={12} className="text-blue-400" />;
|
||||
case 'fem':
|
||||
return <FileCode size={12} className="text-emerald-400" />;
|
||||
case 'sim':
|
||||
return <Cpu size={12} className="text-violet-400" />;
|
||||
case 'idealized':
|
||||
return <Box size={12} className="text-cyan-400" />;
|
||||
default:
|
||||
return <FileBox size={12} className="text-dark-400" />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
relative rounded-xl border min-w-[200px] max-w-[280px]
|
||||
bg-dark-800 shadow-xl transition-all duration-150 overflow-hidden
|
||||
${selected ? 'border-primary-400 ring-2 ring-primary-400/30 shadow-primary-500/20' : 'border-dark-600'}
|
||||
${!data.configured ? 'border-dashed border-dark-500' : ''}
|
||||
`}
|
||||
>
|
||||
{/* Input handle */}
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
className="!w-3 !h-3 !bg-dark-400 !border-2 !border-dark-600 hover:!bg-primary-400 hover:!border-primary-500 transition-colors"
|
||||
/>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="px-4 py-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="text-blue-400 flex-shrink-0">
|
||||
<Box size={16} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-semibold text-white text-sm truncate">
|
||||
{data.label || 'Model'}
|
||||
</div>
|
||||
</div>
|
||||
{!data.configured && (
|
||||
<div className="w-2 h-2 rounded-full bg-amber-400 flex-shrink-0 animate-pulse" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* File info */}
|
||||
<div className="mt-2 text-xs text-dark-300 truncate" title={data.filePath}>
|
||||
{fileName}
|
||||
</div>
|
||||
|
||||
{/* Solver badge */}
|
||||
{introspection?.solver_type && (
|
||||
<div className="mt-2 inline-flex items-center gap-1 px-2 py-0.5 rounded bg-violet-500/20 text-violet-400 text-xs">
|
||||
<Cpu size={10} />
|
||||
{introspection.solver_type}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Dependencies section (collapsible) */}
|
||||
{data.filePath && (
|
||||
<div className="border-t border-dark-700">
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="w-full px-4 py-2 flex items-center gap-2 text-xs text-dark-400 hover:text-white hover:bg-dark-700/50 transition-colors"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown size={12} />
|
||||
) : (
|
||||
<ChevronRight size={12} />
|
||||
)}
|
||||
<span>Dependencies</span>
|
||||
{dependencies.length > 0 && (
|
||||
<span className="ml-auto text-dark-500">{dependencies.length}</span>
|
||||
)}
|
||||
{isLoading && (
|
||||
<RefreshCw size={12} className="ml-auto animate-spin text-primary-400" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="px-3 pb-3 space-y-1">
|
||||
{error ? (
|
||||
<div className="flex items-center gap-1 text-xs text-red-400">
|
||||
<AlertCircle size={12} />
|
||||
{error}
|
||||
</div>
|
||||
) : dependencies.length === 0 && !isLoading ? (
|
||||
<div className="text-xs text-dark-500 py-1">
|
||||
No dependencies found
|
||||
</div>
|
||||
) : (
|
||||
dependencies.map((dep) => (
|
||||
<div
|
||||
key={dep.path}
|
||||
className="flex items-center gap-2 px-2 py-1 rounded bg-dark-900/50 text-xs"
|
||||
title={dep.path}
|
||||
>
|
||||
{getFileIcon(dep.type)}
|
||||
<span className="flex-1 truncate text-dark-300">{dep.name}</span>
|
||||
{dep.exists ? (
|
||||
<CheckCircle size={10} className="text-emerald-400 flex-shrink-0" />
|
||||
) : (
|
||||
<AlertCircle size={10} className="text-amber-400 flex-shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
||||
{/* Expressions count */}
|
||||
{introspection?.expressions && introspection.expressions.length > 0 && (
|
||||
<div className="mt-2 pt-2 border-t border-dark-700">
|
||||
<div className="text-xs text-dark-400">
|
||||
{introspection.expressions.length} expressions found
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Output handle */}
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
className="!w-3 !h-3 !bg-dark-400 !border-2 !border-dark-600 hover:!bg-primary-400 hover:!border-primary-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const ModelNodeV2 = memo(ModelNodeV2Component);
|
||||
@@ -2,13 +2,38 @@ import { memo } from 'react';
|
||||
import { NodeProps } from 'reactflow';
|
||||
import { Target } from 'lucide-react';
|
||||
import { BaseNode } from './BaseNode';
|
||||
import { ResultBadge } from './ResultBadge';
|
||||
import { ConvergenceSparkline } from '../visualization/ConvergenceSparkline';
|
||||
import { ObjectiveNodeData } from '../../../lib/canvas/schema';
|
||||
|
||||
function ObjectiveNodeComponent(props: NodeProps<ObjectiveNodeData>) {
|
||||
const { data } = props;
|
||||
const hasHistory = data.history && data.history.length > 1;
|
||||
|
||||
return (
|
||||
<BaseNode {...props} icon={<Target size={16} />} iconColor="text-rose-400">
|
||||
{data.name ? `${data.direction === 'maximize' ? '↑' : '↓'} ${data.name}` : 'Set objective'}
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm">
|
||||
{data.name ? `${data.direction === 'maximize' ? '↑' : '↓'} ${data.name}` : 'Set objective'}
|
||||
</span>
|
||||
<ResultBadge value={data.resultValue} label="Best" />
|
||||
</div>
|
||||
|
||||
{/* Convergence sparkline */}
|
||||
{hasHistory && (
|
||||
<div className="mt-1 -mb-1">
|
||||
<ConvergenceSparkline
|
||||
values={data.history!}
|
||||
width={120}
|
||||
height={20}
|
||||
direction={data.direction || 'minimize'}
|
||||
color={data.direction === 'maximize' ? '#34d399' : '#60a5fa'}
|
||||
showBest={true}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</BaseNode>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { memo } from 'react';
|
||||
|
||||
interface ResultBadgeProps {
|
||||
value: number | string | null | undefined;
|
||||
unit?: string;
|
||||
isFeasible?: boolean; // For constraints
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export const ResultBadge = memo(function ResultBadge({ value, unit, isFeasible, label }: ResultBadgeProps) {
|
||||
if (value === null || value === undefined) return null;
|
||||
|
||||
const displayValue = typeof value === 'number'
|
||||
? value.toLocaleString(undefined, { maximumFractionDigits: 4 })
|
||||
: value;
|
||||
|
||||
// Determine color based on feasibility (if provided)
|
||||
let bgColor = 'bg-primary-500/20';
|
||||
let textColor = 'text-primary-300';
|
||||
let borderColor = 'border-primary-500/30';
|
||||
|
||||
if (isFeasible === true) {
|
||||
bgColor = 'bg-emerald-500/20';
|
||||
textColor = 'text-emerald-300';
|
||||
borderColor = 'border-emerald-500/30';
|
||||
} else if (isFeasible === false) {
|
||||
bgColor = 'bg-red-500/20';
|
||||
textColor = 'text-red-300';
|
||||
borderColor = 'border-red-500/30';
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`absolute -top-3 -right-2 px-2 py-0.5 rounded-full border ${bgColor} ${borderColor} ${textColor} text-xs font-mono shadow-lg backdrop-blur-sm z-10 flex items-center gap-1`}>
|
||||
{label && <span className="opacity-70 mr-1">{label}:</span>}
|
||||
<span className="font-bold">{displayValue}</span>
|
||||
{unit && <span className="opacity-70 text-[10px] ml-0.5">{unit}</span>}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -1,14 +1,44 @@
|
||||
import { memo } from 'react';
|
||||
import { NodeProps } from 'reactflow';
|
||||
import { Cpu } from 'lucide-react';
|
||||
import { Cpu, Terminal } from 'lucide-react';
|
||||
import { BaseNode } from './BaseNode';
|
||||
import { SolverNodeData } from '../../../lib/canvas/schema';
|
||||
import { SolverNodeData, SolverEngine } from '../../../lib/canvas/schema';
|
||||
|
||||
// Human-readable engine names
|
||||
const ENGINE_LABELS: Record<SolverEngine, string> = {
|
||||
nxnastran: 'NX Nastran',
|
||||
mscnastran: 'MSC Nastran',
|
||||
python: 'Python Script',
|
||||
abaqus: 'Abaqus',
|
||||
ansys: 'ANSYS',
|
||||
};
|
||||
|
||||
function SolverNodeComponent(props: NodeProps<SolverNodeData>) {
|
||||
const { data } = props;
|
||||
|
||||
// Build display string: "Engine - SolutionType" or just one
|
||||
const engineLabel = data.engine ? ENGINE_LABELS[data.engine] : null;
|
||||
const solverTypeLabel = data.solverType || null;
|
||||
|
||||
let displayText: string;
|
||||
if (engineLabel && solverTypeLabel) {
|
||||
displayText = `${engineLabel} (${solverTypeLabel})`;
|
||||
} else if (engineLabel) {
|
||||
displayText = engineLabel;
|
||||
} else if (solverTypeLabel) {
|
||||
displayText = solverTypeLabel;
|
||||
} else {
|
||||
displayText = 'Configure solver';
|
||||
}
|
||||
|
||||
// Use Terminal icon for Python, Cpu for others
|
||||
const icon = data.engine === 'python'
|
||||
? <Terminal size={16} />
|
||||
: <Cpu size={16} />;
|
||||
|
||||
return (
|
||||
<BaseNode {...props} icon={<Cpu size={16} />} iconColor="text-violet-400">
|
||||
{data.solverType || 'Select solution'}
|
||||
<BaseNode {...props} icon={icon} iconColor="text-violet-400">
|
||||
{displayText}
|
||||
</BaseNode>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ModelNode } from './ModelNode';
|
||||
import { ModelNodeV2 } from './ModelNodeV2';
|
||||
import { SolverNode } from './SolverNode';
|
||||
import { DesignVarNode } from './DesignVarNode';
|
||||
import { ExtractorNode } from './ExtractorNode';
|
||||
@@ -9,6 +10,7 @@ import { SurrogateNode } from './SurrogateNode';
|
||||
|
||||
export {
|
||||
ModelNode,
|
||||
ModelNodeV2,
|
||||
SolverNode,
|
||||
DesignVarNode,
|
||||
ExtractorNode,
|
||||
@@ -18,8 +20,12 @@ export {
|
||||
SurrogateNode,
|
||||
};
|
||||
|
||||
// Use ModelNodeV2 by default for enhanced dependency display
|
||||
// Set USE_LEGACY_MODEL_NODE=true to use the original
|
||||
const useEnhancedModelNode = !import.meta.env.VITE_USE_LEGACY_MODEL_NODE;
|
||||
|
||||
export const nodeTypes = {
|
||||
model: ModelNode,
|
||||
model: useEnhancedModelNode ? ModelNodeV2 : ModelNode,
|
||||
solver: SolverNode,
|
||||
designVar: DesignVarNode,
|
||||
extractor: ExtractorNode,
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
/**
|
||||
* NodePalette - Draggable component library for canvas
|
||||
*
|
||||
* Features:
|
||||
* - Draggable node items for canvas drop
|
||||
* - Collapsible mode (icons only)
|
||||
* - Filterable by node type
|
||||
* - Works with both AtomizerCanvas and SpecRenderer
|
||||
*/
|
||||
|
||||
import { DragEvent } from 'react';
|
||||
import { NodeType } from '../../../lib/canvas/schema';
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import {
|
||||
Box,
|
||||
Cpu,
|
||||
@@ -9,63 +19,240 @@ import {
|
||||
ShieldAlert,
|
||||
BrainCircuit,
|
||||
Rocket,
|
||||
LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import { NodeType } from '../../../lib/canvas/schema';
|
||||
|
||||
interface PaletteItem {
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
export interface PaletteItem {
|
||||
type: NodeType;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
icon: LucideIcon;
|
||||
description: string;
|
||||
color: string;
|
||||
/** Whether this can be added via drag-drop (synthetic nodes cannot) */
|
||||
canAdd: boolean;
|
||||
}
|
||||
|
||||
const PALETTE_ITEMS: PaletteItem[] = [
|
||||
{ type: 'model', label: 'Model', icon: <Box size={18} />, description: 'NX model file (.prt, .sim)', color: 'text-blue-400' },
|
||||
{ type: 'solver', label: 'Solver', icon: <Cpu size={18} />, description: 'Nastran solution type', color: 'text-violet-400' },
|
||||
{ type: 'designVar', label: 'Design Variable', icon: <SlidersHorizontal size={18} />, description: 'Parameter to optimize', color: 'text-emerald-400' },
|
||||
{ type: 'extractor', label: 'Extractor', icon: <FlaskConical size={18} />, description: 'Physics result extraction', color: 'text-cyan-400' },
|
||||
{ type: 'objective', label: 'Objective', icon: <Target size={18} />, description: 'Optimization goal', color: 'text-rose-400' },
|
||||
{ type: 'constraint', label: 'Constraint', icon: <ShieldAlert size={18} />, description: 'Design constraint', color: 'text-amber-400' },
|
||||
{ type: 'algorithm', label: 'Algorithm', icon: <BrainCircuit size={18} />, description: 'Optimization method', color: 'text-indigo-400' },
|
||||
{ type: 'surrogate', label: 'Surrogate', icon: <Rocket size={18} />, description: 'Neural acceleration', color: 'text-pink-400' },
|
||||
export interface NodePaletteProps {
|
||||
/** Whether palette is collapsed (icon-only mode) */
|
||||
collapsed?: boolean;
|
||||
/** Callback when collapse state changes */
|
||||
onToggleCollapse?: () => void;
|
||||
/** Custom className for container */
|
||||
className?: string;
|
||||
/** Filter which node types to show */
|
||||
visibleTypes?: NodeType[];
|
||||
/** Show toggle button */
|
||||
showToggle?: boolean;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Constants
|
||||
// ============================================================================
|
||||
|
||||
/** Singleton node types - only one of each allowed on canvas */
|
||||
export const SINGLETON_TYPES: NodeType[] = ['model', 'solver', 'algorithm', 'surrogate'];
|
||||
|
||||
export const PALETTE_ITEMS: PaletteItem[] = [
|
||||
{
|
||||
type: 'model',
|
||||
label: 'Model',
|
||||
icon: Box,
|
||||
description: 'NX model file (.prt, .sim)',
|
||||
color: 'text-blue-400',
|
||||
canAdd: true, // Singleton - only one allowed
|
||||
},
|
||||
{
|
||||
type: 'solver',
|
||||
label: 'Solver',
|
||||
icon: Cpu,
|
||||
description: 'Analysis solver config',
|
||||
color: 'text-violet-400',
|
||||
canAdd: true, // Singleton - only one allowed
|
||||
},
|
||||
{
|
||||
type: 'designVar',
|
||||
label: 'Design Variable',
|
||||
icon: SlidersHorizontal,
|
||||
description: 'Parameter to optimize',
|
||||
color: 'text-emerald-400',
|
||||
canAdd: true,
|
||||
},
|
||||
{
|
||||
type: 'extractor',
|
||||
label: 'Extractor',
|
||||
icon: FlaskConical,
|
||||
description: 'Physics result extraction',
|
||||
color: 'text-cyan-400',
|
||||
canAdd: true,
|
||||
},
|
||||
{
|
||||
type: 'objective',
|
||||
label: 'Objective',
|
||||
icon: Target,
|
||||
description: 'Optimization goal',
|
||||
color: 'text-rose-400',
|
||||
canAdd: true,
|
||||
},
|
||||
{
|
||||
type: 'constraint',
|
||||
label: 'Constraint',
|
||||
icon: ShieldAlert,
|
||||
description: 'Design constraint',
|
||||
color: 'text-amber-400',
|
||||
canAdd: true,
|
||||
},
|
||||
{
|
||||
type: 'algorithm',
|
||||
label: 'Algorithm',
|
||||
icon: BrainCircuit,
|
||||
description: 'Optimization method',
|
||||
color: 'text-indigo-400',
|
||||
canAdd: true, // Singleton - only one allowed
|
||||
},
|
||||
{
|
||||
type: 'surrogate',
|
||||
label: 'Surrogate',
|
||||
icon: Rocket,
|
||||
description: 'Neural acceleration',
|
||||
color: 'text-pink-400',
|
||||
canAdd: true, // Singleton - only one allowed
|
||||
},
|
||||
];
|
||||
|
||||
export function NodePalette() {
|
||||
const onDragStart = (event: DragEvent, nodeType: NodeType) => {
|
||||
event.dataTransfer.setData('application/reactflow', nodeType);
|
||||
/** Items that can be added via drag-drop */
|
||||
export const ADDABLE_ITEMS = PALETTE_ITEMS.filter(item => item.canAdd);
|
||||
|
||||
// ============================================================================
|
||||
// Component
|
||||
// ============================================================================
|
||||
|
||||
export function NodePalette({
|
||||
collapsed = false,
|
||||
onToggleCollapse,
|
||||
className = '',
|
||||
visibleTypes,
|
||||
showToggle = true,
|
||||
}: NodePaletteProps) {
|
||||
// Filter items if visibleTypes is provided
|
||||
const items = visibleTypes
|
||||
? PALETTE_ITEMS.filter(item => visibleTypes.includes(item.type))
|
||||
: PALETTE_ITEMS;
|
||||
|
||||
const onDragStart = (event: DragEvent, item: PaletteItem) => {
|
||||
if (!item.canAdd) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
event.dataTransfer.setData('application/reactflow', item.type);
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-60 bg-dark-850 border-r border-dark-700 flex flex-col">
|
||||
<div className="p-4 border-b border-dark-700">
|
||||
<h3 className="text-sm font-semibold text-dark-300 uppercase tracking-wider">
|
||||
Components
|
||||
</h3>
|
||||
<p className="text-xs text-dark-400 mt-1">
|
||||
Drag to canvas
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-3 space-y-2">
|
||||
{PALETTE_ITEMS.map((item) => (
|
||||
<div
|
||||
key={item.type}
|
||||
draggable
|
||||
onDragStart={(e) => onDragStart(e, item.type)}
|
||||
className="flex items-center gap-3 px-3 py-3 bg-dark-800/50 rounded-lg border border-dark-700/50
|
||||
cursor-grab hover:border-primary-500/50 hover:bg-dark-800
|
||||
active:cursor-grabbing transition-all group"
|
||||
// Collapsed mode - icons only
|
||||
if (collapsed) {
|
||||
return (
|
||||
<div className={`w-14 bg-dark-850 border-r border-dark-700 flex flex-col ${className}`}>
|
||||
{/* Toggle Button */}
|
||||
{showToggle && onToggleCollapse && (
|
||||
<button
|
||||
onClick={onToggleCollapse}
|
||||
className="p-4 border-b border-dark-700 hover:bg-dark-800 transition-colors flex items-center justify-center"
|
||||
title="Expand palette"
|
||||
>
|
||||
<div className={`${item.color} opacity-90 group-hover:opacity-100 transition-opacity`}>
|
||||
{item.icon}
|
||||
<ChevronRight size={18} className="text-dark-400" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Collapsed Items */}
|
||||
<div className="flex-1 overflow-y-auto py-2">
|
||||
{items.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isDraggable = item.canAdd;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.type}
|
||||
draggable={isDraggable}
|
||||
onDragStart={(e) => onDragStart(e, item)}
|
||||
className={`p-3 mx-2 my-1 rounded-lg transition-all flex items-center justify-center
|
||||
${isDraggable
|
||||
? 'cursor-grab hover:bg-dark-800 active:cursor-grabbing'
|
||||
: 'cursor-default opacity-50'
|
||||
}`}
|
||||
title={`${item.label}${!isDraggable ? ' (auto-created)' : ''}`}
|
||||
>
|
||||
<Icon size={18} className={item.color} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Expanded mode - full display
|
||||
return (
|
||||
<div className={`w-60 bg-dark-850 border-r border-dark-700 flex flex-col ${className}`}>
|
||||
{/* Header */}
|
||||
<div className="p-4 border-b border-dark-700 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-dark-300 uppercase tracking-wider">
|
||||
Components
|
||||
</h3>
|
||||
<p className="text-xs text-dark-400 mt-1">
|
||||
Drag to canvas
|
||||
</p>
|
||||
</div>
|
||||
{showToggle && onToggleCollapse && (
|
||||
<button
|
||||
onClick={onToggleCollapse}
|
||||
className="p-1.5 rounded hover:bg-dark-800 transition-colors"
|
||||
title="Collapse palette"
|
||||
>
|
||||
<ChevronLeft size={16} className="text-dark-400" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Items */}
|
||||
<div className="flex-1 overflow-y-auto p-3 space-y-2">
|
||||
{items.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isDraggable = item.canAdd;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.type}
|
||||
draggable={isDraggable}
|
||||
onDragStart={(e) => onDragStart(e, item)}
|
||||
className={`flex items-center gap-3 px-3 py-3 rounded-lg border transition-all group
|
||||
${isDraggable
|
||||
? 'bg-dark-800/50 border-dark-700/50 cursor-grab hover:border-primary-500/50 hover:bg-dark-800 active:cursor-grabbing'
|
||||
: 'bg-dark-900/30 border-dark-800/30 cursor-default'
|
||||
}`}
|
||||
title={!isDraggable ? 'Auto-created from study configuration' : undefined}
|
||||
>
|
||||
<div className={`${item.color} ${isDraggable ? 'opacity-90 group-hover:opacity-100' : 'opacity-50'} transition-opacity`}>
|
||||
<Icon size={18} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className={`font-semibold text-sm leading-tight ${isDraggable ? 'text-white' : 'text-dark-400'}`}>
|
||||
{item.label}
|
||||
</div>
|
||||
<div className="text-xs text-dark-400 truncate">
|
||||
{isDraggable ? item.description : 'Auto-created'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-semibold text-white text-sm leading-tight">{item.label}</div>
|
||||
<div className="text-xs text-dark-400 truncate">{item.description}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default NodePalette;
|
||||
|
||||
@@ -0,0 +1,844 @@
|
||||
/**
|
||||
* CodeEditorPanel - Monaco editor for custom extractor Python code
|
||||
*
|
||||
* Features:
|
||||
* - Python syntax highlighting
|
||||
* - Auto-completion for common patterns
|
||||
* - Error display
|
||||
* - Claude AI code generation with streaming support
|
||||
* - Preview of extracted outputs
|
||||
* - Code snippets library
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import Editor, { OnMount, OnChange } from '@monaco-editor/react';
|
||||
import {
|
||||
Play,
|
||||
Wand2,
|
||||
Copy,
|
||||
Check,
|
||||
AlertCircle,
|
||||
RefreshCw,
|
||||
X,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
FileCode,
|
||||
Sparkles,
|
||||
Square,
|
||||
BookOpen,
|
||||
FlaskConical,
|
||||
} from 'lucide-react';
|
||||
|
||||
// Monaco editor types
|
||||
type Monaco = Parameters<OnMount>[1];
|
||||
type EditorInstance = Parameters<OnMount>[0];
|
||||
|
||||
/** Streaming generation callbacks */
|
||||
export interface StreamingCallbacks {
|
||||
onToken: (token: string) => void;
|
||||
onComplete: (code: string, outputs: string[]) => void;
|
||||
onError: (error: string) => void;
|
||||
}
|
||||
|
||||
/** Request format for streaming generation */
|
||||
export interface StreamingGenerationRequest {
|
||||
prompt: string;
|
||||
study_id?: string;
|
||||
existing_code?: string;
|
||||
output_names?: string[];
|
||||
}
|
||||
|
||||
interface CodeEditorPanelProps {
|
||||
/** Initial code content */
|
||||
initialCode?: string;
|
||||
/** Callback when code changes */
|
||||
onChange?: (code: string) => void;
|
||||
/** Callback when user requests Claude generation (non-streaming) */
|
||||
onRequestGeneration?: (prompt: string) => Promise<string>;
|
||||
/** Callback for streaming generation (preferred over onRequestGeneration) */
|
||||
onRequestStreamingGeneration?: (
|
||||
request: StreamingGenerationRequest,
|
||||
callbacks: StreamingCallbacks
|
||||
) => AbortController;
|
||||
/** Whether the panel is read-only */
|
||||
readOnly?: boolean;
|
||||
/** Extractor name for context */
|
||||
extractorName?: string;
|
||||
/** Output variable names */
|
||||
outputs?: string[];
|
||||
/** Optional height (default: 300px) */
|
||||
height?: number | string;
|
||||
/** Show/hide header */
|
||||
showHeader?: boolean;
|
||||
/** Callback when running code (validation) */
|
||||
onRun?: (code: string) => Promise<{ success: boolean; error?: string; outputs?: Record<string, unknown> }>;
|
||||
/** Callback for live testing against OP2 file */
|
||||
onTest?: (code: string) => Promise<{ success: boolean; error?: string; outputs?: Record<string, number>; execution_time_ms?: number }>;
|
||||
/** Close button callback */
|
||||
onClose?: () => void;
|
||||
/** Study ID for context in generation */
|
||||
studyId?: string;
|
||||
}
|
||||
|
||||
// Default Python template for custom extractors
|
||||
const DEFAULT_EXTRACTOR_TEMPLATE = `"""
|
||||
Custom Extractor Function
|
||||
|
||||
This function is called after FEA simulation completes.
|
||||
It receives the results and should return extracted values.
|
||||
|
||||
Available inputs:
|
||||
- op2_path: Path to the .op2 results file
|
||||
- fem_path: Path to the .fem file
|
||||
- params: Dict of current design variable values
|
||||
- subcase_id: Current subcase being analyzed (optional)
|
||||
|
||||
Return a dict with your extracted values.
|
||||
"""
|
||||
|
||||
from pyNastran.op2.op2 import OP2
|
||||
import numpy as np
|
||||
|
||||
def extract(op2_path: str, fem_path: str, params: dict, subcase_id: int = 1) -> dict:
|
||||
"""
|
||||
Extract physics from FEA results.
|
||||
|
||||
Args:
|
||||
op2_path: Path to OP2 results file
|
||||
fem_path: Path to FEM file
|
||||
params: Current design variable values
|
||||
subcase_id: Subcase ID to analyze
|
||||
|
||||
Returns:
|
||||
Dict with extracted values, e.g. {'max_stress': 150.5, 'mass': 2.3}
|
||||
"""
|
||||
# Load OP2 results
|
||||
op2 = OP2()
|
||||
op2.read_op2(op2_path)
|
||||
|
||||
# Example: Extract max displacement
|
||||
if subcase_id in op2.displacements:
|
||||
disp = op2.displacements[subcase_id]
|
||||
# Get magnitude of displacement vectors
|
||||
magnitudes = np.sqrt(np.sum(disp.data[0, :, 1:4]**2, axis=1))
|
||||
max_disp = float(np.max(magnitudes))
|
||||
else:
|
||||
max_disp = 0.0
|
||||
|
||||
return {
|
||||
'max_displacement': max_disp,
|
||||
# Add more outputs as needed
|
||||
}
|
||||
`;
|
||||
|
||||
// Code snippets library
|
||||
interface CodeSnippet {
|
||||
id: string;
|
||||
name: string;
|
||||
category: string;
|
||||
description: string;
|
||||
code: string;
|
||||
}
|
||||
|
||||
const CODE_SNIPPETS: CodeSnippet[] = [
|
||||
{
|
||||
id: 'displacement',
|
||||
name: 'Max Displacement',
|
||||
category: 'Displacement',
|
||||
description: 'Extract maximum displacement magnitude from results',
|
||||
code: `"""Extract maximum displacement magnitude"""
|
||||
|
||||
from pyNastran.op2.op2 import OP2
|
||||
import numpy as np
|
||||
|
||||
def extract(op2_path: str, fem_path: str, params: dict, subcase_id: int = 1) -> dict:
|
||||
op2 = OP2()
|
||||
op2.read_op2(op2_path)
|
||||
|
||||
if subcase_id in op2.displacements:
|
||||
disp = op2.displacements[subcase_id]
|
||||
# Displacement data: [time, node, component] where component 1-3 are x,y,z
|
||||
magnitudes = np.sqrt(np.sum(disp.data[0, :, 1:4]**2, axis=1))
|
||||
max_disp = float(np.max(magnitudes))
|
||||
else:
|
||||
max_disp = 0.0
|
||||
|
||||
return {'max_displacement': max_disp}
|
||||
`,
|
||||
},
|
||||
{
|
||||
id: 'stress_vonmises',
|
||||
name: 'Von Mises Stress',
|
||||
category: 'Stress',
|
||||
description: 'Extract maximum von Mises stress from shell elements',
|
||||
code: `"""Extract maximum von Mises stress from shell elements"""
|
||||
|
||||
from pyNastran.op2.op2 import OP2
|
||||
import numpy as np
|
||||
|
||||
def extract(op2_path: str, fem_path: str, params: dict, subcase_id: int = 1) -> dict:
|
||||
op2 = OP2()
|
||||
op2.read_op2(op2_path)
|
||||
|
||||
max_stress = 0.0
|
||||
|
||||
# Check CQUAD4 elements
|
||||
if subcase_id in op2.cquad4_stress:
|
||||
stress = op2.cquad4_stress[subcase_id]
|
||||
# Von Mises is typically in the last column
|
||||
vm_stress = stress.data[0, :, -1] # [time, element, component]
|
||||
max_stress = max(max_stress, float(np.max(np.abs(vm_stress))))
|
||||
|
||||
# Check CTRIA3 elements
|
||||
if subcase_id in op2.ctria3_stress:
|
||||
stress = op2.ctria3_stress[subcase_id]
|
||||
vm_stress = stress.data[0, :, -1]
|
||||
max_stress = max(max_stress, float(np.max(np.abs(vm_stress))))
|
||||
|
||||
return {'max_vonmises': max_stress}
|
||||
`,
|
||||
},
|
||||
{
|
||||
id: 'frequency',
|
||||
name: 'Natural Frequency',
|
||||
category: 'Modal',
|
||||
description: 'Extract first natural frequency from modal analysis',
|
||||
code: `"""Extract natural frequencies from modal analysis"""
|
||||
|
||||
from pyNastran.op2.op2 import OP2
|
||||
|
||||
def extract(op2_path: str, fem_path: str, params: dict, subcase_id: int = 1) -> dict:
|
||||
op2 = OP2()
|
||||
op2.read_op2(op2_path)
|
||||
|
||||
freq_1 = 0.0
|
||||
freq_2 = 0.0
|
||||
freq_3 = 0.0
|
||||
|
||||
if subcase_id in op2.eigenvalues:
|
||||
eig = op2.eigenvalues[subcase_id]
|
||||
freqs = eig.radians / (2 * 3.14159) # Convert to Hz
|
||||
if len(freqs) >= 1:
|
||||
freq_1 = float(freqs[0])
|
||||
if len(freqs) >= 2:
|
||||
freq_2 = float(freqs[1])
|
||||
if len(freqs) >= 3:
|
||||
freq_3 = float(freqs[2])
|
||||
|
||||
return {
|
||||
'freq_1': freq_1,
|
||||
'freq_2': freq_2,
|
||||
'freq_3': freq_3,
|
||||
}
|
||||
`,
|
||||
},
|
||||
{
|
||||
id: 'mass_grid',
|
||||
name: 'Grid Point Mass',
|
||||
category: 'Mass',
|
||||
description: 'Extract total mass from grid point weight generator',
|
||||
code: `"""Extract mass from grid point weight generator"""
|
||||
|
||||
from pyNastran.op2.op2 import OP2
|
||||
|
||||
def extract(op2_path: str, fem_path: str, params: dict, subcase_id: int = 1) -> dict:
|
||||
op2 = OP2()
|
||||
op2.read_op2(op2_path)
|
||||
|
||||
total_mass = 0.0
|
||||
|
||||
if hasattr(op2, 'grid_point_weight') and op2.grid_point_weight:
|
||||
gpw = op2.grid_point_weight
|
||||
# Mass is typically M[0,0] in the mass matrix
|
||||
if hasattr(gpw, 'mass') and len(gpw.mass) > 0:
|
||||
total_mass = float(gpw.mass[0])
|
||||
|
||||
return {'total_mass': total_mass}
|
||||
`,
|
||||
},
|
||||
{
|
||||
id: 'strain_energy',
|
||||
name: 'Strain Energy',
|
||||
category: 'Energy',
|
||||
description: 'Extract total strain energy from elements',
|
||||
code: `"""Extract strain energy from elements"""
|
||||
|
||||
from pyNastran.op2.op2 import OP2
|
||||
import numpy as np
|
||||
|
||||
def extract(op2_path: str, fem_path: str, params: dict, subcase_id: int = 1) -> dict:
|
||||
op2 = OP2()
|
||||
op2.read_op2(op2_path)
|
||||
|
||||
total_energy = 0.0
|
||||
|
||||
# Sum strain energy from all element types
|
||||
for key in dir(op2):
|
||||
if 'strain_energy' in key.lower():
|
||||
result = getattr(op2, key)
|
||||
if isinstance(result, dict) and subcase_id in result:
|
||||
se = result[subcase_id]
|
||||
if hasattr(se, 'data'):
|
||||
total_energy += float(np.sum(se.data))
|
||||
|
||||
return {'strain_energy': total_energy}
|
||||
`,
|
||||
},
|
||||
{
|
||||
id: 'reaction_force',
|
||||
name: 'Reaction Forces',
|
||||
category: 'Force',
|
||||
description: 'Extract reaction forces at constrained nodes',
|
||||
code: `"""Extract reaction forces at single point constraints"""
|
||||
|
||||
from pyNastran.op2.op2 import OP2
|
||||
import numpy as np
|
||||
|
||||
def extract(op2_path: str, fem_path: str, params: dict, subcase_id: int = 1) -> dict:
|
||||
op2 = OP2()
|
||||
op2.read_op2(op2_path)
|
||||
|
||||
max_reaction = 0.0
|
||||
total_reaction_z = 0.0
|
||||
|
||||
if subcase_id in op2.spc_forces:
|
||||
spc = op2.spc_forces[subcase_id]
|
||||
# SPC forces: [time, node, component] where 1-3 are Fx,Fy,Fz
|
||||
forces = spc.data[0, :, 1:4] # Get Fx, Fy, Fz
|
||||
magnitudes = np.sqrt(np.sum(forces**2, axis=1))
|
||||
max_reaction = float(np.max(magnitudes))
|
||||
total_reaction_z = float(np.sum(forces[:, 2])) # Sum of Fz
|
||||
|
||||
return {
|
||||
'max_reaction': max_reaction,
|
||||
'total_reaction_z': total_reaction_z,
|
||||
}
|
||||
`,
|
||||
},
|
||||
];
|
||||
|
||||
export function CodeEditorPanel({
|
||||
initialCode = DEFAULT_EXTRACTOR_TEMPLATE,
|
||||
onChange,
|
||||
onRequestGeneration,
|
||||
onRequestStreamingGeneration,
|
||||
readOnly = false,
|
||||
extractorName = 'custom_extractor',
|
||||
outputs = [],
|
||||
height = 400,
|
||||
showHeader = true,
|
||||
onRun,
|
||||
onTest,
|
||||
onClose,
|
||||
studyId,
|
||||
}: CodeEditorPanelProps) {
|
||||
const [code, setCode] = useState(initialCode);
|
||||
const [streamingCode, setStreamingCode] = useState(''); // Partial code during streaming
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
const [isTesting, setIsTesting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [runResult, setRunResult] = useState<{ success: boolean; outputs?: Record<string, unknown> } | null>(null);
|
||||
const [testResult, setTestResult] = useState<{ success: boolean; outputs?: Record<string, number>; execution_time_ms?: number } | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [showPromptInput, setShowPromptInput] = useState(false);
|
||||
const [generationPrompt, setGenerationPrompt] = useState('');
|
||||
const [showOutputs, setShowOutputs] = useState(true);
|
||||
const [showSnippets, setShowSnippets] = useState(false);
|
||||
|
||||
const editorRef = useRef<EditorInstance | null>(null);
|
||||
const monacoRef = useRef<Monaco | null>(null);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
// Cleanup abort controller on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
abortControllerRef.current?.abort();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Handle editor mount
|
||||
const handleEditorMount: OnMount = (editor, monaco) => {
|
||||
editorRef.current = editor;
|
||||
monacoRef.current = monaco;
|
||||
|
||||
// Configure Python language
|
||||
monaco.languages.registerCompletionItemProvider('python', {
|
||||
provideCompletionItems: (model: Parameters<typeof monaco.editor.createModel>[0], position: { lineNumber: number; column: number }) => {
|
||||
const word = model.getWordUntilPosition(position);
|
||||
const range = {
|
||||
startLineNumber: position.lineNumber,
|
||||
endLineNumber: position.lineNumber,
|
||||
startColumn: word.startColumn,
|
||||
endColumn: word.endColumn,
|
||||
};
|
||||
|
||||
const suggestions = [
|
||||
{
|
||||
label: 'op2.read_op2',
|
||||
kind: monaco.languages.CompletionItemKind.Method,
|
||||
insertText: 'op2.read_op2(op2_path)',
|
||||
documentation: 'Read OP2 results file',
|
||||
range,
|
||||
},
|
||||
{
|
||||
label: 'op2.displacements',
|
||||
kind: monaco.languages.CompletionItemKind.Property,
|
||||
insertText: 'op2.displacements[subcase_id]',
|
||||
documentation: 'Access displacement results for a subcase',
|
||||
range,
|
||||
},
|
||||
{
|
||||
label: 'op2.eigenvectors',
|
||||
kind: monaco.languages.CompletionItemKind.Property,
|
||||
insertText: 'op2.eigenvectors[subcase_id]',
|
||||
documentation: 'Access eigenvector results for modal analysis',
|
||||
range,
|
||||
},
|
||||
{
|
||||
label: 'np.max',
|
||||
kind: monaco.languages.CompletionItemKind.Function,
|
||||
insertText: 'np.max(${1:array})',
|
||||
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
|
||||
documentation: 'Get maximum value from array',
|
||||
range,
|
||||
},
|
||||
{
|
||||
label: 'np.sqrt',
|
||||
kind: monaco.languages.CompletionItemKind.Function,
|
||||
insertText: 'np.sqrt(${1:array})',
|
||||
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
|
||||
documentation: 'Square root of array elements',
|
||||
range,
|
||||
},
|
||||
{
|
||||
label: 'extract_function',
|
||||
kind: monaco.languages.CompletionItemKind.Snippet,
|
||||
insertText: `def extract(op2_path: str, fem_path: str, params: dict, subcase_id: int = 1) -> dict:
|
||||
"""Extract physics from FEA results."""
|
||||
op2 = OP2()
|
||||
op2.read_op2(op2_path)
|
||||
|
||||
# Your extraction logic here
|
||||
|
||||
return {
|
||||
'\${1:output_name}': \${2:value},
|
||||
}`,
|
||||
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
|
||||
documentation: 'Insert a complete extract function template',
|
||||
range,
|
||||
},
|
||||
];
|
||||
|
||||
return { suggestions };
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Handle code change
|
||||
const handleCodeChange: OnChange = (value) => {
|
||||
const newCode = value || '';
|
||||
setCode(newCode);
|
||||
setError(null);
|
||||
setRunResult(null);
|
||||
onChange?.(newCode);
|
||||
};
|
||||
|
||||
// Copy code to clipboard
|
||||
const handleCopy = useCallback(() => {
|
||||
navigator.clipboard.writeText(code);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}, [code]);
|
||||
|
||||
// Request Claude generation (with streaming support)
|
||||
const handleGenerate = useCallback(async () => {
|
||||
if ((!onRequestGeneration && !onRequestStreamingGeneration) || !generationPrompt.trim()) return;
|
||||
|
||||
setIsGenerating(true);
|
||||
setError(null);
|
||||
setStreamingCode('');
|
||||
|
||||
// Prefer streaming if available
|
||||
if (onRequestStreamingGeneration) {
|
||||
abortControllerRef.current = onRequestStreamingGeneration(
|
||||
{
|
||||
prompt: generationPrompt,
|
||||
study_id: studyId,
|
||||
existing_code: code !== DEFAULT_EXTRACTOR_TEMPLATE ? code : undefined,
|
||||
output_names: outputs,
|
||||
},
|
||||
{
|
||||
onToken: (token) => {
|
||||
setStreamingCode(prev => prev + token);
|
||||
},
|
||||
onComplete: (generatedCode, _outputs) => {
|
||||
setCode(generatedCode);
|
||||
setStreamingCode('');
|
||||
onChange?.(generatedCode);
|
||||
setShowPromptInput(false);
|
||||
setGenerationPrompt('');
|
||||
setIsGenerating(false);
|
||||
},
|
||||
onError: (errorMsg) => {
|
||||
setError(errorMsg);
|
||||
setStreamingCode('');
|
||||
setIsGenerating(false);
|
||||
},
|
||||
}
|
||||
);
|
||||
} else if (onRequestGeneration) {
|
||||
// Fallback to non-streaming
|
||||
try {
|
||||
const generatedCode = await onRequestGeneration(generationPrompt);
|
||||
setCode(generatedCode);
|
||||
onChange?.(generatedCode);
|
||||
setShowPromptInput(false);
|
||||
setGenerationPrompt('');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Generation failed');
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
}
|
||||
}, [onRequestGeneration, onRequestStreamingGeneration, generationPrompt, onChange, code, outputs, studyId]);
|
||||
|
||||
// Cancel ongoing generation
|
||||
const handleCancelGeneration = useCallback(() => {
|
||||
abortControllerRef.current?.abort();
|
||||
abortControllerRef.current = null;
|
||||
setIsGenerating(false);
|
||||
setStreamingCode('');
|
||||
}, []);
|
||||
|
||||
// Run/validate code
|
||||
const handleRun = useCallback(async () => {
|
||||
if (!onRun) return;
|
||||
|
||||
setIsRunning(true);
|
||||
setError(null);
|
||||
setRunResult(null);
|
||||
|
||||
try {
|
||||
const result = await onRun(code);
|
||||
setRunResult(result);
|
||||
if (!result.success && result.error) {
|
||||
setError(result.error);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Validation failed');
|
||||
} finally {
|
||||
setIsRunning(false);
|
||||
}
|
||||
}, [code, onRun]);
|
||||
|
||||
// Test code against real OP2 file
|
||||
const handleTest = useCallback(async () => {
|
||||
if (!onTest) return;
|
||||
|
||||
setIsTesting(true);
|
||||
setError(null);
|
||||
setTestResult(null);
|
||||
|
||||
try {
|
||||
const result = await onTest(code);
|
||||
setTestResult(result);
|
||||
if (!result.success && result.error) {
|
||||
setError(result.error);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Test failed');
|
||||
} finally {
|
||||
setIsTesting(false);
|
||||
}
|
||||
}, [code, onTest]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-dark-850 border-l border-dark-700">
|
||||
{/* Header */}
|
||||
{showHeader && (
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b border-dark-700">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileCode size={16} className="text-emerald-400" />
|
||||
<span className="font-medium text-white text-sm">{extractorName}</span>
|
||||
<span className="text-xs text-dark-500">.py</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Snippets Button */}
|
||||
<button
|
||||
onClick={() => setShowSnippets(!showSnippets)}
|
||||
className={`p-1.5 rounded transition-colors ${showSnippets ? 'text-amber-400 bg-amber-500/20' : 'text-dark-400 hover:text-amber-400 hover:bg-amber-500/10'}`}
|
||||
title="Code Snippets"
|
||||
>
|
||||
<BookOpen size={16} />
|
||||
</button>
|
||||
|
||||
{/* Claude Generate Button */}
|
||||
{(onRequestGeneration || onRequestStreamingGeneration) && (
|
||||
<button
|
||||
onClick={() => setShowPromptInput(!showPromptInput)}
|
||||
className={`p-1.5 rounded transition-colors ${showPromptInput ? 'text-violet-400 bg-violet-500/20' : 'text-violet-400 hover:bg-violet-500/20'}`}
|
||||
title="Generate with Claude"
|
||||
>
|
||||
<Sparkles size={16} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Copy Button */}
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="p-1.5 rounded text-dark-400 hover:text-white hover:bg-dark-700 transition-colors"
|
||||
title="Copy code"
|
||||
>
|
||||
{copied ? <Check size={16} className="text-emerald-400" /> : <Copy size={16} />}
|
||||
</button>
|
||||
|
||||
{/* Run Button */}
|
||||
{onRun && (
|
||||
<button
|
||||
onClick={handleRun}
|
||||
disabled={isRunning || isTesting}
|
||||
className="p-1.5 rounded text-emerald-400 hover:bg-emerald-500/20 transition-colors disabled:opacity-50"
|
||||
title="Validate code syntax"
|
||||
>
|
||||
{isRunning ? (
|
||||
<RefreshCw size={16} className="animate-spin" />
|
||||
) : (
|
||||
<Play size={16} />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Test Button - Live Preview */}
|
||||
{onTest && (
|
||||
<button
|
||||
onClick={handleTest}
|
||||
disabled={isRunning || isTesting}
|
||||
className="p-1.5 rounded text-cyan-400 hover:bg-cyan-500/20 transition-colors disabled:opacity-50"
|
||||
title="Test against real OP2 file"
|
||||
>
|
||||
{isTesting ? (
|
||||
<RefreshCw size={16} className="animate-spin" />
|
||||
) : (
|
||||
<FlaskConical size={16} />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Close Button */}
|
||||
{onClose && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded text-dark-400 hover:text-white hover:bg-dark-700 transition-colors"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Claude Prompt Input */}
|
||||
{showPromptInput && (
|
||||
<div className="px-4 py-3 border-b border-dark-700 bg-violet-500/5">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Wand2 size={14} className="text-violet-400" />
|
||||
<span className="text-xs text-violet-400 font-medium">Generate with Claude</span>
|
||||
</div>
|
||||
<textarea
|
||||
value={generationPrompt}
|
||||
onChange={(e) => setGenerationPrompt(e.target.value)}
|
||||
placeholder="Describe what you want to extract... e.g., 'Extract maximum von Mises stress and total mass from the model'"
|
||||
className="w-full px-3 py-2 bg-dark-800 border border-dark-600 rounded-lg text-sm text-white placeholder-dark-500 resize-none focus:outline-none focus:border-violet-500"
|
||||
rows={2}
|
||||
/>
|
||||
<div className="flex justify-end gap-2 mt-2">
|
||||
<button
|
||||
onClick={() => setShowPromptInput(false)}
|
||||
disabled={isGenerating}
|
||||
className="px-3 py-1.5 text-xs text-dark-400 hover:text-white transition-colors disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
{isGenerating ? (
|
||||
<button
|
||||
onClick={handleCancelGeneration}
|
||||
className="px-3 py-1.5 text-xs bg-red-600 text-white rounded hover:bg-red-500 transition-colors flex items-center gap-1.5"
|
||||
>
|
||||
<Square size={12} />
|
||||
Stop
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleGenerate}
|
||||
disabled={!generationPrompt.trim()}
|
||||
className="px-3 py-1.5 text-xs bg-violet-600 text-white rounded hover:bg-violet-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center gap-1.5"
|
||||
>
|
||||
<Sparkles size={12} />
|
||||
Generate
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Streaming Preview */}
|
||||
{isGenerating && streamingCode && (
|
||||
<div className="mt-3 p-3 bg-dark-900 rounded-lg border border-dark-600 max-h-48 overflow-auto">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<RefreshCw size={12} className="text-violet-400 animate-spin" />
|
||||
<span className="text-xs text-violet-400">Generating code...</span>
|
||||
</div>
|
||||
<pre className="text-xs text-dark-300 font-mono whitespace-pre-wrap">{streamingCode}</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Code Snippets Panel */}
|
||||
{showSnippets && (
|
||||
<div className="px-4 py-3 border-b border-dark-700 bg-amber-500/5 max-h-64 overflow-y-auto">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<BookOpen size={14} className="text-amber-400" />
|
||||
<span className="text-xs text-amber-400 font-medium">Code Snippets</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowSnippets(false)}
|
||||
className="p-1 rounded hover:bg-dark-700 text-dark-400 hover:text-white"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{CODE_SNIPPETS.map((snippet) => (
|
||||
<button
|
||||
key={snippet.id}
|
||||
onClick={() => {
|
||||
setCode(snippet.code);
|
||||
onChange?.(snippet.code);
|
||||
setShowSnippets(false);
|
||||
}}
|
||||
className="w-full text-left p-2 rounded-lg bg-dark-800 hover:bg-dark-700 border border-dark-600 hover:border-amber-500/30 transition-colors group"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-sm font-medium text-white group-hover:text-amber-400 transition-colors">
|
||||
{snippet.name}
|
||||
</span>
|
||||
<span className="text-xs text-dark-500 bg-dark-700 px-1.5 py-0.5 rounded">
|
||||
{snippet.category}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-dark-400">{snippet.description}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<div className="px-4 py-2 bg-red-500/10 border-b border-red-500/30 flex items-center gap-2">
|
||||
<AlertCircle size={14} className="text-red-400 flex-shrink-0" />
|
||||
<span className="text-xs text-red-400 font-mono">{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Monaco Editor */}
|
||||
<div className="flex-1 min-h-0">
|
||||
<Editor
|
||||
height={height}
|
||||
language="python"
|
||||
theme="vs-dark"
|
||||
value={code}
|
||||
onChange={handleCodeChange}
|
||||
onMount={handleEditorMount}
|
||||
options={{
|
||||
readOnly,
|
||||
minimap: { enabled: false },
|
||||
fontSize: 13,
|
||||
lineNumbers: 'on',
|
||||
scrollBeyondLastLine: false,
|
||||
wordWrap: 'on',
|
||||
automaticLayout: true,
|
||||
tabSize: 4,
|
||||
insertSpaces: true,
|
||||
padding: { top: 8, bottom: 8 },
|
||||
scrollbar: {
|
||||
vertical: 'auto',
|
||||
horizontal: 'auto',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Test Results Preview */}
|
||||
{testResult && testResult.success && testResult.outputs && (
|
||||
<div className="border-t border-dark-700 bg-cyan-500/5">
|
||||
<div className="px-4 py-2 flex items-center gap-2 text-xs">
|
||||
<FlaskConical size={12} className="text-cyan-400" />
|
||||
<span className="text-cyan-400 font-medium">Live Test Results</span>
|
||||
{testResult.execution_time_ms && (
|
||||
<span className="ml-auto text-dark-500">
|
||||
{testResult.execution_time_ms.toFixed(0)}ms
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-4 pb-3 space-y-1">
|
||||
{Object.entries(testResult.outputs).map(([key, value]) => (
|
||||
<div
|
||||
key={key}
|
||||
className="flex items-center justify-between px-2 py-1 bg-dark-800 rounded text-xs"
|
||||
>
|
||||
<span className="text-cyan-400 font-mono">{key}</span>
|
||||
<span className="text-white font-medium">{typeof value === 'number' ? value.toFixed(6) : String(value)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Outputs Preview */}
|
||||
{(outputs.length > 0 || runResult?.outputs) && (
|
||||
<div className="border-t border-dark-700">
|
||||
<button
|
||||
onClick={() => setShowOutputs(!showOutputs)}
|
||||
className="w-full px-4 py-2 flex items-center gap-2 text-xs text-dark-400 hover:text-white hover:bg-dark-800/50 transition-colors"
|
||||
>
|
||||
{showOutputs ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
||||
<span>Expected Outputs</span>
|
||||
<span className="ml-auto text-dark-500">
|
||||
{runResult?.outputs
|
||||
? Object.keys(runResult.outputs).length
|
||||
: outputs.length}
|
||||
</span>
|
||||
</button>
|
||||
{showOutputs && (
|
||||
<div className="px-4 pb-3 space-y-1">
|
||||
{runResult?.outputs ? (
|
||||
Object.entries(runResult.outputs).map(([key, value]) => (
|
||||
<div
|
||||
key={key}
|
||||
className="flex items-center justify-between px-2 py-1 bg-dark-800 rounded text-xs"
|
||||
>
|
||||
<span className="text-emerald-400 font-mono">{key}</span>
|
||||
<span className="text-dark-300">{String(value)}</span>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
outputs.map((output) => (
|
||||
<div
|
||||
key={output}
|
||||
className="flex items-center px-2 py-1 bg-dark-800 rounded text-xs"
|
||||
>
|
||||
<span className="text-dark-400 font-mono">{output}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CodeEditorPanel;
|
||||
@@ -0,0 +1,360 @@
|
||||
/**
|
||||
* CustomExtractorPanel - Panel for editing custom Python extractors
|
||||
*
|
||||
* Provides a code editor for writing custom extraction functions,
|
||||
* output definitions, and validation.
|
||||
*
|
||||
* P3.12: Custom extractor UI component
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { X, Play, AlertCircle, CheckCircle, Plus, Trash2, HelpCircle } from 'lucide-react';
|
||||
|
||||
interface CustomExtractorOutput {
|
||||
name: string;
|
||||
units?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface CustomExtractorPanelProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
initialName?: string;
|
||||
initialFunctionName?: string;
|
||||
initialSource?: string;
|
||||
initialOutputs?: CustomExtractorOutput[];
|
||||
initialDependencies?: string[];
|
||||
onSave: (data: {
|
||||
name: string;
|
||||
functionName: string;
|
||||
source: string;
|
||||
outputs: CustomExtractorOutput[];
|
||||
dependencies: string[];
|
||||
}) => void;
|
||||
}
|
||||
|
||||
// Common styling classes
|
||||
const inputClass =
|
||||
'w-full px-3 py-2 bg-dark-800 border border-dark-600 text-white placeholder-dark-400 rounded-lg focus:border-primary-500 focus:outline-none transition-colors';
|
||||
const labelClass = 'block text-sm font-medium text-dark-300 mb-1';
|
||||
|
||||
// Default extractor template
|
||||
const DEFAULT_SOURCE = `def extract(op2_path, bdf_path=None, params=None, working_dir=None):
|
||||
"""
|
||||
Custom extractor function.
|
||||
|
||||
Args:
|
||||
op2_path: Path to the OP2 results file
|
||||
bdf_path: Optional path to the BDF model file
|
||||
params: Dictionary of current design parameters
|
||||
working_dir: Path to the current trial directory
|
||||
|
||||
Returns:
|
||||
Dictionary of output_name -> value
|
||||
OR a single float value
|
||||
OR a list/tuple of values (mapped to outputs in order)
|
||||
"""
|
||||
import numpy as np
|
||||
from pyNastran.op2.op2 import OP2
|
||||
|
||||
# Load OP2 results
|
||||
op2 = OP2(op2_path, debug=False)
|
||||
|
||||
# Example: compute custom metric
|
||||
# ... your extraction logic here ...
|
||||
|
||||
result = 0.0
|
||||
|
||||
return {"custom_output": result}
|
||||
`;
|
||||
|
||||
export function CustomExtractorPanel({
|
||||
isOpen,
|
||||
onClose,
|
||||
initialName = '',
|
||||
initialFunctionName = 'extract',
|
||||
initialSource = DEFAULT_SOURCE,
|
||||
initialOutputs = [{ name: 'custom_output', units: '' }],
|
||||
initialDependencies = [],
|
||||
onSave,
|
||||
}: CustomExtractorPanelProps) {
|
||||
const [name, setName] = useState(initialName);
|
||||
const [functionName, setFunctionName] = useState(initialFunctionName);
|
||||
const [source, setSource] = useState(initialSource);
|
||||
const [outputs, setOutputs] = useState<CustomExtractorOutput[]>(initialOutputs);
|
||||
const [dependencies] = useState<string[]>(initialDependencies);
|
||||
const [validation, setValidation] = useState<{
|
||||
valid: boolean;
|
||||
errors: string[];
|
||||
} | null>(null);
|
||||
const [isValidating, setIsValidating] = useState(false);
|
||||
const [showHelp, setShowHelp] = useState(false);
|
||||
|
||||
// Add a new output
|
||||
const addOutput = useCallback(() => {
|
||||
setOutputs((prev) => [...prev, { name: '', units: '' }]);
|
||||
}, []);
|
||||
|
||||
// Remove an output
|
||||
const removeOutput = useCallback((index: number) => {
|
||||
setOutputs((prev) => prev.filter((_, i) => i !== index));
|
||||
}, []);
|
||||
|
||||
// Update an output
|
||||
const updateOutput = useCallback(
|
||||
(index: number, field: keyof CustomExtractorOutput, value: string) => {
|
||||
setOutputs((prev) =>
|
||||
prev.map((out, i) => (i === index ? { ...out, [field]: value } : out))
|
||||
);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// Validate the code
|
||||
const validateCode = useCallback(async () => {
|
||||
setIsValidating(true);
|
||||
setValidation(null);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/spec/validate-extractor', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
function_name: functionName,
|
||||
source: source,
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
setValidation({
|
||||
valid: result.valid,
|
||||
errors: result.errors || [],
|
||||
});
|
||||
} catch (error) {
|
||||
setValidation({
|
||||
valid: false,
|
||||
errors: ['Failed to validate: ' + (error instanceof Error ? error.message : 'Unknown error')],
|
||||
});
|
||||
} finally {
|
||||
setIsValidating(false);
|
||||
}
|
||||
}, [functionName, source]);
|
||||
|
||||
// Handle save
|
||||
const handleSave = useCallback(() => {
|
||||
// Filter out empty outputs
|
||||
const validOutputs = outputs.filter((o) => o.name.trim());
|
||||
|
||||
if (!name.trim()) {
|
||||
setValidation({ valid: false, errors: ['Name is required'] });
|
||||
return;
|
||||
}
|
||||
|
||||
if (validOutputs.length === 0) {
|
||||
setValidation({ valid: false, errors: ['At least one output is required'] });
|
||||
return;
|
||||
}
|
||||
|
||||
onSave({
|
||||
name: name.trim(),
|
||||
functionName: functionName.trim() || 'extract',
|
||||
source,
|
||||
outputs: validOutputs,
|
||||
dependencies: dependencies.filter((d) => d.trim()),
|
||||
});
|
||||
onClose();
|
||||
}, [name, functionName, source, outputs, dependencies, onSave, onClose]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-dark-850 rounded-xl shadow-2xl w-[900px] max-h-[90vh] flex flex-col border border-dark-700">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-dark-700">
|
||||
<h2 className="text-lg font-semibold text-white">Custom Extractor</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setShowHelp(!showHelp)}
|
||||
className="p-2 text-dark-400 hover:text-white hover:bg-dark-700 rounded-lg transition-colors"
|
||||
title="Show help"
|
||||
>
|
||||
<HelpCircle size={20} />
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 text-dark-400 hover:text-white hover:bg-dark-700 rounded-lg transition-colors"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-auto p-6">
|
||||
{/* Help Section */}
|
||||
{showHelp && (
|
||||
<div className="mb-4 p-4 bg-primary-900/20 border border-primary-700 rounded-lg">
|
||||
<h3 className="text-sm font-semibold text-primary-400 mb-2">How Custom Extractors Work</h3>
|
||||
<ul className="text-sm text-dark-300 space-y-1">
|
||||
<li>• Your function receives the path to OP2 results and optional BDF/params</li>
|
||||
<li>• Use pyNastran, numpy, scipy for data extraction and analysis</li>
|
||||
<li>• Return a dictionary mapping output names to numeric values</li>
|
||||
<li>• Outputs can be used as objectives or constraints in optimization</li>
|
||||
<li>• Code runs in a sandboxed environment (no file I/O beyond OP2/BDF)</li>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
{/* Left Column - Basic Info & Outputs */}
|
||||
<div className="space-y-4">
|
||||
{/* Name */}
|
||||
<div>
|
||||
<label className={labelClass}>Extractor Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="My Custom Extractor"
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Function Name */}
|
||||
<div>
|
||||
<label className={labelClass}>Function Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={functionName}
|
||||
onChange={(e) => setFunctionName(e.target.value)}
|
||||
placeholder="extract"
|
||||
className={`${inputClass} font-mono`}
|
||||
/>
|
||||
<p className="text-xs text-dark-500 mt-1">
|
||||
Name of the Python function in your code
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Outputs */}
|
||||
<div>
|
||||
<label className={labelClass}>Outputs</label>
|
||||
<div className="space-y-2">
|
||||
{outputs.map((output, index) => (
|
||||
<div key={index} className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={output.name}
|
||||
onChange={(e) => updateOutput(index, 'name', e.target.value)}
|
||||
placeholder="output_name"
|
||||
className={`${inputClass} font-mono flex-1`}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={output.units || ''}
|
||||
onChange={(e) => updateOutput(index, 'units', e.target.value)}
|
||||
placeholder="units"
|
||||
className={`${inputClass} w-24`}
|
||||
/>
|
||||
<button
|
||||
onClick={() => removeOutput(index)}
|
||||
className="p-2 text-red-400 hover:text-red-300 hover:bg-red-900/20 rounded-lg transition-colors"
|
||||
disabled={outputs.length === 1}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
onClick={addOutput}
|
||||
className="flex items-center gap-1 text-sm text-primary-400 hover:text-primary-300 transition-colors"
|
||||
>
|
||||
<Plus size={14} />
|
||||
Add Output
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Validation Status */}
|
||||
{validation && (
|
||||
<div
|
||||
className={`p-3 rounded-lg border ${
|
||||
validation.valid
|
||||
? 'bg-green-900/20 border-green-700'
|
||||
: 'bg-red-900/20 border-red-700'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{validation.valid ? (
|
||||
<CheckCircle size={16} className="text-green-400" />
|
||||
) : (
|
||||
<AlertCircle size={16} className="text-red-400" />
|
||||
)}
|
||||
<span
|
||||
className={`text-sm font-medium ${
|
||||
validation.valid ? 'text-green-400' : 'text-red-400'
|
||||
}`}
|
||||
>
|
||||
{validation.valid ? 'Code is valid' : 'Validation failed'}
|
||||
</span>
|
||||
</div>
|
||||
{validation.errors.length > 0 && (
|
||||
<ul className="mt-2 text-sm text-red-300 space-y-1">
|
||||
{validation.errors.map((err, i) => (
|
||||
<li key={i}>• {err}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right Column - Code Editor */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className={labelClass}>Python Code</label>
|
||||
<button
|
||||
onClick={validateCode}
|
||||
disabled={isValidating}
|
||||
className="flex items-center gap-1 px-3 py-1 bg-primary-600 hover:bg-primary-500
|
||||
text-white text-sm rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
<Play size={14} />
|
||||
{isValidating ? 'Validating...' : 'Validate'}
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
value={source}
|
||||
onChange={(e) => {
|
||||
setSource(e.target.value);
|
||||
setValidation(null);
|
||||
}}
|
||||
className={`${inputClass} h-[400px] font-mono text-sm resize-none`}
|
||||
spellCheck={false}
|
||||
/>
|
||||
<p className="text-xs text-dark-500">
|
||||
Available modules: numpy, scipy, pyNastran, math, statistics
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-dark-700">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-dark-300 hover:text-white hover:bg-dark-700 rounded-lg transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="px-4 py-2 bg-primary-600 hover:bg-primary-500 text-white rounded-lg transition-colors"
|
||||
>
|
||||
Save Extractor
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* ErrorPanel - Displays optimization errors with recovery options
|
||||
*
|
||||
* Shows errors that occurred during optimization with:
|
||||
* - Error classification (NX crash, solver failure, etc.)
|
||||
* - Recovery suggestions
|
||||
* - Ability to dismiss individual errors
|
||||
* - Support for multiple simultaneous errors
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import {
|
||||
X,
|
||||
AlertTriangle,
|
||||
AlertOctagon,
|
||||
RefreshCw,
|
||||
Minimize2,
|
||||
Maximize2,
|
||||
Trash2,
|
||||
Bug,
|
||||
Cpu,
|
||||
FileWarning,
|
||||
Settings,
|
||||
Server,
|
||||
} from 'lucide-react';
|
||||
import { useErrorPanel, usePanelStore, OptimizationError } from '../../../hooks/usePanelStore';
|
||||
|
||||
interface ErrorPanelProps {
|
||||
onClose: () => void;
|
||||
onRetry?: (trial?: number) => void;
|
||||
onSkipTrial?: (trial: number) => void;
|
||||
}
|
||||
|
||||
export function ErrorPanel({ onClose, onRetry, onSkipTrial }: ErrorPanelProps) {
|
||||
const panel = useErrorPanel();
|
||||
const { minimizePanel, dismissError, clearErrors } = usePanelStore();
|
||||
|
||||
const sortedErrors = useMemo(() => {
|
||||
return [...panel.errors].sort((a, b) => b.timestamp - a.timestamp);
|
||||
}, [panel.errors]);
|
||||
|
||||
if (!panel.open || panel.errors.length === 0) return null;
|
||||
|
||||
// Minimized view
|
||||
if (panel.minimized) {
|
||||
return (
|
||||
<div
|
||||
className="bg-dark-850 border border-red-500/50 rounded-lg shadow-xl flex items-center gap-2 px-3 py-2 cursor-pointer hover:bg-dark-800 transition-colors"
|
||||
onClick={() => minimizePanel('error')}
|
||||
>
|
||||
<AlertOctagon size={16} className="text-red-400" />
|
||||
<span className="text-sm text-white font-medium">
|
||||
{panel.errors.length} Error{panel.errors.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
<Maximize2 size={14} className="text-dark-400" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-dark-850 border border-red-500/30 rounded-xl w-[420px] max-h-[500px] flex flex-col shadow-xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-dark-700 bg-red-500/5">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertOctagon size={18} className="text-red-400" />
|
||||
<span className="font-medium text-white">
|
||||
Optimization Errors ({panel.errors.length})
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{panel.errors.length > 1 && (
|
||||
<button
|
||||
onClick={clearErrors}
|
||||
className="p-1.5 text-dark-400 hover:text-red-400 hover:bg-red-500/10 rounded transition-colors"
|
||||
title="Clear all errors"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => minimizePanel('error')}
|
||||
className="p-1.5 text-dark-400 hover:text-white hover:bg-dark-700 rounded transition-colors"
|
||||
title="Minimize"
|
||||
>
|
||||
<Minimize2 size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 text-dark-400 hover:text-white hover:bg-dark-700 rounded transition-colors"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-3 space-y-3">
|
||||
{sortedErrors.map((error) => (
|
||||
<ErrorItem
|
||||
key={error.timestamp}
|
||||
error={error}
|
||||
onDismiss={() => dismissError(error.timestamp)}
|
||||
onRetry={onRetry}
|
||||
onSkipTrial={onSkipTrial}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Error Item Component
|
||||
// ============================================================================
|
||||
|
||||
interface ErrorItemProps {
|
||||
error: OptimizationError;
|
||||
onDismiss: () => void;
|
||||
onRetry?: (trial?: number) => void;
|
||||
onSkipTrial?: (trial: number) => void;
|
||||
}
|
||||
|
||||
function ErrorItem({ error, onDismiss, onRetry, onSkipTrial }: ErrorItemProps) {
|
||||
const icon = getErrorIcon(error.type);
|
||||
const typeLabel = getErrorTypeLabel(error.type);
|
||||
const timeAgo = getTimeAgo(error.timestamp);
|
||||
|
||||
return (
|
||||
<div className="bg-dark-800 rounded-lg border border-dark-700 overflow-hidden">
|
||||
{/* Error header */}
|
||||
<div className="flex items-start gap-3 p-3">
|
||||
<div className="p-2 bg-red-500/10 rounded-lg flex-shrink-0">
|
||||
{icon}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-xs font-medium text-red-400 uppercase tracking-wide">
|
||||
{typeLabel}
|
||||
</span>
|
||||
{error.trial !== undefined && (
|
||||
<span className="text-xs text-dark-500">
|
||||
Trial #{error.trial}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs text-dark-600 ml-auto">
|
||||
{timeAgo}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-white">{error.message}</p>
|
||||
{error.details && (
|
||||
<p className="text-xs text-dark-400 mt-1 font-mono bg-dark-900 p-2 rounded mt-2 max-h-20 overflow-y-auto">
|
||||
{error.details}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={onDismiss}
|
||||
className="p-1 text-dark-500 hover:text-white hover:bg-dark-700 rounded transition-colors flex-shrink-0"
|
||||
title="Dismiss"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Suggestions */}
|
||||
{error.suggestions.length > 0 && (
|
||||
<div className="px-3 pb-3">
|
||||
<p className="text-xs text-dark-500 mb-1.5">Suggestions:</p>
|
||||
<ul className="text-xs text-dark-300 space-y-1">
|
||||
{error.suggestions.map((suggestion, idx) => (
|
||||
<li key={idx} className="flex items-start gap-1.5">
|
||||
<span className="text-dark-500">-</span>
|
||||
<span>{suggestion}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
{error.recoverable && (
|
||||
<div className="flex items-center gap-2 px-3 pb-3">
|
||||
{onRetry && (
|
||||
<button
|
||||
onClick={() => onRetry(error.trial)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-primary-600 hover:bg-primary-500
|
||||
text-white text-xs font-medium rounded transition-colors"
|
||||
>
|
||||
<RefreshCw size={12} />
|
||||
Retry{error.trial !== undefined ? ' Trial' : ''}
|
||||
</button>
|
||||
)}
|
||||
{onSkipTrial && error.trial !== undefined && (
|
||||
<button
|
||||
onClick={() => onSkipTrial(error.trial!)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-dark-700 hover:bg-dark-600
|
||||
text-dark-200 text-xs font-medium rounded transition-colors"
|
||||
>
|
||||
Skip Trial
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Helper Functions
|
||||
// ============================================================================
|
||||
|
||||
function getErrorIcon(type: OptimizationError['type']) {
|
||||
switch (type) {
|
||||
case 'nx_crash':
|
||||
return <Cpu size={16} className="text-red-400" />;
|
||||
case 'solver_fail':
|
||||
return <AlertTriangle size={16} className="text-amber-400" />;
|
||||
case 'extractor_error':
|
||||
return <FileWarning size={16} className="text-orange-400" />;
|
||||
case 'config_error':
|
||||
return <Settings size={16} className="text-blue-400" />;
|
||||
case 'system_error':
|
||||
return <Server size={16} className="text-purple-400" />;
|
||||
default:
|
||||
return <Bug size={16} className="text-red-400" />;
|
||||
}
|
||||
}
|
||||
|
||||
function getErrorTypeLabel(type: OptimizationError['type']) {
|
||||
switch (type) {
|
||||
case 'nx_crash':
|
||||
return 'NX Crash';
|
||||
case 'solver_fail':
|
||||
return 'Solver Failure';
|
||||
case 'extractor_error':
|
||||
return 'Extractor Error';
|
||||
case 'config_error':
|
||||
return 'Configuration Error';
|
||||
case 'system_error':
|
||||
return 'System Error';
|
||||
default:
|
||||
return 'Unknown Error';
|
||||
}
|
||||
}
|
||||
|
||||
function getTimeAgo(timestamp: number): string {
|
||||
const seconds = Math.floor((Date.now() - timestamp) / 1000);
|
||||
|
||||
if (seconds < 60) return 'just now';
|
||||
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
|
||||
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
|
||||
return `${Math.floor(seconds / 86400)}d ago`;
|
||||
}
|
||||
|
||||
export default ErrorPanel;
|
||||
@@ -0,0 +1,310 @@
|
||||
/**
|
||||
* FileStructurePanel - Shows study file structure in the canvas sidebar
|
||||
*
|
||||
* Features:
|
||||
* - Tree view of study directory
|
||||
* - Highlights model files (.prt, .fem, .sim)
|
||||
* - Shows file dependencies
|
||||
* - One-click to set as model source
|
||||
* - Refresh button to reload
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Folder,
|
||||
FolderOpen,
|
||||
FileBox,
|
||||
ChevronRight,
|
||||
RefreshCw,
|
||||
Box,
|
||||
Cpu,
|
||||
FileCode,
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
Plus,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface FileNode {
|
||||
name: string;
|
||||
path: string;
|
||||
type: 'file' | 'directory';
|
||||
extension?: string;
|
||||
size?: number;
|
||||
children?: FileNode[];
|
||||
isModelFile?: boolean;
|
||||
isSelected?: boolean;
|
||||
}
|
||||
|
||||
interface FileStructurePanelProps {
|
||||
studyId: string | null;
|
||||
onModelSelect?: (filePath: string, fileType: string) => void;
|
||||
selectedModelPath?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// File type to icon mapping
|
||||
const FILE_ICONS: Record<string, { icon: typeof FileBox; color: string }> = {
|
||||
'.prt': { icon: Box, color: 'text-blue-400' },
|
||||
'.sim': { icon: Cpu, color: 'text-violet-400' },
|
||||
'.fem': { icon: FileCode, color: 'text-emerald-400' },
|
||||
'.afem': { icon: FileCode, color: 'text-emerald-400' },
|
||||
'.dat': { icon: FileBox, color: 'text-amber-400' },
|
||||
'.bdf': { icon: FileBox, color: 'text-amber-400' },
|
||||
'.op2': { icon: FileBox, color: 'text-rose-400' },
|
||||
'.f06': { icon: FileBox, color: 'text-dark-400' },
|
||||
};
|
||||
|
||||
const MODEL_EXTENSIONS = ['.prt', '.sim', '.fem', '.afem'];
|
||||
|
||||
export function FileStructurePanel({
|
||||
studyId,
|
||||
onModelSelect,
|
||||
selectedModelPath,
|
||||
className = '',
|
||||
}: FileStructurePanelProps) {
|
||||
const [files, setFiles] = useState<FileNode[]>([]);
|
||||
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(new Set());
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Load study file structure
|
||||
const loadFileStructure = useCallback(async () => {
|
||||
if (!studyId) {
|
||||
setFiles([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/files/structure/${encodeURIComponent(studyId)}`);
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
setError('Study not found');
|
||||
} else {
|
||||
throw new Error(`Failed to load: ${response.status}`);
|
||||
}
|
||||
setFiles([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Process the file tree to mark model files
|
||||
const processNode = (node: FileNode): FileNode => {
|
||||
if (node.type === 'directory' && node.children) {
|
||||
return {
|
||||
...node,
|
||||
children: node.children.map(processNode),
|
||||
};
|
||||
}
|
||||
|
||||
const ext = '.' + node.name.split('.').pop()?.toLowerCase();
|
||||
return {
|
||||
...node,
|
||||
extension: ext,
|
||||
isModelFile: MODEL_EXTENSIONS.includes(ext),
|
||||
isSelected: node.path === selectedModelPath,
|
||||
};
|
||||
};
|
||||
|
||||
const processedFiles = (data.files || []).map(processNode);
|
||||
setFiles(processedFiles);
|
||||
|
||||
// Auto-expand 1_setup and root directories
|
||||
const toExpand = new Set<string>();
|
||||
processedFiles.forEach((node: FileNode) => {
|
||||
if (node.type === 'directory') {
|
||||
toExpand.add(node.path);
|
||||
if (node.name === '1_setup' && node.children) {
|
||||
node.children.forEach((child: FileNode) => {
|
||||
if (child.type === 'directory') {
|
||||
toExpand.add(child.path);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
setExpandedPaths(toExpand);
|
||||
} catch (err) {
|
||||
console.error('Failed to load file structure:', err);
|
||||
setError('Failed to load files');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [studyId, selectedModelPath]);
|
||||
|
||||
useEffect(() => {
|
||||
loadFileStructure();
|
||||
}, [loadFileStructure]);
|
||||
|
||||
// Toggle directory expansion
|
||||
const toggleExpand = (path: string) => {
|
||||
setExpandedPaths((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(path)) {
|
||||
next.delete(path);
|
||||
} else {
|
||||
next.add(path);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// Handle file selection
|
||||
const handleFileClick = (node: FileNode) => {
|
||||
if (node.type === 'directory') {
|
||||
toggleExpand(node.path);
|
||||
} else if (node.isModelFile && onModelSelect) {
|
||||
onModelSelect(node.path, node.extension || '');
|
||||
}
|
||||
};
|
||||
|
||||
// Render a file/folder node
|
||||
const renderNode = (node: FileNode, depth: number = 0) => {
|
||||
const isExpanded = expandedPaths.has(node.path);
|
||||
const isDirectory = node.type === 'directory';
|
||||
const fileInfo = node.extension ? FILE_ICONS[node.extension] : null;
|
||||
const Icon = isDirectory
|
||||
? isExpanded
|
||||
? FolderOpen
|
||||
: Folder
|
||||
: fileInfo?.icon || FileBox;
|
||||
const iconColor = isDirectory
|
||||
? 'text-amber-400'
|
||||
: fileInfo?.color || 'text-dark-400';
|
||||
|
||||
const isSelected = node.path === selectedModelPath;
|
||||
|
||||
return (
|
||||
<div key={node.path}>
|
||||
<button
|
||||
onClick={() => handleFileClick(node)}
|
||||
className={`
|
||||
w-full flex items-center gap-2 px-2 py-1.5 text-left text-sm rounded-md
|
||||
transition-colors group
|
||||
${isSelected ? 'bg-primary-500/20 text-primary-400' : 'hover:bg-dark-700/50'}
|
||||
${node.isModelFile ? 'cursor-pointer' : isDirectory ? 'cursor-pointer' : 'cursor-default opacity-60'}
|
||||
`}
|
||||
style={{ paddingLeft: `${depth * 16 + 8}px` }}
|
||||
>
|
||||
{/* Expand/collapse chevron for directories */}
|
||||
{isDirectory ? (
|
||||
<ChevronRight
|
||||
size={14}
|
||||
className={`text-dark-500 transition-transform flex-shrink-0 ${
|
||||
isExpanded ? 'rotate-90' : ''
|
||||
}`}
|
||||
/>
|
||||
) : (
|
||||
<span className="w-3.5 flex-shrink-0" />
|
||||
)}
|
||||
|
||||
{/* Icon */}
|
||||
<Icon size={16} className={`${iconColor} flex-shrink-0`} />
|
||||
|
||||
{/* Name */}
|
||||
<span
|
||||
className={`flex-1 truncate ${
|
||||
isSelected ? 'text-primary-400 font-medium' : 'text-dark-200'
|
||||
}`}
|
||||
>
|
||||
{node.name}
|
||||
</span>
|
||||
|
||||
{/* Model file indicator */}
|
||||
{node.isModelFile && !isSelected && (
|
||||
<span title="Set as model">
|
||||
<Plus
|
||||
size={14}
|
||||
className="text-dark-500 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0"
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Selected indicator */}
|
||||
{isSelected && (
|
||||
<CheckCircle size={14} className="text-primary-400 flex-shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Children */}
|
||||
{isDirectory && isExpanded && node.children && (
|
||||
<div>
|
||||
{node.children.map((child) => renderNode(child, depth + 1))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// No study selected state
|
||||
if (!studyId) {
|
||||
return (
|
||||
<div className={`p-4 ${className}`}>
|
||||
<div className="text-center text-dark-400 text-sm">
|
||||
<Folder size={32} className="mx-auto mb-2 text-dark-500" />
|
||||
<p>No study selected</p>
|
||||
<p className="text-xs text-dark-500 mt-1">
|
||||
Load a study to see its files
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col h-full ${className}`}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b border-dark-700">
|
||||
<div className="flex items-center gap-2">
|
||||
<Folder size={16} className="text-amber-400" />
|
||||
<span className="text-sm font-medium text-white">Files</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={loadFileStructure}
|
||||
disabled={isLoading}
|
||||
className="p-1 rounded hover:bg-dark-700 text-dark-400 hover:text-white transition-colors"
|
||||
title="Refresh"
|
||||
>
|
||||
<RefreshCw size={14} className={isLoading ? 'animate-spin' : ''} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-2">
|
||||
{isLoading && files.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-20 text-dark-400 text-sm">
|
||||
<RefreshCw size={16} className="animate-spin mr-2" />
|
||||
Loading...
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex items-center justify-center h-20 text-red-400 text-sm gap-2">
|
||||
<AlertCircle size={16} />
|
||||
{error}
|
||||
</div>
|
||||
) : files.length === 0 ? (
|
||||
<div className="text-center text-dark-400 text-sm py-4">
|
||||
<p>No files found</p>
|
||||
<p className="text-xs text-dark-500 mt-1">
|
||||
Add model files to 1_setup/
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-0.5">
|
||||
{files.map((node) => renderNode(node))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer hint */}
|
||||
<div className="px-3 py-2 border-t border-dark-700 text-xs text-dark-500">
|
||||
Click a model file to select it
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FileStructurePanel;
|
||||
@@ -0,0 +1,485 @@
|
||||
/**
|
||||
* FloatingIntrospectionPanel - Persistent introspection panel using store
|
||||
*
|
||||
* This is a wrapper around the existing IntrospectionPanel that:
|
||||
* 1. Gets its state from usePanelStore instead of local state
|
||||
* 2. Persists data when the panel is closed and reopened
|
||||
* 3. Can be opened from anywhere without losing state
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
X,
|
||||
Search,
|
||||
RefreshCw,
|
||||
Plus,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Cpu,
|
||||
SlidersHorizontal,
|
||||
Scale,
|
||||
Minimize2,
|
||||
Maximize2,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
useIntrospectionPanel,
|
||||
usePanelStore,
|
||||
} from '../../../hooks/usePanelStore';
|
||||
import { useSpecStore } from '../../../hooks/useSpecStore';
|
||||
|
||||
interface FloatingIntrospectionPanelProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
// Reuse types from original IntrospectionPanel
|
||||
interface Expression {
|
||||
name: string;
|
||||
value: number;
|
||||
rhs?: string;
|
||||
min?: number;
|
||||
max?: number;
|
||||
unit?: string;
|
||||
units?: string;
|
||||
type: string;
|
||||
source?: string;
|
||||
}
|
||||
|
||||
interface ExpressionsResult {
|
||||
user: Expression[];
|
||||
internal: Expression[];
|
||||
total_count: number;
|
||||
user_count: number;
|
||||
}
|
||||
|
||||
interface IntrospectionResult {
|
||||
solver_type?: string;
|
||||
expressions?: ExpressionsResult;
|
||||
// Allow other properties from the API response
|
||||
file_deps?: unknown[];
|
||||
fea_results?: unknown[];
|
||||
fem_mesh?: unknown;
|
||||
sim_solutions?: unknown[];
|
||||
sim_bcs?: unknown[];
|
||||
mass_properties?: {
|
||||
total_mass?: number;
|
||||
center_of_gravity?: { x: number; y: number; z: number };
|
||||
[key: string]: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
interface ModelFileInfo {
|
||||
name: string;
|
||||
stem: string;
|
||||
type: string;
|
||||
description?: string;
|
||||
size_kb: number;
|
||||
has_cache: boolean;
|
||||
}
|
||||
|
||||
interface ModelFilesResponse {
|
||||
files: {
|
||||
sim: ModelFileInfo[];
|
||||
afm: ModelFileInfo[];
|
||||
fem: ModelFileInfo[];
|
||||
idealized: ModelFileInfo[];
|
||||
prt: ModelFileInfo[];
|
||||
};
|
||||
all_files: ModelFileInfo[];
|
||||
}
|
||||
|
||||
export function FloatingIntrospectionPanel({ onClose }: FloatingIntrospectionPanelProps) {
|
||||
const panel = useIntrospectionPanel();
|
||||
const {
|
||||
minimizePanel,
|
||||
updateIntrospectionResult,
|
||||
setIntrospectionLoading,
|
||||
setIntrospectionError,
|
||||
setIntrospectionFile,
|
||||
} = usePanelStore();
|
||||
const { addNode } = useSpecStore();
|
||||
|
||||
// Local UI state
|
||||
const [expandedSections, setExpandedSections] = useState<Set<string>>(
|
||||
new Set(['expressions', 'extractors', 'file_deps', 'fea_results', 'fem_mesh', 'sim_solutions', 'sim_bcs'])
|
||||
);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [modelFiles, setModelFiles] = useState<ModelFilesResponse | null>(null);
|
||||
const [isLoadingFiles, setIsLoadingFiles] = useState(false);
|
||||
|
||||
const data = panel.data;
|
||||
const result = data?.result as IntrospectionResult | undefined;
|
||||
const isLoading = data?.isLoading || false;
|
||||
const error = data?.error as string | null;
|
||||
|
||||
// Fetch available files when studyId changes
|
||||
const fetchAvailableFiles = useCallback(async () => {
|
||||
if (!data?.studyId) return;
|
||||
|
||||
setIsLoadingFiles(true);
|
||||
try {
|
||||
const res = await fetch(`/api/optimization/studies/${data.studyId}/nx/parts`);
|
||||
if (res.ok) {
|
||||
const filesData = await res.json();
|
||||
setModelFiles(filesData);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch model files:', e);
|
||||
} finally {
|
||||
setIsLoadingFiles(false);
|
||||
}
|
||||
}, [data?.studyId]);
|
||||
|
||||
// Run introspection
|
||||
const runIntrospection = useCallback(async (fileName?: string) => {
|
||||
if (!data?.filePath && !data?.studyId) return;
|
||||
|
||||
setIntrospectionLoading(true);
|
||||
setIntrospectionError(null);
|
||||
|
||||
try {
|
||||
let res;
|
||||
|
||||
if (data?.studyId) {
|
||||
const endpoint = fileName
|
||||
? `/api/optimization/studies/${data.studyId}/nx/introspect/${encodeURIComponent(fileName)}`
|
||||
: `/api/optimization/studies/${data.studyId}/nx/introspect`;
|
||||
res = await fetch(endpoint);
|
||||
} else {
|
||||
res = await fetch('/api/nx/introspect', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ file_path: data?.filePath }),
|
||||
});
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const errData = await res.json().catch(() => ({}));
|
||||
throw new Error(errData.detail || 'Introspection failed');
|
||||
}
|
||||
|
||||
const responseData = await res.json();
|
||||
updateIntrospectionResult(responseData.introspection || responseData);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : 'Failed to introspect model';
|
||||
setIntrospectionError(msg);
|
||||
console.error('Introspection error:', e);
|
||||
}
|
||||
}, [data?.filePath, data?.studyId, setIntrospectionLoading, setIntrospectionError, updateIntrospectionResult]);
|
||||
|
||||
// Fetch files list on mount
|
||||
useEffect(() => {
|
||||
fetchAvailableFiles();
|
||||
}, [fetchAvailableFiles]);
|
||||
|
||||
// Run introspection when panel opens or selected file changes
|
||||
useEffect(() => {
|
||||
if (panel.open && data && !result && !isLoading) {
|
||||
runIntrospection(data.selectedFile);
|
||||
}
|
||||
}, [panel.open, data?.selectedFile]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const newFile = e.target.value;
|
||||
setIntrospectionFile(newFile);
|
||||
runIntrospection(newFile);
|
||||
};
|
||||
|
||||
const toggleSection = (section: string) => {
|
||||
setExpandedSections((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(section)) next.delete(section);
|
||||
else next.add(section);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// Handle both array format (old) and object format (new API)
|
||||
const allExpressions: Expression[] = useMemo(() => {
|
||||
if (!result?.expressions) return [];
|
||||
|
||||
if (Array.isArray(result.expressions)) {
|
||||
return result.expressions as Expression[];
|
||||
}
|
||||
|
||||
const exprObj = result.expressions as ExpressionsResult;
|
||||
return [...(exprObj.user || []), ...(exprObj.internal || [])];
|
||||
}, [result?.expressions]);
|
||||
|
||||
const filteredExpressions = allExpressions.filter((e) =>
|
||||
e.name.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
const addExpressionAsDesignVar = (expr: Expression) => {
|
||||
const minValue = expr.min ?? expr.value * 0.5;
|
||||
const maxValue = expr.max ?? expr.value * 1.5;
|
||||
|
||||
addNode('designVar', {
|
||||
name: expr.name,
|
||||
expression_name: expr.name,
|
||||
type: 'continuous',
|
||||
bounds: { min: minValue, max: maxValue },
|
||||
baseline: expr.value,
|
||||
units: expr.unit || expr.units,
|
||||
enabled: true,
|
||||
});
|
||||
};
|
||||
|
||||
if (!panel.open) return null;
|
||||
|
||||
// Minimized view
|
||||
if (panel.minimized) {
|
||||
return (
|
||||
<div
|
||||
className="bg-dark-850 border border-dark-700 rounded-lg shadow-xl flex items-center gap-2 px-3 py-2 cursor-pointer hover:bg-dark-800 transition-colors"
|
||||
onClick={() => minimizePanel('introspection')}
|
||||
>
|
||||
<Search size={16} className="text-primary-400" />
|
||||
<span className="text-sm text-white font-medium">
|
||||
Model Introspection
|
||||
{data?.selectedFile && <span className="text-dark-400 ml-1">({data.selectedFile})</span>}
|
||||
</span>
|
||||
<Maximize2 size={14} className="text-dark-400" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-dark-850 border border-dark-700 rounded-xl w-80 max-h-[70vh] flex flex-col shadow-xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-dark-700">
|
||||
<div className="flex items-center gap-2">
|
||||
<Search size={16} className="text-primary-400" />
|
||||
<span className="font-medium text-white text-sm">
|
||||
Model Introspection
|
||||
{data?.selectedFile && <span className="text-primary-400 ml-1">({data.selectedFile})</span>}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => runIntrospection(data?.selectedFile)}
|
||||
disabled={isLoading}
|
||||
className="p-1.5 text-dark-400 hover:text-white hover:bg-dark-700 rounded transition-colors"
|
||||
title="Refresh"
|
||||
>
|
||||
<RefreshCw size={14} className={isLoading ? 'animate-spin' : ''} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => minimizePanel('introspection')}
|
||||
className="p-1.5 text-dark-400 hover:text-white hover:bg-dark-700 rounded transition-colors"
|
||||
title="Minimize"
|
||||
>
|
||||
<Minimize2 size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 text-dark-400 hover:text-white hover:bg-dark-700 rounded transition-colors"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* File Selector + Search */}
|
||||
<div className="px-4 py-2 border-b border-dark-700 space-y-2">
|
||||
{data?.studyId && modelFiles && modelFiles.all_files.length > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs text-dark-400 whitespace-nowrap">File:</label>
|
||||
<select
|
||||
value={data?.selectedFile || ''}
|
||||
onChange={handleFileChange}
|
||||
disabled={isLoading || isLoadingFiles}
|
||||
className="flex-1 px-2 py-1.5 bg-dark-800 border border-dark-600 rounded-lg
|
||||
text-sm text-white focus:outline-none focus:border-primary-500
|
||||
disabled:opacity-50"
|
||||
>
|
||||
<option value="">Default (Assembly)</option>
|
||||
|
||||
{modelFiles.files.sim.length > 0 && (
|
||||
<optgroup label="Simulation (.sim)">
|
||||
{modelFiles.files.sim.map(f => (
|
||||
<option key={f.name} value={f.name}>
|
||||
{f.stem} ({f.size_kb > 1000 ? `${(f.size_kb/1024).toFixed(1)}MB` : `${f.size_kb}KB`})
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
|
||||
{modelFiles.files.afm.length > 0 && (
|
||||
<optgroup label="Assembly FEM (.afm)">
|
||||
{modelFiles.files.afm.map(f => (
|
||||
<option key={f.name} value={f.name}>
|
||||
{f.stem} ({f.size_kb > 1000 ? `${(f.size_kb/1024).toFixed(1)}MB` : `${f.size_kb}KB`})
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
|
||||
{modelFiles.files.fem.length > 0 && (
|
||||
<optgroup label="FEM (.fem)">
|
||||
{modelFiles.files.fem.map(f => (
|
||||
<option key={f.name} value={f.name}>
|
||||
{f.stem} ({f.size_kb > 1000 ? `${(f.size_kb/1024).toFixed(1)}MB` : `${f.size_kb}KB`})
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
|
||||
{modelFiles.files.prt.length > 0 && (
|
||||
<optgroup label="Geometry (.prt)">
|
||||
{modelFiles.files.prt.map(f => (
|
||||
<option key={f.name} value={f.name}>
|
||||
{f.stem} ({f.size_kb > 1000 ? `${(f.size_kb/1024).toFixed(1)}MB` : `${f.size_kb}KB`})
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
|
||||
{modelFiles.files.idealized.length > 0 && (
|
||||
<optgroup label="Idealized (_i.prt)">
|
||||
{modelFiles.files.idealized.map(f => (
|
||||
<option key={f.name} value={f.name}>
|
||||
{f.stem} ({f.size_kb > 1000 ? `${(f.size_kb/1024).toFixed(1)}MB` : `${f.size_kb}KB`})
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
</select>
|
||||
{isLoadingFiles && (
|
||||
<RefreshCw size={12} className="animate-spin text-dark-400" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Filter expressions..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full px-3 py-1.5 bg-dark-800 border border-dark-600 rounded-lg
|
||||
text-sm text-white placeholder-dark-500 focus:outline-none focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-32 text-dark-500">
|
||||
<RefreshCw size={20} className="animate-spin mr-2" />
|
||||
Analyzing model...
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="p-4 text-red-400 text-sm">{error}</div>
|
||||
) : result ? (
|
||||
<div className="p-2 space-y-2">
|
||||
{/* Solver Type */}
|
||||
{result.solver_type && (
|
||||
<div className="p-2 bg-dark-800 rounded-lg">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Cpu size={14} className="text-violet-400" />
|
||||
<span className="text-dark-300">Solver:</span>
|
||||
<span className="text-white font-medium">{result.solver_type as string}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Expressions Section */}
|
||||
<div className="border border-dark-700 rounded-lg overflow-hidden">
|
||||
<button
|
||||
onClick={() => toggleSection('expressions')}
|
||||
className="w-full flex items-center justify-between px-3 py-2 bg-dark-800 hover:bg-dark-750 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<SlidersHorizontal size={14} className="text-emerald-400" />
|
||||
<span className="text-sm font-medium text-white">
|
||||
Expressions ({filteredExpressions.length})
|
||||
</span>
|
||||
</div>
|
||||
{expandedSections.has('expressions') ? (
|
||||
<ChevronDown size={14} className="text-dark-400" />
|
||||
) : (
|
||||
<ChevronRight size={14} className="text-dark-400" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{expandedSections.has('expressions') && (
|
||||
<div className="p-2 space-y-1 max-h-48 overflow-y-auto">
|
||||
{filteredExpressions.length === 0 ? (
|
||||
<p className="text-xs text-dark-500 text-center py-2">
|
||||
No expressions found
|
||||
</p>
|
||||
) : (
|
||||
filteredExpressions.map((expr) => (
|
||||
<div
|
||||
key={expr.name}
|
||||
className="flex items-center justify-between p-2 bg-dark-850 rounded hover:bg-dark-750 group transition-colors"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-white truncate">{expr.name}</p>
|
||||
<p className="text-xs text-dark-500">
|
||||
{expr.value} {expr.units || expr.unit || ''}
|
||||
{expr.source === 'inferred' && (
|
||||
<span className="ml-1 text-amber-500">(inferred)</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => addExpressionAsDesignVar(expr)}
|
||||
className="p-1.5 text-dark-500 hover:text-primary-400 hover:bg-dark-700 rounded
|
||||
opacity-0 group-hover:opacity-100 transition-all"
|
||||
title="Add as Design Variable"
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mass Properties Section */}
|
||||
{result.mass_properties && (
|
||||
<div className="border border-dark-700 rounded-lg overflow-hidden">
|
||||
<button
|
||||
onClick={() => toggleSection('mass')}
|
||||
className="w-full flex items-center justify-between px-3 py-2 bg-dark-800 hover:bg-dark-750 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Scale size={14} className="text-blue-400" />
|
||||
<span className="text-sm font-medium text-white">Mass Properties</span>
|
||||
</div>
|
||||
{expandedSections.has('mass') ? (
|
||||
<ChevronDown size={14} className="text-dark-400" />
|
||||
) : (
|
||||
<ChevronRight size={14} className="text-dark-400" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{expandedSections.has('mass') && (
|
||||
<div className="p-2 space-y-1">
|
||||
{(result.mass_properties as Record<string, unknown>).mass_kg !== undefined && (
|
||||
<div className="flex justify-between p-2 bg-dark-850 rounded text-xs">
|
||||
<span className="text-dark-400">Mass</span>
|
||||
<span className="text-white font-mono">
|
||||
{((result.mass_properties as Record<string, unknown>).mass_kg as number).toFixed(4)} kg
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* More sections can be added here following the same pattern as the original IntrospectionPanel */}
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-4 text-center text-dark-500 text-sm">
|
||||
Click refresh to analyze the model
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FloatingIntrospectionPanel;
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* PanelContainer - Orchestrates all floating panels in the canvas view
|
||||
*
|
||||
* This component renders floating panels (Introspection, Validation, Error, Results)
|
||||
* in a portal, positioned absolutely within the canvas area.
|
||||
*
|
||||
* Features:
|
||||
* - Draggable panels
|
||||
* - Z-index management (click to bring to front)
|
||||
* - Keyboard shortcuts (Escape to close all)
|
||||
* - Position persistence via usePanelStore
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import {
|
||||
usePanelStore,
|
||||
useIntrospectionPanel,
|
||||
useValidationPanel,
|
||||
useErrorPanel,
|
||||
useResultsPanel,
|
||||
PanelPosition,
|
||||
} from '../../../hooks/usePanelStore';
|
||||
import { FloatingIntrospectionPanel } from './FloatingIntrospectionPanel';
|
||||
import { FloatingValidationPanel } from './ValidationPanel';
|
||||
import { ErrorPanel } from './ErrorPanel';
|
||||
import { ResultsPanel } from './ResultsPanel';
|
||||
|
||||
interface PanelContainerProps {
|
||||
/** Container element to render panels into (defaults to document.body) */
|
||||
container?: HTMLElement;
|
||||
/** Callback when retry is requested from error panel */
|
||||
onRetry?: (trial?: number) => void;
|
||||
/** Callback when skip trial is requested */
|
||||
onSkipTrial?: (trial: number) => void;
|
||||
}
|
||||
|
||||
type PanelName = 'introspection' | 'validation' | 'error' | 'results';
|
||||
|
||||
export function PanelContainer({ container, onRetry, onSkipTrial }: PanelContainerProps) {
|
||||
const { closePanel, setPanelPosition, closeAllPanels } = usePanelStore();
|
||||
|
||||
const introspectionPanel = useIntrospectionPanel();
|
||||
const validationPanel = useValidationPanel();
|
||||
const errorPanel = useErrorPanel();
|
||||
const resultsPanel = useResultsPanel();
|
||||
|
||||
// Track which panel is on top (for z-index)
|
||||
const [topPanel, setTopPanel] = useState<PanelName | null>(null);
|
||||
|
||||
// Dragging state
|
||||
const [dragging, setDragging] = useState<{ panel: PanelName; offset: { x: number; y: number } } | null>(null);
|
||||
const dragRef = useRef<{ panel: PanelName; offset: { x: number; y: number } } | null>(null);
|
||||
|
||||
// Escape key to close all panels
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
closeAllPanels();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [closeAllPanels]);
|
||||
|
||||
// Mouse move handler for dragging
|
||||
useEffect(() => {
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!dragRef.current) return;
|
||||
|
||||
const { panel, offset } = dragRef.current;
|
||||
const newPosition: PanelPosition = {
|
||||
x: e.clientX - offset.x,
|
||||
y: e.clientY - offset.y,
|
||||
};
|
||||
|
||||
// Clamp to viewport
|
||||
newPosition.x = Math.max(0, Math.min(window.innerWidth - 100, newPosition.x));
|
||||
newPosition.y = Math.max(0, Math.min(window.innerHeight - 50, newPosition.y));
|
||||
|
||||
setPanelPosition(panel, newPosition);
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
dragRef.current = null;
|
||||
setDragging(null);
|
||||
};
|
||||
|
||||
if (dragging) {
|
||||
window.addEventListener('mousemove', handleMouseMove);
|
||||
window.addEventListener('mouseup', handleMouseUp);
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', handleMouseMove);
|
||||
window.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
}, [dragging, setPanelPosition]);
|
||||
|
||||
// Start dragging a panel
|
||||
const handleDragStart = useCallback((panel: PanelName, e: React.MouseEvent, position: PanelPosition) => {
|
||||
const offset = {
|
||||
x: e.clientX - position.x,
|
||||
y: e.clientY - position.y,
|
||||
};
|
||||
dragRef.current = { panel, offset };
|
||||
setDragging({ panel, offset });
|
||||
setTopPanel(panel);
|
||||
}, []);
|
||||
|
||||
// Click to bring panel to front
|
||||
const handlePanelClick = useCallback((panel: PanelName) => {
|
||||
setTopPanel(panel);
|
||||
}, []);
|
||||
|
||||
// Get z-index for a panel
|
||||
const getZIndex = (panel: PanelName) => {
|
||||
const baseZ = 100;
|
||||
if (panel === topPanel) return baseZ + 10;
|
||||
return baseZ;
|
||||
};
|
||||
|
||||
// Render a draggable wrapper
|
||||
const renderDraggable = (
|
||||
panel: PanelName,
|
||||
position: PanelPosition,
|
||||
isOpen: boolean,
|
||||
children: React.ReactNode
|
||||
) => {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={panel}
|
||||
className="fixed select-none"
|
||||
style={{
|
||||
left: position.x,
|
||||
top: position.y,
|
||||
zIndex: getZIndex(panel),
|
||||
cursor: dragging?.panel === panel ? 'grabbing' : 'default',
|
||||
}}
|
||||
onClick={() => handlePanelClick(panel)}
|
||||
>
|
||||
{/* Drag handle - the header area */}
|
||||
<div
|
||||
className="absolute top-0 left-0 right-0 h-12 cursor-grab active:cursor-grabbing"
|
||||
onMouseDown={(e) => handleDragStart(panel, e, position)}
|
||||
style={{ zIndex: 1 }}
|
||||
/>
|
||||
{/* Panel content */}
|
||||
<div className="relative" style={{ zIndex: 0 }}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Determine what to render
|
||||
const panels = (
|
||||
<>
|
||||
{/* Introspection Panel */}
|
||||
{renderDraggable(
|
||||
'introspection',
|
||||
introspectionPanel.position || { x: 100, y: 100 },
|
||||
introspectionPanel.open,
|
||||
<FloatingIntrospectionPanel onClose={() => closePanel('introspection')} />
|
||||
)}
|
||||
|
||||
{/* Validation Panel */}
|
||||
{renderDraggable(
|
||||
'validation',
|
||||
validationPanel.position || { x: 150, y: 150 },
|
||||
validationPanel.open,
|
||||
<FloatingValidationPanel onClose={() => closePanel('validation')} />
|
||||
)}
|
||||
|
||||
{/* Error Panel */}
|
||||
{renderDraggable(
|
||||
'error',
|
||||
errorPanel.position || { x: 200, y: 100 },
|
||||
errorPanel.open,
|
||||
<ErrorPanel
|
||||
onClose={() => closePanel('error')}
|
||||
onRetry={onRetry}
|
||||
onSkipTrial={onSkipTrial}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Results Panel */}
|
||||
{renderDraggable(
|
||||
'results',
|
||||
resultsPanel.position || { x: 250, y: 150 },
|
||||
resultsPanel.open,
|
||||
<ResultsPanel onClose={() => closePanel('results')} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
// Use portal if container specified, otherwise render in place
|
||||
if (container) {
|
||||
return createPortal(panels, container);
|
||||
}
|
||||
|
||||
return panels;
|
||||
}
|
||||
|
||||
export default PanelContainer;
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* ResultsPanel - Shows detailed trial results
|
||||
*
|
||||
* Displays the parameters, objectives, and constraints for a specific trial.
|
||||
* Can be opened by clicking on result badges on nodes.
|
||||
*/
|
||||
|
||||
import {
|
||||
X,
|
||||
Minimize2,
|
||||
Maximize2,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Trophy,
|
||||
SlidersHorizontal,
|
||||
Target,
|
||||
AlertTriangle,
|
||||
Clock,
|
||||
} from 'lucide-react';
|
||||
import { useResultsPanel, usePanelStore } from '../../../hooks/usePanelStore';
|
||||
|
||||
interface ResultsPanelProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ResultsPanel({ onClose }: ResultsPanelProps) {
|
||||
const panel = useResultsPanel();
|
||||
const { minimizePanel } = usePanelStore();
|
||||
const data = panel.data;
|
||||
|
||||
if (!panel.open || !data) return null;
|
||||
|
||||
const timestamp = new Date(data.timestamp).toLocaleTimeString();
|
||||
|
||||
// Minimized view
|
||||
if (panel.minimized) {
|
||||
return (
|
||||
<div
|
||||
className="bg-dark-850 border border-dark-700 rounded-lg shadow-xl flex items-center gap-2 px-3 py-2 cursor-pointer hover:bg-dark-800 transition-colors"
|
||||
onClick={() => minimizePanel('results')}
|
||||
>
|
||||
<Trophy size={16} className={data.isBest ? 'text-amber-400' : 'text-dark-400'} />
|
||||
<span className="text-sm text-white font-medium">
|
||||
Trial #{data.trialNumber}
|
||||
</span>
|
||||
<Maximize2 size={14} className="text-dark-400" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-dark-850 border border-dark-700 rounded-xl w-80 max-h-[500px] flex flex-col shadow-xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-dark-700">
|
||||
<div className="flex items-center gap-2">
|
||||
<Trophy size={18} className={data.isBest ? 'text-amber-400' : 'text-dark-400'} />
|
||||
<span className="font-medium text-white">
|
||||
Trial #{data.trialNumber}
|
||||
</span>
|
||||
{data.isBest && (
|
||||
<span className="px-1.5 py-0.5 text-xs bg-amber-500/20 text-amber-400 rounded">
|
||||
Best
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => minimizePanel('results')}
|
||||
className="p-1.5 text-dark-400 hover:text-white hover:bg-dark-700 rounded transition-colors"
|
||||
title="Minimize"
|
||||
>
|
||||
<Minimize2 size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 text-dark-400 hover:text-white hover:bg-dark-700 rounded transition-colors"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-3 space-y-4">
|
||||
{/* Status */}
|
||||
<div className="flex items-center gap-3">
|
||||
{data.isFeasible ? (
|
||||
<div className="flex items-center gap-1.5 text-green-400">
|
||||
<CheckCircle size={16} />
|
||||
<span className="text-sm font-medium">Feasible</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5 text-red-400">
|
||||
<XCircle size={16} />
|
||||
<span className="text-sm font-medium">Infeasible</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-1.5 text-dark-400 ml-auto">
|
||||
<Clock size={14} />
|
||||
<span className="text-xs">{timestamp}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Parameters */}
|
||||
<div>
|
||||
<h4 className="text-xs font-medium text-dark-400 uppercase tracking-wide mb-2 flex items-center gap-1.5">
|
||||
<SlidersHorizontal size={12} />
|
||||
Parameters
|
||||
</h4>
|
||||
<div className="space-y-1">
|
||||
{Object.entries(data.params).map(([name, value]) => (
|
||||
<div key={name} className="flex justify-between p-2 bg-dark-800 rounded text-sm">
|
||||
<span className="text-dark-300">{name}</span>
|
||||
<span className="text-white font-mono">{formatValue(value)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Objectives */}
|
||||
<div>
|
||||
<h4 className="text-xs font-medium text-dark-400 uppercase tracking-wide mb-2 flex items-center gap-1.5">
|
||||
<Target size={12} />
|
||||
Objectives
|
||||
</h4>
|
||||
<div className="space-y-1">
|
||||
{Object.entries(data.objectives).map(([name, value]) => (
|
||||
<div key={name} className="flex justify-between p-2 bg-dark-800 rounded text-sm">
|
||||
<span className="text-dark-300">{name}</span>
|
||||
<span className="text-primary-400 font-mono">{formatValue(value)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Constraints (if any) */}
|
||||
{data.constraints && Object.keys(data.constraints).length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-xs font-medium text-dark-400 uppercase tracking-wide mb-2 flex items-center gap-1.5">
|
||||
<AlertTriangle size={12} />
|
||||
Constraints
|
||||
</h4>
|
||||
<div className="space-y-1">
|
||||
{Object.entries(data.constraints).map(([name, constraint]) => (
|
||||
<div
|
||||
key={name}
|
||||
className={`flex justify-between p-2 rounded text-sm ${
|
||||
constraint.feasible ? 'bg-dark-800' : 'bg-red-500/10 border border-red-500/20'
|
||||
}`}
|
||||
>
|
||||
<span className="text-dark-300 flex items-center gap-1.5">
|
||||
{constraint.feasible ? (
|
||||
<CheckCircle size={12} className="text-green-400" />
|
||||
) : (
|
||||
<XCircle size={12} className="text-red-400" />
|
||||
)}
|
||||
{name}
|
||||
</span>
|
||||
<span className={`font-mono ${constraint.feasible ? 'text-white' : 'text-red-400'}`}>
|
||||
{formatValue(constraint.value)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatValue(value: number): string {
|
||||
if (Math.abs(value) < 0.001 || Math.abs(value) >= 10000) {
|
||||
return value.toExponential(3);
|
||||
}
|
||||
return value.toFixed(4).replace(/\.?0+$/, '');
|
||||
}
|
||||
|
||||
export default ResultsPanel;
|
||||
@@ -1,10 +1,41 @@
|
||||
/**
|
||||
* ValidationPanel - Displays spec validation errors and warnings
|
||||
*
|
||||
* Shows a list of validation issues that need to be fixed before
|
||||
* running an optimization. Supports auto-navigation to problematic nodes.
|
||||
*
|
||||
* Can be used in two modes:
|
||||
* 1. Legacy mode: Pass validation prop directly (for backward compatibility)
|
||||
* 2. Store mode: Uses usePanelStore for persistent state
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import {
|
||||
X,
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
ChevronRight,
|
||||
Minimize2,
|
||||
Maximize2,
|
||||
} from 'lucide-react';
|
||||
import { useValidationPanel, usePanelStore, ValidationError as StoreValidationError } from '../../../hooks/usePanelStore';
|
||||
import { useSpecStore } from '../../../hooks/useSpecStore';
|
||||
import { ValidationResult } from '../../../lib/canvas/validation';
|
||||
|
||||
interface ValidationPanelProps {
|
||||
// ============================================================================
|
||||
// Legacy Props Interface (for backward compatibility)
|
||||
// ============================================================================
|
||||
|
||||
interface LegacyValidationPanelProps {
|
||||
validation: ValidationResult;
|
||||
}
|
||||
|
||||
export function ValidationPanel({ validation }: ValidationPanelProps) {
|
||||
/**
|
||||
* Legacy ValidationPanel - Inline display for canvas overlay
|
||||
* Kept for backward compatibility with AtomizerCanvas
|
||||
*/
|
||||
export function ValidationPanel({ validation }: LegacyValidationPanelProps) {
|
||||
return (
|
||||
<div className="absolute top-4 left-1/2 transform -translate-x-1/2 max-w-md w-full z-10">
|
||||
{validation.errors.length > 0 && (
|
||||
@@ -30,3 +61,199 @@ export function ValidationPanel({ validation }: ValidationPanelProps) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// New Floating Panel (uses store)
|
||||
// ============================================================================
|
||||
|
||||
interface FloatingValidationPanelProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function FloatingValidationPanel({ onClose }: FloatingValidationPanelProps) {
|
||||
const panel = useValidationPanel();
|
||||
const { minimizePanel } = usePanelStore();
|
||||
const { selectNode } = useSpecStore();
|
||||
|
||||
const { errors, warnings, valid } = useMemo(() => {
|
||||
if (!panel.data) {
|
||||
return { errors: [], warnings: [], valid: true };
|
||||
}
|
||||
return {
|
||||
errors: panel.data.errors || [],
|
||||
warnings: panel.data.warnings || [],
|
||||
valid: panel.data.valid,
|
||||
};
|
||||
}, [panel.data]);
|
||||
|
||||
const handleNavigateToNode = (nodeId?: string) => {
|
||||
if (nodeId) {
|
||||
selectNode(nodeId);
|
||||
}
|
||||
};
|
||||
|
||||
if (!panel.open) return null;
|
||||
|
||||
// Minimized view
|
||||
if (panel.minimized) {
|
||||
return (
|
||||
<div
|
||||
className="bg-dark-850 border border-dark-700 rounded-lg shadow-xl flex items-center gap-2 px-3 py-2 cursor-pointer hover:bg-dark-800 transition-colors"
|
||||
onClick={() => minimizePanel('validation')}
|
||||
>
|
||||
{valid ? (
|
||||
<CheckCircle size={16} className="text-green-400" />
|
||||
) : (
|
||||
<AlertCircle size={16} className="text-red-400" />
|
||||
)}
|
||||
<span className="text-sm text-white font-medium">
|
||||
Validation {valid ? 'Passed' : `(${errors.length} errors)`}
|
||||
</span>
|
||||
<Maximize2 size={14} className="text-dark-400" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-dark-850 border border-dark-700 rounded-xl w-96 max-h-[500px] flex flex-col shadow-xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-dark-700">
|
||||
<div className="flex items-center gap-2">
|
||||
{valid ? (
|
||||
<CheckCircle size={18} className="text-green-400" />
|
||||
) : (
|
||||
<AlertCircle size={18} className="text-red-400" />
|
||||
)}
|
||||
<span className="font-medium text-white">
|
||||
{valid ? 'Validation Passed' : 'Validation Issues'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => minimizePanel('validation')}
|
||||
className="p-1.5 text-dark-400 hover:text-white hover:bg-dark-700 rounded transition-colors"
|
||||
title="Minimize"
|
||||
>
|
||||
<Minimize2 size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 text-dark-400 hover:text-white hover:bg-dark-700 rounded transition-colors"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-3 space-y-2">
|
||||
{valid && errors.length === 0 && warnings.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<CheckCircle size={40} className="text-green-400 mb-3" />
|
||||
<p className="text-white font-medium">All checks passed!</p>
|
||||
<p className="text-sm text-dark-400 mt-1">
|
||||
Your spec is ready to run.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Errors */}
|
||||
{errors.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-xs font-medium text-red-400 uppercase tracking-wide flex items-center gap-1">
|
||||
<AlertCircle size={12} />
|
||||
Errors ({errors.length})
|
||||
</h4>
|
||||
{errors.map((error, idx) => (
|
||||
<ValidationItem
|
||||
key={`error-${idx}`}
|
||||
item={error}
|
||||
severity="error"
|
||||
onNavigate={() => handleNavigateToNode(error.nodeId)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Warnings */}
|
||||
{warnings.length > 0 && (
|
||||
<div className="space-y-2 mt-4">
|
||||
<h4 className="text-xs font-medium text-amber-400 uppercase tracking-wide flex items-center gap-1">
|
||||
<AlertTriangle size={12} />
|
||||
Warnings ({warnings.length})
|
||||
</h4>
|
||||
{warnings.map((warning, idx) => (
|
||||
<ValidationItem
|
||||
key={`warning-${idx}`}
|
||||
item={warning}
|
||||
severity="warning"
|
||||
onNavigate={() => handleNavigateToNode(warning.nodeId)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
{!valid && (
|
||||
<div className="px-4 py-3 border-t border-dark-700 bg-dark-800/50">
|
||||
<p className="text-xs text-dark-400">
|
||||
Fix all errors before running the optimization.
|
||||
Warnings can be ignored but may cause issues.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Validation Item Component
|
||||
// ============================================================================
|
||||
|
||||
interface ValidationItemProps {
|
||||
item: StoreValidationError;
|
||||
severity: 'error' | 'warning';
|
||||
onNavigate: () => void;
|
||||
}
|
||||
|
||||
function ValidationItem({ item, severity, onNavigate }: ValidationItemProps) {
|
||||
const isError = severity === 'error';
|
||||
const bgColor = isError ? 'bg-red-500/10' : 'bg-amber-500/10';
|
||||
const borderColor = isError ? 'border-red-500/30' : 'border-amber-500/30';
|
||||
const iconColor = isError ? 'text-red-400' : 'text-amber-400';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`p-3 rounded-lg border ${bgColor} ${borderColor} group cursor-pointer hover:bg-opacity-20 transition-colors`}
|
||||
onClick={onNavigate}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
{isError ? (
|
||||
<AlertCircle size={16} className={`${iconColor} flex-shrink-0 mt-0.5`} />
|
||||
) : (
|
||||
<AlertTriangle size={16} className={`${iconColor} flex-shrink-0 mt-0.5`} />
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-white">{item.message}</p>
|
||||
{item.path && (
|
||||
<p className="text-xs text-dark-400 mt-1 font-mono">{item.path}</p>
|
||||
)}
|
||||
{item.suggestion && (
|
||||
<p className="text-xs text-dark-300 mt-2 italic">{item.suggestion}</p>
|
||||
)}
|
||||
</div>
|
||||
{item.nodeId && (
|
||||
<ChevronRight
|
||||
size={16}
|
||||
className="text-dark-500 group-hover:text-white transition-colors flex-shrink-0"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ValidationPanel;
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* ConvergenceSparkline - Tiny SVG chart showing optimization convergence
|
||||
*
|
||||
* Displays the last N trial values as a mini line chart.
|
||||
* Used on ObjectiveNode to show convergence trend.
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
|
||||
interface ConvergenceSparklineProps {
|
||||
/** Array of values (most recent last) */
|
||||
values: number[];
|
||||
/** Width in pixels */
|
||||
width?: number;
|
||||
/** Height in pixels */
|
||||
height?: number;
|
||||
/** Line color */
|
||||
color?: string;
|
||||
/** Best value line color */
|
||||
bestColor?: string;
|
||||
/** Whether to show the best value line */
|
||||
showBest?: boolean;
|
||||
/** Direction: minimize shows lower as better, maximize shows higher as better */
|
||||
direction?: 'minimize' | 'maximize';
|
||||
/** Show dots at each point */
|
||||
showDots?: boolean;
|
||||
/** Number of points to display */
|
||||
maxPoints?: number;
|
||||
}
|
||||
|
||||
export function ConvergenceSparkline({
|
||||
values,
|
||||
width = 80,
|
||||
height = 24,
|
||||
color = '#60a5fa',
|
||||
bestColor = '#34d399',
|
||||
showBest = true,
|
||||
direction = 'minimize',
|
||||
showDots = false,
|
||||
maxPoints = 20,
|
||||
}: ConvergenceSparklineProps) {
|
||||
const { path, bestY, points } = useMemo(() => {
|
||||
if (!values || values.length === 0) {
|
||||
return { path: '', bestY: null, points: [], minVal: 0, maxVal: 1 };
|
||||
}
|
||||
|
||||
// Take last N points
|
||||
const data = values.slice(-maxPoints);
|
||||
if (data.length === 0) {
|
||||
return { path: '', bestY: null, points: [], minVal: 0, maxVal: 1 };
|
||||
}
|
||||
|
||||
// Calculate bounds with padding
|
||||
const minVal = Math.min(...data);
|
||||
const maxVal = Math.max(...data);
|
||||
const range = maxVal - minVal || 1;
|
||||
const padding = range * 0.1;
|
||||
const yMin = minVal - padding;
|
||||
const yMax = maxVal + padding;
|
||||
const yRange = yMax - yMin;
|
||||
|
||||
// Calculate best value
|
||||
const bestVal = direction === 'minimize' ? Math.min(...data) : Math.max(...data);
|
||||
|
||||
// Map values to SVG coordinates
|
||||
const xStep = width / Math.max(data.length - 1, 1);
|
||||
const mapY = (v: number) => height - ((v - yMin) / yRange) * height;
|
||||
|
||||
// Build path
|
||||
const points = data.map((v, i) => ({
|
||||
x: i * xStep,
|
||||
y: mapY(v),
|
||||
value: v,
|
||||
}));
|
||||
|
||||
const pathParts = points.map((p, i) =>
|
||||
i === 0 ? `M ${p.x} ${p.y}` : `L ${p.x} ${p.y}`
|
||||
);
|
||||
|
||||
return {
|
||||
path: pathParts.join(' '),
|
||||
bestY: mapY(bestVal),
|
||||
points,
|
||||
minVal,
|
||||
maxVal,
|
||||
};
|
||||
}, [values, width, height, maxPoints, direction]);
|
||||
|
||||
if (!values || values.length === 0) {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-center text-dark-500 text-xs"
|
||||
style={{ width, height }}
|
||||
>
|
||||
No data
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
className="overflow-visible"
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
>
|
||||
{/* Best value line */}
|
||||
{showBest && bestY !== null && (
|
||||
<line
|
||||
x1={0}
|
||||
y1={bestY}
|
||||
x2={width}
|
||||
y2={bestY}
|
||||
stroke={bestColor}
|
||||
strokeWidth={1}
|
||||
strokeDasharray="2,2"
|
||||
opacity={0.5}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Main line */}
|
||||
<path
|
||||
d={path}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
|
||||
{/* Gradient fill under the line */}
|
||||
<defs>
|
||||
<linearGradient id="sparkline-gradient" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stopColor={color} stopOpacity={0.3} />
|
||||
<stop offset="100%" stopColor={color} stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
{points.length > 1 && (
|
||||
<path
|
||||
d={`${path} L ${points[points.length - 1].x} ${height} L ${points[0].x} ${height} Z`}
|
||||
fill="url(#sparkline-gradient)"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Dots at each point */}
|
||||
{showDots && points.map((p, i) => (
|
||||
<circle
|
||||
key={i}
|
||||
cx={p.x}
|
||||
cy={p.y}
|
||||
r={2}
|
||||
fill={color}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Last point highlight */}
|
||||
{points.length > 0 && (
|
||||
<circle
|
||||
cx={points[points.length - 1].x}
|
||||
cy={points[points.length - 1].y}
|
||||
r={3}
|
||||
fill={color}
|
||||
stroke="white"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* ProgressRing - Circular progress indicator
|
||||
*/
|
||||
interface ProgressRingProps {
|
||||
/** Progress percentage (0-100) */
|
||||
progress: number;
|
||||
/** Size in pixels */
|
||||
size?: number;
|
||||
/** Stroke width */
|
||||
strokeWidth?: number;
|
||||
/** Progress color */
|
||||
color?: string;
|
||||
/** Background color */
|
||||
bgColor?: string;
|
||||
/** Show percentage text */
|
||||
showText?: boolean;
|
||||
}
|
||||
|
||||
export function ProgressRing({
|
||||
progress,
|
||||
size = 32,
|
||||
strokeWidth = 3,
|
||||
color = '#60a5fa',
|
||||
bgColor = '#374151',
|
||||
showText = true,
|
||||
}: ProgressRingProps) {
|
||||
const radius = (size - strokeWidth) / 2;
|
||||
const circumference = radius * 2 * Math.PI;
|
||||
const offset = circumference - (Math.min(100, Math.max(0, progress)) / 100) * circumference;
|
||||
|
||||
return (
|
||||
<div className="relative inline-flex items-center justify-center" style={{ width: size, height: size }}>
|
||||
<svg width={size} height={size} className="transform -rotate-90">
|
||||
{/* Background circle */}
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke={bgColor}
|
||||
strokeWidth={strokeWidth}
|
||||
/>
|
||||
{/* Progress circle */}
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={offset}
|
||||
strokeLinecap="round"
|
||||
className="transition-all duration-300"
|
||||
/>
|
||||
</svg>
|
||||
{showText && (
|
||||
<span
|
||||
className="absolute text-xs font-medium"
|
||||
style={{ color, fontSize: size * 0.25 }}
|
||||
>
|
||||
{Math.round(progress)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ConvergenceSparkline;
|
||||
@@ -5,7 +5,7 @@ import { ToolCallCard, ToolCall } from './ToolCallCard';
|
||||
|
||||
export interface Message {
|
||||
id: string;
|
||||
role: 'user' | 'assistant';
|
||||
role: 'user' | 'assistant' | 'system';
|
||||
content: string;
|
||||
timestamp: Date;
|
||||
isStreaming?: boolean;
|
||||
@@ -18,6 +18,18 @@ interface ChatMessageProps {
|
||||
|
||||
export const ChatMessage: React.FC<ChatMessageProps> = ({ message }) => {
|
||||
const isAssistant = message.role === 'assistant';
|
||||
const isSystem = message.role === 'system';
|
||||
|
||||
// System messages are displayed centered with special styling
|
||||
if (isSystem) {
|
||||
return (
|
||||
<div 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">
|
||||
{message.content}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useRef, useEffect, useState } from 'react';
|
||||
import React, { useRef, useEffect, useState, useMemo } from 'react';
|
||||
import {
|
||||
MessageSquare,
|
||||
ChevronRight,
|
||||
@@ -13,8 +13,10 @@ import { ChatMessage } from './ChatMessage';
|
||||
import { ChatInput } from './ChatInput';
|
||||
import { ThinkingIndicator } from './ThinkingIndicator';
|
||||
import { ModeToggle } from './ModeToggle';
|
||||
import { useChat } from '../../hooks/useChat';
|
||||
import { useChat, CanvasState, CanvasModification } from '../../hooks/useChat';
|
||||
import { useStudy } from '../../context/StudyContext';
|
||||
import { useCanvasStore } from '../../hooks/useCanvasStore';
|
||||
import { NodeType } from '../../lib/canvas/schema';
|
||||
|
||||
interface ChatPaneProps {
|
||||
isOpen: boolean;
|
||||
@@ -31,6 +33,76 @@ export const ChatPane: React.FC<ChatPaneProps> = ({
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
// Get canvas state and modification functions from the store
|
||||
const { nodes, edges, addNode, updateNodeData, selectNode, deleteSelected } = useCanvasStore();
|
||||
|
||||
// Build canvas state for chat context
|
||||
const canvasState: CanvasState | null = useMemo(() => {
|
||||
if (nodes.length === 0) return null;
|
||||
return {
|
||||
nodes: nodes.map(n => ({
|
||||
id: n.id,
|
||||
type: n.type,
|
||||
data: n.data,
|
||||
position: n.position,
|
||||
})),
|
||||
edges: edges.map(e => ({
|
||||
id: e.id,
|
||||
source: e.source,
|
||||
target: e.target,
|
||||
})),
|
||||
studyName: selectedStudy?.name || selectedStudy?.id,
|
||||
};
|
||||
}, [nodes, edges, selectedStudy]);
|
||||
|
||||
// Track position offset for multiple node additions
|
||||
const nodeAddCountRef = useRef(0);
|
||||
|
||||
// Handle canvas modifications from the assistant
|
||||
const handleCanvasModification = React.useCallback((modification: CanvasModification) => {
|
||||
console.log('Canvas modification from assistant:', modification);
|
||||
|
||||
switch (modification.action) {
|
||||
case 'add_node':
|
||||
if (modification.nodeType) {
|
||||
const nodeType = modification.nodeType as NodeType;
|
||||
// Calculate position: offset each new node so they don't stack
|
||||
const basePosition = modification.position || { x: 100, y: 100 };
|
||||
const offset = nodeAddCountRef.current * 120;
|
||||
const position = {
|
||||
x: basePosition.x,
|
||||
y: basePosition.y + offset,
|
||||
};
|
||||
nodeAddCountRef.current += 1;
|
||||
// Reset counter after a delay (for batch operations)
|
||||
setTimeout(() => { nodeAddCountRef.current = 0; }, 2000);
|
||||
|
||||
addNode(nodeType, position, modification.data);
|
||||
console.log(`Added ${nodeType} node at position:`, position);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'update_node':
|
||||
if (modification.nodeId && modification.data) {
|
||||
updateNodeData(modification.nodeId, modification.data);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'remove_node':
|
||||
if (modification.nodeId) {
|
||||
selectNode(modification.nodeId);
|
||||
deleteSelected();
|
||||
}
|
||||
break;
|
||||
|
||||
// Edge operations would need additional store methods
|
||||
case 'add_edge':
|
||||
case 'remove_edge':
|
||||
console.warn('Edge modification not yet implemented:', modification);
|
||||
break;
|
||||
}
|
||||
}, [addNode, updateNodeData, selectNode, deleteSelected]);
|
||||
|
||||
const {
|
||||
messages,
|
||||
isThinking,
|
||||
@@ -41,22 +113,38 @@ export const ChatPane: React.FC<ChatPaneProps> = ({
|
||||
sendMessage,
|
||||
clearMessages,
|
||||
switchMode,
|
||||
updateCanvasState,
|
||||
} = useChat({
|
||||
studyId: selectedStudy?.id,
|
||||
mode: 'user',
|
||||
useWebSocket: true,
|
||||
canvasState,
|
||||
onError: (err) => console.error('Chat error:', err),
|
||||
onCanvasModification: handleCanvasModification,
|
||||
});
|
||||
|
||||
// Keep canvas state synced with chat
|
||||
useEffect(() => {
|
||||
updateCanvasState(canvasState);
|
||||
}, [canvasState, updateCanvasState]);
|
||||
|
||||
// Auto-scroll to bottom when new messages arrive
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages, isThinking]);
|
||||
|
||||
// Welcome message based on study context
|
||||
const welcomeMessage = selectedStudy
|
||||
? `Ready to help with **${selectedStudy.name || selectedStudy.id}**. Ask me about optimization progress, results analysis, or how to improve your design.`
|
||||
: 'Select a study to get started, or ask me to help you create a new one.';
|
||||
// Welcome message based on study and canvas context
|
||||
const welcomeMessage = useMemo(() => {
|
||||
if (selectedStudy) {
|
||||
return `Ready to help with **${selectedStudy.name || selectedStudy.id}**. Ask me about optimization progress, results analysis, or how to improve your design.`;
|
||||
}
|
||||
if (nodes.length > 0) {
|
||||
const dvCount = nodes.filter(n => n.type === 'designVar').length;
|
||||
const objCount = nodes.filter(n => n.type === 'objective').length;
|
||||
return `I can see your canvas with ${dvCount} design variables and ${objCount} objectives. Ask me to analyze, validate, or create a study from this setup.`;
|
||||
}
|
||||
return 'Select a study to get started, or build an optimization in the Canvas Builder.';
|
||||
}, [selectedStudy, nodes]);
|
||||
|
||||
// Collapsed state - just show toggle button
|
||||
if (!isOpen) {
|
||||
|
||||
@@ -30,22 +30,25 @@ interface ToolCallCardProps {
|
||||
}
|
||||
|
||||
// Map tool names to friendly labels and icons
|
||||
const TOOL_INFO: Record<string, { label: string; icon: React.ComponentType<{ className?: string }> }> = {
|
||||
const TOOL_INFO: Record<string, { label: string; icon: React.ComponentType<{ className?: string }>; color?: string }> = {
|
||||
// Study tools
|
||||
list_studies: { label: 'Listing Studies', icon: Database },
|
||||
get_study_status: { label: 'Getting Status', icon: FileSearch },
|
||||
create_study: { label: 'Creating Study', icon: Settings },
|
||||
create_study: { label: 'Creating Study', icon: Settings, color: 'text-green-400' },
|
||||
|
||||
// Optimization tools
|
||||
run_optimization: { label: 'Starting Optimization', icon: Play },
|
||||
run_optimization: { label: 'Starting Optimization', icon: Play, color: 'text-blue-400' },
|
||||
stop_optimization: { label: 'Stopping Optimization', icon: XCircle },
|
||||
get_optimization_status: { label: 'Checking Progress', icon: BarChart2 },
|
||||
|
||||
// Analysis tools
|
||||
get_trial_data: { label: 'Querying Trials', icon: Database },
|
||||
query_trials: { label: 'Querying Trials', icon: Database },
|
||||
get_trial_details: { label: 'Getting Trial Details', icon: FileSearch },
|
||||
analyze_convergence: { label: 'Analyzing Convergence', icon: BarChart2 },
|
||||
compare_trials: { label: 'Comparing Trials', icon: BarChart2 },
|
||||
get_best_design: { label: 'Getting Best Design', icon: CheckCircle },
|
||||
get_optimization_summary: { label: 'Getting Summary', icon: BarChart2 },
|
||||
|
||||
// Reporting tools
|
||||
generate_report: { label: 'Generating Report', icon: FileText },
|
||||
@@ -56,6 +59,25 @@ const TOOL_INFO: Record<string, { label: string; icon: React.ComponentType<{ cla
|
||||
recommend_method: { label: 'Recommending Method', icon: Settings },
|
||||
query_extractors: { label: 'Listing Extractors', icon: Database },
|
||||
|
||||
// Config tools (read)
|
||||
read_study_config: { label: 'Reading Config', icon: FileSearch },
|
||||
read_study_readme: { label: 'Reading README', icon: FileText },
|
||||
|
||||
// === WRITE TOOLS (Power Mode) ===
|
||||
add_design_variable: { label: 'Adding Design Variable', icon: Settings, color: 'text-amber-400' },
|
||||
add_extractor: { label: 'Adding Extractor', icon: Settings, color: 'text-amber-400' },
|
||||
add_objective: { label: 'Adding Objective', icon: Settings, color: 'text-amber-400' },
|
||||
add_constraint: { label: 'Adding Constraint', icon: Settings, color: 'text-amber-400' },
|
||||
update_spec_field: { label: 'Updating Field', icon: Settings, color: 'text-amber-400' },
|
||||
remove_node: { label: 'Removing Node', icon: XCircle, color: 'text-red-400' },
|
||||
|
||||
// === INTERVIEW TOOLS ===
|
||||
start_interview: { label: 'Starting Interview', icon: HelpCircle, color: 'text-purple-400' },
|
||||
interview_record: { label: 'Recording Answer', icon: CheckCircle, color: 'text-purple-400' },
|
||||
interview_advance: { label: 'Advancing Interview', icon: Play, color: 'text-purple-400' },
|
||||
interview_status: { label: 'Checking Progress', icon: BarChart2, color: 'text-purple-400' },
|
||||
interview_finalize: { label: 'Creating Study', icon: CheckCircle, color: 'text-green-400' },
|
||||
|
||||
// Admin tools (power mode)
|
||||
edit_file: { label: 'Editing File', icon: FileText },
|
||||
create_file: { label: 'Creating File', icon: FileText },
|
||||
@@ -104,7 +126,7 @@ export const ToolCallCard: React.FC<ToolCallCardProps> = ({ toolCall }) => {
|
||||
)}
|
||||
|
||||
{/* Tool icon */}
|
||||
<Icon className="w-4 h-4 text-dark-400 flex-shrink-0" />
|
||||
<Icon className={`w-4 h-4 flex-shrink-0 ${info.color || 'text-dark-400'}`} />
|
||||
|
||||
{/* Label */}
|
||||
<span className="flex-1 text-sm text-dark-200 truncate">{info.label}</span>
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
/**
|
||||
* DevLoopPanel - Control panel for closed-loop development
|
||||
*
|
||||
* Features:
|
||||
* - Start/stop development cycles
|
||||
* - Real-time phase monitoring
|
||||
* - Iteration history view
|
||||
* - Test result visualization
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
PlayCircle,
|
||||
StopCircle,
|
||||
RefreshCw,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
AlertCircle,
|
||||
Clock,
|
||||
ListChecks,
|
||||
Zap,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
} from 'lucide-react';
|
||||
import useWebSocket from 'react-use-websocket';
|
||||
|
||||
interface LoopState {
|
||||
phase: string;
|
||||
iteration: number;
|
||||
current_task: string | null;
|
||||
last_update: string;
|
||||
}
|
||||
|
||||
interface CycleResult {
|
||||
objective: string;
|
||||
status: string;
|
||||
iterations: number;
|
||||
duration_seconds: number;
|
||||
}
|
||||
|
||||
interface TestResult {
|
||||
scenario_id: string;
|
||||
scenario_name: string;
|
||||
passed: boolean;
|
||||
duration_ms: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const PHASE_COLORS: Record<string, string> = {
|
||||
idle: 'bg-gray-500',
|
||||
planning: 'bg-blue-500',
|
||||
implementing: 'bg-purple-500',
|
||||
testing: 'bg-yellow-500',
|
||||
analyzing: 'bg-orange-500',
|
||||
fixing: 'bg-red-500',
|
||||
verifying: 'bg-green-500',
|
||||
};
|
||||
|
||||
const PHASE_ICONS: Record<string, React.ReactNode> = {
|
||||
idle: <Clock className="w-4 h-4" />,
|
||||
planning: <ListChecks className="w-4 h-4" />,
|
||||
implementing: <Zap className="w-4 h-4" />,
|
||||
testing: <RefreshCw className="w-4 h-4 animate-spin" />,
|
||||
analyzing: <AlertCircle className="w-4 h-4" />,
|
||||
fixing: <Zap className="w-4 h-4" />,
|
||||
verifying: <CheckCircle className="w-4 h-4" />,
|
||||
};
|
||||
|
||||
export function DevLoopPanel() {
|
||||
const [state, setState] = useState<LoopState>({
|
||||
phase: 'idle',
|
||||
iteration: 0,
|
||||
current_task: null,
|
||||
last_update: new Date().toISOString(),
|
||||
});
|
||||
const [objective, setObjective] = useState('');
|
||||
const [history, setHistory] = useState<CycleResult[]>([]);
|
||||
const [testResults, setTestResults] = useState<TestResult[]>([]);
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
const [isStarting, setIsStarting] = useState(false);
|
||||
|
||||
// WebSocket connection for real-time updates
|
||||
const { lastJsonMessage, readyState } = useWebSocket(
|
||||
'ws://localhost:8000/api/devloop/ws',
|
||||
{
|
||||
shouldReconnect: () => true,
|
||||
reconnectInterval: 3000,
|
||||
}
|
||||
);
|
||||
|
||||
// Handle WebSocket messages
|
||||
useEffect(() => {
|
||||
if (!lastJsonMessage) return;
|
||||
|
||||
const msg = lastJsonMessage as any;
|
||||
|
||||
switch (msg.type) {
|
||||
case 'connection_ack':
|
||||
case 'state_update':
|
||||
case 'state':
|
||||
if (msg.state) {
|
||||
setState(msg.state);
|
||||
}
|
||||
break;
|
||||
case 'cycle_complete':
|
||||
setHistory(prev => [msg.result, ...prev].slice(0, 10));
|
||||
setIsStarting(false);
|
||||
break;
|
||||
case 'cycle_error':
|
||||
console.error('DevLoop error:', msg.error);
|
||||
setIsStarting(false);
|
||||
break;
|
||||
case 'test_progress':
|
||||
if (msg.result) {
|
||||
setTestResults(prev => [...prev, msg.result]);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}, [lastJsonMessage]);
|
||||
|
||||
// Start a development cycle
|
||||
const startCycle = useCallback(async () => {
|
||||
if (!objective.trim()) return;
|
||||
|
||||
setIsStarting(true);
|
||||
setTestResults([]);
|
||||
|
||||
try {
|
||||
const response = await fetch('http://localhost:8000/api/devloop/start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
objective: objective.trim(),
|
||||
max_iterations: 10,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
console.error('Failed to start cycle:', error);
|
||||
setIsStarting(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to start cycle:', error);
|
||||
setIsStarting(false);
|
||||
}
|
||||
}, [objective]);
|
||||
|
||||
// Stop the current cycle
|
||||
const stopCycle = useCallback(async () => {
|
||||
try {
|
||||
await fetch('http://localhost:8000/api/devloop/stop', {
|
||||
method: 'POST',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to stop cycle:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Quick start: Create support_arm study
|
||||
const quickStartSupportArm = useCallback(() => {
|
||||
setObjective('Create support_arm optimization study with 5 design variables (center_space, arm_thk, arm_angle, end_thk, base_thk), objectives (minimize displacement, minimize mass), and stress constraint (< 30% yield)');
|
||||
// Auto-start after a brief delay
|
||||
setTimeout(() => {
|
||||
startCycle();
|
||||
}, 500);
|
||||
}, [startCycle]);
|
||||
|
||||
const isActive = state.phase !== 'idle';
|
||||
const wsConnected = readyState === WebSocket.OPEN;
|
||||
|
||||
return (
|
||||
<div className="bg-gray-900 rounded-lg border border-gray-700 overflow-hidden">
|
||||
{/* Header */}
|
||||
<div
|
||||
className="flex items-center justify-between px-4 py-3 bg-gray-800 cursor-pointer"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{expanded ? (
|
||||
<ChevronDown className="w-4 h-4 text-gray-400" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4 text-gray-400" />
|
||||
)}
|
||||
<RefreshCw className="w-5 h-5 text-blue-400" />
|
||||
<h3 className="font-semibold text-white">DevLoop Control</h3>
|
||||
</div>
|
||||
|
||||
{/* Status indicator */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={`w-2 h-2 rounded-full ${
|
||||
wsConnected ? 'bg-green-500' : 'bg-red-500'
|
||||
}`}
|
||||
/>
|
||||
<span className={`px-2 py-1 text-xs rounded ${PHASE_COLORS[state.phase]} text-white`}>
|
||||
{state.phase.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Objective Input */}
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1">
|
||||
Development Objective
|
||||
</label>
|
||||
<textarea
|
||||
value={objective}
|
||||
onChange={(e) => setObjective(e.target.value)}
|
||||
placeholder="e.g., Create support_arm optimization study..."
|
||||
className="w-full px-3 py-2 bg-gray-800 border border-gray-600 rounded text-white text-sm resize-none h-20"
|
||||
disabled={isActive}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={quickStartSupportArm}
|
||||
disabled={isActive}
|
||||
className="px-3 py-1.5 bg-purple-600 hover:bg-purple-700 disabled:bg-gray-600 text-white text-sm rounded flex items-center gap-1"
|
||||
>
|
||||
<Zap className="w-4 h-4" />
|
||||
Quick: support_arm
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Control Buttons */}
|
||||
<div className="flex gap-2">
|
||||
{!isActive ? (
|
||||
<button
|
||||
onClick={startCycle}
|
||||
disabled={!objective.trim() || isStarting}
|
||||
className="flex-1 px-4 py-2 bg-green-600 hover:bg-green-700 disabled:bg-gray-600 text-white rounded flex items-center justify-center gap-2"
|
||||
>
|
||||
<PlayCircle className="w-5 h-5" />
|
||||
{isStarting ? 'Starting...' : 'Start Cycle'}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={stopCycle}
|
||||
className="flex-1 px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded flex items-center justify-center gap-2"
|
||||
>
|
||||
<StopCircle className="w-5 h-5" />
|
||||
Stop Cycle
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Current Phase Progress */}
|
||||
{isActive && (
|
||||
<div className="bg-gray-800 rounded p-3 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{PHASE_ICONS[state.phase]}
|
||||
<span className="text-sm text-white font-medium">
|
||||
{state.phase.charAt(0).toUpperCase() + state.phase.slice(1)}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400">
|
||||
Iteration {state.iteration + 1}
|
||||
</span>
|
||||
</div>
|
||||
{state.current_task && (
|
||||
<p className="text-xs text-gray-400 truncate">
|
||||
{state.current_task}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Test Results */}
|
||||
{testResults.length > 0 && (
|
||||
<div className="bg-gray-800 rounded p-3">
|
||||
<h4 className="text-sm font-medium text-white mb-2">Test Results</h4>
|
||||
<div className="space-y-1 max-h-32 overflow-y-auto">
|
||||
{testResults.map((test, i) => (
|
||||
<div
|
||||
key={`${test.scenario_id}-${i}`}
|
||||
className="flex items-center gap-2 text-xs"
|
||||
>
|
||||
{test.passed ? (
|
||||
<CheckCircle className="w-3 h-3 text-green-500" />
|
||||
) : (
|
||||
<XCircle className="w-3 h-3 text-red-500" />
|
||||
)}
|
||||
<span className="text-gray-300 truncate flex-1">
|
||||
{test.scenario_name}
|
||||
</span>
|
||||
<span className="text-gray-500">
|
||||
{test.duration_ms.toFixed(0)}ms
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* History */}
|
||||
{history.length > 0 && (
|
||||
<div className="bg-gray-800 rounded p-3">
|
||||
<h4 className="text-sm font-medium text-white mb-2">Recent Cycles</h4>
|
||||
<div className="space-y-2">
|
||||
{history.slice(0, 3).map((cycle, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center justify-between text-xs"
|
||||
>
|
||||
<span className="text-gray-300 truncate flex-1">
|
||||
{cycle.objective.substring(0, 40)}...
|
||||
</span>
|
||||
<span
|
||||
className={`px-1.5 py-0.5 rounded ${
|
||||
cycle.status === 'completed'
|
||||
? 'bg-green-900 text-green-300'
|
||||
: 'bg-yellow-900 text-yellow-300'
|
||||
}`}
|
||||
>
|
||||
{cycle.status}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Phase Legend */}
|
||||
<div className="grid grid-cols-4 gap-2 text-xs">
|
||||
{Object.entries(PHASE_COLORS).map(([phase, color]) => (
|
||||
<div key={phase} className="flex items-center gap-1">
|
||||
<div className={`w-2 h-2 rounded ${color}`} />
|
||||
<span className="text-gray-400 capitalize">{phase}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DevLoopPanel;
|
||||
@@ -0,0 +1,292 @@
|
||||
/**
|
||||
* ContextFileUpload - Upload context files for study configuration
|
||||
*
|
||||
* Allows uploading markdown, text, PDF, and image files that help
|
||||
* Claude understand optimization goals and generate better documentation.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { Upload, FileText, X, Loader2, AlertCircle, CheckCircle, Trash2, BookOpen } from 'lucide-react';
|
||||
import { intakeApi } from '../../api/intake';
|
||||
|
||||
interface ContextFileUploadProps {
|
||||
studyName: string;
|
||||
onUploadComplete: () => void;
|
||||
}
|
||||
|
||||
interface ContextFile {
|
||||
name: string;
|
||||
path: string;
|
||||
size: number;
|
||||
extension: string;
|
||||
}
|
||||
|
||||
interface FileStatus {
|
||||
file: File;
|
||||
status: 'pending' | 'uploading' | 'success' | 'error';
|
||||
message?: string;
|
||||
}
|
||||
|
||||
const VALID_EXTENSIONS = ['.md', '.txt', '.pdf', '.png', '.jpg', '.jpeg', '.json', '.csv'];
|
||||
|
||||
export const ContextFileUpload: React.FC<ContextFileUploadProps> = ({
|
||||
studyName,
|
||||
onUploadComplete,
|
||||
}) => {
|
||||
const [contextFiles, setContextFiles] = useState<ContextFile[]>([]);
|
||||
const [pendingFiles, setPendingFiles] = useState<FileStatus[]>([]);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Load existing context files
|
||||
const loadContextFiles = useCallback(async () => {
|
||||
try {
|
||||
const response = await intakeApi.listContextFiles(studyName);
|
||||
setContextFiles(response.context_files);
|
||||
} catch (err) {
|
||||
console.error('Failed to load context files:', err);
|
||||
}
|
||||
}, [studyName]);
|
||||
|
||||
useEffect(() => {
|
||||
loadContextFiles();
|
||||
}, [loadContextFiles]);
|
||||
|
||||
const validateFile = (file: File): { valid: boolean; reason?: string } => {
|
||||
const ext = '.' + file.name.split('.').pop()?.toLowerCase();
|
||||
if (!VALID_EXTENSIONS.includes(ext)) {
|
||||
return { valid: false, reason: `Invalid type: ${ext}` };
|
||||
}
|
||||
// Max 10MB per file
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
return { valid: false, reason: 'File too large (max 10MB)' };
|
||||
}
|
||||
return { valid: true };
|
||||
};
|
||||
|
||||
const addFiles = useCallback((newFiles: File[]) => {
|
||||
const validFiles: FileStatus[] = [];
|
||||
|
||||
for (const file of newFiles) {
|
||||
// Skip duplicates
|
||||
if (pendingFiles.some(f => f.file.name === file.name)) {
|
||||
continue;
|
||||
}
|
||||
if (contextFiles.some(f => f.name === file.name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const validation = validateFile(file);
|
||||
if (validation.valid) {
|
||||
validFiles.push({ file, status: 'pending' });
|
||||
} else {
|
||||
validFiles.push({ file, status: 'error', message: validation.reason });
|
||||
}
|
||||
}
|
||||
|
||||
setPendingFiles(prev => [...prev, ...validFiles]);
|
||||
}, [pendingFiles, contextFiles]);
|
||||
|
||||
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const selectedFiles = Array.from(e.target.files || []);
|
||||
addFiles(selectedFiles);
|
||||
e.target.value = '';
|
||||
}, [addFiles]);
|
||||
|
||||
const removeFile = (index: number) => {
|
||||
setPendingFiles(prev => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleUpload = async () => {
|
||||
const filesToUpload = pendingFiles.filter(f => f.status === 'pending');
|
||||
if (filesToUpload.length === 0) return;
|
||||
|
||||
setIsUploading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await intakeApi.uploadContextFiles(
|
||||
studyName,
|
||||
filesToUpload.map(f => f.file)
|
||||
);
|
||||
|
||||
// Update pending file statuses
|
||||
const uploadResults = new Map(
|
||||
response.uploaded_files.map(f => [f.name, f.status === 'uploaded'])
|
||||
);
|
||||
|
||||
setPendingFiles(prev => prev.map(f => {
|
||||
if (f.status !== 'pending') return f;
|
||||
const success = uploadResults.get(f.file.name);
|
||||
return {
|
||||
...f,
|
||||
status: success ? 'success' : 'error',
|
||||
message: success ? undefined : 'Upload failed',
|
||||
};
|
||||
}));
|
||||
|
||||
// Refresh and clear after a moment
|
||||
setTimeout(() => {
|
||||
setPendingFiles(prev => prev.filter(f => f.status !== 'success'));
|
||||
loadContextFiles();
|
||||
onUploadComplete();
|
||||
}, 1500);
|
||||
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Upload failed');
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteFile = async (filename: string) => {
|
||||
try {
|
||||
await intakeApi.deleteContextFile(studyName, filename);
|
||||
loadContextFiles();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Delete failed');
|
||||
}
|
||||
};
|
||||
|
||||
const pendingCount = pendingFiles.filter(f => f.status === 'pending').length;
|
||||
|
||||
const formatSize = (bytes: number) => {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h5 className="text-sm font-medium text-dark-300 flex items-center gap-2">
|
||||
<BookOpen className="w-4 h-4 text-purple-400" />
|
||||
Context Files
|
||||
</h5>
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="flex items-center gap-1.5 px-2 py-1 rounded text-xs font-medium
|
||||
bg-purple-500/10 text-purple-400 hover:bg-purple-500/20
|
||||
transition-colors"
|
||||
>
|
||||
<Upload className="w-3 h-3" />
|
||||
Add Context
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-dark-500">
|
||||
Add .md, .txt, or .pdf files describing your optimization goals. Claude will use these to generate documentation.
|
||||
</p>
|
||||
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<div className="p-2 rounded-lg bg-red-500/10 border border-red-500/30 text-red-400 text-xs flex items-center gap-2">
|
||||
<AlertCircle className="w-3 h-3 flex-shrink-0" />
|
||||
{error}
|
||||
<button onClick={() => setError(null)} className="ml-auto hover:text-white">
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Existing Context Files */}
|
||||
{contextFiles.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{contextFiles.map((file) => (
|
||||
<div
|
||||
key={file.name}
|
||||
className="flex items-center justify-between p-2 rounded-lg bg-purple-500/5 border border-purple-500/20"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="w-4 h-4 text-purple-400" />
|
||||
<span className="text-sm text-white">{file.name}</span>
|
||||
<span className="text-xs text-dark-500">{formatSize(file.size)}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleDeleteFile(file.name)}
|
||||
className="p-1 hover:bg-white/10 rounded text-dark-400 hover:text-red-400"
|
||||
title="Delete file"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pending Files */}
|
||||
{pendingFiles.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{pendingFiles.map((f, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`flex items-center justify-between p-2 rounded-lg
|
||||
${f.status === 'error' ? 'bg-red-500/10' :
|
||||
f.status === 'success' ? 'bg-green-500/10' :
|
||||
'bg-dark-700'}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{f.status === 'pending' && <FileText className="w-4 h-4 text-dark-400" />}
|
||||
{f.status === 'uploading' && <Loader2 className="w-4 h-4 text-purple-400 animate-spin" />}
|
||||
{f.status === 'success' && <CheckCircle className="w-4 h-4 text-green-400" />}
|
||||
{f.status === 'error' && <AlertCircle className="w-4 h-4 text-red-400" />}
|
||||
<span className={`text-sm ${f.status === 'error' ? 'text-red-400' :
|
||||
f.status === 'success' ? 'text-green-400' :
|
||||
'text-white'}`}>
|
||||
{f.file.name}
|
||||
</span>
|
||||
{f.message && (
|
||||
<span className="text-xs text-red-400">({f.message})</span>
|
||||
)}
|
||||
</div>
|
||||
{f.status === 'pending' && (
|
||||
<button
|
||||
onClick={() => removeFile(i)}
|
||||
className="p-1 hover:bg-white/10 rounded text-dark-400 hover:text-white"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Upload Button */}
|
||||
{pendingCount > 0 && (
|
||||
<button
|
||||
onClick={handleUpload}
|
||||
disabled={isUploading}
|
||||
className="w-full flex items-center justify-center gap-2 px-3 py-2 rounded-lg
|
||||
bg-purple-500 text-white text-sm font-medium
|
||||
hover:bg-purple-400 disabled:opacity-50 disabled:cursor-not-allowed
|
||||
transition-colors"
|
||||
>
|
||||
{isUploading ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Uploading...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="w-4 h-4" />
|
||||
Upload {pendingCount} {pendingCount === 1 ? 'File' : 'Files'}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept={VALID_EXTENSIONS.join(',')}
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContextFileUpload;
|
||||
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* CreateStudyCard - Card for initiating new study creation
|
||||
*
|
||||
* Displays a prominent card on the Home page that allows users to
|
||||
* create a new study through the intake workflow.
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Plus, Loader2 } from 'lucide-react';
|
||||
import { intakeApi } from '../../api/intake';
|
||||
import { TopicInfo } from '../../types/intake';
|
||||
|
||||
interface CreateStudyCardProps {
|
||||
topics: TopicInfo[];
|
||||
onStudyCreated: (studyName: string) => void;
|
||||
}
|
||||
|
||||
export const CreateStudyCard: React.FC<CreateStudyCardProps> = ({
|
||||
topics,
|
||||
onStudyCreated,
|
||||
}) => {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [studyName, setStudyName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [selectedTopic, setSelectedTopic] = useState('');
|
||||
const [newTopic, setNewTopic] = useState('');
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!studyName.trim()) {
|
||||
setError('Study name is required');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate study name format
|
||||
const nameRegex = /^[a-z0-9_]+$/;
|
||||
if (!nameRegex.test(studyName)) {
|
||||
setError('Study name must be lowercase with underscores only (e.g., my_study_name)');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsCreating(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const topic = newTopic.trim() || selectedTopic || undefined;
|
||||
await intakeApi.createInbox({
|
||||
study_name: studyName.trim(),
|
||||
description: description.trim() || undefined,
|
||||
topic,
|
||||
});
|
||||
|
||||
// Reset form
|
||||
setStudyName('');
|
||||
setDescription('');
|
||||
setSelectedTopic('');
|
||||
setNewTopic('');
|
||||
setIsExpanded(false);
|
||||
|
||||
onStudyCreated(studyName.trim());
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to create study');
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isExpanded) {
|
||||
return (
|
||||
<button
|
||||
onClick={() => setIsExpanded(true)}
|
||||
className="w-full glass rounded-xl p-6 border border-dashed border-primary-400/30
|
||||
hover:border-primary-400/60 hover:bg-primary-400/5 transition-all
|
||||
flex items-center justify-center gap-3 group"
|
||||
>
|
||||
<div className="w-12 h-12 rounded-xl bg-primary-400/10 flex items-center justify-center
|
||||
group-hover:bg-primary-400/20 transition-colors">
|
||||
<Plus className="w-6 h-6 text-primary-400" />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<h3 className="text-lg font-semibold text-white">Create New Study</h3>
|
||||
<p className="text-sm text-dark-400">Set up a new optimization study</p>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="glass-strong rounded-xl border border-primary-400/20 overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="px-6 py-4 border-b border-primary-400/10 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-primary-400/10 flex items-center justify-center">
|
||||
<Plus className="w-5 h-5 text-primary-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-white">Create New Study</h3>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsExpanded(false)}
|
||||
className="text-dark-400 hover:text-white transition-colors text-sm"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<div className="p-6 space-y-4">
|
||||
{/* Study Name */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-dark-300 mb-2">
|
||||
Study Name <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={studyName}
|
||||
onChange={(e) => setStudyName(e.target.value.toLowerCase().replace(/[^a-z0-9_]/g, '_'))}
|
||||
placeholder="my_optimization_study"
|
||||
className="w-full px-4 py-2.5 rounded-lg bg-dark-800 border border-dark-600
|
||||
text-white placeholder-dark-500 focus:border-primary-400
|
||||
focus:outline-none focus:ring-1 focus:ring-primary-400/50"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-dark-500">
|
||||
Lowercase letters, numbers, and underscores only
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-dark-300 mb-2">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Brief description of the optimization goal..."
|
||||
rows={2}
|
||||
className="w-full px-4 py-2.5 rounded-lg bg-dark-800 border border-dark-600
|
||||
text-white placeholder-dark-500 focus:border-primary-400
|
||||
focus:outline-none focus:ring-1 focus:ring-primary-400/50 resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Topic Selection */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-dark-300 mb-2">
|
||||
Topic Folder
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<select
|
||||
value={selectedTopic}
|
||||
onChange={(e) => {
|
||||
setSelectedTopic(e.target.value);
|
||||
setNewTopic('');
|
||||
}}
|
||||
className="flex-1 px-4 py-2.5 rounded-lg bg-dark-800 border border-dark-600
|
||||
text-white focus:border-primary-400 focus:outline-none
|
||||
focus:ring-1 focus:ring-primary-400/50"
|
||||
>
|
||||
<option value="">Select existing topic...</option>
|
||||
{topics.map((topic) => (
|
||||
<option key={topic.name} value={topic.name}>
|
||||
{topic.name} ({topic.study_count} studies)
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="text-dark-500 self-center">or</span>
|
||||
<input
|
||||
type="text"
|
||||
value={newTopic}
|
||||
onChange={(e) => {
|
||||
setNewTopic(e.target.value.replace(/[^A-Za-z0-9_]/g, '_'));
|
||||
setSelectedTopic('');
|
||||
}}
|
||||
placeholder="New_Topic"
|
||||
className="flex-1 px-4 py-2.5 rounded-lg bg-dark-800 border border-dark-600
|
||||
text-white placeholder-dark-500 focus:border-primary-400
|
||||
focus:outline-none focus:ring-1 focus:ring-primary-400/50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="p-3 rounded-lg bg-red-500/10 border border-red-500/30 text-red-400 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<button
|
||||
onClick={() => setIsExpanded(false)}
|
||||
className="px-4 py-2 rounded-lg border border-dark-600 text-dark-300
|
||||
hover:border-dark-500 hover:text-white transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
disabled={isCreating || !studyName.trim()}
|
||||
className="px-6 py-2 rounded-lg font-medium transition-all disabled:opacity-50
|
||||
flex items-center gap-2"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #00d4e6 0%, #0891b2 100%)',
|
||||
color: '#000',
|
||||
}}
|
||||
>
|
||||
{isCreating ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Plus className="w-4 h-4" />
|
||||
Create Study
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateStudyCard;
|
||||
@@ -0,0 +1,270 @@
|
||||
/**
|
||||
* ExpressionList - Display discovered expressions with selection capability
|
||||
*
|
||||
* Shows expressions from NX introspection, allowing users to:
|
||||
* - View all discovered expressions
|
||||
* - See which are design variable candidates (auto-detected)
|
||||
* - Select/deselect expressions to use as design variables
|
||||
* - View expression values and units
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Check,
|
||||
Search,
|
||||
AlertTriangle,
|
||||
Sparkles,
|
||||
Info,
|
||||
Variable,
|
||||
} from 'lucide-react';
|
||||
import { ExpressionInfo } from '../../types/intake';
|
||||
|
||||
interface ExpressionListProps {
|
||||
/** Expression data from introspection */
|
||||
expressions: ExpressionInfo[];
|
||||
/** Mass from introspection (kg) */
|
||||
massKg?: number | null;
|
||||
/** Currently selected expressions (to become DVs) */
|
||||
selectedExpressions: string[];
|
||||
/** Callback when selection changes */
|
||||
onSelectionChange: (selected: string[]) => void;
|
||||
/** Whether in read-only mode */
|
||||
readOnly?: boolean;
|
||||
/** Compact display mode */
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
export const ExpressionList: React.FC<ExpressionListProps> = ({
|
||||
expressions,
|
||||
massKg,
|
||||
selectedExpressions,
|
||||
onSelectionChange,
|
||||
readOnly = false,
|
||||
compact = false,
|
||||
}) => {
|
||||
const [filter, setFilter] = useState('');
|
||||
const [showCandidatesOnly, setShowCandidatesOnly] = useState(true);
|
||||
|
||||
// Filter expressions based on search and candidate toggle
|
||||
const filteredExpressions = expressions.filter((expr) => {
|
||||
const matchesSearch = filter === '' ||
|
||||
expr.name.toLowerCase().includes(filter.toLowerCase());
|
||||
const matchesCandidate = !showCandidatesOnly || expr.is_candidate;
|
||||
return matchesSearch && matchesCandidate;
|
||||
});
|
||||
|
||||
// Sort: candidates first, then by confidence, then alphabetically
|
||||
const sortedExpressions = [...filteredExpressions].sort((a, b) => {
|
||||
if (a.is_candidate !== b.is_candidate) {
|
||||
return a.is_candidate ? -1 : 1;
|
||||
}
|
||||
if (a.confidence !== b.confidence) {
|
||||
return b.confidence - a.confidence;
|
||||
}
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
const toggleExpression = (name: string) => {
|
||||
if (readOnly) return;
|
||||
|
||||
if (selectedExpressions.includes(name)) {
|
||||
onSelectionChange(selectedExpressions.filter(n => n !== name));
|
||||
} else {
|
||||
onSelectionChange([...selectedExpressions, name]);
|
||||
}
|
||||
};
|
||||
|
||||
const selectAllCandidates = () => {
|
||||
const candidateNames = expressions
|
||||
.filter(e => e.is_candidate)
|
||||
.map(e => e.name);
|
||||
onSelectionChange(candidateNames);
|
||||
};
|
||||
|
||||
const clearSelection = () => {
|
||||
onSelectionChange([]);
|
||||
};
|
||||
|
||||
const candidateCount = expressions.filter(e => e.is_candidate).length;
|
||||
|
||||
if (expressions.length === 0) {
|
||||
return (
|
||||
<div className="p-4 rounded-lg bg-dark-700/50 border border-dark-600">
|
||||
<div className="flex items-center gap-2 text-dark-400">
|
||||
<AlertTriangle className="w-4 h-4" />
|
||||
<span>No expressions found. Run introspection to discover model parameters.</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Header with stats */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<h5 className="text-sm font-medium text-dark-300 flex items-center gap-2">
|
||||
<Variable className="w-4 h-4" />
|
||||
Discovered Expressions
|
||||
</h5>
|
||||
<span className="text-xs text-dark-500">
|
||||
{expressions.length} total, {candidateCount} candidates
|
||||
</span>
|
||||
{massKg && (
|
||||
<span className="text-xs text-primary-400">
|
||||
Mass: {massKg.toFixed(3)} kg
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{!readOnly && selectedExpressions.length > 0 && (
|
||||
<span className="text-xs text-green-400">
|
||||
{selectedExpressions.length} selected
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
{!compact && (
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Search */}
|
||||
<div className="relative flex-1 max-w-xs">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-dark-500" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search expressions..."
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
className="w-full pl-8 pr-3 py-1.5 text-sm rounded-lg bg-dark-700 border border-dark-600
|
||||
text-white placeholder-dark-500 focus:border-primary-500/50 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Show candidates only toggle */}
|
||||
<label className="flex items-center gap-2 text-xs text-dark-400 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showCandidatesOnly}
|
||||
onChange={(e) => setShowCandidatesOnly(e.target.checked)}
|
||||
className="w-4 h-4 rounded border-dark-500 bg-dark-700 text-primary-500
|
||||
focus:ring-primary-500/30"
|
||||
/>
|
||||
Candidates only
|
||||
</label>
|
||||
|
||||
{/* Quick actions */}
|
||||
{!readOnly && (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={selectAllCandidates}
|
||||
className="px-2 py-1 text-xs rounded bg-primary-500/10 text-primary-400
|
||||
hover:bg-primary-500/20 transition-colors"
|
||||
>
|
||||
Select all candidates
|
||||
</button>
|
||||
<button
|
||||
onClick={clearSelection}
|
||||
className="px-2 py-1 text-xs rounded bg-dark-600 text-dark-400
|
||||
hover:bg-dark-500 transition-colors"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Expression list */}
|
||||
<div className={`rounded-lg border border-dark-600 overflow-hidden ${
|
||||
compact ? 'max-h-48' : 'max-h-72'
|
||||
} overflow-y-auto`}>
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-dark-700 sticky top-0">
|
||||
<tr>
|
||||
{!readOnly && (
|
||||
<th className="w-8 px-2 py-2"></th>
|
||||
)}
|
||||
<th className="px-3 py-2 text-left text-dark-400 font-medium">Name</th>
|
||||
<th className="px-3 py-2 text-right text-dark-400 font-medium w-24">Value</th>
|
||||
<th className="px-3 py-2 text-left text-dark-400 font-medium w-16">Units</th>
|
||||
<th className="px-3 py-2 text-center text-dark-400 font-medium w-20">Candidate</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-dark-700">
|
||||
{sortedExpressions.map((expr) => {
|
||||
const isSelected = selectedExpressions.includes(expr.name);
|
||||
return (
|
||||
<tr
|
||||
key={expr.name}
|
||||
onClick={() => toggleExpression(expr.name)}
|
||||
className={`
|
||||
${readOnly ? '' : 'cursor-pointer hover:bg-dark-700/50'}
|
||||
${isSelected ? 'bg-primary-500/10' : ''}
|
||||
transition-colors
|
||||
`}
|
||||
>
|
||||
{!readOnly && (
|
||||
<td className="px-2 py-2">
|
||||
<div className={`w-5 h-5 rounded border flex items-center justify-center
|
||||
${isSelected
|
||||
? 'bg-primary-500 border-primary-500'
|
||||
: 'border-dark-500 bg-dark-700'
|
||||
}`}
|
||||
>
|
||||
{isSelected && <Check className="w-3 h-3 text-white" />}
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<code className={`text-xs ${isSelected ? 'text-primary-300' : 'text-white'}`}>
|
||||
{expr.name}
|
||||
</code>
|
||||
{expr.formula && (
|
||||
<span className="text-xs text-dark-500" title={expr.formula}>
|
||||
<Info className="w-3 h-3" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right font-mono text-xs text-dark-300">
|
||||
{expr.value !== null ? expr.value.toFixed(3) : '-'}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-xs text-dark-400">
|
||||
{expr.units || '-'}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
{expr.is_candidate ? (
|
||||
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs
|
||||
bg-green-500/10 text-green-400">
|
||||
<Sparkles className="w-3 h-3" />
|
||||
{Math.round(expr.confidence * 100)}%
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-dark-500">-</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{sortedExpressions.length === 0 && (
|
||||
<div className="px-4 py-8 text-center text-dark-500">
|
||||
No expressions match your filter
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Help text */}
|
||||
{!readOnly && !compact && (
|
||||
<p className="text-xs text-dark-500">
|
||||
Select expressions to use as design variables. Candidates (marked with %) are
|
||||
automatically identified based on naming patterns and units.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExpressionList;
|
||||
@@ -0,0 +1,348 @@
|
||||
/**
|
||||
* FileDropzone - Drag and drop file upload component
|
||||
*
|
||||
* Supports drag-and-drop or click-to-browse for model files.
|
||||
* Accepts .prt, .sim, .fem, .afem files.
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useRef } from 'react';
|
||||
import { Upload, FileText, X, Loader2, AlertCircle, CheckCircle } from 'lucide-react';
|
||||
import { intakeApi } from '../../api/intake';
|
||||
|
||||
interface FileDropzoneProps {
|
||||
studyName: string;
|
||||
onUploadComplete: () => void;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
interface FileStatus {
|
||||
file: File;
|
||||
status: 'pending' | 'uploading' | 'success' | 'error';
|
||||
message?: string;
|
||||
}
|
||||
|
||||
const VALID_EXTENSIONS = ['.prt', '.sim', '.fem', '.afem'];
|
||||
|
||||
export const FileDropzone: React.FC<FileDropzoneProps> = ({
|
||||
studyName,
|
||||
onUploadComplete,
|
||||
compact = false,
|
||||
}) => {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [files, setFiles] = useState<FileStatus[]>([]);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const validateFile = (file: File): { valid: boolean; reason?: string } => {
|
||||
const ext = '.' + file.name.split('.').pop()?.toLowerCase();
|
||||
if (!VALID_EXTENSIONS.includes(ext)) {
|
||||
return { valid: false, reason: `Invalid type: ${ext}` };
|
||||
}
|
||||
// Max 500MB per file
|
||||
if (file.size > 500 * 1024 * 1024) {
|
||||
return { valid: false, reason: 'File too large (max 500MB)' };
|
||||
}
|
||||
return { valid: true };
|
||||
};
|
||||
|
||||
const handleDragEnter = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(true);
|
||||
}, []);
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
}, []);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}, []);
|
||||
|
||||
const addFiles = useCallback((newFiles: File[]) => {
|
||||
const validFiles: FileStatus[] = [];
|
||||
|
||||
for (const file of newFiles) {
|
||||
// Skip duplicates
|
||||
if (files.some(f => f.file.name === file.name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const validation = validateFile(file);
|
||||
if (validation.valid) {
|
||||
validFiles.push({ file, status: 'pending' });
|
||||
} else {
|
||||
validFiles.push({ file, status: 'error', message: validation.reason });
|
||||
}
|
||||
}
|
||||
|
||||
setFiles(prev => [...prev, ...validFiles]);
|
||||
}, [files]);
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
|
||||
const droppedFiles = Array.from(e.dataTransfer.files);
|
||||
addFiles(droppedFiles);
|
||||
}, [addFiles]);
|
||||
|
||||
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const selectedFiles = Array.from(e.target.files || []);
|
||||
addFiles(selectedFiles);
|
||||
// Reset input so the same file can be selected again
|
||||
e.target.value = '';
|
||||
}, [addFiles]);
|
||||
|
||||
const removeFile = (index: number) => {
|
||||
setFiles(prev => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleUpload = async () => {
|
||||
const pendingFiles = files.filter(f => f.status === 'pending');
|
||||
if (pendingFiles.length === 0) return;
|
||||
|
||||
setIsUploading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Upload files
|
||||
const response = await intakeApi.uploadFiles(
|
||||
studyName,
|
||||
pendingFiles.map(f => f.file)
|
||||
);
|
||||
|
||||
// Update file statuses based on response
|
||||
const uploadResults = new Map(
|
||||
response.uploaded_files.map(f => [f.name, f.status === 'uploaded'])
|
||||
);
|
||||
|
||||
setFiles(prev => prev.map(f => {
|
||||
if (f.status !== 'pending') return f;
|
||||
const success = uploadResults.get(f.file.name);
|
||||
return {
|
||||
...f,
|
||||
status: success ? 'success' : 'error',
|
||||
message: success ? undefined : 'Upload failed',
|
||||
};
|
||||
}));
|
||||
|
||||
// Clear successful uploads after a moment and refresh
|
||||
setTimeout(() => {
|
||||
setFiles(prev => prev.filter(f => f.status !== 'success'));
|
||||
onUploadComplete();
|
||||
}, 1500);
|
||||
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Upload failed');
|
||||
setFiles(prev => prev.map(f =>
|
||||
f.status === 'pending'
|
||||
? { ...f, status: 'error', message: 'Upload failed' }
|
||||
: f
|
||||
));
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const pendingCount = files.filter(f => f.status === 'pending').length;
|
||||
|
||||
if (compact) {
|
||||
// Compact inline version
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm font-medium
|
||||
bg-dark-700 text-dark-300 hover:bg-dark-600 hover:text-white
|
||||
transition-colors"
|
||||
>
|
||||
<Upload className="w-4 h-4" />
|
||||
Add Files
|
||||
</button>
|
||||
{pendingCount > 0 && (
|
||||
<button
|
||||
onClick={handleUpload}
|
||||
disabled={isUploading}
|
||||
className="flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm font-medium
|
||||
bg-primary-500/10 text-primary-400 hover:bg-primary-500/20
|
||||
disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{isUploading ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Upload className="w-4 h-4" />
|
||||
)}
|
||||
Upload {pendingCount} {pendingCount === 1 ? 'File' : 'Files'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* File list */}
|
||||
{files.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{files.map((f, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={`inline-flex items-center gap-1.5 px-2 py-1 rounded text-xs
|
||||
${f.status === 'error' ? 'bg-red-500/10 text-red-400' :
|
||||
f.status === 'success' ? 'bg-green-500/10 text-green-400' :
|
||||
'bg-dark-700 text-dark-300'}`}
|
||||
>
|
||||
{f.status === 'uploading' && <Loader2 className="w-3 h-3 animate-spin" />}
|
||||
{f.status === 'success' && <CheckCircle className="w-3 h-3" />}
|
||||
{f.status === 'error' && <AlertCircle className="w-3 h-3" />}
|
||||
{f.file.name}
|
||||
{f.status === 'pending' && (
|
||||
<button onClick={() => removeFile(i)} className="hover:text-white">
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept={VALID_EXTENSIONS.join(',')}
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Full dropzone version
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Dropzone */}
|
||||
<div
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className={`
|
||||
relative border-2 border-dashed rounded-xl p-6 cursor-pointer
|
||||
transition-all duration-200
|
||||
${isDragging
|
||||
? 'border-primary-400 bg-primary-400/5'
|
||||
: 'border-dark-600 hover:border-primary-400/50 hover:bg-white/5'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<div className={`w-12 h-12 rounded-full flex items-center justify-center mb-3
|
||||
${isDragging ? 'bg-primary-400/20 text-primary-400' : 'bg-dark-700 text-dark-400'}`}>
|
||||
<Upload className="w-6 h-6" />
|
||||
</div>
|
||||
<p className="text-white font-medium mb-1">
|
||||
{isDragging ? 'Drop files here' : 'Drop model files here'}
|
||||
</p>
|
||||
<p className="text-sm text-dark-400">
|
||||
or <span className="text-primary-400">click to browse</span>
|
||||
</p>
|
||||
<p className="text-xs text-dark-500 mt-2">
|
||||
Accepts: {VALID_EXTENSIONS.join(', ')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="p-3 rounded-lg bg-red-500/10 border border-red-500/30 text-red-400 text-sm flex items-center gap-2">
|
||||
<AlertCircle className="w-4 h-4 flex-shrink-0" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* File List */}
|
||||
{files.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h5 className="text-sm font-medium text-dark-300">Files to Upload</h5>
|
||||
<div className="space-y-1">
|
||||
{files.map((f, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`flex items-center justify-between p-2 rounded-lg
|
||||
${f.status === 'error' ? 'bg-red-500/10' :
|
||||
f.status === 'success' ? 'bg-green-500/10' :
|
||||
'bg-dark-700'}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{f.status === 'pending' && <FileText className="w-4 h-4 text-dark-400" />}
|
||||
{f.status === 'uploading' && <Loader2 className="w-4 h-4 text-primary-400 animate-spin" />}
|
||||
{f.status === 'success' && <CheckCircle className="w-4 h-4 text-green-400" />}
|
||||
{f.status === 'error' && <AlertCircle className="w-4 h-4 text-red-400" />}
|
||||
<span className={`text-sm ${f.status === 'error' ? 'text-red-400' :
|
||||
f.status === 'success' ? 'text-green-400' :
|
||||
'text-white'}`}>
|
||||
{f.file.name}
|
||||
</span>
|
||||
{f.message && (
|
||||
<span className="text-xs text-red-400">({f.message})</span>
|
||||
)}
|
||||
</div>
|
||||
{f.status === 'pending' && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeFile(i);
|
||||
}}
|
||||
className="p-1 hover:bg-white/10 rounded text-dark-400 hover:text-white"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Upload Button */}
|
||||
{pendingCount > 0 && (
|
||||
<button
|
||||
onClick={handleUpload}
|
||||
disabled={isUploading}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-2 rounded-lg
|
||||
bg-primary-500 text-white font-medium
|
||||
hover:bg-primary-400 disabled:opacity-50 disabled:cursor-not-allowed
|
||||
transition-colors"
|
||||
>
|
||||
{isUploading ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Uploading...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="w-4 h-4" />
|
||||
Upload {pendingCount} {pendingCount === 1 ? 'File' : 'Files'}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept={VALID_EXTENSIONS.join(',')}
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FileDropzone;
|
||||
@@ -0,0 +1,272 @@
|
||||
/**
|
||||
* FinalizeModal - Modal for finalizing an inbox study
|
||||
*
|
||||
* Allows user to:
|
||||
* - Select/create topic folder
|
||||
* - Choose whether to run baseline FEA
|
||||
* - See progress during finalization
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
X,
|
||||
Folder,
|
||||
CheckCircle,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
} from 'lucide-react';
|
||||
import { intakeApi } from '../../api/intake';
|
||||
import { TopicInfo, InboxStudyDetail } from '../../types/intake';
|
||||
|
||||
interface FinalizeModalProps {
|
||||
studyName: string;
|
||||
topics: TopicInfo[];
|
||||
onClose: () => void;
|
||||
onFinalized: (finalPath: string) => void;
|
||||
}
|
||||
|
||||
export const FinalizeModal: React.FC<FinalizeModalProps> = ({
|
||||
studyName,
|
||||
topics,
|
||||
onClose,
|
||||
onFinalized,
|
||||
}) => {
|
||||
const [studyDetail, setStudyDetail] = useState<InboxStudyDetail | null>(null);
|
||||
const [selectedTopic, setSelectedTopic] = useState('');
|
||||
const [newTopic, setNewTopic] = useState('');
|
||||
const [runBaseline, setRunBaseline] = useState(true);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isFinalizing, setIsFinalizing] = useState(false);
|
||||
const [progress, setProgress] = useState<string>('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Load study detail
|
||||
useEffect(() => {
|
||||
const loadStudy = async () => {
|
||||
try {
|
||||
const detail = await intakeApi.getInboxStudy(studyName);
|
||||
setStudyDetail(detail);
|
||||
// Pre-select topic if set in spec
|
||||
if (detail.spec.meta.topic) {
|
||||
setSelectedTopic(detail.spec.meta.topic);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load study');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
loadStudy();
|
||||
}, [studyName]);
|
||||
|
||||
const handleFinalize = async () => {
|
||||
const topic = newTopic.trim() || selectedTopic;
|
||||
if (!topic) {
|
||||
setError('Please select or create a topic folder');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsFinalizing(true);
|
||||
setError(null);
|
||||
setProgress('Starting finalization...');
|
||||
|
||||
try {
|
||||
setProgress('Validating study configuration...');
|
||||
await new Promise((r) => setTimeout(r, 500)); // Visual feedback
|
||||
|
||||
if (runBaseline) {
|
||||
setProgress('Running baseline FEA solve...');
|
||||
}
|
||||
|
||||
const result = await intakeApi.finalize(studyName, {
|
||||
topic,
|
||||
run_baseline: runBaseline,
|
||||
});
|
||||
|
||||
setProgress('Finalization complete!');
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
|
||||
onFinalized(result.final_path);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Finalization failed');
|
||||
setIsFinalizing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-dark-900/80 backdrop-blur-sm">
|
||||
<div className="w-full max-w-lg glass-strong rounded-xl border border-primary-400/20 overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="px-6 py-4 border-b border-primary-400/10 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-primary-400/10 flex items-center justify-center">
|
||||
<Folder className="w-5 h-5 text-primary-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white">Finalize Study</h3>
|
||||
<p className="text-sm text-dark-400">{studyName}</p>
|
||||
</div>
|
||||
</div>
|
||||
{!isFinalizing && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 hover:bg-white/5 rounded-lg transition-colors text-dark-400 hover:text-white"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 space-y-6">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-primary-400" />
|
||||
</div>
|
||||
) : isFinalizing ? (
|
||||
/* Progress View */
|
||||
<div className="text-center py-8 space-y-4">
|
||||
<Loader2 className="w-12 h-12 animate-spin text-primary-400 mx-auto" />
|
||||
<p className="text-white font-medium">{progress}</p>
|
||||
<p className="text-sm text-dark-400">
|
||||
Please wait while your study is being finalized...
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<div className="p-3 rounded-lg bg-red-500/10 border border-red-500/30 text-red-400 text-sm flex items-center gap-2">
|
||||
<AlertCircle className="w-4 h-4 flex-shrink-0" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Study Summary */}
|
||||
{studyDetail && (
|
||||
<div className="p-4 rounded-lg bg-dark-800 space-y-2">
|
||||
<h4 className="text-sm font-medium text-dark-300">Study Summary</h4>
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-dark-500">Status:</span>
|
||||
<span className="ml-2 text-white capitalize">
|
||||
{studyDetail.spec.meta.status}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-dark-500">Model Files:</span>
|
||||
<span className="ml-2 text-white">
|
||||
{studyDetail.files.sim.length + studyDetail.files.prt.length + studyDetail.files.fem.length}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-dark-500">Design Variables:</span>
|
||||
<span className="ml-2 text-white">
|
||||
{studyDetail.spec.design_variables?.length || 0}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-dark-500">Objectives:</span>
|
||||
<span className="ml-2 text-white">
|
||||
{studyDetail.spec.objectives?.length || 0}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Topic Selection */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-dark-300 mb-2">
|
||||
Topic Folder <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<select
|
||||
value={selectedTopic}
|
||||
onChange={(e) => {
|
||||
setSelectedTopic(e.target.value);
|
||||
setNewTopic('');
|
||||
}}
|
||||
className="flex-1 px-4 py-2.5 rounded-lg bg-dark-800 border border-dark-600
|
||||
text-white focus:border-primary-400 focus:outline-none
|
||||
focus:ring-1 focus:ring-primary-400/50"
|
||||
>
|
||||
<option value="">Select existing topic...</option>
|
||||
{topics.map((topic) => (
|
||||
<option key={topic.name} value={topic.name}>
|
||||
{topic.name} ({topic.study_count} studies)
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="text-dark-500 self-center">or</span>
|
||||
<input
|
||||
type="text"
|
||||
value={newTopic}
|
||||
onChange={(e) => {
|
||||
setNewTopic(e.target.value.replace(/[^A-Za-z0-9_]/g, '_'));
|
||||
setSelectedTopic('');
|
||||
}}
|
||||
placeholder="New_Topic"
|
||||
className="flex-1 px-4 py-2.5 rounded-lg bg-dark-800 border border-dark-600
|
||||
text-white placeholder-dark-500 focus:border-primary-400
|
||||
focus:outline-none focus:ring-1 focus:ring-primary-400/50"
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-dark-500">
|
||||
Study will be created at: studies/{newTopic || selectedTopic || '<topic>'}/{studyName}/
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Baseline Option */}
|
||||
<div>
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={runBaseline}
|
||||
onChange={(e) => setRunBaseline(e.target.checked)}
|
||||
className="w-4 h-4 rounded border-dark-600 bg-dark-800 text-primary-400
|
||||
focus:ring-primary-400/50"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-white font-medium">Run baseline FEA solve</span>
|
||||
<p className="text-xs text-dark-500">
|
||||
Validates the model and captures baseline performance metrics
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
{!isLoading && !isFinalizing && (
|
||||
<div className="px-6 py-4 border-t border-primary-400/10 flex justify-end gap-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 rounded-lg border border-dark-600 text-dark-300
|
||||
hover:border-dark-500 hover:text-white transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleFinalize}
|
||||
disabled={!selectedTopic && !newTopic.trim()}
|
||||
className="px-6 py-2 rounded-lg font-medium transition-all disabled:opacity-50
|
||||
flex items-center gap-2"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #00d4e6 0%, #0891b2 100%)',
|
||||
color: '#000',
|
||||
}}
|
||||
>
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
Finalize Study
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FinalizeModal;
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* InboxSection - Section displaying inbox studies on Home page
|
||||
*
|
||||
* Shows the "Create New Study" card and lists all inbox studies
|
||||
* with their current status and available actions.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Inbox, RefreshCw, ChevronDown, ChevronRight } from 'lucide-react';
|
||||
import { intakeApi } from '../../api/intake';
|
||||
import { InboxStudy, TopicInfo } from '../../types/intake';
|
||||
import { CreateStudyCard } from './CreateStudyCard';
|
||||
import { InboxStudyCard } from './InboxStudyCard';
|
||||
import { FinalizeModal } from './FinalizeModal';
|
||||
|
||||
interface InboxSectionProps {
|
||||
onStudyFinalized?: () => void;
|
||||
}
|
||||
|
||||
export const InboxSection: React.FC<InboxSectionProps> = ({ onStudyFinalized }) => {
|
||||
const [inboxStudies, setInboxStudies] = useState<InboxStudy[]>([]);
|
||||
const [topics, setTopics] = useState<TopicInfo[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isExpanded, setIsExpanded] = useState(true);
|
||||
const [selectedStudyForFinalize, setSelectedStudyForFinalize] = useState<string | null>(null);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const [inboxResponse, topicsResponse] = await Promise.all([
|
||||
intakeApi.listInbox(),
|
||||
intakeApi.listTopics(),
|
||||
]);
|
||||
setInboxStudies(inboxResponse.studies);
|
||||
setTopics(topicsResponse.topics);
|
||||
} catch (err) {
|
||||
console.error('Failed to load inbox data:', err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
const handleStudyCreated = (_studyName: string) => {
|
||||
loadData();
|
||||
};
|
||||
|
||||
const handleStudyFinalized = (_finalPath: string) => {
|
||||
setSelectedStudyForFinalize(null);
|
||||
loadData();
|
||||
onStudyFinalized?.();
|
||||
};
|
||||
|
||||
const pendingStudies = inboxStudies.filter(
|
||||
(s) => !['ready', 'running', 'completed'].includes(s.status)
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Section Header */}
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="w-full flex items-center justify-between px-2 py-1 hover:bg-white/5 rounded-lg transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-primary-400/10 flex items-center justify-center">
|
||||
<Inbox className="w-4 h-4 text-primary-400" />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<h2 className="text-lg font-semibold text-white">Study Inbox</h2>
|
||||
<p className="text-sm text-dark-400">
|
||||
{pendingStudies.length} pending studies
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
loadData();
|
||||
}}
|
||||
className="p-2 hover:bg-white/5 rounded-lg transition-colors text-dark-400 hover:text-primary-400"
|
||||
title="Refresh"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="w-5 h-5 text-dark-400" />
|
||||
) : (
|
||||
<ChevronRight className="w-5 h-5 text-dark-400" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Content */}
|
||||
{isExpanded && (
|
||||
<div className="space-y-4">
|
||||
{/* Create Study Card */}
|
||||
<CreateStudyCard topics={topics} onStudyCreated={handleStudyCreated} />
|
||||
|
||||
{/* Inbox Studies List */}
|
||||
{inboxStudies.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-medium text-dark-400 px-2">
|
||||
Inbox Studies ({inboxStudies.length})
|
||||
</h3>
|
||||
{inboxStudies.map((study) => (
|
||||
<InboxStudyCard
|
||||
key={study.study_name}
|
||||
study={study}
|
||||
onRefresh={loadData}
|
||||
onSelect={setSelectedStudyForFinalize}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{!isLoading && inboxStudies.length === 0 && (
|
||||
<div className="text-center py-8 text-dark-400">
|
||||
<Inbox className="w-12 h-12 mx-auto mb-3 opacity-30" />
|
||||
<p>No studies in inbox</p>
|
||||
<p className="text-sm text-dark-500">
|
||||
Create a new study to get started
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Finalize Modal */}
|
||||
{selectedStudyForFinalize && (
|
||||
<FinalizeModal
|
||||
studyName={selectedStudyForFinalize}
|
||||
topics={topics}
|
||||
onClose={() => setSelectedStudyForFinalize(null)}
|
||||
onFinalized={handleStudyFinalized}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default InboxSection;
|
||||
@@ -0,0 +1,455 @@
|
||||
/**
|
||||
* InboxStudyCard - Card displaying an inbox study with actions
|
||||
*
|
||||
* Shows study status, files, and provides actions for:
|
||||
* - Running introspection
|
||||
* - Generating README
|
||||
* - Finalizing the study
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
FileText,
|
||||
Folder,
|
||||
Trash2,
|
||||
Play,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
AlertCircle,
|
||||
Loader2,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Sparkles,
|
||||
ArrowRight,
|
||||
Eye,
|
||||
Save,
|
||||
} from 'lucide-react';
|
||||
import { InboxStudy, SpecStatus, ExpressionInfo, InboxStudyDetail } from '../../types/intake';
|
||||
import { intakeApi } from '../../api/intake';
|
||||
import { FileDropzone } from './FileDropzone';
|
||||
import { ContextFileUpload } from './ContextFileUpload';
|
||||
import { ExpressionList } from './ExpressionList';
|
||||
|
||||
interface InboxStudyCardProps {
|
||||
study: InboxStudy;
|
||||
onRefresh: () => void;
|
||||
onSelect: (studyName: string) => void;
|
||||
}
|
||||
|
||||
const statusConfig: Record<SpecStatus, { icon: React.ReactNode; color: string; label: string }> = {
|
||||
draft: {
|
||||
icon: <Clock className="w-4 h-4" />,
|
||||
color: 'text-dark-400 bg-dark-600',
|
||||
label: 'Draft',
|
||||
},
|
||||
introspected: {
|
||||
icon: <CheckCircle className="w-4 h-4" />,
|
||||
color: 'text-blue-400 bg-blue-500/10',
|
||||
label: 'Introspected',
|
||||
},
|
||||
configured: {
|
||||
icon: <CheckCircle className="w-4 h-4" />,
|
||||
color: 'text-green-400 bg-green-500/10',
|
||||
label: 'Configured',
|
||||
},
|
||||
validated: {
|
||||
icon: <CheckCircle className="w-4 h-4" />,
|
||||
color: 'text-green-400 bg-green-500/10',
|
||||
label: 'Validated',
|
||||
},
|
||||
ready: {
|
||||
icon: <CheckCircle className="w-4 h-4" />,
|
||||
color: 'text-primary-400 bg-primary-500/10',
|
||||
label: 'Ready',
|
||||
},
|
||||
running: {
|
||||
icon: <Play className="w-4 h-4" />,
|
||||
color: 'text-yellow-400 bg-yellow-500/10',
|
||||
label: 'Running',
|
||||
},
|
||||
completed: {
|
||||
icon: <CheckCircle className="w-4 h-4" />,
|
||||
color: 'text-green-400 bg-green-500/10',
|
||||
label: 'Completed',
|
||||
},
|
||||
failed: {
|
||||
icon: <AlertCircle className="w-4 h-4" />,
|
||||
color: 'text-red-400 bg-red-500/10',
|
||||
label: 'Failed',
|
||||
},
|
||||
};
|
||||
|
||||
export const InboxStudyCard: React.FC<InboxStudyCardProps> = ({
|
||||
study,
|
||||
onRefresh,
|
||||
onSelect,
|
||||
}) => {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [isIntrospecting, setIsIntrospecting] = useState(false);
|
||||
const [isGeneratingReadme, setIsGeneratingReadme] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Introspection data (fetched when expanded)
|
||||
const [studyDetail, setStudyDetail] = useState<InboxStudyDetail | null>(null);
|
||||
const [isLoadingDetail, setIsLoadingDetail] = useState(false);
|
||||
const [selectedExpressions, setSelectedExpressions] = useState<string[]>([]);
|
||||
const [showReadme, setShowReadme] = useState(false);
|
||||
const [readmeContent, setReadmeContent] = useState<string | null>(null);
|
||||
const [isSavingDVs, setIsSavingDVs] = useState(false);
|
||||
const [dvSaveMessage, setDvSaveMessage] = useState<string | null>(null);
|
||||
|
||||
const status = statusConfig[study.status] || statusConfig.draft;
|
||||
|
||||
// Fetch study details when expanded for the first time
|
||||
useEffect(() => {
|
||||
if (isExpanded && !studyDetail && !isLoadingDetail) {
|
||||
loadStudyDetail();
|
||||
}
|
||||
}, [isExpanded]);
|
||||
|
||||
const loadStudyDetail = async () => {
|
||||
setIsLoadingDetail(true);
|
||||
try {
|
||||
const detail = await intakeApi.getInboxStudy(study.study_name);
|
||||
setStudyDetail(detail);
|
||||
|
||||
// Auto-select candidate expressions
|
||||
const introspection = detail.spec?.model?.introspection;
|
||||
if (introspection?.expressions) {
|
||||
const candidates = introspection.expressions
|
||||
.filter((e: ExpressionInfo) => e.is_candidate)
|
||||
.map((e: ExpressionInfo) => e.name);
|
||||
setSelectedExpressions(candidates);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load study detail:', err);
|
||||
} finally {
|
||||
setIsLoadingDetail(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleIntrospect = async () => {
|
||||
setIsIntrospecting(true);
|
||||
setError(null);
|
||||
try {
|
||||
await intakeApi.introspect({ study_name: study.study_name });
|
||||
// Reload study detail to get new introspection data
|
||||
await loadStudyDetail();
|
||||
onRefresh();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Introspection failed');
|
||||
} finally {
|
||||
setIsIntrospecting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerateReadme = async () => {
|
||||
setIsGeneratingReadme(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await intakeApi.generateReadme(study.study_name);
|
||||
setReadmeContent(response.content);
|
||||
setShowReadme(true);
|
||||
onRefresh();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'README generation failed');
|
||||
} finally {
|
||||
setIsGeneratingReadme(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!confirm(`Delete inbox study "${study.study_name}"? This cannot be undone.`)) {
|
||||
return;
|
||||
}
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
await intakeApi.deleteInboxStudy(study.study_name);
|
||||
onRefresh();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Delete failed');
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveDesignVariables = async () => {
|
||||
if (selectedExpressions.length === 0) {
|
||||
setError('Please select at least one expression to use as a design variable');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSavingDVs(true);
|
||||
setError(null);
|
||||
setDvSaveMessage(null);
|
||||
|
||||
try {
|
||||
const result = await intakeApi.createDesignVariables(study.study_name, selectedExpressions);
|
||||
setDvSaveMessage(`Created ${result.total_created} design variable(s)`);
|
||||
// Reload study detail to see updated spec
|
||||
await loadStudyDetail();
|
||||
onRefresh();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to save design variables');
|
||||
} finally {
|
||||
setIsSavingDVs(false);
|
||||
}
|
||||
};
|
||||
|
||||
const canIntrospect = study.status === 'draft' && study.model_files.length > 0;
|
||||
const canGenerateReadme = study.status === 'introspected';
|
||||
const canFinalize = ['introspected', 'configured'].includes(study.status);
|
||||
const canSaveDVs = study.status === 'introspected' && selectedExpressions.length > 0;
|
||||
|
||||
return (
|
||||
<div className="glass rounded-xl border border-primary-400/10 overflow-hidden">
|
||||
{/* Header - Always visible */}
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="w-full px-4 py-3 flex items-center justify-between hover:bg-white/5 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-dark-700 flex items-center justify-center">
|
||||
<Folder className="w-5 h-5 text-primary-400" />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<h4 className="text-white font-medium">{study.study_name}</h4>
|
||||
{study.description && (
|
||||
<p className="text-sm text-dark-400 truncate max-w-[300px]">
|
||||
{study.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Status Badge */}
|
||||
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium ${status.color}`}>
|
||||
{status.icon}
|
||||
{status.label}
|
||||
</span>
|
||||
{/* File Count */}
|
||||
<span className="text-dark-500 text-sm">
|
||||
{study.model_files.length} files
|
||||
</span>
|
||||
{/* Expand Icon */}
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="w-4 h-4 text-dark-400" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4 text-dark-400" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Expanded Content */}
|
||||
{isExpanded && (
|
||||
<div className="px-4 pb-4 space-y-4 border-t border-primary-400/10 pt-4">
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<div className="p-3 rounded-lg bg-red-500/10 border border-red-500/30 text-red-400 text-sm flex items-center gap-2">
|
||||
<AlertCircle className="w-4 h-4 flex-shrink-0" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Success Message */}
|
||||
{dvSaveMessage && (
|
||||
<div className="p-3 rounded-lg bg-green-500/10 border border-green-500/30 text-green-400 text-sm flex items-center gap-2">
|
||||
<CheckCircle className="w-4 h-4 flex-shrink-0" />
|
||||
{dvSaveMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Files Section */}
|
||||
{study.model_files.length > 0 && (
|
||||
<div>
|
||||
<h5 className="text-sm font-medium text-dark-300 mb-2">Model Files</h5>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{study.model_files.map((file) => (
|
||||
<span
|
||||
key={file}
|
||||
className="inline-flex items-center gap-1.5 px-2 py-1 rounded bg-dark-700 text-dark-300 text-xs"
|
||||
>
|
||||
<FileText className="w-3 h-3" />
|
||||
{file}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Model File Upload Section */}
|
||||
<div>
|
||||
<h5 className="text-sm font-medium text-dark-300 mb-2">Upload Model Files</h5>
|
||||
<FileDropzone
|
||||
studyName={study.study_name}
|
||||
onUploadComplete={onRefresh}
|
||||
compact={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Context File Upload Section */}
|
||||
<ContextFileUpload
|
||||
studyName={study.study_name}
|
||||
onUploadComplete={onRefresh}
|
||||
/>
|
||||
|
||||
{/* Introspection Results - Expressions */}
|
||||
{isLoadingDetail && (
|
||||
<div className="flex items-center gap-2 text-dark-400 text-sm py-4">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Loading introspection data...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{studyDetail?.spec?.model?.introspection?.expressions &&
|
||||
studyDetail.spec.model.introspection.expressions.length > 0 && (
|
||||
<ExpressionList
|
||||
expressions={studyDetail.spec.model.introspection.expressions}
|
||||
massKg={studyDetail.spec.model.introspection.mass_kg}
|
||||
selectedExpressions={selectedExpressions}
|
||||
onSelectionChange={setSelectedExpressions}
|
||||
readOnly={study.status === 'configured'}
|
||||
compact={true}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* README Preview Section */}
|
||||
{(readmeContent || study.status === 'configured') && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<h5 className="text-sm font-medium text-dark-300 flex items-center gap-2">
|
||||
<FileText className="w-4 h-4" />
|
||||
README.md
|
||||
</h5>
|
||||
<button
|
||||
onClick={() => setShowReadme(!showReadme)}
|
||||
className="flex items-center gap-1 px-2 py-1 text-xs rounded bg-dark-600
|
||||
text-dark-300 hover:bg-dark-500 transition-colors"
|
||||
>
|
||||
<Eye className="w-3 h-3" />
|
||||
{showReadme ? 'Hide' : 'Preview'}
|
||||
</button>
|
||||
</div>
|
||||
{showReadme && readmeContent && (
|
||||
<div className="max-h-64 overflow-y-auto rounded-lg border border-dark-600
|
||||
bg-dark-800 p-4">
|
||||
<pre className="text-xs text-dark-300 whitespace-pre-wrap font-mono">
|
||||
{readmeContent}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No Files Warning */}
|
||||
{study.model_files.length === 0 && (
|
||||
<div className="p-3 rounded-lg bg-yellow-500/10 border border-yellow-500/30 text-yellow-400 text-sm flex items-center gap-2">
|
||||
<AlertCircle className="w-4 h-4 flex-shrink-0" />
|
||||
No model files found. Upload .prt, .sim, or .fem files to continue.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{/* Introspect */}
|
||||
{canIntrospect && (
|
||||
<button
|
||||
onClick={handleIntrospect}
|
||||
disabled={isIntrospecting}
|
||||
className="flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm font-medium
|
||||
bg-blue-500/10 text-blue-400 hover:bg-blue-500/20
|
||||
disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{isIntrospecting ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Play className="w-4 h-4" />
|
||||
)}
|
||||
Introspect Model
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Save Design Variables */}
|
||||
{canSaveDVs && (
|
||||
<button
|
||||
onClick={handleSaveDesignVariables}
|
||||
disabled={isSavingDVs}
|
||||
className="flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm font-medium
|
||||
bg-green-500/10 text-green-400 hover:bg-green-500/20
|
||||
disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{isSavingDVs ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="w-4 h-4" />
|
||||
)}
|
||||
Save as DVs ({selectedExpressions.length})
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Generate README */}
|
||||
{canGenerateReadme && (
|
||||
<button
|
||||
onClick={handleGenerateReadme}
|
||||
disabled={isGeneratingReadme}
|
||||
className="flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm font-medium
|
||||
bg-purple-500/10 text-purple-400 hover:bg-purple-500/20
|
||||
disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{isGeneratingReadme ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Sparkles className="w-4 h-4" />
|
||||
)}
|
||||
Generate README
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Finalize */}
|
||||
{canFinalize && (
|
||||
<button
|
||||
onClick={() => onSelect(study.study_name)}
|
||||
className="flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm font-medium
|
||||
bg-primary-500/10 text-primary-400 hover:bg-primary-500/20
|
||||
transition-colors"
|
||||
>
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
Finalize Study
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Delete */}
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={isDeleting}
|
||||
className="flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm font-medium
|
||||
bg-red-500/10 text-red-400 hover:bg-red-500/20
|
||||
disabled:opacity-50 transition-colors ml-auto"
|
||||
>
|
||||
{isDeleting ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="w-4 h-4" />
|
||||
)}
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Workflow Hint */}
|
||||
{study.status === 'draft' && study.model_files.length > 0 && (
|
||||
<p className="text-xs text-dark-500">
|
||||
Next step: Run introspection to discover expressions and model properties.
|
||||
</p>
|
||||
)}
|
||||
{study.status === 'introspected' && (
|
||||
<p className="text-xs text-dark-500">
|
||||
Next step: Generate README with Claude AI, then finalize to create the study.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default InboxStudyCard;
|
||||
13
atomizer-dashboard/frontend/src/components/intake/index.ts
Normal file
13
atomizer-dashboard/frontend/src/components/intake/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Intake Components Index
|
||||
*
|
||||
* Export all intake workflow components.
|
||||
*/
|
||||
|
||||
export { CreateStudyCard } from './CreateStudyCard';
|
||||
export { InboxStudyCard } from './InboxStudyCard';
|
||||
export { FinalizeModal } from './FinalizeModal';
|
||||
export { InboxSection } from './InboxSection';
|
||||
export { FileDropzone } from './FileDropzone';
|
||||
export { ContextFileUpload } from './ContextFileUpload';
|
||||
export { ExpressionList } from './ExpressionList';
|
||||
@@ -1,260 +0,0 @@
|
||||
/**
|
||||
* PlotlyConvergencePlot - Interactive convergence plot using Plotly
|
||||
*
|
||||
* Features:
|
||||
* - Line plot showing objective vs trial number
|
||||
* - Best-so-far trace overlay
|
||||
* - FEA vs NN trial differentiation
|
||||
* - Hover tooltips with trial details
|
||||
* - Range slider for zooming
|
||||
* - Log scale toggle
|
||||
* - Export to PNG/SVG
|
||||
*/
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import Plot from 'react-plotly.js';
|
||||
|
||||
interface Trial {
|
||||
trial_number: number;
|
||||
values: number[];
|
||||
params: Record<string, number>;
|
||||
user_attrs?: Record<string, any>;
|
||||
source?: 'FEA' | 'NN' | 'V10_FEA';
|
||||
constraint_satisfied?: boolean;
|
||||
}
|
||||
|
||||
// Penalty threshold - objectives above this are considered failed/penalty trials
|
||||
const PENALTY_THRESHOLD = 100000;
|
||||
|
||||
interface PlotlyConvergencePlotProps {
|
||||
trials: Trial[];
|
||||
objectiveIndex?: number;
|
||||
objectiveName?: string;
|
||||
direction?: 'minimize' | 'maximize';
|
||||
height?: number;
|
||||
showRangeSlider?: boolean;
|
||||
showLogScaleToggle?: boolean;
|
||||
}
|
||||
|
||||
export function PlotlyConvergencePlot({
|
||||
trials,
|
||||
objectiveIndex = 0,
|
||||
objectiveName = 'Objective',
|
||||
direction = 'minimize',
|
||||
height = 400,
|
||||
showRangeSlider = true,
|
||||
showLogScaleToggle = true
|
||||
}: PlotlyConvergencePlotProps) {
|
||||
const [useLogScale, setUseLogScale] = useState(false);
|
||||
|
||||
// Process trials and calculate best-so-far
|
||||
const { feaData, nnData, bestSoFar, allX, allY } = useMemo(() => {
|
||||
if (!trials.length) return { feaData: { x: [], y: [], text: [] }, nnData: { x: [], y: [], text: [] }, bestSoFar: { x: [], y: [] }, allX: [], allY: [] };
|
||||
|
||||
// Sort by trial number
|
||||
const sorted = [...trials].sort((a, b) => a.trial_number - b.trial_number);
|
||||
|
||||
const fea: { x: number[]; y: number[]; text: string[] } = { x: [], y: [], text: [] };
|
||||
const nn: { x: number[]; y: number[]; text: string[] } = { x: [], y: [], text: [] };
|
||||
const best: { x: number[]; y: number[] } = { x: [], y: [] };
|
||||
const xs: number[] = [];
|
||||
const ys: number[] = [];
|
||||
|
||||
let bestValue = direction === 'minimize' ? Infinity : -Infinity;
|
||||
|
||||
sorted.forEach(t => {
|
||||
const val = t.values?.[objectiveIndex] ?? t.user_attrs?.[objectiveName] ?? null;
|
||||
if (val === null || !isFinite(val)) return;
|
||||
|
||||
// Filter out failed/penalty trials:
|
||||
// 1. Objective above penalty threshold (e.g., 1000000 = solver failure)
|
||||
// 2. constraint_satisfied explicitly false
|
||||
// 3. user_attrs indicates pruned/failed
|
||||
const isPenalty = val >= PENALTY_THRESHOLD;
|
||||
const isFailed = t.constraint_satisfied === false;
|
||||
const isPruned = t.user_attrs?.pruned === true || t.user_attrs?.fail_reason;
|
||||
if (isPenalty || isFailed || isPruned) return;
|
||||
|
||||
const source = t.source || t.user_attrs?.source || 'FEA';
|
||||
const hoverText = `Trial #${t.trial_number}<br>${objectiveName}: ${val.toFixed(4)}<br>Source: ${source}`;
|
||||
|
||||
xs.push(t.trial_number);
|
||||
ys.push(val);
|
||||
|
||||
if (source === 'NN') {
|
||||
nn.x.push(t.trial_number);
|
||||
nn.y.push(val);
|
||||
nn.text.push(hoverText);
|
||||
} else {
|
||||
fea.x.push(t.trial_number);
|
||||
fea.y.push(val);
|
||||
fea.text.push(hoverText);
|
||||
}
|
||||
|
||||
// Update best-so-far
|
||||
if (direction === 'minimize') {
|
||||
if (val < bestValue) bestValue = val;
|
||||
} else {
|
||||
if (val > bestValue) bestValue = val;
|
||||
}
|
||||
best.x.push(t.trial_number);
|
||||
best.y.push(bestValue);
|
||||
});
|
||||
|
||||
return { feaData: fea, nnData: nn, bestSoFar: best, allX: xs, allY: ys };
|
||||
}, [trials, objectiveIndex, objectiveName, direction]);
|
||||
|
||||
if (!trials.length || allX.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64 text-gray-500">
|
||||
No trial data available
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const traces: any[] = [];
|
||||
|
||||
// FEA trials scatter
|
||||
if (feaData.x.length > 0) {
|
||||
traces.push({
|
||||
type: 'scatter',
|
||||
mode: 'markers',
|
||||
name: `FEA (${feaData.x.length})`,
|
||||
x: feaData.x,
|
||||
y: feaData.y,
|
||||
text: feaData.text,
|
||||
hoverinfo: 'text',
|
||||
marker: {
|
||||
color: '#3B82F6',
|
||||
size: 8,
|
||||
opacity: 0.7,
|
||||
line: { color: '#1E40AF', width: 1 }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// NN trials scatter
|
||||
if (nnData.x.length > 0) {
|
||||
traces.push({
|
||||
type: 'scatter',
|
||||
mode: 'markers',
|
||||
name: `NN (${nnData.x.length})`,
|
||||
x: nnData.x,
|
||||
y: nnData.y,
|
||||
text: nnData.text,
|
||||
hoverinfo: 'text',
|
||||
marker: {
|
||||
color: '#F97316',
|
||||
size: 6,
|
||||
symbol: 'cross',
|
||||
opacity: 0.6
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Best-so-far line
|
||||
if (bestSoFar.x.length > 0) {
|
||||
traces.push({
|
||||
type: 'scatter',
|
||||
mode: 'lines',
|
||||
name: 'Best So Far',
|
||||
x: bestSoFar.x,
|
||||
y: bestSoFar.y,
|
||||
line: {
|
||||
color: '#10B981',
|
||||
width: 3,
|
||||
shape: 'hv' // Step line
|
||||
},
|
||||
hoverinfo: 'y'
|
||||
});
|
||||
}
|
||||
|
||||
const layout: any = {
|
||||
height,
|
||||
margin: { l: 60, r: 30, t: 30, b: showRangeSlider ? 80 : 50 },
|
||||
paper_bgcolor: 'rgba(0,0,0,0)',
|
||||
plot_bgcolor: 'rgba(0,0,0,0)',
|
||||
xaxis: {
|
||||
title: 'Trial Number',
|
||||
gridcolor: '#E5E7EB',
|
||||
zerolinecolor: '#D1D5DB',
|
||||
rangeslider: showRangeSlider ? { visible: true } : undefined
|
||||
},
|
||||
yaxis: {
|
||||
title: useLogScale ? `log₁₀(${objectiveName})` : objectiveName,
|
||||
gridcolor: '#E5E7EB',
|
||||
zerolinecolor: '#D1D5DB',
|
||||
type: useLogScale ? 'log' : 'linear'
|
||||
},
|
||||
legend: {
|
||||
x: 1,
|
||||
y: 1,
|
||||
xanchor: 'right',
|
||||
bgcolor: 'rgba(255,255,255,0.8)',
|
||||
bordercolor: '#E5E7EB',
|
||||
borderwidth: 1
|
||||
},
|
||||
font: { family: 'Inter, system-ui, sans-serif' },
|
||||
hovermode: 'closest'
|
||||
};
|
||||
|
||||
// Best value annotation
|
||||
const bestVal = direction === 'minimize'
|
||||
? Math.min(...allY)
|
||||
: Math.max(...allY);
|
||||
const bestIdx = allY.indexOf(bestVal);
|
||||
const bestTrial = allX[bestIdx];
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{/* Summary stats and controls */}
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex gap-6 text-sm">
|
||||
<div className="text-gray-600">
|
||||
Best: <span className="font-semibold text-green-600">{bestVal.toFixed(4)}</span>
|
||||
<span className="text-gray-400 ml-1">(Trial #{bestTrial})</span>
|
||||
</div>
|
||||
<div className="text-gray-600">
|
||||
Current: <span className="font-semibold">{allY[allY.length - 1].toFixed(4)}</span>
|
||||
</div>
|
||||
<div className="text-gray-600">
|
||||
Trials: <span className="font-semibold">{allX.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Log scale toggle */}
|
||||
{showLogScaleToggle && (
|
||||
<button
|
||||
onClick={() => setUseLogScale(!useLogScale)}
|
||||
className={`px-3 py-1 text-xs rounded transition-colors ${
|
||||
useLogScale
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
|
||||
}`}
|
||||
title="Toggle logarithmic scale - better for viewing early improvements"
|
||||
>
|
||||
{useLogScale ? 'Log Scale' : 'Linear Scale'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Plot
|
||||
data={traces}
|
||||
layout={layout}
|
||||
config={{
|
||||
displayModeBar: true,
|
||||
displaylogo: false,
|
||||
modeBarButtonsToRemove: ['lasso2d', 'select2d'],
|
||||
toImageButtonOptions: {
|
||||
format: 'png',
|
||||
filename: 'convergence_plot',
|
||||
height: 600,
|
||||
width: 1200,
|
||||
scale: 2
|
||||
}
|
||||
}}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import Plot from 'react-plotly.js';
|
||||
|
||||
interface TrialData {
|
||||
trial_number: number;
|
||||
values: number[];
|
||||
params: Record<string, number>;
|
||||
}
|
||||
|
||||
interface PlotlyCorrelationHeatmapProps {
|
||||
trials: TrialData[];
|
||||
objectiveName?: string;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
// Calculate Pearson correlation coefficient
|
||||
function pearsonCorrelation(x: number[], y: number[]): number {
|
||||
const n = x.length;
|
||||
if (n === 0 || n !== y.length) return 0;
|
||||
|
||||
const meanX = x.reduce((a, b) => a + b, 0) / n;
|
||||
const meanY = y.reduce((a, b) => a + b, 0) / n;
|
||||
|
||||
let numerator = 0;
|
||||
let denomX = 0;
|
||||
let denomY = 0;
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
const dx = x[i] - meanX;
|
||||
const dy = y[i] - meanY;
|
||||
numerator += dx * dy;
|
||||
denomX += dx * dx;
|
||||
denomY += dy * dy;
|
||||
}
|
||||
|
||||
const denominator = Math.sqrt(denomX) * Math.sqrt(denomY);
|
||||
return denominator === 0 ? 0 : numerator / denominator;
|
||||
}
|
||||
|
||||
export function PlotlyCorrelationHeatmap({
|
||||
trials,
|
||||
objectiveName = 'Objective',
|
||||
height = 500
|
||||
}: PlotlyCorrelationHeatmapProps) {
|
||||
const { matrix, labels, annotations } = useMemo(() => {
|
||||
if (trials.length < 3) {
|
||||
return { matrix: [], labels: [], annotations: [] };
|
||||
}
|
||||
|
||||
// Get parameter names
|
||||
const paramNames = Object.keys(trials[0].params);
|
||||
const allLabels = [...paramNames, objectiveName];
|
||||
|
||||
// Extract data columns
|
||||
const columns: Record<string, number[]> = {};
|
||||
paramNames.forEach(name => {
|
||||
columns[name] = trials.map(t => t.params[name]).filter(v => v !== undefined && !isNaN(v));
|
||||
});
|
||||
columns[objectiveName] = trials.map(t => t.values[0]).filter(v => v !== undefined && !isNaN(v));
|
||||
|
||||
// Calculate correlation matrix
|
||||
const n = allLabels.length;
|
||||
const correlationMatrix: number[][] = [];
|
||||
const annotationData: any[] = [];
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
const row: number[] = [];
|
||||
for (let j = 0; j < n; j++) {
|
||||
const col1 = columns[allLabels[i]];
|
||||
const col2 = columns[allLabels[j]];
|
||||
|
||||
// Ensure same length
|
||||
const minLen = Math.min(col1.length, col2.length);
|
||||
const corr = pearsonCorrelation(col1.slice(0, minLen), col2.slice(0, minLen));
|
||||
row.push(corr);
|
||||
|
||||
// Add annotation
|
||||
annotationData.push({
|
||||
x: allLabels[j],
|
||||
y: allLabels[i],
|
||||
text: corr.toFixed(2),
|
||||
showarrow: false,
|
||||
font: {
|
||||
color: Math.abs(corr) > 0.5 ? '#fff' : '#888',
|
||||
size: 11
|
||||
}
|
||||
});
|
||||
}
|
||||
correlationMatrix.push(row);
|
||||
}
|
||||
|
||||
return {
|
||||
matrix: correlationMatrix,
|
||||
labels: allLabels,
|
||||
annotations: annotationData
|
||||
};
|
||||
}, [trials, objectiveName]);
|
||||
|
||||
if (trials.length < 3) {
|
||||
return (
|
||||
<div className="h-64 flex items-center justify-center text-dark-400">
|
||||
<p>Need at least 3 trials to compute correlations</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Plot
|
||||
data={[
|
||||
{
|
||||
z: matrix,
|
||||
x: labels,
|
||||
y: labels,
|
||||
type: 'heatmap',
|
||||
colorscale: [
|
||||
[0, '#ef4444'], // -1: strong negative (red)
|
||||
[0.25, '#f87171'], // -0.5: moderate negative
|
||||
[0.5, '#1a1b26'], // 0: no correlation (dark)
|
||||
[0.75, '#60a5fa'], // 0.5: moderate positive
|
||||
[1, '#3b82f6'] // 1: strong positive (blue)
|
||||
],
|
||||
zmin: -1,
|
||||
zmax: 1,
|
||||
showscale: true,
|
||||
colorbar: {
|
||||
title: { text: 'Correlation', font: { color: '#888' } },
|
||||
tickfont: { color: '#888' },
|
||||
len: 0.8
|
||||
},
|
||||
hovertemplate: '%{y} vs %{x}<br>Correlation: %{z:.3f}<extra></extra>'
|
||||
}
|
||||
]}
|
||||
layout={{
|
||||
title: {
|
||||
text: 'Parameter-Objective Correlation Matrix',
|
||||
font: { color: '#fff', size: 14 }
|
||||
},
|
||||
height,
|
||||
margin: { l: 120, r: 60, t: 60, b: 120 },
|
||||
paper_bgcolor: 'transparent',
|
||||
plot_bgcolor: 'transparent',
|
||||
xaxis: {
|
||||
tickangle: 45,
|
||||
tickfont: { color: '#888', size: 10 },
|
||||
gridcolor: 'rgba(255,255,255,0.05)'
|
||||
},
|
||||
yaxis: {
|
||||
tickfont: { color: '#888', size: 10 },
|
||||
gridcolor: 'rgba(255,255,255,0.05)'
|
||||
},
|
||||
annotations: annotations
|
||||
}}
|
||||
config={{
|
||||
displayModeBar: true,
|
||||
modeBarButtonsToRemove: ['lasso2d', 'select2d'],
|
||||
displaylogo: false
|
||||
}}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import Plot from 'react-plotly.js';
|
||||
|
||||
interface TrialData {
|
||||
trial_number: number;
|
||||
values: number[];
|
||||
constraint_satisfied?: boolean;
|
||||
}
|
||||
|
||||
interface PlotlyFeasibilityChartProps {
|
||||
trials: TrialData[];
|
||||
height?: number;
|
||||
}
|
||||
|
||||
export function PlotlyFeasibilityChart({
|
||||
trials,
|
||||
height = 350
|
||||
}: PlotlyFeasibilityChartProps) {
|
||||
const { trialNumbers, cumulativeFeasibility, windowedFeasibility } = useMemo(() => {
|
||||
if (trials.length === 0) {
|
||||
return { trialNumbers: [], cumulativeFeasibility: [], windowedFeasibility: [] };
|
||||
}
|
||||
|
||||
// Sort trials by number
|
||||
const sorted = [...trials].sort((a, b) => a.trial_number - b.trial_number);
|
||||
|
||||
const numbers: number[] = [];
|
||||
const cumulative: number[] = [];
|
||||
const windowed: number[] = [];
|
||||
|
||||
let feasibleCount = 0;
|
||||
const windowSize = Math.min(20, Math.floor(sorted.length / 5) || 1);
|
||||
|
||||
sorted.forEach((trial, idx) => {
|
||||
numbers.push(trial.trial_number);
|
||||
|
||||
// Cumulative feasibility
|
||||
if (trial.constraint_satisfied !== false) {
|
||||
feasibleCount++;
|
||||
}
|
||||
cumulative.push((feasibleCount / (idx + 1)) * 100);
|
||||
|
||||
// Windowed (rolling) feasibility
|
||||
const windowStart = Math.max(0, idx - windowSize + 1);
|
||||
const windowTrials = sorted.slice(windowStart, idx + 1);
|
||||
const windowFeasible = windowTrials.filter(t => t.constraint_satisfied !== false).length;
|
||||
windowed.push((windowFeasible / windowTrials.length) * 100);
|
||||
});
|
||||
|
||||
return { trialNumbers: numbers, cumulativeFeasibility: cumulative, windowedFeasibility: windowed };
|
||||
}, [trials]);
|
||||
|
||||
if (trials.length === 0) {
|
||||
return (
|
||||
<div className="h-64 flex items-center justify-center text-dark-400">
|
||||
<p>No trials to display</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Plot
|
||||
data={[
|
||||
{
|
||||
x: trialNumbers,
|
||||
y: cumulativeFeasibility,
|
||||
type: 'scatter',
|
||||
mode: 'lines',
|
||||
name: 'Cumulative Feasibility',
|
||||
line: { color: '#22c55e', width: 2 },
|
||||
hovertemplate: 'Trial %{x}<br>Cumulative: %{y:.1f}%<extra></extra>'
|
||||
},
|
||||
{
|
||||
x: trialNumbers,
|
||||
y: windowedFeasibility,
|
||||
type: 'scatter',
|
||||
mode: 'lines',
|
||||
name: 'Rolling (20-trial)',
|
||||
line: { color: '#60a5fa', width: 2, dash: 'dot' },
|
||||
hovertemplate: 'Trial %{x}<br>Rolling: %{y:.1f}%<extra></extra>'
|
||||
}
|
||||
]}
|
||||
layout={{
|
||||
height,
|
||||
margin: { l: 60, r: 30, t: 30, b: 50 },
|
||||
paper_bgcolor: 'transparent',
|
||||
plot_bgcolor: 'transparent',
|
||||
xaxis: {
|
||||
title: { text: 'Trial Number', font: { color: '#888' } },
|
||||
tickfont: { color: '#888' },
|
||||
gridcolor: 'rgba(255,255,255,0.05)',
|
||||
zeroline: false
|
||||
},
|
||||
yaxis: {
|
||||
title: { text: 'Feasibility Rate (%)', font: { color: '#888' } },
|
||||
tickfont: { color: '#888' },
|
||||
gridcolor: 'rgba(255,255,255,0.1)',
|
||||
zeroline: false,
|
||||
range: [0, 105]
|
||||
},
|
||||
legend: {
|
||||
font: { color: '#888' },
|
||||
bgcolor: 'rgba(0,0,0,0.5)',
|
||||
x: 0.02,
|
||||
y: 0.98,
|
||||
xanchor: 'left',
|
||||
yanchor: 'top'
|
||||
},
|
||||
showlegend: true,
|
||||
hovermode: 'x unified'
|
||||
}}
|
||||
config={{
|
||||
displayModeBar: true,
|
||||
modeBarButtonsToRemove: ['lasso2d', 'select2d'],
|
||||
displaylogo: false
|
||||
}}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,221 +0,0 @@
|
||||
/**
|
||||
* PlotlyParallelCoordinates - Interactive parallel coordinates plot using Plotly
|
||||
*
|
||||
* Features:
|
||||
* - Native zoom, pan, and selection
|
||||
* - Hover tooltips with trial details
|
||||
* - Brush filtering on each axis
|
||||
* - FEA vs NN color differentiation
|
||||
* - Export to PNG/SVG
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import Plot from 'react-plotly.js';
|
||||
|
||||
interface Trial {
|
||||
trial_number: number;
|
||||
values: number[];
|
||||
params: Record<string, number>;
|
||||
user_attrs?: Record<string, any>;
|
||||
constraint_satisfied?: boolean;
|
||||
source?: 'FEA' | 'NN' | 'V10_FEA';
|
||||
}
|
||||
|
||||
interface Objective {
|
||||
name: string;
|
||||
direction?: 'minimize' | 'maximize';
|
||||
unit?: string;
|
||||
}
|
||||
|
||||
interface DesignVariable {
|
||||
name: string;
|
||||
unit?: string;
|
||||
min?: number;
|
||||
max?: number;
|
||||
}
|
||||
|
||||
interface PlotlyParallelCoordinatesProps {
|
||||
trials: Trial[];
|
||||
objectives: Objective[];
|
||||
designVariables: DesignVariable[];
|
||||
paretoFront?: Trial[];
|
||||
height?: number;
|
||||
}
|
||||
|
||||
export function PlotlyParallelCoordinates({
|
||||
trials,
|
||||
objectives,
|
||||
designVariables,
|
||||
paretoFront = [],
|
||||
height = 500
|
||||
}: PlotlyParallelCoordinatesProps) {
|
||||
// Create set of Pareto front trial numbers
|
||||
const paretoSet = useMemo(() => new Set(paretoFront.map(t => t.trial_number)), [paretoFront]);
|
||||
|
||||
// Build dimensions array for parallel coordinates
|
||||
const { dimensions, colorValues, colorScale } = useMemo(() => {
|
||||
if (!trials.length) return { dimensions: [], colorValues: [], colorScale: [] };
|
||||
|
||||
const dims: any[] = [];
|
||||
const colors: number[] = [];
|
||||
|
||||
// Get all design variable names
|
||||
const dvNames = designVariables.map(dv => dv.name);
|
||||
const objNames = objectives.map(obj => obj.name);
|
||||
|
||||
// Add design variable dimensions
|
||||
dvNames.forEach((name, idx) => {
|
||||
const dv = designVariables[idx];
|
||||
const values = trials.map(t => t.params[name] ?? 0);
|
||||
const validValues = values.filter(v => v !== null && v !== undefined && isFinite(v));
|
||||
|
||||
if (validValues.length === 0) return;
|
||||
|
||||
dims.push({
|
||||
label: name,
|
||||
values: values,
|
||||
range: [
|
||||
dv?.min ?? Math.min(...validValues),
|
||||
dv?.max ?? Math.max(...validValues)
|
||||
],
|
||||
constraintrange: undefined
|
||||
});
|
||||
});
|
||||
|
||||
// Add objective dimensions
|
||||
objNames.forEach((name, idx) => {
|
||||
const obj = objectives[idx];
|
||||
const values = trials.map(t => {
|
||||
// Try to get from values array first, then user_attrs
|
||||
if (t.values && t.values[idx] !== undefined) {
|
||||
return t.values[idx];
|
||||
}
|
||||
return t.user_attrs?.[name] ?? 0;
|
||||
});
|
||||
const validValues = values.filter(v => v !== null && v !== undefined && isFinite(v));
|
||||
|
||||
if (validValues.length === 0) return;
|
||||
|
||||
dims.push({
|
||||
label: `${name}${obj.unit ? ` (${obj.unit})` : ''}`,
|
||||
values: values,
|
||||
range: [Math.min(...validValues) * 0.95, Math.max(...validValues) * 1.05]
|
||||
});
|
||||
});
|
||||
|
||||
// Build color array: 0 = V10_FEA, 1 = FEA, 2 = NN, 3 = Pareto
|
||||
trials.forEach(t => {
|
||||
const source = t.source || t.user_attrs?.source || 'FEA';
|
||||
const isPareto = paretoSet.has(t.trial_number);
|
||||
|
||||
if (isPareto) {
|
||||
colors.push(3); // Pareto - special color
|
||||
} else if (source === 'NN') {
|
||||
colors.push(2); // NN trials
|
||||
} else if (source === 'V10_FEA') {
|
||||
colors.push(0); // V10 FEA
|
||||
} else {
|
||||
colors.push(1); // V11 FEA
|
||||
}
|
||||
});
|
||||
|
||||
// Color scale: V10_FEA (light blue), FEA (blue), NN (orange), Pareto (green)
|
||||
const scale: [number, string][] = [
|
||||
[0, '#93C5FD'], // V10_FEA - light blue
|
||||
[0.33, '#2563EB'], // FEA - blue
|
||||
[0.66, '#F97316'], // NN - orange
|
||||
[1, '#10B981'] // Pareto - green
|
||||
];
|
||||
|
||||
return { dimensions: dims, colorValues: colors, colorScale: scale };
|
||||
}, [trials, objectives, designVariables, paretoSet]);
|
||||
|
||||
if (!trials.length || dimensions.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64 text-gray-500">
|
||||
No trial data available for parallel coordinates
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Count trial types for legend
|
||||
const feaCount = trials.filter(t => {
|
||||
const source = t.source || t.user_attrs?.source || 'FEA';
|
||||
return source === 'FEA' || source === 'V10_FEA';
|
||||
}).length;
|
||||
const nnCount = trials.filter(t => {
|
||||
const source = t.source || t.user_attrs?.source || 'FEA';
|
||||
return source === 'NN';
|
||||
}).length;
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{/* Legend */}
|
||||
<div className="flex gap-4 justify-center mb-2 text-sm">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-4 h-1 rounded" style={{ backgroundColor: '#2563EB' }} />
|
||||
<span className="text-gray-600">FEA ({feaCount})</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-4 h-1 rounded" style={{ backgroundColor: '#F97316' }} />
|
||||
<span className="text-gray-600">NN ({nnCount})</span>
|
||||
</div>
|
||||
{paretoFront.length > 0 && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-4 h-1 rounded" style={{ backgroundColor: '#10B981' }} />
|
||||
<span className="text-gray-600">Pareto ({paretoFront.length})</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Plot
|
||||
data={[
|
||||
{
|
||||
type: 'parcoords',
|
||||
line: {
|
||||
color: colorValues,
|
||||
colorscale: colorScale as any,
|
||||
showscale: false
|
||||
},
|
||||
dimensions: dimensions,
|
||||
labelangle: -30,
|
||||
labelfont: {
|
||||
size: 11,
|
||||
color: '#374151'
|
||||
},
|
||||
tickfont: {
|
||||
size: 10,
|
||||
color: '#6B7280'
|
||||
}
|
||||
} as any
|
||||
]}
|
||||
layout={{
|
||||
height: height,
|
||||
margin: { l: 80, r: 80, t: 30, b: 30 },
|
||||
paper_bgcolor: 'rgba(0,0,0,0)',
|
||||
plot_bgcolor: 'rgba(0,0,0,0)',
|
||||
font: {
|
||||
family: 'Inter, system-ui, sans-serif'
|
||||
}
|
||||
}}
|
||||
config={{
|
||||
displayModeBar: true,
|
||||
displaylogo: false,
|
||||
modeBarButtonsToRemove: ['lasso2d', 'select2d'],
|
||||
toImageButtonOptions: {
|
||||
format: 'png',
|
||||
filename: 'parallel_coordinates',
|
||||
height: 800,
|
||||
width: 1400,
|
||||
scale: 2
|
||||
}
|
||||
}}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
|
||||
<p className="text-xs text-gray-500 text-center mt-2">
|
||||
Drag along axes to filter. Double-click to reset.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,209 +0,0 @@
|
||||
/**
|
||||
* PlotlyParameterImportance - Interactive parameter importance chart using Plotly
|
||||
*
|
||||
* Features:
|
||||
* - Horizontal bar chart showing correlation/importance
|
||||
* - Color coding by positive/negative correlation
|
||||
* - Hover tooltips with details
|
||||
* - Sortable by importance
|
||||
*/
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import Plot from 'react-plotly.js';
|
||||
|
||||
interface Trial {
|
||||
trial_number: number;
|
||||
values: number[];
|
||||
params: Record<string, number>;
|
||||
user_attrs?: Record<string, any>;
|
||||
}
|
||||
|
||||
interface DesignVariable {
|
||||
name: string;
|
||||
unit?: string;
|
||||
}
|
||||
|
||||
interface PlotlyParameterImportanceProps {
|
||||
trials: Trial[];
|
||||
designVariables: DesignVariable[];
|
||||
objectiveIndex?: number;
|
||||
objectiveName?: string;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
// Calculate Pearson correlation coefficient
|
||||
function pearsonCorrelation(x: number[], y: number[]): number {
|
||||
const n = x.length;
|
||||
if (n === 0) return 0;
|
||||
|
||||
const sumX = x.reduce((a, b) => a + b, 0);
|
||||
const sumY = y.reduce((a, b) => a + b, 0);
|
||||
const sumXY = x.reduce((acc, xi, i) => acc + xi * y[i], 0);
|
||||
const sumX2 = x.reduce((acc, xi) => acc + xi * xi, 0);
|
||||
const sumY2 = y.reduce((acc, yi) => acc + yi * yi, 0);
|
||||
|
||||
const numerator = n * sumXY - sumX * sumY;
|
||||
const denominator = Math.sqrt((n * sumX2 - sumX * sumX) * (n * sumY2 - sumY * sumY));
|
||||
|
||||
if (denominator === 0) return 0;
|
||||
return numerator / denominator;
|
||||
}
|
||||
|
||||
export function PlotlyParameterImportance({
|
||||
trials,
|
||||
designVariables,
|
||||
objectiveIndex = 0,
|
||||
objectiveName = 'Objective',
|
||||
height = 400
|
||||
}: PlotlyParameterImportanceProps) {
|
||||
const [sortBy, setSortBy] = useState<'importance' | 'name'>('importance');
|
||||
|
||||
// Calculate correlations for each parameter
|
||||
const correlations = useMemo(() => {
|
||||
if (!trials.length || !designVariables.length) return [];
|
||||
|
||||
// Get objective values
|
||||
const objValues = trials.map(t => {
|
||||
if (t.values && t.values[objectiveIndex] !== undefined) {
|
||||
return t.values[objectiveIndex];
|
||||
}
|
||||
return t.user_attrs?.[objectiveName] ?? null;
|
||||
}).filter((v): v is number => v !== null && isFinite(v));
|
||||
|
||||
if (objValues.length < 3) return []; // Need at least 3 points for correlation
|
||||
|
||||
const results: { name: string; correlation: number; absCorrelation: number }[] = [];
|
||||
|
||||
designVariables.forEach(dv => {
|
||||
const paramValues = trials
|
||||
.map((t) => {
|
||||
const objVal = t.values?.[objectiveIndex] ?? t.user_attrs?.[objectiveName];
|
||||
if (objVal === null || objVal === undefined || !isFinite(objVal)) return null;
|
||||
return { param: t.params[dv.name], obj: objVal };
|
||||
})
|
||||
.filter((v): v is { param: number; obj: number } => v !== null && v.param !== undefined);
|
||||
|
||||
if (paramValues.length < 3) return;
|
||||
|
||||
const x = paramValues.map(v => v.param);
|
||||
const y = paramValues.map(v => v.obj);
|
||||
const corr = pearsonCorrelation(x, y);
|
||||
|
||||
results.push({
|
||||
name: dv.name,
|
||||
correlation: corr,
|
||||
absCorrelation: Math.abs(corr)
|
||||
});
|
||||
});
|
||||
|
||||
// Sort by absolute correlation or name
|
||||
if (sortBy === 'importance') {
|
||||
results.sort((a, b) => b.absCorrelation - a.absCorrelation);
|
||||
} else {
|
||||
results.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
return results;
|
||||
}, [trials, designVariables, objectiveIndex, objectiveName, sortBy]);
|
||||
|
||||
if (!correlations.length) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64 text-gray-500">
|
||||
Not enough data to calculate parameter importance
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Build bar chart data
|
||||
const names = correlations.map(c => c.name);
|
||||
const values = correlations.map(c => c.correlation);
|
||||
const colors = values.map(v => v > 0 ? '#EF4444' : '#22C55E'); // Red for positive (worse), Green for negative (better) when minimizing
|
||||
const hoverTexts = correlations.map(c =>
|
||||
`${c.name}<br>Correlation: ${c.correlation.toFixed(4)}<br>|r|: ${c.absCorrelation.toFixed(4)}<br>${c.correlation > 0 ? 'Higher → Higher objective' : 'Higher → Lower objective'}`
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{/* Controls */}
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<div className="text-sm text-gray-600">
|
||||
Correlation with <span className="font-semibold">{objectiveName}</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setSortBy('importance')}
|
||||
className={`px-3 py-1 text-xs rounded ${sortBy === 'importance' ? 'bg-blue-500 text-white' : 'bg-gray-100 text-gray-700'}`}
|
||||
>
|
||||
By Importance
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSortBy('name')}
|
||||
className={`px-3 py-1 text-xs rounded ${sortBy === 'name' ? 'bg-blue-500 text-white' : 'bg-gray-100 text-gray-700'}`}
|
||||
>
|
||||
By Name
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Plot
|
||||
data={[
|
||||
{
|
||||
type: 'bar',
|
||||
orientation: 'h',
|
||||
y: names,
|
||||
x: values,
|
||||
text: hoverTexts,
|
||||
hoverinfo: 'text',
|
||||
marker: {
|
||||
color: colors,
|
||||
line: { color: '#fff', width: 1 }
|
||||
}
|
||||
}
|
||||
]}
|
||||
layout={{
|
||||
height: Math.max(height, correlations.length * 30 + 80),
|
||||
margin: { l: 150, r: 30, t: 10, b: 50 },
|
||||
paper_bgcolor: 'rgba(0,0,0,0)',
|
||||
plot_bgcolor: 'rgba(0,0,0,0)',
|
||||
xaxis: {
|
||||
title: { text: 'Correlation Coefficient' },
|
||||
range: [-1, 1],
|
||||
gridcolor: '#E5E7EB',
|
||||
zerolinecolor: '#9CA3AF',
|
||||
zerolinewidth: 2
|
||||
},
|
||||
yaxis: {
|
||||
automargin: true
|
||||
},
|
||||
font: { family: 'Inter, system-ui, sans-serif', size: 11 },
|
||||
bargap: 0.3
|
||||
}}
|
||||
config={{
|
||||
displayModeBar: true,
|
||||
displaylogo: false,
|
||||
modeBarButtonsToRemove: ['lasso2d', 'select2d'],
|
||||
toImageButtonOptions: {
|
||||
format: 'png',
|
||||
filename: 'parameter_importance',
|
||||
height: 600,
|
||||
width: 800,
|
||||
scale: 2
|
||||
}
|
||||
}}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="flex gap-6 justify-center mt-3 text-xs">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-4 h-3 rounded" style={{ backgroundColor: '#EF4444' }} />
|
||||
<span className="text-gray-600">Positive correlation (higher param → higher objective)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-4 h-3 rounded" style={{ backgroundColor: '#22C55E' }} />
|
||||
<span className="text-gray-600">Negative correlation (higher param → lower objective)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,448 +0,0 @@
|
||||
/**
|
||||
* PlotlyParetoPlot - Interactive Pareto front visualization using Plotly
|
||||
*
|
||||
* Features:
|
||||
* - 2D scatter with Pareto front highlighted
|
||||
* - 3D scatter for 3-objective problems
|
||||
* - Hover tooltips with trial details
|
||||
* - Pareto front connection line
|
||||
* - FEA vs NN differentiation
|
||||
* - Constraint satisfaction highlighting
|
||||
* - Dark mode styling
|
||||
* - Zoom, pan, and export
|
||||
*/
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import Plot from 'react-plotly.js';
|
||||
|
||||
interface Trial {
|
||||
trial_number: number;
|
||||
values: number[];
|
||||
params: Record<string, number>;
|
||||
user_attrs?: Record<string, any>;
|
||||
source?: 'FEA' | 'NN' | 'V10_FEA';
|
||||
constraint_satisfied?: boolean;
|
||||
}
|
||||
|
||||
interface Objective {
|
||||
name: string;
|
||||
direction?: 'minimize' | 'maximize';
|
||||
unit?: string;
|
||||
}
|
||||
|
||||
interface PlotlyParetoPlotProps {
|
||||
trials: Trial[];
|
||||
paretoFront: Trial[];
|
||||
objectives: Objective[];
|
||||
height?: number;
|
||||
showParetoLine?: boolean;
|
||||
showInfeasible?: boolean;
|
||||
}
|
||||
|
||||
export function PlotlyParetoPlot({
|
||||
trials,
|
||||
paretoFront,
|
||||
objectives,
|
||||
height = 500,
|
||||
showParetoLine = true,
|
||||
showInfeasible = true
|
||||
}: PlotlyParetoPlotProps) {
|
||||
const [viewMode, setViewMode] = useState<'2d' | '3d'>(objectives.length >= 3 ? '3d' : '2d');
|
||||
const [selectedObjectives, setSelectedObjectives] = useState<[number, number, number]>([0, 1, 2]);
|
||||
|
||||
const paretoSet = useMemo(() => new Set(paretoFront.map(t => t.trial_number)), [paretoFront]);
|
||||
|
||||
// Separate trials by source, Pareto status, and constraint satisfaction
|
||||
const { feaTrials, nnTrials, paretoTrials, infeasibleTrials, stats } = useMemo(() => {
|
||||
const fea: Trial[] = [];
|
||||
const nn: Trial[] = [];
|
||||
const pareto: Trial[] = [];
|
||||
const infeasible: Trial[] = [];
|
||||
|
||||
trials.forEach(t => {
|
||||
const source = t.source || t.user_attrs?.source || 'FEA';
|
||||
const isFeasible = t.constraint_satisfied !== false && t.user_attrs?.constraint_satisfied !== false;
|
||||
|
||||
if (!isFeasible && showInfeasible) {
|
||||
infeasible.push(t);
|
||||
} else if (paretoSet.has(t.trial_number)) {
|
||||
pareto.push(t);
|
||||
} else if (source === 'NN') {
|
||||
nn.push(t);
|
||||
} else {
|
||||
fea.push(t);
|
||||
}
|
||||
});
|
||||
|
||||
// Calculate statistics
|
||||
const stats = {
|
||||
totalTrials: trials.length,
|
||||
paretoCount: pareto.length,
|
||||
feaCount: fea.length + pareto.filter(t => (t.source || 'FEA') !== 'NN').length,
|
||||
nnCount: nn.length + pareto.filter(t => t.source === 'NN').length,
|
||||
infeasibleCount: infeasible.length,
|
||||
hypervolume: 0 // Could calculate if needed
|
||||
};
|
||||
|
||||
return { feaTrials: fea, nnTrials: nn, paretoTrials: pareto, infeasibleTrials: infeasible, stats };
|
||||
}, [trials, paretoSet, showInfeasible]);
|
||||
|
||||
// Helper to get objective value
|
||||
const getObjValue = (trial: Trial, idx: number): number => {
|
||||
if (trial.values && trial.values[idx] !== undefined) {
|
||||
return trial.values[idx];
|
||||
}
|
||||
const objName = objectives[idx]?.name;
|
||||
return trial.user_attrs?.[objName] ?? 0;
|
||||
};
|
||||
|
||||
// Build hover text
|
||||
const buildHoverText = (trial: Trial): string => {
|
||||
const lines = [`Trial #${trial.trial_number}`];
|
||||
objectives.forEach((obj, i) => {
|
||||
const val = getObjValue(trial, i);
|
||||
lines.push(`${obj.name}: ${val.toFixed(4)}${obj.unit ? ` ${obj.unit}` : ''}`);
|
||||
});
|
||||
const source = trial.source || trial.user_attrs?.source || 'FEA';
|
||||
lines.push(`Source: ${source}`);
|
||||
return lines.join('<br>');
|
||||
};
|
||||
|
||||
// Create trace data
|
||||
const createTrace = (
|
||||
trialList: Trial[],
|
||||
name: string,
|
||||
color: string,
|
||||
symbol: string,
|
||||
size: number,
|
||||
opacity: number
|
||||
) => {
|
||||
const [i, j, k] = selectedObjectives;
|
||||
|
||||
if (viewMode === '3d' && objectives.length >= 3) {
|
||||
return {
|
||||
type: 'scatter3d' as const,
|
||||
mode: 'markers' as const,
|
||||
name,
|
||||
x: trialList.map(t => getObjValue(t, i)),
|
||||
y: trialList.map(t => getObjValue(t, j)),
|
||||
z: trialList.map(t => getObjValue(t, k)),
|
||||
text: trialList.map(buildHoverText),
|
||||
hoverinfo: 'text' as const,
|
||||
marker: {
|
||||
color,
|
||||
size,
|
||||
symbol,
|
||||
opacity,
|
||||
line: { color: '#fff', width: 1 }
|
||||
}
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
type: 'scatter' as const,
|
||||
mode: 'markers' as const,
|
||||
name,
|
||||
x: trialList.map(t => getObjValue(t, i)),
|
||||
y: trialList.map(t => getObjValue(t, j)),
|
||||
text: trialList.map(buildHoverText),
|
||||
hoverinfo: 'text' as const,
|
||||
marker: {
|
||||
color,
|
||||
size,
|
||||
symbol,
|
||||
opacity,
|
||||
line: { color: '#fff', width: 1 }
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Sort Pareto trials by first objective for line connection
|
||||
const sortedParetoTrials = useMemo(() => {
|
||||
const [i] = selectedObjectives;
|
||||
return [...paretoTrials].sort((a, b) => getObjValue(a, i) - getObjValue(b, i));
|
||||
}, [paretoTrials, selectedObjectives]);
|
||||
|
||||
// Create Pareto front line trace (2D only)
|
||||
const createParetoLine = () => {
|
||||
if (!showParetoLine || viewMode === '3d' || sortedParetoTrials.length < 2) return null;
|
||||
const [i, j] = selectedObjectives;
|
||||
return {
|
||||
type: 'scatter' as const,
|
||||
mode: 'lines' as const,
|
||||
name: 'Pareto Front',
|
||||
x: sortedParetoTrials.map(t => getObjValue(t, i)),
|
||||
y: sortedParetoTrials.map(t => getObjValue(t, j)),
|
||||
line: {
|
||||
color: '#10B981',
|
||||
width: 2,
|
||||
dash: 'dot'
|
||||
},
|
||||
hoverinfo: 'skip' as const,
|
||||
showlegend: false
|
||||
};
|
||||
};
|
||||
|
||||
const traces = [
|
||||
// Infeasible trials (background, red X)
|
||||
...(showInfeasible && infeasibleTrials.length > 0 ? [
|
||||
createTrace(infeasibleTrials, `Infeasible (${infeasibleTrials.length})`, '#EF4444', 'x', 7, 0.4)
|
||||
] : []),
|
||||
// FEA trials (blue circles)
|
||||
createTrace(feaTrials, `FEA (${feaTrials.length})`, '#3B82F6', 'circle', 8, 0.6),
|
||||
// NN trials (purple diamonds)
|
||||
createTrace(nnTrials, `NN (${nnTrials.length})`, '#A855F7', 'diamond', 8, 0.5),
|
||||
// Pareto front line (2D only)
|
||||
createParetoLine(),
|
||||
// Pareto front points (highlighted)
|
||||
createTrace(sortedParetoTrials, `Pareto (${sortedParetoTrials.length})`, '#10B981', 'star', 14, 1.0)
|
||||
].filter(trace => trace && (trace.x as number[]).length > 0);
|
||||
|
||||
const [i, j, k] = selectedObjectives;
|
||||
|
||||
// Dark mode color scheme
|
||||
const colors = {
|
||||
text: '#E5E7EB',
|
||||
textMuted: '#9CA3AF',
|
||||
grid: 'rgba(255,255,255,0.1)',
|
||||
zeroline: 'rgba(255,255,255,0.2)',
|
||||
legendBg: 'rgba(30,30,30,0.9)',
|
||||
legendBorder: 'rgba(255,255,255,0.1)'
|
||||
};
|
||||
|
||||
const layout: any = viewMode === '3d' && objectives.length >= 3
|
||||
? {
|
||||
height,
|
||||
margin: { l: 50, r: 50, t: 30, b: 50 },
|
||||
paper_bgcolor: 'transparent',
|
||||
plot_bgcolor: 'transparent',
|
||||
scene: {
|
||||
xaxis: {
|
||||
title: { text: objectives[i]?.name || 'Objective 1', font: { color: colors.text } },
|
||||
gridcolor: colors.grid,
|
||||
zerolinecolor: colors.zeroline,
|
||||
tickfont: { color: colors.textMuted }
|
||||
},
|
||||
yaxis: {
|
||||
title: { text: objectives[j]?.name || 'Objective 2', font: { color: colors.text } },
|
||||
gridcolor: colors.grid,
|
||||
zerolinecolor: colors.zeroline,
|
||||
tickfont: { color: colors.textMuted }
|
||||
},
|
||||
zaxis: {
|
||||
title: { text: objectives[k]?.name || 'Objective 3', font: { color: colors.text } },
|
||||
gridcolor: colors.grid,
|
||||
zerolinecolor: colors.zeroline,
|
||||
tickfont: { color: colors.textMuted }
|
||||
},
|
||||
bgcolor: 'transparent'
|
||||
},
|
||||
legend: {
|
||||
x: 1,
|
||||
y: 1,
|
||||
font: { color: colors.text },
|
||||
bgcolor: colors.legendBg,
|
||||
bordercolor: colors.legendBorder,
|
||||
borderwidth: 1
|
||||
},
|
||||
font: { family: 'Inter, system-ui, sans-serif', color: colors.text }
|
||||
}
|
||||
: {
|
||||
height,
|
||||
margin: { l: 60, r: 30, t: 30, b: 60 },
|
||||
paper_bgcolor: 'transparent',
|
||||
plot_bgcolor: 'transparent',
|
||||
xaxis: {
|
||||
title: { text: objectives[i]?.name || 'Objective 1', font: { color: colors.text } },
|
||||
gridcolor: colors.grid,
|
||||
zerolinecolor: colors.zeroline,
|
||||
tickfont: { color: colors.textMuted }
|
||||
},
|
||||
yaxis: {
|
||||
title: { text: objectives[j]?.name || 'Objective 2', font: { color: colors.text } },
|
||||
gridcolor: colors.grid,
|
||||
zerolinecolor: colors.zeroline,
|
||||
tickfont: { color: colors.textMuted }
|
||||
},
|
||||
legend: {
|
||||
x: 1,
|
||||
y: 1,
|
||||
xanchor: 'right',
|
||||
font: { color: colors.text },
|
||||
bgcolor: colors.legendBg,
|
||||
bordercolor: colors.legendBorder,
|
||||
borderwidth: 1
|
||||
},
|
||||
font: { family: 'Inter, system-ui, sans-serif', color: colors.text },
|
||||
hovermode: 'closest' as const
|
||||
};
|
||||
|
||||
if (!trials.length) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64 text-dark-400">
|
||||
No trial data available
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{/* Stats Bar */}
|
||||
<div className="flex gap-4 mb-4 text-sm">
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-dark-700 rounded-lg">
|
||||
<div className="w-3 h-3 bg-green-500 rounded-full" />
|
||||
<span className="text-dark-300">Pareto:</span>
|
||||
<span className="text-green-400 font-medium">{stats.paretoCount}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-dark-700 rounded-lg">
|
||||
<div className="w-3 h-3 bg-blue-500 rounded-full" />
|
||||
<span className="text-dark-300">FEA:</span>
|
||||
<span className="text-blue-400 font-medium">{stats.feaCount}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-dark-700 rounded-lg">
|
||||
<div className="w-3 h-3 bg-purple-500 rounded-full" />
|
||||
<span className="text-dark-300">NN:</span>
|
||||
<span className="text-purple-400 font-medium">{stats.nnCount}</span>
|
||||
</div>
|
||||
{stats.infeasibleCount > 0 && (
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-dark-700 rounded-lg">
|
||||
<div className="w-3 h-3 bg-red-500 rounded-full" />
|
||||
<span className="text-dark-300">Infeasible:</span>
|
||||
<span className="text-red-400 font-medium">{stats.infeasibleCount}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex gap-4 items-center justify-between mb-3">
|
||||
<div className="flex gap-2 items-center">
|
||||
{objectives.length >= 3 && (
|
||||
<div className="flex rounded-lg overflow-hidden border border-dark-600">
|
||||
<button
|
||||
onClick={() => setViewMode('2d')}
|
||||
className={`px-3 py-1.5 text-sm font-medium transition-colors ${
|
||||
viewMode === '2d'
|
||||
? 'bg-primary-600 text-white'
|
||||
: 'bg-dark-700 text-dark-300 hover:bg-dark-600 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
2D
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('3d')}
|
||||
className={`px-3 py-1.5 text-sm font-medium transition-colors ${
|
||||
viewMode === '3d'
|
||||
? 'bg-primary-600 text-white'
|
||||
: 'bg-dark-700 text-dark-300 hover:bg-dark-600 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
3D
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Objective selectors */}
|
||||
<div className="flex gap-2 items-center text-sm">
|
||||
<label className="text-dark-400">X:</label>
|
||||
<select
|
||||
value={selectedObjectives[0]}
|
||||
onChange={(e) => setSelectedObjectives([parseInt(e.target.value), selectedObjectives[1], selectedObjectives[2]])}
|
||||
className="px-2 py-1.5 bg-dark-700 border border-dark-600 rounded text-white text-sm"
|
||||
>
|
||||
{objectives.map((obj, idx) => (
|
||||
<option key={idx} value={idx}>{obj.name}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<label className="text-dark-400 ml-2">Y:</label>
|
||||
<select
|
||||
value={selectedObjectives[1]}
|
||||
onChange={(e) => setSelectedObjectives([selectedObjectives[0], parseInt(e.target.value), selectedObjectives[2]])}
|
||||
className="px-2 py-1.5 bg-dark-700 border border-dark-600 rounded text-white text-sm"
|
||||
>
|
||||
{objectives.map((obj, idx) => (
|
||||
<option key={idx} value={idx}>{obj.name}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{viewMode === '3d' && objectives.length >= 3 && (
|
||||
<>
|
||||
<label className="text-dark-400 ml-2">Z:</label>
|
||||
<select
|
||||
value={selectedObjectives[2]}
|
||||
onChange={(e) => setSelectedObjectives([selectedObjectives[0], selectedObjectives[1], parseInt(e.target.value)])}
|
||||
className="px-2 py-1.5 bg-dark-700 border border-dark-600 rounded text-white text-sm"
|
||||
>
|
||||
{objectives.map((obj, idx) => (
|
||||
<option key={idx} value={idx}>{obj.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Plot
|
||||
data={traces as any}
|
||||
layout={layout}
|
||||
config={{
|
||||
displayModeBar: true,
|
||||
displaylogo: false,
|
||||
modeBarButtonsToRemove: ['lasso2d', 'select2d'],
|
||||
toImageButtonOptions: {
|
||||
format: 'png',
|
||||
filename: 'pareto_front',
|
||||
height: 800,
|
||||
width: 1200,
|
||||
scale: 2
|
||||
}
|
||||
}}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
|
||||
{/* Pareto Front Table for 2D view */}
|
||||
{viewMode === '2d' && sortedParetoTrials.length > 0 && (
|
||||
<div className="mt-4 max-h-48 overflow-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="sticky top-0 bg-dark-800">
|
||||
<tr className="border-b border-dark-600">
|
||||
<th className="text-left py-2 px-3 text-dark-400 font-medium">Trial</th>
|
||||
<th className="text-left py-2 px-3 text-dark-400 font-medium">{objectives[i]?.name || 'Obj 1'}</th>
|
||||
<th className="text-left py-2 px-3 text-dark-400 font-medium">{objectives[j]?.name || 'Obj 2'}</th>
|
||||
<th className="text-left py-2 px-3 text-dark-400 font-medium">Source</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedParetoTrials.slice(0, 10).map(trial => (
|
||||
<tr key={trial.trial_number} className="border-b border-dark-700 hover:bg-dark-750">
|
||||
<td className="py-2 px-3 font-mono text-white">#{trial.trial_number}</td>
|
||||
<td className="py-2 px-3 font-mono text-green-400">
|
||||
{getObjValue(trial, i).toExponential(4)}
|
||||
</td>
|
||||
<td className="py-2 px-3 font-mono text-green-400">
|
||||
{getObjValue(trial, j).toExponential(4)}
|
||||
</td>
|
||||
<td className="py-2 px-3">
|
||||
<span className={`px-2 py-0.5 rounded text-xs ${
|
||||
(trial.source || trial.user_attrs?.source) === 'NN'
|
||||
? 'bg-purple-500/20 text-purple-400'
|
||||
: 'bg-blue-500/20 text-blue-400'
|
||||
}`}>
|
||||
{trial.source || trial.user_attrs?.source || 'FEA'}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{sortedParetoTrials.length > 10 && (
|
||||
<div className="text-center py-2 text-dark-500 text-xs">
|
||||
Showing 10 of {sortedParetoTrials.length} Pareto-optimal solutions
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,247 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import Plot from 'react-plotly.js';
|
||||
import { TrendingUp, TrendingDown, Minus } from 'lucide-react';
|
||||
|
||||
interface Run {
|
||||
run_id: number;
|
||||
name: string;
|
||||
source: 'FEA' | 'NN';
|
||||
trial_count: number;
|
||||
best_value: number | null;
|
||||
avg_value: number | null;
|
||||
first_trial: string | null;
|
||||
last_trial: string | null;
|
||||
}
|
||||
|
||||
interface PlotlyRunComparisonProps {
|
||||
runs: Run[];
|
||||
height?: number;
|
||||
}
|
||||
|
||||
export function PlotlyRunComparison({ runs, height = 400 }: PlotlyRunComparisonProps) {
|
||||
const chartData = useMemo(() => {
|
||||
if (runs.length === 0) return null;
|
||||
|
||||
// Separate FEA and NN runs
|
||||
const feaRuns = runs.filter(r => r.source === 'FEA');
|
||||
const nnRuns = runs.filter(r => r.source === 'NN');
|
||||
|
||||
// Create bar chart for trial counts
|
||||
const trialCountData = {
|
||||
x: runs.map(r => r.name),
|
||||
y: runs.map(r => r.trial_count),
|
||||
type: 'bar' as const,
|
||||
name: 'Trial Count',
|
||||
marker: {
|
||||
color: runs.map(r => r.source === 'NN' ? 'rgba(147, 51, 234, 0.8)' : 'rgba(59, 130, 246, 0.8)'),
|
||||
line: { color: runs.map(r => r.source === 'NN' ? 'rgb(147, 51, 234)' : 'rgb(59, 130, 246)'), width: 1 }
|
||||
},
|
||||
hovertemplate: '<b>%{x}</b><br>Trials: %{y}<extra></extra>'
|
||||
};
|
||||
|
||||
// Create line chart for best values
|
||||
const bestValueData = {
|
||||
x: runs.map(r => r.name),
|
||||
y: runs.map(r => r.best_value),
|
||||
type: 'scatter' as const,
|
||||
mode: 'lines+markers' as const,
|
||||
name: 'Best Value',
|
||||
yaxis: 'y2',
|
||||
line: { color: 'rgba(16, 185, 129, 1)', width: 2 },
|
||||
marker: { size: 8, color: 'rgba(16, 185, 129, 1)' },
|
||||
hovertemplate: '<b>%{x}</b><br>Best: %{y:.4e}<extra></extra>'
|
||||
};
|
||||
|
||||
return { trialCountData, bestValueData, feaRuns, nnRuns };
|
||||
}, [runs]);
|
||||
|
||||
// Calculate statistics
|
||||
const stats = useMemo(() => {
|
||||
if (runs.length === 0) return null;
|
||||
|
||||
const totalTrials = runs.reduce((sum, r) => sum + r.trial_count, 0);
|
||||
const feaTrials = runs.filter(r => r.source === 'FEA').reduce((sum, r) => sum + r.trial_count, 0);
|
||||
const nnTrials = runs.filter(r => r.source === 'NN').reduce((sum, r) => sum + r.trial_count, 0);
|
||||
|
||||
const bestValues = runs.map(r => r.best_value).filter((v): v is number => v !== null);
|
||||
const overallBest = bestValues.length > 0 ? Math.min(...bestValues) : null;
|
||||
|
||||
// Calculate improvement from first FEA run to overall best
|
||||
const feaRuns = runs.filter(r => r.source === 'FEA');
|
||||
const firstFEA = feaRuns.length > 0 ? feaRuns[0].best_value : null;
|
||||
const improvement = firstFEA && overallBest ? ((firstFEA - overallBest) / Math.abs(firstFEA)) * 100 : null;
|
||||
|
||||
return {
|
||||
totalTrials,
|
||||
feaTrials,
|
||||
nnTrials,
|
||||
overallBest,
|
||||
improvement,
|
||||
totalRuns: runs.length,
|
||||
feaRuns: runs.filter(r => r.source === 'FEA').length,
|
||||
nnRuns: runs.filter(r => r.source === 'NN').length
|
||||
};
|
||||
}, [runs]);
|
||||
|
||||
if (!chartData || !stats) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64 text-dark-400">
|
||||
No run data available
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Stats Summary */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3">
|
||||
<div className="bg-dark-750 rounded-lg p-3">
|
||||
<div className="text-xs text-dark-400 mb-1">Total Runs</div>
|
||||
<div className="text-xl font-bold text-white">{stats.totalRuns}</div>
|
||||
</div>
|
||||
<div className="bg-dark-750 rounded-lg p-3">
|
||||
<div className="text-xs text-dark-400 mb-1">Total Trials</div>
|
||||
<div className="text-xl font-bold text-white">{stats.totalTrials}</div>
|
||||
</div>
|
||||
<div className="bg-dark-750 rounded-lg p-3">
|
||||
<div className="text-xs text-dark-400 mb-1">FEA Trials</div>
|
||||
<div className="text-xl font-bold text-blue-400">{stats.feaTrials}</div>
|
||||
</div>
|
||||
<div className="bg-dark-750 rounded-lg p-3">
|
||||
<div className="text-xs text-dark-400 mb-1">NN Trials</div>
|
||||
<div className="text-xl font-bold text-purple-400">{stats.nnTrials}</div>
|
||||
</div>
|
||||
<div className="bg-dark-750 rounded-lg p-3">
|
||||
<div className="text-xs text-dark-400 mb-1">Best Value</div>
|
||||
<div className="text-xl font-bold text-green-400">
|
||||
{stats.overallBest !== null ? stats.overallBest.toExponential(3) : 'N/A'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-dark-750 rounded-lg p-3">
|
||||
<div className="text-xs text-dark-400 mb-1">Improvement</div>
|
||||
<div className="text-xl font-bold text-primary-400 flex items-center gap-1">
|
||||
{stats.improvement !== null ? (
|
||||
<>
|
||||
{stats.improvement > 0 ? <TrendingDown className="w-4 h-4" /> :
|
||||
stats.improvement < 0 ? <TrendingUp className="w-4 h-4" /> :
|
||||
<Minus className="w-4 h-4" />}
|
||||
{Math.abs(stats.improvement).toFixed(1)}%
|
||||
</>
|
||||
) : 'N/A'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chart */}
|
||||
<Plot
|
||||
data={[chartData.trialCountData, chartData.bestValueData]}
|
||||
layout={{
|
||||
height,
|
||||
margin: { l: 60, r: 60, t: 40, b: 100 },
|
||||
paper_bgcolor: 'transparent',
|
||||
plot_bgcolor: 'transparent',
|
||||
font: { color: '#9ca3af', size: 11 },
|
||||
showlegend: true,
|
||||
legend: {
|
||||
orientation: 'h',
|
||||
y: 1.12,
|
||||
x: 0.5,
|
||||
xanchor: 'center',
|
||||
bgcolor: 'transparent'
|
||||
},
|
||||
xaxis: {
|
||||
tickangle: -45,
|
||||
gridcolor: 'rgba(75, 85, 99, 0.3)',
|
||||
linecolor: 'rgba(75, 85, 99, 0.5)',
|
||||
tickfont: { size: 10 }
|
||||
},
|
||||
yaxis: {
|
||||
title: { text: 'Trial Count' },
|
||||
gridcolor: 'rgba(75, 85, 99, 0.3)',
|
||||
linecolor: 'rgba(75, 85, 99, 0.5)',
|
||||
zeroline: false
|
||||
},
|
||||
yaxis2: {
|
||||
title: { text: 'Best Value' },
|
||||
overlaying: 'y',
|
||||
side: 'right',
|
||||
gridcolor: 'rgba(75, 85, 99, 0.1)',
|
||||
linecolor: 'rgba(75, 85, 99, 0.5)',
|
||||
zeroline: false,
|
||||
tickformat: '.2e'
|
||||
},
|
||||
bargap: 0.3,
|
||||
hovermode: 'x unified'
|
||||
}}
|
||||
config={{
|
||||
displayModeBar: true,
|
||||
displaylogo: false,
|
||||
modeBarButtonsToRemove: ['select2d', 'lasso2d', 'autoScale2d']
|
||||
}}
|
||||
className="w-full"
|
||||
useResizeHandler
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
|
||||
{/* Runs Table */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-dark-600">
|
||||
<th className="text-left py-2 px-3 text-dark-400 font-medium">Run Name</th>
|
||||
<th className="text-left py-2 px-3 text-dark-400 font-medium">Source</th>
|
||||
<th className="text-right py-2 px-3 text-dark-400 font-medium">Trials</th>
|
||||
<th className="text-right py-2 px-3 text-dark-400 font-medium">Best Value</th>
|
||||
<th className="text-right py-2 px-3 text-dark-400 font-medium">Avg Value</th>
|
||||
<th className="text-left py-2 px-3 text-dark-400 font-medium">Duration</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{runs.map((run) => {
|
||||
// Calculate duration if times available
|
||||
let duration = '-';
|
||||
if (run.first_trial && run.last_trial) {
|
||||
const start = new Date(run.first_trial);
|
||||
const end = new Date(run.last_trial);
|
||||
const diffMs = end.getTime() - start.getTime();
|
||||
const diffMins = Math.round(diffMs / 60000);
|
||||
if (diffMins < 60) {
|
||||
duration = `${diffMins}m`;
|
||||
} else {
|
||||
const hours = Math.floor(diffMins / 60);
|
||||
const mins = diffMins % 60;
|
||||
duration = `${hours}h ${mins}m`;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<tr key={run.run_id} className="border-b border-dark-700 hover:bg-dark-750">
|
||||
<td className="py-2 px-3 font-mono text-white">{run.name}</td>
|
||||
<td className="py-2 px-3">
|
||||
<span className={`px-2 py-0.5 rounded text-xs ${
|
||||
run.source === 'NN'
|
||||
? 'bg-purple-500/20 text-purple-400'
|
||||
: 'bg-blue-500/20 text-blue-400'
|
||||
}`}>
|
||||
{run.source}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 px-3 text-right font-mono text-white">{run.trial_count}</td>
|
||||
<td className="py-2 px-3 text-right font-mono text-green-400">
|
||||
{run.best_value !== null ? run.best_value.toExponential(4) : '-'}
|
||||
</td>
|
||||
<td className="py-2 px-3 text-right font-mono text-dark-300">
|
||||
{run.avg_value !== null ? run.avg_value.toExponential(4) : '-'}
|
||||
</td>
|
||||
<td className="py-2 px-3 text-dark-400">{duration}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PlotlyRunComparison;
|
||||
@@ -1,202 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import Plot from 'react-plotly.js';
|
||||
|
||||
interface TrialData {
|
||||
trial_number: number;
|
||||
values: number[];
|
||||
source?: 'FEA' | 'NN' | 'V10_FEA';
|
||||
user_attrs?: Record<string, any>;
|
||||
}
|
||||
|
||||
interface PlotlySurrogateQualityProps {
|
||||
trials: TrialData[];
|
||||
height?: number;
|
||||
}
|
||||
|
||||
export function PlotlySurrogateQuality({
|
||||
trials,
|
||||
height = 400
|
||||
}: PlotlySurrogateQualityProps) {
|
||||
const { feaTrials, nnTrials, timeline } = useMemo(() => {
|
||||
const fea = trials.filter(t => t.source === 'FEA' || t.source === 'V10_FEA');
|
||||
const nn = trials.filter(t => t.source === 'NN');
|
||||
|
||||
// Sort by trial number for timeline
|
||||
const sorted = [...trials].sort((a, b) => a.trial_number - b.trial_number);
|
||||
|
||||
// Calculate source distribution over time
|
||||
const timeline: { trial: number; feaCount: number; nnCount: number }[] = [];
|
||||
let feaCount = 0;
|
||||
let nnCount = 0;
|
||||
|
||||
sorted.forEach(t => {
|
||||
if (t.source === 'NN') nnCount++;
|
||||
else feaCount++;
|
||||
|
||||
timeline.push({
|
||||
trial: t.trial_number,
|
||||
feaCount,
|
||||
nnCount
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
feaTrials: fea,
|
||||
nnTrials: nn,
|
||||
timeline
|
||||
};
|
||||
}, [trials]);
|
||||
|
||||
if (nnTrials.length === 0) {
|
||||
return (
|
||||
<div className="h-64 flex items-center justify-center text-dark-400">
|
||||
<p>No neural network evaluations in this study</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Objective distribution by source
|
||||
const feaObjectives = feaTrials.map(t => t.values[0]).filter(v => v !== undefined && !isNaN(v));
|
||||
const nnObjectives = nnTrials.map(t => t.values[0]).filter(v => v !== undefined && !isNaN(v));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Source Distribution Over Time */}
|
||||
<Plot
|
||||
data={[
|
||||
{
|
||||
x: timeline.map(t => t.trial),
|
||||
y: timeline.map(t => t.feaCount),
|
||||
type: 'scatter',
|
||||
mode: 'lines',
|
||||
name: 'FEA Cumulative',
|
||||
line: { color: '#3b82f6', width: 2 },
|
||||
fill: 'tozeroy',
|
||||
fillcolor: 'rgba(59, 130, 246, 0.2)'
|
||||
},
|
||||
{
|
||||
x: timeline.map(t => t.trial),
|
||||
y: timeline.map(t => t.nnCount),
|
||||
type: 'scatter',
|
||||
mode: 'lines',
|
||||
name: 'NN Cumulative',
|
||||
line: { color: '#a855f7', width: 2 },
|
||||
fill: 'tozeroy',
|
||||
fillcolor: 'rgba(168, 85, 247, 0.2)'
|
||||
}
|
||||
]}
|
||||
layout={{
|
||||
title: {
|
||||
text: 'Evaluation Source Over Time',
|
||||
font: { color: '#fff', size: 14 }
|
||||
},
|
||||
height: height * 0.6,
|
||||
margin: { l: 60, r: 30, t: 50, b: 50 },
|
||||
paper_bgcolor: 'transparent',
|
||||
plot_bgcolor: 'transparent',
|
||||
xaxis: {
|
||||
title: { text: 'Trial Number', font: { color: '#888' } },
|
||||
tickfont: { color: '#888' },
|
||||
gridcolor: 'rgba(255,255,255,0.05)'
|
||||
},
|
||||
yaxis: {
|
||||
title: { text: 'Cumulative Count', font: { color: '#888' } },
|
||||
tickfont: { color: '#888' },
|
||||
gridcolor: 'rgba(255,255,255,0.1)'
|
||||
},
|
||||
legend: {
|
||||
font: { color: '#888' },
|
||||
bgcolor: 'rgba(0,0,0,0.5)',
|
||||
orientation: 'h',
|
||||
y: 1.1
|
||||
},
|
||||
showlegend: true
|
||||
}}
|
||||
config={{
|
||||
displayModeBar: true,
|
||||
modeBarButtonsToRemove: ['lasso2d', 'select2d'],
|
||||
displaylogo: false
|
||||
}}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
|
||||
{/* Objective Distribution by Source */}
|
||||
<Plot
|
||||
data={[
|
||||
{
|
||||
x: feaObjectives,
|
||||
type: 'histogram',
|
||||
name: 'FEA',
|
||||
marker: { color: 'rgba(59, 130, 246, 0.7)' },
|
||||
opacity: 0.8
|
||||
} as any,
|
||||
{
|
||||
x: nnObjectives,
|
||||
type: 'histogram',
|
||||
name: 'NN',
|
||||
marker: { color: 'rgba(168, 85, 247, 0.7)' },
|
||||
opacity: 0.8
|
||||
} as any
|
||||
]}
|
||||
layout={{
|
||||
title: {
|
||||
text: 'Objective Distribution by Source',
|
||||
font: { color: '#fff', size: 14 }
|
||||
},
|
||||
height: height * 0.5,
|
||||
margin: { l: 60, r: 30, t: 50, b: 50 },
|
||||
paper_bgcolor: 'transparent',
|
||||
plot_bgcolor: 'transparent',
|
||||
xaxis: {
|
||||
title: { text: 'Objective Value', font: { color: '#888' } },
|
||||
tickfont: { color: '#888' },
|
||||
gridcolor: 'rgba(255,255,255,0.05)'
|
||||
},
|
||||
yaxis: {
|
||||
title: { text: 'Count', font: { color: '#888' } },
|
||||
tickfont: { color: '#888' },
|
||||
gridcolor: 'rgba(255,255,255,0.1)'
|
||||
},
|
||||
barmode: 'overlay',
|
||||
legend: {
|
||||
font: { color: '#888' },
|
||||
bgcolor: 'rgba(0,0,0,0.5)',
|
||||
orientation: 'h',
|
||||
y: 1.1
|
||||
},
|
||||
showlegend: true
|
||||
}}
|
||||
config={{
|
||||
displayModeBar: true,
|
||||
modeBarButtonsToRemove: ['lasso2d', 'select2d'],
|
||||
displaylogo: false
|
||||
}}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
|
||||
{/* FEA vs NN Best Values Comparison */}
|
||||
{feaObjectives.length > 0 && nnObjectives.length > 0 && (
|
||||
<div className="grid grid-cols-2 gap-4 mt-4">
|
||||
<div className="bg-dark-750 rounded-lg p-4 border border-dark-600">
|
||||
<div className="text-xs text-dark-400 uppercase mb-2">FEA Best</div>
|
||||
<div className="text-xl font-mono text-blue-400">
|
||||
{Math.min(...feaObjectives).toExponential(4)}
|
||||
</div>
|
||||
<div className="text-xs text-dark-500 mt-1">
|
||||
from {feaObjectives.length} evaluations
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-dark-750 rounded-lg p-4 border border-dark-600">
|
||||
<div className="text-xs text-dark-400 uppercase mb-2">NN Best</div>
|
||||
<div className="text-xl font-mono text-purple-400">
|
||||
{Math.min(...nnObjectives).toExponential(4)}
|
||||
</div>
|
||||
<div className="text-xs text-dark-500 mt-1">
|
||||
from {nnObjectives.length} predictions
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,217 +0,0 @@
|
||||
# Plotly Chart Components
|
||||
|
||||
Interactive visualization components using Plotly.js for the Atomizer Dashboard.
|
||||
|
||||
## Overview
|
||||
|
||||
These components provide enhanced interactivity compared to Recharts:
|
||||
- Native zoom, pan, and selection
|
||||
- Export to PNG/SVG
|
||||
- Hover tooltips with detailed information
|
||||
- Brush filtering (parallel coordinates)
|
||||
- 3D visualization support
|
||||
|
||||
## Components
|
||||
|
||||
### PlotlyParallelCoordinates
|
||||
|
||||
Multi-dimensional data visualization showing relationships between all variables.
|
||||
|
||||
```tsx
|
||||
import { PlotlyParallelCoordinates } from '../components/plotly';
|
||||
|
||||
<PlotlyParallelCoordinates
|
||||
trials={allTrials}
|
||||
objectives={studyMetadata.objectives}
|
||||
designVariables={studyMetadata.design_variables}
|
||||
paretoFront={paretoFront}
|
||||
height={450}
|
||||
/>
|
||||
```
|
||||
|
||||
**Props:**
|
||||
| Prop | Type | Description |
|
||||
|------|------|-------------|
|
||||
| trials | Trial[] | All trial data |
|
||||
| objectives | Objective[] | Objective definitions |
|
||||
| designVariables | DesignVariable[] | Design variable definitions |
|
||||
| paretoFront | Trial[] | Pareto-optimal trials (optional) |
|
||||
| height | number | Chart height in pixels |
|
||||
|
||||
**Features:**
|
||||
- Drag on axes to filter data
|
||||
- Double-click to reset filters
|
||||
- Color coding: FEA (blue), NN (orange), Pareto (green)
|
||||
|
||||
### PlotlyParetoPlot
|
||||
|
||||
2D/3D scatter plot for Pareto front visualization.
|
||||
|
||||
```tsx
|
||||
<PlotlyParetoPlot
|
||||
trials={allTrials}
|
||||
paretoFront={paretoFront}
|
||||
objectives={studyMetadata.objectives}
|
||||
height={350}
|
||||
/>
|
||||
```
|
||||
|
||||
**Props:**
|
||||
| Prop | Type | Description |
|
||||
|------|------|-------------|
|
||||
| trials | Trial[] | All trial data |
|
||||
| paretoFront | Trial[] | Pareto-optimal trials |
|
||||
| objectives | Objective[] | Objective definitions |
|
||||
| height | number | Chart height in pixels |
|
||||
|
||||
**Features:**
|
||||
- Toggle between 2D and 3D views
|
||||
- Axis selector for multi-objective problems
|
||||
- Click to select trials
|
||||
- Hover for trial details
|
||||
|
||||
### PlotlyConvergencePlot
|
||||
|
||||
Optimization progress over trials.
|
||||
|
||||
```tsx
|
||||
<PlotlyConvergencePlot
|
||||
trials={allTrials}
|
||||
objectiveIndex={0}
|
||||
objectiveName="weighted_objective"
|
||||
direction="minimize"
|
||||
height={350}
|
||||
/>
|
||||
```
|
||||
|
||||
**Props:**
|
||||
| Prop | Type | Description |
|
||||
|------|------|-------------|
|
||||
| trials | Trial[] | All trial data |
|
||||
| objectiveIndex | number | Which objective to plot |
|
||||
| objectiveName | string | Objective display name |
|
||||
| direction | 'minimize' \| 'maximize' | Optimization direction |
|
||||
| height | number | Chart height |
|
||||
| showRangeSlider | boolean | Show zoom slider |
|
||||
|
||||
**Features:**
|
||||
- Scatter points for each trial
|
||||
- Best-so-far step line
|
||||
- Range slider for zooming
|
||||
- FEA vs NN differentiation
|
||||
|
||||
### PlotlyParameterImportance
|
||||
|
||||
Correlation-based parameter sensitivity analysis.
|
||||
|
||||
```tsx
|
||||
<PlotlyParameterImportance
|
||||
trials={allTrials}
|
||||
designVariables={studyMetadata.design_variables}
|
||||
objectiveIndex={0}
|
||||
objectiveName="weighted_objective"
|
||||
height={350}
|
||||
/>
|
||||
```
|
||||
|
||||
**Props:**
|
||||
| Prop | Type | Description |
|
||||
|------|------|-------------|
|
||||
| trials | Trial[] | All trial data |
|
||||
| designVariables | DesignVariable[] | Design variables |
|
||||
| objectiveIndex | number | Which objective |
|
||||
| objectiveName | string | Objective display name |
|
||||
| height | number | Chart height |
|
||||
|
||||
**Features:**
|
||||
- Horizontal bar chart of correlations
|
||||
- Sort by importance or name
|
||||
- Color: Red (positive), Green (negative)
|
||||
- Pearson correlation coefficient
|
||||
|
||||
## Bundle Optimization
|
||||
|
||||
To minimize bundle size, we use:
|
||||
|
||||
1. **plotly.js-basic-dist**: Smaller bundle (~1MB vs 3.5MB)
|
||||
- Includes: scatter, bar, parcoords
|
||||
- Excludes: 3D plots, maps, animations
|
||||
|
||||
2. **Lazy Loading**: Components loaded on demand
|
||||
```tsx
|
||||
const PlotlyParetoPlot = lazy(() =>
|
||||
import('./plotly/PlotlyParetoPlot')
|
||||
.then(m => ({ default: m.PlotlyParetoPlot }))
|
||||
);
|
||||
```
|
||||
|
||||
3. **Code Splitting**: Vite config separates Plotly into its own chunk
|
||||
```ts
|
||||
manualChunks: {
|
||||
plotly: ['plotly.js-basic-dist', 'react-plotly.js']
|
||||
}
|
||||
```
|
||||
|
||||
## Usage with Suspense
|
||||
|
||||
Always wrap Plotly components with Suspense:
|
||||
|
||||
```tsx
|
||||
<Suspense fallback={<ChartLoading />}>
|
||||
<PlotlyParetoPlot {...props} />
|
||||
</Suspense>
|
||||
```
|
||||
|
||||
## Type Definitions
|
||||
|
||||
```typescript
|
||||
interface Trial {
|
||||
trial_number: number;
|
||||
values: number[];
|
||||
params: Record<string, number>;
|
||||
user_attrs?: Record<string, any>;
|
||||
source?: 'FEA' | 'NN' | 'V10_FEA';
|
||||
}
|
||||
|
||||
interface Objective {
|
||||
name: string;
|
||||
direction?: 'minimize' | 'maximize';
|
||||
unit?: string;
|
||||
}
|
||||
|
||||
interface DesignVariable {
|
||||
name: string;
|
||||
unit?: string;
|
||||
min?: number;
|
||||
max?: number;
|
||||
}
|
||||
```
|
||||
|
||||
## Styling
|
||||
|
||||
Components use transparent backgrounds for dark theme compatibility:
|
||||
- `paper_bgcolor: 'rgba(0,0,0,0)'`
|
||||
- `plot_bgcolor: 'rgba(0,0,0,0)'`
|
||||
- Font: Inter, system-ui, sans-serif
|
||||
- Grid colors: Tailwind gray palette
|
||||
|
||||
## Export Options
|
||||
|
||||
All Plotly charts include a mode bar with:
|
||||
- Download PNG
|
||||
- Download SVG (via menu)
|
||||
- Zoom, Pan, Reset
|
||||
- Auto-scale
|
||||
|
||||
Configure export in the `config` prop:
|
||||
```tsx
|
||||
config={{
|
||||
toImageButtonOptions: {
|
||||
format: 'png',
|
||||
filename: 'my_chart',
|
||||
height: 600,
|
||||
width: 1200,
|
||||
scale: 2
|
||||
}
|
||||
}}
|
||||
```
|
||||
@@ -1,15 +0,0 @@
|
||||
/**
|
||||
* Plotly-based interactive chart components
|
||||
*
|
||||
* These components provide enhanced interactivity compared to Recharts:
|
||||
* - Native zoom/pan
|
||||
* - Brush selection on axes
|
||||
* - 3D views for multi-objective problems
|
||||
* - Export to PNG/SVG
|
||||
* - Detailed hover tooltips
|
||||
*/
|
||||
|
||||
export { PlotlyParallelCoordinates } from './PlotlyParallelCoordinates';
|
||||
export { PlotlyParetoPlot } from './PlotlyParetoPlot';
|
||||
export { PlotlyConvergencePlot } from './PlotlyConvergencePlot';
|
||||
export { PlotlyParameterImportance } from './PlotlyParameterImportance';
|
||||
@@ -0,0 +1,254 @@
|
||||
/**
|
||||
* StudioBuildDialog - Final dialog to name and build the study
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { X, Loader2, FolderOpen, AlertCircle, CheckCircle, Sparkles, Play } from 'lucide-react';
|
||||
import { intakeApi } from '../../api/intake';
|
||||
|
||||
interface StudioBuildDialogProps {
|
||||
draftId: string;
|
||||
onClose: () => void;
|
||||
onBuildComplete: (finalPath: string, finalName: string) => void;
|
||||
}
|
||||
|
||||
interface Topic {
|
||||
name: string;
|
||||
study_count: number;
|
||||
}
|
||||
|
||||
export const StudioBuildDialog: React.FC<StudioBuildDialogProps> = ({
|
||||
draftId,
|
||||
onClose,
|
||||
onBuildComplete,
|
||||
}) => {
|
||||
const [studyName, setStudyName] = useState('');
|
||||
const [topic, setTopic] = useState('');
|
||||
const [newTopic, setNewTopic] = useState('');
|
||||
const [useNewTopic, setUseNewTopic] = useState(false);
|
||||
const [topics, setTopics] = useState<Topic[]>([]);
|
||||
const [isBuilding, setIsBuilding] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [validationErrors, setValidationErrors] = useState<string[]>([]);
|
||||
|
||||
// Load topics
|
||||
useEffect(() => {
|
||||
loadTopics();
|
||||
}, []);
|
||||
|
||||
const loadTopics = async () => {
|
||||
try {
|
||||
const response = await intakeApi.listTopics();
|
||||
setTopics(response.topics);
|
||||
if (response.topics.length > 0) {
|
||||
setTopic(response.topics[0].name);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load topics:', err);
|
||||
}
|
||||
};
|
||||
|
||||
// Validate study name
|
||||
useEffect(() => {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (studyName.length > 0) {
|
||||
if (studyName.length < 3) {
|
||||
errors.push('Name must be at least 3 characters');
|
||||
}
|
||||
if (!/^[a-z0-9_]+$/.test(studyName)) {
|
||||
errors.push('Use only lowercase letters, numbers, and underscores');
|
||||
}
|
||||
if (studyName.startsWith('draft_')) {
|
||||
errors.push('Name cannot start with "draft_"');
|
||||
}
|
||||
}
|
||||
|
||||
setValidationErrors(errors);
|
||||
}, [studyName]);
|
||||
|
||||
const handleBuild = async () => {
|
||||
const finalTopic = useNewTopic ? newTopic : topic;
|
||||
|
||||
if (!studyName || !finalTopic) {
|
||||
setError('Please provide both a study name and topic');
|
||||
return;
|
||||
}
|
||||
|
||||
if (validationErrors.length > 0) {
|
||||
setError('Please fix validation errors');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsBuilding(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await intakeApi.finalizeStudio(draftId, {
|
||||
topic: finalTopic,
|
||||
newName: studyName,
|
||||
runBaseline: false,
|
||||
});
|
||||
|
||||
onBuildComplete(response.final_path, response.final_name);
|
||||
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Build failed');
|
||||
} finally {
|
||||
setIsBuilding(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isValid = studyName.length >= 3 &&
|
||||
validationErrors.length === 0 &&
|
||||
(topic || (useNewTopic && newTopic));
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-dark-850 border border-dark-700 rounded-xl shadow-xl w-full max-w-lg mx-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-dark-700">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="w-5 h-5 text-primary-400" />
|
||||
<h2 className="text-lg font-semibold text-white">Build Study</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 hover:bg-dark-700 rounded text-dark-400 hover:text-white transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Study Name */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-dark-300 mb-2">
|
||||
Study Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={studyName}
|
||||
onChange={(e) => setStudyName(e.target.value.toLowerCase().replace(/[^a-z0-9_]/g, '_'))}
|
||||
placeholder="my_optimization_study"
|
||||
className="w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-white placeholder-dark-500 focus:outline-none focus:border-primary-400"
|
||||
/>
|
||||
{validationErrors.length > 0 && (
|
||||
<div className="mt-2 space-y-1">
|
||||
{validationErrors.map((err, i) => (
|
||||
<p key={i} className="text-xs text-red-400 flex items-center gap-1">
|
||||
<AlertCircle className="w-3 h-3" />
|
||||
{err}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{studyName.length >= 3 && validationErrors.length === 0 && (
|
||||
<p className="mt-2 text-xs text-green-400 flex items-center gap-1">
|
||||
<CheckCircle className="w-3 h-3" />
|
||||
Name is valid
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Topic Selection */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-dark-300 mb-2">
|
||||
Topic Folder
|
||||
</label>
|
||||
|
||||
{!useNewTopic && topics.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<select
|
||||
value={topic}
|
||||
onChange={(e) => setTopic(e.target.value)}
|
||||
className="w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-white focus:outline-none focus:border-primary-400"
|
||||
>
|
||||
{topics.map((t) => (
|
||||
<option key={t.name} value={t.name}>
|
||||
{t.name} ({t.study_count} studies)
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
onClick={() => setUseNewTopic(true)}
|
||||
className="text-sm text-primary-400 hover:text-primary-300"
|
||||
>
|
||||
+ Create new topic
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(useNewTopic || topics.length === 0) && (
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newTopic}
|
||||
onChange={(e) => setNewTopic(e.target.value.replace(/[^A-Za-z0-9_]/g, '_'))}
|
||||
placeholder="NewTopic"
|
||||
className="w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-white placeholder-dark-500 focus:outline-none focus:border-primary-400"
|
||||
/>
|
||||
{topics.length > 0 && (
|
||||
<button
|
||||
onClick={() => setUseNewTopic(false)}
|
||||
className="text-sm text-dark-400 hover:text-white"
|
||||
>
|
||||
Use existing topic
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Preview */}
|
||||
<div className="p-3 bg-dark-700/50 rounded-lg">
|
||||
<p className="text-xs text-dark-400 mb-1">Study will be created at:</p>
|
||||
<p className="text-sm text-white font-mono flex items-center gap-2">
|
||||
<FolderOpen className="w-4 h-4 text-primary-400" />
|
||||
studies/{useNewTopic ? newTopic || '...' : topic}/{studyName || '...'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="p-3 rounded-lg bg-red-500/10 border border-red-500/30 text-red-400 text-sm flex items-center gap-2">
|
||||
<AlertCircle className="w-4 h-4 flex-shrink-0" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-3 p-4 border-t border-dark-700">
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={isBuilding}
|
||||
className="px-4 py-2 text-sm text-dark-300 hover:text-white hover:bg-dark-700 rounded-lg transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleBuild}
|
||||
disabled={!isValid || isBuilding}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm font-medium bg-primary-500 text-white rounded-lg hover:bg-primary-400 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{isBuilding ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Building...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="w-4 h-4" />
|
||||
Build Study
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StudioBuildDialog;
|
||||
375
atomizer-dashboard/frontend/src/components/studio/StudioChat.tsx
Normal file
375
atomizer-dashboard/frontend/src/components/studio/StudioChat.tsx
Normal file
@@ -0,0 +1,375 @@
|
||||
/**
|
||||
* StudioChat - Context-aware AI chat for Studio
|
||||
*
|
||||
* Uses the existing useChat hook to communicate with Claude via WebSocket.
|
||||
* Injects model files and context documents into the conversation.
|
||||
*/
|
||||
|
||||
import React, { useRef, useEffect, useState, useMemo } from 'react';
|
||||
import { Send, Loader2, Sparkles, FileText, Wifi, WifiOff, Bot, User, File, AlertCircle } from 'lucide-react';
|
||||
import { useChat } from '../../hooks/useChat';
|
||||
import { useSpecStore, useSpec } from '../../hooks/useSpecStore';
|
||||
import { MarkdownRenderer } from '../MarkdownRenderer';
|
||||
import { ToolCallCard } from '../chat/ToolCallCard';
|
||||
|
||||
interface StudioChatProps {
|
||||
draftId: string;
|
||||
contextFiles: string[];
|
||||
contextContent: string;
|
||||
modelFiles: string[];
|
||||
onSpecUpdated: () => void;
|
||||
}
|
||||
|
||||
export const StudioChat: React.FC<StudioChatProps> = ({
|
||||
draftId,
|
||||
contextFiles,
|
||||
contextContent,
|
||||
modelFiles,
|
||||
onSpecUpdated,
|
||||
}) => {
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const [input, setInput] = useState('');
|
||||
const [hasInjectedContext, setHasInjectedContext] = useState(false);
|
||||
|
||||
// Get spec store for canvas updates
|
||||
const spec = useSpec();
|
||||
const { reloadSpec, setSpecFromWebSocket } = useSpecStore();
|
||||
|
||||
// Build canvas state with full context for Claude
|
||||
const canvasState = useMemo(() => ({
|
||||
nodes: [],
|
||||
edges: [],
|
||||
studyName: draftId,
|
||||
studyPath: `_inbox/${draftId}`,
|
||||
// Include file info for Claude context
|
||||
modelFiles,
|
||||
contextFiles,
|
||||
contextContent: contextContent.substring(0, 50000), // Limit context size
|
||||
}), [draftId, modelFiles, contextFiles, contextContent]);
|
||||
|
||||
// Use the chat hook with WebSocket
|
||||
// Power mode gives Claude write permissions to modify the spec
|
||||
const {
|
||||
messages,
|
||||
isThinking,
|
||||
error,
|
||||
isConnected,
|
||||
sendMessage,
|
||||
updateCanvasState,
|
||||
} = useChat({
|
||||
studyId: draftId,
|
||||
mode: 'power', // Power mode = --dangerously-skip-permissions = can write files
|
||||
useWebSocket: true,
|
||||
canvasState,
|
||||
onError: (err) => console.error('[StudioChat] Error:', err),
|
||||
onSpecUpdated: (newSpec) => {
|
||||
// Claude modified the spec - update the store directly
|
||||
console.log('[StudioChat] Spec updated by Claude');
|
||||
setSpecFromWebSocket(newSpec, draftId);
|
||||
onSpecUpdated();
|
||||
},
|
||||
onCanvasModification: (modification) => {
|
||||
// Claude wants to modify canvas - reload the spec
|
||||
console.log('[StudioChat] Canvas modification:', modification);
|
||||
reloadSpec();
|
||||
onSpecUpdated();
|
||||
},
|
||||
});
|
||||
|
||||
// Update canvas state when context changes
|
||||
useEffect(() => {
|
||||
updateCanvasState(canvasState);
|
||||
}, [canvasState, updateCanvasState]);
|
||||
|
||||
// Scroll to bottom when messages change
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages]);
|
||||
|
||||
// Auto-focus input
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
// Build context summary for display
|
||||
const contextSummary = useMemo(() => {
|
||||
const parts: string[] = [];
|
||||
if (modelFiles.length > 0) {
|
||||
parts.push(`${modelFiles.length} model file${modelFiles.length > 1 ? 's' : ''}`);
|
||||
}
|
||||
if (contextFiles.length > 0) {
|
||||
parts.push(`${contextFiles.length} context doc${contextFiles.length > 1 ? 's' : ''}`);
|
||||
}
|
||||
if (contextContent) {
|
||||
parts.push(`${contextContent.length.toLocaleString()} chars context`);
|
||||
}
|
||||
return parts.join(', ');
|
||||
}, [modelFiles, contextFiles, contextContent]);
|
||||
|
||||
const handleSend = () => {
|
||||
if (!input.trim() || isThinking) return;
|
||||
|
||||
let messageToSend = input.trim();
|
||||
|
||||
// On first message, inject full context so Claude has everything it needs
|
||||
if (!hasInjectedContext && (modelFiles.length > 0 || contextContent)) {
|
||||
const contextParts: string[] = [];
|
||||
|
||||
// Add model files info
|
||||
if (modelFiles.length > 0) {
|
||||
contextParts.push(`**Model Files Uploaded:**\n${modelFiles.map(f => `- ${f}`).join('\n')}`);
|
||||
}
|
||||
|
||||
// Add context document content (full text)
|
||||
if (contextContent) {
|
||||
contextParts.push(`**Context Documents Content:**\n\`\`\`\n${contextContent.substring(0, 30000)}\n\`\`\``);
|
||||
}
|
||||
|
||||
// Add current spec state
|
||||
if (spec) {
|
||||
const dvCount = spec.design_variables?.length || 0;
|
||||
const objCount = spec.objectives?.length || 0;
|
||||
const extCount = spec.extractors?.length || 0;
|
||||
if (dvCount > 0 || objCount > 0 || extCount > 0) {
|
||||
contextParts.push(`**Current Configuration:** ${dvCount} design variables, ${objCount} objectives, ${extCount} extractors`);
|
||||
}
|
||||
}
|
||||
|
||||
if (contextParts.length > 0) {
|
||||
messageToSend = `${contextParts.join('\n\n')}\n\n---\n\n**User Request:** ${messageToSend}`;
|
||||
}
|
||||
|
||||
setHasInjectedContext(true);
|
||||
}
|
||||
|
||||
sendMessage(messageToSend);
|
||||
setInput('');
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
};
|
||||
|
||||
// Welcome message for empty state
|
||||
const showWelcome = messages.length === 0;
|
||||
|
||||
// Check if we have any context
|
||||
const hasContext = modelFiles.length > 0 || contextContent.length > 0;
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="p-3 border-b border-dark-700 flex-shrink-0">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="w-5 h-5 text-primary-400" />
|
||||
<span className="font-medium text-white">Studio Assistant</span>
|
||||
</div>
|
||||
<span className={`flex items-center gap-1 text-xs px-2 py-0.5 rounded ${
|
||||
isConnected
|
||||
? 'text-green-400 bg-green-400/10'
|
||||
: 'text-red-400 bg-red-400/10'
|
||||
}`}>
|
||||
{isConnected ? <Wifi className="w-3 h-3" /> : <WifiOff className="w-3 h-3" />}
|
||||
{isConnected ? 'Connected' : 'Disconnected'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Context indicator */}
|
||||
{contextSummary && (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<div className="flex items-center gap-1 text-amber-400 bg-amber-400/10 px-2 py-1 rounded">
|
||||
<FileText className="w-3 h-3" />
|
||||
<span>{contextSummary}</span>
|
||||
</div>
|
||||
{hasContext && !hasInjectedContext && (
|
||||
<span className="text-dark-500">Will be sent with first message</span>
|
||||
)}
|
||||
{hasInjectedContext && (
|
||||
<span className="text-green-500">Context sent</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div className="flex-1 overflow-y-auto p-3 space-y-4">
|
||||
{/* Welcome message with context awareness */}
|
||||
{showWelcome && (
|
||||
<div className="flex gap-3">
|
||||
<div className="flex-shrink-0 w-8 h-8 rounded-lg flex items-center justify-center bg-primary-500/20 text-primary-400">
|
||||
<Bot className="w-4 h-4" />
|
||||
</div>
|
||||
<div className="flex-1 bg-dark-700 rounded-lg px-4 py-3 text-sm text-dark-100">
|
||||
<MarkdownRenderer content={hasContext
|
||||
? `I can see you've uploaded files. Here's what I have access to:
|
||||
|
||||
${modelFiles.length > 0 ? `**Model Files:** ${modelFiles.join(', ')}` : ''}
|
||||
${contextContent ? `\n**Context Document:** ${contextContent.substring(0, 200)}...` : ''}
|
||||
|
||||
Tell me what you want to optimize and I'll help you configure the study!`
|
||||
: `Welcome to Atomizer Studio! I'm here to help you configure your optimization study.
|
||||
|
||||
**What I can do:**
|
||||
- Read your uploaded context documents
|
||||
- Help set up design variables, objectives, and constraints
|
||||
- Create extractors for physics outputs
|
||||
- Suggest optimization strategies
|
||||
|
||||
Upload your model files and any requirements documents, then tell me what you want to optimize!`} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* File context display (only if we have files but no messages yet) */}
|
||||
{showWelcome && modelFiles.length > 0 && (
|
||||
<div className="bg-dark-800/50 rounded-lg p-3 border border-dark-700">
|
||||
<p className="text-xs text-dark-400 mb-2 font-medium">Loaded Files:</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{modelFiles.map((file, idx) => (
|
||||
<span key={idx} className="flex items-center gap-1 text-xs bg-blue-500/10 text-blue-400 px-2 py-1 rounded">
|
||||
<File className="w-3 h-3" />
|
||||
{file}
|
||||
</span>
|
||||
))}
|
||||
{contextFiles.map((file, idx) => (
|
||||
<span key={idx} className="flex items-center gap-1 text-xs bg-amber-500/10 text-amber-400 px-2 py-1 rounded">
|
||||
<FileText className="w-3 h-3" />
|
||||
{file}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Chat messages */}
|
||||
{messages.map((msg) => {
|
||||
const isAssistant = msg.role === 'assistant';
|
||||
const isSystem = msg.role === 'system';
|
||||
|
||||
// System messages
|
||||
if (isSystem) {
|
||||
return (
|
||||
<div key={msg.id} className="flex justify-center my-2">
|
||||
<div className="px-3 py-1 bg-dark-700/50 rounded-full text-xs text-dark-400 border border-dark-600">
|
||||
{msg.content}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={msg.id}
|
||||
className={`flex gap-3 ${isAssistant ? '' : 'flex-row-reverse'}`}
|
||||
>
|
||||
{/* Avatar */}
|
||||
<div
|
||||
className={`flex-shrink-0 w-8 h-8 rounded-lg flex items-center justify-center ${
|
||||
isAssistant
|
||||
? 'bg-primary-500/20 text-primary-400'
|
||||
: 'bg-dark-600 text-dark-300'
|
||||
}`}
|
||||
>
|
||||
{isAssistant ? <Bot className="w-4 h-4" /> : <User className="w-4 h-4" />}
|
||||
</div>
|
||||
|
||||
{/* Message content */}
|
||||
<div
|
||||
className={`flex-1 max-w-[85%] rounded-lg px-4 py-3 text-sm ${
|
||||
isAssistant
|
||||
? 'bg-dark-700 text-dark-100'
|
||||
: 'bg-primary-500 text-white ml-auto'
|
||||
}`}
|
||||
>
|
||||
{isAssistant ? (
|
||||
<>
|
||||
{msg.content && <MarkdownRenderer content={msg.content} />}
|
||||
{msg.isStreaming && !msg.content && (
|
||||
<span className="text-dark-400">Thinking...</span>
|
||||
)}
|
||||
{/* Tool calls */}
|
||||
{msg.toolCalls && msg.toolCalls.length > 0 && (
|
||||
<div className="mt-3 space-y-2">
|
||||
{msg.toolCalls.map((tool, idx) => (
|
||||
<ToolCallCard key={idx} toolCall={tool} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className="whitespace-pre-wrap">{msg.content}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Thinking indicator */}
|
||||
{isThinking && messages.length > 0 && !messages[messages.length - 1]?.isStreaming && (
|
||||
<div className="flex gap-3">
|
||||
<div className="flex-shrink-0 w-8 h-8 rounded-lg flex items-center justify-center bg-primary-500/20 text-primary-400">
|
||||
<Bot className="w-4 h-4" />
|
||||
</div>
|
||||
<div className="bg-dark-700 rounded-lg px-4 py-3 flex items-center gap-2">
|
||||
<Loader2 className="w-4 h-4 text-primary-400 animate-spin" />
|
||||
<span className="text-sm text-dark-300">Thinking...</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error display */}
|
||||
{error && (
|
||||
<div className="flex gap-3">
|
||||
<div className="flex-shrink-0 w-8 h-8 rounded-lg flex items-center justify-center bg-red-500/20 text-red-400">
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
</div>
|
||||
<div className="flex-1 px-4 py-3 bg-red-500/10 rounded-lg text-sm text-red-400 border border-red-500/30">
|
||||
{error}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<div className="p-3 border-t border-dark-700 flex-shrink-0">
|
||||
<div className="flex gap-2">
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={isConnected ? "Ask about your optimization..." : "Connecting..."}
|
||||
disabled={!isConnected}
|
||||
rows={1}
|
||||
className="flex-1 bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-sm text-white placeholder-dark-400 resize-none focus:outline-none focus:border-primary-400 disabled:opacity-50"
|
||||
/>
|
||||
<button
|
||||
onClick={handleSend}
|
||||
disabled={!input.trim() || isThinking || !isConnected}
|
||||
className="p-2 bg-primary-500 text-white rounded-lg hover:bg-primary-400 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{isThinking ? (
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
) : (
|
||||
<Send className="w-5 h-5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{!isConnected && (
|
||||
<p className="text-xs text-dark-500 mt-1">
|
||||
Waiting for connection to Claude...
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StudioChat;
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* StudioContextFiles - Context document upload and display
|
||||
*/
|
||||
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { FileText, Upload, Trash2, Loader2 } from 'lucide-react';
|
||||
import { intakeApi } from '../../api/intake';
|
||||
|
||||
interface StudioContextFilesProps {
|
||||
draftId: string;
|
||||
files: string[];
|
||||
onUploadComplete: () => void;
|
||||
}
|
||||
|
||||
export const StudioContextFiles: React.FC<StudioContextFilesProps> = ({
|
||||
draftId,
|
||||
files,
|
||||
onUploadComplete,
|
||||
}) => {
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [deleting, setDeleting] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const VALID_EXTENSIONS = ['.md', '.txt', '.pdf', '.json', '.csv', '.docx'];
|
||||
|
||||
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const selectedFiles = Array.from(e.target.files || []);
|
||||
if (selectedFiles.length === 0) return;
|
||||
|
||||
e.target.value = '';
|
||||
setIsUploading(true);
|
||||
|
||||
try {
|
||||
await intakeApi.uploadContextFiles(draftId, selectedFiles);
|
||||
onUploadComplete();
|
||||
} catch (err) {
|
||||
console.error('Failed to upload context files:', err);
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteFile = async (filename: string) => {
|
||||
setDeleting(filename);
|
||||
|
||||
try {
|
||||
await intakeApi.deleteContextFile(draftId, filename);
|
||||
onUploadComplete();
|
||||
} catch (err) {
|
||||
console.error('Failed to delete context file:', err);
|
||||
} finally {
|
||||
setDeleting(null);
|
||||
}
|
||||
};
|
||||
|
||||
const getFileIcon = (_filename: string) => {
|
||||
return <FileText className="w-3.5 h-3.5 text-amber-400" />;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{/* File List */}
|
||||
{files.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{files.map((name) => (
|
||||
<div
|
||||
key={name}
|
||||
className="flex items-center gap-2 px-2 py-1.5 rounded bg-dark-700/50 text-sm group"
|
||||
>
|
||||
{getFileIcon(name)}
|
||||
<span className="text-dark-200 truncate flex-1">{name}</span>
|
||||
<button
|
||||
onClick={() => deleteFile(name)}
|
||||
disabled={deleting === name}
|
||||
className="p-1 opacity-0 group-hover:opacity-100 hover:bg-red-500/20 rounded text-red-400 transition-all"
|
||||
>
|
||||
{deleting === name ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="w-3 h-3" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Upload Button */}
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={isUploading}
|
||||
className="w-full flex items-center justify-center gap-2 px-3 py-2 rounded-lg
|
||||
border border-dashed border-dark-600 text-dark-400 text-sm
|
||||
hover:border-primary-400/50 hover:text-primary-400 hover:bg-primary-400/5
|
||||
disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{isUploading ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Upload className="w-4 h-4" />
|
||||
)}
|
||||
{isUploading ? 'Uploading...' : 'Add context files'}
|
||||
</button>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept={VALID_EXTENSIONS.join(',')}
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StudioContextFiles;
|
||||
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* StudioDropZone - Smart file drop zone for Studio
|
||||
*
|
||||
* Handles both model files (.sim, .prt, .fem) and context files (.pdf, .md, .txt)
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useRef } from 'react';
|
||||
import { Upload, X, Loader2, AlertCircle, CheckCircle, File } from 'lucide-react';
|
||||
import { intakeApi } from '../../api/intake';
|
||||
|
||||
interface StudioDropZoneProps {
|
||||
draftId: string;
|
||||
type: 'model' | 'context';
|
||||
files: string[];
|
||||
onUploadComplete: () => void;
|
||||
}
|
||||
|
||||
interface FileStatus {
|
||||
file: File;
|
||||
status: 'pending' | 'uploading' | 'success' | 'error';
|
||||
message?: string;
|
||||
}
|
||||
|
||||
const MODEL_EXTENSIONS = ['.prt', '.sim', '.fem', '.afem'];
|
||||
const CONTEXT_EXTENSIONS = ['.md', '.txt', '.pdf', '.json', '.csv', '.docx'];
|
||||
|
||||
export const StudioDropZone: React.FC<StudioDropZoneProps> = ({
|
||||
draftId,
|
||||
type,
|
||||
files,
|
||||
onUploadComplete,
|
||||
}) => {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [pendingFiles, setPendingFiles] = useState<FileStatus[]>([]);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const validExtensions = type === 'model' ? MODEL_EXTENSIONS : CONTEXT_EXTENSIONS;
|
||||
|
||||
const validateFile = (file: File): { valid: boolean; reason?: string } => {
|
||||
const ext = '.' + file.name.split('.').pop()?.toLowerCase();
|
||||
if (!validExtensions.includes(ext)) {
|
||||
return { valid: false, reason: `Invalid type: ${ext}` };
|
||||
}
|
||||
if (file.size > 500 * 1024 * 1024) {
|
||||
return { valid: false, reason: 'File too large (max 500MB)' };
|
||||
}
|
||||
return { valid: true };
|
||||
};
|
||||
|
||||
const handleDragEnter = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(true);
|
||||
}, []);
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
}, []);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}, []);
|
||||
|
||||
const addFiles = useCallback((newFiles: File[]) => {
|
||||
const validFiles: FileStatus[] = [];
|
||||
|
||||
for (const file of newFiles) {
|
||||
if (pendingFiles.some(f => f.file.name === file.name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const validation = validateFile(file);
|
||||
validFiles.push({
|
||||
file,
|
||||
status: validation.valid ? 'pending' : 'error',
|
||||
message: validation.reason,
|
||||
});
|
||||
}
|
||||
|
||||
setPendingFiles(prev => [...prev, ...validFiles]);
|
||||
}, [pendingFiles, validExtensions]);
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
addFiles(Array.from(e.dataTransfer.files));
|
||||
}, [addFiles]);
|
||||
|
||||
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
addFiles(Array.from(e.target.files || []));
|
||||
e.target.value = '';
|
||||
}, [addFiles]);
|
||||
|
||||
const removeFile = (index: number) => {
|
||||
setPendingFiles(prev => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const uploadFiles = async () => {
|
||||
const toUpload = pendingFiles.filter(f => f.status === 'pending');
|
||||
if (toUpload.length === 0) return;
|
||||
|
||||
setIsUploading(true);
|
||||
|
||||
try {
|
||||
const uploadFn = type === 'model'
|
||||
? intakeApi.uploadFiles
|
||||
: intakeApi.uploadContextFiles;
|
||||
|
||||
const response = await uploadFn(draftId, toUpload.map(f => f.file));
|
||||
|
||||
const results = new Map(
|
||||
response.uploaded_files.map(f => [f.name, f.status === 'uploaded'])
|
||||
);
|
||||
|
||||
setPendingFiles(prev => prev.map(f => {
|
||||
if (f.status !== 'pending') return f;
|
||||
const success = results.get(f.file.name);
|
||||
return {
|
||||
...f,
|
||||
status: success ? 'success' : 'error',
|
||||
message: success ? undefined : 'Upload failed',
|
||||
};
|
||||
}));
|
||||
|
||||
setTimeout(() => {
|
||||
setPendingFiles(prev => prev.filter(f => f.status !== 'success'));
|
||||
onUploadComplete();
|
||||
}, 1000);
|
||||
|
||||
} catch (err) {
|
||||
setPendingFiles(prev => prev.map(f =>
|
||||
f.status === 'pending'
|
||||
? { ...f, status: 'error', message: 'Upload failed' }
|
||||
: f
|
||||
));
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Auto-upload when files are added
|
||||
React.useEffect(() => {
|
||||
const pending = pendingFiles.filter(f => f.status === 'pending');
|
||||
if (pending.length > 0 && !isUploading) {
|
||||
uploadFiles();
|
||||
}
|
||||
}, [pendingFiles, isUploading]);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{/* Drop Zone */}
|
||||
<div
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className={`
|
||||
relative border-2 border-dashed rounded-lg p-4 cursor-pointer
|
||||
transition-all duration-200 text-center
|
||||
${isDragging
|
||||
? 'border-primary-400 bg-primary-400/5'
|
||||
: 'border-dark-600 hover:border-primary-400/50 hover:bg-white/5'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center mx-auto mb-2
|
||||
${isDragging ? 'bg-primary-400/20 text-primary-400' : 'bg-dark-700 text-dark-400'}`}>
|
||||
<Upload className="w-4 h-4" />
|
||||
</div>
|
||||
<p className="text-sm text-dark-300">
|
||||
{isDragging ? 'Drop files here' : 'Drop or click to add'}
|
||||
</p>
|
||||
<p className="text-xs text-dark-500 mt-1">
|
||||
{validExtensions.join(', ')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Existing Files */}
|
||||
{files.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{files.map((name, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center gap-2 px-2 py-1.5 rounded bg-dark-700/50 text-sm"
|
||||
>
|
||||
<File className="w-3.5 h-3.5 text-dark-400" />
|
||||
<span className="text-dark-200 truncate flex-1">{name}</span>
|
||||
<CheckCircle className="w-3.5 h-3.5 text-green-400" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pending Files */}
|
||||
{pendingFiles.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{pendingFiles.map((f, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`flex items-center gap-2 px-2 py-1.5 rounded text-sm
|
||||
${f.status === 'error' ? 'bg-red-500/10' :
|
||||
f.status === 'success' ? 'bg-green-500/10' : 'bg-dark-700'}`}
|
||||
>
|
||||
{f.status === 'pending' && <Loader2 className="w-3.5 h-3.5 text-primary-400 animate-spin" />}
|
||||
{f.status === 'uploading' && <Loader2 className="w-3.5 h-3.5 text-primary-400 animate-spin" />}
|
||||
{f.status === 'success' && <CheckCircle className="w-3.5 h-3.5 text-green-400" />}
|
||||
{f.status === 'error' && <AlertCircle className="w-3.5 h-3.5 text-red-400" />}
|
||||
<span className={`truncate flex-1 ${f.status === 'error' ? 'text-red-400' : 'text-dark-200'}`}>
|
||||
{f.file.name}
|
||||
</span>
|
||||
{f.message && (
|
||||
<span className="text-xs text-red-400">({f.message})</span>
|
||||
)}
|
||||
{f.status === 'pending' && (
|
||||
<button onClick={(e) => { e.stopPropagation(); removeFile(i); }} className="p-0.5 hover:bg-white/10 rounded">
|
||||
<X className="w-3 h-3 text-dark-400" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept={validExtensions.join(',')}
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StudioDropZone;
|
||||
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* StudioParameterList - Display and add discovered parameters as design variables
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Plus, Check, SlidersHorizontal, Loader2 } from 'lucide-react';
|
||||
import { intakeApi } from '../../api/intake';
|
||||
|
||||
interface Expression {
|
||||
name: string;
|
||||
value: number | null;
|
||||
units: string | null;
|
||||
is_candidate: boolean;
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
interface StudioParameterListProps {
|
||||
draftId: string;
|
||||
onParameterAdded: () => void;
|
||||
}
|
||||
|
||||
export const StudioParameterList: React.FC<StudioParameterListProps> = ({
|
||||
draftId,
|
||||
onParameterAdded,
|
||||
}) => {
|
||||
const [expressions, setExpressions] = useState<Expression[]>([]);
|
||||
const [addedParams, setAddedParams] = useState<Set<string>>(new Set());
|
||||
const [adding, setAdding] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Load expressions from spec introspection
|
||||
useEffect(() => {
|
||||
loadExpressions();
|
||||
}, [draftId]);
|
||||
|
||||
const loadExpressions = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await intakeApi.getStudioDraft(draftId);
|
||||
const introspection = (data.spec as any)?.model?.introspection;
|
||||
|
||||
if (introspection?.expressions) {
|
||||
setExpressions(introspection.expressions);
|
||||
|
||||
// Check which are already added as DVs
|
||||
const existingDVs = new Set<string>(
|
||||
((data.spec as any)?.design_variables || []).map((dv: any) => dv.expression_name as string)
|
||||
);
|
||||
setAddedParams(existingDVs);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load expressions:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const addAsDesignVariable = async (expressionName: string) => {
|
||||
setAdding(expressionName);
|
||||
|
||||
try {
|
||||
await intakeApi.createDesignVariables(draftId, [expressionName]);
|
||||
setAddedParams(prev => new Set([...prev, expressionName]));
|
||||
onParameterAdded();
|
||||
} catch (err) {
|
||||
console.error('Failed to add design variable:', err);
|
||||
} finally {
|
||||
setAdding(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Sort: candidates first, then by confidence
|
||||
const sortedExpressions = [...expressions].sort((a, b) => {
|
||||
if (a.is_candidate !== b.is_candidate) {
|
||||
return b.is_candidate ? 1 : -1;
|
||||
}
|
||||
return (b.confidence || 0) - (a.confidence || 0);
|
||||
});
|
||||
|
||||
// Show only candidates by default, with option to show all
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
const displayExpressions = showAll
|
||||
? sortedExpressions
|
||||
: sortedExpressions.filter(e => e.is_candidate);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<Loader2 className="w-5 h-5 text-primary-400 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (expressions.length === 0) {
|
||||
return (
|
||||
<p className="text-xs text-dark-500 italic py-2">
|
||||
No expressions found. Try running introspection.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const candidateCount = expressions.filter(e => e.is_candidate).length;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{/* Header with toggle */}
|
||||
<div className="flex items-center justify-between text-xs text-dark-400">
|
||||
<span>{candidateCount} candidates</span>
|
||||
<button
|
||||
onClick={() => setShowAll(!showAll)}
|
||||
className="hover:text-primary-400 transition-colors"
|
||||
>
|
||||
{showAll ? 'Show candidates only' : `Show all (${expressions.length})`}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Parameter List */}
|
||||
<div className="space-y-1 max-h-48 overflow-y-auto">
|
||||
{displayExpressions.map((expr) => {
|
||||
const isAdded = addedParams.has(expr.name);
|
||||
const isAdding = adding === expr.name;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={expr.name}
|
||||
className={`flex items-center gap-2 px-2 py-1.5 rounded text-sm
|
||||
${isAdded ? 'bg-green-500/10' : 'bg-dark-700/50 hover:bg-dark-700'}
|
||||
transition-colors`}
|
||||
>
|
||||
<SlidersHorizontal className="w-3.5 h-3.5 text-dark-400 flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className={`block truncate ${isAdded ? 'text-green-400' : 'text-dark-200'}`}>
|
||||
{expr.name}
|
||||
</span>
|
||||
{expr.value !== null && (
|
||||
<span className="text-xs text-dark-500">
|
||||
= {expr.value}{expr.units ? ` ${expr.units}` : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isAdded ? (
|
||||
<Check className="w-4 h-4 text-green-400 flex-shrink-0" />
|
||||
) : (
|
||||
<button
|
||||
onClick={() => addAsDesignVariable(expr.name)}
|
||||
disabled={isAdding}
|
||||
className="p-1 hover:bg-primary-400/20 rounded text-primary-400 transition-colors disabled:opacity-50"
|
||||
title="Add as design variable"
|
||||
>
|
||||
{isAdding ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{displayExpressions.length === 0 && (
|
||||
<p className="text-xs text-dark-500 italic py-2">
|
||||
No candidate parameters found. Click "Show all" to see all expressions.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StudioParameterList;
|
||||
11
atomizer-dashboard/frontend/src/components/studio/index.ts
Normal file
11
atomizer-dashboard/frontend/src/components/studio/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Studio Components Index
|
||||
*
|
||||
* Export all Studio-related components.
|
||||
*/
|
||||
|
||||
export { StudioDropZone } from './StudioDropZone';
|
||||
export { StudioParameterList } from './StudioParameterList';
|
||||
export { StudioContextFiles } from './StudioContextFiles';
|
||||
export { StudioChat } from './StudioChat';
|
||||
export { StudioBuildDialog } from './StudioBuildDialog';
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user