feat: Add dashboard chat integration and MCP server
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>
This commit is contained in:
295
atomizer-dashboard/backend/api/services/conversation_store.py
Normal file
295
atomizer-dashboard/backend/api/services/conversation_store.py
Normal file
@@ -0,0 +1,295 @@
|
||||
"""
|
||||
Conversation Store
|
||||
|
||||
SQLite-backed persistence for Claude chat sessions.
|
||||
"""
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
# Database path relative to backend directory
|
||||
DB_PATH = Path(__file__).parent.parent.parent / "sessions.db"
|
||||
|
||||
|
||||
class ConversationStore:
|
||||
"""SQLite-backed conversation storage for Claude sessions"""
|
||||
|
||||
def __init__(self, db_path: Path = DB_PATH):
|
||||
self.db_path = db_path
|
||||
self._init_db()
|
||||
|
||||
def _get_conn(self) -> sqlite3.Connection:
|
||||
"""Get database connection with row factory"""
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
def _init_db(self):
|
||||
"""Initialize database schema"""
|
||||
conn = self._get_conn()
|
||||
try:
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
session_id TEXT PRIMARY KEY,
|
||||
mode TEXT NOT NULL DEFAULT 'user',
|
||||
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.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_timestamp
|
||||
ON messages(session_id, timestamp)
|
||||
""")
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def create_session(
|
||||
self,
|
||||
session_id: str,
|
||||
mode: str = "user",
|
||||
study_id: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Create a new session record.
|
||||
|
||||
Args:
|
||||
session_id: Unique session identifier
|
||||
mode: "user" or "power"
|
||||
study_id: Optional study context
|
||||
|
||||
Returns:
|
||||
Session record dict
|
||||
"""
|
||||
conn = self._get_conn()
|
||||
try:
|
||||
now = datetime.now().isoformat()
|
||||
conn.execute(
|
||||
"""INSERT INTO sessions (session_id, mode, study_id, created_at, last_active)
|
||||
VALUES (?, ?, ?, ?, ?)""",
|
||||
(session_id, mode, study_id, now, now),
|
||||
)
|
||||
conn.commit()
|
||||
return {
|
||||
"session_id": session_id,
|
||||
"mode": mode,
|
||||
"study_id": study_id,
|
||||
"created_at": now,
|
||||
"last_active": now,
|
||||
}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def get_session(self, session_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Get session by ID"""
|
||||
conn = self._get_conn()
|
||||
try:
|
||||
cursor = conn.execute(
|
||||
"SELECT * FROM sessions WHERE session_id = ?",
|
||||
(session_id,),
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
if row:
|
||||
return dict(row)
|
||||
return None
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def update_session(
|
||||
self,
|
||||
session_id: str,
|
||||
mode: Optional[str] = None,
|
||||
study_id: Optional[str] = None,
|
||||
):
|
||||
"""Update session properties"""
|
||||
conn = self._get_conn()
|
||||
try:
|
||||
updates = ["last_active = ?"]
|
||||
values: List[Any] = [datetime.now().isoformat()]
|
||||
|
||||
if mode is not None:
|
||||
updates.append("mode = ?")
|
||||
values.append(mode)
|
||||
if study_id is not None:
|
||||
updates.append("study_id = ?")
|
||||
values.append(study_id)
|
||||
|
||||
values.append(session_id)
|
||||
conn.execute(
|
||||
f"UPDATE sessions SET {', '.join(updates)} WHERE session_id = ?",
|
||||
values,
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def touch_session(self, session_id: str):
|
||||
"""Update last_active timestamp"""
|
||||
conn = self._get_conn()
|
||||
try:
|
||||
conn.execute(
|
||||
"UPDATE sessions SET last_active = ? WHERE session_id = ?",
|
||||
(datetime.now().isoformat(), session_id),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def add_message(
|
||||
self,
|
||||
session_id: str,
|
||||
role: str,
|
||||
content: str,
|
||||
tool_calls: Optional[List[Dict]] = None,
|
||||
) -> int:
|
||||
"""
|
||||
Add a message to a session.
|
||||
|
||||
Args:
|
||||
session_id: Session identifier
|
||||
role: "user" or "assistant"
|
||||
content: Message text content
|
||||
tool_calls: Optional list of tool call records
|
||||
|
||||
Returns:
|
||||
Message ID
|
||||
"""
|
||||
conn = self._get_conn()
|
||||
try:
|
||||
cursor = 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(),
|
||||
),
|
||||
)
|
||||
# Update session last_active
|
||||
conn.execute(
|
||||
"UPDATE sessions SET last_active = ? WHERE session_id = ?",
|
||||
(datetime.now().isoformat(), session_id),
|
||||
)
|
||||
conn.commit()
|
||||
return cursor.lastrowid or 0
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def get_history(
|
||||
self,
|
||||
session_id: str,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get conversation history for a session.
|
||||
|
||||
Args:
|
||||
session_id: Session identifier
|
||||
limit: Maximum messages to return
|
||||
offset: Number of messages to skip (from most recent)
|
||||
|
||||
Returns:
|
||||
List of message dicts in chronological order
|
||||
"""
|
||||
conn = self._get_conn()
|
||||
try:
|
||||
cursor = conn.execute(
|
||||
"""
|
||||
SELECT role, content, tool_calls, timestamp
|
||||
FROM messages
|
||||
WHERE session_id = ?
|
||||
ORDER BY id DESC
|
||||
LIMIT ? OFFSET ?
|
||||
""",
|
||||
(session_id, limit, offset),
|
||||
)
|
||||
rows = cursor.fetchall()
|
||||
|
||||
messages = []
|
||||
for row in reversed(rows):
|
||||
msg: Dict[str, Any] = {
|
||||
"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
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def get_message_count(self, session_id: str) -> int:
|
||||
"""Get total message count for a session"""
|
||||
conn = self._get_conn()
|
||||
try:
|
||||
cursor = conn.execute(
|
||||
"SELECT COUNT(*) FROM messages WHERE session_id = ?",
|
||||
(session_id,),
|
||||
)
|
||||
return cursor.fetchone()[0]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def delete_session(self, session_id: str):
|
||||
"""Delete a session and all its messages"""
|
||||
conn = self._get_conn()
|
||||
try:
|
||||
conn.execute(
|
||||
"DELETE FROM messages WHERE session_id = ?",
|
||||
(session_id,),
|
||||
)
|
||||
conn.execute(
|
||||
"DELETE FROM sessions WHERE session_id = ?",
|
||||
(session_id,),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def get_stale_sessions(self, max_age_hours: int = 24) -> List[str]:
|
||||
"""Get session IDs that haven't been active for max_age_hours"""
|
||||
conn = self._get_conn()
|
||||
try:
|
||||
cutoff = datetime.now()
|
||||
# Calculate cutoff time
|
||||
from datetime import timedelta
|
||||
cutoff = (cutoff - timedelta(hours=max_age_hours)).isoformat()
|
||||
|
||||
cursor = conn.execute(
|
||||
"SELECT session_id FROM sessions WHERE last_active < ?",
|
||||
(cutoff,),
|
||||
)
|
||||
return [row["session_id"] for row in cursor.fetchall()]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def cleanup_stale_sessions(self, max_age_hours: int = 24) -> int:
|
||||
"""Delete stale sessions and return count deleted"""
|
||||
stale = self.get_stale_sessions(max_age_hours)
|
||||
for session_id in stale:
|
||||
self.delete_session(session_id)
|
||||
return len(stale)
|
||||
Reference in New Issue
Block a user