Major changes: - Dashboard: WebSocket-based chat with session management - Dashboard: New chat components (ChatPane, ChatInput, ModeToggle) - Dashboard: Enhanced UI with parallel coordinates chart - MCP Server: New atomizer-tools server for Claude integration - Extractors: Enhanced Zernike OPD extractor - Reports: Improved report generator New studies (configs and scripts only): - M1 Mirror: Cost reduction campaign studies - Simple Beam, Simple Bracket, UAV Arm studies Note: Large iteration data (2_iterations/, best_design_archive/) excluded via .gitignore - kept on local Gitea only. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2429 lines
75 KiB
Markdown
2429 lines
75 KiB
Markdown
# Dashboard Chat Architecture - Full Implementation Plan
|
|
|
|
**Status**: Planning
|
|
**Created**: 2025-01-08
|
|
**Target**: YouTube Launch Demo
|
|
|
|
---
|
|
|
|
## Executive Summary
|
|
|
|
Transform the Atomizer dashboard chat from a stateless CLI wrapper into a full-featured, MCP-powered Claude Code experience with two modes:
|
|
|
|
- **User Mode**: Safe, product-like experience for running optimizations
|
|
- **Power Mode**: Full Claude Code capabilities for extending Atomizer
|
|
|
|
---
|
|
|
|
## Architecture Overview
|
|
|
|
```
|
|
┌──────────────────────────────────────────────────────────────────────────┐
|
|
│ FRONTEND (React) │
|
|
│ ┌────────────────────────────────────────────────────────────────────┐ │
|
|
│ │ ChatPane │ │
|
|
│ │ ┌─────────────┐ ┌─────────────┐ │ │
|
|
│ │ │ 👤 User │ │ ⚡ Power │ Mode Toggle │ │
|
|
│ │ └─────────────┘ └─────────────┘ │ │
|
|
│ │ │ │
|
|
│ │ Messages Area (streaming) │ │
|
|
│ │ Tool Call Visualizations │ │
|
|
│ │ Input with suggestions │ │
|
|
│ └────────────────────────────────────────────────────────────────────┘ │
|
|
│ │ │
|
|
│ │ WebSocket │
|
|
│ ▼ │
|
|
├──────────────────────────────────────────────────────────────────────────┤
|
|
│ BACKEND (FastAPI) │
|
|
│ ┌────────────────────────────────────────────────────────────────────┐ │
|
|
│ │ SessionManager │ │
|
|
│ │ - Session lifecycle (create/resume/destroy) │ │
|
|
│ │ - Mode enforcement (user/power) │ │
|
|
│ │ - Conversation persistence (SQLite) │ │
|
|
│ │ - Context injection (study, global) │ │
|
|
│ └────────────────────────────────────────────────────────────────────┘ │
|
|
│ │ │
|
|
│ │ Subprocess │
|
|
│ ▼ │
|
|
├──────────────────────────────────────────────────────────────────────────┤
|
|
│ CLAUDE CODE (subprocess) │
|
|
│ ┌────────────────────────────────────────────────────────────────────┐ │
|
|
│ │ claude --mcp-config <config.json> │ │
|
|
│ │ │ │
|
|
│ │ Configured with: │ │
|
|
│ │ - Atomizer MCP Server (tools) │ │
|
|
│ │ - System prompt (context) │ │
|
|
│ │ - Allowed directories │ │
|
|
│ └────────────────────────────────────────────────────────────────────┘ │
|
|
│ │ │
|
|
│ │ MCP Protocol │
|
|
│ ▼ │
|
|
├──────────────────────────────────────────────────────────────────────────┤
|
|
│ ATOMIZER MCP SERVER (Node.js) │
|
|
│ ┌────────────────────────────────────────────────────────────────────┐ │
|
|
│ │ USER MODE TOOLS │ POWER MODE TOOLS (additional) │ │
|
|
│ │ ─────────────────────────────┼────────────────────────────────── │ │
|
|
│ │ create_study │ edit_file │ │
|
|
│ │ list_studies │ create_extractor │ │
|
|
│ │ get_study_status │ modify_protocol │ │
|
|
│ │ run_optimization │ run_shell_command │ │
|
|
│ │ stop_optimization │ git_commit │ │
|
|
│ │ get_trial_data │ git_push │ │
|
|
│ │ analyze_convergence │ create_file │ │
|
|
│ │ compare_trials │ delete_file │ │
|
|
│ │ generate_report │ search_codebase │ │
|
|
│ │ get_best_design │ modify_dashboard │ │
|
|
│ │ explain_physics │ │ │
|
|
│ │ recommend_method │ │ │
|
|
│ │ query_database │ │ │
|
|
│ └────────────────────────────────────────────────────────────────────┘ │
|
|
└──────────────────────────────────────────────────────────────────────────┘
|
|
```
|
|
|
|
---
|
|
|
|
## Directory Structure
|
|
|
|
```
|
|
atomizer-dashboard/
|
|
├── backend/
|
|
│ ├── api/
|
|
│ │ ├── services/
|
|
│ │ │ ├── session_manager.py # NEW: Manages Claude sessions
|
|
│ │ │ ├── conversation_store.py # NEW: SQLite persistence
|
|
│ │ │ ├── context_builder.py # NEW: Builds prompts/context
|
|
│ │ │ └── claude_cli_agent.py # DEPRECATED: Old stateless approach
|
|
│ │ └── routes/
|
|
│ │ └── claude.py # UPDATE: Use SessionManager
|
|
│ └── sessions.db # NEW: Conversation storage
|
|
│
|
|
├── frontend/
|
|
│ └── src/
|
|
│ ├── components/
|
|
│ │ └── chat/
|
|
│ │ ├── ChatPane.tsx # UPDATE: Mode toggle, tool viz
|
|
│ │ ├── ChatMessage.tsx # UPDATE: Tool call rendering
|
|
│ │ ├── ModeToggle.tsx # NEW: User/Power mode switch
|
|
│ │ └── ToolCallCard.tsx # NEW: Visualize tool calls
|
|
│ └── hooks/
|
|
│ └── useChat.ts # UPDATE: WebSocket, sessions
|
|
│
|
|
└── mcp-server/ # EXISTING (siemens-docs)
|
|
└── atomizer-tools/ # NEW: Atomizer MCP server
|
|
├── package.json
|
|
├── tsconfig.json
|
|
├── src/
|
|
│ ├── index.ts # MCP server entry
|
|
│ ├── tools/
|
|
│ │ ├── study.ts # Study management tools
|
|
│ │ ├── optimization.ts # Run/stop/monitor tools
|
|
│ │ ├── analysis.ts # Results analysis tools
|
|
│ │ ├── reporting.ts # Report generation tools
|
|
│ │ ├── physics.ts # FEA explanation tools
|
|
│ │ └── admin.ts # Power mode tools
|
|
│ └── utils/
|
|
│ ├── database.ts # SQLite access
|
|
│ ├── python.ts # Call Python scripts
|
|
│ └── validation.ts # Input validation
|
|
└── README.md
|
|
```
|
|
|
|
---
|
|
|
|
## Phase 1: MCP Server Foundation
|
|
|
|
### 1.1 Create MCP Server Scaffold
|
|
|
|
**Files to create:**
|
|
- `mcp-server/atomizer-tools/package.json`
|
|
- `mcp-server/atomizer-tools/tsconfig.json`
|
|
- `mcp-server/atomizer-tools/src/index.ts`
|
|
|
|
**Package.json:**
|
|
```json
|
|
{
|
|
"name": "atomizer-mcp-server",
|
|
"version": "1.0.0",
|
|
"type": "module",
|
|
"main": "dist/index.js",
|
|
"scripts": {
|
|
"build": "tsc",
|
|
"start": "node dist/index.js",
|
|
"dev": "tsx src/index.ts"
|
|
},
|
|
"dependencies": {
|
|
"@anthropic-ai/sdk": "^0.30.0",
|
|
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
"better-sqlite3": "^11.0.0",
|
|
"zod": "^3.23.0"
|
|
},
|
|
"devDependencies": {
|
|
"@types/better-sqlite3": "^7.6.0",
|
|
"@types/node": "^20.0.0",
|
|
"tsx": "^4.0.0",
|
|
"typescript": "^5.0.0"
|
|
}
|
|
}
|
|
```
|
|
|
|
**Index.ts structure:**
|
|
```typescript
|
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
|
|
// Tool imports
|
|
import { studyTools } from "./tools/study.js";
|
|
import { optimizationTools } from "./tools/optimization.js";
|
|
import { analysisTools } from "./tools/analysis.js";
|
|
import { reportingTools } from "./tools/reporting.js";
|
|
import { physicsTools } from "./tools/physics.js";
|
|
import { adminTools } from "./tools/admin.js";
|
|
|
|
const MODE = process.env.ATOMIZER_MODE || "user"; // "user" or "power"
|
|
|
|
const server = new Server({
|
|
name: "atomizer-tools",
|
|
version: "1.0.0",
|
|
});
|
|
|
|
// Register tools based on mode
|
|
const userTools = [
|
|
...studyTools,
|
|
...optimizationTools,
|
|
...analysisTools,
|
|
...reportingTools,
|
|
...physicsTools,
|
|
];
|
|
|
|
const powerTools = [
|
|
...userTools,
|
|
...adminTools,
|
|
];
|
|
|
|
const tools = MODE === "power" ? powerTools : userTools;
|
|
|
|
// Register all tools
|
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
tools: tools.map(t => t.definition),
|
|
}));
|
|
|
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
const tool = tools.find(t => t.definition.name === request.params.name);
|
|
if (!tool) throw new Error(`Unknown tool: ${request.params.name}`);
|
|
return tool.handler(request.params.arguments);
|
|
});
|
|
|
|
// Start server
|
|
const transport = new StdioServerTransport();
|
|
await server.connect(transport);
|
|
```
|
|
|
|
### 1.2 Implement Core User Mode Tools
|
|
|
|
**Priority order:**
|
|
1. `list_studies` - List available studies
|
|
2. `get_study_status` - Get current study state
|
|
3. `create_study` - Create new study from description
|
|
4. `run_optimization` - Start optimization
|
|
5. `get_trial_data` - Query trial results
|
|
6. `analyze_convergence` - Convergence analysis
|
|
|
|
**Tool Definition Template:**
|
|
```typescript
|
|
// tools/study.ts
|
|
import { z } from "zod";
|
|
import { execSync } from "child_process";
|
|
import Database from "better-sqlite3";
|
|
|
|
const STUDIES_DIR = "C:/Users/antoi/Atomizer/studies";
|
|
const PYTHON_PATH = "C:/Users/antoi/anaconda3/envs/atomizer/python.exe";
|
|
|
|
export const studyTools = [
|
|
{
|
|
definition: {
|
|
name: "list_studies",
|
|
description: "List all available optimization studies",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {
|
|
filter: {
|
|
type: "string",
|
|
description: "Optional filter pattern (e.g., 'bracket', 'mirror')",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
handler: async (args: { filter?: string }) => {
|
|
// Implementation: Read studies directory, return list
|
|
const fs = await import("fs/promises");
|
|
const studies = await fs.readdir(STUDIES_DIR);
|
|
|
|
let filtered = studies.filter(s => !s.startsWith("_"));
|
|
if (args.filter) {
|
|
filtered = filtered.filter(s =>
|
|
s.toLowerCase().includes(args.filter!.toLowerCase())
|
|
);
|
|
}
|
|
|
|
// Get status for each study
|
|
const results = await Promise.all(filtered.map(async (name) => {
|
|
const configPath = `${STUDIES_DIR}/${name}/optimization_config.json`;
|
|
const dbPath = `${STUDIES_DIR}/${name}/3_results/study.db`;
|
|
|
|
let status = "unknown";
|
|
let trials = 0;
|
|
|
|
try {
|
|
const db = new Database(dbPath, { readonly: true });
|
|
const row = db.prepare("SELECT COUNT(*) as count FROM trials").get();
|
|
trials = row.count;
|
|
status = trials > 0 ? "has_results" : "configured";
|
|
db.close();
|
|
} catch {
|
|
status = "no_results";
|
|
}
|
|
|
|
return { name, status, trials };
|
|
}));
|
|
|
|
return {
|
|
content: [{
|
|
type: "text",
|
|
text: JSON.stringify(results, null, 2),
|
|
}],
|
|
};
|
|
},
|
|
},
|
|
|
|
{
|
|
definition: {
|
|
name: "create_study",
|
|
description: "Create a new optimization study from a natural language description",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {
|
|
name: {
|
|
type: "string",
|
|
description: "Study name (snake_case, e.g., 'bracket_mass_v1')",
|
|
},
|
|
description: {
|
|
type: "string",
|
|
description: "Natural language description of what to optimize",
|
|
},
|
|
template: {
|
|
type: "string",
|
|
enum: ["bracket", "beam", "mirror", "custom"],
|
|
description: "Base template to use",
|
|
},
|
|
nx_model_path: {
|
|
type: "string",
|
|
description: "Path to the NX model file (.prt)",
|
|
},
|
|
},
|
|
required: ["name", "description"],
|
|
},
|
|
},
|
|
handler: async (args) => {
|
|
// Call Python study creator
|
|
const script = `
|
|
from optimization_engine.study.creator import StudyCreator
|
|
creator = StudyCreator()
|
|
result = creator.create_from_description(
|
|
name="${args.name}",
|
|
description="""${args.description}""",
|
|
template="${args.template || 'custom'}",
|
|
nx_model_path=${args.nx_model_path ? `"${args.nx_model_path}"` : 'None'}
|
|
)
|
|
print(result)
|
|
`;
|
|
|
|
try {
|
|
const output = execSync(`${PYTHON_PATH} -c "${script}"`, {
|
|
encoding: "utf-8",
|
|
cwd: STUDIES_DIR,
|
|
});
|
|
|
|
return {
|
|
content: [{
|
|
type: "text",
|
|
text: `Study created successfully!\n\n${output}`,
|
|
}],
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
content: [{
|
|
type: "text",
|
|
text: `Error creating study: ${error.message}`,
|
|
}],
|
|
isError: true,
|
|
};
|
|
}
|
|
},
|
|
},
|
|
|
|
// ... more tools
|
|
];
|
|
```
|
|
|
|
### 1.3 Implement Analysis Tools
|
|
|
|
```typescript
|
|
// tools/analysis.ts
|
|
export const analysisTools = [
|
|
{
|
|
definition: {
|
|
name: "get_trial_data",
|
|
description: "Query trial data from a study's database",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {
|
|
study_name: { type: "string", description: "Study name" },
|
|
query: {
|
|
type: "string",
|
|
enum: ["all", "best", "pareto", "recent", "failed"],
|
|
description: "Type of query",
|
|
},
|
|
limit: { type: "number", description: "Max results to return" },
|
|
objective: { type: "string", description: "Objective to sort by" },
|
|
},
|
|
required: ["study_name", "query"],
|
|
},
|
|
},
|
|
handler: async (args) => {
|
|
const dbPath = `${STUDIES_DIR}/${args.study_name}/3_results/study.db`;
|
|
const db = new Database(dbPath, { readonly: true });
|
|
|
|
let sql: string;
|
|
switch (args.query) {
|
|
case "best":
|
|
sql = `
|
|
SELECT t.number, tv.value as objective_value,
|
|
GROUP_CONCAT(tp.key || '=' || tp.value) as params
|
|
FROM trials t
|
|
JOIN trial_values tv ON t.trial_id = tv.trial_id
|
|
JOIN trial_params tp ON t.trial_id = tp.trial_id
|
|
WHERE t.state = 'COMPLETE'
|
|
GROUP BY t.trial_id
|
|
ORDER BY tv.value ASC
|
|
LIMIT ${args.limit || 10}
|
|
`;
|
|
break;
|
|
// ... other query types
|
|
}
|
|
|
|
const results = db.prepare(sql).all();
|
|
db.close();
|
|
|
|
return {
|
|
content: [{
|
|
type: "text",
|
|
text: JSON.stringify(results, null, 2),
|
|
}],
|
|
};
|
|
},
|
|
},
|
|
|
|
{
|
|
definition: {
|
|
name: "analyze_convergence",
|
|
description: "Analyze optimization convergence and provide insights",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {
|
|
study_name: { type: "string" },
|
|
window_size: { type: "number", description: "Rolling window for analysis" },
|
|
},
|
|
required: ["study_name"],
|
|
},
|
|
},
|
|
handler: async (args) => {
|
|
// Call Python analysis script
|
|
const script = `
|
|
from optimization_engine.reporting.convergence_analyzer import analyze_convergence
|
|
result = analyze_convergence("${args.study_name}", window=${args.window_size || 10})
|
|
import json
|
|
print(json.dumps(result, indent=2))
|
|
`;
|
|
const output = execSync(`${PYTHON_PATH} -c '${script}'`, { encoding: "utf-8" });
|
|
return { content: [{ type: "text", text: output }] };
|
|
},
|
|
},
|
|
];
|
|
```
|
|
|
|
### 1.4 Implement Power Mode Tools
|
|
|
|
```typescript
|
|
// tools/admin.ts
|
|
export const adminTools = [
|
|
{
|
|
definition: {
|
|
name: "edit_file",
|
|
description: "Edit a file in the Atomizer codebase",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {
|
|
file_path: { type: "string", description: "Relative path from Atomizer root" },
|
|
old_content: { type: "string", description: "Content to replace" },
|
|
new_content: { type: "string", description: "New content" },
|
|
},
|
|
required: ["file_path", "old_content", "new_content"],
|
|
},
|
|
},
|
|
handler: async (args) => {
|
|
const fs = await import("fs/promises");
|
|
const fullPath = `C:/Users/antoi/Atomizer/${args.file_path}`;
|
|
|
|
const content = await fs.readFile(fullPath, "utf-8");
|
|
if (!content.includes(args.old_content)) {
|
|
return {
|
|
content: [{ type: "text", text: "Error: old_content not found in file" }],
|
|
isError: true,
|
|
};
|
|
}
|
|
|
|
const newContent = content.replace(args.old_content, args.new_content);
|
|
await fs.writeFile(fullPath, newContent);
|
|
|
|
return {
|
|
content: [{ type: "text", text: `File updated: ${args.file_path}` }],
|
|
};
|
|
},
|
|
},
|
|
|
|
{
|
|
definition: {
|
|
name: "run_shell_command",
|
|
description: "Run a shell command (Power Mode only)",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {
|
|
command: { type: "string" },
|
|
cwd: { type: "string", description: "Working directory" },
|
|
},
|
|
required: ["command"],
|
|
},
|
|
},
|
|
handler: async (args) => {
|
|
const output = execSync(args.command, {
|
|
encoding: "utf-8",
|
|
cwd: args.cwd || "C:/Users/antoi/Atomizer",
|
|
});
|
|
return { content: [{ type: "text", text: output }] };
|
|
},
|
|
},
|
|
|
|
{
|
|
definition: {
|
|
name: "create_extractor",
|
|
description: "Create a new physics extractor following Atomizer conventions",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {
|
|
name: { type: "string", description: "Extractor name (e.g., 'thermal_gradient')" },
|
|
physics_type: {
|
|
type: "string",
|
|
enum: ["displacement", "stress", "frequency", "thermal", "custom"],
|
|
},
|
|
description: { type: "string" },
|
|
},
|
|
required: ["name", "physics_type", "description"],
|
|
},
|
|
},
|
|
handler: async (args) => {
|
|
// Generate extractor from template
|
|
const template = `\"\"\"
|
|
${args.name} Extractor
|
|
|
|
Auto-generated extractor for ${args.physics_type} physics.
|
|
${args.description}
|
|
\"\"\"
|
|
|
|
from pathlib import Path
|
|
from typing import Dict, Any, Optional
|
|
import numpy as np
|
|
|
|
def extract_${args.name}(
|
|
op2_path: Path,
|
|
config: Optional[Dict[str, Any]] = None
|
|
) -> Dict[str, Any]:
|
|
\"\"\"
|
|
Extract ${args.name} from OP2 results.
|
|
|
|
Args:
|
|
op2_path: Path to the OP2 file
|
|
config: Optional configuration dict
|
|
|
|
Returns:
|
|
Dict with extracted values
|
|
\"\"\"
|
|
from pyNastran.op2.op2 import OP2
|
|
|
|
op2 = OP2()
|
|
op2.read_op2(str(op2_path))
|
|
|
|
# TODO: Implement extraction logic
|
|
result = {
|
|
"value": 0.0,
|
|
"units": "TODO",
|
|
"metadata": {}
|
|
}
|
|
|
|
return result
|
|
`;
|
|
|
|
const fs = await import("fs/promises");
|
|
const path = `C:/Users/antoi/Atomizer/optimization_engine/extractors/extract_${args.name}.py`;
|
|
await fs.writeFile(path, template);
|
|
|
|
// Update __init__.py
|
|
const initPath = "C:/Users/antoi/Atomizer/optimization_engine/extractors/__init__.py";
|
|
const initContent = await fs.readFile(initPath, "utf-8");
|
|
const newImport = `from .extract_${args.name} import extract_${args.name}\n`;
|
|
await fs.writeFile(initPath, newImport + initContent);
|
|
|
|
return {
|
|
content: [{
|
|
type: "text",
|
|
text: `Created extractor: ${path}\nUpdated __init__.py\n\nNext steps:\n1. Implement the extraction logic\n2. Add to SYS_12_EXTRACTOR_LIBRARY.md\n3. Test with a sample OP2 file`,
|
|
}],
|
|
};
|
|
},
|
|
},
|
|
];
|
|
```
|
|
|
|
---
|
|
|
|
## Phase 2: Backend Session Manager
|
|
|
|
### 2.1 Create Session Manager
|
|
|
|
**File:** `backend/api/services/session_manager.py`
|
|
|
|
```python
|
|
"""
|
|
Claude Code Session Manager
|
|
|
|
Manages persistent Claude Code sessions with MCP integration.
|
|
"""
|
|
|
|
import asyncio
|
|
import json
|
|
import os
|
|
import uuid
|
|
from dataclasses import dataclass, field
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import AsyncGenerator, Dict, List, Literal, Optional
|
|
|
|
from .conversation_store import ConversationStore
|
|
from .context_builder import ContextBuilder
|
|
|
|
ATOMIZER_ROOT = Path(__file__).parent.parent.parent.parent.parent
|
|
MCP_SERVER_PATH = ATOMIZER_ROOT / "mcp-server" / "atomizer-tools"
|
|
|
|
|
|
@dataclass
|
|
class ClaudeSession:
|
|
"""Represents an active Claude Code session"""
|
|
session_id: str
|
|
mode: Literal["user", "power"]
|
|
study_id: Optional[str]
|
|
process: Optional[asyncio.subprocess.Process] = None
|
|
created_at: datetime = field(default_factory=datetime.now)
|
|
last_active: datetime = field(default_factory=datetime.now)
|
|
|
|
def is_alive(self) -> bool:
|
|
return self.process is not None and self.process.returncode is None
|
|
|
|
|
|
class SessionManager:
|
|
"""Manages Claude Code sessions with MCP tools"""
|
|
|
|
def __init__(self):
|
|
self.sessions: Dict[str, ClaudeSession] = {}
|
|
self.store = ConversationStore()
|
|
self.context_builder = ContextBuilder()
|
|
self._cleanup_task: Optional[asyncio.Task] = None
|
|
|
|
async def start(self):
|
|
"""Start the session manager"""
|
|
# Start periodic cleanup of stale sessions
|
|
self._cleanup_task = asyncio.create_task(self._cleanup_loop())
|
|
|
|
async def stop(self):
|
|
"""Stop the session manager"""
|
|
if self._cleanup_task:
|
|
self._cleanup_task.cancel()
|
|
|
|
# Terminate all sessions
|
|
for session in self.sessions.values():
|
|
await self._terminate_session(session)
|
|
|
|
async def create_session(
|
|
self,
|
|
mode: Literal["user", "power"] = "user",
|
|
study_id: Optional[str] = None,
|
|
resume_session_id: Optional[str] = None,
|
|
) -> ClaudeSession:
|
|
"""
|
|
Create or resume a Claude Code session.
|
|
|
|
Args:
|
|
mode: "user" for safe mode, "power" for full access
|
|
study_id: Optional study context
|
|
resume_session_id: Optional session ID to resume
|
|
|
|
Returns:
|
|
ClaudeSession object
|
|
"""
|
|
# Resume existing session if requested
|
|
if resume_session_id and resume_session_id in self.sessions:
|
|
session = self.sessions[resume_session_id]
|
|
if session.is_alive():
|
|
session.last_active = datetime.now()
|
|
return session
|
|
|
|
session_id = resume_session_id or str(uuid.uuid4())[:8]
|
|
|
|
# Build MCP config for this session
|
|
mcp_config = self._build_mcp_config(mode)
|
|
mcp_config_path = ATOMIZER_ROOT / f".claude-mcp-{session_id}.json"
|
|
|
|
with open(mcp_config_path, "w") as f:
|
|
json.dump(mcp_config, f)
|
|
|
|
# Build system prompt with context
|
|
system_prompt = self.context_builder.build(
|
|
mode=mode,
|
|
study_id=study_id,
|
|
conversation_history=self.store.get_history(session_id) if resume_session_id else [],
|
|
)
|
|
|
|
# Write system prompt to temp file
|
|
prompt_path = ATOMIZER_ROOT / f".claude-prompt-{session_id}.md"
|
|
with open(prompt_path, "w") as f:
|
|
f.write(system_prompt)
|
|
|
|
# Start Claude Code subprocess
|
|
# Using --mcp-config to load our MCP server
|
|
# Using --system-prompt to set context
|
|
process = await asyncio.create_subprocess_exec(
|
|
"claude",
|
|
"--mcp-config", str(mcp_config_path),
|
|
"--system-prompt", str(prompt_path),
|
|
"--output-format", "stream-json", # For structured streaming
|
|
stdin=asyncio.subprocess.PIPE,
|
|
stdout=asyncio.subprocess.PIPE,
|
|
stderr=asyncio.subprocess.PIPE,
|
|
cwd=str(ATOMIZER_ROOT),
|
|
env={
|
|
**os.environ,
|
|
"ATOMIZER_MODE": mode,
|
|
"ATOMIZER_STUDY": study_id or "",
|
|
},
|
|
)
|
|
|
|
session = ClaudeSession(
|
|
session_id=session_id,
|
|
mode=mode,
|
|
study_id=study_id,
|
|
process=process,
|
|
)
|
|
|
|
self.sessions[session_id] = session
|
|
return session
|
|
|
|
async def send_message(
|
|
self,
|
|
session_id: str,
|
|
message: str,
|
|
) -> AsyncGenerator[Dict, None]:
|
|
"""
|
|
Send a message to a session and stream the response.
|
|
|
|
Args:
|
|
session_id: Session ID
|
|
message: User message
|
|
|
|
Yields:
|
|
Response chunks (text, tool_calls, etc.)
|
|
"""
|
|
session = self.sessions.get(session_id)
|
|
if not session or not session.is_alive():
|
|
yield {"type": "error", "message": "Session not found or expired"}
|
|
return
|
|
|
|
session.last_active = datetime.now()
|
|
|
|
# Store user message
|
|
self.store.add_message(session_id, "user", message)
|
|
|
|
# Send message to Claude
|
|
session.process.stdin.write(f"{message}\n".encode())
|
|
await session.process.stdin.drain()
|
|
|
|
# Stream response
|
|
full_response = ""
|
|
tool_calls = []
|
|
|
|
while True:
|
|
line = await session.process.stdout.readline()
|
|
if not line:
|
|
break
|
|
|
|
try:
|
|
data = json.loads(line.decode())
|
|
|
|
if data.get("type") == "text":
|
|
full_response += data.get("content", "")
|
|
yield {"type": "text", "content": data.get("content", "")}
|
|
|
|
elif data.get("type") == "tool_use":
|
|
tool_calls.append(data)
|
|
yield {"type": "tool_call", "tool": data}
|
|
|
|
elif data.get("type") == "tool_result":
|
|
yield {"type": "tool_result", "result": data}
|
|
|
|
elif data.get("type") == "done":
|
|
break
|
|
|
|
except json.JSONDecodeError:
|
|
# Raw text output
|
|
text = line.decode()
|
|
full_response += text
|
|
yield {"type": "text", "content": text}
|
|
|
|
# Store assistant response
|
|
self.store.add_message(
|
|
session_id,
|
|
"assistant",
|
|
full_response,
|
|
tool_calls=tool_calls,
|
|
)
|
|
|
|
yield {"type": "done", "tool_calls": tool_calls}
|
|
|
|
async def switch_mode(
|
|
self,
|
|
session_id: str,
|
|
new_mode: Literal["user", "power"],
|
|
) -> ClaudeSession:
|
|
"""Switch a session's mode (requires restart)"""
|
|
session = self.sessions.get(session_id)
|
|
if not session:
|
|
raise ValueError(f"Session {session_id} not found")
|
|
|
|
# Terminate existing session
|
|
await self._terminate_session(session)
|
|
|
|
# Create new session with same ID but different mode
|
|
return await self.create_session(
|
|
mode=new_mode,
|
|
study_id=session.study_id,
|
|
resume_session_id=session_id,
|
|
)
|
|
|
|
async def set_study_context(
|
|
self,
|
|
session_id: str,
|
|
study_id: str,
|
|
):
|
|
"""Update the study context for a session"""
|
|
session = self.sessions.get(session_id)
|
|
if session:
|
|
session.study_id = study_id
|
|
# Send context update command to Claude
|
|
if session.is_alive():
|
|
context_update = self.context_builder.build_study_context(study_id)
|
|
session.process.stdin.write(
|
|
f"[CONTEXT UPDATE] Study changed to: {study_id}\n{context_update}\n".encode()
|
|
)
|
|
await session.process.stdin.drain()
|
|
|
|
def get_session(self, session_id: str) -> Optional[ClaudeSession]:
|
|
"""Get session by ID"""
|
|
return self.sessions.get(session_id)
|
|
|
|
def _build_mcp_config(self, mode: Literal["user", "power"]) -> dict:
|
|
"""Build MCP configuration for Claude"""
|
|
return {
|
|
"mcpServers": {
|
|
"atomizer": {
|
|
"command": "node",
|
|
"args": [str(MCP_SERVER_PATH / "dist" / "index.js")],
|
|
"env": {
|
|
"ATOMIZER_MODE": mode,
|
|
"ATOMIZER_ROOT": str(ATOMIZER_ROOT),
|
|
},
|
|
},
|
|
# Keep existing siemens-docs server
|
|
"siemens-docs": {
|
|
"command": "node",
|
|
"args": [str(ATOMIZER_ROOT / "mcp-server" / "dist" / "index.js")],
|
|
},
|
|
},
|
|
}
|
|
|
|
async def _terminate_session(self, session: ClaudeSession):
|
|
"""Terminate a Claude session"""
|
|
if session.process and session.is_alive():
|
|
session.process.terminate()
|
|
try:
|
|
await asyncio.wait_for(session.process.wait(), timeout=5.0)
|
|
except asyncio.TimeoutError:
|
|
session.process.kill()
|
|
|
|
# Clean up temp files
|
|
for pattern in [f".claude-mcp-{session.session_id}.json",
|
|
f".claude-prompt-{session.session_id}.md"]:
|
|
path = ATOMIZER_ROOT / pattern
|
|
if path.exists():
|
|
path.unlink()
|
|
|
|
async def _cleanup_loop(self):
|
|
"""Periodically clean up stale sessions"""
|
|
while True:
|
|
await asyncio.sleep(300) # Every 5 minutes
|
|
|
|
now = datetime.now()
|
|
stale = [
|
|
sid for sid, session in self.sessions.items()
|
|
if (now - session.last_active).total_seconds() > 3600 # 1 hour
|
|
]
|
|
|
|
for sid in stale:
|
|
session = self.sessions.pop(sid, None)
|
|
if session:
|
|
await self._terminate_session(session)
|
|
```
|
|
|
|
### 2.2 Create Conversation Store
|
|
|
|
**File:** `backend/api/services/conversation_store.py`
|
|
|
|
```python
|
|
"""
|
|
Conversation persistence using SQLite.
|
|
"""
|
|
|
|
import json
|
|
import sqlite3
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
DB_PATH = Path(__file__).parent.parent.parent / "sessions.db"
|
|
|
|
|
|
class ConversationStore:
|
|
"""SQLite-backed conversation storage"""
|
|
|
|
def __init__(self, db_path: Path = DB_PATH):
|
|
self.db_path = db_path
|
|
self._init_db()
|
|
|
|
def _init_db(self):
|
|
"""Initialize database schema"""
|
|
conn = sqlite3.connect(self.db_path)
|
|
conn.execute("""
|
|
CREATE TABLE IF NOT EXISTS sessions (
|
|
session_id TEXT PRIMARY KEY,
|
|
mode TEXT NOT NULL,
|
|
study_id TEXT,
|
|
created_at TEXT NOT NULL,
|
|
last_active TEXT NOT NULL
|
|
)
|
|
""")
|
|
conn.execute("""
|
|
CREATE TABLE IF NOT EXISTS messages (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
session_id TEXT NOT NULL,
|
|
role TEXT NOT NULL,
|
|
content TEXT NOT NULL,
|
|
tool_calls TEXT,
|
|
timestamp TEXT NOT NULL,
|
|
FOREIGN KEY (session_id) REFERENCES sessions(session_id)
|
|
)
|
|
""")
|
|
conn.execute("""
|
|
CREATE INDEX IF NOT EXISTS idx_messages_session
|
|
ON messages(session_id)
|
|
""")
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
def create_session(
|
|
self,
|
|
session_id: str,
|
|
mode: str,
|
|
study_id: Optional[str] = None,
|
|
):
|
|
"""Create a new session record"""
|
|
conn = sqlite3.connect(self.db_path)
|
|
now = datetime.now().isoformat()
|
|
conn.execute(
|
|
"INSERT INTO sessions VALUES (?, ?, ?, ?, ?)",
|
|
(session_id, mode, study_id, now, now),
|
|
)
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
def add_message(
|
|
self,
|
|
session_id: str,
|
|
role: str,
|
|
content: str,
|
|
tool_calls: Optional[List[Dict]] = None,
|
|
):
|
|
"""Add a message to a session"""
|
|
conn = sqlite3.connect(self.db_path)
|
|
conn.execute(
|
|
"INSERT INTO messages (session_id, role, content, tool_calls, timestamp) VALUES (?, ?, ?, ?, ?)",
|
|
(
|
|
session_id,
|
|
role,
|
|
content,
|
|
json.dumps(tool_calls) if tool_calls else None,
|
|
datetime.now().isoformat(),
|
|
),
|
|
)
|
|
conn.execute(
|
|
"UPDATE sessions SET last_active = ? WHERE session_id = ?",
|
|
(datetime.now().isoformat(), session_id),
|
|
)
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
def get_history(
|
|
self,
|
|
session_id: str,
|
|
limit: int = 50,
|
|
) -> List[Dict[str, Any]]:
|
|
"""Get conversation history for a session"""
|
|
conn = sqlite3.connect(self.db_path)
|
|
conn.row_factory = sqlite3.Row
|
|
cursor = conn.execute(
|
|
"""
|
|
SELECT role, content, tool_calls, timestamp
|
|
FROM messages
|
|
WHERE session_id = ?
|
|
ORDER BY id DESC
|
|
LIMIT ?
|
|
""",
|
|
(session_id, limit),
|
|
)
|
|
rows = cursor.fetchall()
|
|
conn.close()
|
|
|
|
messages = []
|
|
for row in reversed(rows):
|
|
msg = {
|
|
"role": row["role"],
|
|
"content": row["content"],
|
|
"timestamp": row["timestamp"],
|
|
}
|
|
if row["tool_calls"]:
|
|
msg["tool_calls"] = json.loads(row["tool_calls"])
|
|
messages.append(msg)
|
|
|
|
return messages
|
|
|
|
def get_session_info(self, session_id: str) -> Optional[Dict]:
|
|
"""Get session metadata"""
|
|
conn = sqlite3.connect(self.db_path)
|
|
conn.row_factory = sqlite3.Row
|
|
cursor = conn.execute(
|
|
"SELECT * FROM sessions WHERE session_id = ?",
|
|
(session_id,),
|
|
)
|
|
row = cursor.fetchone()
|
|
conn.close()
|
|
|
|
if row:
|
|
return dict(row)
|
|
return None
|
|
```
|
|
|
|
### 2.3 Create Context Builder
|
|
|
|
**File:** `backend/api/services/context_builder.py`
|
|
|
|
```python
|
|
"""
|
|
Context builder for Claude sessions.
|
|
|
|
Builds rich context from Atomizer knowledge for Claude prompts.
|
|
"""
|
|
|
|
import json
|
|
from pathlib import Path
|
|
from typing import List, Literal, Optional
|
|
|
|
ATOMIZER_ROOT = Path(__file__).parent.parent.parent.parent.parent
|
|
|
|
|
|
class ContextBuilder:
|
|
"""Builds context prompts for Claude sessions"""
|
|
|
|
def build(
|
|
self,
|
|
mode: Literal["user", "power"],
|
|
study_id: Optional[str] = None,
|
|
conversation_history: Optional[List[dict]] = None,
|
|
) -> str:
|
|
"""Build full system prompt with context"""
|
|
|
|
parts = [self._base_context(mode)]
|
|
|
|
if study_id:
|
|
parts.append(self._study_context(study_id))
|
|
else:
|
|
parts.append(self._global_context())
|
|
|
|
if conversation_history:
|
|
parts.append(self._conversation_context(conversation_history))
|
|
|
|
parts.append(self._mode_instructions(mode))
|
|
|
|
return "\n\n---\n\n".join(parts)
|
|
|
|
def _base_context(self, mode: str) -> str:
|
|
"""Base identity and capabilities"""
|
|
return f"""# Atomizer Assistant
|
|
|
|
You are the Atomizer Assistant - an expert system for structural optimization using FEA.
|
|
|
|
**Current Mode**: {mode.upper()}
|
|
|
|
Your role:
|
|
- Help engineers with FEA optimization workflows
|
|
- Create, configure, and run optimization studies
|
|
- Analyze results and provide insights
|
|
- Explain FEA concepts and methodology
|
|
|
|
Important:
|
|
- Be concise and professional
|
|
- Use technical language appropriate for engineers
|
|
- You are "Atomizer Assistant", not an AI or Claude
|
|
- Use the available tools to perform actions
|
|
"""
|
|
|
|
def _study_context(self, study_id: str) -> str:
|
|
"""Context for a specific study"""
|
|
study_dir = ATOMIZER_ROOT / "studies" / study_id
|
|
|
|
context = f"# Current Study: {study_id}\n\n"
|
|
|
|
# Load config
|
|
config_path = study_dir / "1_setup" / "optimization_config.json"
|
|
if not config_path.exists():
|
|
config_path = study_dir / "optimization_config.json"
|
|
|
|
if config_path.exists():
|
|
with open(config_path) as f:
|
|
config = json.load(f)
|
|
|
|
context += "## Configuration\n\n"
|
|
|
|
dvs = config.get("design_variables", [])
|
|
if dvs:
|
|
context += "**Design Variables:**\n"
|
|
for dv in dvs[:10]:
|
|
context += f"- {dv['name']}: [{dv.get('lower', '?')}, {dv.get('upper', '?')}]\n"
|
|
if len(dvs) > 10:
|
|
context += f"- ... and {len(dvs) - 10} more\n"
|
|
|
|
objs = config.get("objectives", [])
|
|
if objs:
|
|
context += "\n**Objectives:**\n"
|
|
for obj in objs:
|
|
direction = "minimize" if obj.get("direction") == "minimize" else "maximize"
|
|
context += f"- {obj['name']} ({direction})\n"
|
|
|
|
constraints = config.get("constraints", [])
|
|
if constraints:
|
|
context += "\n**Constraints:**\n"
|
|
for c in constraints:
|
|
context += f"- {c['name']}: {c.get('type', 'unknown')}\n"
|
|
|
|
# Check for results
|
|
db_path = study_dir / "3_results" / "study.db"
|
|
if db_path.exists():
|
|
import sqlite3
|
|
conn = sqlite3.connect(db_path)
|
|
try:
|
|
count = conn.execute("SELECT COUNT(*) FROM trials").fetchone()[0]
|
|
best = conn.execute("""
|
|
SELECT MIN(tv.value) FROM trial_values tv
|
|
JOIN trials t ON tv.trial_id = t.trial_id
|
|
WHERE t.state = 'COMPLETE'
|
|
""").fetchone()[0]
|
|
|
|
context += f"\n## Status\n\n"
|
|
context += f"- **Trials completed**: {count}\n"
|
|
if best is not None:
|
|
context += f"- **Best objective**: {best:.4f}\n"
|
|
except:
|
|
pass
|
|
finally:
|
|
conn.close()
|
|
|
|
return context
|
|
|
|
def _global_context(self) -> str:
|
|
"""Context when no study is selected"""
|
|
studies_dir = ATOMIZER_ROOT / "studies"
|
|
|
|
context = "# Available Studies\n\n"
|
|
|
|
if studies_dir.exists():
|
|
studies = [d.name for d in studies_dir.iterdir()
|
|
if d.is_dir() and not d.name.startswith("_")]
|
|
|
|
if studies:
|
|
context += "The following studies are available:\n\n"
|
|
for name in sorted(studies)[:20]:
|
|
context += f"- {name}\n"
|
|
if len(studies) > 20:
|
|
context += f"\n... and {len(studies) - 20} more\n"
|
|
else:
|
|
context += "No studies found. Use `create_study` to create one.\n"
|
|
|
|
context += "\n## Quick Actions\n\n"
|
|
context += "- Create a new study: Describe what you want to optimize\n"
|
|
context += "- List studies: Ask to see available studies\n"
|
|
context += "- Open a study: Say the study name to get its details\n"
|
|
|
|
return context
|
|
|
|
def _conversation_context(self, history: List[dict]) -> str:
|
|
"""Recent conversation for continuity"""
|
|
if not history:
|
|
return ""
|
|
|
|
context = "# Recent Conversation\n\n"
|
|
for msg in history[-10:]:
|
|
role = "User" if msg["role"] == "user" else "Assistant"
|
|
content = msg["content"][:500] # Truncate long messages
|
|
if len(msg["content"]) > 500:
|
|
content += "..."
|
|
context += f"**{role}**: {content}\n\n"
|
|
|
|
return context
|
|
|
|
def _mode_instructions(self, mode: str) -> str:
|
|
"""Mode-specific instructions"""
|
|
if mode == "power":
|
|
return """# Power Mode Instructions
|
|
|
|
You have full access to Atomizer's codebase. You can:
|
|
- Edit any file
|
|
- Create new extractors and protocols
|
|
- Run shell commands
|
|
- Modify the dashboard code
|
|
- Commit and push changes
|
|
|
|
Use these powers responsibly. Always explain what you're doing.
|
|
"""
|
|
else:
|
|
return """# User Mode Instructions
|
|
|
|
You can help with optimization workflows:
|
|
- Create and configure studies
|
|
- Run optimizations
|
|
- Analyze results
|
|
- Generate reports
|
|
|
|
For code modifications, suggest switching to Power Mode.
|
|
"""
|
|
|
|
def build_study_context(self, study_id: str) -> str:
|
|
"""Build just the study context (for updates)"""
|
|
return self._study_context(study_id)
|
|
```
|
|
|
|
### 2.4 Update API Routes
|
|
|
|
**File:** `backend/api/routes/claude.py` (UPDATE)
|
|
|
|
```python
|
|
"""
|
|
Claude Chat API Routes - Updated for Session Manager
|
|
"""
|
|
|
|
from fastapi import APIRouter, HTTPException, WebSocket, WebSocketDisconnect
|
|
from pydantic import BaseModel
|
|
from typing import Optional, List, Dict, Any, Literal
|
|
import json
|
|
|
|
router = APIRouter()
|
|
|
|
# Lazy import to avoid circular dependencies
|
|
_session_manager = None
|
|
|
|
def get_session_manager():
|
|
global _session_manager
|
|
if _session_manager is None:
|
|
from api.services.session_manager import SessionManager
|
|
_session_manager = SessionManager()
|
|
return _session_manager
|
|
|
|
|
|
class CreateSessionRequest(BaseModel):
|
|
mode: Literal["user", "power"] = "user"
|
|
study_id: Optional[str] = None
|
|
resume_session_id: Optional[str] = None
|
|
|
|
|
|
class SendMessageRequest(BaseModel):
|
|
session_id: str
|
|
message: str
|
|
|
|
|
|
class SwitchModeRequest(BaseModel):
|
|
session_id: str
|
|
mode: Literal["user", "power"]
|
|
|
|
|
|
@router.post("/sessions")
|
|
async def create_session(request: CreateSessionRequest):
|
|
"""Create or resume a Claude session"""
|
|
try:
|
|
manager = get_session_manager()
|
|
session = await manager.create_session(
|
|
mode=request.mode,
|
|
study_id=request.study_id,
|
|
resume_session_id=request.resume_session_id,
|
|
)
|
|
return {
|
|
"session_id": session.session_id,
|
|
"mode": session.mode,
|
|
"study_id": session.study_id,
|
|
}
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@router.post("/sessions/{session_id}/mode")
|
|
async def switch_mode(session_id: str, request: SwitchModeRequest):
|
|
"""Switch session mode"""
|
|
try:
|
|
manager = get_session_manager()
|
|
session = await manager.switch_mode(session_id, request.mode)
|
|
return {
|
|
"session_id": session.session_id,
|
|
"mode": session.mode,
|
|
}
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@router.websocket("/sessions/{session_id}/ws")
|
|
async def session_websocket(websocket: WebSocket, session_id: str):
|
|
"""WebSocket for real-time chat with a session"""
|
|
await websocket.accept()
|
|
|
|
manager = get_session_manager()
|
|
session = manager.get_session(session_id)
|
|
|
|
if not session:
|
|
await websocket.send_json({"type": "error", "message": "Session not found"})
|
|
await websocket.close()
|
|
return
|
|
|
|
try:
|
|
while True:
|
|
data = await websocket.receive_json()
|
|
|
|
if data.get("type") == "message":
|
|
message = data.get("content", "")
|
|
|
|
async for chunk in manager.send_message(session_id, message):
|
|
await websocket.send_json(chunk)
|
|
|
|
elif data.get("type") == "set_study":
|
|
study_id = data.get("study_id")
|
|
await manager.set_study_context(session_id, study_id)
|
|
await websocket.send_json({
|
|
"type": "context_updated",
|
|
"study_id": study_id,
|
|
})
|
|
|
|
elif data.get("type") == "ping":
|
|
await websocket.send_json({"type": "pong"})
|
|
|
|
except WebSocketDisconnect:
|
|
pass
|
|
except Exception as e:
|
|
await websocket.send_json({"type": "error", "message": str(e)})
|
|
|
|
|
|
# Keep legacy endpoints for backwards compatibility
|
|
@router.get("/status")
|
|
async def get_claude_status():
|
|
"""Check if Claude CLI is available"""
|
|
import shutil
|
|
claude_available = shutil.which("claude") is not None
|
|
return {
|
|
"available": claude_available,
|
|
"message": "Claude CLI available" if claude_available else "Claude CLI not found",
|
|
"mode": "session",
|
|
}
|
|
|
|
|
|
@router.get("/suggestions")
|
|
async def get_suggestions(study_id: Optional[str] = None):
|
|
"""Get contextual suggestions"""
|
|
if study_id:
|
|
return {
|
|
"suggestions": [
|
|
f"What's the status of {study_id}?",
|
|
"Show me the best design found",
|
|
"Analyze convergence",
|
|
"Generate a report",
|
|
"Compare the top 5 trials",
|
|
]
|
|
}
|
|
return {
|
|
"suggestions": [
|
|
"List available studies",
|
|
"Create a new optimization study",
|
|
"Help me understand Atomizer",
|
|
"What can you help me with?",
|
|
]
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Phase 3: Frontend Updates
|
|
|
|
### 3.1 Update useChat Hook
|
|
|
|
**File:** `frontend/src/hooks/useChat.ts` (UPDATE)
|
|
|
|
```typescript
|
|
import { useState, useCallback, useRef, useEffect } from 'react';
|
|
import { Message } from '../components/chat/ChatMessage';
|
|
|
|
interface UseChatOptions {
|
|
studyId?: string | null;
|
|
initialMode?: 'user' | 'power';
|
|
onError?: (error: string) => void;
|
|
}
|
|
|
|
interface ToolCall {
|
|
name: string;
|
|
arguments: Record<string, any>;
|
|
result?: any;
|
|
}
|
|
|
|
interface ChatState {
|
|
messages: Message[];
|
|
isThinking: boolean;
|
|
error: string | null;
|
|
suggestions: string[];
|
|
mode: 'user' | 'power';
|
|
sessionId: string | null;
|
|
isConnected: boolean;
|
|
}
|
|
|
|
export function useChat({ studyId, initialMode = 'user', onError }: UseChatOptions = {}) {
|
|
const [state, setState] = useState<ChatState>({
|
|
messages: [],
|
|
isThinking: false,
|
|
error: null,
|
|
suggestions: [],
|
|
mode: initialMode,
|
|
sessionId: null,
|
|
isConnected: false,
|
|
});
|
|
|
|
const wsRef = useRef<WebSocket | null>(null);
|
|
const reconnectTimeoutRef = useRef<number | null>(null);
|
|
|
|
// Create session on mount
|
|
useEffect(() => {
|
|
createSession();
|
|
return () => {
|
|
if (wsRef.current) {
|
|
wsRef.current.close();
|
|
}
|
|
if (reconnectTimeoutRef.current) {
|
|
clearTimeout(reconnectTimeoutRef.current);
|
|
}
|
|
};
|
|
}, []);
|
|
|
|
// Update study context when study changes
|
|
useEffect(() => {
|
|
if (state.sessionId && wsRef.current?.readyState === WebSocket.OPEN && studyId) {
|
|
wsRef.current.send(JSON.stringify({
|
|
type: 'set_study',
|
|
study_id: studyId,
|
|
}));
|
|
}
|
|
}, [studyId, state.sessionId]);
|
|
|
|
const createSession = async () => {
|
|
try {
|
|
const response = await fetch('/api/claude/sessions', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
mode: state.mode,
|
|
study_id: studyId || undefined,
|
|
}),
|
|
});
|
|
|
|
if (!response.ok) throw new Error('Failed to create session');
|
|
|
|
const data = await response.json();
|
|
setState(prev => ({ ...prev, sessionId: data.session_id }));
|
|
|
|
connectWebSocket(data.session_id);
|
|
loadSuggestions();
|
|
} catch (error: any) {
|
|
setState(prev => ({ ...prev, error: error.message }));
|
|
onError?.(error.message);
|
|
}
|
|
};
|
|
|
|
const connectWebSocket = (sessionId: string) => {
|
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
const ws = new WebSocket(`${protocol}//${window.location.host}/api/claude/sessions/${sessionId}/ws`);
|
|
|
|
ws.onopen = () => {
|
|
setState(prev => ({ ...prev, isConnected: true, error: null }));
|
|
};
|
|
|
|
ws.onmessage = (event) => {
|
|
const data = JSON.parse(event.data);
|
|
handleWebSocketMessage(data);
|
|
};
|
|
|
|
ws.onerror = () => {
|
|
setState(prev => ({ ...prev, error: 'Connection error' }));
|
|
};
|
|
|
|
ws.onclose = () => {
|
|
setState(prev => ({ ...prev, isConnected: false }));
|
|
// Attempt reconnection
|
|
reconnectTimeoutRef.current = window.setTimeout(() => {
|
|
if (state.sessionId) {
|
|
connectWebSocket(state.sessionId);
|
|
}
|
|
}, 3000);
|
|
};
|
|
|
|
wsRef.current = ws;
|
|
};
|
|
|
|
const handleWebSocketMessage = (data: any) => {
|
|
switch (data.type) {
|
|
case 'text':
|
|
setState(prev => {
|
|
const messages = [...prev.messages];
|
|
const lastMsg = messages[messages.length - 1];
|
|
if (lastMsg && lastMsg.role === 'assistant' && lastMsg.isStreaming) {
|
|
lastMsg.content += data.content;
|
|
}
|
|
return { ...prev, messages };
|
|
});
|
|
break;
|
|
|
|
case 'tool_call':
|
|
setState(prev => {
|
|
const messages = [...prev.messages];
|
|
const lastMsg = messages[messages.length - 1];
|
|
if (lastMsg && lastMsg.role === 'assistant') {
|
|
lastMsg.toolCalls = [...(lastMsg.toolCalls || []), data.tool];
|
|
}
|
|
return { ...prev, messages };
|
|
});
|
|
break;
|
|
|
|
case 'tool_result':
|
|
setState(prev => {
|
|
const messages = [...prev.messages];
|
|
const lastMsg = messages[messages.length - 1];
|
|
if (lastMsg && lastMsg.toolCalls) {
|
|
const tool = lastMsg.toolCalls.find(t => t.name === data.result.name);
|
|
if (tool) {
|
|
tool.result = data.result.content;
|
|
}
|
|
}
|
|
return { ...prev, messages };
|
|
});
|
|
break;
|
|
|
|
case 'done':
|
|
setState(prev => {
|
|
const messages = [...prev.messages];
|
|
const lastMsg = messages[messages.length - 1];
|
|
if (lastMsg) {
|
|
lastMsg.isStreaming = false;
|
|
}
|
|
return { ...prev, messages, isThinking: false };
|
|
});
|
|
break;
|
|
|
|
case 'error':
|
|
setState(prev => ({ ...prev, error: data.message, isThinking: false }));
|
|
onError?.(data.message);
|
|
break;
|
|
}
|
|
};
|
|
|
|
const loadSuggestions = async () => {
|
|
try {
|
|
const url = studyId
|
|
? `/api/claude/suggestions?study_id=${encodeURIComponent(studyId)}`
|
|
: '/api/claude/suggestions';
|
|
const response = await fetch(url);
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
setState(prev => ({ ...prev, suggestions: data.suggestions || [] }));
|
|
}
|
|
} catch {
|
|
// Silently fail
|
|
}
|
|
};
|
|
|
|
const sendMessage = useCallback(async (content: string) => {
|
|
if (!content.trim() || state.isThinking || !wsRef.current) return;
|
|
|
|
const userMessage: Message = {
|
|
id: `msg_${Date.now()}`,
|
|
role: 'user',
|
|
content: content.trim(),
|
|
timestamp: new Date(),
|
|
};
|
|
|
|
const assistantMessage: Message = {
|
|
id: `msg_${Date.now()}_response`,
|
|
role: 'assistant',
|
|
content: '',
|
|
timestamp: new Date(),
|
|
isStreaming: true,
|
|
toolCalls: [],
|
|
};
|
|
|
|
setState(prev => ({
|
|
...prev,
|
|
messages: [...prev.messages, userMessage, assistantMessage],
|
|
isThinking: true,
|
|
error: null,
|
|
}));
|
|
|
|
wsRef.current.send(JSON.stringify({
|
|
type: 'message',
|
|
content: content.trim(),
|
|
}));
|
|
}, [state.isThinking]);
|
|
|
|
const switchMode = useCallback(async (newMode: 'user' | 'power') => {
|
|
if (newMode === state.mode) return;
|
|
|
|
try {
|
|
const response = await fetch(`/api/claude/sessions/${state.sessionId}/mode`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ mode: newMode }),
|
|
});
|
|
|
|
if (!response.ok) throw new Error('Failed to switch mode');
|
|
|
|
setState(prev => ({ ...prev, mode: newMode }));
|
|
|
|
// Reconnect WebSocket
|
|
if (wsRef.current) {
|
|
wsRef.current.close();
|
|
}
|
|
if (state.sessionId) {
|
|
connectWebSocket(state.sessionId);
|
|
}
|
|
} catch (error: any) {
|
|
onError?.(error.message);
|
|
}
|
|
}, [state.sessionId, state.mode]);
|
|
|
|
const clearMessages = useCallback(() => {
|
|
setState(prev => ({
|
|
...prev,
|
|
messages: [],
|
|
error: null,
|
|
}));
|
|
}, []);
|
|
|
|
return {
|
|
messages: state.messages,
|
|
isThinking: state.isThinking,
|
|
error: state.error,
|
|
suggestions: state.suggestions,
|
|
mode: state.mode,
|
|
isConnected: state.isConnected,
|
|
sendMessage,
|
|
switchMode,
|
|
clearMessages,
|
|
};
|
|
}
|
|
```
|
|
|
|
### 3.2 Create Mode Toggle Component
|
|
|
|
**File:** `frontend/src/components/chat/ModeToggle.tsx` (NEW)
|
|
|
|
```tsx
|
|
import React from 'react';
|
|
import { User, Zap, AlertTriangle } from 'lucide-react';
|
|
|
|
interface ModeToggleProps {
|
|
mode: 'user' | 'power';
|
|
onModeChange: (mode: 'user' | 'power') => void;
|
|
disabled?: boolean;
|
|
}
|
|
|
|
export const ModeToggle: React.FC<ModeToggleProps> = ({
|
|
mode,
|
|
onModeChange,
|
|
disabled = false,
|
|
}) => {
|
|
const [showConfirm, setShowConfirm] = React.useState(false);
|
|
|
|
const handlePowerClick = () => {
|
|
if (mode === 'power') return;
|
|
setShowConfirm(true);
|
|
};
|
|
|
|
const confirmPowerMode = () => {
|
|
setShowConfirm(false);
|
|
onModeChange('power');
|
|
};
|
|
|
|
return (
|
|
<div className="relative">
|
|
<div className="flex rounded-lg bg-dark-800 p-1 gap-1">
|
|
<button
|
|
onClick={() => onModeChange('user')}
|
|
disabled={disabled}
|
|
className={`flex items-center gap-2 px-3 py-1.5 rounded-md text-sm transition-all ${
|
|
mode === 'user'
|
|
? 'bg-primary-500/20 text-primary-400'
|
|
: 'text-dark-400 hover:text-dark-200'
|
|
} ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
|
|
>
|
|
<User className="w-4 h-4" />
|
|
<span>User</span>
|
|
</button>
|
|
<button
|
|
onClick={handlePowerClick}
|
|
disabled={disabled}
|
|
className={`flex items-center gap-2 px-3 py-1.5 rounded-md text-sm transition-all ${
|
|
mode === 'power'
|
|
? 'bg-amber-500/20 text-amber-400'
|
|
: 'text-dark-400 hover:text-dark-200'
|
|
} ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
|
|
>
|
|
<Zap className="w-4 h-4" />
|
|
<span>Power</span>
|
|
</button>
|
|
</div>
|
|
|
|
{/* Power Mode Confirmation Modal */}
|
|
{showConfirm && (
|
|
<div className="absolute top-full right-0 mt-2 w-72 p-4 bg-dark-800 rounded-lg border border-dark-600 shadow-xl z-50">
|
|
<div className="flex items-start gap-3 mb-3">
|
|
<AlertTriangle className="w-5 h-5 text-amber-400 flex-shrink-0 mt-0.5" />
|
|
<div>
|
|
<h4 className="text-sm font-medium text-dark-100 mb-1">
|
|
Enable Power Mode?
|
|
</h4>
|
|
<p className="text-xs text-dark-400">
|
|
Power Mode allows full system access including file editing,
|
|
code modification, and shell commands.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex gap-2 justify-end">
|
|
<button
|
|
onClick={() => setShowConfirm(false)}
|
|
className="px-3 py-1.5 text-sm text-dark-300 hover:text-dark-100"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
onClick={confirmPowerMode}
|
|
className="px-3 py-1.5 text-sm bg-amber-500/20 text-amber-400 rounded-md hover:bg-amber-500/30"
|
|
>
|
|
Enable
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
```
|
|
|
|
### 3.3 Create Tool Call Card Component
|
|
|
|
**File:** `frontend/src/components/chat/ToolCallCard.tsx` (NEW)
|
|
|
|
```tsx
|
|
import React, { useState } from 'react';
|
|
import { ChevronDown, ChevronRight, CheckCircle, XCircle, Loader } from 'lucide-react';
|
|
|
|
interface ToolCall {
|
|
name: string;
|
|
arguments: Record<string, any>;
|
|
result?: {
|
|
type: string;
|
|
content: any;
|
|
isError?: boolean;
|
|
};
|
|
}
|
|
|
|
interface ToolCallCardProps {
|
|
toolCall: ToolCall;
|
|
}
|
|
|
|
// Map tool names to friendly labels
|
|
const TOOL_LABELS: Record<string, string> = {
|
|
list_studies: 'Listing Studies',
|
|
create_study: 'Creating Study',
|
|
get_study_status: 'Getting Status',
|
|
run_optimization: 'Starting Optimization',
|
|
get_trial_data: 'Querying Trials',
|
|
analyze_convergence: 'Analyzing Convergence',
|
|
generate_report: 'Generating Report',
|
|
edit_file: 'Editing File',
|
|
run_shell_command: 'Running Command',
|
|
};
|
|
|
|
export const ToolCallCard: React.FC<ToolCallCardProps> = ({ toolCall }) => {
|
|
const [isExpanded, setIsExpanded] = useState(false);
|
|
|
|
const label = TOOL_LABELS[toolCall.name] || toolCall.name;
|
|
const hasResult = toolCall.result !== undefined;
|
|
const isError = toolCall.result?.isError;
|
|
const isLoading = !hasResult;
|
|
|
|
return (
|
|
<div className="my-2 rounded-lg border border-dark-600 bg-dark-800/50 overflow-hidden">
|
|
<button
|
|
onClick={() => setIsExpanded(!isExpanded)}
|
|
className="w-full flex items-center gap-2 px-3 py-2 text-left hover:bg-dark-700/50"
|
|
>
|
|
{isExpanded ? (
|
|
<ChevronDown className="w-4 h-4 text-dark-400" />
|
|
) : (
|
|
<ChevronRight className="w-4 h-4 text-dark-400" />
|
|
)}
|
|
|
|
<span className="flex-1 text-sm text-dark-200">{label}</span>
|
|
|
|
{isLoading && (
|
|
<Loader className="w-4 h-4 text-primary-400 animate-spin" />
|
|
)}
|
|
{hasResult && !isError && (
|
|
<CheckCircle className="w-4 h-4 text-green-400" />
|
|
)}
|
|
{hasResult && isError && (
|
|
<XCircle className="w-4 h-4 text-red-400" />
|
|
)}
|
|
</button>
|
|
|
|
{isExpanded && (
|
|
<div className="px-3 pb-3 border-t border-dark-700">
|
|
{/* Arguments */}
|
|
<div className="mt-2">
|
|
<span className="text-xs text-dark-500 uppercase">Arguments</span>
|
|
<pre className="mt-1 p-2 rounded bg-dark-900 text-xs text-dark-300 overflow-x-auto">
|
|
{JSON.stringify(toolCall.arguments, null, 2)}
|
|
</pre>
|
|
</div>
|
|
|
|
{/* Result */}
|
|
{hasResult && (
|
|
<div className="mt-2">
|
|
<span className="text-xs text-dark-500 uppercase">Result</span>
|
|
<pre className={`mt-1 p-2 rounded text-xs overflow-x-auto ${
|
|
isError ? 'bg-red-900/20 text-red-400' : 'bg-dark-900 text-dark-300'
|
|
}`}>
|
|
{typeof toolCall.result?.content === 'string'
|
|
? toolCall.result.content
|
|
: JSON.stringify(toolCall.result?.content, null, 2)}
|
|
</pre>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
```
|
|
|
|
### 3.4 Update ChatMessage Component
|
|
|
|
**File:** `frontend/src/components/chat/ChatMessage.tsx` (UPDATE)
|
|
|
|
```tsx
|
|
import React from 'react';
|
|
import ReactMarkdown from 'react-markdown';
|
|
import { User, Bot } from 'lucide-react';
|
|
import { ToolCallCard } from './ToolCallCard';
|
|
|
|
export interface Message {
|
|
id: string;
|
|
role: 'user' | 'assistant';
|
|
content: string;
|
|
timestamp: Date;
|
|
isStreaming?: boolean;
|
|
toolCalls?: Array<{
|
|
name: string;
|
|
arguments: Record<string, any>;
|
|
result?: any;
|
|
}>;
|
|
}
|
|
|
|
interface ChatMessageProps {
|
|
message: Message;
|
|
}
|
|
|
|
export const ChatMessage: React.FC<ChatMessageProps> = ({ message }) => {
|
|
const isUser = message.role === 'user';
|
|
|
|
return (
|
|
<div className={`flex gap-3 ${isUser ? 'flex-row-reverse' : ''}`}>
|
|
{/* Avatar */}
|
|
<div
|
|
className={`w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0 ${
|
|
isUser
|
|
? 'bg-primary-500/20 text-primary-400'
|
|
: 'bg-dark-700 text-dark-300'
|
|
}`}
|
|
>
|
|
{isUser ? <User className="w-4 h-4" /> : <Bot className="w-4 h-4" />}
|
|
</div>
|
|
|
|
{/* Message Content */}
|
|
<div className={`flex-1 max-w-[85%] ${isUser ? 'text-right' : ''}`}>
|
|
<div
|
|
className={`inline-block rounded-lg px-4 py-2 ${
|
|
isUser
|
|
? 'bg-primary-500/20 text-dark-100'
|
|
: 'bg-dark-700/50 text-dark-200'
|
|
}`}
|
|
>
|
|
{isUser ? (
|
|
<p className="text-sm whitespace-pre-wrap">{message.content}</p>
|
|
) : (
|
|
<div className="text-sm prose prose-invert prose-sm max-w-none">
|
|
<ReactMarkdown>{message.content}</ReactMarkdown>
|
|
|
|
{/* Tool Calls */}
|
|
{message.toolCalls && message.toolCalls.length > 0 && (
|
|
<div className="mt-3 space-y-2">
|
|
{message.toolCalls.map((tool, idx) => (
|
|
<ToolCallCard key={idx} toolCall={tool} />
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Streaming cursor */}
|
|
{message.isStreaming && (
|
|
<span className="inline-block w-2 h-4 bg-primary-400 animate-pulse ml-1" />
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Timestamp */}
|
|
<div className={`text-xs text-dark-500 mt-1 ${isUser ? 'text-right' : ''}`}>
|
|
{message.timestamp.toLocaleTimeString([], {
|
|
hour: '2-digit',
|
|
minute: '2-digit'
|
|
})}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
```
|
|
|
|
### 3.5 Update ChatPane Component
|
|
|
|
**File:** `frontend/src/components/chat/ChatPane.tsx` (UPDATE)
|
|
|
|
Add mode toggle and update header:
|
|
|
|
```tsx
|
|
import React, { useRef, useEffect, useState } from 'react';
|
|
import {
|
|
MessageSquare,
|
|
ChevronRight,
|
|
ChevronLeft,
|
|
Trash2,
|
|
Minimize2,
|
|
Maximize2,
|
|
Wifi,
|
|
WifiOff,
|
|
} from 'lucide-react';
|
|
import { ChatMessage } from './ChatMessage';
|
|
import { ChatInput } from './ChatInput';
|
|
import { ThinkingIndicator } from './ThinkingIndicator';
|
|
import { ModeToggle } from './ModeToggle';
|
|
import { useChat } from '../../hooks/useChat';
|
|
import { useStudy } from '../../context/StudyContext';
|
|
|
|
interface ChatPaneProps {
|
|
isOpen: boolean;
|
|
onToggle: () => void;
|
|
className?: string;
|
|
}
|
|
|
|
export const ChatPane: React.FC<ChatPaneProps> = ({
|
|
isOpen,
|
|
onToggle,
|
|
className = '',
|
|
}) => {
|
|
const { selectedStudy } = useStudy();
|
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
|
const [isExpanded, setIsExpanded] = useState(false);
|
|
|
|
const {
|
|
messages,
|
|
isThinking,
|
|
error,
|
|
suggestions,
|
|
mode,
|
|
isConnected,
|
|
sendMessage,
|
|
switchMode,
|
|
clearMessages,
|
|
} = useChat({
|
|
studyId: selectedStudy?.id,
|
|
onError: (err) => console.error('Chat error:', err),
|
|
});
|
|
|
|
useEffect(() => {
|
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
|
}, [messages, isThinking]);
|
|
|
|
if (!isOpen) {
|
|
return (
|
|
<button
|
|
onClick={onToggle}
|
|
className={`fixed right-0 top-1/2 -translate-y-1/2 z-40 bg-dark-800 border border-dark-700 border-r-0 rounded-l-lg p-3 hover:bg-dark-700 transition-colors group ${className}`}
|
|
title="Open Assistant"
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<ChevronLeft className="w-4 h-4 text-dark-400 group-hover:text-primary-400" />
|
|
<MessageSquare className="w-5 h-5 text-primary-400" />
|
|
</div>
|
|
</button>
|
|
);
|
|
}
|
|
|
|
const panelWidth = isExpanded ? 'w-[600px]' : 'w-[380px]';
|
|
|
|
return (
|
|
<div className={`fixed right-0 top-0 h-full ${panelWidth} bg-dark-850 border-l border-dark-700 flex flex-col z-40 shadow-2xl transition-all duration-200 ${className}`}>
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between px-4 py-3 border-b border-dark-700 bg-dark-800/50">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-8 h-8 rounded-lg bg-primary-500/20 flex items-center justify-center">
|
|
<MessageSquare className="w-4 h-4 text-primary-400" />
|
|
</div>
|
|
<div>
|
|
<div className="flex items-center gap-2">
|
|
<h3 className="font-medium text-dark-100 text-sm">Atomizer Assistant</h3>
|
|
{isConnected ? (
|
|
<Wifi className="w-3 h-3 text-green-400" />
|
|
) : (
|
|
<WifiOff className="w-3 h-3 text-red-400" />
|
|
)}
|
|
</div>
|
|
<p className="text-xs text-dark-400">
|
|
{selectedStudy ? selectedStudy.name || selectedStudy.id : 'No study'}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
<ModeToggle mode={mode} onModeChange={switchMode} disabled={isThinking} />
|
|
{messages.length > 0 && (
|
|
<button
|
|
onClick={clearMessages}
|
|
className="p-2 rounded-lg text-dark-400 hover:text-red-400 hover:bg-dark-700"
|
|
title="Clear conversation"
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
</button>
|
|
)}
|
|
<button
|
|
onClick={() => setIsExpanded(!isExpanded)}
|
|
className="p-2 rounded-lg text-dark-400 hover:text-primary-400 hover:bg-dark-700"
|
|
title={isExpanded ? 'Collapse' : 'Expand'}
|
|
>
|
|
{isExpanded ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />}
|
|
</button>
|
|
<button
|
|
onClick={onToggle}
|
|
className="p-2 rounded-lg text-dark-400 hover:text-dark-200 hover:bg-dark-700"
|
|
title="Close"
|
|
>
|
|
<ChevronRight className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Mode indicator bar */}
|
|
{mode === 'power' && (
|
|
<div className="px-4 py-2 bg-amber-500/10 border-b border-amber-500/20">
|
|
<p className="text-xs text-amber-400">
|
|
⚡ Power Mode - Full system access enabled
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Messages Area */}
|
|
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
|
{messages.length === 0 && !isThinking && (
|
|
<div className="text-center py-8">
|
|
<div className="w-12 h-12 rounded-xl bg-primary-500/10 flex items-center justify-center mx-auto mb-4">
|
|
<MessageSquare className="w-6 h-6 text-primary-400" />
|
|
</div>
|
|
<p className="text-dark-300 text-sm max-w-xs mx-auto mb-6">
|
|
{selectedStudy
|
|
? `Ready to help with **${selectedStudy.name || selectedStudy.id}**`
|
|
: 'Select a study or ask me to create one'}
|
|
</p>
|
|
{suggestions.length > 0 && (
|
|
<div className="space-y-2">
|
|
<p className="text-xs text-dark-500 uppercase">Try asking</p>
|
|
{suggestions.slice(0, 3).map((suggestion, idx) => (
|
|
<button
|
|
key={idx}
|
|
onClick={() => sendMessage(suggestion)}
|
|
className="block w-full text-left text-sm px-4 py-2 rounded-lg bg-dark-700/50 text-dark-300 hover:bg-dark-700 hover:text-white"
|
|
>
|
|
{suggestion}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{messages.map((message) => (
|
|
<ChatMessage key={message.id} message={message} />
|
|
))}
|
|
|
|
{isThinking && <ThinkingIndicator />}
|
|
|
|
{error && (
|
|
<div className="bg-red-500/10 border border-red-500/20 rounded-lg px-4 py-3 text-sm text-red-400">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
<div ref={messagesEndRef} />
|
|
</div>
|
|
|
|
{/* Input */}
|
|
<ChatInput
|
|
onSend={sendMessage}
|
|
disabled={isThinking || !isConnected}
|
|
suggestions={suggestions}
|
|
placeholder={
|
|
!isConnected
|
|
? 'Connecting...'
|
|
: selectedStudy
|
|
? `Ask about ${selectedStudy.name || 'this study'}...`
|
|
: 'Ask a question...'
|
|
}
|
|
/>
|
|
</div>
|
|
);
|
|
};
|
|
```
|
|
|
|
---
|
|
|
|
## Phase 4: Integration & Testing
|
|
|
|
### 4.1 Update Main Entry Point
|
|
|
|
**File:** `backend/main.py` (UPDATE)
|
|
|
|
```python
|
|
from fastapi import FastAPI
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from contextlib import asynccontextmanager
|
|
|
|
# Import routers
|
|
from api.routes import studies, trials, optimization, insights, claude
|
|
|
|
# Session manager lifecycle
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI):
|
|
# Startup
|
|
from api.routes.claude import get_session_manager
|
|
manager = get_session_manager()
|
|
await manager.start()
|
|
|
|
yield
|
|
|
|
# Shutdown
|
|
await manager.stop()
|
|
|
|
app = FastAPI(
|
|
title="Atomizer Dashboard API",
|
|
version="2.0.0",
|
|
lifespan=lifespan,
|
|
)
|
|
|
|
# CORS
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=["http://localhost:3004", "http://localhost:5173"],
|
|
allow_credentials=True,
|
|
allow_methods=["*"],
|
|
allow_headers=["*"],
|
|
)
|
|
|
|
# Routes
|
|
app.include_router(studies.router, prefix="/api/studies", tags=["studies"])
|
|
app.include_router(trials.router, prefix="/api/trials", tags=["trials"])
|
|
app.include_router(optimization.router, prefix="/api/optimization", tags=["optimization"])
|
|
app.include_router(insights.router, prefix="/api/insights", tags=["insights"])
|
|
app.include_router(claude.router, prefix="/api/claude", tags=["claude"])
|
|
|
|
@app.get("/health")
|
|
async def health():
|
|
return {"status": "healthy", "version": "2.0.0"}
|
|
```
|
|
|
|
### 4.2 Test Plan
|
|
|
|
```markdown
|
|
## Test Cases
|
|
|
|
### Phase 1: MCP Server
|
|
- [ ] Server starts without errors
|
|
- [ ] list_studies returns study list
|
|
- [ ] get_study_status returns correct info
|
|
- [ ] create_study creates valid study structure
|
|
- [ ] run_optimization starts optimization
|
|
- [ ] Power mode tools only available in power mode
|
|
|
|
### Phase 2: Session Manager
|
|
- [ ] Session creation works
|
|
- [ ] WebSocket connects
|
|
- [ ] Messages stream correctly
|
|
- [ ] Tool calls are executed
|
|
- [ ] Mode switching works
|
|
- [ ] Session cleanup on disconnect
|
|
- [ ] Conversation persistence
|
|
|
|
### Phase 3: Frontend
|
|
- [ ] Mode toggle displays correctly
|
|
- [ ] Mode switch confirmation works
|
|
- [ ] Tool calls render properly
|
|
- [ ] Messages stream smoothly
|
|
- [ ] Connection status shows correctly
|
|
- [ ] Error handling works
|
|
|
|
### Phase 4: End-to-End
|
|
- [ ] Create study via chat
|
|
- [ ] Run optimization via chat
|
|
- [ ] Analyze results via chat
|
|
- [ ] Power mode: Edit file
|
|
- [ ] Power mode: Create extractor
|
|
- [ ] Session resume after page refresh
|
|
```
|
|
|
|
---
|
|
|
|
## Task List (for Ralph Loop)
|
|
|
|
### PHASE 1: MCP SERVER (Priority: High)
|
|
|
|
```
|
|
[ ] P1.1 Create MCP server scaffold
|
|
- Create mcp-server/atomizer-tools/package.json
|
|
- Create mcp-server/atomizer-tools/tsconfig.json
|
|
- Create mcp-server/atomizer-tools/src/index.ts
|
|
|
|
[ ] P1.2 Implement study tools
|
|
- Create src/tools/study.ts
|
|
- Implement list_studies
|
|
- Implement get_study_status
|
|
- Implement create_study
|
|
|
|
[ ] P1.3 Implement optimization tools
|
|
- Create src/tools/optimization.ts
|
|
- Implement run_optimization
|
|
- Implement stop_optimization
|
|
- Implement get_trial_data
|
|
|
|
[ ] P1.4 Implement analysis tools
|
|
- Create src/tools/analysis.ts
|
|
- Implement analyze_convergence
|
|
- Implement compare_trials
|
|
- Implement get_best_design
|
|
|
|
[ ] P1.5 Implement reporting tools
|
|
- Create src/tools/reporting.ts
|
|
- Implement generate_report
|
|
|
|
[ ] P1.6 Implement physics tools
|
|
- Create src/tools/physics.ts
|
|
- Implement explain_physics
|
|
- Implement recommend_method
|
|
|
|
[ ] P1.7 Implement admin tools (power mode)
|
|
- Create src/tools/admin.ts
|
|
- Implement edit_file
|
|
- Implement create_extractor
|
|
- Implement run_shell_command
|
|
|
|
[ ] P1.8 Build and test MCP server
|
|
- npm install
|
|
- npm run build
|
|
- Test with claude --mcp-config
|
|
```
|
|
|
|
### PHASE 2: BACKEND SESSION MANAGER (Priority: High)
|
|
|
|
```
|
|
[ ] P2.1 Create conversation store
|
|
- Create backend/api/services/conversation_store.py
|
|
- Implement SQLite schema
|
|
- Implement CRUD operations
|
|
|
|
[ ] P2.2 Create context builder
|
|
- Create backend/api/services/context_builder.py
|
|
- Implement base context
|
|
- Implement study context
|
|
- Implement mode instructions
|
|
|
|
[ ] P2.3 Create session manager
|
|
- Create backend/api/services/session_manager.py
|
|
- Implement session lifecycle
|
|
- Implement WebSocket communication
|
|
- Implement mode switching
|
|
|
|
[ ] P2.4 Update API routes
|
|
- Update backend/api/routes/claude.py
|
|
- Add session endpoints
|
|
- Add WebSocket endpoint
|
|
- Keep legacy endpoints
|
|
|
|
[ ] P2.5 Update main.py
|
|
- Add lifespan handler
|
|
- Initialize session manager
|
|
```
|
|
|
|
### PHASE 3: FRONTEND UPDATES (Priority: High)
|
|
|
|
```
|
|
[ ] P3.1 Create new components
|
|
- Create ModeToggle.tsx
|
|
- Create ToolCallCard.tsx
|
|
|
|
[ ] P3.2 Update useChat hook
|
|
- Add WebSocket support
|
|
- Add session management
|
|
- Add mode switching
|
|
- Add tool call handling
|
|
|
|
[ ] P3.3 Update ChatMessage
|
|
- Add tool call rendering
|
|
- Update styling
|
|
|
|
[ ] P3.4 Update ChatPane
|
|
- Add mode toggle
|
|
- Add connection status
|
|
- Update styling
|
|
|
|
[ ] P3.5 Export new components
|
|
- Update components/chat/index.ts
|
|
```
|
|
|
|
### PHASE 4: TESTING & POLISH (Priority: Medium)
|
|
|
|
```
|
|
[ ] P4.1 Backend tests
|
|
- Test session creation
|
|
- Test message handling
|
|
- Test mode switching
|
|
|
|
[ ] P4.2 Frontend tests
|
|
- Test mode toggle
|
|
- Test tool rendering
|
|
- Test connection handling
|
|
|
|
[ ] P4.3 End-to-end tests
|
|
- Test full conversation flow
|
|
- Test study creation
|
|
- Test power mode operations
|
|
|
|
[ ] P4.4 Documentation
|
|
- Update README
|
|
- Add API documentation
|
|
- Add user guide
|
|
```
|
|
|
|
---
|
|
|
|
## Dependencies
|
|
|
|
```
|
|
Backend:
|
|
- FastAPI (existing)
|
|
- SQLite3 (built-in)
|
|
- asyncio (built-in)
|
|
|
|
MCP Server (new):
|
|
- @modelcontextprotocol/sdk
|
|
- better-sqlite3
|
|
- zod
|
|
- typescript
|
|
|
|
Frontend (existing):
|
|
- React
|
|
- react-markdown
|
|
- lucide-react
|
|
```
|
|
|
|
---
|
|
|
|
## Risk Mitigation
|
|
|
|
| Risk | Mitigation |
|
|
|------|------------|
|
|
| Claude CLI doesn't support --mcp-config | Fall back to enhanced context approach |
|
|
| WebSocket connection instability | Implement reconnection logic |
|
|
| Session memory issues | Implement cleanup and limits |
|
|
| MCP server crashes | Add error boundaries and restart logic |
|
|
|
|
---
|
|
|
|
## Success Criteria
|
|
|
|
1. **User Mode**: User can create study, run optimization, get results via chat
|
|
2. **Power Mode**: User can edit files, create extractors via chat
|
|
3. **Persistence**: Conversation survives page refresh
|
|
4. **Context**: Chat knows about current study when inside one
|
|
5. **Demo-ready**: Smooth enough for YouTube demo
|
|
|
|
---
|
|
|
|
*Document Version: 1.0*
|
|
*Last Updated: 2025-01-08*
|