Archive Management: - Moved RALPH_LOOP, CANVAS, and dashboard implementation plans to archive/review/ for CEO review - Moved completed restructuring plan and protocol v1 to archive/historical/ - Moved old session summaries to archive/review/ New HQ Documentation (docs/hq/): - README.md: Overview of Atomizer-HQ multi-agent optimization team - PROJECT_STRUCTURE.md: Standard KB-integrated project layout with Hydrotech reference - KB_CONVENTIONS.md: Knowledge Base accumulation principles with generation tracking - AGENT_WORKFLOWS.md: Project lifecycle phases and agent handoffs (OP_09 integration) - STUDY_CONVENTIONS.md: Technical study execution standards and atomizer_spec.json format Index Update: - Reorganized docs/00_INDEX.md with HQ docs prominent - Updated structure to reflect new agent-focused organization - Maintained core documentation access for engineers No files deleted, only moved to appropriate archive locations.
75 KiB
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.jsonmcp-server/atomizer-tools/tsconfig.jsonmcp-server/atomizer-tools/src/index.ts
Package.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:
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:
list_studies- List available studiesget_study_status- Get current study statecreate_study- Create new study from descriptionrun_optimization- Start optimizationget_trial_data- Query trial resultsanalyze_convergence- Convergence analysis
Tool Definition Template:
// 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
// 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
// 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
"""
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
"""
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
"""
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)
"""
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)
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)
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)
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)
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:
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)
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
## 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
- User Mode: User can create study, run optimization, get results via chat
- Power Mode: User can edit files, create extractors via chat
- Persistence: Conversation survives page refresh
- Context: Chat knows about current study when inside one
- Demo-ready: Smooth enough for YouTube demo
Document Version: 1.0 Last Updated: 2025-01-08