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
|
||||
|
||||
### ✅ Live Dashboard (Current)
|
||||
### Core Dashboard
|
||||
- **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
|
||||
- **Data export** - Download trial history as JSON or CSV
|
||||
- **Study discovery** - Automatically detects all active studies
|
||||
- **Connection monitoring** - WebSocket status indicator
|
||||
|
||||
### 🔮 Future Features
|
||||
- React + TypeScript frontend
|
||||
- Study Configurator page
|
||||
- Results Report Viewer
|
||||
- LLM chat interface for configuration
|
||||
- Study control (start/stop/pause)
|
||||
### Optimization Control
|
||||
- **Start/Kill optimization** - Launch or force-kill optimization processes
|
||||
- **Validate top N** - Run FEA validation on best neural network predictions
|
||||
- **Process monitoring** - Real-time status of running optimizations
|
||||
- **Console output** - Live logs from optimization process
|
||||
|
||||
### 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
|
||||
```
|
||||
|
||||
### 2. Start the Backend
|
||||
### 2. Install Frontend Dependencies
|
||||
```bash
|
||||
cd atomizer-dashboard/frontend
|
||||
npm install
|
||||
```
|
||||
|
||||
### 3. Start the Backend
|
||||
```bash
|
||||
# From backend directory
|
||||
python -m uvicorn api.main:app --reload --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
### 3. Access the Dashboard
|
||||
Open your browser: **http://localhost:8000**
|
||||
|
||||
### 4. Monitor an Optimization
|
||||
### 4. Start the Frontend
|
||||
```bash
|
||||
# In a separate terminal
|
||||
cd ../..
|
||||
python studies/circular_plate_frequency_tuning/run_optimization.py
|
||||
# From frontend directory
|
||||
npm run dev
|
||||
```
|
||||
|
||||
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
|
||||
- **FastAPI** - Modern async Python web framework
|
||||
- **Uvicorn** - ASGI server
|
||||
- **Watchdog** - File system event monitoring
|
||||
- **WebSockets** - Bidirectional real-time communication
|
||||
- **WebSockets** - Real-time communication
|
||||
- **psutil** - Process management
|
||||
|
||||
### Current Frontend
|
||||
- **HTML/CSS/JavaScript** - Single-page application
|
||||
- **Chart.js** - Interactive charts
|
||||
- **WebSocket API** - Real-time data streaming
|
||||
|
||||
### Planned Frontend
|
||||
### Frontend Stack
|
||||
- **React 18** + **Vite** + **TypeScript**
|
||||
- **TailwindCSS** - Utility-first CSS
|
||||
- **Recharts** - React charting library
|
||||
- **Recharts** / **Plotly** - Charting libraries
|
||||
- **xterm.js** - Terminal emulator
|
||||
- **React Query** - Server state management
|
||||
|
||||
---
|
||||
@@ -74,18 +83,36 @@ The dashboard will automatically detect the running study and stream updates in
|
||||
|
||||
```
|
||||
atomizer-dashboard/
|
||||
├── backend/ ✅ COMPLETE
|
||||
├── backend/
|
||||
│ ├── api/
|
||||
│ │ ├── main.py # FastAPI app entry
|
||||
│ │ ├── main.py # FastAPI app entry
|
||||
│ │ ├── 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/
|
||||
│ │ └── optimization_stream.py # WebSocket + file watching
|
||||
│ ├── requirements.txt
|
||||
│ └── README.md # Backend API docs
|
||||
│ │ └── optimization_stream.py # Real-time trial streaming
|
||||
│ └── requirements.txt
|
||||
│
|
||||
├── 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)
|
||||
```
|
||||
|
||||
@@ -94,118 +121,77 @@ atomizer-dashboard/
|
||||
## API Documentation
|
||||
|
||||
### REST Endpoints
|
||||
|
||||
#### Studies
|
||||
- `GET /api/optimization/studies` - List all studies
|
||||
- `GET /api/optimization/studies/{id}/status` - Get study status
|
||||
- `GET /api/optimization/studies/{id}/history` - Get trial history
|
||||
- `GET /api/optimization/studies/{id}/pruning` - Get pruning diagnostics
|
||||
- `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
|
||||
- `ws://localhost:8000/api/ws/optimization/{study_id}` - Real-time trial stream
|
||||
#### Control
|
||||
- `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**:
|
||||
- `connected` - Initial connection confirmation
|
||||
- `trial_completed` - New trial finished
|
||||
- `new_best` - New best trial found
|
||||
- `progress` - Progress update (X/Y trials)
|
||||
- `trial_pruned` - Trial pruned with diagnostics
|
||||
#### Claude Code
|
||||
- `GET /api/terminal/status` - Check Claude CLI availability
|
||||
- `WebSocket /api/terminal/claude` - Terminal session
|
||||
|
||||
### WebSocket Endpoints
|
||||
- `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
|
||||
Line chart showing:
|
||||
- **Objective value** progression over trials
|
||||
- **Best so far** trajectory
|
||||
- Real-time updates without animation lag
|
||||
The dashboard includes an embedded Claude Code terminal that provides the full CLI experience:
|
||||
|
||||
### Parameter Space Chart
|
||||
Scatter plot showing:
|
||||
- 2D visualization of first two design variables
|
||||
- Points colored by objective value
|
||||
- Best trial highlighted in green
|
||||
### Features
|
||||
- Real terminal emulation with xterm.js
|
||||
- WebSocket-based PTY communication
|
||||
- Automatic context loading (CLAUDE.md, .claude/skills/)
|
||||
- Expand/minimize mode
|
||||
|
||||
### Pruning Alerts
|
||||
- Toast notifications for pruned trials
|
||||
- Auto-dismiss after 5 seconds
|
||||
- Warning styling (orange) with pruning cause
|
||||
### Requirements
|
||||
- Claude Code CLI installed: `npm install -g @anthropic-ai/claude-code`
|
||||
- Authenticated with Claude Code
|
||||
|
||||
### Data Export
|
||||
- **Export JSON** - Download complete trial history
|
||||
- **Export CSV** - Export as spreadsheet-compatible format
|
||||
- Success alerts on export
|
||||
|
||||
### Metrics Dashboard
|
||||
- **Total Trials** - Number of completed trials
|
||||
- **Best Value** - Best objective value found
|
||||
- **Avg Objective** - Average objective value
|
||||
- **Pruned** - Number of failed trials
|
||||
### Usage
|
||||
1. Click "Claude Code" button in dashboard header
|
||||
2. Click "Connect" to start a session
|
||||
3. Claude starts in Atomizer root with full context
|
||||
4. Use all Claude Code features (tools, file editing, etc.)
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
## Control Panel
|
||||
|
||||
### Verify Backend is Running
|
||||
```bash
|
||||
curl http://localhost:8000/health
|
||||
# Should return: {"status":"healthy"}
|
||||
### Start Optimization
|
||||
Launches `run_optimization.py` with configurable options:
|
||||
- Max iterations
|
||||
- FEA batch size
|
||||
- Tuning trials
|
||||
- Ensemble size
|
||||
- Patience
|
||||
- Fresh start option
|
||||
|
||||
curl http://localhost:8000/api/optimization/studies
|
||||
# Should return: {"studies":[...]}
|
||||
```
|
||||
### Kill Process
|
||||
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
|
||||
```bash
|
||||
# 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
|
||||
### Validate Top N
|
||||
Runs FEA validation on the best N neural network predictions to verify accuracy.
|
||||
|
||||
---
|
||||
|
||||
@@ -214,23 +200,49 @@ asyncio.run(test())
|
||||
### Dashboard shows "Failed to fetch"
|
||||
- Ensure backend is running: `http://localhost:8000/health`
|
||||
- Check CORS settings in `backend/api/main.py`
|
||||
- Access dashboard via `http://localhost:8000` (not `file://`)
|
||||
|
||||
### WebSocket not connecting
|
||||
- Verify backend is running on port 8000
|
||||
- Check firewall settings
|
||||
- Look for errors in browser console (F12)
|
||||
|
||||
### No studies appearing
|
||||
- Ensure studies directory exists: `studies/`
|
||||
- Check study has `1_setup/optimization_config.json`
|
||||
- Verify `2_results/optimization_history_incremental.json` exists
|
||||
### Claude Code terminal not connecting
|
||||
- Verify Claude CLI is installed: `claude --version`
|
||||
- Check that you're authenticated with Claude Code
|
||||
- Look for errors in browser console
|
||||
|
||||
### Charts not updating
|
||||
- Check WebSocket connection status in dashboard
|
||||
- Verify file watcher is running (check backend console)
|
||||
- Ensure optimization is actually running and creating trials
|
||||
- Check WebSocket connection status
|
||||
- Verify optimization is running
|
||||
- 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
|
||||
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
|
||||
|
||||
# Create FastAPI app
|
||||
@@ -34,6 +34,8 @@ app.add_middleware(
|
||||
# Include routers
|
||||
app.include_router(optimization.router, prefix="/api/optimization", tags=["optimization"])
|
||||
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("/")
|
||||
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.responses import JSONResponse, FileResponse
|
||||
from pydantic import BaseModel
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Optional
|
||||
import json
|
||||
import sys
|
||||
import sqlite3
|
||||
import shutil
|
||||
import subprocess
|
||||
import psutil
|
||||
import signal
|
||||
from datetime import datetime
|
||||
|
||||
# Add project root to path
|
||||
@@ -1024,3 +1028,620 @@ async def get_study_report(study_id: str):
|
||||
raise
|
||||
except Exception as 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
|
||||
weasyprint>=60.0.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",
|
||||
"@tanstack/react-query": "^5.90.10",
|
||||
"@types/react-plotly.js": "^2.6.3",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@types/three": "^0.181.0",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/addon-web-links": "^0.11.0",
|
||||
"clsx": "^2.1.1",
|
||||
"katex": "^0.16.25",
|
||||
"lucide-react": "^0.554.0",
|
||||
"plotly.js-basic-dist": "^3.3.0",
|
||||
"react": "^18.2.0",
|
||||
@@ -21,13 +25,15 @@
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-plotly.js": "^2.6.0",
|
||||
"react-router-dom": "^6.20.0",
|
||||
"react-syntax-highlighter": "^16.1.0",
|
||||
"react-use-websocket": "^4.13.0",
|
||||
"recharts": "^2.10.3",
|
||||
"rehype-katex": "^7.0.1",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"remark-math": "^6.0.0",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"three": "^0.181.2"
|
||||
"three": "^0.181.2",
|
||||
"xterm": "^5.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.43",
|
||||
@@ -1660,6 +1666,12 @@
|
||||
"integrity": "sha512-FjmSFaLmHVgBIBL6H0yX5k/AB3a7FQzjKBlRUF8YT6HiXMArE+hbXYIZXZ/42SBrdL05LWEog0zPqEaIDNsAiw==",
|
||||
"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": {
|
||||
"version": "15.7.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||
@@ -1705,6 +1717,15 @@
|
||||
"@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": {
|
||||
"version": "7.7.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz",
|
||||
@@ -1994,6 +2015,24 @@
|
||||
"integrity": "sha512-YA2hLrwLpDsRueNDXIMqN9NTzD6bCDkuXbOSe0heS+f8YE8usA6Gbv1prj81pzVHrbaAma7zObnIC+I6/sXJgA==",
|
||||
"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": {
|
||||
"version": "8.15.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||
@@ -3188,6 +3227,19 @@
|
||||
"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": {
|
||||
"version": "0.8.2",
|
||||
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
|
||||
@@ -3259,6 +3311,14 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "5.3.4",
|
||||
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
|
||||
@@ -3615,6 +3675,21 @@
|
||||
"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": {
|
||||
"version": "1.6.15",
|
||||
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.15.tgz",
|
||||
@@ -4094,6 +4169,20 @@
|
||||
"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": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
||||
@@ -5542,6 +5631,15 @@
|
||||
"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": {
|
||||
"version": "1.0.4",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "4.4.5",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/rehype-katex/-/rehype-katex-7.0.1.tgz",
|
||||
@@ -6989,6 +7123,13 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||
|
||||
@@ -14,8 +14,12 @@
|
||||
"@react-three/fiber": "^9.4.0",
|
||||
"@tanstack/react-query": "^5.90.10",
|
||||
"@types/react-plotly.js": "^2.6.3",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@types/three": "^0.181.0",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/addon-web-links": "^0.11.0",
|
||||
"clsx": "^2.1.1",
|
||||
"katex": "^0.16.25",
|
||||
"lucide-react": "^0.554.0",
|
||||
"plotly.js-basic-dist": "^3.3.0",
|
||||
"react": "^18.2.0",
|
||||
@@ -23,13 +27,15 @@
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-plotly.js": "^2.6.0",
|
||||
"react-router-dom": "^6.20.0",
|
||||
"react-syntax-highlighter": "^16.1.0",
|
||||
"react-use-websocket": "^4.13.0",
|
||||
"recharts": "^2.10.3",
|
||||
"rehype-katex": "^7.0.1",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"remark-math": "^6.0.0",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"three": "^0.181.2"
|
||||
"three": "^0.181.2",
|
||||
"xterm": "^5.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@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 { StudyProvider } from './context/StudyContext';
|
||||
import { MainLayout } from './components/layout/MainLayout';
|
||||
import Home from './pages/Home';
|
||||
import Dashboard from './pages/Dashboard';
|
||||
import Configurator from './pages/Configurator';
|
||||
import Results from './pages/Results';
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 5000,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<MainLayout />}>
|
||||
<Route index element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="dashboard" element={<Dashboard />} />
|
||||
<Route path="configurator" element={<Configurator />} />
|
||||
<Route path="results" element={<Results />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
<StudyProvider>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
{/* Home page - no sidebar layout */}
|
||||
<Route path="/" element={<Home />} />
|
||||
|
||||
{/* Study pages - with sidebar layout */}
|
||||
<Route element={<MainLayout />}>
|
||||
<Route path="dashboard" element={<Dashboard />} />
|
||||
<Route path="results" element={<Results />} />
|
||||
<Route path="analytics" element={<Dashboard />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</StudyProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,56 @@ import { StudyListResponse, HistoryResponse, PruningResponse, StudyStatus } from
|
||||
|
||||
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 {
|
||||
async getStudies(): Promise<StudyListResponse> {
|
||||
const response = await fetch(`${API_BASE}/optimization/studies`);
|
||||
@@ -37,12 +87,24 @@ class ApiClient {
|
||||
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`);
|
||||
if (!response.ok) throw new Error('Failed to fetch report');
|
||||
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<{
|
||||
lines: string[];
|
||||
total_lines: number;
|
||||
@@ -56,16 +118,81 @@ class ApiClient {
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// Future endpoints for control
|
||||
async startOptimization(studyId: string): Promise<void> {
|
||||
const response = await fetch(`${API_BASE}/optimization/studies/${studyId}/start`, { method: 'POST' });
|
||||
if (!response.ok) throw new Error('Failed to start optimization');
|
||||
async getProcessStatus(studyId: string): Promise<ProcessStatus> {
|
||||
const response = await fetch(`${API_BASE}/optimization/studies/${studyId}/process`);
|
||||
if (!response.ok) throw new Error('Failed to fetch process status');
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async stopOptimization(studyId: string): Promise<void> {
|
||||
const response = await fetch(`${API_BASE}/optimization/studies/${studyId}/stop`, { method: 'POST' });
|
||||
if (!response.ok) throw new Error('Failed to stop optimization');
|
||||
// Control operations
|
||||
async startOptimization(studyId: string, options?: {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
export const apiClient = new ApiClient();
|
||||
export const apiClient = new ApiClient();
|
||||
|
||||
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 { LayoutDashboard, Settings, FileText, Activity } from 'lucide-react';
|
||||
import { NavLink, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Home,
|
||||
Activity,
|
||||
FileText,
|
||||
BarChart3,
|
||||
ChevronLeft,
|
||||
Play,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
Zap
|
||||
} from 'lucide-react';
|
||||
import clsx from 'clsx';
|
||||
import { useStudy } from '../../context/StudyContext';
|
||||
|
||||
export const Sidebar = () => {
|
||||
const navItems = [
|
||||
{ to: '/dashboard', icon: Activity, label: 'Live Dashboard' },
|
||||
{ to: '/configurator', icon: Settings, label: 'Configurator' },
|
||||
{ to: '/results', icon: FileText, label: 'Results Viewer' },
|
||||
];
|
||||
const { selectedStudy, clearStudy } = useStudy();
|
||||
const navigate = useNavigate();
|
||||
|
||||
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 (
|
||||
<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="flex items-center gap-3">
|
||||
<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>
|
||||
<h1 className="text-xl font-bold text-white tracking-tight">Atomizer</h1>
|
||||
</div>
|
||||
<p className="text-xs text-dark-300 mt-1 ml-11">Optimization Platform</p>
|
||||
</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">
|
||||
{navItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.to === '/'}
|
||||
className={({ isActive }) =>
|
||||
clsx(
|
||||
'flex items-center gap-3 px-4 py-3 rounded-lg transition-colors duration-200',
|
||||
@@ -41,6 +119,7 @@ export const Sidebar = () => {
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Footer Status */}
|
||||
<div className="p-4 border-t border-dark-600">
|
||||
<div className="bg-dark-700 rounded-lg p-4">
|
||||
<div className="text-xs font-medium text-dark-400 uppercase mb-2">System Status</div>
|
||||
@@ -48,8 +127,14 @@ export const Sidebar = () => {
|
||||
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
|
||||
Backend Online
|
||||
</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>
|
||||
</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 components;
|
||||
@tailwind utilities;
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import { useState, useEffect, lazy, Suspense } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
LineChart, Line, ScatterChart, Scatter,
|
||||
XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, Cell
|
||||
} from 'recharts';
|
||||
import { Terminal } from 'lucide-react';
|
||||
import { useOptimizationWebSocket } from '../hooks/useWebSocket';
|
||||
import { apiClient } from '../api/client';
|
||||
import { useStudy } from '../context/StudyContext';
|
||||
import { Card } from '../components/common/Card';
|
||||
import { MetricCard } from '../components/dashboard/MetricCard';
|
||||
import { StudyCard } from '../components/dashboard/StudyCard';
|
||||
// import { OptimizerPanel } from '../components/OptimizerPanel'; // Not used currently
|
||||
import { ControlPanel } from '../components/dashboard/ControlPanel';
|
||||
import { ClaudeTerminal } from '../components/ClaudeTerminal';
|
||||
import { ParetoPlot } from '../components/ParetoPlot';
|
||||
import { ParallelCoordinatesPlot } from '../components/ParallelCoordinatesPlot';
|
||||
import { ParameterImportanceChart } from '../components/ParameterImportanceChart';
|
||||
@@ -16,7 +19,7 @@ import { ConvergencePlot } from '../components/ConvergencePlot';
|
||||
import { StudyReportViewer } from '../components/StudyReportViewer';
|
||||
import { ConsoleOutput } from '../components/ConsoleOutput';
|
||||
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
|
||||
const PlotlyParallelCoordinates = lazy(() => import('../components/plotly/PlotlyParallelCoordinates').then(m => ({ default: m.PlotlyParallelCoordinates })));
|
||||
@@ -32,8 +35,17 @@ const ChartLoading = () => (
|
||||
);
|
||||
|
||||
export default function Dashboard() {
|
||||
const [studies, setStudies] = useState<Study[]>([]);
|
||||
const [selectedStudyId, setSelectedStudyId] = useState<string | null>(null);
|
||||
const navigate = useNavigate();
|
||||
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 [displayedTrials, setDisplayedTrials] = useState<Trial[]>([]);
|
||||
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)
|
||||
const [chartLibrary, setChartLibrary] = useState<'plotly' | 'recharts'>('recharts');
|
||||
|
||||
// Load studies on mount
|
||||
useEffect(() => {
|
||||
apiClient.getStudies()
|
||||
.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);
|
||||
}, []);
|
||||
// Claude chat panel state
|
||||
const [chatOpen, setChatOpen] = useState(false);
|
||||
const [chatExpanded, setChatExpanded] = useState(false);
|
||||
|
||||
const showAlert = (type: 'success' | 'warning', message: string) => {
|
||||
const id = alertIdCounter;
|
||||
@@ -111,9 +106,6 @@ export default function Dashboard() {
|
||||
setPrunedCount(0);
|
||||
setExpandedTrials(new Set());
|
||||
|
||||
// Save to LocalStorage
|
||||
localStorage.setItem('lastSelectedStudyId', selectedStudyId);
|
||||
|
||||
apiClient.getStudyHistory(selectedStudyId)
|
||||
.then(data => {
|
||||
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>
|
||||
</div>
|
||||
<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 && (
|
||||
<StudyReportViewer studyId={selectedStudyId} />
|
||||
)}
|
||||
@@ -380,24 +385,13 @@ export default function Dashboard() {
|
||||
</header>
|
||||
|
||||
<div className="grid grid-cols-12 gap-6">
|
||||
{/* Sidebar - Study List */}
|
||||
{/* Control Panel - Left Sidebar */}
|
||||
<aside className="col-span-3">
|
||||
<Card title="Active Studies">
|
||||
<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>
|
||||
<ControlPanel onStatusChange={refreshStudies} />
|
||||
</aside>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="col-span-9">
|
||||
{/* Main Content - shrinks when chat is open */}
|
||||
<main className={chatOpen ? 'col-span-5' : 'col-span-9'}>
|
||||
{/* Study Name Header */}
|
||||
{selectedStudyId && (
|
||||
<div className="mb-4 pb-3 border-b border-dark-600">
|
||||
@@ -884,6 +878,17 @@ export default function Dashboard() {
|
||||
/>
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
|
||||
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,151 +1,242 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Card } from '../components/common/Card';
|
||||
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 { Study } from '../types';
|
||||
import { useStudy } from '../context/StudyContext';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
|
||||
export default function Results() {
|
||||
const [studies, setStudies] = useState<Study[]>([]);
|
||||
const [selectedStudyId, setSelectedStudyId] = useState<string | null>(null);
|
||||
const { selectedStudy } = useStudy();
|
||||
const navigate = useNavigate();
|
||||
const [reportContent, setReportContent] = useState<string | null>(null);
|
||||
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(() => {
|
||||
apiClient.getStudies()
|
||||
.then(data => {
|
||||
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);
|
||||
});
|
||||
if (!selectedStudy) {
|
||||
navigate('/');
|
||||
}
|
||||
}, [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);
|
||||
// In a real app, this would call an endpoint to trigger report generation
|
||||
setTimeout(() => {
|
||||
setError(null);
|
||||
|
||||
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);
|
||||
}, 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 (
|
||||
<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">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-primary-400">Results Viewer</h1>
|
||||
<p className="text-dark-300 mt-1">Analyze completed optimization studies</p>
|
||||
<h1 className="text-2xl font-bold text-white">Optimization Report</h1>
|
||||
<p className="text-dark-400 mt-1">{selectedStudy.name}</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon={<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />}
|
||||
onClick={handleRegenerate}
|
||||
disabled={loading || !selectedStudyId}
|
||||
<Button
|
||||
variant="primary"
|
||||
icon={generating ? <Loader2 className="w-4 h-4 animate-spin" /> : <Sparkles className="w-4 h-4" />}
|
||||
onClick={handleGenerate}
|
||||
disabled={generating}
|
||||
>
|
||||
Regenerate
|
||||
</Button>
|
||||
<Button variant="secondary" icon={<Download className="w-4 h-4" />}>
|
||||
Export Data
|
||||
{generating ? 'Generating...' : reportContent ? 'Update Report' : 'Generate Report'}
|
||||
</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>
|
||||
</header>
|
||||
|
||||
<div className="grid grid-cols-12 gap-6 flex-1 min-h-0">
|
||||
{/* Sidebar - Study Selection */}
|
||||
<aside className="col-span-3 flex flex-col gap-4">
|
||||
<Card title="Select Study" className="flex-1 overflow-hidden flex flex-col">
|
||||
<div className="space-y-2 overflow-y-auto flex-1 pr-2">
|
||||
{studies.map(study => (
|
||||
<button
|
||||
key={study.id}
|
||||
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>
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="mb-4 p-4 bg-red-900/20 border border-red-800/30 rounded-lg">
|
||||
<div className="flex items-center gap-2 text-red-400">
|
||||
<AlertTriangle className="w-5 h-5" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main Content - Report Viewer */}
|
||||
<main className="col-span-9 flex flex-col gap-6 overflow-hidden">
|
||||
<Card className="flex-1 overflow-hidden flex flex-col">
|
||||
<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">
|
||||
<FileText className="w-5 h-5 text-primary-400" />
|
||||
Optimization Report
|
||||
</h2>
|
||||
<div className="flex gap-2">
|
||||
<button className="p-2 text-dark-300 hover:text-white hover:bg-dark-700 rounded-lg" title="View Charts">
|
||||
<Image className="w-5 h-5" />
|
||||
</button>
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 min-h-0">
|
||||
<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">
|
||||
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
|
||||
<FileText className="w-5 h-5 text-primary-400" />
|
||||
Report Content
|
||||
</h2>
|
||||
{lastGenerated && (
|
||||
<span className="text-xs text-dark-400">
|
||||
Last generated: {new Date(lastGenerated).toLocaleString()}
|
||||
</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 className="flex-1 overflow-y-auto pr-4 custom-scrollbar">
|
||||
{loading ? (
|
||||
<div className="h-full flex items-center justify-center text-dark-300">
|
||||
<RefreshCw className="w-8 h-8 animate-spin mb-2" />
|
||||
<span className="ml-2">Loading report...</span>
|
||||
</div>
|
||||
) : reportContent ? (
|
||||
<div className="prose prose-invert max-w-none">
|
||||
{/* Simple markdown rendering for now */}
|
||||
{reportContent.split('\n').map((line, i) => {
|
||||
if (line.startsWith('# ')) return <h1 key={i} className="text-2xl font-bold text-white mt-6 mb-4">{line.substring(2)}</h1>;
|
||||
if (line.startsWith('## ')) return <h2 key={i} className="text-xl font-bold text-primary-200 mt-6 mb-3">{line.substring(3)}</h2>;
|
||||
if (line.startsWith('- ')) return <li key={i} className="ml-4 text-dark-100">{line.substring(2)}</li>;
|
||||
return <p key={i} className="text-dark-200 mb-2">{line}</p>;
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full flex items-center justify-center text-dark-300">
|
||||
Select a study to view results
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</main>
|
||||
) : 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>
|
||||
|
||||
{/* Study Stats */}
|
||||
<div className="mt-4 grid grid-cols-4 gap-4">
|
||||
<div className="bg-dark-800 rounded-lg p-4 border border-dark-600">
|
||||
<div className="text-xs text-dark-400 uppercase mb-1">Total Trials</div>
|
||||
<div className="text-2xl font-bold text-white">{selectedStudy.progress.current}</div>
|
||||
</div>
|
||||
<div className="bg-dark-800 rounded-lg p-4 border border-dark-600">
|
||||
<div className="text-xs text-dark-400 uppercase mb-1">Best Value</div>
|
||||
<div className="text-2xl font-bold text-primary-400">
|
||||
{selectedStudy.best_value?.toFixed(4) || 'N/A'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-dark-800 rounded-lg p-4 border border-dark-600">
|
||||
<div className="text-xs text-dark-400 uppercase mb-1">Target</div>
|
||||
<div className="text-2xl font-bold text-dark-300">
|
||||
{selectedStudy.target?.toFixed(4) || 'N/A'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-dark-800 rounded-lg p-4 border border-dark-600">
|
||||
<div className="text-xs text-dark-400 uppercase mb-1">Status</div>
|
||||
<div className={`text-lg font-bold capitalize ${
|
||||
selectedStudy.status === 'completed' ? 'text-green-400' :
|
||||
selectedStudy.status === 'running' ? 'text-blue-400' : 'text-dark-400'
|
||||
}`}>
|
||||
{selectedStudy.status}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,9 @@ export default {
|
||||
500: '#334155',
|
||||
600: '#1e293b',
|
||||
700: '#0f172a',
|
||||
750: '#0a1120',
|
||||
800: '#020617',
|
||||
850: '#010410',
|
||||
900: '#000000',
|
||||
},
|
||||
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