feat: Add Claude Code terminal integration to dashboard
- Add embedded Claude Code terminal with xterm.js for full CLI experience - Create WebSocket PTY backend for real-time terminal communication - Add terminal status endpoint to check CLI availability - Update dashboard to use Claude Code terminal instead of API chat - Add optimization control panel with start/stop/validate actions - Add study context provider for global state management - Update frontend with new dependencies (xterm.js addons) - Comprehensive README documentation for all new features 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -4,20 +4,29 @@ Real-time optimization monitoring and control dashboard for the Atomizer optimiz
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
### ✅ Live Dashboard (Current)
|
### Core Dashboard
|
||||||
- **Real-time WebSocket streaming** - Instant updates on new trials
|
- **Real-time WebSocket streaming** - Instant updates on new trials
|
||||||
- **Interactive charts** - Convergence plots and parameter space visualization
|
- **Interactive charts** - Convergence, Pareto front, parallel coordinates, parameter importance
|
||||||
|
- **Chart library toggle** - Switch between Plotly (interactive) and Recharts (fast)
|
||||||
- **Pruning alerts** - Toast notifications for failed trials
|
- **Pruning alerts** - Toast notifications for failed trials
|
||||||
- **Data export** - Download trial history as JSON or CSV
|
- **Data export** - Download trial history as JSON or CSV
|
||||||
- **Study discovery** - Automatically detects all active studies
|
- **Study discovery** - Automatically detects all active studies
|
||||||
- **Connection monitoring** - WebSocket status indicator
|
- **Connection monitoring** - WebSocket status indicator
|
||||||
|
|
||||||
### 🔮 Future Features
|
### Optimization Control
|
||||||
- React + TypeScript frontend
|
- **Start/Kill optimization** - Launch or force-kill optimization processes
|
||||||
- Study Configurator page
|
- **Validate top N** - Run FEA validation on best neural network predictions
|
||||||
- Results Report Viewer
|
- **Process monitoring** - Real-time status of running optimizations
|
||||||
- LLM chat interface for configuration
|
- **Console output** - Live logs from optimization process
|
||||||
- Study control (start/stop/pause)
|
|
||||||
|
### Claude Code Integration
|
||||||
|
- **Embedded terminal** - Full Claude Code CLI in the dashboard
|
||||||
|
- **Context-aware** - Automatically loads CLAUDE.md and .claude/ skills
|
||||||
|
- **WebSocket PTY** - Real terminal emulation with xterm.js
|
||||||
|
|
||||||
|
### Study Reports
|
||||||
|
- **Markdown viewer** - View study README and reports
|
||||||
|
- **Auto-generated reports** - Generate OPTIMIZATION_REPORT.md
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -29,23 +38,27 @@ cd atomizer-dashboard/backend
|
|||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Start the Backend
|
### 2. Install Frontend Dependencies
|
||||||
|
```bash
|
||||||
|
cd atomizer-dashboard/frontend
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Start the Backend
|
||||||
```bash
|
```bash
|
||||||
# From backend directory
|
# From backend directory
|
||||||
python -m uvicorn api.main:app --reload --host 0.0.0.0 --port 8000
|
python -m uvicorn api.main:app --reload --host 0.0.0.0 --port 8000
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. Access the Dashboard
|
### 4. Start the Frontend
|
||||||
Open your browser: **http://localhost:8000**
|
|
||||||
|
|
||||||
### 4. Monitor an Optimization
|
|
||||||
```bash
|
```bash
|
||||||
# In a separate terminal
|
# From frontend directory
|
||||||
cd ../..
|
npm run dev
|
||||||
python studies/circular_plate_frequency_tuning/run_optimization.py
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The dashboard will automatically detect the running study and stream updates in real-time!
|
### 5. Access the Dashboard
|
||||||
|
- **Frontend**: http://localhost:5173
|
||||||
|
- **API docs**: http://localhost:8000/docs
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -54,18 +67,14 @@ The dashboard will automatically detect the running study and stream updates in
|
|||||||
### Backend Stack
|
### Backend Stack
|
||||||
- **FastAPI** - Modern async Python web framework
|
- **FastAPI** - Modern async Python web framework
|
||||||
- **Uvicorn** - ASGI server
|
- **Uvicorn** - ASGI server
|
||||||
- **Watchdog** - File system event monitoring
|
- **WebSockets** - Real-time communication
|
||||||
- **WebSockets** - Bidirectional real-time communication
|
- **psutil** - Process management
|
||||||
|
|
||||||
### Current Frontend
|
### Frontend Stack
|
||||||
- **HTML/CSS/JavaScript** - Single-page application
|
|
||||||
- **Chart.js** - Interactive charts
|
|
||||||
- **WebSocket API** - Real-time data streaming
|
|
||||||
|
|
||||||
### Planned Frontend
|
|
||||||
- **React 18** + **Vite** + **TypeScript**
|
- **React 18** + **Vite** + **TypeScript**
|
||||||
- **TailwindCSS** - Utility-first CSS
|
- **TailwindCSS** - Utility-first CSS
|
||||||
- **Recharts** - React charting library
|
- **Recharts** / **Plotly** - Charting libraries
|
||||||
|
- **xterm.js** - Terminal emulator
|
||||||
- **React Query** - Server state management
|
- **React Query** - Server state management
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -74,18 +83,36 @@ The dashboard will automatically detect the running study and stream updates in
|
|||||||
|
|
||||||
```
|
```
|
||||||
atomizer-dashboard/
|
atomizer-dashboard/
|
||||||
├── backend/ ✅ COMPLETE
|
├── backend/
|
||||||
│ ├── api/
|
│ ├── api/
|
||||||
│ │ ├── main.py # FastAPI app entry
|
│ │ ├── main.py # FastAPI app entry
|
||||||
│ │ ├── routes/
|
│ │ ├── routes/
|
||||||
│ │ │ └── optimization.py # REST endpoints
|
│ │ │ ├── optimization.py # Study REST endpoints
|
||||||
|
│ │ │ ├── claude.py # Claude chat API (legacy)
|
||||||
|
│ │ │ └── terminal.py # Claude Code terminal WebSocket
|
||||||
|
│ │ ├── services/
|
||||||
|
│ │ │ └── claude_agent.py # Anthropic API agent (legacy)
|
||||||
│ │ └── websocket/
|
│ │ └── websocket/
|
||||||
│ │ └── optimization_stream.py # WebSocket + file watching
|
│ │ └── optimization_stream.py # Real-time trial streaming
|
||||||
│ ├── requirements.txt
|
│ └── requirements.txt
|
||||||
│ └── README.md # Backend API docs
|
│
|
||||||
|
├── frontend/
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── components/
|
||||||
|
│ │ │ ├── ClaudeTerminal.tsx # Claude Code terminal
|
||||||
|
│ │ │ ├── ConsoleOutput.tsx # Optimization logs
|
||||||
|
│ │ │ ├── StudyReportViewer.tsx # Markdown report viewer
|
||||||
|
│ │ │ ├── dashboard/
|
||||||
|
│ │ │ │ ├── ControlPanel.tsx # Start/Stop/Validate
|
||||||
|
│ │ │ │ └── MetricCard.tsx
|
||||||
|
│ │ │ └── plotly/ # Plotly chart components
|
||||||
|
│ │ ├── pages/
|
||||||
|
│ │ │ ├── Home.tsx # Study selection
|
||||||
|
│ │ │ └── Dashboard.tsx # Main monitoring view
|
||||||
|
│ │ └── context/
|
||||||
|
│ │ └── StudyContext.tsx # Global study state
|
||||||
|
│ └── package.json
|
||||||
│
|
│
|
||||||
├── dashboard-test.html ✅ Basic live dashboard
|
|
||||||
├── dashboard-enhanced.html ✅ Enhanced with charts & export
|
|
||||||
└── README.md (this file)
|
└── README.md (this file)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -94,118 +121,77 @@ atomizer-dashboard/
|
|||||||
## API Documentation
|
## API Documentation
|
||||||
|
|
||||||
### REST Endpoints
|
### REST Endpoints
|
||||||
|
|
||||||
|
#### Studies
|
||||||
- `GET /api/optimization/studies` - List all studies
|
- `GET /api/optimization/studies` - List all studies
|
||||||
- `GET /api/optimization/studies/{id}/status` - Get study status
|
- `GET /api/optimization/studies/{id}/status` - Get study status
|
||||||
- `GET /api/optimization/studies/{id}/history` - Get trial history
|
- `GET /api/optimization/studies/{id}/history` - Get trial history
|
||||||
- `GET /api/optimization/studies/{id}/pruning` - Get pruning diagnostics
|
- `GET /api/optimization/studies/{id}/config` - Get optimization config
|
||||||
|
- `GET /api/optimization/studies/{id}/readme` - Get study README
|
||||||
|
- `GET /api/optimization/studies/{id}/report` - Get generated report
|
||||||
|
- `GET /api/optimization/studies/{id}/console` - Get console output
|
||||||
|
- `GET /api/optimization/studies/{id}/process` - Get process status
|
||||||
|
- `GET /api/optimization/studies/{id}/metadata` - Get study metadata
|
||||||
|
- `GET /api/optimization/studies/{id}/pareto-front` - Get Pareto front
|
||||||
|
|
||||||
### WebSocket Endpoint
|
#### Control
|
||||||
- `ws://localhost:8000/api/ws/optimization/{study_id}` - Real-time trial stream
|
- `POST /api/optimization/studies/{id}/start` - Start optimization
|
||||||
|
- `POST /api/optimization/studies/{id}/stop` - Kill optimization process
|
||||||
|
- `POST /api/optimization/studies/{id}/validate` - Validate top N predictions
|
||||||
|
- `POST /api/optimization/studies/{id}/report/generate` - Generate report
|
||||||
|
- `POST /api/optimization/studies/{id}/optuna-dashboard` - Launch Optuna dashboard
|
||||||
|
|
||||||
**Message Types**:
|
#### Claude Code
|
||||||
- `connected` - Initial connection confirmation
|
- `GET /api/terminal/status` - Check Claude CLI availability
|
||||||
- `trial_completed` - New trial finished
|
- `WebSocket /api/terminal/claude` - Terminal session
|
||||||
- `new_best` - New best trial found
|
|
||||||
- `progress` - Progress update (X/Y trials)
|
### WebSocket Endpoints
|
||||||
- `trial_pruned` - Trial pruned with diagnostics
|
- `ws://localhost:8000/api/ws/optimization/{study_id}` - Trial stream
|
||||||
|
- `ws://localhost:8000/api/terminal/claude` - Claude Code terminal
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Dashboard Features
|
## Claude Code Terminal
|
||||||
|
|
||||||
### Convergence Chart
|
The dashboard includes an embedded Claude Code terminal that provides the full CLI experience:
|
||||||
Line chart showing:
|
|
||||||
- **Objective value** progression over trials
|
|
||||||
- **Best so far** trajectory
|
|
||||||
- Real-time updates without animation lag
|
|
||||||
|
|
||||||
### Parameter Space Chart
|
### Features
|
||||||
Scatter plot showing:
|
- Real terminal emulation with xterm.js
|
||||||
- 2D visualization of first two design variables
|
- WebSocket-based PTY communication
|
||||||
- Points colored by objective value
|
- Automatic context loading (CLAUDE.md, .claude/skills/)
|
||||||
- Best trial highlighted in green
|
- Expand/minimize mode
|
||||||
|
|
||||||
### Pruning Alerts
|
### Requirements
|
||||||
- Toast notifications for pruned trials
|
- Claude Code CLI installed: `npm install -g @anthropic-ai/claude-code`
|
||||||
- Auto-dismiss after 5 seconds
|
- Authenticated with Claude Code
|
||||||
- Warning styling (orange) with pruning cause
|
|
||||||
|
|
||||||
### Data Export
|
### Usage
|
||||||
- **Export JSON** - Download complete trial history
|
1. Click "Claude Code" button in dashboard header
|
||||||
- **Export CSV** - Export as spreadsheet-compatible format
|
2. Click "Connect" to start a session
|
||||||
- Success alerts on export
|
3. Claude starts in Atomizer root with full context
|
||||||
|
4. Use all Claude Code features (tools, file editing, etc.)
|
||||||
### Metrics Dashboard
|
|
||||||
- **Total Trials** - Number of completed trials
|
|
||||||
- **Best Value** - Best objective value found
|
|
||||||
- **Avg Objective** - Average objective value
|
|
||||||
- **Pruned** - Number of failed trials
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Testing
|
## Control Panel
|
||||||
|
|
||||||
### Verify Backend is Running
|
### Start Optimization
|
||||||
```bash
|
Launches `run_optimization.py` with configurable options:
|
||||||
curl http://localhost:8000/health
|
- Max iterations
|
||||||
# Should return: {"status":"healthy"}
|
- FEA batch size
|
||||||
|
- Tuning trials
|
||||||
|
- Ensemble size
|
||||||
|
- Patience
|
||||||
|
- Fresh start option
|
||||||
|
|
||||||
curl http://localhost:8000/api/optimization/studies
|
### Kill Process
|
||||||
# Should return: {"studies":[...]}
|
Force-kills the optimization process and all child processes:
|
||||||
```
|
- Gets child processes before killing parent
|
||||||
|
- Kills children bottom-up
|
||||||
|
- Uses SIGKILL for immediate termination
|
||||||
|
|
||||||
### Test WebSocket Connection
|
### Validate Top N
|
||||||
```bash
|
Runs FEA validation on the best N neural network predictions to verify accuracy.
|
||||||
# Using wscat (npm install -g wscat)
|
|
||||||
wscat -c ws://localhost:8000/api/ws/optimization/circular_plate_frequency_tuning
|
|
||||||
|
|
||||||
# Or using Python
|
|
||||||
python -c "
|
|
||||||
import asyncio
|
|
||||||
import websockets
|
|
||||||
import json
|
|
||||||
|
|
||||||
async def test():
|
|
||||||
uri = 'ws://localhost:8000/api/ws/optimization/circular_plate_frequency_tuning'
|
|
||||||
async with websockets.connect(uri) as ws:
|
|
||||||
while True:
|
|
||||||
msg = await ws.recv()
|
|
||||||
print(json.loads(msg))
|
|
||||||
|
|
||||||
asyncio.run(test())
|
|
||||||
"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Documentation
|
|
||||||
|
|
||||||
- [Master Plan](../docs/DASHBOARD_MASTER_PLAN.md) - Complete architecture roadmap
|
|
||||||
- [Implementation Status](../docs/DASHBOARD_IMPLEMENTATION_STATUS.md) - Current progress
|
|
||||||
- [Session Summary](../docs/DASHBOARD_SESSION_SUMMARY.md) - Implementation notes
|
|
||||||
- [Backend API](backend/README.md) - Detailed API documentation
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Next Steps
|
|
||||||
|
|
||||||
### Short Term
|
|
||||||
1. Build full React + Vite + TypeScript frontend
|
|
||||||
2. Migrate to Recharts for React-compatible charts
|
|
||||||
3. Add parameter importance visualization
|
|
||||||
4. Polish UI/UX with TailwindCSS
|
|
||||||
|
|
||||||
### Medium Term
|
|
||||||
5. Build Study Configurator page
|
|
||||||
6. Build Results Report Viewer page
|
|
||||||
7. Add study control (start/stop/pause)
|
|
||||||
8. Implement authentication
|
|
||||||
|
|
||||||
### Long Term
|
|
||||||
9. Add LLM chat interface for configuration
|
|
||||||
10. Deploy with Docker
|
|
||||||
11. Add user management
|
|
||||||
12. Implement study templates
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -214,23 +200,49 @@ asyncio.run(test())
|
|||||||
### Dashboard shows "Failed to fetch"
|
### Dashboard shows "Failed to fetch"
|
||||||
- Ensure backend is running: `http://localhost:8000/health`
|
- Ensure backend is running: `http://localhost:8000/health`
|
||||||
- Check CORS settings in `backend/api/main.py`
|
- Check CORS settings in `backend/api/main.py`
|
||||||
- Access dashboard via `http://localhost:8000` (not `file://`)
|
|
||||||
|
|
||||||
### WebSocket not connecting
|
### Claude Code terminal not connecting
|
||||||
- Verify backend is running on port 8000
|
- Verify Claude CLI is installed: `claude --version`
|
||||||
- Check firewall settings
|
- Check that you're authenticated with Claude Code
|
||||||
- Look for errors in browser console (F12)
|
- Look for errors in browser console
|
||||||
|
|
||||||
### No studies appearing
|
|
||||||
- Ensure studies directory exists: `studies/`
|
|
||||||
- Check study has `1_setup/optimization_config.json`
|
|
||||||
- Verify `2_results/optimization_history_incremental.json` exists
|
|
||||||
|
|
||||||
### Charts not updating
|
### Charts not updating
|
||||||
- Check WebSocket connection status in dashboard
|
- Check WebSocket connection status
|
||||||
- Verify file watcher is running (check backend console)
|
- Verify optimization is running
|
||||||
- Ensure optimization is actually running and creating trials
|
- Check backend console for errors
|
||||||
|
|
||||||
|
### Process won't stop
|
||||||
|
- Use "Kill Process" button (force kill)
|
||||||
|
- Check Task Manager for orphaned processes
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Status**: ✅ Live dashboard functional and ready for use!
|
## Development
|
||||||
|
|
||||||
|
### Running Tests
|
||||||
|
```bash
|
||||||
|
# Backend
|
||||||
|
cd backend
|
||||||
|
pytest
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
cd frontend
|
||||||
|
npm run test
|
||||||
|
```
|
||||||
|
|
||||||
|
### Building for Production
|
||||||
|
```bash
|
||||||
|
# Frontend
|
||||||
|
cd frontend
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
### Type Checking
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npx tsc --noEmit
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Status**: Full React dashboard with Claude Code integration
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import sys
|
|||||||
# Add parent directory to path to import optimization_engine
|
# Add parent directory to path to import optimization_engine
|
||||||
sys.path.append(str(Path(__file__).parent.parent.parent.parent))
|
sys.path.append(str(Path(__file__).parent.parent.parent.parent))
|
||||||
|
|
||||||
from api.routes import optimization
|
from api.routes import optimization, claude, terminal
|
||||||
from api.websocket import optimization_stream
|
from api.websocket import optimization_stream
|
||||||
|
|
||||||
# Create FastAPI app
|
# Create FastAPI app
|
||||||
@@ -34,6 +34,8 @@ app.add_middleware(
|
|||||||
# Include routers
|
# Include routers
|
||||||
app.include_router(optimization.router, prefix="/api/optimization", tags=["optimization"])
|
app.include_router(optimization.router, prefix="/api/optimization", tags=["optimization"])
|
||||||
app.include_router(optimization_stream.router, prefix="/api/ws", tags=["websocket"])
|
app.include_router(optimization_stream.router, prefix="/api/ws", tags=["websocket"])
|
||||||
|
app.include_router(claude.router, prefix="/api/claude", tags=["claude"])
|
||||||
|
app.include_router(terminal.router, prefix="/api/terminal", tags=["terminal"])
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
async def root():
|
async def root():
|
||||||
|
|||||||
276
atomizer-dashboard/backend/api/routes/claude.py
Normal file
276
atomizer-dashboard/backend/api/routes/claude.py
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
"""
|
||||||
|
Claude Chat API Routes
|
||||||
|
|
||||||
|
Provides endpoints for AI-powered chat within the Atomizer dashboard.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, WebSocket, WebSocketDisconnect
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional, List, Dict, Any
|
||||||
|
import json
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
# Check for API key
|
||||||
|
ANTHROPIC_API_KEY = os.environ.get("ANTHROPIC_API_KEY")
|
||||||
|
|
||||||
|
|
||||||
|
class ChatMessage(BaseModel):
|
||||||
|
role: str # "user" or "assistant"
|
||||||
|
content: str
|
||||||
|
|
||||||
|
|
||||||
|
class ChatRequest(BaseModel):
|
||||||
|
message: str
|
||||||
|
study_id: Optional[str] = None
|
||||||
|
conversation_history: Optional[List[Dict[str, Any]]] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ChatResponse(BaseModel):
|
||||||
|
response: str
|
||||||
|
tool_calls: Optional[List[Dict[str, Any]]] = None
|
||||||
|
study_id: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
# Store active conversations (in production, use Redis or database)
|
||||||
|
_conversations: Dict[str, List[Dict[str, Any]]] = {}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/status")
|
||||||
|
async def get_claude_status():
|
||||||
|
"""
|
||||||
|
Check if Claude API is configured and available
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON with API status
|
||||||
|
"""
|
||||||
|
has_key = bool(ANTHROPIC_API_KEY)
|
||||||
|
return {
|
||||||
|
"available": has_key,
|
||||||
|
"message": "Claude API is configured" if has_key else "ANTHROPIC_API_KEY not set"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/chat", response_model=ChatResponse)
|
||||||
|
async def chat_with_claude(request: ChatRequest):
|
||||||
|
"""
|
||||||
|
Send a message to Claude with Atomizer context
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: ChatRequest with message, optional study_id, and conversation history
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ChatResponse with Claude's response and any tool calls made
|
||||||
|
"""
|
||||||
|
if not ANTHROPIC_API_KEY:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail="Claude API not configured. Set ANTHROPIC_API_KEY environment variable."
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Import here to avoid issues if anthropic not installed
|
||||||
|
from api.services.claude_agent import AtomizerClaudeAgent
|
||||||
|
|
||||||
|
# Create agent with study context
|
||||||
|
agent = AtomizerClaudeAgent(study_id=request.study_id)
|
||||||
|
|
||||||
|
# Convert conversation history format if needed
|
||||||
|
history = []
|
||||||
|
if request.conversation_history:
|
||||||
|
for msg in request.conversation_history:
|
||||||
|
if isinstance(msg.get('content'), str):
|
||||||
|
history.append(msg)
|
||||||
|
# Skip complex message formats for simplicity
|
||||||
|
|
||||||
|
# Get response
|
||||||
|
result = await agent.chat(request.message, history)
|
||||||
|
|
||||||
|
return ChatResponse(
|
||||||
|
response=result["response"],
|
||||||
|
tool_calls=result.get("tool_calls"),
|
||||||
|
study_id=request.study_id
|
||||||
|
)
|
||||||
|
|
||||||
|
except ImportError as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail=f"Anthropic SDK not installed: {str(e)}"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail=f"Chat error: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/chat/stream")
|
||||||
|
async def chat_stream(request: ChatRequest):
|
||||||
|
"""
|
||||||
|
Stream a response from Claude token by token
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: ChatRequest with message and optional context
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
StreamingResponse with text/event-stream
|
||||||
|
"""
|
||||||
|
if not ANTHROPIC_API_KEY:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail="Claude API not configured. Set ANTHROPIC_API_KEY environment variable."
|
||||||
|
)
|
||||||
|
|
||||||
|
async def generate():
|
||||||
|
try:
|
||||||
|
from api.services.claude_agent import AtomizerClaudeAgent
|
||||||
|
|
||||||
|
agent = AtomizerClaudeAgent(study_id=request.study_id)
|
||||||
|
|
||||||
|
# Convert history
|
||||||
|
history = []
|
||||||
|
if request.conversation_history:
|
||||||
|
for msg in request.conversation_history:
|
||||||
|
if isinstance(msg.get('content'), str):
|
||||||
|
history.append(msg)
|
||||||
|
|
||||||
|
# Stream response
|
||||||
|
async for token in agent.chat_stream(request.message, history):
|
||||||
|
yield f"data: {json.dumps({'token': token})}\n\n"
|
||||||
|
|
||||||
|
yield f"data: {json.dumps({'done': True})}\n\n"
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
yield f"data: {json.dumps({'error': str(e)})}\n\n"
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
generate(),
|
||||||
|
media_type="text/event-stream",
|
||||||
|
headers={
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
"Connection": "keep-alive",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.websocket("/chat/ws")
|
||||||
|
async def websocket_chat(websocket: WebSocket):
|
||||||
|
"""
|
||||||
|
WebSocket endpoint for real-time chat
|
||||||
|
|
||||||
|
Message format (client -> server):
|
||||||
|
{"type": "message", "content": "user message", "study_id": "optional"}
|
||||||
|
|
||||||
|
Message format (server -> client):
|
||||||
|
{"type": "token", "content": "..."}
|
||||||
|
{"type": "done", "tool_calls": [...]}
|
||||||
|
{"type": "error", "message": "..."}
|
||||||
|
"""
|
||||||
|
await websocket.accept()
|
||||||
|
|
||||||
|
if not ANTHROPIC_API_KEY:
|
||||||
|
await websocket.send_json({
|
||||||
|
"type": "error",
|
||||||
|
"message": "Claude API not configured. Set ANTHROPIC_API_KEY environment variable."
|
||||||
|
})
|
||||||
|
await websocket.close()
|
||||||
|
return
|
||||||
|
|
||||||
|
conversation_history = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
from api.services.claude_agent import AtomizerClaudeAgent
|
||||||
|
|
||||||
|
while True:
|
||||||
|
# Receive message from client
|
||||||
|
data = await websocket.receive_json()
|
||||||
|
|
||||||
|
if data.get("type") == "message":
|
||||||
|
content = data.get("content", "")
|
||||||
|
study_id = data.get("study_id")
|
||||||
|
|
||||||
|
if not content:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Create agent
|
||||||
|
agent = AtomizerClaudeAgent(study_id=study_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Use non-streaming chat for tool support
|
||||||
|
result = await agent.chat(content, conversation_history)
|
||||||
|
|
||||||
|
# Send response
|
||||||
|
await websocket.send_json({
|
||||||
|
"type": "response",
|
||||||
|
"content": result["response"],
|
||||||
|
"tool_calls": result.get("tool_calls", [])
|
||||||
|
})
|
||||||
|
|
||||||
|
# Update history (simplified - just user/assistant text)
|
||||||
|
conversation_history.append({"role": "user", "content": content})
|
||||||
|
conversation_history.append({"role": "assistant", "content": result["response"]})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
await websocket.send_json({
|
||||||
|
"type": "error",
|
||||||
|
"message": str(e)
|
||||||
|
})
|
||||||
|
|
||||||
|
elif data.get("type") == "clear":
|
||||||
|
# Clear conversation history
|
||||||
|
conversation_history = []
|
||||||
|
await websocket.send_json({"type": "cleared"})
|
||||||
|
|
||||||
|
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):
|
||||||
|
"""
|
||||||
|
Get contextual chat suggestions based on current study
|
||||||
|
|
||||||
|
Args:
|
||||||
|
study_id: Optional study to get suggestions for
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of suggested prompts
|
||||||
|
"""
|
||||||
|
base_suggestions = [
|
||||||
|
"What's the status of my optimization?",
|
||||||
|
"Show me the best designs found",
|
||||||
|
"Compare the top 3 trials",
|
||||||
|
"What parameters have the most impact?",
|
||||||
|
"Explain the convergence behavior"
|
||||||
|
]
|
||||||
|
|
||||||
|
if study_id:
|
||||||
|
# Add study-specific suggestions
|
||||||
|
return {
|
||||||
|
"suggestions": [
|
||||||
|
f"Summarize the {study_id} study",
|
||||||
|
"What's the current best objective value?",
|
||||||
|
"Are there any failed trials? Why?",
|
||||||
|
"Show parameter sensitivity analysis",
|
||||||
|
"What should I try next to improve results?"
|
||||||
|
] + base_suggestions[:3]
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"suggestions": [
|
||||||
|
"List all available studies",
|
||||||
|
"Help me create a new study",
|
||||||
|
"What can you help me with?"
|
||||||
|
] + base_suggestions[:3]
|
||||||
|
}
|
||||||
@@ -5,12 +5,16 @@ Handles study status, history retrieval, and control operations
|
|||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, UploadFile, File, Form
|
from fastapi import APIRouter, HTTPException, UploadFile, File, Form
|
||||||
from fastapi.responses import JSONResponse, FileResponse
|
from fastapi.responses import JSONResponse, FileResponse
|
||||||
|
from pydantic import BaseModel
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import List, Dict, Optional
|
from typing import List, Dict, Optional
|
||||||
import json
|
import json
|
||||||
import sys
|
import sys
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import shutil
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import psutil
|
||||||
|
import signal
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
# Add project root to path
|
# Add project root to path
|
||||||
@@ -1024,3 +1028,620 @@ async def get_study_report(study_id: str):
|
|||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=f"Failed to read study report: {str(e)}")
|
raise HTTPException(status_code=500, detail=f"Failed to read study report: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Study README and Config Endpoints
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
@router.get("/studies/{study_id}/readme")
|
||||||
|
async def get_study_readme(study_id: str):
|
||||||
|
"""
|
||||||
|
Get the README.md file content for a study (from 1_setup folder)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
study_id: Study identifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON with the markdown content
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
study_dir = STUDIES_DIR / study_id
|
||||||
|
|
||||||
|
if not study_dir.exists():
|
||||||
|
raise HTTPException(status_code=404, detail=f"Study {study_id} not found")
|
||||||
|
|
||||||
|
# Look for README.md in various locations
|
||||||
|
readme_paths = [
|
||||||
|
study_dir / "README.md",
|
||||||
|
study_dir / "1_setup" / "README.md",
|
||||||
|
study_dir / "readme.md",
|
||||||
|
]
|
||||||
|
|
||||||
|
readme_content = None
|
||||||
|
readme_path = None
|
||||||
|
|
||||||
|
for path in readme_paths:
|
||||||
|
if path.exists():
|
||||||
|
readme_path = path
|
||||||
|
with open(path, 'r', encoding='utf-8') as f:
|
||||||
|
readme_content = f.read()
|
||||||
|
break
|
||||||
|
|
||||||
|
if readme_content is None:
|
||||||
|
# Generate a basic README from config if none exists
|
||||||
|
config_file = study_dir / "1_setup" / "optimization_config.json"
|
||||||
|
if not config_file.exists():
|
||||||
|
config_file = study_dir / "optimization_config.json"
|
||||||
|
|
||||||
|
if config_file.exists():
|
||||||
|
with open(config_file) as f:
|
||||||
|
config = json.load(f)
|
||||||
|
|
||||||
|
readme_content = f"""# {config.get('study_name', study_id)}
|
||||||
|
|
||||||
|
{config.get('description', 'No description available.')}
|
||||||
|
|
||||||
|
## Design Variables
|
||||||
|
{chr(10).join([f"- **{dv['name']}**: {dv.get('min', '?')} - {dv.get('max', '?')} {dv.get('units', '')}" for dv in config.get('design_variables', [])])}
|
||||||
|
|
||||||
|
## Objectives
|
||||||
|
{chr(10).join([f"- **{obj['name']}**: {obj.get('description', '')} ({obj.get('direction', 'minimize')})" for obj in config.get('objectives', [])])}
|
||||||
|
"""
|
||||||
|
else:
|
||||||
|
readme_content = f"# {study_id}\n\nNo README or configuration found for this study."
|
||||||
|
|
||||||
|
return {
|
||||||
|
"content": readme_content,
|
||||||
|
"path": str(readme_path) if readme_path else None,
|
||||||
|
"study_id": study_id
|
||||||
|
}
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Failed to read README: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/studies/{study_id}/config")
|
||||||
|
async def get_study_config(study_id: str):
|
||||||
|
"""
|
||||||
|
Get the full optimization_config.json for a study
|
||||||
|
|
||||||
|
Args:
|
||||||
|
study_id: Study identifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON with the complete configuration
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
study_dir = STUDIES_DIR / study_id
|
||||||
|
|
||||||
|
if not study_dir.exists():
|
||||||
|
raise HTTPException(status_code=404, detail=f"Study {study_id} not found")
|
||||||
|
|
||||||
|
# Look for config in various locations
|
||||||
|
config_file = study_dir / "1_setup" / "optimization_config.json"
|
||||||
|
if not config_file.exists():
|
||||||
|
config_file = study_dir / "optimization_config.json"
|
||||||
|
|
||||||
|
if not config_file.exists():
|
||||||
|
raise HTTPException(status_code=404, detail=f"Config file not found for study {study_id}")
|
||||||
|
|
||||||
|
with open(config_file) as f:
|
||||||
|
config = json.load(f)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"config": config,
|
||||||
|
"path": str(config_file),
|
||||||
|
"study_id": study_id
|
||||||
|
}
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Failed to read config: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Process Control Endpoints
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# Track running processes by study_id
|
||||||
|
_running_processes: Dict[str, int] = {}
|
||||||
|
|
||||||
|
def _find_optimization_process(study_id: str) -> Optional[psutil.Process]:
|
||||||
|
"""Find a running optimization process for a given study"""
|
||||||
|
study_dir = STUDIES_DIR / study_id
|
||||||
|
|
||||||
|
for proc in psutil.process_iter(['pid', 'name', 'cmdline', 'cwd']):
|
||||||
|
try:
|
||||||
|
cmdline = proc.info.get('cmdline') or []
|
||||||
|
cmdline_str = ' '.join(cmdline) if cmdline else ''
|
||||||
|
|
||||||
|
# Check if this is a Python process running run_optimization.py for this study
|
||||||
|
if 'python' in cmdline_str.lower() and 'run_optimization' in cmdline_str:
|
||||||
|
if study_id in cmdline_str or str(study_dir) in cmdline_str:
|
||||||
|
return proc
|
||||||
|
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
||||||
|
continue
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/studies/{study_id}/process")
|
||||||
|
async def get_process_status(study_id: str):
|
||||||
|
"""
|
||||||
|
Get the process status for a study's optimization run
|
||||||
|
|
||||||
|
Args:
|
||||||
|
study_id: Study identifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON with process status (is_running, pid, iteration counts)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
study_dir = STUDIES_DIR / study_id
|
||||||
|
|
||||||
|
if not study_dir.exists():
|
||||||
|
raise HTTPException(status_code=404, detail=f"Study {study_id} not found")
|
||||||
|
|
||||||
|
# Check if process is running
|
||||||
|
proc = _find_optimization_process(study_id)
|
||||||
|
is_running = proc is not None
|
||||||
|
pid = proc.pid if proc else None
|
||||||
|
|
||||||
|
# Get iteration counts from database
|
||||||
|
results_dir = get_results_dir(study_dir)
|
||||||
|
study_db = results_dir / "study.db"
|
||||||
|
|
||||||
|
fea_count = 0
|
||||||
|
nn_count = 0
|
||||||
|
iteration = None
|
||||||
|
|
||||||
|
if study_db.exists():
|
||||||
|
try:
|
||||||
|
conn = sqlite3.connect(str(study_db))
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Count FEA trials (from main study or studies with "_fea" suffix)
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT COUNT(*) FROM trials t
|
||||||
|
JOIN studies s ON t.study_id = s.study_id
|
||||||
|
WHERE t.state = 'COMPLETE'
|
||||||
|
AND (s.study_name LIKE '%_fea' OR s.study_name NOT LIKE '%_nn%')
|
||||||
|
""")
|
||||||
|
fea_count = cursor.fetchone()[0]
|
||||||
|
|
||||||
|
# Count NN trials
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT COUNT(*) FROM trials t
|
||||||
|
JOIN studies s ON t.study_id = s.study_id
|
||||||
|
WHERE t.state = 'COMPLETE'
|
||||||
|
AND s.study_name LIKE '%_nn%'
|
||||||
|
""")
|
||||||
|
nn_count = cursor.fetchone()[0]
|
||||||
|
|
||||||
|
# Try to get current iteration from study names
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT study_name FROM studies
|
||||||
|
WHERE study_name LIKE '%_iter%'
|
||||||
|
ORDER BY study_name DESC LIMIT 1
|
||||||
|
""")
|
||||||
|
result = cursor.fetchone()
|
||||||
|
if result:
|
||||||
|
import re
|
||||||
|
match = re.search(r'iter(\d+)', result[0])
|
||||||
|
if match:
|
||||||
|
iteration = int(match.group(1))
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Warning: Failed to read database for process status: {e}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"is_running": is_running,
|
||||||
|
"pid": pid,
|
||||||
|
"iteration": iteration,
|
||||||
|
"fea_count": fea_count,
|
||||||
|
"nn_count": nn_count,
|
||||||
|
"study_id": study_id
|
||||||
|
}
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Failed to get process status: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
class StartOptimizationRequest(BaseModel):
|
||||||
|
freshStart: bool = False
|
||||||
|
maxIterations: int = 100
|
||||||
|
feaBatchSize: int = 5
|
||||||
|
tuneTrials: int = 30
|
||||||
|
ensembleSize: int = 3
|
||||||
|
patience: int = 5
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/studies/{study_id}/start")
|
||||||
|
async def start_optimization(study_id: str, request: StartOptimizationRequest = None):
|
||||||
|
"""
|
||||||
|
Start the optimization process for a study
|
||||||
|
|
||||||
|
Args:
|
||||||
|
study_id: Study identifier
|
||||||
|
request: Optional start options
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON with process info
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
study_dir = STUDIES_DIR / study_id
|
||||||
|
|
||||||
|
if not study_dir.exists():
|
||||||
|
raise HTTPException(status_code=404, detail=f"Study {study_id} not found")
|
||||||
|
|
||||||
|
# Check if already running
|
||||||
|
existing_proc = _find_optimization_process(study_id)
|
||||||
|
if existing_proc:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"message": f"Optimization already running (PID: {existing_proc.pid})",
|
||||||
|
"pid": existing_proc.pid
|
||||||
|
}
|
||||||
|
|
||||||
|
# Find run_optimization.py
|
||||||
|
run_script = study_dir / "run_optimization.py"
|
||||||
|
if not run_script.exists():
|
||||||
|
raise HTTPException(status_code=404, detail=f"run_optimization.py not found for study {study_id}")
|
||||||
|
|
||||||
|
# Build command with arguments
|
||||||
|
python_exe = sys.executable
|
||||||
|
cmd = [python_exe, str(run_script), "--start"]
|
||||||
|
|
||||||
|
if request:
|
||||||
|
if request.freshStart:
|
||||||
|
cmd.append("--fresh")
|
||||||
|
cmd.extend(["--fea-batch", str(request.feaBatchSize)])
|
||||||
|
cmd.extend(["--tune-trials", str(request.tuneTrials)])
|
||||||
|
cmd.extend(["--ensemble-size", str(request.ensembleSize)])
|
||||||
|
cmd.extend(["--patience", str(request.patience)])
|
||||||
|
|
||||||
|
# Start process in background
|
||||||
|
proc = subprocess.Popen(
|
||||||
|
cmd,
|
||||||
|
cwd=str(study_dir),
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
start_new_session=True
|
||||||
|
)
|
||||||
|
|
||||||
|
_running_processes[study_id] = proc.pid
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": f"Optimization started successfully",
|
||||||
|
"pid": proc.pid,
|
||||||
|
"command": ' '.join(cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Failed to start optimization: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
class StopRequest(BaseModel):
|
||||||
|
force: bool = True # Default to force kill
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/studies/{study_id}/stop")
|
||||||
|
async def stop_optimization(study_id: str, request: StopRequest = None):
|
||||||
|
"""
|
||||||
|
Stop the optimization process for a study (hard kill by default)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
study_id: Study identifier
|
||||||
|
request.force: If True (default), immediately kill. If False, try graceful first.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON with result
|
||||||
|
"""
|
||||||
|
if request is None:
|
||||||
|
request = StopRequest()
|
||||||
|
|
||||||
|
try:
|
||||||
|
study_dir = STUDIES_DIR / study_id
|
||||||
|
|
||||||
|
if not study_dir.exists():
|
||||||
|
raise HTTPException(status_code=404, detail=f"Study {study_id} not found")
|
||||||
|
|
||||||
|
# Find running process
|
||||||
|
proc = _find_optimization_process(study_id)
|
||||||
|
|
||||||
|
if not proc:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"message": "No running optimization process found"
|
||||||
|
}
|
||||||
|
|
||||||
|
pid = proc.pid
|
||||||
|
killed_pids = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
# FIRST: Get all children BEFORE killing parent
|
||||||
|
children = []
|
||||||
|
try:
|
||||||
|
children = proc.children(recursive=True)
|
||||||
|
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
||||||
|
pass
|
||||||
|
|
||||||
|
if request.force:
|
||||||
|
# Hard kill: immediately kill parent and all children
|
||||||
|
# Kill children first (bottom-up)
|
||||||
|
for child in reversed(children):
|
||||||
|
try:
|
||||||
|
child.kill() # SIGKILL on Unix, TerminateProcess on Windows
|
||||||
|
killed_pids.append(child.pid)
|
||||||
|
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Then kill parent
|
||||||
|
try:
|
||||||
|
proc.kill()
|
||||||
|
killed_pids.append(pid)
|
||||||
|
except psutil.NoSuchProcess:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
# Graceful: try SIGTERM first, then force
|
||||||
|
try:
|
||||||
|
proc.terminate()
|
||||||
|
proc.wait(timeout=5)
|
||||||
|
except psutil.TimeoutExpired:
|
||||||
|
# Didn't stop gracefully, force kill
|
||||||
|
for child in reversed(children):
|
||||||
|
try:
|
||||||
|
child.kill()
|
||||||
|
killed_pids.append(child.pid)
|
||||||
|
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
||||||
|
pass
|
||||||
|
proc.kill()
|
||||||
|
killed_pids.append(pid)
|
||||||
|
except psutil.NoSuchProcess:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Clean up tracking
|
||||||
|
if study_id in _running_processes:
|
||||||
|
del _running_processes[study_id]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": f"Optimization killed (PID: {pid}, +{len(children)} children)",
|
||||||
|
"pid": pid,
|
||||||
|
"killed_pids": killed_pids
|
||||||
|
}
|
||||||
|
|
||||||
|
except psutil.NoSuchProcess:
|
||||||
|
if study_id in _running_processes:
|
||||||
|
del _running_processes[study_id]
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": "Process already terminated"
|
||||||
|
}
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Failed to stop optimization: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
class ValidateRequest(BaseModel):
|
||||||
|
topN: int = 5
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/studies/{study_id}/validate")
|
||||||
|
async def validate_optimization(study_id: str, request: ValidateRequest = None):
|
||||||
|
"""
|
||||||
|
Run final FEA validation on top NN predictions
|
||||||
|
|
||||||
|
Args:
|
||||||
|
study_id: Study identifier
|
||||||
|
request: Validation options (topN)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON with process info
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
study_dir = STUDIES_DIR / study_id
|
||||||
|
|
||||||
|
if not study_dir.exists():
|
||||||
|
raise HTTPException(status_code=404, detail=f"Study {study_id} not found")
|
||||||
|
|
||||||
|
# Check if optimization is still running
|
||||||
|
existing_proc = _find_optimization_process(study_id)
|
||||||
|
if existing_proc:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"message": "Cannot validate while optimization is running. Stop optimization first."
|
||||||
|
}
|
||||||
|
|
||||||
|
# Look for final_validation.py script
|
||||||
|
validation_script = study_dir / "final_validation.py"
|
||||||
|
|
||||||
|
if not validation_script.exists():
|
||||||
|
# Fall back to run_optimization.py with --validate flag if script doesn't exist
|
||||||
|
run_script = study_dir / "run_optimization.py"
|
||||||
|
if not run_script.exists():
|
||||||
|
raise HTTPException(status_code=404, detail="No validation script found")
|
||||||
|
|
||||||
|
python_exe = sys.executable
|
||||||
|
top_n = request.topN if request else 5
|
||||||
|
cmd = [python_exe, str(run_script), "--validate", "--top", str(top_n)]
|
||||||
|
else:
|
||||||
|
python_exe = sys.executable
|
||||||
|
top_n = request.topN if request else 5
|
||||||
|
cmd = [python_exe, str(validation_script), "--top", str(top_n)]
|
||||||
|
|
||||||
|
# Start validation process
|
||||||
|
proc = subprocess.Popen(
|
||||||
|
cmd,
|
||||||
|
cwd=str(study_dir),
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
start_new_session=True
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": f"Validation started for top {top_n} NN predictions",
|
||||||
|
"pid": proc.pid,
|
||||||
|
"command": ' '.join(cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Failed to start validation: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Optuna Dashboard Launch
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
_optuna_processes: Dict[str, subprocess.Popen] = {}
|
||||||
|
|
||||||
|
@router.post("/studies/{study_id}/optuna-dashboard")
|
||||||
|
async def launch_optuna_dashboard(study_id: str):
|
||||||
|
"""
|
||||||
|
Launch Optuna dashboard for a specific study
|
||||||
|
|
||||||
|
Args:
|
||||||
|
study_id: Study identifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON with dashboard URL and process info
|
||||||
|
"""
|
||||||
|
import time
|
||||||
|
import socket
|
||||||
|
|
||||||
|
def is_port_in_use(port: int) -> bool:
|
||||||
|
"""Check if a port is already in use"""
|
||||||
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||||
|
return s.connect_ex(('localhost', port)) == 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
study_dir = STUDIES_DIR / study_id
|
||||||
|
|
||||||
|
if not study_dir.exists():
|
||||||
|
raise HTTPException(status_code=404, detail=f"Study {study_id} not found")
|
||||||
|
|
||||||
|
results_dir = get_results_dir(study_dir)
|
||||||
|
study_db = results_dir / "study.db"
|
||||||
|
|
||||||
|
if not study_db.exists():
|
||||||
|
raise HTTPException(status_code=404, detail=f"No Optuna database found for study {study_id}")
|
||||||
|
|
||||||
|
port = 8081
|
||||||
|
|
||||||
|
# Check if dashboard is already running on this port
|
||||||
|
if is_port_in_use(port):
|
||||||
|
# Check if it's our process
|
||||||
|
if study_id in _optuna_processes:
|
||||||
|
proc = _optuna_processes[study_id]
|
||||||
|
if proc.poll() is None: # Still running
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"url": f"http://localhost:{port}",
|
||||||
|
"pid": proc.pid,
|
||||||
|
"message": "Optuna dashboard already running"
|
||||||
|
}
|
||||||
|
# Port in use but not by us - still return success since dashboard is available
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"url": f"http://localhost:{port}",
|
||||||
|
"pid": None,
|
||||||
|
"message": "Optuna dashboard already running on port 8081"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Launch optuna-dashboard using Python script
|
||||||
|
python_exe = sys.executable
|
||||||
|
# Use absolute path with POSIX format for SQLite URL
|
||||||
|
abs_db_path = study_db.absolute().as_posix()
|
||||||
|
storage_url = f"sqlite:///{abs_db_path}"
|
||||||
|
|
||||||
|
# Create a small Python script to run optuna-dashboard
|
||||||
|
launch_script = f'''
|
||||||
|
from optuna_dashboard import run_server
|
||||||
|
run_server("{storage_url}", host="0.0.0.0", port={port})
|
||||||
|
'''
|
||||||
|
cmd = [python_exe, "-c", launch_script]
|
||||||
|
|
||||||
|
# On Windows, use CREATE_NEW_PROCESS_GROUP and DETACHED_PROCESS flags
|
||||||
|
import platform
|
||||||
|
if platform.system() == 'Windows':
|
||||||
|
# Windows-specific: create detached process
|
||||||
|
DETACHED_PROCESS = 0x00000008
|
||||||
|
CREATE_NEW_PROCESS_GROUP = 0x00000200
|
||||||
|
proc = subprocess.Popen(
|
||||||
|
cmd,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
creationflags=DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
proc = subprocess.Popen(
|
||||||
|
cmd,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
start_new_session=True
|
||||||
|
)
|
||||||
|
|
||||||
|
_optuna_processes[study_id] = proc
|
||||||
|
|
||||||
|
# Wait for dashboard to start (check port repeatedly)
|
||||||
|
max_wait = 5 # seconds
|
||||||
|
start_time = time.time()
|
||||||
|
while time.time() - start_time < max_wait:
|
||||||
|
if is_port_in_use(port):
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"url": f"http://localhost:{port}",
|
||||||
|
"pid": proc.pid,
|
||||||
|
"message": "Optuna dashboard launched successfully"
|
||||||
|
}
|
||||||
|
# Check if process died
|
||||||
|
if proc.poll() is not None:
|
||||||
|
stderr = ""
|
||||||
|
try:
|
||||||
|
stderr = proc.stderr.read().decode() if proc.stderr else ""
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"message": f"Failed to start Optuna dashboard: {stderr}"
|
||||||
|
}
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
# Timeout - process might still be starting
|
||||||
|
if proc.poll() is None:
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"url": f"http://localhost:{port}",
|
||||||
|
"pid": proc.pid,
|
||||||
|
"message": "Optuna dashboard starting (may take a moment)"
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
stderr = ""
|
||||||
|
try:
|
||||||
|
stderr = proc.stderr.read().decode() if proc.stderr else ""
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"message": f"Failed to start Optuna dashboard: {stderr}"
|
||||||
|
}
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Failed to launch Optuna dashboard: {str(e)}")
|
||||||
|
|||||||
289
atomizer-dashboard/backend/api/routes/terminal.py
Normal file
289
atomizer-dashboard/backend/api/routes/terminal.py
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
"""
|
||||||
|
Terminal WebSocket for Claude Code CLI
|
||||||
|
|
||||||
|
Provides a PTY-based terminal that runs Claude Code in the dashboard.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||||
|
from typing import Optional
|
||||||
|
import asyncio
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import signal
|
||||||
|
import json
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
# Store active terminal sessions
|
||||||
|
_terminal_sessions: dict = {}
|
||||||
|
|
||||||
|
|
||||||
|
class TerminalSession:
|
||||||
|
"""Manages a Claude Code terminal session."""
|
||||||
|
|
||||||
|
def __init__(self, session_id: str, working_dir: str):
|
||||||
|
self.session_id = session_id
|
||||||
|
self.working_dir = working_dir
|
||||||
|
self.process: Optional[subprocess.Popen] = None
|
||||||
|
self.websocket: Optional[WebSocket] = None
|
||||||
|
self._read_task: Optional[asyncio.Task] = None
|
||||||
|
self._running = False
|
||||||
|
|
||||||
|
async def start(self, websocket: WebSocket):
|
||||||
|
"""Start the Claude Code process."""
|
||||||
|
self.websocket = websocket
|
||||||
|
self._running = True
|
||||||
|
|
||||||
|
# Determine the claude command
|
||||||
|
# On Windows, claude is typically installed via npm and available in PATH
|
||||||
|
claude_cmd = "claude"
|
||||||
|
|
||||||
|
# Check if we're on Windows
|
||||||
|
is_windows = sys.platform == "win32"
|
||||||
|
|
||||||
|
try:
|
||||||
|
if is_windows:
|
||||||
|
# On Windows, use subprocess with pipes
|
||||||
|
# We need to use cmd.exe to get proper terminal behavior
|
||||||
|
self.process = subprocess.Popen(
|
||||||
|
["cmd.exe", "/c", claude_cmd],
|
||||||
|
stdin=subprocess.PIPE,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.STDOUT,
|
||||||
|
cwd=self.working_dir,
|
||||||
|
bufsize=0,
|
||||||
|
creationflags=subprocess.CREATE_NEW_PROCESS_GROUP,
|
||||||
|
env={**os.environ, "FORCE_COLOR": "1", "TERM": "xterm-256color"}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# On Unix, we can use pty
|
||||||
|
import pty
|
||||||
|
master_fd, slave_fd = pty.openpty()
|
||||||
|
self.process = subprocess.Popen(
|
||||||
|
[claude_cmd],
|
||||||
|
stdin=slave_fd,
|
||||||
|
stdout=slave_fd,
|
||||||
|
stderr=slave_fd,
|
||||||
|
cwd=self.working_dir,
|
||||||
|
env={**os.environ, "TERM": "xterm-256color"}
|
||||||
|
)
|
||||||
|
os.close(slave_fd)
|
||||||
|
self._master_fd = master_fd
|
||||||
|
|
||||||
|
# Start reading output
|
||||||
|
self._read_task = asyncio.create_task(self._read_output())
|
||||||
|
|
||||||
|
await self.websocket.send_json({
|
||||||
|
"type": "started",
|
||||||
|
"message": f"Claude Code started in {self.working_dir}"
|
||||||
|
})
|
||||||
|
|
||||||
|
except FileNotFoundError:
|
||||||
|
await self.websocket.send_json({
|
||||||
|
"type": "error",
|
||||||
|
"message": "Claude Code CLI not found. Please install it with: npm install -g @anthropic-ai/claude-code"
|
||||||
|
})
|
||||||
|
self._running = False
|
||||||
|
except Exception as e:
|
||||||
|
await self.websocket.send_json({
|
||||||
|
"type": "error",
|
||||||
|
"message": f"Failed to start Claude Code: {str(e)}"
|
||||||
|
})
|
||||||
|
self._running = False
|
||||||
|
|
||||||
|
async def _read_output(self):
|
||||||
|
"""Read output from the process and send to WebSocket."""
|
||||||
|
is_windows = sys.platform == "win32"
|
||||||
|
|
||||||
|
try:
|
||||||
|
while self._running and self.process and self.process.poll() is None:
|
||||||
|
if is_windows:
|
||||||
|
# Read from stdout pipe
|
||||||
|
if self.process.stdout:
|
||||||
|
# Use asyncio to read without blocking
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
try:
|
||||||
|
data = await loop.run_in_executor(
|
||||||
|
None,
|
||||||
|
lambda: self.process.stdout.read(1024)
|
||||||
|
)
|
||||||
|
if data:
|
||||||
|
await self.websocket.send_json({
|
||||||
|
"type": "output",
|
||||||
|
"data": data.decode("utf-8", errors="replace")
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
# Read from PTY master
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
try:
|
||||||
|
data = await loop.run_in_executor(
|
||||||
|
None,
|
||||||
|
lambda: os.read(self._master_fd, 1024)
|
||||||
|
)
|
||||||
|
if data:
|
||||||
|
await self.websocket.send_json({
|
||||||
|
"type": "output",
|
||||||
|
"data": data.decode("utf-8", errors="replace")
|
||||||
|
})
|
||||||
|
except OSError:
|
||||||
|
break
|
||||||
|
|
||||||
|
await asyncio.sleep(0.01)
|
||||||
|
|
||||||
|
# Process ended
|
||||||
|
if self.websocket:
|
||||||
|
exit_code = self.process.poll() if self.process else -1
|
||||||
|
await self.websocket.send_json({
|
||||||
|
"type": "exit",
|
||||||
|
"code": exit_code
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
if self.websocket:
|
||||||
|
try:
|
||||||
|
await self.websocket.send_json({
|
||||||
|
"type": "error",
|
||||||
|
"message": str(e)
|
||||||
|
})
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def write(self, data: str):
|
||||||
|
"""Write input to the process."""
|
||||||
|
if not self.process or not self._running:
|
||||||
|
return
|
||||||
|
|
||||||
|
is_windows = sys.platform == "win32"
|
||||||
|
|
||||||
|
try:
|
||||||
|
if is_windows:
|
||||||
|
if self.process.stdin:
|
||||||
|
self.process.stdin.write(data.encode())
|
||||||
|
self.process.stdin.flush()
|
||||||
|
else:
|
||||||
|
os.write(self._master_fd, data.encode())
|
||||||
|
except Exception as e:
|
||||||
|
if self.websocket:
|
||||||
|
await self.websocket.send_json({
|
||||||
|
"type": "error",
|
||||||
|
"message": f"Write error: {str(e)}"
|
||||||
|
})
|
||||||
|
|
||||||
|
async def resize(self, cols: int, rows: int):
|
||||||
|
"""Resize the terminal (Unix only)."""
|
||||||
|
if sys.platform != "win32" and hasattr(self, '_master_fd'):
|
||||||
|
import struct
|
||||||
|
import fcntl
|
||||||
|
import termios
|
||||||
|
winsize = struct.pack("HHHH", rows, cols, 0, 0)
|
||||||
|
fcntl.ioctl(self._master_fd, termios.TIOCSWINSZ, winsize)
|
||||||
|
|
||||||
|
async def stop(self):
|
||||||
|
"""Stop the terminal session."""
|
||||||
|
self._running = False
|
||||||
|
|
||||||
|
if self._read_task:
|
||||||
|
self._read_task.cancel()
|
||||||
|
try:
|
||||||
|
await self._read_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if self.process:
|
||||||
|
try:
|
||||||
|
if sys.platform == "win32":
|
||||||
|
self.process.terminate()
|
||||||
|
else:
|
||||||
|
os.kill(self.process.pid, signal.SIGTERM)
|
||||||
|
self.process.wait(timeout=2)
|
||||||
|
except:
|
||||||
|
try:
|
||||||
|
self.process.kill()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if sys.platform != "win32" and hasattr(self, '_master_fd'):
|
||||||
|
try:
|
||||||
|
os.close(self._master_fd)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@router.websocket("/claude")
|
||||||
|
async def claude_terminal(websocket: WebSocket, working_dir: str = None):
|
||||||
|
"""
|
||||||
|
WebSocket endpoint for Claude Code terminal.
|
||||||
|
|
||||||
|
Query params:
|
||||||
|
working_dir: Directory to start Claude Code in (defaults to Atomizer root)
|
||||||
|
|
||||||
|
Client -> Server messages:
|
||||||
|
{"type": "input", "data": "user input text"}
|
||||||
|
{"type": "resize", "cols": 80, "rows": 24}
|
||||||
|
|
||||||
|
Server -> Client messages:
|
||||||
|
{"type": "started", "message": "..."}
|
||||||
|
{"type": "output", "data": "terminal output"}
|
||||||
|
{"type": "exit", "code": 0}
|
||||||
|
{"type": "error", "message": "..."}
|
||||||
|
"""
|
||||||
|
await websocket.accept()
|
||||||
|
|
||||||
|
# Default to Atomizer root directory
|
||||||
|
if not working_dir:
|
||||||
|
working_dir = str(os.path.dirname(os.path.dirname(os.path.dirname(
|
||||||
|
os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
))))
|
||||||
|
|
||||||
|
# Create session
|
||||||
|
session_id = f"claude-{id(websocket)}"
|
||||||
|
session = TerminalSession(session_id, working_dir)
|
||||||
|
_terminal_sessions[session_id] = session
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Start Claude Code
|
||||||
|
await session.start(websocket)
|
||||||
|
|
||||||
|
# Handle incoming messages
|
||||||
|
while session._running:
|
||||||
|
try:
|
||||||
|
message = await websocket.receive_json()
|
||||||
|
|
||||||
|
if message.get("type") == "input":
|
||||||
|
await session.write(message.get("data", ""))
|
||||||
|
elif message.get("type") == "resize":
|
||||||
|
await session.resize(
|
||||||
|
message.get("cols", 80),
|
||||||
|
message.get("rows", 24)
|
||||||
|
)
|
||||||
|
elif message.get("type") == "stop":
|
||||||
|
break
|
||||||
|
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
await websocket.send_json({
|
||||||
|
"type": "error",
|
||||||
|
"message": str(e)
|
||||||
|
})
|
||||||
|
|
||||||
|
finally:
|
||||||
|
await session.stop()
|
||||||
|
_terminal_sessions.pop(session_id, None)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/status")
|
||||||
|
async def terminal_status():
|
||||||
|
"""Check if Claude Code CLI is available."""
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
claude_path = shutil.which("claude")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"available": claude_path is not None,
|
||||||
|
"path": claude_path,
|
||||||
|
"message": "Claude Code CLI is available" if claude_path else "Claude Code CLI not found. Install with: npm install -g @anthropic-ai/claude-code"
|
||||||
|
}
|
||||||
7
atomizer-dashboard/backend/api/services/__init__.py
Normal file
7
atomizer-dashboard/backend/api/services/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
"""
|
||||||
|
Atomizer Dashboard Services
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .claude_agent import AtomizerClaudeAgent
|
||||||
|
|
||||||
|
__all__ = ['AtomizerClaudeAgent']
|
||||||
715
atomizer-dashboard/backend/api/services/claude_agent.py
Normal file
715
atomizer-dashboard/backend/api/services/claude_agent.py
Normal file
@@ -0,0 +1,715 @@
|
|||||||
|
"""
|
||||||
|
Atomizer Claude Agent Service
|
||||||
|
|
||||||
|
Provides Claude AI integration with Atomizer-specific tools for:
|
||||||
|
- Analyzing optimization results
|
||||||
|
- Querying trial data
|
||||||
|
- Modifying configurations
|
||||||
|
- Creating new studies
|
||||||
|
- Explaining FEA/Zernike concepts
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional, List, Dict, Any, AsyncGenerator
|
||||||
|
from datetime import datetime
|
||||||
|
import anthropic
|
||||||
|
|
||||||
|
# Base studies directory
|
||||||
|
STUDIES_DIR = Path(__file__).parent.parent.parent.parent.parent / "studies"
|
||||||
|
ATOMIZER_ROOT = Path(__file__).parent.parent.parent.parent.parent
|
||||||
|
|
||||||
|
|
||||||
|
class AtomizerClaudeAgent:
|
||||||
|
"""Claude agent with Atomizer-specific tools and context"""
|
||||||
|
|
||||||
|
def __init__(self, study_id: Optional[str] = None):
|
||||||
|
self.client = anthropic.Anthropic()
|
||||||
|
self.study_id = study_id
|
||||||
|
self.study_dir = STUDIES_DIR / study_id if study_id else None
|
||||||
|
self.tools = self._define_tools()
|
||||||
|
self.system_prompt = self._build_system_prompt()
|
||||||
|
|
||||||
|
def _build_system_prompt(self) -> str:
|
||||||
|
"""Build context-aware system prompt for Atomizer"""
|
||||||
|
base_prompt = """You are Claude Code embedded in the Atomizer FEA optimization dashboard.
|
||||||
|
|
||||||
|
## Your Role
|
||||||
|
You help engineers with structural optimization using NX Nastran simulations. You can:
|
||||||
|
1. **Analyze Results** - Interpret optimization progress, identify trends, explain convergence
|
||||||
|
2. **Query Data** - Fetch trial data, compare configurations, find best designs
|
||||||
|
3. **Modify Settings** - Update design variable bounds, objectives, constraints
|
||||||
|
4. **Explain Concepts** - FEA, Zernike polynomials, wavefront error, stress analysis
|
||||||
|
5. **Troubleshoot** - Debug failed trials, identify issues, suggest fixes
|
||||||
|
|
||||||
|
## Atomizer Context
|
||||||
|
- Atomizer uses Optuna for Bayesian optimization
|
||||||
|
- Studies can use FEA-only or hybrid FEA/Neural surrogate approaches
|
||||||
|
- Results are stored in SQLite databases (study.db)
|
||||||
|
- Design variables are NX expressions in CAD models
|
||||||
|
- Objectives include stress, displacement, frequency, Zernike WFE
|
||||||
|
|
||||||
|
## Guidelines
|
||||||
|
- Be concise but thorough
|
||||||
|
- Use technical language appropriate for engineers
|
||||||
|
- When showing data, format it clearly (tables, lists)
|
||||||
|
- If uncertain, say so and suggest how to verify
|
||||||
|
- Proactively suggest next steps or insights
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Add study-specific context if available
|
||||||
|
if self.study_id and self.study_dir and self.study_dir.exists():
|
||||||
|
context = self._get_study_context()
|
||||||
|
base_prompt += f"\n## Current Study: {self.study_id}\n{context}\n"
|
||||||
|
else:
|
||||||
|
base_prompt += "\n## Current Study: None selected\nAsk the user to select a study or help them create a new one.\n"
|
||||||
|
|
||||||
|
return base_prompt
|
||||||
|
|
||||||
|
def _get_study_context(self) -> str:
|
||||||
|
"""Get context information about the current study"""
|
||||||
|
context_parts = []
|
||||||
|
|
||||||
|
# Try to load config
|
||||||
|
config_path = self.study_dir / "1_setup" / "optimization_config.json"
|
||||||
|
if not config_path.exists():
|
||||||
|
config_path = self.study_dir / "optimization_config.json"
|
||||||
|
|
||||||
|
if config_path.exists():
|
||||||
|
try:
|
||||||
|
with open(config_path) as f:
|
||||||
|
config = json.load(f)
|
||||||
|
|
||||||
|
# Design variables
|
||||||
|
dvs = config.get('design_variables', [])
|
||||||
|
if dvs:
|
||||||
|
context_parts.append(f"**Design Variables ({len(dvs)})**: " +
|
||||||
|
", ".join(dv['name'] for dv in dvs[:5]) +
|
||||||
|
("..." if len(dvs) > 5 else ""))
|
||||||
|
|
||||||
|
# Objectives
|
||||||
|
objs = config.get('objectives', [])
|
||||||
|
if objs:
|
||||||
|
context_parts.append(f"**Objectives ({len(objs)})**: " +
|
||||||
|
", ".join(f"{o['name']} ({o.get('direction', 'minimize')})"
|
||||||
|
for o in objs))
|
||||||
|
|
||||||
|
# Constraints
|
||||||
|
constraints = config.get('constraints', [])
|
||||||
|
if constraints:
|
||||||
|
context_parts.append(f"**Constraints**: " +
|
||||||
|
", ".join(c['name'] for c in constraints))
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Try to get trial count from database
|
||||||
|
results_dir = self.study_dir / "2_results"
|
||||||
|
if not results_dir.exists():
|
||||||
|
results_dir = self.study_dir / "3_results"
|
||||||
|
|
||||||
|
db_path = results_dir / "study.db" if results_dir.exists() else None
|
||||||
|
if db_path and db_path.exists():
|
||||||
|
try:
|
||||||
|
conn = sqlite3.connect(str(db_path))
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("SELECT COUNT(*) FROM trials WHERE state='COMPLETE'")
|
||||||
|
trial_count = cursor.fetchone()[0]
|
||||||
|
context_parts.append(f"**Completed Trials**: {trial_count}")
|
||||||
|
|
||||||
|
# Get best value
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT MIN(value) FROM trial_values
|
||||||
|
WHERE trial_id IN (SELECT trial_id FROM trials WHERE state='COMPLETE')
|
||||||
|
""")
|
||||||
|
best = cursor.fetchone()[0]
|
||||||
|
if best is not None:
|
||||||
|
context_parts.append(f"**Best Objective**: {best:.6f}")
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return "\n".join(context_parts) if context_parts else "No configuration found."
|
||||||
|
|
||||||
|
def _define_tools(self) -> List[Dict[str, Any]]:
|
||||||
|
"""Define Atomizer-specific tools for Claude"""
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"name": "read_study_config",
|
||||||
|
"description": "Read the optimization configuration for the current or specified study. Returns design variables, objectives, constraints, and algorithm settings.",
|
||||||
|
"input_schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"study_id": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Study ID to read config from. Uses current study if not specified."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "query_trials",
|
||||||
|
"description": "Query trial data from the Optuna database. Can filter by state, source (FEA/NN), objective value range, or parameter values.",
|
||||||
|
"input_schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"study_id": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Study ID to query. Uses current study if not specified."
|
||||||
|
},
|
||||||
|
"state": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["COMPLETE", "PRUNED", "FAIL", "RUNNING", "all"],
|
||||||
|
"description": "Filter by trial state. Default: COMPLETE"
|
||||||
|
},
|
||||||
|
"source": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["fea", "nn", "all"],
|
||||||
|
"description": "Filter by trial source (FEA simulation or Neural Network). Default: all"
|
||||||
|
},
|
||||||
|
"limit": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Maximum number of trials to return. Default: 20"
|
||||||
|
},
|
||||||
|
"order_by": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["value_asc", "value_desc", "trial_id_asc", "trial_id_desc"],
|
||||||
|
"description": "Sort order. Default: value_asc (best first)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "get_trial_details",
|
||||||
|
"description": "Get detailed information about a specific trial including all parameters, objective values, and user attributes.",
|
||||||
|
"input_schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"study_id": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Study ID. Uses current study if not specified."
|
||||||
|
},
|
||||||
|
"trial_id": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "The trial number to get details for."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["trial_id"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "compare_trials",
|
||||||
|
"description": "Compare two or more trials side-by-side, showing parameter differences and objective values.",
|
||||||
|
"input_schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"study_id": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Study ID. Uses current study if not specified."
|
||||||
|
},
|
||||||
|
"trial_ids": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {"type": "integer"},
|
||||||
|
"description": "List of trial IDs to compare (2-5 trials)."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["trial_ids"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "get_optimization_summary",
|
||||||
|
"description": "Get a high-level summary of the optimization progress including trial counts, convergence status, best designs, and parameter sensitivity.",
|
||||||
|
"input_schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"study_id": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Study ID. Uses current study if not specified."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "read_study_readme",
|
||||||
|
"description": "Read the README.md documentation for a study, which contains the engineering problem description, mathematical formulation, and methodology.",
|
||||||
|
"input_schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"study_id": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Study ID. Uses current study if not specified."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "list_studies",
|
||||||
|
"description": "List all available optimization studies with their status and trial counts.",
|
||||||
|
"input_schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {},
|
||||||
|
"required": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
def _execute_tool(self, tool_name: str, tool_input: Dict[str, Any]) -> str:
|
||||||
|
"""Execute an Atomizer tool and return the result"""
|
||||||
|
try:
|
||||||
|
if tool_name == "read_study_config":
|
||||||
|
return self._tool_read_config(tool_input.get('study_id'))
|
||||||
|
elif tool_name == "query_trials":
|
||||||
|
return self._tool_query_trials(tool_input)
|
||||||
|
elif tool_name == "get_trial_details":
|
||||||
|
return self._tool_get_trial_details(tool_input)
|
||||||
|
elif tool_name == "compare_trials":
|
||||||
|
return self._tool_compare_trials(tool_input)
|
||||||
|
elif tool_name == "get_optimization_summary":
|
||||||
|
return self._tool_get_summary(tool_input.get('study_id'))
|
||||||
|
elif tool_name == "read_study_readme":
|
||||||
|
return self._tool_read_readme(tool_input.get('study_id'))
|
||||||
|
elif tool_name == "list_studies":
|
||||||
|
return self._tool_list_studies()
|
||||||
|
else:
|
||||||
|
return f"Unknown tool: {tool_name}"
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error executing {tool_name}: {str(e)}"
|
||||||
|
|
||||||
|
def _get_study_dir(self, study_id: Optional[str]) -> Path:
|
||||||
|
"""Get study directory, using current study if not specified"""
|
||||||
|
sid = study_id or self.study_id
|
||||||
|
if not sid:
|
||||||
|
raise ValueError("No study specified and no current study selected")
|
||||||
|
study_dir = STUDIES_DIR / sid
|
||||||
|
if not study_dir.exists():
|
||||||
|
raise ValueError(f"Study '{sid}' not found")
|
||||||
|
return study_dir
|
||||||
|
|
||||||
|
def _get_db_path(self, study_id: Optional[str]) -> Path:
|
||||||
|
"""Get database path for a study"""
|
||||||
|
study_dir = self._get_study_dir(study_id)
|
||||||
|
for results_dir_name in ["2_results", "3_results"]:
|
||||||
|
db_path = study_dir / results_dir_name / "study.db"
|
||||||
|
if db_path.exists():
|
||||||
|
return db_path
|
||||||
|
raise ValueError(f"No database found for study")
|
||||||
|
|
||||||
|
def _tool_read_config(self, study_id: Optional[str]) -> str:
|
||||||
|
"""Read study configuration"""
|
||||||
|
study_dir = self._get_study_dir(study_id)
|
||||||
|
|
||||||
|
config_path = study_dir / "1_setup" / "optimization_config.json"
|
||||||
|
if not config_path.exists():
|
||||||
|
config_path = study_dir / "optimization_config.json"
|
||||||
|
|
||||||
|
if not config_path.exists():
|
||||||
|
return "No configuration file found for this study."
|
||||||
|
|
||||||
|
with open(config_path) as f:
|
||||||
|
config = json.load(f)
|
||||||
|
|
||||||
|
# Format nicely
|
||||||
|
result = [f"# Configuration for {study_id or self.study_id}\n"]
|
||||||
|
|
||||||
|
# Design variables
|
||||||
|
dvs = config.get('design_variables', [])
|
||||||
|
if dvs:
|
||||||
|
result.append("## Design Variables")
|
||||||
|
result.append("| Name | Min | Max | Baseline | Units |")
|
||||||
|
result.append("|------|-----|-----|----------|-------|")
|
||||||
|
for dv in dvs:
|
||||||
|
result.append(f"| {dv['name']} | {dv.get('min', '-')} | {dv.get('max', '-')} | {dv.get('baseline', '-')} | {dv.get('units', '-')} |")
|
||||||
|
|
||||||
|
# Objectives
|
||||||
|
objs = config.get('objectives', [])
|
||||||
|
if objs:
|
||||||
|
result.append("\n## Objectives")
|
||||||
|
result.append("| Name | Direction | Weight | Target | Units |")
|
||||||
|
result.append("|------|-----------|--------|--------|-------|")
|
||||||
|
for obj in objs:
|
||||||
|
result.append(f"| {obj['name']} | {obj.get('direction', 'minimize')} | {obj.get('weight', 1.0)} | {obj.get('target', '-')} | {obj.get('units', '-')} |")
|
||||||
|
|
||||||
|
# Constraints
|
||||||
|
constraints = config.get('constraints', [])
|
||||||
|
if constraints:
|
||||||
|
result.append("\n## Constraints")
|
||||||
|
for c in constraints:
|
||||||
|
result.append(f"- **{c['name']}**: {c.get('type', 'bound')} {c.get('max_value', c.get('min_value', ''))} {c.get('units', '')}")
|
||||||
|
|
||||||
|
return "\n".join(result)
|
||||||
|
|
||||||
|
def _tool_query_trials(self, params: Dict[str, Any]) -> str:
|
||||||
|
"""Query trials from database"""
|
||||||
|
db_path = self._get_db_path(params.get('study_id'))
|
||||||
|
|
||||||
|
state = params.get('state', 'COMPLETE')
|
||||||
|
source = params.get('source', 'all')
|
||||||
|
limit = params.get('limit', 20)
|
||||||
|
order_by = params.get('order_by', 'value_asc')
|
||||||
|
|
||||||
|
conn = sqlite3.connect(str(db_path))
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Build query
|
||||||
|
query = """
|
||||||
|
SELECT t.trial_id, t.state, tv.value,
|
||||||
|
GROUP_CONCAT(tp.param_name || '=' || ROUND(tp.param_value, 4), ', ') as params
|
||||||
|
FROM trials t
|
||||||
|
LEFT JOIN trial_values tv ON t.trial_id = tv.trial_id
|
||||||
|
LEFT JOIN trial_params tp ON t.trial_id = tp.trial_id
|
||||||
|
"""
|
||||||
|
|
||||||
|
conditions = []
|
||||||
|
if state != 'all':
|
||||||
|
conditions.append(f"t.state = '{state}'")
|
||||||
|
|
||||||
|
if conditions:
|
||||||
|
query += " WHERE " + " AND ".join(conditions)
|
||||||
|
|
||||||
|
query += " GROUP BY t.trial_id"
|
||||||
|
|
||||||
|
# Order
|
||||||
|
if order_by == 'value_asc':
|
||||||
|
query += " ORDER BY tv.value ASC"
|
||||||
|
elif order_by == 'value_desc':
|
||||||
|
query += " ORDER BY tv.value DESC"
|
||||||
|
elif order_by == 'trial_id_desc':
|
||||||
|
query += " ORDER BY t.trial_id DESC"
|
||||||
|
else:
|
||||||
|
query += " ORDER BY t.trial_id ASC"
|
||||||
|
|
||||||
|
query += f" LIMIT {limit}"
|
||||||
|
|
||||||
|
cursor.execute(query)
|
||||||
|
rows = cursor.fetchall()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
if not rows:
|
||||||
|
return "No trials found matching the criteria."
|
||||||
|
|
||||||
|
# Filter by source if needed (check user_attrs)
|
||||||
|
if source != 'all':
|
||||||
|
# Would need another query to filter by trial_source attr
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Format results
|
||||||
|
result = [f"# Trials (showing {len(rows)}/{limit} max)\n"]
|
||||||
|
result.append("| Trial | State | Objective | Parameters |")
|
||||||
|
result.append("|-------|-------|-----------|------------|")
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
value = f"{row['value']:.6f}" if row['value'] else "N/A"
|
||||||
|
params = row['params'][:50] + "..." if row['params'] and len(row['params']) > 50 else (row['params'] or "")
|
||||||
|
result.append(f"| {row['trial_id']} | {row['state']} | {value} | {params} |")
|
||||||
|
|
||||||
|
return "\n".join(result)
|
||||||
|
|
||||||
|
def _tool_get_trial_details(self, params: Dict[str, Any]) -> str:
|
||||||
|
"""Get detailed trial information"""
|
||||||
|
db_path = self._get_db_path(params.get('study_id'))
|
||||||
|
trial_id = params['trial_id']
|
||||||
|
|
||||||
|
conn = sqlite3.connect(str(db_path))
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Get trial info
|
||||||
|
cursor.execute("SELECT * FROM trials WHERE trial_id = ?", (trial_id,))
|
||||||
|
trial = cursor.fetchone()
|
||||||
|
|
||||||
|
if not trial:
|
||||||
|
conn.close()
|
||||||
|
return f"Trial {trial_id} not found."
|
||||||
|
|
||||||
|
result = [f"# Trial {trial_id} Details\n"]
|
||||||
|
result.append(f"**State**: {trial['state']}")
|
||||||
|
|
||||||
|
# Get objective value
|
||||||
|
cursor.execute("SELECT value FROM trial_values WHERE trial_id = ?", (trial_id,))
|
||||||
|
value_row = cursor.fetchone()
|
||||||
|
if value_row:
|
||||||
|
result.append(f"**Objective Value**: {value_row['value']:.6f}")
|
||||||
|
|
||||||
|
# Get parameters
|
||||||
|
cursor.execute("SELECT param_name, param_value FROM trial_params WHERE trial_id = ? ORDER BY param_name", (trial_id,))
|
||||||
|
params_rows = cursor.fetchall()
|
||||||
|
|
||||||
|
if params_rows:
|
||||||
|
result.append("\n## Parameters")
|
||||||
|
result.append("| Parameter | Value |")
|
||||||
|
result.append("|-----------|-------|")
|
||||||
|
for p in params_rows:
|
||||||
|
result.append(f"| {p['param_name']} | {p['param_value']:.6f} |")
|
||||||
|
|
||||||
|
# Get user attributes
|
||||||
|
cursor.execute("SELECT key, value_json FROM trial_user_attributes WHERE trial_id = ?", (trial_id,))
|
||||||
|
attrs = cursor.fetchall()
|
||||||
|
|
||||||
|
if attrs:
|
||||||
|
result.append("\n## Attributes")
|
||||||
|
for attr in attrs:
|
||||||
|
try:
|
||||||
|
value = json.loads(attr['value_json'])
|
||||||
|
if isinstance(value, float):
|
||||||
|
result.append(f"- **{attr['key']}**: {value:.6f}")
|
||||||
|
else:
|
||||||
|
result.append(f"- **{attr['key']}**: {value}")
|
||||||
|
except:
|
||||||
|
result.append(f"- **{attr['key']}**: {attr['value_json']}")
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
return "\n".join(result)
|
||||||
|
|
||||||
|
def _tool_compare_trials(self, params: Dict[str, Any]) -> str:
|
||||||
|
"""Compare multiple trials"""
|
||||||
|
db_path = self._get_db_path(params.get('study_id'))
|
||||||
|
trial_ids = params['trial_ids']
|
||||||
|
|
||||||
|
if len(trial_ids) < 2:
|
||||||
|
return "Need at least 2 trials to compare."
|
||||||
|
if len(trial_ids) > 5:
|
||||||
|
return "Maximum 5 trials for comparison."
|
||||||
|
|
||||||
|
conn = sqlite3.connect(str(db_path))
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
result = ["# Trial Comparison\n"]
|
||||||
|
|
||||||
|
# Get all parameter names
|
||||||
|
cursor.execute("SELECT DISTINCT param_name FROM trial_params ORDER BY param_name")
|
||||||
|
param_names = [row['param_name'] for row in cursor.fetchall()]
|
||||||
|
|
||||||
|
# Build comparison table header
|
||||||
|
header = "| Parameter | " + " | ".join(f"Trial {tid}" for tid in trial_ids) + " |"
|
||||||
|
separator = "|-----------|" + "|".join("-" * 10 for _ in trial_ids) + "|"
|
||||||
|
|
||||||
|
result.append(header)
|
||||||
|
result.append(separator)
|
||||||
|
|
||||||
|
# Objective values row
|
||||||
|
obj_values = []
|
||||||
|
for tid in trial_ids:
|
||||||
|
cursor.execute("SELECT value FROM trial_values WHERE trial_id = ?", (tid,))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
obj_values.append(f"{row['value']:.4f}" if row else "N/A")
|
||||||
|
result.append("| **Objective** | " + " | ".join(obj_values) + " |")
|
||||||
|
|
||||||
|
# Parameter rows
|
||||||
|
for pname in param_names:
|
||||||
|
values = []
|
||||||
|
for tid in trial_ids:
|
||||||
|
cursor.execute("SELECT param_value FROM trial_params WHERE trial_id = ? AND param_name = ?", (tid, pname))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
values.append(f"{row['param_value']:.4f}" if row else "N/A")
|
||||||
|
result.append(f"| {pname} | " + " | ".join(values) + " |")
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
return "\n".join(result)
|
||||||
|
|
||||||
|
def _tool_get_summary(self, study_id: Optional[str]) -> str:
|
||||||
|
"""Get optimization summary"""
|
||||||
|
db_path = self._get_db_path(study_id)
|
||||||
|
|
||||||
|
conn = sqlite3.connect(str(db_path))
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
result = [f"# Optimization Summary\n"]
|
||||||
|
|
||||||
|
# Trial counts by state
|
||||||
|
cursor.execute("SELECT state, COUNT(*) as count FROM trials GROUP BY state")
|
||||||
|
states = {row['state']: row['count'] for row in cursor.fetchall()}
|
||||||
|
|
||||||
|
result.append("## Trial Counts")
|
||||||
|
total = sum(states.values())
|
||||||
|
result.append(f"- **Total**: {total}")
|
||||||
|
for state, count in states.items():
|
||||||
|
result.append(f"- {state}: {count}")
|
||||||
|
|
||||||
|
# Best trial
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT t.trial_id, tv.value
|
||||||
|
FROM trials t
|
||||||
|
JOIN trial_values tv ON t.trial_id = tv.trial_id
|
||||||
|
WHERE t.state = 'COMPLETE'
|
||||||
|
ORDER BY tv.value ASC LIMIT 1
|
||||||
|
""")
|
||||||
|
best = cursor.fetchone()
|
||||||
|
if best:
|
||||||
|
result.append(f"\n## Best Trial")
|
||||||
|
result.append(f"- **Trial ID**: {best['trial_id']}")
|
||||||
|
result.append(f"- **Objective**: {best['value']:.6f}")
|
||||||
|
|
||||||
|
# FEA vs NN counts
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT value_json, COUNT(*) as count
|
||||||
|
FROM trial_user_attributes
|
||||||
|
WHERE key = 'trial_source'
|
||||||
|
GROUP BY value_json
|
||||||
|
""")
|
||||||
|
sources = cursor.fetchall()
|
||||||
|
if sources:
|
||||||
|
result.append("\n## Trial Sources")
|
||||||
|
for src in sources:
|
||||||
|
source_name = json.loads(src['value_json']) if src['value_json'] else 'unknown'
|
||||||
|
result.append(f"- **{source_name}**: {src['count']}")
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
return "\n".join(result)
|
||||||
|
|
||||||
|
def _tool_read_readme(self, study_id: Optional[str]) -> str:
|
||||||
|
"""Read study README"""
|
||||||
|
study_dir = self._get_study_dir(study_id)
|
||||||
|
readme_path = study_dir / "README.md"
|
||||||
|
|
||||||
|
if not readme_path.exists():
|
||||||
|
return "No README.md found for this study."
|
||||||
|
|
||||||
|
content = readme_path.read_text(encoding='utf-8')
|
||||||
|
# Truncate if too long
|
||||||
|
if len(content) > 8000:
|
||||||
|
content = content[:8000] + "\n\n... (truncated)"
|
||||||
|
|
||||||
|
return content
|
||||||
|
|
||||||
|
def _tool_list_studies(self) -> str:
|
||||||
|
"""List all studies"""
|
||||||
|
if not STUDIES_DIR.exists():
|
||||||
|
return "Studies directory not found."
|
||||||
|
|
||||||
|
result = ["# Available Studies\n"]
|
||||||
|
result.append("| Study | Status | Trials |")
|
||||||
|
result.append("|-------|--------|--------|")
|
||||||
|
|
||||||
|
for study_dir in sorted(STUDIES_DIR.iterdir()):
|
||||||
|
if not study_dir.is_dir():
|
||||||
|
continue
|
||||||
|
|
||||||
|
study_id = study_dir.name
|
||||||
|
|
||||||
|
# Check for database
|
||||||
|
trial_count = 0
|
||||||
|
for results_dir_name in ["2_results", "3_results"]:
|
||||||
|
db_path = study_dir / results_dir_name / "study.db"
|
||||||
|
if db_path.exists():
|
||||||
|
try:
|
||||||
|
conn = sqlite3.connect(str(db_path))
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("SELECT COUNT(*) FROM trials WHERE state='COMPLETE'")
|
||||||
|
trial_count = cursor.fetchone()[0]
|
||||||
|
conn.close()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
break
|
||||||
|
|
||||||
|
# Determine status
|
||||||
|
status = "ready" if trial_count > 0 else "not_started"
|
||||||
|
|
||||||
|
result.append(f"| {study_id} | {status} | {trial_count} |")
|
||||||
|
|
||||||
|
return "\n".join(result)
|
||||||
|
|
||||||
|
async def chat(self, message: str, conversation_history: Optional[List[Dict]] = None) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Process a chat message with tool use support
|
||||||
|
|
||||||
|
Args:
|
||||||
|
message: User's message
|
||||||
|
conversation_history: Previous messages for context
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with response text and any tool calls made
|
||||||
|
"""
|
||||||
|
messages = conversation_history.copy() if conversation_history else []
|
||||||
|
messages.append({"role": "user", "content": message})
|
||||||
|
|
||||||
|
tool_calls_made = []
|
||||||
|
|
||||||
|
# Loop to handle tool use
|
||||||
|
while True:
|
||||||
|
response = self.client.messages.create(
|
||||||
|
model="claude-sonnet-4-20250514",
|
||||||
|
max_tokens=4096,
|
||||||
|
system=self.system_prompt,
|
||||||
|
tools=self.tools,
|
||||||
|
messages=messages
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if we need to handle tool use
|
||||||
|
if response.stop_reason == "tool_use":
|
||||||
|
# Process tool calls
|
||||||
|
assistant_content = response.content
|
||||||
|
tool_results = []
|
||||||
|
|
||||||
|
for block in assistant_content:
|
||||||
|
if block.type == "tool_use":
|
||||||
|
tool_name = block.name
|
||||||
|
tool_input = block.input
|
||||||
|
tool_id = block.id
|
||||||
|
|
||||||
|
# Execute the tool
|
||||||
|
result = self._execute_tool(tool_name, tool_input)
|
||||||
|
|
||||||
|
tool_calls_made.append({
|
||||||
|
"tool": tool_name,
|
||||||
|
"input": tool_input,
|
||||||
|
"result_preview": result[:200] + "..." if len(result) > 200 else result
|
||||||
|
})
|
||||||
|
|
||||||
|
tool_results.append({
|
||||||
|
"type": "tool_result",
|
||||||
|
"tool_use_id": tool_id,
|
||||||
|
"content": result
|
||||||
|
})
|
||||||
|
|
||||||
|
# Add assistant response and tool results to messages
|
||||||
|
messages.append({"role": "assistant", "content": assistant_content})
|
||||||
|
messages.append({"role": "user", "content": tool_results})
|
||||||
|
|
||||||
|
else:
|
||||||
|
# No more tool use, extract final response
|
||||||
|
final_text = ""
|
||||||
|
for block in response.content:
|
||||||
|
if hasattr(block, 'text'):
|
||||||
|
final_text += block.text
|
||||||
|
|
||||||
|
return {
|
||||||
|
"response": final_text,
|
||||||
|
"tool_calls": tool_calls_made,
|
||||||
|
"conversation": messages + [{"role": "assistant", "content": response.content}]
|
||||||
|
}
|
||||||
|
|
||||||
|
async def chat_stream(self, message: str, conversation_history: Optional[List[Dict]] = None) -> AsyncGenerator[str, None]:
|
||||||
|
"""
|
||||||
|
Stream a chat response token by token
|
||||||
|
|
||||||
|
Args:
|
||||||
|
message: User's message
|
||||||
|
conversation_history: Previous messages
|
||||||
|
|
||||||
|
Yields:
|
||||||
|
Response tokens as they arrive
|
||||||
|
"""
|
||||||
|
messages = conversation_history.copy() if conversation_history else []
|
||||||
|
messages.append({"role": "user", "content": message})
|
||||||
|
|
||||||
|
# For streaming, we'll do a simpler approach without tool use for now
|
||||||
|
# (Tool use with streaming is more complex)
|
||||||
|
with self.client.messages.stream(
|
||||||
|
model="claude-sonnet-4-20250514",
|
||||||
|
max_tokens=4096,
|
||||||
|
system=self.system_prompt,
|
||||||
|
messages=messages
|
||||||
|
) as stream:
|
||||||
|
for text in stream.text_stream:
|
||||||
|
yield text
|
||||||
@@ -29,3 +29,6 @@ scipy>=1.10.0
|
|||||||
markdown>=3.5.0
|
markdown>=3.5.0
|
||||||
weasyprint>=60.0.0
|
weasyprint>=60.0.0
|
||||||
jinja2>=3.1.0
|
jinja2>=3.1.0
|
||||||
|
|
||||||
|
# AI Integration
|
||||||
|
anthropic>=0.18.0
|
||||||
|
|||||||
143
atomizer-dashboard/frontend/package-lock.json
generated
143
atomizer-dashboard/frontend/package-lock.json
generated
@@ -12,8 +12,12 @@
|
|||||||
"@react-three/fiber": "^9.4.0",
|
"@react-three/fiber": "^9.4.0",
|
||||||
"@tanstack/react-query": "^5.90.10",
|
"@tanstack/react-query": "^5.90.10",
|
||||||
"@types/react-plotly.js": "^2.6.3",
|
"@types/react-plotly.js": "^2.6.3",
|
||||||
|
"@types/react-syntax-highlighter": "^15.5.13",
|
||||||
"@types/three": "^0.181.0",
|
"@types/three": "^0.181.0",
|
||||||
|
"@xterm/addon-fit": "^0.10.0",
|
||||||
|
"@xterm/addon-web-links": "^0.11.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"katex": "^0.16.25",
|
||||||
"lucide-react": "^0.554.0",
|
"lucide-react": "^0.554.0",
|
||||||
"plotly.js-basic-dist": "^3.3.0",
|
"plotly.js-basic-dist": "^3.3.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
@@ -21,13 +25,15 @@
|
|||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-plotly.js": "^2.6.0",
|
"react-plotly.js": "^2.6.0",
|
||||||
"react-router-dom": "^6.20.0",
|
"react-router-dom": "^6.20.0",
|
||||||
|
"react-syntax-highlighter": "^16.1.0",
|
||||||
"react-use-websocket": "^4.13.0",
|
"react-use-websocket": "^4.13.0",
|
||||||
"recharts": "^2.10.3",
|
"recharts": "^2.10.3",
|
||||||
"rehype-katex": "^7.0.1",
|
"rehype-katex": "^7.0.1",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"remark-math": "^6.0.0",
|
"remark-math": "^6.0.0",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"three": "^0.181.2"
|
"three": "^0.181.2",
|
||||||
|
"xterm": "^5.3.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^18.2.43",
|
"@types/react": "^18.2.43",
|
||||||
@@ -1660,6 +1666,12 @@
|
|||||||
"integrity": "sha512-FjmSFaLmHVgBIBL6H0yX5k/AB3a7FQzjKBlRUF8YT6HiXMArE+hbXYIZXZ/42SBrdL05LWEog0zPqEaIDNsAiw==",
|
"integrity": "sha512-FjmSFaLmHVgBIBL6H0yX5k/AB3a7FQzjKBlRUF8YT6HiXMArE+hbXYIZXZ/42SBrdL05LWEog0zPqEaIDNsAiw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/prismjs": {
|
||||||
|
"version": "1.26.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.5.tgz",
|
||||||
|
"integrity": "sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/prop-types": {
|
"node_modules/@types/prop-types": {
|
||||||
"version": "15.7.15",
|
"version": "15.7.15",
|
||||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||||
@@ -1705,6 +1717,15 @@
|
|||||||
"@types/react": "*"
|
"@types/react": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/react-syntax-highlighter": {
|
||||||
|
"version": "15.5.13",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/react-syntax-highlighter/-/react-syntax-highlighter-15.5.13.tgz",
|
||||||
|
"integrity": "sha512-uLGJ87j6Sz8UaBAooU0T6lWJ0dBmjZgN1PZTrj05TNql2/XpC6+4HhMT5syIdFUUt+FASfCeLLv4kBygNU+8qA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/react": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/semver": {
|
"node_modules/@types/semver": {
|
||||||
"version": "7.7.1",
|
"version": "7.7.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz",
|
||||||
@@ -1994,6 +2015,24 @@
|
|||||||
"integrity": "sha512-YA2hLrwLpDsRueNDXIMqN9NTzD6bCDkuXbOSe0heS+f8YE8usA6Gbv1prj81pzVHrbaAma7zObnIC+I6/sXJgA==",
|
"integrity": "sha512-YA2hLrwLpDsRueNDXIMqN9NTzD6bCDkuXbOSe0heS+f8YE8usA6Gbv1prj81pzVHrbaAma7zObnIC+I6/sXJgA==",
|
||||||
"license": "BSD-3-Clause"
|
"license": "BSD-3-Clause"
|
||||||
},
|
},
|
||||||
|
"node_modules/@xterm/addon-fit": {
|
||||||
|
"version": "0.10.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz",
|
||||||
|
"integrity": "sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@xterm/xterm": "^5.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@xterm/addon-web-links": {
|
||||||
|
"version": "0.11.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.11.0.tgz",
|
||||||
|
"integrity": "sha512-nIHQ38pQI+a5kXnRaTgwqSHnX7KE6+4SVoceompgHL26unAxdfP6IPqUTSYPQgSwM56hsElfoNrrW5V7BUED/Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@xterm/xterm": "^5.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/acorn": {
|
"node_modules/acorn": {
|
||||||
"version": "8.15.0",
|
"version": "8.15.0",
|
||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||||
@@ -3188,6 +3227,19 @@
|
|||||||
"reusify": "^1.0.4"
|
"reusify": "^1.0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fault": {
|
||||||
|
"version": "1.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/fault/-/fault-1.0.4.tgz",
|
||||||
|
"integrity": "sha512-CJ0HCB5tL5fYTEA7ToAq5+kTwd++Borf1/bifxd9iT70QcXr4MRrO3Llf8Ifs70q+SJcGHFtnIE/Nw6giCtECA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"format": "^0.2.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/wooorm"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fflate": {
|
"node_modules/fflate": {
|
||||||
"version": "0.8.2",
|
"version": "0.8.2",
|
||||||
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
|
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
|
||||||
@@ -3259,6 +3311,14 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/format": {
|
||||||
|
"version": "0.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/format/-/format-0.2.2.tgz",
|
||||||
|
"integrity": "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.4.x"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fraction.js": {
|
"node_modules/fraction.js": {
|
||||||
"version": "5.3.4",
|
"version": "5.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
|
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
|
||||||
@@ -3615,6 +3675,21 @@
|
|||||||
"url": "https://opencollective.com/unified"
|
"url": "https://opencollective.com/unified"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/highlight.js": {
|
||||||
|
"version": "10.7.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz",
|
||||||
|
"integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/highlightjs-vue": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/highlightjs-vue/-/highlightjs-vue-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA==",
|
||||||
|
"license": "CC0-1.0"
|
||||||
|
},
|
||||||
"node_modules/hls.js": {
|
"node_modules/hls.js": {
|
||||||
"version": "1.6.15",
|
"version": "1.6.15",
|
||||||
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.15.tgz",
|
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.15.tgz",
|
||||||
@@ -4094,6 +4169,20 @@
|
|||||||
"loose-envify": "cli.js"
|
"loose-envify": "cli.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lowlight": {
|
||||||
|
"version": "1.20.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lowlight/-/lowlight-1.20.0.tgz",
|
||||||
|
"integrity": "sha512-8Ktj+prEb1RoCPkEOrPMYUN/nCggB7qAWe3a7OpMjWQkh3l2RD5wKRQ+o8Q8YuI9RG/xs95waaI/E6ym/7NsTw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"fault": "^1.0.0",
|
||||||
|
"highlight.js": "~10.7.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/wooorm"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/lru-cache": {
|
"node_modules/lru-cache": {
|
||||||
"version": "5.1.1",
|
"version": "5.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
||||||
@@ -5542,6 +5631,15 @@
|
|||||||
"node": ">= 0.8.0"
|
"node": ">= 0.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/prismjs": {
|
||||||
|
"version": "1.30.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz",
|
||||||
|
"integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/promise-worker-transferable": {
|
"node_modules/promise-worker-transferable": {
|
||||||
"version": "1.0.4",
|
"version": "1.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/promise-worker-transferable/-/promise-worker-transferable-1.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/promise-worker-transferable/-/promise-worker-transferable-1.0.4.tgz",
|
||||||
@@ -5759,6 +5857,26 @@
|
|||||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-syntax-highlighter": {
|
||||||
|
"version": "16.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-16.1.0.tgz",
|
||||||
|
"integrity": "sha512-E40/hBiP5rCNwkeBN1vRP+xow1X0pndinO+z3h7HLsHyjztbyjfzNWNKuAsJj+7DLam9iT4AaaOZnueCU+Nplg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.28.4",
|
||||||
|
"highlight.js": "^10.4.1",
|
||||||
|
"highlightjs-vue": "^1.0.0",
|
||||||
|
"lowlight": "^1.17.0",
|
||||||
|
"prismjs": "^1.30.0",
|
||||||
|
"refractor": "^5.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 16.20.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">= 0.14.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-transition-group": {
|
"node_modules/react-transition-group": {
|
||||||
"version": "4.4.5",
|
"version": "4.4.5",
|
||||||
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
|
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
|
||||||
@@ -5851,6 +5969,22 @@
|
|||||||
"decimal.js-light": "^2.4.1"
|
"decimal.js-light": "^2.4.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/refractor": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/refractor/-/refractor-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-QXOrHQF5jOpjjLfiNk5GFnWhRXvxjUVnlFxkeDmewR5sXkr3iM46Zo+CnRR8B+MDVqkULW4EcLVcRBNOPXHosw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/hast": "^3.0.0",
|
||||||
|
"@types/prismjs": "^1.0.0",
|
||||||
|
"hastscript": "^9.0.0",
|
||||||
|
"parse-entities": "^4.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/wooorm"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/rehype-katex": {
|
"node_modules/rehype-katex": {
|
||||||
"version": "7.0.1",
|
"version": "7.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/rehype-katex/-/rehype-katex-7.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/rehype-katex/-/rehype-katex-7.0.1.tgz",
|
||||||
@@ -6989,6 +7123,13 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/xterm": {
|
||||||
|
"version": "5.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/xterm/-/xterm-5.3.0.tgz",
|
||||||
|
"integrity": "sha512-8QqjlekLUFTrU6x7xck1MsPzPA571K5zNqWm0M0oroYEWVOptZ0+ubQSkQ3uxIEhcIHRujJy6emDWX4A7qyFzg==",
|
||||||
|
"deprecated": "This package is now deprecated. Move to @xterm/xterm instead.",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/yallist": {
|
"node_modules/yallist": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||||
|
|||||||
@@ -14,8 +14,12 @@
|
|||||||
"@react-three/fiber": "^9.4.0",
|
"@react-three/fiber": "^9.4.0",
|
||||||
"@tanstack/react-query": "^5.90.10",
|
"@tanstack/react-query": "^5.90.10",
|
||||||
"@types/react-plotly.js": "^2.6.3",
|
"@types/react-plotly.js": "^2.6.3",
|
||||||
|
"@types/react-syntax-highlighter": "^15.5.13",
|
||||||
"@types/three": "^0.181.0",
|
"@types/three": "^0.181.0",
|
||||||
|
"@xterm/addon-fit": "^0.10.0",
|
||||||
|
"@xterm/addon-web-links": "^0.11.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"katex": "^0.16.25",
|
||||||
"lucide-react": "^0.554.0",
|
"lucide-react": "^0.554.0",
|
||||||
"plotly.js-basic-dist": "^3.3.0",
|
"plotly.js-basic-dist": "^3.3.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
@@ -23,13 +27,15 @@
|
|||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-plotly.js": "^2.6.0",
|
"react-plotly.js": "^2.6.0",
|
||||||
"react-router-dom": "^6.20.0",
|
"react-router-dom": "^6.20.0",
|
||||||
|
"react-syntax-highlighter": "^16.1.0",
|
||||||
"react-use-websocket": "^4.13.0",
|
"react-use-websocket": "^4.13.0",
|
||||||
"recharts": "^2.10.3",
|
"recharts": "^2.10.3",
|
||||||
"rehype-katex": "^7.0.1",
|
"rehype-katex": "^7.0.1",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"remark-math": "^6.0.0",
|
"remark-math": "^6.0.0",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"three": "^0.181.2"
|
"three": "^0.181.2",
|
||||||
|
"xterm": "^5.3.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^18.2.43",
|
"@types/react": "^18.2.43",
|
||||||
|
|||||||
@@ -1,25 +1,38 @@
|
|||||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { StudyProvider } from './context/StudyContext';
|
||||||
import { MainLayout } from './components/layout/MainLayout';
|
import { MainLayout } from './components/layout/MainLayout';
|
||||||
|
import Home from './pages/Home';
|
||||||
import Dashboard from './pages/Dashboard';
|
import Dashboard from './pages/Dashboard';
|
||||||
import Configurator from './pages/Configurator';
|
|
||||||
import Results from './pages/Results';
|
import Results from './pages/Results';
|
||||||
|
|
||||||
const queryClient = new QueryClient();
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
staleTime: 5000,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<BrowserRouter>
|
<StudyProvider>
|
||||||
<Routes>
|
<BrowserRouter>
|
||||||
<Route path="/" element={<MainLayout />}>
|
<Routes>
|
||||||
<Route index element={<Navigate to="/dashboard" replace />} />
|
{/* Home page - no sidebar layout */}
|
||||||
<Route path="dashboard" element={<Dashboard />} />
|
<Route path="/" element={<Home />} />
|
||||||
<Route path="configurator" element={<Configurator />} />
|
|
||||||
<Route path="results" element={<Results />} />
|
{/* Study pages - with sidebar layout */}
|
||||||
</Route>
|
<Route element={<MainLayout />}>
|
||||||
</Routes>
|
<Route path="dashboard" element={<Dashboard />} />
|
||||||
</BrowserRouter>
|
<Route path="results" element={<Results />} />
|
||||||
|
<Route path="analytics" element={<Dashboard />} />
|
||||||
|
</Route>
|
||||||
|
</Routes>
|
||||||
|
</BrowserRouter>
|
||||||
|
</StudyProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,56 @@ import { StudyListResponse, HistoryResponse, PruningResponse, StudyStatus } from
|
|||||||
|
|
||||||
const API_BASE = '/api';
|
const API_BASE = '/api';
|
||||||
|
|
||||||
|
export interface OptimizationControlResponse {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
pid?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReadmeResponse {
|
||||||
|
content: string;
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReportResponse {
|
||||||
|
content: string;
|
||||||
|
generated_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConfigResponse {
|
||||||
|
config: Record<string, any>;
|
||||||
|
objectives: Array<{
|
||||||
|
name: string;
|
||||||
|
direction: string;
|
||||||
|
weight?: number;
|
||||||
|
target?: number;
|
||||||
|
units?: string;
|
||||||
|
}>;
|
||||||
|
design_variables: Array<{
|
||||||
|
name: string;
|
||||||
|
min: number;
|
||||||
|
max: number;
|
||||||
|
baseline?: number;
|
||||||
|
units?: string;
|
||||||
|
}>;
|
||||||
|
constraints?: Array<{
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
max_value?: number;
|
||||||
|
min_value?: number;
|
||||||
|
units?: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProcessStatus {
|
||||||
|
is_running: boolean;
|
||||||
|
pid?: number;
|
||||||
|
start_time?: string;
|
||||||
|
iteration?: number;
|
||||||
|
fea_count?: number;
|
||||||
|
nn_count?: number;
|
||||||
|
}
|
||||||
|
|
||||||
class ApiClient {
|
class ApiClient {
|
||||||
async getStudies(): Promise<StudyListResponse> {
|
async getStudies(): Promise<StudyListResponse> {
|
||||||
const response = await fetch(`${API_BASE}/optimization/studies`);
|
const response = await fetch(`${API_BASE}/optimization/studies`);
|
||||||
@@ -37,12 +87,24 @@ class ApiClient {
|
|||||||
return response.json();
|
return response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
async getStudyReport(studyId: string): Promise<{ content: string }> {
|
async getStudyReport(studyId: string): Promise<ReportResponse> {
|
||||||
const response = await fetch(`${API_BASE}/optimization/studies/${studyId}/report`);
|
const response = await fetch(`${API_BASE}/optimization/studies/${studyId}/report`);
|
||||||
if (!response.ok) throw new Error('Failed to fetch report');
|
if (!response.ok) throw new Error('Failed to fetch report');
|
||||||
return response.json();
|
return response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getStudyReadme(studyId: string): Promise<ReadmeResponse> {
|
||||||
|
const response = await fetch(`${API_BASE}/optimization/studies/${studyId}/readme`);
|
||||||
|
if (!response.ok) throw new Error('Failed to fetch README');
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async getStudyConfig(studyId: string): Promise<ConfigResponse> {
|
||||||
|
const response = await fetch(`${API_BASE}/optimization/studies/${studyId}/config`);
|
||||||
|
if (!response.ok) throw new Error('Failed to fetch config');
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
async getConsoleOutput(studyId: string, lines: number = 200): Promise<{
|
async getConsoleOutput(studyId: string, lines: number = 200): Promise<{
|
||||||
lines: string[];
|
lines: string[];
|
||||||
total_lines: number;
|
total_lines: number;
|
||||||
@@ -56,15 +118,80 @@ class ApiClient {
|
|||||||
return response.json();
|
return response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Future endpoints for control
|
async getProcessStatus(studyId: string): Promise<ProcessStatus> {
|
||||||
async startOptimization(studyId: string): Promise<void> {
|
const response = await fetch(`${API_BASE}/optimization/studies/${studyId}/process`);
|
||||||
const response = await fetch(`${API_BASE}/optimization/studies/${studyId}/start`, { method: 'POST' });
|
if (!response.ok) throw new Error('Failed to fetch process status');
|
||||||
if (!response.ok) throw new Error('Failed to start optimization');
|
return response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
async stopOptimization(studyId: string): Promise<void> {
|
// Control operations
|
||||||
const response = await fetch(`${API_BASE}/optimization/studies/${studyId}/stop`, { method: 'POST' });
|
async startOptimization(studyId: string, options?: {
|
||||||
if (!response.ok) throw new Error('Failed to stop optimization');
|
freshStart?: boolean;
|
||||||
|
maxIterations?: number;
|
||||||
|
feaBatchSize?: number;
|
||||||
|
tuneTrials?: number;
|
||||||
|
ensembleSize?: number;
|
||||||
|
patience?: number;
|
||||||
|
}): Promise<OptimizationControlResponse> {
|
||||||
|
const response = await fetch(`${API_BASE}/optimization/studies/${studyId}/start`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(options || {}),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
throw new Error(error.detail || 'Failed to start optimization');
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async stopOptimization(studyId: string): Promise<OptimizationControlResponse> {
|
||||||
|
const response = await fetch(`${API_BASE}/optimization/studies/${studyId}/stop`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
throw new Error(error.detail || 'Failed to stop optimization');
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async validateOptimization(studyId: string, options?: {
|
||||||
|
topN?: number;
|
||||||
|
}): Promise<OptimizationControlResponse> {
|
||||||
|
const response = await fetch(`${API_BASE}/optimization/studies/${studyId}/validate`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(options || {}),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
throw new Error(error.detail || 'Failed to start validation');
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async generateReport(studyId: string): Promise<ReportResponse> {
|
||||||
|
const response = await fetch(`${API_BASE}/optimization/studies/${studyId}/report/generate`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
throw new Error(error.detail || 'Failed to generate report');
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optuna dashboard
|
||||||
|
async launchOptunaDashboard(studyId: string): Promise<{ url: string; pid: number }> {
|
||||||
|
const response = await fetch(`${API_BASE}/optimization/studies/${studyId}/optuna-dashboard`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
throw new Error(error.detail || 'Failed to launch Optuna dashboard');
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
450
atomizer-dashboard/frontend/src/components/ClaudeChat.tsx
Normal file
450
atomizer-dashboard/frontend/src/components/ClaudeChat.tsx
Normal file
@@ -0,0 +1,450 @@
|
|||||||
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Send,
|
||||||
|
Bot,
|
||||||
|
User,
|
||||||
|
Sparkles,
|
||||||
|
Loader2,
|
||||||
|
X,
|
||||||
|
Maximize2,
|
||||||
|
Minimize2,
|
||||||
|
AlertCircle,
|
||||||
|
Wrench,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
|
Trash2
|
||||||
|
} from 'lucide-react';
|
||||||
|
import ReactMarkdown from 'react-markdown';
|
||||||
|
import remarkGfm from 'remark-gfm';
|
||||||
|
import { useStudy } from '../context/StudyContext';
|
||||||
|
|
||||||
|
interface Message {
|
||||||
|
id: string;
|
||||||
|
role: 'user' | 'assistant';
|
||||||
|
content: string;
|
||||||
|
timestamp: Date;
|
||||||
|
toolCalls?: Array<{
|
||||||
|
tool: string;
|
||||||
|
input: Record<string, any>;
|
||||||
|
result_preview: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ClaudeChatProps {
|
||||||
|
isExpanded?: boolean;
|
||||||
|
onToggleExpand?: () => void;
|
||||||
|
onClose?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ClaudeChat: React.FC<ClaudeChatProps> = ({
|
||||||
|
isExpanded = false,
|
||||||
|
onToggleExpand,
|
||||||
|
onClose
|
||||||
|
}) => {
|
||||||
|
const { selectedStudy } = useStudy();
|
||||||
|
const [messages, setMessages] = useState<Message[]>([]);
|
||||||
|
const [input, setInput] = useState('');
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [apiAvailable, setApiAvailable] = useState<boolean | null>(null);
|
||||||
|
const [suggestions, setSuggestions] = useState<string[]>([]);
|
||||||
|
const [expandedTools, setExpandedTools] = useState<Set<string>>(new Set());
|
||||||
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// Check API status on mount
|
||||||
|
useEffect(() => {
|
||||||
|
checkApiStatus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Load suggestions when study changes
|
||||||
|
useEffect(() => {
|
||||||
|
loadSuggestions();
|
||||||
|
}, [selectedStudy]);
|
||||||
|
|
||||||
|
// Scroll to bottom when messages change
|
||||||
|
useEffect(() => {
|
||||||
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
|
const checkApiStatus = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/claude/status');
|
||||||
|
const data = await response.json();
|
||||||
|
setApiAvailable(data.available);
|
||||||
|
if (!data.available) {
|
||||||
|
setError(data.message);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setApiAvailable(false);
|
||||||
|
setError('Could not connect to Claude API');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadSuggestions = async () => {
|
||||||
|
try {
|
||||||
|
const url = selectedStudy
|
||||||
|
? `/api/claude/suggestions?study_id=${selectedStudy.id}`
|
||||||
|
: '/api/claude/suggestions';
|
||||||
|
const response = await fetch(url);
|
||||||
|
const data = await response.json();
|
||||||
|
setSuggestions(data.suggestions || []);
|
||||||
|
} catch (err) {
|
||||||
|
setSuggestions([]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const sendMessage = async (messageText?: string) => {
|
||||||
|
const text = messageText || input.trim();
|
||||||
|
if (!text || isLoading) return;
|
||||||
|
|
||||||
|
setError(null);
|
||||||
|
const userMessage: Message = {
|
||||||
|
id: `user-${Date.now()}`,
|
||||||
|
role: 'user',
|
||||||
|
content: text,
|
||||||
|
timestamp: new Date()
|
||||||
|
};
|
||||||
|
|
||||||
|
setMessages(prev => [...prev, userMessage]);
|
||||||
|
setInput('');
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/claude/chat', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
message: text,
|
||||||
|
study_id: selectedStudy?.id,
|
||||||
|
conversation_history: messages.map(m => ({
|
||||||
|
role: m.role,
|
||||||
|
content: m.content
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json();
|
||||||
|
throw new Error(errorData.detail || 'Failed to get response');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
const assistantMessage: Message = {
|
||||||
|
id: `assistant-${Date.now()}`,
|
||||||
|
role: 'assistant',
|
||||||
|
content: data.response,
|
||||||
|
timestamp: new Date(),
|
||||||
|
toolCalls: data.tool_calls
|
||||||
|
};
|
||||||
|
|
||||||
|
setMessages(prev => [...prev, assistantMessage]);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Failed to send message');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearConversation = () => {
|
||||||
|
setMessages([]);
|
||||||
|
setError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleToolExpand = (toolId: string) => {
|
||||||
|
setExpandedTools(prev => {
|
||||||
|
const newSet = new Set(prev);
|
||||||
|
if (newSet.has(toolId)) {
|
||||||
|
newSet.delete(toolId);
|
||||||
|
} else {
|
||||||
|
newSet.add(toolId);
|
||||||
|
}
|
||||||
|
return newSet;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Render tool call indicator
|
||||||
|
const renderToolCalls = (toolCalls: Message['toolCalls'], messageId: string) => {
|
||||||
|
if (!toolCalls || toolCalls.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-2 space-y-1">
|
||||||
|
{toolCalls.map((tool, index) => {
|
||||||
|
const toolId = `${messageId}-tool-${index}`;
|
||||||
|
const isExpanded = expandedTools.has(toolId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="bg-dark-700/50 rounded-lg border border-dark-600 overflow-hidden"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => toggleToolExpand(toolId)}
|
||||||
|
className="w-full px-3 py-2 flex items-center justify-between text-xs hover:bg-dark-600/50 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Wrench className="w-3 h-3 text-primary-400" />
|
||||||
|
<span className="text-dark-300">Used tool: </span>
|
||||||
|
<span className="text-primary-400 font-mono">{tool.tool}</span>
|
||||||
|
</div>
|
||||||
|
{isExpanded ? (
|
||||||
|
<ChevronUp className="w-3 h-3 text-dark-400" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="w-3 h-3 text-dark-400" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="px-3 py-2 border-t border-dark-600 text-xs">
|
||||||
|
<div className="text-dark-400 mb-1">Input:</div>
|
||||||
|
<pre className="text-dark-300 bg-dark-800 p-2 rounded overflow-x-auto">
|
||||||
|
{JSON.stringify(tool.input, null, 2)}
|
||||||
|
</pre>
|
||||||
|
<div className="text-dark-400 mt-2 mb-1">Result preview:</div>
|
||||||
|
<pre className="text-dark-300 bg-dark-800 p-2 rounded overflow-x-auto whitespace-pre-wrap">
|
||||||
|
{tool.result_preview}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`flex flex-col bg-dark-800 rounded-xl border border-dark-600 overflow-hidden ${
|
||||||
|
isExpanded ? 'fixed inset-4 z-50' : 'h-full'
|
||||||
|
}`}>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="px-4 py-3 border-b border-dark-600 flex items-center justify-between bg-dark-800">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-8 h-8 bg-gradient-to-br from-primary-500 to-primary-700 rounded-lg flex items-center justify-center">
|
||||||
|
<Bot className="w-4 h-4 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-white">Claude Code</span>
|
||||||
|
{selectedStudy && (
|
||||||
|
<span className="ml-2 text-xs bg-dark-700 px-2 py-0.5 rounded text-dark-300">
|
||||||
|
{selectedStudy.id}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{messages.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={clearConversation}
|
||||||
|
className="p-2 text-dark-400 hover:text-white hover:bg-dark-700 rounded-lg transition-colors"
|
||||||
|
title="Clear conversation"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{onToggleExpand && (
|
||||||
|
<button
|
||||||
|
onClick={onToggleExpand}
|
||||||
|
className="p-2 text-dark-400 hover:text-white hover:bg-dark-700 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
{isExpanded ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{onClose && (
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-2 text-dark-400 hover:text-white hover:bg-dark-700 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* API Status Warning */}
|
||||||
|
{apiAvailable === false && (
|
||||||
|
<div className="px-4 py-3 bg-yellow-900/20 border-b border-yellow-800/30 flex items-center gap-2">
|
||||||
|
<AlertCircle className="w-4 h-4 text-yellow-500" />
|
||||||
|
<span className="text-yellow-400 text-sm">
|
||||||
|
{error || 'Claude API not available'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Messages */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||||
|
{messages.length === 0 ? (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<Sparkles className="w-12 h-12 mx-auto mb-4 text-primary-400 opacity-50" />
|
||||||
|
<p className="text-dark-300 mb-2">Ask me anything about your optimization</p>
|
||||||
|
<p className="text-dark-500 text-sm mb-6">
|
||||||
|
I can analyze results, explain concepts, and help you improve your designs.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Suggestions */}
|
||||||
|
{suggestions.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-2 justify-center max-w-md mx-auto">
|
||||||
|
{suggestions.slice(0, 6).map((suggestion, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
onClick={() => sendMessage(suggestion)}
|
||||||
|
disabled={isLoading || apiAvailable === false}
|
||||||
|
className="px-3 py-1.5 bg-dark-700 hover:bg-dark-600 disabled:opacity-50
|
||||||
|
rounded-lg text-sm text-dark-300 hover:text-white transition-colors
|
||||||
|
border border-dark-600 hover:border-dark-500"
|
||||||
|
>
|
||||||
|
{suggestion}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
messages.map((msg) => (
|
||||||
|
<div
|
||||||
|
key={msg.id}
|
||||||
|
className={`flex gap-3 ${msg.role === 'user' ? 'justify-end' : ''}`}
|
||||||
|
>
|
||||||
|
{msg.role === 'assistant' && (
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-primary-500 to-primary-700 flex items-center justify-center flex-shrink-0">
|
||||||
|
<Bot className="w-4 h-4 text-white" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className={`max-w-[85%] rounded-lg ${
|
||||||
|
msg.role === 'user'
|
||||||
|
? 'bg-primary-600 text-white px-4 py-2'
|
||||||
|
: 'bg-dark-700 text-dark-200 px-4 py-3'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{msg.role === 'assistant' ? (
|
||||||
|
<>
|
||||||
|
<div className="prose prose-sm prose-invert max-w-none">
|
||||||
|
<ReactMarkdown
|
||||||
|
remarkPlugins={[remarkGfm]}
|
||||||
|
components={{
|
||||||
|
// Simplified markdown styling for chat
|
||||||
|
p: ({ children }) => <p className="mb-2 last:mb-0">{children}</p>,
|
||||||
|
ul: ({ children }) => <ul className="list-disc list-inside mb-2">{children}</ul>,
|
||||||
|
ol: ({ children }) => <ol className="list-decimal list-inside mb-2">{children}</ol>,
|
||||||
|
li: ({ children }) => <li className="mb-1">{children}</li>,
|
||||||
|
code: ({ inline, children }: any) =>
|
||||||
|
inline ? (
|
||||||
|
<code className="px-1 py-0.5 bg-dark-600 rounded text-primary-400 text-xs">
|
||||||
|
{children}
|
||||||
|
</code>
|
||||||
|
) : (
|
||||||
|
<pre className="p-2 bg-dark-800 rounded overflow-x-auto text-xs my-2">
|
||||||
|
<code>{children}</code>
|
||||||
|
</pre>
|
||||||
|
),
|
||||||
|
table: ({ children }) => (
|
||||||
|
<div className="overflow-x-auto my-2">
|
||||||
|
<table className="text-xs border-collapse">{children}</table>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
th: ({ children }) => (
|
||||||
|
<th className="border border-dark-600 px-2 py-1 bg-dark-800 text-left">
|
||||||
|
{children}
|
||||||
|
</th>
|
||||||
|
),
|
||||||
|
td: ({ children }) => (
|
||||||
|
<td className="border border-dark-600 px-2 py-1">{children}</td>
|
||||||
|
),
|
||||||
|
strong: ({ children }) => <strong className="text-white">{children}</strong>,
|
||||||
|
h1: ({ children }) => <h1 className="text-lg font-bold text-white mt-4 mb-2">{children}</h1>,
|
||||||
|
h2: ({ children }) => <h2 className="text-base font-semibold text-white mt-3 mb-2">{children}</h2>,
|
||||||
|
h3: ({ children }) => <h3 className="text-sm font-semibold text-white mt-2 mb-1">{children}</h3>,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{msg.content}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
{renderToolCalls(msg.toolCalls, msg.id)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p>{msg.content}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{msg.role === 'user' && (
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-dark-600 flex items-center justify-center flex-shrink-0">
|
||||||
|
<User className="w-4 h-4 text-dark-300" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Loading indicator */}
|
||||||
|
{isLoading && (
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-primary-500 to-primary-700 flex items-center justify-center">
|
||||||
|
<Loader2 className="w-4 h-4 text-white animate-spin" />
|
||||||
|
</div>
|
||||||
|
<div className="bg-dark-700 rounded-lg px-4 py-3 text-dark-400">
|
||||||
|
<span className="inline-flex items-center gap-2">
|
||||||
|
<span>Thinking</span>
|
||||||
|
<span className="flex gap-1">
|
||||||
|
<span className="w-1.5 h-1.5 bg-dark-400 rounded-full animate-bounce" style={{ animationDelay: '0ms' }} />
|
||||||
|
<span className="w-1.5 h-1.5 bg-dark-400 rounded-full animate-bounce" style={{ animationDelay: '150ms' }} />
|
||||||
|
<span className="w-1.5 h-1.5 bg-dark-400 rounded-full animate-bounce" style={{ animationDelay: '300ms' }} />
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error message */}
|
||||||
|
{error && !isLoading && (
|
||||||
|
<div className="flex items-center gap-2 px-4 py-2 bg-red-900/20 border border-red-800/30 rounded-lg">
|
||||||
|
<AlertCircle className="w-4 h-4 text-red-400" />
|
||||||
|
<span className="text-red-400 text-sm">{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div ref={messagesEndRef} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Input */}
|
||||||
|
<div className="p-4 border-t border-dark-600 bg-dark-800">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
sendMessage();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder={apiAvailable === false ? 'API not available...' : 'Ask about your optimization...'}
|
||||||
|
disabled={isLoading || apiAvailable === false}
|
||||||
|
className="flex-1 px-4 py-2.5 bg-dark-700 border border-dark-600 rounded-lg
|
||||||
|
text-white placeholder-dark-400 focus:outline-none focus:border-primary-500
|
||||||
|
disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => sendMessage()}
|
||||||
|
disabled={!input.trim() || isLoading || apiAvailable === false}
|
||||||
|
className="px-4 py-2.5 bg-primary-600 hover:bg-primary-500 disabled:opacity-50
|
||||||
|
disabled:cursor-not-allowed text-white rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<Loader2 className="w-5 h-5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Send className="w-5 h-5" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-dark-500 mt-2 text-center">
|
||||||
|
Claude can query your study data, analyze results, and help improve your optimization.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ClaudeChat;
|
||||||
336
atomizer-dashboard/frontend/src/components/ClaudeTerminal.tsx
Normal file
336
atomizer-dashboard/frontend/src/components/ClaudeTerminal.tsx
Normal file
@@ -0,0 +1,336 @@
|
|||||||
|
import React, { useEffect, useRef, useState, useCallback } from 'react';
|
||||||
|
import { Terminal } from 'xterm';
|
||||||
|
import { FitAddon } from '@xterm/addon-fit';
|
||||||
|
import { WebLinksAddon } from '@xterm/addon-web-links';
|
||||||
|
import 'xterm/css/xterm.css';
|
||||||
|
import {
|
||||||
|
Terminal as TerminalIcon,
|
||||||
|
Maximize2,
|
||||||
|
Minimize2,
|
||||||
|
X,
|
||||||
|
RefreshCw,
|
||||||
|
AlertCircle
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useStudy } from '../context/StudyContext';
|
||||||
|
|
||||||
|
interface ClaudeTerminalProps {
|
||||||
|
isExpanded?: boolean;
|
||||||
|
onToggleExpand?: () => void;
|
||||||
|
onClose?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ClaudeTerminal: React.FC<ClaudeTerminalProps> = ({
|
||||||
|
isExpanded = false,
|
||||||
|
onToggleExpand,
|
||||||
|
onClose
|
||||||
|
}) => {
|
||||||
|
const { selectedStudy } = useStudy();
|
||||||
|
const terminalRef = useRef<HTMLDivElement>(null);
|
||||||
|
const xtermRef = useRef<Terminal | null>(null);
|
||||||
|
const fitAddonRef = useRef<FitAddon | null>(null);
|
||||||
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
|
const [isConnected, setIsConnected] = useState(false);
|
||||||
|
const [isConnecting, setIsConnecting] = useState(false);
|
||||||
|
const [_error, setError] = useState<string | null>(null);
|
||||||
|
const [cliAvailable, setCliAvailable] = useState<boolean | null>(null);
|
||||||
|
|
||||||
|
// Check CLI availability
|
||||||
|
useEffect(() => {
|
||||||
|
fetch('/api/terminal/status')
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(data => {
|
||||||
|
setCliAvailable(data.available);
|
||||||
|
if (!data.available) {
|
||||||
|
setError(data.message);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setCliAvailable(false);
|
||||||
|
setError('Could not check Claude Code CLI status');
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Initialize terminal
|
||||||
|
useEffect(() => {
|
||||||
|
if (!terminalRef.current || xtermRef.current) return;
|
||||||
|
|
||||||
|
const term = new Terminal({
|
||||||
|
cursorBlink: true,
|
||||||
|
fontSize: 13,
|
||||||
|
fontFamily: '"JetBrains Mono", "Fira Code", Consolas, monospace',
|
||||||
|
theme: {
|
||||||
|
background: '#0f172a',
|
||||||
|
foreground: '#e2e8f0',
|
||||||
|
cursor: '#60a5fa',
|
||||||
|
cursorAccent: '#0f172a',
|
||||||
|
selectionBackground: '#334155',
|
||||||
|
black: '#1e293b',
|
||||||
|
red: '#ef4444',
|
||||||
|
green: '#22c55e',
|
||||||
|
yellow: '#eab308',
|
||||||
|
blue: '#3b82f6',
|
||||||
|
magenta: '#a855f7',
|
||||||
|
cyan: '#06b6d4',
|
||||||
|
white: '#f1f5f9',
|
||||||
|
brightBlack: '#475569',
|
||||||
|
brightRed: '#f87171',
|
||||||
|
brightGreen: '#4ade80',
|
||||||
|
brightYellow: '#facc15',
|
||||||
|
brightBlue: '#60a5fa',
|
||||||
|
brightMagenta: '#c084fc',
|
||||||
|
brightCyan: '#22d3ee',
|
||||||
|
brightWhite: '#f8fafc',
|
||||||
|
},
|
||||||
|
allowProposedApi: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const fitAddon = new FitAddon();
|
||||||
|
const webLinksAddon = new WebLinksAddon();
|
||||||
|
|
||||||
|
term.loadAddon(fitAddon);
|
||||||
|
term.loadAddon(webLinksAddon);
|
||||||
|
term.open(terminalRef.current);
|
||||||
|
|
||||||
|
// Initial fit
|
||||||
|
setTimeout(() => fitAddon.fit(), 0);
|
||||||
|
|
||||||
|
xtermRef.current = term;
|
||||||
|
fitAddonRef.current = fitAddon;
|
||||||
|
|
||||||
|
// Welcome message
|
||||||
|
term.writeln('\x1b[1;36m╔══════════════════════════════════════════════════════════╗\x1b[0m');
|
||||||
|
term.writeln('\x1b[1;36m║\x1b[0m \x1b[1;37mClaude Code Terminal\x1b[0m \x1b[1;36m║\x1b[0m');
|
||||||
|
term.writeln('\x1b[1;36m║\x1b[0m \x1b[90mFull Claude Code experience in the Atomizer dashboard\x1b[0m \x1b[1;36m║\x1b[0m');
|
||||||
|
term.writeln('\x1b[1;36m╚══════════════════════════════════════════════════════════╝\x1b[0m');
|
||||||
|
term.writeln('');
|
||||||
|
|
||||||
|
if (cliAvailable === false) {
|
||||||
|
term.writeln('\x1b[1;31mError:\x1b[0m Claude Code CLI not found.');
|
||||||
|
term.writeln('Install with: \x1b[1;33mnpm install -g @anthropic-ai/claude-code\x1b[0m');
|
||||||
|
} else {
|
||||||
|
term.writeln('\x1b[90mClick "Connect" to start a Claude Code session.\x1b[0m');
|
||||||
|
term.writeln('\x1b[90mClaude will have access to CLAUDE.md and .claude/ skills.\x1b[0m');
|
||||||
|
}
|
||||||
|
term.writeln('');
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
term.dispose();
|
||||||
|
xtermRef.current = null;
|
||||||
|
};
|
||||||
|
}, [cliAvailable]);
|
||||||
|
|
||||||
|
// Handle resize
|
||||||
|
useEffect(() => {
|
||||||
|
const handleResize = () => {
|
||||||
|
if (fitAddonRef.current) {
|
||||||
|
fitAddonRef.current.fit();
|
||||||
|
// Send resize to backend
|
||||||
|
if (wsRef.current?.readyState === WebSocket.OPEN && xtermRef.current) {
|
||||||
|
wsRef.current.send(JSON.stringify({
|
||||||
|
type: 'resize',
|
||||||
|
cols: xtermRef.current.cols,
|
||||||
|
rows: xtermRef.current.rows
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('resize', handleResize);
|
||||||
|
// Also fit when expanded state changes
|
||||||
|
setTimeout(handleResize, 100);
|
||||||
|
|
||||||
|
return () => window.removeEventListener('resize', handleResize);
|
||||||
|
}, [isExpanded]);
|
||||||
|
|
||||||
|
// Connect to terminal WebSocket
|
||||||
|
const connect = useCallback(() => {
|
||||||
|
if (!xtermRef.current || wsRef.current?.readyState === WebSocket.OPEN) return;
|
||||||
|
|
||||||
|
setIsConnecting(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
// Determine working directory - use study path if available
|
||||||
|
let workingDir = '';
|
||||||
|
if (selectedStudy?.id) {
|
||||||
|
// The study directory path
|
||||||
|
workingDir = `?working_dir=C:/Users/Antoine/Atomizer`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
const ws = new WebSocket(`${protocol}//${window.location.host}/api/terminal/claude${workingDir}`);
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
setIsConnected(true);
|
||||||
|
setIsConnecting(false);
|
||||||
|
xtermRef.current?.clear();
|
||||||
|
xtermRef.current?.writeln('\x1b[1;32mConnected to Claude Code\x1b[0m');
|
||||||
|
xtermRef.current?.writeln('');
|
||||||
|
|
||||||
|
// Send initial resize
|
||||||
|
if (xtermRef.current) {
|
||||||
|
ws.send(JSON.stringify({
|
||||||
|
type: 'resize',
|
||||||
|
cols: xtermRef.current.cols,
|
||||||
|
rows: xtermRef.current.rows
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const message = JSON.parse(event.data);
|
||||||
|
|
||||||
|
switch (message.type) {
|
||||||
|
case 'output':
|
||||||
|
xtermRef.current?.write(message.data);
|
||||||
|
break;
|
||||||
|
case 'started':
|
||||||
|
xtermRef.current?.writeln(`\x1b[90m${message.message}\x1b[0m`);
|
||||||
|
break;
|
||||||
|
case 'exit':
|
||||||
|
xtermRef.current?.writeln('');
|
||||||
|
xtermRef.current?.writeln(`\x1b[33mClaude Code exited with code ${message.code}\x1b[0m`);
|
||||||
|
setIsConnected(false);
|
||||||
|
break;
|
||||||
|
case 'error':
|
||||||
|
xtermRef.current?.writeln(`\x1b[1;31mError: ${message.message}\x1b[0m`);
|
||||||
|
setError(message.message);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Raw output
|
||||||
|
xtermRef.current?.write(event.data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = () => {
|
||||||
|
setError('WebSocket connection error');
|
||||||
|
setIsConnecting(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
setIsConnected(false);
|
||||||
|
setIsConnecting(false);
|
||||||
|
xtermRef.current?.writeln('');
|
||||||
|
xtermRef.current?.writeln('\x1b[90mDisconnected from Claude Code\x1b[0m');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle terminal input
|
||||||
|
const disposable = xtermRef.current.onData((data) => {
|
||||||
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(JSON.stringify({ type: 'input', data }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
wsRef.current = ws;
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
disposable.dispose();
|
||||||
|
};
|
||||||
|
}, [selectedStudy]);
|
||||||
|
|
||||||
|
// Disconnect
|
||||||
|
const disconnect = useCallback(() => {
|
||||||
|
if (wsRef.current) {
|
||||||
|
wsRef.current.send(JSON.stringify({ type: 'stop' }));
|
||||||
|
wsRef.current.close();
|
||||||
|
wsRef.current = null;
|
||||||
|
}
|
||||||
|
setIsConnected(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Cleanup on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (wsRef.current) {
|
||||||
|
wsRef.current.close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`flex flex-col bg-dark-800 rounded-xl border border-dark-600 overflow-hidden ${
|
||||||
|
isExpanded ? 'fixed inset-4 z-50' : 'h-full'
|
||||||
|
}`}>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="px-4 py-3 border-b border-dark-600 flex items-center justify-between bg-dark-800">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-8 h-8 bg-gradient-to-br from-primary-500 to-primary-700 rounded-lg flex items-center justify-center">
|
||||||
|
<TerminalIcon className="w-4 h-4 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-white">Claude Code</span>
|
||||||
|
{selectedStudy && (
|
||||||
|
<span className="ml-2 text-xs bg-dark-700 px-2 py-0.5 rounded text-dark-300">
|
||||||
|
{selectedStudy.id}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* Connection status indicator */}
|
||||||
|
<div className={`w-2 h-2 rounded-full ml-2 ${
|
||||||
|
isConnected ? 'bg-green-500' : 'bg-dark-500'
|
||||||
|
}`} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{/* Connect/Disconnect button */}
|
||||||
|
<button
|
||||||
|
onClick={isConnected ? disconnect : connect}
|
||||||
|
disabled={isConnecting || cliAvailable === false}
|
||||||
|
className={`px-3 py-1.5 text-sm rounded-lg transition-colors flex items-center gap-2 ${
|
||||||
|
isConnected
|
||||||
|
? 'bg-red-600/20 text-red-400 hover:bg-red-600/30'
|
||||||
|
: 'bg-green-600/20 text-green-400 hover:bg-green-600/30'
|
||||||
|
} disabled:opacity-50 disabled:cursor-not-allowed`}
|
||||||
|
>
|
||||||
|
{isConnecting ? (
|
||||||
|
<RefreshCw className="w-3 h-3 animate-spin" />
|
||||||
|
) : null}
|
||||||
|
{isConnected ? 'Disconnect' : 'Connect'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{onToggleExpand && (
|
||||||
|
<button
|
||||||
|
onClick={onToggleExpand}
|
||||||
|
className="p-2 text-dark-400 hover:text-white hover:bg-dark-700 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
{isExpanded ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{onClose && (
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-2 text-dark-400 hover:text-white hover:bg-dark-700 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* CLI not available warning */}
|
||||||
|
{cliAvailable === false && (
|
||||||
|
<div className="px-4 py-3 bg-yellow-900/20 border-b border-yellow-800/30 flex items-center gap-2">
|
||||||
|
<AlertCircle className="w-4 h-4 text-yellow-500" />
|
||||||
|
<span className="text-yellow-400 text-sm">
|
||||||
|
Claude Code CLI not found. Install with: npm install -g @anthropic-ai/claude-code
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Terminal */}
|
||||||
|
<div className="flex-1 p-2 bg-[#0f172a]">
|
||||||
|
<div ref={terminalRef} className="h-full" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="px-4 py-2 border-t border-dark-600 bg-dark-800">
|
||||||
|
<p className="text-xs text-dark-500 text-center">
|
||||||
|
Claude Code has access to CLAUDE.md instructions and .claude/ skills for Atomizer optimization
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ClaudeTerminal;
|
||||||
@@ -0,0 +1,355 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Play,
|
||||||
|
CheckCircle,
|
||||||
|
Settings,
|
||||||
|
AlertTriangle,
|
||||||
|
Loader2,
|
||||||
|
ExternalLink,
|
||||||
|
Sliders,
|
||||||
|
Skull
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { apiClient, ProcessStatus } from '../../api/client';
|
||||||
|
import { useStudy } from '../../context/StudyContext';
|
||||||
|
|
||||||
|
interface ControlPanelProps {
|
||||||
|
onStatusChange?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ControlPanel: React.FC<ControlPanelProps> = ({ onStatusChange }) => {
|
||||||
|
const { selectedStudy, refreshStudies } = useStudy();
|
||||||
|
const [processStatus, setProcessStatus] = useState<ProcessStatus | null>(null);
|
||||||
|
const [actionInProgress, setActionInProgress] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [showSettings, setShowSettings] = useState(false);
|
||||||
|
|
||||||
|
// Settings for starting optimization
|
||||||
|
const [settings, setSettings] = useState({
|
||||||
|
freshStart: false,
|
||||||
|
maxIterations: 100,
|
||||||
|
feaBatchSize: 5,
|
||||||
|
tuneTrials: 30,
|
||||||
|
ensembleSize: 3,
|
||||||
|
patience: 5,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Validate top N
|
||||||
|
const [validateTopN, setValidateTopN] = useState(5);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedStudy) {
|
||||||
|
fetchProcessStatus();
|
||||||
|
const interval = setInterval(fetchProcessStatus, 5000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}
|
||||||
|
}, [selectedStudy]);
|
||||||
|
|
||||||
|
const fetchProcessStatus = async () => {
|
||||||
|
if (!selectedStudy) return;
|
||||||
|
try {
|
||||||
|
const status = await apiClient.getProcessStatus(selectedStudy.id);
|
||||||
|
setProcessStatus(status);
|
||||||
|
} catch (err) {
|
||||||
|
// Process status endpoint might not exist yet
|
||||||
|
setProcessStatus(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStart = async () => {
|
||||||
|
if (!selectedStudy) return;
|
||||||
|
setActionInProgress('start');
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await apiClient.startOptimization(selectedStudy.id, {
|
||||||
|
freshStart: settings.freshStart,
|
||||||
|
maxIterations: settings.maxIterations,
|
||||||
|
feaBatchSize: settings.feaBatchSize,
|
||||||
|
tuneTrials: settings.tuneTrials,
|
||||||
|
ensembleSize: settings.ensembleSize,
|
||||||
|
patience: settings.patience,
|
||||||
|
});
|
||||||
|
|
||||||
|
await fetchProcessStatus();
|
||||||
|
await refreshStudies();
|
||||||
|
onStatusChange?.();
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Failed to start optimization');
|
||||||
|
} finally {
|
||||||
|
setActionInProgress(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStop = async () => {
|
||||||
|
if (!selectedStudy) return;
|
||||||
|
setActionInProgress('stop');
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await apiClient.stopOptimization(selectedStudy.id);
|
||||||
|
await fetchProcessStatus();
|
||||||
|
await refreshStudies();
|
||||||
|
onStatusChange?.();
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Failed to stop optimization');
|
||||||
|
} finally {
|
||||||
|
setActionInProgress(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleValidate = async () => {
|
||||||
|
if (!selectedStudy) return;
|
||||||
|
setActionInProgress('validate');
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await apiClient.validateOptimization(selectedStudy.id, { topN: validateTopN });
|
||||||
|
await fetchProcessStatus();
|
||||||
|
await refreshStudies();
|
||||||
|
onStatusChange?.();
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Failed to start validation');
|
||||||
|
} finally {
|
||||||
|
setActionInProgress(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLaunchOptuna = async () => {
|
||||||
|
if (!selectedStudy) return;
|
||||||
|
setActionInProgress('optuna');
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await apiClient.launchOptunaDashboard(selectedStudy.id);
|
||||||
|
window.open(result.url, '_blank');
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Failed to launch Optuna dashboard');
|
||||||
|
} finally {
|
||||||
|
setActionInProgress(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isRunning = processStatus?.is_running || selectedStudy?.status === 'running';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-dark-800 rounded-xl border border-dark-600 overflow-hidden">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="px-6 py-4 border-b border-dark-600 flex items-center justify-between">
|
||||||
|
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
|
||||||
|
<Sliders className="w-5 h-5 text-primary-400" />
|
||||||
|
Optimization Control
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowSettings(!showSettings)}
|
||||||
|
className={`p-2 rounded-lg transition-colors ${
|
||||||
|
showSettings ? 'bg-primary-600 text-white' : 'bg-dark-700 text-dark-300 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Settings className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status */}
|
||||||
|
<div className="px-6 py-4 border-b border-dark-700">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-dark-400 mb-1">Status</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{isRunning ? (
|
||||||
|
<>
|
||||||
|
<div className="w-3 h-3 bg-green-500 rounded-full animate-pulse" />
|
||||||
|
<span className="text-green-400 font-medium">Running</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="w-3 h-3 bg-dark-500 rounded-full" />
|
||||||
|
<span className="text-dark-400">Stopped</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{processStatus && (
|
||||||
|
<div className="text-right">
|
||||||
|
{processStatus.iteration && (
|
||||||
|
<div className="text-sm text-dark-400">
|
||||||
|
Iteration: <span className="text-white">{processStatus.iteration}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{processStatus.fea_count && (
|
||||||
|
<div className="text-sm text-dark-400">
|
||||||
|
FEA: <span className="text-primary-400">{processStatus.fea_count}</span>
|
||||||
|
{processStatus.nn_count && (
|
||||||
|
<> | NN: <span className="text-orange-400">{processStatus.nn_count}</span></>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Settings Panel */}
|
||||||
|
{showSettings && (
|
||||||
|
<div className="px-6 py-4 border-b border-dark-700 bg-dark-750">
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-dark-400 mb-1">Max Iterations</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={settings.maxIterations}
|
||||||
|
onChange={(e) => setSettings({ ...settings, maxIterations: parseInt(e.target.value) || 100 })}
|
||||||
|
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-white text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-dark-400 mb-1">FEA Batch Size</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={settings.feaBatchSize}
|
||||||
|
onChange={(e) => setSettings({ ...settings, feaBatchSize: parseInt(e.target.value) || 5 })}
|
||||||
|
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-white text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-dark-400 mb-1">Patience</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={settings.patience}
|
||||||
|
onChange={(e) => setSettings({ ...settings, patience: parseInt(e.target.value) || 5 })}
|
||||||
|
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-white text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-dark-400 mb-1">Tuning Trials</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={settings.tuneTrials}
|
||||||
|
onChange={(e) => setSettings({ ...settings, tuneTrials: parseInt(e.target.value) || 30 })}
|
||||||
|
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-white text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-dark-400 mb-1">Ensemble Size</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={settings.ensembleSize}
|
||||||
|
onChange={(e) => setSettings({ ...settings, ensembleSize: parseInt(e.target.value) || 3 })}
|
||||||
|
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-white text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-end">
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={settings.freshStart}
|
||||||
|
onChange={(e) => setSettings({ ...settings, freshStart: e.target.checked })}
|
||||||
|
className="w-4 h-4 rounded border-dark-600 bg-dark-700 text-primary-600"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-dark-300">Fresh Start</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error Message */}
|
||||||
|
{error && (
|
||||||
|
<div className="px-6 py-3 bg-red-900/20 border-b border-red-800/30">
|
||||||
|
<div className="flex items-center gap-2 text-red-400 text-sm">
|
||||||
|
<AlertTriangle className="w-4 h-4" />
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{/* Start / Kill Button */}
|
||||||
|
{isRunning ? (
|
||||||
|
<button
|
||||||
|
onClick={handleStop}
|
||||||
|
disabled={actionInProgress !== null}
|
||||||
|
className="flex items-center justify-center gap-2 px-4 py-3 bg-red-600 hover:bg-red-500
|
||||||
|
disabled:opacity-50 disabled:cursor-not-allowed
|
||||||
|
text-white rounded-lg transition-colors font-medium"
|
||||||
|
title="Force kill the optimization process and all child processes"
|
||||||
|
>
|
||||||
|
{actionInProgress === 'stop' ? (
|
||||||
|
<Loader2 className="w-5 h-5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Skull className="w-5 h-5" />
|
||||||
|
)}
|
||||||
|
Kill Process
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={handleStart}
|
||||||
|
disabled={actionInProgress !== null}
|
||||||
|
className="flex items-center justify-center gap-2 px-4 py-3 bg-green-600 hover:bg-green-500
|
||||||
|
disabled:opacity-50 disabled:cursor-not-allowed
|
||||||
|
text-white rounded-lg transition-colors font-medium"
|
||||||
|
>
|
||||||
|
{actionInProgress === 'start' ? (
|
||||||
|
<Loader2 className="w-5 h-5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Play className="w-5 h-5" />
|
||||||
|
)}
|
||||||
|
Start Optimization
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Validate Button */}
|
||||||
|
<button
|
||||||
|
onClick={handleValidate}
|
||||||
|
disabled={actionInProgress !== null || isRunning}
|
||||||
|
className="flex items-center justify-center gap-2 px-4 py-3 bg-primary-600 hover:bg-primary-500
|
||||||
|
disabled:opacity-50 disabled:cursor-not-allowed
|
||||||
|
text-white rounded-lg transition-colors font-medium"
|
||||||
|
>
|
||||||
|
{actionInProgress === 'validate' ? (
|
||||||
|
<Loader2 className="w-5 h-5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<CheckCircle className="w-5 h-5" />
|
||||||
|
)}
|
||||||
|
Validate Top {validateTopN}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Validation Settings */}
|
||||||
|
<div className="mt-3 flex items-center gap-2">
|
||||||
|
<span className="text-sm text-dark-400">Validate top</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={20}
|
||||||
|
value={validateTopN}
|
||||||
|
onChange={(e) => setValidateTopN(parseInt(e.target.value) || 5)}
|
||||||
|
className="w-16 px-2 py-1 bg-dark-700 border border-dark-600 rounded text-white text-sm text-center"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-dark-400">NN predictions with FEA</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Optuna Dashboard Button */}
|
||||||
|
<button
|
||||||
|
onClick={handleLaunchOptuna}
|
||||||
|
disabled={actionInProgress !== null}
|
||||||
|
className="w-full mt-4 flex items-center justify-center gap-2 px-4 py-2
|
||||||
|
bg-dark-700 hover:bg-dark-600 border border-dark-600
|
||||||
|
disabled:opacity-50 disabled:cursor-not-allowed
|
||||||
|
text-dark-300 hover:text-white rounded-lg transition-colors text-sm"
|
||||||
|
>
|
||||||
|
{actionInProgress === 'optuna' ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<ExternalLink className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
Launch Optuna Dashboard
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ControlPanel;
|
||||||
@@ -1,31 +1,109 @@
|
|||||||
import { NavLink } from 'react-router-dom';
|
import { NavLink, useNavigate } from 'react-router-dom';
|
||||||
import { LayoutDashboard, Settings, FileText, Activity } from 'lucide-react';
|
import {
|
||||||
|
Home,
|
||||||
|
Activity,
|
||||||
|
FileText,
|
||||||
|
BarChart3,
|
||||||
|
ChevronLeft,
|
||||||
|
Play,
|
||||||
|
CheckCircle,
|
||||||
|
Clock,
|
||||||
|
Zap
|
||||||
|
} from 'lucide-react';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
|
import { useStudy } from '../../context/StudyContext';
|
||||||
|
|
||||||
export const Sidebar = () => {
|
export const Sidebar = () => {
|
||||||
const navItems = [
|
const { selectedStudy, clearStudy } = useStudy();
|
||||||
{ to: '/dashboard', icon: Activity, label: 'Live Dashboard' },
|
const navigate = useNavigate();
|
||||||
{ to: '/configurator', icon: Settings, label: 'Configurator' },
|
|
||||||
{ to: '/results', icon: FileText, label: 'Results Viewer' },
|
const handleBackToHome = () => {
|
||||||
];
|
clearStudy();
|
||||||
|
navigate('/');
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusIcon = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'running':
|
||||||
|
return <Play className="w-3 h-3 text-green-400" />;
|
||||||
|
case 'completed':
|
||||||
|
return <CheckCircle className="w-3 h-3 text-blue-400" />;
|
||||||
|
default:
|
||||||
|
return <Clock className="w-3 h-3 text-dark-400" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusColor = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'running':
|
||||||
|
return 'text-green-400';
|
||||||
|
case 'completed':
|
||||||
|
return 'text-blue-400';
|
||||||
|
default:
|
||||||
|
return 'text-dark-400';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Navigation items depend on whether a study is selected
|
||||||
|
const navItems = selectedStudy
|
||||||
|
? [
|
||||||
|
{ to: '/dashboard', icon: Activity, label: 'Live Tracker' },
|
||||||
|
{ to: '/results', icon: FileText, label: 'Reports' },
|
||||||
|
{ to: '/analytics', icon: BarChart3, label: 'Analytics' },
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
{ to: '/', icon: Home, label: 'Select Study' },
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="w-64 bg-dark-800 border-r border-dark-600 flex flex-col h-screen fixed left-0 top-0">
|
<aside className="w-64 bg-dark-800 border-r border-dark-600 flex flex-col h-screen fixed left-0 top-0">
|
||||||
|
{/* Header */}
|
||||||
<div className="p-6 border-b border-dark-600">
|
<div className="p-6 border-b border-dark-600">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-8 h-8 bg-primary-600 rounded-lg flex items-center justify-center">
|
<div className="w-8 h-8 bg-primary-600 rounded-lg flex items-center justify-center">
|
||||||
<LayoutDashboard className="w-5 h-5 text-white" />
|
<Zap className="w-5 h-5 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-xl font-bold text-white tracking-tight">Atomizer</h1>
|
<h1 className="text-xl font-bold text-white tracking-tight">Atomizer</h1>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-dark-300 mt-1 ml-11">Optimization Platform</p>
|
<p className="text-xs text-dark-300 mt-1 ml-11">Optimization Platform</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Selected Study Info */}
|
||||||
|
{selectedStudy && (
|
||||||
|
<div className="p-4 border-b border-dark-600">
|
||||||
|
<button
|
||||||
|
onClick={handleBackToHome}
|
||||||
|
className="flex items-center gap-2 text-sm text-dark-400 hover:text-white
|
||||||
|
transition-colors mb-3 group"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-4 h-4 group-hover:-translate-x-1 transition-transform" />
|
||||||
|
Change Study
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="bg-dark-700 rounded-lg p-3">
|
||||||
|
<div className="text-xs font-medium text-dark-400 uppercase mb-1">Active Study</div>
|
||||||
|
<div className="text-white font-medium truncate">{selectedStudy.name}</div>
|
||||||
|
<div className="flex items-center gap-2 mt-2">
|
||||||
|
<span className={`flex items-center gap-1 text-xs ${getStatusColor(selectedStudy.status)}`}>
|
||||||
|
{getStatusIcon(selectedStudy.status)}
|
||||||
|
{selectedStudy.status}
|
||||||
|
</span>
|
||||||
|
<span className="text-dark-500">|</span>
|
||||||
|
<span className="text-xs text-dark-400">
|
||||||
|
{selectedStudy.progress.current}/{selectedStudy.progress.total}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Navigation */}
|
||||||
<nav className="flex-1 p-4 space-y-1">
|
<nav className="flex-1 p-4 space-y-1">
|
||||||
{navItems.map((item) => (
|
{navItems.map((item) => (
|
||||||
<NavLink
|
<NavLink
|
||||||
key={item.to}
|
key={item.to}
|
||||||
to={item.to}
|
to={item.to}
|
||||||
|
end={item.to === '/'}
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
clsx(
|
clsx(
|
||||||
'flex items-center gap-3 px-4 py-3 rounded-lg transition-colors duration-200',
|
'flex items-center gap-3 px-4 py-3 rounded-lg transition-colors duration-200',
|
||||||
@@ -41,6 +119,7 @@ export const Sidebar = () => {
|
|||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
{/* Footer Status */}
|
||||||
<div className="p-4 border-t border-dark-600">
|
<div className="p-4 border-t border-dark-600">
|
||||||
<div className="bg-dark-700 rounded-lg p-4">
|
<div className="bg-dark-700 rounded-lg p-4">
|
||||||
<div className="text-xs font-medium text-dark-400 uppercase mb-2">System Status</div>
|
<div className="text-xs font-medium text-dark-400 uppercase mb-2">System Status</div>
|
||||||
@@ -48,6 +127,12 @@ export const Sidebar = () => {
|
|||||||
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
|
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
|
||||||
Backend Online
|
Backend Online
|
||||||
</div>
|
</div>
|
||||||
|
{selectedStudy && selectedStudy.status === 'running' && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-green-400 mt-1">
|
||||||
|
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
|
||||||
|
Optimization Running
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
93
atomizer-dashboard/frontend/src/context/StudyContext.tsx
Normal file
93
atomizer-dashboard/frontend/src/context/StudyContext.tsx
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||||
|
import { Study } from '../types';
|
||||||
|
import { apiClient } from '../api/client';
|
||||||
|
|
||||||
|
interface StudyContextType {
|
||||||
|
selectedStudy: Study | null;
|
||||||
|
setSelectedStudy: (study: Study | null) => void;
|
||||||
|
studies: Study[];
|
||||||
|
refreshStudies: () => Promise<void>;
|
||||||
|
isLoading: boolean;
|
||||||
|
clearStudy: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const StudyContext = createContext<StudyContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
export const StudyProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
||||||
|
const [selectedStudy, setSelectedStudyState] = useState<Study | null>(null);
|
||||||
|
const [studies, setStudies] = useState<Study[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
const refreshStudies = async () => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
const response = await apiClient.getStudies();
|
||||||
|
setStudies(response.studies);
|
||||||
|
|
||||||
|
// If we have a selected study, refresh its data
|
||||||
|
if (selectedStudy) {
|
||||||
|
const updated = response.studies.find(s => s.id === selectedStudy.id);
|
||||||
|
if (updated) {
|
||||||
|
setSelectedStudyState(updated);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch studies:', error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setSelectedStudy = (study: Study | null) => {
|
||||||
|
setSelectedStudyState(study);
|
||||||
|
if (study) {
|
||||||
|
localStorage.setItem('selectedStudyId', study.id);
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem('selectedStudyId');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearStudy = () => {
|
||||||
|
setSelectedStudyState(null);
|
||||||
|
localStorage.removeItem('selectedStudyId');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initial load
|
||||||
|
useEffect(() => {
|
||||||
|
const init = async () => {
|
||||||
|
await refreshStudies();
|
||||||
|
|
||||||
|
// Restore last selected study
|
||||||
|
const lastStudyId = localStorage.getItem('selectedStudyId');
|
||||||
|
if (lastStudyId) {
|
||||||
|
const response = await apiClient.getStudies();
|
||||||
|
const study = response.studies.find(s => s.id === lastStudyId);
|
||||||
|
if (study) {
|
||||||
|
setSelectedStudyState(study);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
init();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StudyContext.Provider value={{
|
||||||
|
selectedStudy,
|
||||||
|
setSelectedStudy,
|
||||||
|
studies,
|
||||||
|
refreshStudies,
|
||||||
|
isLoading,
|
||||||
|
clearStudy
|
||||||
|
}}>
|
||||||
|
{children}
|
||||||
|
</StudyContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useStudy = () => {
|
||||||
|
const context = useContext(StudyContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error('useStudy must be used within a StudyProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
@import 'katex/dist/katex.min.css';
|
||||||
|
|
||||||
@tailwind base;
|
@tailwind base;
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
import { useState, useEffect, lazy, Suspense } from 'react';
|
import { useState, useEffect, lazy, Suspense } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
LineChart, Line, ScatterChart, Scatter,
|
LineChart, Line, ScatterChart, Scatter,
|
||||||
XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, Cell
|
XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, Cell
|
||||||
} from 'recharts';
|
} from 'recharts';
|
||||||
|
import { Terminal } from 'lucide-react';
|
||||||
import { useOptimizationWebSocket } from '../hooks/useWebSocket';
|
import { useOptimizationWebSocket } from '../hooks/useWebSocket';
|
||||||
import { apiClient } from '../api/client';
|
import { apiClient } from '../api/client';
|
||||||
|
import { useStudy } from '../context/StudyContext';
|
||||||
import { Card } from '../components/common/Card';
|
import { Card } from '../components/common/Card';
|
||||||
import { MetricCard } from '../components/dashboard/MetricCard';
|
import { MetricCard } from '../components/dashboard/MetricCard';
|
||||||
import { StudyCard } from '../components/dashboard/StudyCard';
|
import { ControlPanel } from '../components/dashboard/ControlPanel';
|
||||||
// import { OptimizerPanel } from '../components/OptimizerPanel'; // Not used currently
|
import { ClaudeTerminal } from '../components/ClaudeTerminal';
|
||||||
import { ParetoPlot } from '../components/ParetoPlot';
|
import { ParetoPlot } from '../components/ParetoPlot';
|
||||||
import { ParallelCoordinatesPlot } from '../components/ParallelCoordinatesPlot';
|
import { ParallelCoordinatesPlot } from '../components/ParallelCoordinatesPlot';
|
||||||
import { ParameterImportanceChart } from '../components/ParameterImportanceChart';
|
import { ParameterImportanceChart } from '../components/ParameterImportanceChart';
|
||||||
@@ -16,7 +19,7 @@ import { ConvergencePlot } from '../components/ConvergencePlot';
|
|||||||
import { StudyReportViewer } from '../components/StudyReportViewer';
|
import { StudyReportViewer } from '../components/StudyReportViewer';
|
||||||
import { ConsoleOutput } from '../components/ConsoleOutput';
|
import { ConsoleOutput } from '../components/ConsoleOutput';
|
||||||
import { ExpandableChart } from '../components/ExpandableChart';
|
import { ExpandableChart } from '../components/ExpandableChart';
|
||||||
import type { Study, Trial, ConvergenceDataPoint, ParameterSpaceDataPoint } from '../types';
|
import type { Trial, ConvergenceDataPoint, ParameterSpaceDataPoint } from '../types';
|
||||||
|
|
||||||
// Lazy load Plotly components for better initial load performance
|
// Lazy load Plotly components for better initial load performance
|
||||||
const PlotlyParallelCoordinates = lazy(() => import('../components/plotly/PlotlyParallelCoordinates').then(m => ({ default: m.PlotlyParallelCoordinates })));
|
const PlotlyParallelCoordinates = lazy(() => import('../components/plotly/PlotlyParallelCoordinates').then(m => ({ default: m.PlotlyParallelCoordinates })));
|
||||||
@@ -32,8 +35,17 @@ const ChartLoading = () => (
|
|||||||
);
|
);
|
||||||
|
|
||||||
export default function Dashboard() {
|
export default function Dashboard() {
|
||||||
const [studies, setStudies] = useState<Study[]>([]);
|
const navigate = useNavigate();
|
||||||
const [selectedStudyId, setSelectedStudyId] = useState<string | null>(null);
|
const { selectedStudy, refreshStudies } = useStudy();
|
||||||
|
const selectedStudyId = selectedStudy?.id || null;
|
||||||
|
|
||||||
|
// Redirect to home if no study selected
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedStudy) {
|
||||||
|
navigate('/');
|
||||||
|
}
|
||||||
|
}, [selectedStudy, navigate]);
|
||||||
|
|
||||||
const [allTrials, setAllTrials] = useState<Trial[]>([]);
|
const [allTrials, setAllTrials] = useState<Trial[]>([]);
|
||||||
const [displayedTrials, setDisplayedTrials] = useState<Trial[]>([]);
|
const [displayedTrials, setDisplayedTrials] = useState<Trial[]>([]);
|
||||||
const [bestValue, setBestValue] = useState<number>(Infinity);
|
const [bestValue, setBestValue] = useState<number>(Infinity);
|
||||||
@@ -55,26 +67,9 @@ export default function Dashboard() {
|
|||||||
// Chart library toggle: 'recharts' (faster) or 'plotly' (more interactive but slower)
|
// Chart library toggle: 'recharts' (faster) or 'plotly' (more interactive but slower)
|
||||||
const [chartLibrary, setChartLibrary] = useState<'plotly' | 'recharts'>('recharts');
|
const [chartLibrary, setChartLibrary] = useState<'plotly' | 'recharts'>('recharts');
|
||||||
|
|
||||||
// Load studies on mount
|
// Claude chat panel state
|
||||||
useEffect(() => {
|
const [chatOpen, setChatOpen] = useState(false);
|
||||||
apiClient.getStudies()
|
const [chatExpanded, setChatExpanded] = useState(false);
|
||||||
.then(data => {
|
|
||||||
setStudies(data.studies);
|
|
||||||
if (data.studies.length > 0) {
|
|
||||||
// Check LocalStorage for last selected study
|
|
||||||
const savedStudyId = localStorage.getItem('lastSelectedStudyId');
|
|
||||||
const studyExists = data.studies.find(s => s.id === savedStudyId);
|
|
||||||
|
|
||||||
if (savedStudyId && studyExists) {
|
|
||||||
setSelectedStudyId(savedStudyId);
|
|
||||||
} else {
|
|
||||||
const running = data.studies.find(s => s.status === 'running');
|
|
||||||
setSelectedStudyId(running?.id || data.studies[0].id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(console.error);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const showAlert = (type: 'success' | 'warning', message: string) => {
|
const showAlert = (type: 'success' | 'warning', message: string) => {
|
||||||
const id = alertIdCounter;
|
const id = alertIdCounter;
|
||||||
@@ -111,9 +106,6 @@ export default function Dashboard() {
|
|||||||
setPrunedCount(0);
|
setPrunedCount(0);
|
||||||
setExpandedTrials(new Set());
|
setExpandedTrials(new Set());
|
||||||
|
|
||||||
// Save to LocalStorage
|
|
||||||
localStorage.setItem('lastSelectedStudyId', selectedStudyId);
|
|
||||||
|
|
||||||
apiClient.getStudyHistory(selectedStudyId)
|
apiClient.getStudyHistory(selectedStudyId)
|
||||||
.then(data => {
|
.then(data => {
|
||||||
const validTrials = data.trials.filter(t => t.objective !== null && t.objective !== undefined);
|
const validTrials = data.trials.filter(t => t.objective !== null && t.objective !== undefined);
|
||||||
@@ -331,6 +323,19 @@ export default function Dashboard() {
|
|||||||
<p className="text-dark-300 mt-1">Real-time optimization monitoring</p>
|
<p className="text-dark-300 mt-1">Real-time optimization monitoring</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
|
{/* Claude Code Terminal Toggle Button */}
|
||||||
|
<button
|
||||||
|
onClick={() => setChatOpen(!chatOpen)}
|
||||||
|
className={`flex items-center gap-2 px-4 py-2 rounded-lg transition-colors ${
|
||||||
|
chatOpen
|
||||||
|
? 'bg-primary-600 text-white'
|
||||||
|
: 'bg-dark-700 text-dark-200 hover:bg-dark-600 hover:text-white border border-dark-600'
|
||||||
|
}`}
|
||||||
|
title="Open Claude Code terminal"
|
||||||
|
>
|
||||||
|
<Terminal className="w-4 h-4" />
|
||||||
|
<span className="hidden sm:inline">Claude Code</span>
|
||||||
|
</button>
|
||||||
{selectedStudyId && (
|
{selectedStudyId && (
|
||||||
<StudyReportViewer studyId={selectedStudyId} />
|
<StudyReportViewer studyId={selectedStudyId} />
|
||||||
)}
|
)}
|
||||||
@@ -380,24 +385,13 @@ export default function Dashboard() {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="grid grid-cols-12 gap-6">
|
<div className="grid grid-cols-12 gap-6">
|
||||||
{/* Sidebar - Study List */}
|
{/* Control Panel - Left Sidebar */}
|
||||||
<aside className="col-span-3">
|
<aside className="col-span-3">
|
||||||
<Card title="Active Studies">
|
<ControlPanel onStatusChange={refreshStudies} />
|
||||||
<div className="space-y-3 max-h-[calc(100vh-200px)] overflow-y-auto">
|
|
||||||
{studies.map(study => (
|
|
||||||
<StudyCard
|
|
||||||
key={study.id}
|
|
||||||
study={study}
|
|
||||||
isActive={study.id === selectedStudyId}
|
|
||||||
onClick={() => setSelectedStudyId(study.id)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{/* Main Content */}
|
{/* Main Content - shrinks when chat is open */}
|
||||||
<main className="col-span-9">
|
<main className={chatOpen ? 'col-span-5' : 'col-span-9'}>
|
||||||
{/* Study Name Header */}
|
{/* Study Name Header */}
|
||||||
{selectedStudyId && (
|
{selectedStudyId && (
|
||||||
<div className="mb-4 pb-3 border-b border-dark-600">
|
<div className="mb-4 pb-3 border-b border-dark-600">
|
||||||
@@ -884,6 +878,17 @@ export default function Dashboard() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
{/* Claude Code Terminal - Right Sidebar */}
|
||||||
|
{chatOpen && (
|
||||||
|
<aside className="col-span-4 h-[calc(100vh-12rem)] sticky top-24">
|
||||||
|
<ClaudeTerminal
|
||||||
|
isExpanded={chatExpanded}
|
||||||
|
onToggleExpand={() => setChatExpanded(!chatExpanded)}
|
||||||
|
onClose={() => setChatOpen(false)}
|
||||||
|
/>
|
||||||
|
</aside>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
455
atomizer-dashboard/frontend/src/pages/Home.tsx
Normal file
455
atomizer-dashboard/frontend/src/pages/Home.tsx
Normal file
@@ -0,0 +1,455 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
FolderOpen,
|
||||||
|
Play,
|
||||||
|
CheckCircle,
|
||||||
|
Clock,
|
||||||
|
AlertCircle,
|
||||||
|
ArrowRight,
|
||||||
|
RefreshCw,
|
||||||
|
Zap,
|
||||||
|
FileText,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
|
Target,
|
||||||
|
Activity
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useStudy } from '../context/StudyContext';
|
||||||
|
import { Study } from '../types';
|
||||||
|
import ReactMarkdown from 'react-markdown';
|
||||||
|
import remarkGfm from 'remark-gfm';
|
||||||
|
import remarkMath from 'remark-math';
|
||||||
|
import rehypeKatex from 'rehype-katex';
|
||||||
|
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||||
|
import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
||||||
|
import { apiClient } from '../api/client';
|
||||||
|
|
||||||
|
const Home: React.FC = () => {
|
||||||
|
const { studies, setSelectedStudy, refreshStudies, isLoading } = useStudy();
|
||||||
|
const [selectedPreview, setSelectedPreview] = useState<Study | null>(null);
|
||||||
|
const [readme, setReadme] = useState<string>('');
|
||||||
|
const [readmeLoading, setReadmeLoading] = useState(false);
|
||||||
|
const [showAllStudies, setShowAllStudies] = useState(false);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
// Load README when a study is selected for preview
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedPreview) {
|
||||||
|
loadReadme(selectedPreview.id);
|
||||||
|
} else {
|
||||||
|
setReadme('');
|
||||||
|
}
|
||||||
|
}, [selectedPreview]);
|
||||||
|
|
||||||
|
const loadReadme = async (studyId: string) => {
|
||||||
|
setReadmeLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await apiClient.getStudyReadme(studyId);
|
||||||
|
setReadme(response.content || 'No README found for this study.');
|
||||||
|
} catch (error) {
|
||||||
|
setReadme('No README found for this study.');
|
||||||
|
} finally {
|
||||||
|
setReadmeLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectStudy = (study: Study) => {
|
||||||
|
setSelectedStudy(study);
|
||||||
|
navigate('/dashboard');
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusIcon = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'running':
|
||||||
|
return <Play className="w-3.5 h-3.5" />;
|
||||||
|
case 'completed':
|
||||||
|
return <CheckCircle className="w-3.5 h-3.5" />;
|
||||||
|
case 'not_started':
|
||||||
|
return <Clock className="w-3.5 h-3.5" />;
|
||||||
|
default:
|
||||||
|
return <AlertCircle className="w-3.5 h-3.5" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusStyles = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'running':
|
||||||
|
return {
|
||||||
|
badge: 'bg-green-500/20 text-green-400 border-green-500/30',
|
||||||
|
card: 'border-green-500/30 hover:border-green-500/50',
|
||||||
|
glow: 'shadow-green-500/10'
|
||||||
|
};
|
||||||
|
case 'completed':
|
||||||
|
return {
|
||||||
|
badge: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
|
||||||
|
card: 'border-blue-500/30 hover:border-blue-500/50',
|
||||||
|
glow: 'shadow-blue-500/10'
|
||||||
|
};
|
||||||
|
case 'not_started':
|
||||||
|
return {
|
||||||
|
badge: 'bg-dark-600 text-dark-400 border-dark-500',
|
||||||
|
card: 'border-dark-600 hover:border-dark-500',
|
||||||
|
glow: ''
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
badge: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30',
|
||||||
|
card: 'border-yellow-500/30 hover:border-yellow-500/50',
|
||||||
|
glow: 'shadow-yellow-500/10'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Sort studies: running first, then by trial count
|
||||||
|
const sortedStudies = [...studies].sort((a, b) => {
|
||||||
|
if (a.status === 'running' && b.status !== 'running') return -1;
|
||||||
|
if (b.status === 'running' && a.status !== 'running') return 1;
|
||||||
|
return b.progress.current - a.progress.current;
|
||||||
|
});
|
||||||
|
|
||||||
|
const displayedStudies = showAllStudies ? sortedStudies : sortedStudies.slice(0, 6);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-dark-900">
|
||||||
|
{/* Header */}
|
||||||
|
<header className="bg-dark-800/50 border-b border-dark-700 backdrop-blur-sm sticky top-0 z-10">
|
||||||
|
<div className="max-w-[1600px] mx-auto px-6 py-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-11 h-11 bg-gradient-to-br from-primary-500 to-primary-700 rounded-xl flex items-center justify-center shadow-lg shadow-primary-500/20">
|
||||||
|
<Zap className="w-6 h-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-white">Atomizer</h1>
|
||||||
|
<p className="text-dark-400 text-sm">FEA Optimization Platform</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => refreshStudies()}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-dark-700 hover:bg-dark-600
|
||||||
|
text-white rounded-lg transition-all disabled:opacity-50 border border-dark-600"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="max-w-[1600px] mx-auto px-6 py-8">
|
||||||
|
{/* Study Selection Section */}
|
||||||
|
<section className="mb-8">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
|
||||||
|
<FolderOpen className="w-5 h-5 text-primary-400" />
|
||||||
|
Select a Study
|
||||||
|
</h2>
|
||||||
|
{studies.length > 6 && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAllStudies(!showAllStudies)}
|
||||||
|
className="text-sm text-primary-400 hover:text-primary-300 flex items-center gap-1"
|
||||||
|
>
|
||||||
|
{showAllStudies ? (
|
||||||
|
<>Show Less <ChevronUp className="w-4 h-4" /></>
|
||||||
|
) : (
|
||||||
|
<>Show All ({studies.length}) <ChevronDown className="w-4 h-4" /></>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-12 text-dark-400">
|
||||||
|
<RefreshCw className="w-6 h-6 animate-spin mr-3" />
|
||||||
|
Loading studies...
|
||||||
|
</div>
|
||||||
|
) : studies.length === 0 ? (
|
||||||
|
<div className="text-center py-12 text-dark-400">
|
||||||
|
<FolderOpen className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
||||||
|
<p>No studies found. Create a new study to get started.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{displayedStudies.map((study) => {
|
||||||
|
const styles = getStatusStyles(study.status);
|
||||||
|
const isSelected = selectedPreview?.id === study.id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={study.id}
|
||||||
|
onClick={() => setSelectedPreview(study)}
|
||||||
|
className={`
|
||||||
|
relative p-4 rounded-xl border cursor-pointer transition-all duration-200
|
||||||
|
bg-dark-800 hover:bg-dark-750
|
||||||
|
${styles.card} ${styles.glow}
|
||||||
|
${isSelected ? 'ring-2 ring-primary-500 border-primary-500' : ''}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{/* Status Badge */}
|
||||||
|
<div className="flex items-start justify-between mb-3">
|
||||||
|
<div className="flex-1 min-w-0 pr-2">
|
||||||
|
<h3 className="text-white font-medium truncate">{study.name || study.id}</h3>
|
||||||
|
<p className="text-dark-500 text-xs truncate mt-0.5">{study.id}</p>
|
||||||
|
</div>
|
||||||
|
<span className={`flex items-center gap-1.5 px-2 py-1 text-xs font-medium rounded-full border ${styles.badge}`}>
|
||||||
|
{getStatusIcon(study.status)}
|
||||||
|
{study.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="flex items-center gap-4 text-sm mb-3">
|
||||||
|
<div className="flex items-center gap-1.5 text-dark-400">
|
||||||
|
<Activity className="w-3.5 h-3.5" />
|
||||||
|
<span>{study.progress.current} trials</span>
|
||||||
|
</div>
|
||||||
|
{study.best_value !== null && (
|
||||||
|
<div className="flex items-center gap-1.5 text-primary-400">
|
||||||
|
<Target className="w-3.5 h-3.5" />
|
||||||
|
<span>{study.best_value.toFixed(4)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress Bar */}
|
||||||
|
<div className="h-1.5 bg-dark-700 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={`h-full rounded-full transition-all duration-500 ${
|
||||||
|
study.status === 'running' ? 'bg-green-500' :
|
||||||
|
study.status === 'completed' ? 'bg-blue-500' : 'bg-primary-500'
|
||||||
|
}`}
|
||||||
|
style={{ width: `${Math.min((study.progress.current / study.progress.total) * 100, 100)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Selected Indicator */}
|
||||||
|
{isSelected && (
|
||||||
|
<div className="absolute -bottom-px left-1/2 -translate-x-1/2 w-12 h-1 bg-primary-500 rounded-t-full" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Study Documentation Section */}
|
||||||
|
{selectedPreview && (
|
||||||
|
<section className="animate-in fade-in slide-in-from-bottom-4 duration-300">
|
||||||
|
{/* Documentation Header */}
|
||||||
|
<div className="bg-dark-800 rounded-t-xl border border-dark-600 border-b-0">
|
||||||
|
<div className="px-6 py-4 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-10 h-10 bg-dark-700 rounded-lg flex items-center justify-center">
|
||||||
|
<FileText className="w-5 h-5 text-primary-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold text-white">{selectedPreview.name || selectedPreview.id}</h2>
|
||||||
|
<p className="text-dark-400 text-sm">Study Documentation</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => handleSelectStudy(selectedPreview)}
|
||||||
|
className="flex items-center gap-2 px-5 py-2.5 bg-primary-600 hover:bg-primary-500
|
||||||
|
text-white rounded-lg transition-all font-medium shadow-lg shadow-primary-500/20
|
||||||
|
hover:shadow-primary-500/30"
|
||||||
|
>
|
||||||
|
Open Dashboard
|
||||||
|
<ArrowRight className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* README Content */}
|
||||||
|
<div className="bg-dark-850 rounded-b-xl border border-dark-600 border-t-0 overflow-hidden">
|
||||||
|
{readmeLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-16 text-dark-400">
|
||||||
|
<RefreshCw className="w-6 h-6 animate-spin mr-3" />
|
||||||
|
Loading documentation...
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="p-8 overflow-x-auto">
|
||||||
|
<article className="markdown-body max-w-none">
|
||||||
|
<ReactMarkdown
|
||||||
|
remarkPlugins={[remarkGfm, remarkMath]}
|
||||||
|
rehypePlugins={[rehypeKatex]}
|
||||||
|
components={{
|
||||||
|
// Custom heading styles
|
||||||
|
h1: ({ children }) => (
|
||||||
|
<h1 className="text-3xl font-bold text-white mb-6 pb-3 border-b border-dark-600">
|
||||||
|
{children}
|
||||||
|
</h1>
|
||||||
|
),
|
||||||
|
h2: ({ children }) => (
|
||||||
|
<h2 className="text-2xl font-semibold text-white mt-10 mb-4 pb-2 border-b border-dark-700">
|
||||||
|
{children}
|
||||||
|
</h2>
|
||||||
|
),
|
||||||
|
h3: ({ children }) => (
|
||||||
|
<h3 className="text-xl font-semibold text-white mt-8 mb-3">
|
||||||
|
{children}
|
||||||
|
</h3>
|
||||||
|
),
|
||||||
|
h4: ({ children }) => (
|
||||||
|
<h4 className="text-lg font-medium text-white mt-6 mb-2">
|
||||||
|
{children}
|
||||||
|
</h4>
|
||||||
|
),
|
||||||
|
// Paragraphs
|
||||||
|
p: ({ children }) => (
|
||||||
|
<p className="text-dark-300 leading-relaxed mb-4">
|
||||||
|
{children}
|
||||||
|
</p>
|
||||||
|
),
|
||||||
|
// Strong/Bold
|
||||||
|
strong: ({ children }) => (
|
||||||
|
<strong className="text-white font-semibold">{children}</strong>
|
||||||
|
),
|
||||||
|
// Links
|
||||||
|
a: ({ href, children }) => (
|
||||||
|
<a
|
||||||
|
href={href}
|
||||||
|
className="text-primary-400 hover:text-primary-300 underline underline-offset-2"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
// Lists
|
||||||
|
ul: ({ children }) => (
|
||||||
|
<ul className="list-disc list-inside text-dark-300 mb-4 space-y-1.5 ml-2">
|
||||||
|
{children}
|
||||||
|
</ul>
|
||||||
|
),
|
||||||
|
ol: ({ children }) => (
|
||||||
|
<ol className="list-decimal list-inside text-dark-300 mb-4 space-y-1.5 ml-2">
|
||||||
|
{children}
|
||||||
|
</ol>
|
||||||
|
),
|
||||||
|
li: ({ children }) => (
|
||||||
|
<li className="text-dark-300 leading-relaxed">{children}</li>
|
||||||
|
),
|
||||||
|
// Code blocks with syntax highlighting
|
||||||
|
code: ({ inline, className, children, ...props }: any) => {
|
||||||
|
const match = /language-(\w+)/.exec(className || '');
|
||||||
|
const language = match ? match[1] : '';
|
||||||
|
|
||||||
|
if (!inline && language) {
|
||||||
|
return (
|
||||||
|
<div className="my-4 rounded-lg overflow-hidden border border-dark-600">
|
||||||
|
<div className="bg-dark-700 px-4 py-2 text-xs text-dark-400 font-mono border-b border-dark-600">
|
||||||
|
{language}
|
||||||
|
</div>
|
||||||
|
<SyntaxHighlighter
|
||||||
|
style={oneDark}
|
||||||
|
language={language}
|
||||||
|
PreTag="div"
|
||||||
|
customStyle={{
|
||||||
|
margin: 0,
|
||||||
|
padding: '1rem',
|
||||||
|
background: '#1a1d23',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{String(children).replace(/\n$/, '')}
|
||||||
|
</SyntaxHighlighter>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!inline) {
|
||||||
|
return (
|
||||||
|
<pre className="my-4 p-4 bg-dark-700 rounded-lg border border-dark-600 overflow-x-auto">
|
||||||
|
<code className="text-primary-400 text-sm font-mono">{children}</code>
|
||||||
|
</pre>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<code className="px-1.5 py-0.5 bg-dark-700 text-primary-400 rounded text-sm font-mono">
|
||||||
|
{children}
|
||||||
|
</code>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
// Tables
|
||||||
|
table: ({ children }) => (
|
||||||
|
<div className="my-6 overflow-x-auto rounded-lg border border-dark-600">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
{children}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
thead: ({ children }) => (
|
||||||
|
<thead className="bg-dark-700 text-white">
|
||||||
|
{children}
|
||||||
|
</thead>
|
||||||
|
),
|
||||||
|
tbody: ({ children }) => (
|
||||||
|
<tbody className="divide-y divide-dark-600">
|
||||||
|
{children}
|
||||||
|
</tbody>
|
||||||
|
),
|
||||||
|
tr: ({ children }) => (
|
||||||
|
<tr className="hover:bg-dark-750 transition-colors">
|
||||||
|
{children}
|
||||||
|
</tr>
|
||||||
|
),
|
||||||
|
th: ({ children }) => (
|
||||||
|
<th className="px-4 py-3 text-left font-semibold text-white border-b border-dark-600">
|
||||||
|
{children}
|
||||||
|
</th>
|
||||||
|
),
|
||||||
|
td: ({ children }) => (
|
||||||
|
<td className="px-4 py-3 text-dark-300">
|
||||||
|
{children}
|
||||||
|
</td>
|
||||||
|
),
|
||||||
|
// Blockquotes
|
||||||
|
blockquote: ({ children }) => (
|
||||||
|
<blockquote className="my-4 pl-4 border-l-4 border-primary-500 bg-dark-750 py-3 pr-4 rounded-r-lg">
|
||||||
|
{children}
|
||||||
|
</blockquote>
|
||||||
|
),
|
||||||
|
// Horizontal rules
|
||||||
|
hr: () => (
|
||||||
|
<hr className="my-8 border-dark-600" />
|
||||||
|
),
|
||||||
|
// Images
|
||||||
|
img: ({ src, alt }) => (
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt={alt}
|
||||||
|
className="my-4 rounded-lg max-w-full h-auto border border-dark-600"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{readme}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Empty State when no study selected */}
|
||||||
|
{!selectedPreview && studies.length > 0 && (
|
||||||
|
<section className="flex items-center justify-center py-16 text-dark-400">
|
||||||
|
<div className="text-center">
|
||||||
|
<FileText className="w-16 h-16 mx-auto mb-4 opacity-30" />
|
||||||
|
<p className="text-lg">Select a study to view its documentation</p>
|
||||||
|
<p className="text-sm mt-1 text-dark-500">Click on any study card above</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Home;
|
||||||
@@ -1,150 +1,241 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Card } from '../components/common/Card';
|
import { Card } from '../components/common/Card';
|
||||||
import { Button } from '../components/common/Button';
|
import { Button } from '../components/common/Button';
|
||||||
import { Download, FileText, Image, RefreshCw } from 'lucide-react';
|
import {
|
||||||
|
Download,
|
||||||
|
FileText,
|
||||||
|
RefreshCw,
|
||||||
|
Sparkles,
|
||||||
|
Loader2,
|
||||||
|
AlertTriangle,
|
||||||
|
CheckCircle,
|
||||||
|
Copy
|
||||||
|
} from 'lucide-react';
|
||||||
import { apiClient } from '../api/client';
|
import { apiClient } from '../api/client';
|
||||||
import { Study } from '../types';
|
import { useStudy } from '../context/StudyContext';
|
||||||
|
import ReactMarkdown from 'react-markdown';
|
||||||
|
|
||||||
export default function Results() {
|
export default function Results() {
|
||||||
const [studies, setStudies] = useState<Study[]>([]);
|
const { selectedStudy } = useStudy();
|
||||||
const [selectedStudyId, setSelectedStudyId] = useState<string | null>(null);
|
const navigate = useNavigate();
|
||||||
const [reportContent, setReportContent] = useState<string | null>(null);
|
const [reportContent, setReportContent] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [generating, setGenerating] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [lastGenerated, setLastGenerated] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Redirect if no study selected
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
apiClient.getStudies()
|
if (!selectedStudy) {
|
||||||
.then(data => {
|
navigate('/');
|
||||||
setStudies(data.studies);
|
|
||||||
if (data.studies.length > 0) {
|
|
||||||
const completed = data.studies.find(s => s.status === 'completed');
|
|
||||||
setSelectedStudyId(completed?.id || data.studies[0].id);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(console.error);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (selectedStudyId) {
|
|
||||||
setLoading(true);
|
|
||||||
apiClient.getStudyReport(selectedStudyId)
|
|
||||||
.then(data => {
|
|
||||||
setReportContent(data.content);
|
|
||||||
setLoading(false);
|
|
||||||
})
|
|
||||||
.catch(err => {
|
|
||||||
console.error('Failed to fetch report:', err);
|
|
||||||
// Fallback for demo if report doesn't exist
|
|
||||||
setReportContent(`# Optimization Report: ${selectedStudyId}
|
|
||||||
|
|
||||||
## Executive Summary
|
|
||||||
The optimization study successfully converged after 45 trials. The best design achieved a mass reduction of 15% while maintaining all constraints.
|
|
||||||
|
|
||||||
## Key Findings
|
|
||||||
- **Best Objective Value**: 115.185 Hz
|
|
||||||
- **Critical Parameter**: Plate Thickness (sensitivity: 0.85)
|
|
||||||
- **Constraint Margins**: All safety factors > 1.2
|
|
||||||
|
|
||||||
## Recommendations
|
|
||||||
Based on the results, we recommend proceeding with the design from Trial #45. Further refinement could be achieved by narrowing the bounds for 'thickness'.
|
|
||||||
`);
|
|
||||||
setLoading(false);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}, [selectedStudyId]);
|
}, [selectedStudy, navigate]);
|
||||||
|
|
||||||
|
// Load report when study changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedStudy) {
|
||||||
|
loadReport();
|
||||||
|
}
|
||||||
|
}, [selectedStudy]);
|
||||||
|
|
||||||
|
const loadReport = async () => {
|
||||||
|
if (!selectedStudy) return;
|
||||||
|
|
||||||
const handleRegenerate = () => {
|
|
||||||
if (!selectedStudyId) return;
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
// In a real app, this would call an endpoint to trigger report generation
|
setError(null);
|
||||||
setTimeout(() => {
|
|
||||||
|
try {
|
||||||
|
const data = await apiClient.getStudyReport(selectedStudy.id);
|
||||||
|
setReportContent(data.content);
|
||||||
|
if (data.generated_at) {
|
||||||
|
setLastGenerated(data.generated_at);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
// No report yet - show placeholder
|
||||||
|
setReportContent(null);
|
||||||
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}, 2000);
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleGenerate = async () => {
|
||||||
|
if (!selectedStudy) return;
|
||||||
|
|
||||||
|
setGenerating(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await apiClient.generateReport(selectedStudy.id);
|
||||||
|
setReportContent(data.content);
|
||||||
|
if (data.generated_at) {
|
||||||
|
setLastGenerated(data.generated_at);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Failed to generate report');
|
||||||
|
} finally {
|
||||||
|
setGenerating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopy = async () => {
|
||||||
|
if (reportContent) {
|
||||||
|
await navigator.clipboard.writeText(reportContent);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownload = () => {
|
||||||
|
if (!reportContent || !selectedStudy) return;
|
||||||
|
|
||||||
|
const blob = new Blob([reportContent], { type: 'text/markdown' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `${selectedStudy.id}_report.md`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!selectedStudy) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto h-[calc(100vh-100px)] flex flex-col">
|
<div className="h-full flex flex-col">
|
||||||
|
{/* Header */}
|
||||||
<header className="mb-6 flex items-center justify-between">
|
<header className="mb-6 flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold text-primary-400">Results Viewer</h1>
|
<h1 className="text-2xl font-bold text-white">Optimization Report</h1>
|
||||||
<p className="text-dark-300 mt-1">Analyze completed optimization studies</p>
|
<p className="text-dark-400 mt-1">{selectedStudy.name}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="primary"
|
||||||
icon={<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />}
|
icon={generating ? <Loader2 className="w-4 h-4 animate-spin" /> : <Sparkles className="w-4 h-4" />}
|
||||||
onClick={handleRegenerate}
|
onClick={handleGenerate}
|
||||||
disabled={loading || !selectedStudyId}
|
disabled={generating}
|
||||||
>
|
>
|
||||||
Regenerate
|
{generating ? 'Generating...' : reportContent ? 'Update Report' : 'Generate Report'}
|
||||||
</Button>
|
|
||||||
<Button variant="secondary" icon={<Download className="w-4 h-4" />}>
|
|
||||||
Export Data
|
|
||||||
</Button>
|
</Button>
|
||||||
|
{reportContent && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
icon={copied ? <CheckCircle className="w-4 h-4 text-green-400" /> : <Copy className="w-4 h-4" />}
|
||||||
|
onClick={handleCopy}
|
||||||
|
>
|
||||||
|
{copied ? 'Copied!' : 'Copy'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
icon={<Download className="w-4 h-4" />}
|
||||||
|
onClick={handleDownload}
|
||||||
|
>
|
||||||
|
Download
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="grid grid-cols-12 gap-6 flex-1 min-h-0">
|
{/* Error Message */}
|
||||||
{/* Sidebar - Study Selection */}
|
{error && (
|
||||||
<aside className="col-span-3 flex flex-col gap-4">
|
<div className="mb-4 p-4 bg-red-900/20 border border-red-800/30 rounded-lg">
|
||||||
<Card title="Select Study" className="flex-1 overflow-hidden flex flex-col">
|
<div className="flex items-center gap-2 text-red-400">
|
||||||
<div className="space-y-2 overflow-y-auto flex-1 pr-2">
|
<AlertTriangle className="w-5 h-5" />
|
||||||
{studies.map(study => (
|
<span>{error}</span>
|
||||||
<button
|
</div>
|
||||||
key={study.id}
|
</div>
|
||||||
onClick={() => setSelectedStudyId(study.id)}
|
)}
|
||||||
className={`w-full text-left p-3 rounded-lg transition-colors ${
|
|
||||||
selectedStudyId === study.id
|
|
||||||
? 'bg-primary-900/30 text-primary-100 border border-primary-700/50'
|
|
||||||
: 'text-dark-300 hover:bg-dark-700'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="font-medium truncate">{study.name}</div>
|
|
||||||
<div className="text-xs text-dark-400 mt-1 capitalize">{study.status}</div>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
{/* Main Content - Report Viewer */}
|
{/* Main Content */}
|
||||||
<main className="col-span-9 flex flex-col gap-6 overflow-hidden">
|
<div className="flex-1 min-h-0">
|
||||||
<Card className="flex-1 overflow-hidden flex flex-col">
|
<Card className="h-full overflow-hidden flex flex-col">
|
||||||
<div className="flex items-center justify-between border-b border-dark-600 pb-4 mb-4">
|
<div className="flex items-center justify-between border-b border-dark-600 pb-4 mb-4">
|
||||||
<h2 className="text-xl font-semibold text-white flex items-center gap-2">
|
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
|
||||||
<FileText className="w-5 h-5 text-primary-400" />
|
<FileText className="w-5 h-5 text-primary-400" />
|
||||||
Optimization Report
|
Report Content
|
||||||
</h2>
|
</h2>
|
||||||
<div className="flex gap-2">
|
{lastGenerated && (
|
||||||
<button className="p-2 text-dark-300 hover:text-white hover:bg-dark-700 rounded-lg" title="View Charts">
|
<span className="text-xs text-dark-400">
|
||||||
<Image className="w-5 h-5" />
|
Last generated: {new Date(lastGenerated).toLocaleString()}
|
||||||
</button>
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto pr-4 custom-scrollbar">
|
||||||
|
{loading ? (
|
||||||
|
<div className="h-full flex flex-col items-center justify-center text-dark-300">
|
||||||
|
<RefreshCw className="w-8 h-8 animate-spin mb-3" />
|
||||||
|
<span>Loading report...</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : reportContent ? (
|
||||||
|
<div className="prose prose-invert prose-sm max-w-none
|
||||||
|
prose-headings:text-white prose-headings:font-semibold
|
||||||
|
prose-p:text-dark-300 prose-strong:text-white
|
||||||
|
prose-code:text-primary-400 prose-code:bg-dark-700 prose-code:px-1 prose-code:rounded
|
||||||
|
prose-pre:bg-dark-700 prose-pre:border prose-pre:border-dark-600
|
||||||
|
prose-a:text-primary-400 prose-a:no-underline hover:prose-a:underline
|
||||||
|
prose-ul:text-dark-300 prose-ol:text-dark-300
|
||||||
|
prose-li:text-dark-300
|
||||||
|
prose-table:border-collapse prose-th:border prose-th:border-dark-600 prose-th:p-2 prose-th:bg-dark-700
|
||||||
|
prose-td:border prose-td:border-dark-600 prose-td:p-2
|
||||||
|
prose-hr:border-dark-600">
|
||||||
|
<ReactMarkdown>{reportContent}</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="h-full flex flex-col items-center justify-center text-dark-400">
|
||||||
|
<FileText className="w-16 h-16 mb-4 opacity-50" />
|
||||||
|
<h3 className="text-lg font-medium text-dark-300 mb-2">No Report Generated</h3>
|
||||||
|
<p className="text-sm text-center mb-6 max-w-md">
|
||||||
|
Click "Generate Report" to create an AI-generated analysis of your optimization results.
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
icon={<Sparkles className="w-4 h-4" />}
|
||||||
|
onClick={handleGenerate}
|
||||||
|
disabled={generating}
|
||||||
|
>
|
||||||
|
Generate Report
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto pr-4 custom-scrollbar">
|
{/* Study Stats */}
|
||||||
{loading ? (
|
<div className="mt-4 grid grid-cols-4 gap-4">
|
||||||
<div className="h-full flex items-center justify-center text-dark-300">
|
<div className="bg-dark-800 rounded-lg p-4 border border-dark-600">
|
||||||
<RefreshCw className="w-8 h-8 animate-spin mb-2" />
|
<div className="text-xs text-dark-400 uppercase mb-1">Total Trials</div>
|
||||||
<span className="ml-2">Loading report...</span>
|
<div className="text-2xl font-bold text-white">{selectedStudy.progress.current}</div>
|
||||||
</div>
|
</div>
|
||||||
) : reportContent ? (
|
<div className="bg-dark-800 rounded-lg p-4 border border-dark-600">
|
||||||
<div className="prose prose-invert max-w-none">
|
<div className="text-xs text-dark-400 uppercase mb-1">Best Value</div>
|
||||||
{/* Simple markdown rendering for now */}
|
<div className="text-2xl font-bold text-primary-400">
|
||||||
{reportContent.split('\n').map((line, i) => {
|
{selectedStudy.best_value?.toFixed(4) || 'N/A'}
|
||||||
if (line.startsWith('# ')) return <h1 key={i} className="text-2xl font-bold text-white mt-6 mb-4">{line.substring(2)}</h1>;
|
</div>
|
||||||
if (line.startsWith('## ')) return <h2 key={i} className="text-xl font-bold text-primary-200 mt-6 mb-3">{line.substring(3)}</h2>;
|
</div>
|
||||||
if (line.startsWith('- ')) return <li key={i} className="ml-4 text-dark-100">{line.substring(2)}</li>;
|
<div className="bg-dark-800 rounded-lg p-4 border border-dark-600">
|
||||||
return <p key={i} className="text-dark-200 mb-2">{line}</p>;
|
<div className="text-xs text-dark-400 uppercase mb-1">Target</div>
|
||||||
})}
|
<div className="text-2xl font-bold text-dark-300">
|
||||||
</div>
|
{selectedStudy.target?.toFixed(4) || 'N/A'}
|
||||||
) : (
|
</div>
|
||||||
<div className="h-full flex items-center justify-center text-dark-300">
|
</div>
|
||||||
Select a study to view results
|
<div className="bg-dark-800 rounded-lg p-4 border border-dark-600">
|
||||||
</div>
|
<div className="text-xs text-dark-400 uppercase mb-1">Status</div>
|
||||||
)}
|
<div className={`text-lg font-bold capitalize ${
|
||||||
</div>
|
selectedStudy.status === 'completed' ? 'text-green-400' :
|
||||||
</Card>
|
selectedStudy.status === 'running' ? 'text-blue-400' : 'text-dark-400'
|
||||||
</main>
|
}`}>
|
||||||
|
{selectedStudy.status}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ export default {
|
|||||||
500: '#334155',
|
500: '#334155',
|
||||||
600: '#1e293b',
|
600: '#1e293b',
|
||||||
700: '#0f172a',
|
700: '#0f172a',
|
||||||
|
750: '#0a1120',
|
||||||
800: '#020617',
|
800: '#020617',
|
||||||
|
850: '#010410',
|
||||||
900: '#000000',
|
900: '#000000',
|
||||||
},
|
},
|
||||||
primary: {
|
primary: {
|
||||||
|
|||||||
635
docs/07_DEVELOPMENT/DASHBOARD_IMPROVEMENT_PLAN.md
Normal file
635
docs/07_DEVELOPMENT/DASHBOARD_IMPROVEMENT_PLAN.md
Normal file
@@ -0,0 +1,635 @@
|
|||||||
|
# Atomizer Dashboard Improvement Plan
|
||||||
|
|
||||||
|
## Executive Summary
|
||||||
|
|
||||||
|
This document outlines a comprehensive plan to enhance the Atomizer dashboard into a self-contained, professional optimization platform with integrated AI assistance through Claude Code.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current State
|
||||||
|
|
||||||
|
### Existing Pages
|
||||||
|
- **Home** (`/`): Study selection with README preview
|
||||||
|
- **Dashboard** (`/dashboard`): Real-time monitoring, charts, control panel
|
||||||
|
- **Results** (`/results`): AI-generated report viewer
|
||||||
|
|
||||||
|
### Existing Features
|
||||||
|
- Study selection with persistence
|
||||||
|
- README display on study hover
|
||||||
|
- Convergence plot (Plotly)
|
||||||
|
- Pareto plot for multi-objective
|
||||||
|
- Parallel coordinates
|
||||||
|
- Parameter importance chart
|
||||||
|
- Console output viewer
|
||||||
|
- Control panel (start/stop/validate)
|
||||||
|
- Optuna dashboard launch
|
||||||
|
- AI report generation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Proposed Improvements
|
||||||
|
|
||||||
|
### Phase 1: Core UX Enhancements
|
||||||
|
|
||||||
|
#### 1.1 Unified Navigation & Branding
|
||||||
|
- **Logo & Brand Identity**: Professional Atomizer logo in sidebar
|
||||||
|
- **Breadcrumb Navigation**: Show current path (e.g., `Atomizer > m1_mirror > Dashboard`)
|
||||||
|
- **Quick Study Switcher**: Dropdown in header to switch studies without returning to Home
|
||||||
|
- **Keyboard Shortcuts**: `Ctrl+K` for command palette, `Ctrl+1/2/3` for page navigation
|
||||||
|
|
||||||
|
#### 1.2 Study Overview Card (Home Page Enhancement)
|
||||||
|
When a study is selected, show a summary card with:
|
||||||
|
- Trial progress ring/chart
|
||||||
|
- Best objective value with trend indicator
|
||||||
|
- Last activity timestamp
|
||||||
|
- Quick action buttons (Start, Validate, Open)
|
||||||
|
- Thumbnail preview of convergence
|
||||||
|
|
||||||
|
#### 1.3 Real-Time Status Indicators
|
||||||
|
- **Global Status Bar**: Shows running processes, current trial, ETA
|
||||||
|
- **Live Toast Notifications**: Trial completed, error occurred, validation done
|
||||||
|
- **Sound Notifications** (optional): Audio cue on trial completion
|
||||||
|
|
||||||
|
#### 1.4 Dark/Light Theme Toggle
|
||||||
|
- Persist theme preference in localStorage
|
||||||
|
- System theme detection
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 2: Advanced Visualization
|
||||||
|
|
||||||
|
#### 2.1 Interactive Trial Table
|
||||||
|
- Sortable/filterable data grid with all trial data
|
||||||
|
- Column visibility toggles
|
||||||
|
- Export to CSV/Excel
|
||||||
|
- Click row to highlight in plots
|
||||||
|
- Filter by FEA vs Neural trials
|
||||||
|
|
||||||
|
#### 2.2 Enhanced Charts
|
||||||
|
- **Zoomable Convergence**: Brushing to select time ranges
|
||||||
|
- **3D Parameter Space**: Three.js visualization of design space
|
||||||
|
- **Heatmap**: Parameter correlation matrix
|
||||||
|
- **Animation**: Play through optimization history
|
||||||
|
|
||||||
|
#### 2.3 Comparison Mode
|
||||||
|
- Side-by-side comparison of 2-3 trials
|
||||||
|
- Diff view for parameter values
|
||||||
|
- Overlay plots
|
||||||
|
|
||||||
|
#### 2.4 Design Space Explorer
|
||||||
|
- Interactive sliders for design variables
|
||||||
|
- Predict objective using neural surrogate
|
||||||
|
- "What-if" analysis without running FEA
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 3: Claude Code Integration (AI Chat)
|
||||||
|
|
||||||
|
#### 3.1 Architecture Overview
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Atomizer Dashboard │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────────────────┐ ┌──────────────────────────┐ │
|
||||||
|
│ │ │ │ │ │
|
||||||
|
│ │ Main Dashboard │ │ Claude Code Panel │ │
|
||||||
|
│ │ (Charts, Controls) │ │ (Chat Interface) │ │
|
||||||
|
│ │ │ │ │ │
|
||||||
|
│ │ │ │ ┌────────────────────┐ │ │
|
||||||
|
│ │ │ │ │ Conversation │ │ │
|
||||||
|
│ │ │ │ │ History │ │ │
|
||||||
|
│ │ │ │ │ │ │ │
|
||||||
|
│ │ │ │ └────────────────────┘ │ │
|
||||||
|
│ │ │ │ │ │
|
||||||
|
│ │ │ │ ┌────────────────────┐ │ │
|
||||||
|
│ │ │ │ │ Input Box │ │ │
|
||||||
|
│ │ │ │ └────────────────────┘ │ │
|
||||||
|
│ │ │ │ │ │
|
||||||
|
│ └─────────────────────────┘ └──────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────┐
|
||||||
|
│ Backend API │
|
||||||
|
│ /api/claude │
|
||||||
|
└────────┬────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────┐
|
||||||
|
│ Claude Agent │
|
||||||
|
│ SDK Backend │
|
||||||
|
│ (Python) │
|
||||||
|
└────────┬────────┘
|
||||||
|
│
|
||||||
|
┌────────┴────────┐
|
||||||
|
│ │
|
||||||
|
┌────▼────┐ ┌─────▼─────┐
|
||||||
|
│ Atomizer│ │ Anthropic │
|
||||||
|
│ Tools │ │ Claude API│
|
||||||
|
└─────────┘ └───────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3.2 Backend Implementation
|
||||||
|
|
||||||
|
**New API Endpoints:**
|
||||||
|
|
||||||
|
```python
|
||||||
|
# atomizer-dashboard/backend/api/routes/claude.py
|
||||||
|
|
||||||
|
@router.post("/chat")
|
||||||
|
async def chat_with_claude(request: ChatRequest):
|
||||||
|
"""
|
||||||
|
Send a message to Claude with study context
|
||||||
|
|
||||||
|
Request:
|
||||||
|
- message: User's message
|
||||||
|
- study_id: Current study context
|
||||||
|
- conversation_id: For multi-turn conversations
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
- response: Claude's response
|
||||||
|
- actions: Any tool calls made (file edits, commands)
|
||||||
|
"""
|
||||||
|
|
||||||
|
@router.websocket("/chat/stream")
|
||||||
|
async def chat_stream(websocket: WebSocket):
|
||||||
|
"""
|
||||||
|
WebSocket for streaming Claude responses
|
||||||
|
Real-time token streaming for better UX
|
||||||
|
"""
|
||||||
|
|
||||||
|
@router.get("/conversations")
|
||||||
|
async def list_conversations():
|
||||||
|
"""Get conversation history for current study"""
|
||||||
|
|
||||||
|
@router.delete("/conversations/{conversation_id}")
|
||||||
|
async def delete_conversation(conversation_id: str):
|
||||||
|
"""Delete a conversation"""
|
||||||
|
```
|
||||||
|
|
||||||
|
**Claude Agent SDK Integration:**
|
||||||
|
|
||||||
|
```python
|
||||||
|
# atomizer-dashboard/backend/services/claude_agent.py
|
||||||
|
|
||||||
|
from anthropic import Anthropic
|
||||||
|
import json
|
||||||
|
|
||||||
|
class AtomizerClaudeAgent:
|
||||||
|
def __init__(self, study_id: str = None):
|
||||||
|
self.client = Anthropic()
|
||||||
|
self.study_id = study_id
|
||||||
|
self.tools = self._load_atomizer_tools()
|
||||||
|
self.system_prompt = self._build_system_prompt()
|
||||||
|
|
||||||
|
def _build_system_prompt(self) -> str:
|
||||||
|
"""Build context-aware system prompt"""
|
||||||
|
prompt = """You are Claude Code embedded in the Atomizer optimization dashboard.
|
||||||
|
|
||||||
|
You have access to the current optimization study and can help users:
|
||||||
|
1. Analyze optimization results
|
||||||
|
2. Modify study configurations
|
||||||
|
3. Create new studies
|
||||||
|
4. Explain FEA/Zernike concepts
|
||||||
|
5. Suggest design improvements
|
||||||
|
|
||||||
|
Current Study Context:
|
||||||
|
{study_context}
|
||||||
|
|
||||||
|
Available Tools:
|
||||||
|
- read_study_config: Read optimization configuration
|
||||||
|
- modify_config: Update design variables, objectives
|
||||||
|
- query_trials: Get trial data from database
|
||||||
|
- create_study: Create new optimization study
|
||||||
|
- run_analysis: Perform custom analysis
|
||||||
|
- edit_file: Modify study files
|
||||||
|
"""
|
||||||
|
if self.study_id:
|
||||||
|
prompt = prompt.format(study_context=self._get_study_context())
|
||||||
|
else:
|
||||||
|
prompt = prompt.format(study_context="No study selected")
|
||||||
|
return prompt
|
||||||
|
|
||||||
|
def _load_atomizer_tools(self) -> list:
|
||||||
|
"""Define Atomizer-specific tools for Claude"""
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"name": "read_study_config",
|
||||||
|
"description": "Read the optimization configuration for the current study",
|
||||||
|
"input_schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {},
|
||||||
|
"required": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "query_trials",
|
||||||
|
"description": "Query trial data from the Optuna database",
|
||||||
|
"input_schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"filter": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "SQL-like filter (e.g., 'state=COMPLETE')"
|
||||||
|
},
|
||||||
|
"limit": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Max results to return"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "modify_config",
|
||||||
|
"description": "Modify the optimization configuration",
|
||||||
|
"input_schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"path": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "JSON path to modify (e.g., 'design_variables[0].max')"
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"type": "any",
|
||||||
|
"description": "New value to set"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["path", "value"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "create_study",
|
||||||
|
"description": "Create a new optimization study",
|
||||||
|
"input_schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {"type": "string"},
|
||||||
|
"description": {"type": "string"},
|
||||||
|
"model_path": {"type": "string"},
|
||||||
|
"design_variables": {"type": "array"},
|
||||||
|
"objectives": {"type": "array"}
|
||||||
|
},
|
||||||
|
"required": ["name"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
async def chat(self, message: str, conversation_history: list = None) -> dict:
|
||||||
|
"""Process a chat message with tool use"""
|
||||||
|
messages = conversation_history or []
|
||||||
|
messages.append({"role": "user", "content": message})
|
||||||
|
|
||||||
|
response = await self.client.messages.create(
|
||||||
|
model="claude-sonnet-4-20250514",
|
||||||
|
max_tokens=4096,
|
||||||
|
system=self.system_prompt,
|
||||||
|
tools=self.tools,
|
||||||
|
messages=messages
|
||||||
|
)
|
||||||
|
|
||||||
|
# Handle tool calls
|
||||||
|
if response.stop_reason == "tool_use":
|
||||||
|
tool_results = await self._execute_tools(response.content)
|
||||||
|
messages.append({"role": "assistant", "content": response.content})
|
||||||
|
messages.append({"role": "user", "content": tool_results})
|
||||||
|
return await self.chat("", messages) # Continue conversation
|
||||||
|
|
||||||
|
return {
|
||||||
|
"response": response.content[0].text,
|
||||||
|
"conversation": messages + [{"role": "assistant", "content": response.content}]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3.3 Frontend Implementation
|
||||||
|
|
||||||
|
**Chat Panel Component:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// atomizer-dashboard/frontend/src/components/ClaudeChat.tsx
|
||||||
|
|
||||||
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
|
import { Send, Bot, User, Sparkles, Loader2 } from 'lucide-react';
|
||||||
|
import ReactMarkdown from 'react-markdown';
|
||||||
|
import { useStudy } from '../context/StudyContext';
|
||||||
|
|
||||||
|
interface Message {
|
||||||
|
role: 'user' | 'assistant';
|
||||||
|
content: string;
|
||||||
|
timestamp: Date;
|
||||||
|
toolCalls?: any[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ClaudeChat: React.FC = () => {
|
||||||
|
const { selectedStudy } = useStudy();
|
||||||
|
const [messages, setMessages] = useState<Message[]>([]);
|
||||||
|
const [input, setInput] = useState('');
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const sendMessage = async () => {
|
||||||
|
if (!input.trim() || isLoading) return;
|
||||||
|
|
||||||
|
const userMessage: Message = {
|
||||||
|
role: 'user',
|
||||||
|
content: input,
|
||||||
|
timestamp: new Date()
|
||||||
|
};
|
||||||
|
|
||||||
|
setMessages(prev => [...prev, userMessage]);
|
||||||
|
setInput('');
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/claude/chat', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
message: input,
|
||||||
|
study_id: selectedStudy?.id,
|
||||||
|
conversation_history: messages
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
setMessages(prev => [...prev, {
|
||||||
|
role: 'assistant',
|
||||||
|
content: data.response,
|
||||||
|
timestamp: new Date(),
|
||||||
|
toolCalls: data.tool_calls
|
||||||
|
}]);
|
||||||
|
} catch (error) {
|
||||||
|
// Handle error
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Suggested prompts for new conversations
|
||||||
|
const suggestions = [
|
||||||
|
"Analyze my optimization results",
|
||||||
|
"What parameters have the most impact?",
|
||||||
|
"Create a new study for my bracket",
|
||||||
|
"Explain the Zernike coefficients"
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full bg-dark-800 rounded-xl border border-dark-600">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="px-4 py-3 border-b border-dark-600 flex items-center gap-2">
|
||||||
|
<Bot className="w-5 h-5 text-primary-400" />
|
||||||
|
<span className="font-medium text-white">Claude Code</span>
|
||||||
|
{selectedStudy && (
|
||||||
|
<span className="text-xs bg-dark-700 px-2 py-0.5 rounded text-dark-300">
|
||||||
|
{selectedStudy.id}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Messages */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||||
|
{messages.length === 0 ? (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<Sparkles className="w-12 h-12 mx-auto mb-4 text-primary-400 opacity-50" />
|
||||||
|
<p className="text-dark-300 mb-4">Ask me anything about your optimization</p>
|
||||||
|
<div className="flex flex-wrap gap-2 justify-center">
|
||||||
|
{suggestions.map((s, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
onClick={() => setInput(s)}
|
||||||
|
className="px-3 py-1.5 bg-dark-700 hover:bg-dark-600 rounded-lg
|
||||||
|
text-sm text-dark-300 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
{s}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
messages.map((msg, i) => (
|
||||||
|
<div key={i} className={`flex gap-3 ${msg.role === 'user' ? 'justify-end' : ''}`}>
|
||||||
|
{msg.role === 'assistant' && (
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-primary-600 flex items-center justify-center flex-shrink-0">
|
||||||
|
<Bot className="w-4 h-4 text-white" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className={`max-w-[80%] rounded-lg p-3 ${
|
||||||
|
msg.role === 'user'
|
||||||
|
? 'bg-primary-600 text-white'
|
||||||
|
: 'bg-dark-700 text-dark-200'
|
||||||
|
}`}>
|
||||||
|
<ReactMarkdown className="prose prose-sm prose-invert">
|
||||||
|
{msg.content}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
{msg.role === 'user' && (
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-dark-600 flex items-center justify-center flex-shrink-0">
|
||||||
|
<User className="w-4 h-4 text-dark-300" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
{isLoading && (
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-primary-600 flex items-center justify-center">
|
||||||
|
<Loader2 className="w-4 h-4 text-white animate-spin" />
|
||||||
|
</div>
|
||||||
|
<div className="bg-dark-700 rounded-lg p-3 text-dark-400">
|
||||||
|
Thinking...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div ref={messagesEndRef} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Input */}
|
||||||
|
<div className="p-4 border-t border-dark-600">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
onKeyPress={(e) => e.key === 'Enter' && sendMessage()}
|
||||||
|
placeholder="Ask about your optimization..."
|
||||||
|
className="flex-1 px-4 py-2 bg-dark-700 border border-dark-600 rounded-lg
|
||||||
|
text-white placeholder-dark-400 focus:outline-none focus:border-primary-500"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={sendMessage}
|
||||||
|
disabled={!input.trim() || isLoading}
|
||||||
|
className="px-4 py-2 bg-primary-600 hover:bg-primary-500 disabled:opacity-50
|
||||||
|
text-white rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<Send className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3.4 Claude Code Capabilities
|
||||||
|
|
||||||
|
When integrated, Claude Code will be able to:
|
||||||
|
|
||||||
|
| Capability | Description | Example Command |
|
||||||
|
|------------|-------------|-----------------|
|
||||||
|
| **Analyze Results** | Interpret optimization progress | "Why is my convergence plateauing?" |
|
||||||
|
| **Explain Physics** | Describe FEA/Zernike concepts | "Explain astigmatism in my mirror" |
|
||||||
|
| **Modify Config** | Update design variables | "Increase the max bounds for whiffle_min to 60" |
|
||||||
|
| **Create Studies** | Generate new study from description | "Create a study for my new bracket" |
|
||||||
|
| **Query Data** | SQL-like data exploration | "Show me the top 5 trials by stress" |
|
||||||
|
| **Generate Code** | Write custom analysis scripts | "Write a Python script to compare trials" |
|
||||||
|
| **Debug Issues** | Diagnose optimization problems | "Why did trial 42 fail?" |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 4: Study Creation Wizard
|
||||||
|
|
||||||
|
#### 4.1 Guided Study Setup
|
||||||
|
|
||||||
|
Multi-step wizard for creating new studies:
|
||||||
|
|
||||||
|
1. **Model Selection**
|
||||||
|
- Browse NX model files
|
||||||
|
- Auto-detect expressions
|
||||||
|
- Preview 3D geometry (if possible)
|
||||||
|
|
||||||
|
2. **Design Variables**
|
||||||
|
- Interactive table to set bounds
|
||||||
|
- Baseline detection from model
|
||||||
|
- Sensitivity hints from similar studies
|
||||||
|
|
||||||
|
3. **Objectives**
|
||||||
|
- Template selection (stress, displacement, frequency, Zernike)
|
||||||
|
- Direction (minimize/maximize)
|
||||||
|
- Target values and weights
|
||||||
|
|
||||||
|
4. **Constraints**
|
||||||
|
- Add geometric/physical constraints
|
||||||
|
- Feasibility preview
|
||||||
|
|
||||||
|
5. **Algorithm Settings**
|
||||||
|
- Protocol selection (10/11/12)
|
||||||
|
- Sampler configuration
|
||||||
|
- Neural surrogate options
|
||||||
|
|
||||||
|
6. **Review & Create**
|
||||||
|
- Summary of all settings
|
||||||
|
- Validation checks
|
||||||
|
- One-click creation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 5: Self-Contained Packaging
|
||||||
|
|
||||||
|
#### 5.1 Electron Desktop App
|
||||||
|
|
||||||
|
Package the dashboard as a standalone desktop application:
|
||||||
|
|
||||||
|
```
|
||||||
|
Atomizer.exe
|
||||||
|
├── Frontend (React bundled)
|
||||||
|
├── Backend (Python bundled with PyInstaller)
|
||||||
|
├── NX Integration (optional)
|
||||||
|
└── Claude API (requires key)
|
||||||
|
```
|
||||||
|
|
||||||
|
Benefits:
|
||||||
|
- No Node.js/Python installation needed
|
||||||
|
- Single installer for users
|
||||||
|
- Offline capability (except AI features)
|
||||||
|
- Native file dialogs
|
||||||
|
- System tray integration
|
||||||
|
|
||||||
|
#### 5.2 Docker Deployment
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# docker-compose.yml
|
||||||
|
version: '3.8'
|
||||||
|
services:
|
||||||
|
frontend:
|
||||||
|
build: ./atomizer-dashboard/frontend
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
|
||||||
|
backend:
|
||||||
|
build: ./atomizer-dashboard/backend
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
volumes:
|
||||||
|
- ./studies:/app/studies
|
||||||
|
environment:
|
||||||
|
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Priority
|
||||||
|
|
||||||
|
| Phase | Feature | Effort | Impact | Priority |
|
||||||
|
|-------|---------|--------|--------|----------|
|
||||||
|
| 1.1 | Unified Navigation | Medium | High | P1 |
|
||||||
|
| 1.2 | Study Overview Card | Low | High | P1 |
|
||||||
|
| 1.3 | Real-Time Status | Medium | High | P1 |
|
||||||
|
| 2.1 | Interactive Trial Table | Medium | High | P1 |
|
||||||
|
| 3.1 | Claude Chat Backend | High | Critical | P1 |
|
||||||
|
| 3.3 | Claude Chat Frontend | Medium | Critical | P1 |
|
||||||
|
| 2.2 | Enhanced Charts | Medium | Medium | P2 |
|
||||||
|
| 2.4 | Design Space Explorer | High | High | P2 |
|
||||||
|
| 4.1 | Study Creation Wizard | High | High | P2 |
|
||||||
|
| 5.1 | Electron Packaging | High | Medium | P3 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Technical Requirements
|
||||||
|
|
||||||
|
### Dependencies to Add
|
||||||
|
|
||||||
|
**Backend:**
|
||||||
|
```
|
||||||
|
anthropic>=0.18.0 # Claude API
|
||||||
|
websockets>=12.0 # Real-time chat
|
||||||
|
```
|
||||||
|
|
||||||
|
**Frontend:**
|
||||||
|
```
|
||||||
|
@radix-ui/react-dialog # Modals
|
||||||
|
@radix-ui/react-tabs # Tab navigation
|
||||||
|
cmdk # Command palette
|
||||||
|
framer-motion # Animations
|
||||||
|
```
|
||||||
|
|
||||||
|
### API Keys Required
|
||||||
|
|
||||||
|
- `ANTHROPIC_API_KEY`: For Claude Code integration (user provides)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
1. **API Key Storage**: Never store API keys in frontend; use backend proxy
|
||||||
|
2. **File Access**: Sandbox Claude's file operations to study directories only
|
||||||
|
3. **Command Execution**: Whitelist allowed commands (no arbitrary shell)
|
||||||
|
4. **Rate Limiting**: Prevent API abuse through the chat interface
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
1. Review and approve this plan
|
||||||
|
2. Prioritize features based on user needs
|
||||||
|
3. Create GitHub issues for each feature
|
||||||
|
4. Begin Phase 1 implementation
|
||||||
|
5. Set up Claude API integration testing
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Document Version: 1.0*
|
||||||
|
*Created: 2024-12-04*
|
||||||
|
*Author: Claude Code*
|
||||||
Reference in New Issue
Block a user