Add Claude Code conversation tracker #46
@@ -0,0 +1,6 @@
|
|||||||
|
ANTHROPIC_API_KEY=your_api_key_here
|
||||||
|
ANTHROPIC_BASE_URL=https://www.bytecatcode.org
|
||||||
|
SUMMARY_HOUR=23
|
||||||
|
COLLECT_INTERVAL=60
|
||||||
|
DB_PATH=/app/data/conversations.db
|
||||||
|
CONVERSATIONS_PATH=/app/conversations
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy requirements and install dependencies
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Copy application code
|
||||||
|
COPY *.py ./
|
||||||
|
|
||||||
|
# Create data directory
|
||||||
|
RUN mkdir -p /app/data
|
||||||
|
|
||||||
|
# Run as non-root user
|
||||||
|
RUN useradd -m -u 1000 appuser && chown -R appuser:appuser /app
|
||||||
|
USER appuser
|
||||||
|
|
||||||
|
CMD ["python", "main.py"]
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import os
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import datetime
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
import db
|
||||||
|
import parser
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
CONVERSATIONS_PATH = os.environ.get("CONVERSATIONS_PATH", "/app/conversations")
|
||||||
|
COLLECT_INTERVAL = int(os.environ.get("COLLECT_INTERVAL", "60"))
|
||||||
|
|
||||||
|
|
||||||
|
async def discover_jsonl_files():
|
||||||
|
"""Discover all .jsonl files in conversations directory"""
|
||||||
|
conversations_dir = Path(CONVERSATIONS_PATH)
|
||||||
|
if not conversations_dir.exists():
|
||||||
|
logger.warning(f"Conversations directory does not exist: {CONVERSATIONS_PATH}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
jsonl_files = []
|
||||||
|
for file_path in conversations_dir.rglob("*.jsonl"):
|
||||||
|
if file_path.is_file():
|
||||||
|
jsonl_files.append(str(file_path))
|
||||||
|
|
||||||
|
return jsonl_files
|
||||||
|
|
||||||
|
|
||||||
|
async def process_file(file_path: str):
|
||||||
|
"""Process a single conversation file incrementally"""
|
||||||
|
try:
|
||||||
|
# Get checkpoint
|
||||||
|
checkpoint = await db.get_checkpoint(file_path)
|
||||||
|
start_line = checkpoint['last_line_count'] if checkpoint else 0
|
||||||
|
|
||||||
|
# Get file modification time
|
||||||
|
file_stat = os.stat(file_path)
|
||||||
|
file_mtime = datetime.fromtimestamp(file_stat.st_mtime).isoformat()
|
||||||
|
|
||||||
|
# Check if file has been modified
|
||||||
|
if checkpoint and checkpoint['last_modified'] == file_mtime:
|
||||||
|
# File hasn't changed, skip
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# Count total lines
|
||||||
|
with open(file_path, 'r') as f:
|
||||||
|
total_lines = sum(1 for _ in f)
|
||||||
|
|
||||||
|
if total_lines <= start_line:
|
||||||
|
# No new lines
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# Parse new content
|
||||||
|
logger.info(f"Processing {file_path} from line {start_line} to {total_lines}")
|
||||||
|
conversation_data = parser.parse_conversation_file(file_path, start_line)
|
||||||
|
|
||||||
|
if not conversation_data:
|
||||||
|
logger.warning(f"No data extracted from {file_path}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# Store in database
|
||||||
|
await db.upsert_conversation(
|
||||||
|
conversation_data['session_id'],
|
||||||
|
{
|
||||||
|
'project_path': conversation_data['project_path'],
|
||||||
|
'git_branch': conversation_data.get('git_branch'),
|
||||||
|
'started_at': conversation_data['started_at'],
|
||||||
|
'last_updated_at': conversation_data['last_updated_at'],
|
||||||
|
'message_count': conversation_data['message_count'],
|
||||||
|
'user_message_count': conversation_data['user_message_count'],
|
||||||
|
'assistant_message_count': conversation_data['assistant_message_count'],
|
||||||
|
'total_input_tokens': conversation_data['total_input_tokens'],
|
||||||
|
'total_output_tokens': conversation_data['total_output_tokens'],
|
||||||
|
'model': conversation_data.get('model'),
|
||||||
|
'slug': conversation_data.get('slug'),
|
||||||
|
'file_path': file_path
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Store messages
|
||||||
|
for msg in conversation_data['messages']:
|
||||||
|
await db.insert_message(msg)
|
||||||
|
|
||||||
|
# Store tool calls
|
||||||
|
for tool_call in conversation_data['tool_calls']:
|
||||||
|
await db.insert_tool_call(tool_call)
|
||||||
|
|
||||||
|
# Update checkpoint
|
||||||
|
await db.update_checkpoint(file_path, total_lines, file_mtime)
|
||||||
|
|
||||||
|
logger.info(f"Processed {len(conversation_data['messages'])} messages from {file_path}")
|
||||||
|
return len(conversation_data['messages'])
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error processing {file_path}: {e}", exc_info=True)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
async def collect_conversations():
|
||||||
|
"""Main collection loop"""
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
logger.info("Starting conversation collection cycle")
|
||||||
|
|
||||||
|
# Discover files
|
||||||
|
files = await discover_jsonl_files()
|
||||||
|
logger.info(f"Found {len(files)} conversation files")
|
||||||
|
|
||||||
|
# Process each file
|
||||||
|
total_messages = 0
|
||||||
|
for file_path in files:
|
||||||
|
messages_processed = await process_file(file_path)
|
||||||
|
total_messages += messages_processed
|
||||||
|
|
||||||
|
if total_messages > 0:
|
||||||
|
logger.info(f"Collection cycle complete: processed {total_messages} new messages")
|
||||||
|
else:
|
||||||
|
logger.debug("Collection cycle complete: no new messages")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in collection cycle: {e}", exc_info=True)
|
||||||
|
|
||||||
|
# Wait before next cycle
|
||||||
|
await asyncio.sleep(COLLECT_INTERVAL)
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
import os
|
||||||
|
import aiosqlite
|
||||||
|
|
||||||
|
DB_PATH = os.environ.get("DB_PATH", "/app/data/conversations.db")
|
||||||
|
|
||||||
|
|
||||||
|
async def init_db():
|
||||||
|
"""Initialize database with schema"""
|
||||||
|
async with aiosqlite.connect(DB_PATH) as db:
|
||||||
|
# Conversations table
|
||||||
|
await db.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS conversations (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
session_id TEXT UNIQUE NOT NULL,
|
||||||
|
project_path TEXT NOT NULL,
|
||||||
|
git_branch TEXT,
|
||||||
|
started_at DATETIME NOT NULL,
|
||||||
|
last_updated_at DATETIME NOT NULL,
|
||||||
|
message_count INTEGER DEFAULT 0,
|
||||||
|
user_message_count INTEGER DEFAULT 0,
|
||||||
|
assistant_message_count INTEGER DEFAULT 0,
|
||||||
|
total_input_tokens INTEGER DEFAULT 0,
|
||||||
|
total_output_tokens INTEGER DEFAULT 0,
|
||||||
|
model TEXT,
|
||||||
|
slug TEXT,
|
||||||
|
file_path TEXT NOT NULL
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Messages table
|
||||||
|
await db.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS messages (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
session_id TEXT NOT NULL,
|
||||||
|
message_uuid TEXT UNIQUE NOT NULL,
|
||||||
|
parent_uuid TEXT,
|
||||||
|
role TEXT NOT NULL,
|
||||||
|
content_text TEXT,
|
||||||
|
timestamp DATETIME NOT NULL,
|
||||||
|
prompt_id TEXT,
|
||||||
|
model TEXT,
|
||||||
|
input_tokens INTEGER,
|
||||||
|
output_tokens INTEGER,
|
||||||
|
has_tool_use BOOLEAN DEFAULT 0,
|
||||||
|
has_thinking BOOLEAN DEFAULT 0,
|
||||||
|
stop_reason TEXT
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Tool calls table
|
||||||
|
await db.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS tool_calls (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
message_uuid TEXT NOT NULL,
|
||||||
|
tool_use_id TEXT UNIQUE NOT NULL,
|
||||||
|
tool_name TEXT NOT NULL,
|
||||||
|
tool_input TEXT,
|
||||||
|
tool_result TEXT,
|
||||||
|
is_error BOOLEAN DEFAULT 0,
|
||||||
|
timestamp DATETIME NOT NULL
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Daily summaries table
|
||||||
|
await db.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS daily_summaries (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
date TEXT UNIQUE NOT NULL,
|
||||||
|
project_path TEXT,
|
||||||
|
conversation_count INTEGER,
|
||||||
|
total_messages INTEGER,
|
||||||
|
total_tokens INTEGER,
|
||||||
|
summary_content TEXT NOT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Checkpoints table
|
||||||
|
await db.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS checkpoints (
|
||||||
|
file_path TEXT PRIMARY KEY,
|
||||||
|
last_line_count INTEGER NOT NULL,
|
||||||
|
last_modified DATETIME NOT NULL
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Create indexes
|
||||||
|
await db.execute("CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id)")
|
||||||
|
await db.execute("CREATE INDEX IF NOT EXISTS idx_messages_timestamp ON messages(timestamp)")
|
||||||
|
await db.execute("CREATE INDEX IF NOT EXISTS idx_tool_calls_message ON tool_calls(message_uuid)")
|
||||||
|
await db.execute("CREATE INDEX IF NOT EXISTS idx_conversations_project ON conversations(project_path)")
|
||||||
|
await db.execute("CREATE INDEX IF NOT EXISTS idx_conversations_started ON conversations(started_at)")
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def upsert_conversation(session_id, data):
|
||||||
|
"""Insert or update conversation"""
|
||||||
|
async with aiosqlite.connect(DB_PATH) as db:
|
||||||
|
await db.execute("""
|
||||||
|
INSERT INTO conversations (
|
||||||
|
session_id, project_path, git_branch, started_at, last_updated_at,
|
||||||
|
message_count, user_message_count, assistant_message_count,
|
||||||
|
total_input_tokens, total_output_tokens, model, slug, file_path
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(session_id) DO UPDATE SET
|
||||||
|
last_updated_at = excluded.last_updated_at,
|
||||||
|
message_count = excluded.message_count,
|
||||||
|
user_message_count = excluded.user_message_count,
|
||||||
|
assistant_message_count = excluded.assistant_message_count,
|
||||||
|
total_input_tokens = excluded.total_input_tokens,
|
||||||
|
total_output_tokens = excluded.total_output_tokens,
|
||||||
|
model = excluded.model,
|
||||||
|
slug = excluded.slug,
|
||||||
|
git_branch = excluded.git_branch
|
||||||
|
""", (
|
||||||
|
session_id, data['project_path'], data.get('git_branch'),
|
||||||
|
data['started_at'], data['last_updated_at'],
|
||||||
|
data['message_count'], data['user_message_count'], data['assistant_message_count'],
|
||||||
|
data['total_input_tokens'], data['total_output_tokens'],
|
||||||
|
data.get('model'), data.get('slug'), data['file_path']
|
||||||
|
))
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def insert_message(message_data):
|
||||||
|
"""Insert message (skip if exists)"""
|
||||||
|
async with aiosqlite.connect(DB_PATH) as db:
|
||||||
|
try:
|
||||||
|
await db.execute("""
|
||||||
|
INSERT INTO messages (
|
||||||
|
session_id, message_uuid, parent_uuid, role, content_text,
|
||||||
|
timestamp, prompt_id, model, input_tokens, output_tokens,
|
||||||
|
has_tool_use, has_thinking, stop_reason
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""", (
|
||||||
|
message_data['session_id'], message_data['message_uuid'],
|
||||||
|
message_data.get('parent_uuid'), message_data['role'],
|
||||||
|
message_data.get('content_text'), message_data['timestamp'],
|
||||||
|
message_data.get('prompt_id'), message_data.get('model'),
|
||||||
|
message_data.get('input_tokens'), message_data.get('output_tokens'),
|
||||||
|
message_data.get('has_tool_use', False), message_data.get('has_thinking', False),
|
||||||
|
message_data.get('stop_reason')
|
||||||
|
))
|
||||||
|
await db.commit()
|
||||||
|
except aiosqlite.IntegrityError:
|
||||||
|
# Message already exists, skip
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def insert_tool_call(tool_data):
|
||||||
|
"""Insert tool call (skip if exists)"""
|
||||||
|
async with aiosqlite.connect(DB_PATH) as db:
|
||||||
|
try:
|
||||||
|
await db.execute("""
|
||||||
|
INSERT INTO tool_calls (
|
||||||
|
message_uuid, tool_use_id, tool_name, tool_input,
|
||||||
|
tool_result, is_error, timestamp
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""", (
|
||||||
|
tool_data['message_uuid'], tool_data['tool_use_id'],
|
||||||
|
tool_data['tool_name'], tool_data.get('tool_input'),
|
||||||
|
tool_data.get('tool_result'), tool_data.get('is_error', False),
|
||||||
|
tool_data['timestamp']
|
||||||
|
))
|
||||||
|
await db.commit()
|
||||||
|
except aiosqlite.IntegrityError:
|
||||||
|
# Tool call already exists, skip
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def get_checkpoint(file_path):
|
||||||
|
"""Get checkpoint for a file"""
|
||||||
|
async with aiosqlite.connect(DB_PATH) as db:
|
||||||
|
cursor = await db.execute(
|
||||||
|
"SELECT last_line_count, last_modified FROM checkpoints WHERE file_path = ?",
|
||||||
|
(file_path,)
|
||||||
|
)
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
return {"last_line_count": row[0], "last_modified": row[1]} if row else None
|
||||||
|
|
||||||
|
|
||||||
|
async def update_checkpoint(file_path, line_count, modified_time):
|
||||||
|
"""Update checkpoint for a file"""
|
||||||
|
async with aiosqlite.connect(DB_PATH) as db:
|
||||||
|
await db.execute("""
|
||||||
|
INSERT INTO checkpoints (file_path, last_line_count, last_modified)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT(file_path) DO UPDATE SET
|
||||||
|
last_line_count = excluded.last_line_count,
|
||||||
|
last_modified = excluded.last_modified
|
||||||
|
""", (file_path, line_count, modified_time))
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def upsert_summary(date, data):
|
||||||
|
"""Insert or update daily summary"""
|
||||||
|
async with aiosqlite.connect(DB_PATH) as db:
|
||||||
|
await db.execute("""
|
||||||
|
INSERT INTO daily_summaries (
|
||||||
|
date, project_path, conversation_count, total_messages,
|
||||||
|
total_tokens, summary_content
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(date) DO UPDATE SET
|
||||||
|
project_path = excluded.project_path,
|
||||||
|
conversation_count = excluded.conversation_count,
|
||||||
|
total_messages = excluded.total_messages,
|
||||||
|
total_tokens = excluded.total_tokens,
|
||||||
|
summary_content = excluded.summary_content,
|
||||||
|
created_at = CURRENT_TIMESTAMP
|
||||||
|
""", (
|
||||||
|
date, data.get('project_path'), data['conversation_count'],
|
||||||
|
data['total_messages'], data['total_tokens'], data['summary_content']
|
||||||
|
))
|
||||||
|
await db.commit()
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
services:
|
||||||
|
claude-code-tracker:
|
||||||
|
build: .
|
||||||
|
container_name: claude-code-tracker
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- /volume1/docker/claude-code-tracker/data:/app/data
|
||||||
|
- /volume1/docker/claude-code-tracker/conversations:/app/conversations:ro
|
||||||
|
environment:
|
||||||
|
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
|
||||||
|
- ANTHROPIC_BASE_URL=${ANTHROPIC_BASE_URL:-https://www.bytecatcode.org}
|
||||||
|
- SUMMARY_HOUR=${SUMMARY_HOUR:-23}
|
||||||
|
- COLLECT_INTERVAL=${COLLECT_INTERVAL:-60}
|
||||||
|
- DB_PATH=/app/data/conversations.db
|
||||||
|
- CONVERSATIONS_PATH=/app/conversations
|
||||||
|
- TRIGGER_PATH=/app/data/trigger
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import os
|
||||||
|
import logging
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
import db
|
||||||
|
import collector
|
||||||
|
import summarizer
|
||||||
|
|
||||||
|
# Configure logging
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
"""Main service loop"""
|
||||||
|
logger.info("Starting Claude Code Tracker service")
|
||||||
|
|
||||||
|
# Initialize database
|
||||||
|
await db.init_db()
|
||||||
|
logger.info("Database initialized")
|
||||||
|
|
||||||
|
# Create data directory if it doesn't exist
|
||||||
|
data_dir = os.path.dirname(db.DB_PATH)
|
||||||
|
os.makedirs(data_dir, exist_ok=True)
|
||||||
|
|
||||||
|
# Start collection and summarization tasks
|
||||||
|
tasks = [
|
||||||
|
asyncio.create_task(collector.collect_conversations()),
|
||||||
|
asyncio.create_task(summarizer.summarize_daily())
|
||||||
|
]
|
||||||
|
|
||||||
|
logger.info("Service started - collection and summarization tasks running")
|
||||||
|
|
||||||
|
try:
|
||||||
|
await asyncio.gather(*tasks)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
logger.info("Shutting down...")
|
||||||
|
for task in tasks:
|
||||||
|
task.cancel()
|
||||||
|
await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Dict, List, Any, Optional
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_jsonl_file(file_path: str, start_line: int = 0) -> List[Dict[str, Any]]:
|
||||||
|
"""Parse JSONL file from a specific line number"""
|
||||||
|
messages = []
|
||||||
|
try:
|
||||||
|
with open(file_path, 'r', encoding='utf-8') as f:
|
||||||
|
for i, line in enumerate(f):
|
||||||
|
if i < start_line:
|
||||||
|
continue
|
||||||
|
if not line.strip():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
obj = json.loads(line)
|
||||||
|
messages.append(obj)
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
logger.warning(f"Failed to parse line {i} in {file_path}: {e}")
|
||||||
|
continue
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to read {file_path}: {e}")
|
||||||
|
return messages
|
||||||
|
|
||||||
|
|
||||||
|
def extract_text_content(content: Any) -> str:
|
||||||
|
"""Extract text from message content"""
|
||||||
|
if isinstance(content, str):
|
||||||
|
return content
|
||||||
|
|
||||||
|
if isinstance(content, list):
|
||||||
|
text_parts = []
|
||||||
|
for block in content:
|
||||||
|
if isinstance(block, dict):
|
||||||
|
if block.get('type') == 'text':
|
||||||
|
text_parts.append(block.get('text', ''))
|
||||||
|
elif block.get('type') == 'tool_result':
|
||||||
|
# Include tool results in searchable text
|
||||||
|
result = block.get('content', '')
|
||||||
|
if isinstance(result, str):
|
||||||
|
text_parts.append(result[:500]) # Limit length
|
||||||
|
return '\n'.join(text_parts)
|
||||||
|
|
||||||
|
return ''
|
||||||
|
|
||||||
|
|
||||||
|
def extract_tool_calls(content: Any, message_uuid: str, timestamp: str) -> List[Dict[str, Any]]:
|
||||||
|
"""Extract tool use blocks from message content"""
|
||||||
|
tool_calls = []
|
||||||
|
|
||||||
|
if not isinstance(content, list):
|
||||||
|
return tool_calls
|
||||||
|
|
||||||
|
for block in content:
|
||||||
|
if isinstance(block, dict) and block.get('type') == 'tool_use':
|
||||||
|
tool_calls.append({
|
||||||
|
'message_uuid': message_uuid,
|
||||||
|
'tool_use_id': block.get('id'),
|
||||||
|
'tool_name': block.get('name'),
|
||||||
|
'tool_input': json.dumps(block.get('input', {})),
|
||||||
|
'tool_result': None,
|
||||||
|
'is_error': False,
|
||||||
|
'timestamp': timestamp
|
||||||
|
})
|
||||||
|
|
||||||
|
return tool_calls
|
||||||
|
|
||||||
|
|
||||||
|
def extract_tool_results(content: Any, tool_calls_map: Dict[str, Dict]) -> None:
|
||||||
|
"""Extract tool results and match them to tool calls"""
|
||||||
|
if not isinstance(content, list):
|
||||||
|
return
|
||||||
|
|
||||||
|
for block in content:
|
||||||
|
if isinstance(block, dict) and block.get('type') == 'tool_result':
|
||||||
|
tool_use_id = block.get('tool_use_id')
|
||||||
|
if tool_use_id and tool_use_id in tool_calls_map:
|
||||||
|
result_content = block.get('content', '')
|
||||||
|
if isinstance(result_content, str):
|
||||||
|
tool_calls_map[tool_use_id]['tool_result'] = result_content[:5000] # Limit length
|
||||||
|
tool_calls_map[tool_use_id]['is_error'] = block.get('is_error', False)
|
||||||
|
|
||||||
|
|
||||||
|
def extract_message_data(obj: Dict[str, Any], session_id: str) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Extract relevant fields from a message object"""
|
||||||
|
msg_type = obj.get('type')
|
||||||
|
|
||||||
|
# Only process user and assistant messages
|
||||||
|
if msg_type not in ['user', 'assistant']:
|
||||||
|
return None
|
||||||
|
|
||||||
|
message = obj.get('message', {})
|
||||||
|
role = message.get('role')
|
||||||
|
|
||||||
|
if not role or role not in ['user', 'assistant']:
|
||||||
|
return None
|
||||||
|
|
||||||
|
content = message.get('content', '')
|
||||||
|
has_tool_use = False
|
||||||
|
has_thinking = False
|
||||||
|
|
||||||
|
# Check for tool use and thinking blocks
|
||||||
|
if isinstance(content, list):
|
||||||
|
for block in content:
|
||||||
|
if isinstance(block, dict):
|
||||||
|
if block.get('type') == 'tool_use':
|
||||||
|
has_tool_use = True
|
||||||
|
elif block.get('type') == 'thinking':
|
||||||
|
has_thinking = True
|
||||||
|
|
||||||
|
# Extract usage metrics
|
||||||
|
usage = message.get('usage', {})
|
||||||
|
input_tokens = usage.get('input_tokens', 0)
|
||||||
|
output_tokens = usage.get('output_tokens', 0)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'session_id': session_id,
|
||||||
|
'message_uuid': obj.get('uuid'),
|
||||||
|
'parent_uuid': obj.get('parentUuid'),
|
||||||
|
'role': role,
|
||||||
|
'content_text': extract_text_content(content),
|
||||||
|
'timestamp': obj.get('timestamp'),
|
||||||
|
'prompt_id': obj.get('promptId'),
|
||||||
|
'model': message.get('model'),
|
||||||
|
'input_tokens': input_tokens,
|
||||||
|
'output_tokens': output_tokens,
|
||||||
|
'has_tool_use': has_tool_use,
|
||||||
|
'has_thinking': has_thinking,
|
||||||
|
'stop_reason': message.get('stop_reason')
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_conversation_stats(messages: List[Dict[str, Any]]) -> Dict[str, Any]:
|
||||||
|
"""Calculate aggregate statistics for a conversation"""
|
||||||
|
stats = {
|
||||||
|
'message_count': 0,
|
||||||
|
'user_message_count': 0,
|
||||||
|
'assistant_message_count': 0,
|
||||||
|
'total_input_tokens': 0,
|
||||||
|
'total_output_tokens': 0,
|
||||||
|
'started_at': None,
|
||||||
|
'last_updated_at': None,
|
||||||
|
'model': None
|
||||||
|
}
|
||||||
|
|
||||||
|
for msg in messages:
|
||||||
|
if msg.get('role') == 'user':
|
||||||
|
stats['user_message_count'] += 1
|
||||||
|
elif msg.get('role') == 'assistant':
|
||||||
|
stats['assistant_message_count'] += 1
|
||||||
|
if not stats['model'] and msg.get('model'):
|
||||||
|
stats['model'] = msg.get('model')
|
||||||
|
|
||||||
|
stats['message_count'] += 1
|
||||||
|
stats['total_input_tokens'] += msg.get('input_tokens', 0)
|
||||||
|
stats['total_output_tokens'] += msg.get('output_tokens', 0)
|
||||||
|
|
||||||
|
timestamp = msg.get('timestamp')
|
||||||
|
if timestamp:
|
||||||
|
if not stats['started_at'] or timestamp < stats['started_at']:
|
||||||
|
stats['started_at'] = timestamp
|
||||||
|
if not stats['last_updated_at'] or timestamp > stats['last_updated_at']:
|
||||||
|
stats['last_updated_at'] = timestamp
|
||||||
|
|
||||||
|
return stats
|
||||||
|
|
||||||
|
|
||||||
|
def parse_conversation_file(file_path: str, start_line: int = 0) -> Dict[str, Any]:
|
||||||
|
"""Parse a conversation file and extract all data"""
|
||||||
|
raw_messages = parse_jsonl_file(file_path, start_line)
|
||||||
|
|
||||||
|
if not raw_messages:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Extract session metadata from first message
|
||||||
|
session_id = None
|
||||||
|
project_path = None
|
||||||
|
git_branch = None
|
||||||
|
slug = None
|
||||||
|
|
||||||
|
for obj in raw_messages:
|
||||||
|
if obj.get('sessionId'):
|
||||||
|
session_id = obj['sessionId']
|
||||||
|
if obj.get('cwd'):
|
||||||
|
project_path = obj['cwd']
|
||||||
|
if obj.get('gitBranch'):
|
||||||
|
git_branch = obj['gitBranch']
|
||||||
|
if obj.get('slug'):
|
||||||
|
slug = obj['slug']
|
||||||
|
|
||||||
|
if session_id and project_path:
|
||||||
|
break
|
||||||
|
|
||||||
|
if not session_id:
|
||||||
|
logger.warning(f"No session ID found in {file_path}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Extract messages
|
||||||
|
messages = []
|
||||||
|
tool_calls_map = {} # Map tool_use_id to tool call data
|
||||||
|
|
||||||
|
for obj in raw_messages:
|
||||||
|
msg_data = extract_message_data(obj, session_id)
|
||||||
|
if msg_data:
|
||||||
|
messages.append(msg_data)
|
||||||
|
|
||||||
|
# Extract tool calls from assistant messages
|
||||||
|
if msg_data['role'] == 'assistant' and msg_data['has_tool_use']:
|
||||||
|
message_content = obj.get('message', {}).get('content', [])
|
||||||
|
tool_calls = extract_tool_calls(message_content, msg_data['message_uuid'], msg_data['timestamp'])
|
||||||
|
for tc in tool_calls:
|
||||||
|
tool_calls_map[tc['tool_use_id']] = tc
|
||||||
|
|
||||||
|
# Extract tool results from user messages
|
||||||
|
if obj.get('type') == 'user':
|
||||||
|
message_content = obj.get('message', {}).get('content', [])
|
||||||
|
extract_tool_results(message_content, tool_calls_map)
|
||||||
|
|
||||||
|
if not messages:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Calculate stats
|
||||||
|
stats = calculate_conversation_stats(messages)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'session_id': session_id,
|
||||||
|
'project_path': project_path or 'unknown',
|
||||||
|
'git_branch': git_branch,
|
||||||
|
'slug': slug,
|
||||||
|
'file_path': file_path,
|
||||||
|
'messages': messages,
|
||||||
|
'tool_calls': list(tool_calls_map.values()),
|
||||||
|
**stats
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
aiosqlite==0.19.0
|
||||||
|
anthropic==0.39.0
|
||||||
|
httpx==0.27.0
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import os
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
import asyncio
|
||||||
|
import aiosqlite
|
||||||
|
from anthropic import AsyncAnthropic
|
||||||
|
|
||||||
|
import db
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
ANTHROPIC_API_KEY = os.environ.get("ANTHROPIC_API_KEY")
|
||||||
|
ANTHROPIC_BASE_URL = os.environ.get("ANTHROPIC_BASE_URL", "https://www.bytecatcode.org")
|
||||||
|
SUMMARY_HOUR = int(os.environ.get("SUMMARY_HOUR", "23"))
|
||||||
|
TRIGGER_PATH = os.environ.get("TRIGGER_PATH", "/app/data/trigger")
|
||||||
|
|
||||||
|
|
||||||
|
async def generate_summary_for_date(date_str: str):
|
||||||
|
"""Generate summary for a specific date"""
|
||||||
|
try:
|
||||||
|
logger.info(f"Generating summary for {date_str}")
|
||||||
|
|
||||||
|
# Query conversations for this date
|
||||||
|
async with aiosqlite.connect(db.DB_PATH) as conn:
|
||||||
|
# Get conversations
|
||||||
|
cursor = await conn.execute("""
|
||||||
|
SELECT session_id, project_path, slug, message_count,
|
||||||
|
total_input_tokens, total_output_tokens
|
||||||
|
FROM conversations
|
||||||
|
WHERE DATE(started_at) = ? OR DATE(last_updated_at) = ?
|
||||||
|
ORDER BY started_at
|
||||||
|
""", (date_str, date_str))
|
||||||
|
conversations = await cursor.fetchall()
|
||||||
|
|
||||||
|
if not conversations:
|
||||||
|
logger.info(f"No conversations found for {date_str}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Get sample messages from each conversation
|
||||||
|
conversation_summaries = []
|
||||||
|
total_messages = 0
|
||||||
|
total_tokens = 0
|
||||||
|
projects = set()
|
||||||
|
|
||||||
|
for conv in conversations:
|
||||||
|
session_id, project_path, slug, msg_count, input_tokens, output_tokens = conv
|
||||||
|
projects.add(project_path)
|
||||||
|
total_messages += msg_count
|
||||||
|
total_tokens += (input_tokens + output_tokens)
|
||||||
|
|
||||||
|
# Get first few user messages to understand what was worked on
|
||||||
|
cursor = await conn.execute("""
|
||||||
|
SELECT content_text FROM messages
|
||||||
|
WHERE session_id = ? AND role = 'user' AND content_text IS NOT NULL
|
||||||
|
ORDER BY timestamp
|
||||||
|
LIMIT 3
|
||||||
|
""", (session_id,))
|
||||||
|
user_messages = await cursor.fetchall()
|
||||||
|
|
||||||
|
# Get tool usage
|
||||||
|
cursor = await conn.execute("""
|
||||||
|
SELECT tool_name, COUNT(*) as count
|
||||||
|
FROM tool_calls
|
||||||
|
WHERE message_uuid IN (
|
||||||
|
SELECT message_uuid FROM messages WHERE session_id = ?
|
||||||
|
)
|
||||||
|
GROUP BY tool_name
|
||||||
|
ORDER BY count DESC
|
||||||
|
LIMIT 5
|
||||||
|
""", (session_id,))
|
||||||
|
tools = await cursor.fetchall()
|
||||||
|
|
||||||
|
conversation_summaries.append({
|
||||||
|
'slug': slug or 'Untitled',
|
||||||
|
'project': project_path.split('/')[-1] if project_path else 'unknown',
|
||||||
|
'messages': msg_count,
|
||||||
|
'tokens': input_tokens + output_tokens,
|
||||||
|
'user_messages': [msg[0][:200] for msg in user_messages if msg[0]],
|
||||||
|
'tools': [f"{tool[0]} ({tool[1]}x)" for tool in tools]
|
||||||
|
})
|
||||||
|
|
||||||
|
# Format for Claude
|
||||||
|
formatted_convs = []
|
||||||
|
for i, conv in enumerate(conversation_summaries, 1):
|
||||||
|
formatted_convs.append(f"""
|
||||||
|
**Conversation {i}: {conv['slug']}**
|
||||||
|
- Project: {conv['project']}
|
||||||
|
- Messages: {conv['messages']} ({conv['tokens']} tokens)
|
||||||
|
- Initial requests: {', '.join(conv['user_messages'][:2]) if conv['user_messages'] else 'N/A'}
|
||||||
|
- Tools used: {', '.join(conv['tools']) if conv['tools'] else 'None'}
|
||||||
|
""")
|
||||||
|
|
||||||
|
prompt = f"""You are analyzing a developer's Claude Code conversations from {date_str}.
|
||||||
|
|
||||||
|
**Overview:**
|
||||||
|
- Conversations: {len(conversations)}
|
||||||
|
- Total messages: {total_messages}
|
||||||
|
- Total tokens: {total_tokens:,}
|
||||||
|
- Projects: {', '.join(sorted(projects))}
|
||||||
|
|
||||||
|
**Conversations:**
|
||||||
|
{''.join(formatted_convs)}
|
||||||
|
|
||||||
|
Generate a concise daily summary covering:
|
||||||
|
1. Main tasks and goals worked on
|
||||||
|
2. Key decisions and solutions implemented
|
||||||
|
3. Files and components modified (if evident from tool usage)
|
||||||
|
4. Challenges encountered and how they were resolved
|
||||||
|
5. Tools and patterns used
|
||||||
|
6. Overall progress and outcomes
|
||||||
|
|
||||||
|
Use markdown formatting. Be specific and technical. Focus on what was accomplished. Keep it under 500 words."""
|
||||||
|
|
||||||
|
# Call Claude API
|
||||||
|
client = AsyncAnthropic(api_key=ANTHROPIC_API_KEY, base_url=ANTHROPIC_BASE_URL)
|
||||||
|
response = await client.messages.create(
|
||||||
|
model="claude-haiku-4-5-20251001",
|
||||||
|
max_tokens=4096,
|
||||||
|
messages=[{"role": "user", "content": prompt}]
|
||||||
|
)
|
||||||
|
|
||||||
|
summary_content = response.content[0].text
|
||||||
|
|
||||||
|
# Store summary
|
||||||
|
await db.upsert_summary(date_str, {
|
||||||
|
'project_path': ', '.join(sorted(projects)),
|
||||||
|
'conversation_count': len(conversations),
|
||||||
|
'total_messages': total_messages,
|
||||||
|
'total_tokens': total_tokens,
|
||||||
|
'summary_content': summary_content
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.info(f"Summary generated for {date_str}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error generating summary for {date_str}: {e}", exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
|
async def check_trigger():
|
||||||
|
"""Check for manual trigger file"""
|
||||||
|
if os.path.exists(TRIGGER_PATH):
|
||||||
|
try:
|
||||||
|
os.remove(TRIGGER_PATH)
|
||||||
|
logger.info("Manual trigger detected")
|
||||||
|
# Generate summary for yesterday
|
||||||
|
yesterday = (datetime.now() - timedelta(days=1)).strftime("%Y-%m-%d")
|
||||||
|
await generate_summary_for_date(yesterday)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error processing trigger: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
async def summarize_daily():
|
||||||
|
"""Daily summarization loop"""
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
now = datetime.now()
|
||||||
|
|
||||||
|
# Check for manual trigger
|
||||||
|
await check_trigger()
|
||||||
|
|
||||||
|
# Check if it's time for daily summary
|
||||||
|
if now.hour == SUMMARY_HOUR and now.minute < 5:
|
||||||
|
yesterday = (now - timedelta(days=1)).strftime("%Y-%m-%d")
|
||||||
|
await generate_summary_for_date(yesterday)
|
||||||
|
|
||||||
|
# Sleep until next hour to avoid duplicate runs
|
||||||
|
await asyncio.sleep(3600)
|
||||||
|
else:
|
||||||
|
# Check every 5 minutes
|
||||||
|
await asyncio.sleep(300)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in summarization loop: {e}", exc_info=True)
|
||||||
|
await asyncio.sleep(300)
|
||||||
@@ -54,6 +54,9 @@ CORS_ORIGINS = os.environ.get("CORS_ORIGINS", "https://nas.jimmygan.com,https://
|
|||||||
# Chat Summary
|
# Chat Summary
|
||||||
CHAT_SUMMARY_DB = os.environ.get("CHAT_SUMMARY_DB", "/app/data/chat-summarizer/chat_summary.db")
|
CHAT_SUMMARY_DB = os.environ.get("CHAT_SUMMARY_DB", "/app/data/chat-summarizer/chat_summary.db")
|
||||||
|
|
||||||
|
# Conversation Tracker
|
||||||
|
CONVERSATION_TRACKER_DB = os.environ.get("CONVERSATION_TRACKER_DB", "/app/data/claude-code-tracker/conversations.db")
|
||||||
|
|
||||||
# Info Engine
|
# Info Engine
|
||||||
INFO_ENGINE_DB = os.environ.get("INFO_ENGINE_DB", "/app/data/info-engine/info_engine.db")
|
INFO_ENGINE_DB = os.environ.get("INFO_ENGINE_DB", "/app/data/info-engine/info_engine.db")
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ from routers import auth as auth_router
|
|||||||
from routers import (
|
from routers import (
|
||||||
cc_connect,
|
cc_connect,
|
||||||
chat_summary,
|
chat_summary,
|
||||||
|
conversation_tracker,
|
||||||
docker_router,
|
docker_router,
|
||||||
files,
|
files,
|
||||||
gitea,
|
gitea,
|
||||||
@@ -296,6 +297,10 @@ app.include_router(
|
|||||||
prefix="/api/chat-summary",
|
prefix="/api/chat-summary",
|
||||||
dependencies=[Depends(_inject_user), Depends(require_page("chat-digest"))],
|
dependencies=[Depends(_inject_user), Depends(require_page("chat-digest"))],
|
||||||
)
|
)
|
||||||
|
app.include_router(
|
||||||
|
conversation_tracker.router,
|
||||||
|
dependencies=[Depends(_inject_user), Depends(require_page("conversations"))],
|
||||||
|
)
|
||||||
app.include_router(
|
app.include_router(
|
||||||
info_engine.router,
|
info_engine.router,
|
||||||
prefix="/api/info-engine",
|
prefix="/api/info-engine",
|
||||||
|
|||||||
@@ -0,0 +1,286 @@
|
|||||||
|
import os
|
||||||
|
import aiosqlite
|
||||||
|
from fastapi import APIRouter, HTTPException, Query
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/conversations", tags=["conversations"])
|
||||||
|
|
||||||
|
CONVERSATION_TRACKER_DB = os.environ.get("CONVERSATION_TRACKER_DB", "/app/data/claude-code-tracker/conversations.db")
|
||||||
|
|
||||||
|
|
||||||
|
async def _db():
|
||||||
|
return aiosqlite.connect(CONVERSATION_TRACKER_DB)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/dates")
|
||||||
|
async def list_dates():
|
||||||
|
"""List all dates with conversations"""
|
||||||
|
async with await _db() as db:
|
||||||
|
cursor = await db.execute("""
|
||||||
|
SELECT DISTINCT DATE(started_at) as date
|
||||||
|
FROM conversations
|
||||||
|
ORDER BY date DESC
|
||||||
|
""")
|
||||||
|
return [r[0] for r in await cursor.fetchall()]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/summary/{date}")
|
||||||
|
async def get_summary(date: str):
|
||||||
|
"""Get daily summary for a date"""
|
||||||
|
async with await _db() as db:
|
||||||
|
cursor = await db.execute("""
|
||||||
|
SELECT summary_content, conversation_count, total_messages,
|
||||||
|
total_tokens, created_at, project_path
|
||||||
|
FROM daily_summaries
|
||||||
|
WHERE date = ?
|
||||||
|
""", (date,))
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(404, "No summary for this date")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"date": date,
|
||||||
|
"content": row[0],
|
||||||
|
"conversation_count": row[1],
|
||||||
|
"total_messages": row[2],
|
||||||
|
"total_tokens": row[3],
|
||||||
|
"created_at": row[4],
|
||||||
|
"projects": row[5]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/list")
|
||||||
|
async def list_conversations(
|
||||||
|
date: Optional[str] = None,
|
||||||
|
project_path: Optional[str] = None,
|
||||||
|
limit: int = Query(50, le=200),
|
||||||
|
offset: int = 0
|
||||||
|
):
|
||||||
|
"""List conversations with filters"""
|
||||||
|
async with await _db() as db:
|
||||||
|
query = "SELECT session_id, project_path, slug, started_at, message_count, total_input_tokens, total_output_tokens, model FROM conversations WHERE 1=1"
|
||||||
|
params = []
|
||||||
|
|
||||||
|
if date:
|
||||||
|
query += " AND DATE(started_at) = ?"
|
||||||
|
params.append(date)
|
||||||
|
|
||||||
|
if project_path:
|
||||||
|
query += " AND project_path LIKE ?"
|
||||||
|
params.append(f"%{project_path}%")
|
||||||
|
|
||||||
|
query += " ORDER BY started_at DESC LIMIT ? OFFSET ?"
|
||||||
|
params.extend([limit, offset])
|
||||||
|
|
||||||
|
cursor = await db.execute(query, params)
|
||||||
|
rows = await cursor.fetchall()
|
||||||
|
|
||||||
|
return [{
|
||||||
|
"session_id": r[0],
|
||||||
|
"project_path": r[1],
|
||||||
|
"slug": r[2],
|
||||||
|
"started_at": r[3],
|
||||||
|
"message_count": r[4],
|
||||||
|
"total_tokens": r[5] + r[6],
|
||||||
|
"model": r[7]
|
||||||
|
} for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{session_id}")
|
||||||
|
async def get_conversation(session_id: str):
|
||||||
|
"""Get conversation details"""
|
||||||
|
async with await _db() as db:
|
||||||
|
# Get conversation metadata
|
||||||
|
cursor = await db.execute("""
|
||||||
|
SELECT project_path, git_branch, slug, started_at, last_updated_at,
|
||||||
|
message_count, total_input_tokens, total_output_tokens, model
|
||||||
|
FROM conversations
|
||||||
|
WHERE session_id = ?
|
||||||
|
""", (session_id,))
|
||||||
|
conv = await cursor.fetchone()
|
||||||
|
|
||||||
|
if not conv:
|
||||||
|
raise HTTPException(404, "Conversation not found")
|
||||||
|
|
||||||
|
# Get message count
|
||||||
|
cursor = await db.execute("""
|
||||||
|
SELECT COUNT(*) FROM messages WHERE session_id = ?
|
||||||
|
""", (session_id,))
|
||||||
|
msg_count = (await cursor.fetchone())[0]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"session_id": session_id,
|
||||||
|
"project_path": conv[0],
|
||||||
|
"git_branch": conv[1],
|
||||||
|
"slug": conv[2],
|
||||||
|
"started_at": conv[3],
|
||||||
|
"last_updated_at": conv[4],
|
||||||
|
"message_count": msg_count,
|
||||||
|
"total_input_tokens": conv[6],
|
||||||
|
"total_output_tokens": conv[7],
|
||||||
|
"model": conv[8]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{session_id}/messages")
|
||||||
|
async def get_messages(
|
||||||
|
session_id: str,
|
||||||
|
limit: int = Query(100, le=500),
|
||||||
|
offset: int = 0
|
||||||
|
):
|
||||||
|
"""Get messages for a conversation"""
|
||||||
|
async with await _db() as db:
|
||||||
|
cursor = await db.execute("""
|
||||||
|
SELECT message_uuid, role, content_text, timestamp, model,
|
||||||
|
input_tokens, output_tokens, has_tool_use, has_thinking
|
||||||
|
FROM messages
|
||||||
|
WHERE session_id = ?
|
||||||
|
ORDER BY timestamp
|
||||||
|
LIMIT ? OFFSET ?
|
||||||
|
""", (session_id, limit, offset))
|
||||||
|
rows = await cursor.fetchall()
|
||||||
|
|
||||||
|
messages = []
|
||||||
|
for r in rows:
|
||||||
|
msg = {
|
||||||
|
"uuid": r[0],
|
||||||
|
"role": r[1],
|
||||||
|
"content": r[2],
|
||||||
|
"timestamp": r[3],
|
||||||
|
"model": r[4],
|
||||||
|
"input_tokens": r[5],
|
||||||
|
"output_tokens": r[6],
|
||||||
|
"has_tool_use": bool(r[7]),
|
||||||
|
"has_thinking": bool(r[8]),
|
||||||
|
"tool_calls": []
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get tool calls for this message
|
||||||
|
if msg["has_tool_use"]:
|
||||||
|
cursor2 = await db.execute("""
|
||||||
|
SELECT tool_name, tool_input, tool_result, is_error
|
||||||
|
FROM tool_calls
|
||||||
|
WHERE message_uuid = ?
|
||||||
|
""", (r[0],))
|
||||||
|
tool_rows = await cursor2.fetchall()
|
||||||
|
msg["tool_calls"] = [{
|
||||||
|
"name": tr[0],
|
||||||
|
"input": tr[1],
|
||||||
|
"result": tr[2],
|
||||||
|
"is_error": bool(tr[3])
|
||||||
|
} for tr in tool_rows]
|
||||||
|
|
||||||
|
messages.append(msg)
|
||||||
|
|
||||||
|
return messages
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/search")
|
||||||
|
async def search_conversations(
|
||||||
|
q: str = Query(..., min_length=2),
|
||||||
|
date_from: Optional[str] = None,
|
||||||
|
date_to: Optional[str] = None,
|
||||||
|
limit: int = Query(50, le=200)
|
||||||
|
):
|
||||||
|
"""Search conversations by text"""
|
||||||
|
async with await _db() as db:
|
||||||
|
query = """
|
||||||
|
SELECT DISTINCT m.session_id, c.project_path, c.slug, c.started_at,
|
||||||
|
c.message_count, c.total_input_tokens, c.total_output_tokens
|
||||||
|
FROM messages m
|
||||||
|
JOIN conversations c ON m.session_id = c.session_id
|
||||||
|
WHERE m.content_text LIKE ?
|
||||||
|
"""
|
||||||
|
params = [f"%{q}%"]
|
||||||
|
|
||||||
|
if date_from:
|
||||||
|
query += " AND DATE(c.started_at) >= ?"
|
||||||
|
params.append(date_from)
|
||||||
|
|
||||||
|
if date_to:
|
||||||
|
query += " AND DATE(c.started_at) <= ?"
|
||||||
|
params.append(date_to)
|
||||||
|
|
||||||
|
query += " ORDER BY c.started_at DESC LIMIT ?"
|
||||||
|
params.append(limit)
|
||||||
|
|
||||||
|
cursor = await db.execute(query, params)
|
||||||
|
rows = await cursor.fetchall()
|
||||||
|
|
||||||
|
return [{
|
||||||
|
"session_id": r[0],
|
||||||
|
"project_path": r[1],
|
||||||
|
"slug": r[2],
|
||||||
|
"started_at": r[3],
|
||||||
|
"message_count": r[4],
|
||||||
|
"total_tokens": r[5] + r[6]
|
||||||
|
} for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/stats")
|
||||||
|
async def get_stats(
|
||||||
|
date_from: Optional[str] = None,
|
||||||
|
date_to: Optional[str] = None
|
||||||
|
):
|
||||||
|
"""Get overall statistics"""
|
||||||
|
async with await _db() as db:
|
||||||
|
query = "SELECT COUNT(*), SUM(message_count), SUM(total_input_tokens + total_output_tokens) FROM conversations WHERE 1=1"
|
||||||
|
params = []
|
||||||
|
|
||||||
|
if date_from:
|
||||||
|
query += " AND DATE(started_at) >= ?"
|
||||||
|
params.append(date_from)
|
||||||
|
|
||||||
|
if date_to:
|
||||||
|
query += " AND DATE(started_at) <= ?"
|
||||||
|
params.append(date_to)
|
||||||
|
|
||||||
|
cursor = await db.execute(query, params)
|
||||||
|
stats = await cursor.fetchone()
|
||||||
|
|
||||||
|
# Get top projects
|
||||||
|
cursor = await db.execute("""
|
||||||
|
SELECT project_path, COUNT(*) as count
|
||||||
|
FROM conversations
|
||||||
|
GROUP BY project_path
|
||||||
|
ORDER BY count DESC
|
||||||
|
LIMIT 10
|
||||||
|
""")
|
||||||
|
projects = await cursor.fetchall()
|
||||||
|
|
||||||
|
# Get tool usage
|
||||||
|
cursor = await db.execute("""
|
||||||
|
SELECT tool_name, COUNT(*) as count
|
||||||
|
FROM tool_calls
|
||||||
|
GROUP BY tool_name
|
||||||
|
ORDER BY count DESC
|
||||||
|
LIMIT 10
|
||||||
|
""")
|
||||||
|
tools = await cursor.fetchall()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total_conversations": stats[0] or 0,
|
||||||
|
"total_messages": stats[1] or 0,
|
||||||
|
"total_tokens": stats[2] or 0,
|
||||||
|
"top_projects": [{"path": p[0], "count": p[1]} for p in projects],
|
||||||
|
"top_tools": [{"name": t[0], "count": t[1]} for t in tools]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/trigger")
|
||||||
|
async def trigger_summary():
|
||||||
|
"""Trigger manual summary generation"""
|
||||||
|
trigger_path = os.environ.get("TRIGGER_PATH", "/app/data/claude-code-tracker/trigger")
|
||||||
|
|
||||||
|
# Validate path
|
||||||
|
trigger_path = os.path.abspath(trigger_path)
|
||||||
|
forbidden_prefixes = ("/etc/", "/root/", "/sys/", "/proc/", "/boot/")
|
||||||
|
if any(trigger_path.startswith(prefix) for prefix in forbidden_prefixes):
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid trigger path")
|
||||||
|
|
||||||
|
os.makedirs(os.path.dirname(trigger_path), exist_ok=True)
|
||||||
|
with open(trigger_path, "w") as f:
|
||||||
|
f.write("1")
|
||||||
|
|
||||||
|
return {"status": "triggered"}
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
import OpenClaw from "./routes/OpenClaw.svelte";
|
import OpenClaw from "./routes/OpenClaw.svelte";
|
||||||
import Settings from "./routes/Settings.svelte";
|
import Settings from "./routes/Settings.svelte";
|
||||||
import ChatSummary from "./routes/ChatSummary.svelte";
|
import ChatSummary from "./routes/ChatSummary.svelte";
|
||||||
|
import Conversations from "./routes/Conversations.svelte";
|
||||||
import Security from "./routes/Security.svelte";
|
import Security from "./routes/Security.svelte";
|
||||||
import LiteLLM from "./routes/LiteLLM.svelte";
|
import LiteLLM from "./routes/LiteLLM.svelte";
|
||||||
import CcConnect from "./routes/CcConnect.svelte";
|
import CcConnect from "./routes/CcConnect.svelte";
|
||||||
@@ -26,6 +27,7 @@
|
|||||||
"terminal",
|
"terminal",
|
||||||
"openclaw",
|
"openclaw",
|
||||||
"chat-digest",
|
"chat-digest",
|
||||||
|
"conversations",
|
||||||
"settings",
|
"settings",
|
||||||
"security",
|
"security",
|
||||||
"litellm",
|
"litellm",
|
||||||
@@ -201,6 +203,8 @@
|
|||||||
<OpenClaw />
|
<OpenClaw />
|
||||||
{:else if page === "chat-digest" && hasPageAccess("chat-digest")}
|
{:else if page === "chat-digest" && hasPageAccess("chat-digest")}
|
||||||
<ChatSummary />
|
<ChatSummary />
|
||||||
|
{:else if page === "conversations" && hasPageAccess("conversations")}
|
||||||
|
<Conversations />
|
||||||
{:else if page === "settings" && userRole === "admin"}
|
{:else if page === "settings" && userRole === "admin"}
|
||||||
<Settings />
|
<Settings />
|
||||||
{:else if page === "security" && hasPageAccess("security")}
|
{:else if page === "security" && hasPageAccess("security")}
|
||||||
|
|||||||
@@ -49,6 +49,7 @@
|
|||||||
{ id: "openclaw", label: "OpenClaw", icon: "openclaw" },
|
{ id: "openclaw", label: "OpenClaw", icon: "openclaw" },
|
||||||
{ id: "cc-connect", label: "cc-connect", icon: "users" },
|
{ id: "cc-connect", label: "cc-connect", icon: "users" },
|
||||||
{ id: "chat-digest", label: "Chat Digest", icon: "chat" },
|
{ id: "chat-digest", label: "Chat Digest", icon: "chat" },
|
||||||
|
{ id: "conversations", label: "Code Sessions", icon: "code" },
|
||||||
{ id: "gitea", label: "Repos", icon: "git" },
|
{ id: "gitea", label: "Repos", icon: "git" },
|
||||||
{ label: "Gitea Web", remoteHref: "https://git.jimmygan.com:8443", icon: "git", external: true },
|
{ label: "Gitea Web", remoteHref: "https://git.jimmygan.com:8443", icon: "git", external: true },
|
||||||
{ label: "Stirling PDF", remoteHref: "https://pdf.jimmygan.com:8443", icon: "pdf", external: true },
|
{ label: "Stirling PDF", remoteHref: "https://pdf.jimmygan.com:8443", icon: "pdf", external: true },
|
||||||
@@ -261,6 +262,8 @@
|
|||||||
<svg class="w-[16px] h-[16px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" /></svg>
|
<svg class="w-[16px] h-[16px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" /></svg>
|
||||||
{:else if icon === "chat"}
|
{:else if icon === "chat"}
|
||||||
<svg class="w-[16px] h-[16px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M17 8h2a2 2 0 012 2v6a2 2 0 01-2 2h-2v4l-4-4H9a2 2 0 01-2-2v-1m0-3V6a2 2 0 012-2h8a2 2 0 012 2v3a2 2 0 01-2 2H9l-4 4V9z" /></svg>
|
<svg class="w-[16px] h-[16px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M17 8h2a2 2 0 012 2v6a2 2 0 01-2 2h-2v4l-4-4H9a2 2 0 01-2-2v-1m0-3V6a2 2 0 012-2h8a2 2 0 012 2v3a2 2 0 01-2 2H9l-4 4V9z" /></svg>
|
||||||
|
{:else if icon === "code"}
|
||||||
|
<svg class="w-[16px] h-[16px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" /></svg>
|
||||||
{:else if icon === "git"}
|
{:else if icon === "git"}
|
||||||
<svg class="w-[16px] h-[16px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" /></svg>
|
<svg class="w-[16px] h-[16px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" /></svg>
|
||||||
{:else if icon === "pdf"}
|
{:else if icon === "pdf"}
|
||||||
|
|||||||
@@ -0,0 +1,306 @@
|
|||||||
|
<script>
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import { get, post } from "../lib/api.js";
|
||||||
|
|
||||||
|
let dates = $state([]);
|
||||||
|
let selectedDate = $state("");
|
||||||
|
let summary = $state(null);
|
||||||
|
let conversations = $state([]);
|
||||||
|
let selectedConversation = $state(null);
|
||||||
|
let messages = $state([]);
|
||||||
|
let searchQuery = $state("");
|
||||||
|
let searchResults = $state([]);
|
||||||
|
let stats = $state(null);
|
||||||
|
let loading = $state(false);
|
||||||
|
let triggering = $state(false);
|
||||||
|
let error = $state("");
|
||||||
|
let view = $state("summary"); // summary, list, detail, search, stats
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
try {
|
||||||
|
dates = await get("/api/conversations/dates");
|
||||||
|
if (dates.length) selectDate(dates[0]);
|
||||||
|
loadStats();
|
||||||
|
} catch (e) {
|
||||||
|
error = e.body?.detail || "Failed to load dates";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function selectDate(d) {
|
||||||
|
selectedDate = d;
|
||||||
|
view = "summary";
|
||||||
|
loading = true;
|
||||||
|
error = "";
|
||||||
|
try {
|
||||||
|
summary = await get(`/api/conversations/summary/${d}`);
|
||||||
|
conversations = await get(`/api/conversations/list?date=${d}`);
|
||||||
|
} catch (e) {
|
||||||
|
summary = null;
|
||||||
|
if (e.status !== 404) error = e.body?.detail || "Failed to load summary";
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectConversation(sessionId) {
|
||||||
|
view = "detail";
|
||||||
|
loading = true;
|
||||||
|
try {
|
||||||
|
selectedConversation = await get(`/api/conversations/${sessionId}`);
|
||||||
|
messages = await get(`/api/conversations/${sessionId}/messages`);
|
||||||
|
} catch (e) {
|
||||||
|
error = "Failed to load conversation";
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function search() {
|
||||||
|
if (!searchQuery || searchQuery.length < 2) return;
|
||||||
|
view = "search";
|
||||||
|
loading = true;
|
||||||
|
try {
|
||||||
|
searchResults = await get(`/api/conversations/search?q=${encodeURIComponent(searchQuery)}`);
|
||||||
|
} catch (e) {
|
||||||
|
error = "Search failed";
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadStats() {
|
||||||
|
try {
|
||||||
|
stats = await get("/api/conversations/stats");
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to load stats", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function trigger() {
|
||||||
|
triggering = true;
|
||||||
|
try {
|
||||||
|
await post("/api/conversations/trigger");
|
||||||
|
setTimeout(() => selectDate(selectedDate), 5000);
|
||||||
|
} catch (e) {
|
||||||
|
error = "Trigger failed";
|
||||||
|
} finally {
|
||||||
|
triggering = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function markdownToHtml(md) {
|
||||||
|
if (!md) return "";
|
||||||
|
return md
|
||||||
|
.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
||||||
|
.replace(/^### (.+)$/gm, '<h3 class="text-lg font-semibold mt-4 mb-2">$1</h3>')
|
||||||
|
.replace(/^## (.+)$/gm, '<h2 class="text-xl font-bold mt-5 mb-2">$1</h2>')
|
||||||
|
.replace(/^# (.+)$/gm, '<h1 class="text-2xl font-bold mt-6 mb-3">$1</h1>')
|
||||||
|
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||||
|
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
||||||
|
.replace(/^- (.+)$/gm, '<li class="ml-4">$1</li>')
|
||||||
|
.replace(/\n{2,}/g, '<br><br>')
|
||||||
|
.replace(/\n/g, '<br>');
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTokens(n) {
|
||||||
|
return n ? n.toLocaleString() : "0";
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<h2 class="text-xl font-bold text-surface-800 dark:text-surface-100">Code Sessions</h2>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button onclick={() => view = "stats"} class="px-3 py-1.5 text-sm font-medium rounded-lg bg-surface-100 hover:bg-surface-200 dark:bg-surface-700 dark:hover:bg-surface-600 transition-colors">
|
||||||
|
Stats
|
||||||
|
</button>
|
||||||
|
<button onclick={trigger} disabled={triggering}
|
||||||
|
class="px-3 py-1.5 text-sm font-medium rounded-lg bg-primary-600 text-white hover:bg-primary-700 disabled:opacity-50">
|
||||||
|
{triggering ? "Generating..." : "Generate Summary"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<div class="mb-4 p-3 rounded-lg bg-red-50 text-red-700 text-sm dark:bg-red-900/20 dark:text-red-400">{error}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Search Bar -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={searchQuery}
|
||||||
|
onkeydown={(e) => e.key === "Enter" && search()}
|
||||||
|
placeholder="Search conversations..."
|
||||||
|
class="w-full px-4 py-2 text-sm border border-surface-200 dark:border-surface-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-surface-700 dark:text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Date Selector -->
|
||||||
|
{#if view !== "search" && view !== "stats"}
|
||||||
|
<div class="flex gap-2 mb-6 flex-wrap">
|
||||||
|
{#each dates as d}
|
||||||
|
<button onclick={() => selectDate(d)}
|
||||||
|
class="px-3 py-1.5 text-sm rounded-lg transition-colors {selectedDate === d ? 'bg-primary-600 text-white' : 'bg-surface-100 text-surface-600 hover:bg-surface-200 dark:bg-surface-700 dark:text-surface-300 dark:hover:bg-surface-600'}">
|
||||||
|
{d}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
{#if !dates.length && !error}
|
||||||
|
<p class="text-sm text-surface-400">No conversations yet.</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="flex justify-center py-12"><div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div></div>
|
||||||
|
{:else if view === "summary" && summary}
|
||||||
|
<!-- Summary View -->
|
||||||
|
<div class="mb-4 grid grid-cols-4 gap-3">
|
||||||
|
<div class="bg-white dark:bg-surface-800 rounded-lg p-3 border border-surface-200 dark:border-surface-700">
|
||||||
|
<div class="text-xs text-surface-400">Conversations</div>
|
||||||
|
<div class="text-xl font-bold text-surface-900 dark:text-white">{summary.conversation_count}</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white dark:bg-surface-800 rounded-lg p-3 border border-surface-200 dark:border-surface-700">
|
||||||
|
<div class="text-xs text-surface-400">Messages</div>
|
||||||
|
<div class="text-xl font-bold text-surface-900 dark:text-white">{summary.total_messages}</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white dark:bg-surface-800 rounded-lg p-3 border border-surface-200 dark:border-surface-700">
|
||||||
|
<div class="text-xs text-surface-400">Tokens</div>
|
||||||
|
<div class="text-xl font-bold text-surface-900 dark:text-white">{formatTokens(summary.total_tokens)}</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white dark:bg-surface-800 rounded-lg p-3 border border-surface-200 dark:border-surface-700">
|
||||||
|
<div class="text-xs text-surface-400">Projects</div>
|
||||||
|
<div class="text-sm font-medium text-surface-900 dark:text-white truncate">{summary.projects?.split(',')[0] || 'N/A'}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="prose dark:prose-invert max-w-none bg-white dark:bg-surface-800 rounded-xl p-6 shadow-sm border border-surface-200 dark:border-surface-700 mb-4">
|
||||||
|
{@html markdownToHtml(summary.content)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Conversation List -->
|
||||||
|
<div class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 shadow-sm overflow-hidden">
|
||||||
|
<div class="px-4 py-2 text-xs font-semibold uppercase tracking-wider text-surface-400 border-b border-surface-100 dark:border-surface-700">
|
||||||
|
Conversations ({conversations.length})
|
||||||
|
</div>
|
||||||
|
{#each conversations as conv}
|
||||||
|
<button
|
||||||
|
onclick={() => selectConversation(conv.session_id)}
|
||||||
|
class="w-full px-4 py-3 hover:bg-surface-50 dark:hover:bg-surface-700 border-b border-surface-100 dark:border-surface-700 last:border-0 transition-colors text-left">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="text-sm font-medium text-surface-700 dark:text-surface-200 truncate">{conv.slug || 'Untitled'}</div>
|
||||||
|
<div class="text-xs text-surface-400 truncate">{conv.project_path?.split('/').pop() || 'unknown'}</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3 text-xs text-surface-400">
|
||||||
|
<span>{conv.message_count} msgs</span>
|
||||||
|
<span>{formatTokens(conv.total_tokens)} tokens</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{:else if view === "detail" && selectedConversation}
|
||||||
|
<!-- Conversation Detail -->
|
||||||
|
<button onclick={() => view = "summary"} class="mb-4 text-sm text-primary-600 hover:text-primary-700">← Back to summary</button>
|
||||||
|
|
||||||
|
<div class="bg-white dark:bg-surface-800 rounded-xl p-4 border border-surface-200 dark:border-surface-700 mb-4">
|
||||||
|
<h3 class="text-lg font-bold text-surface-900 dark:text-white mb-2">{selectedConversation.slug || 'Untitled'}</h3>
|
||||||
|
<div class="grid grid-cols-2 gap-2 text-sm">
|
||||||
|
<div><span class="text-surface-400">Project:</span> <span class="text-surface-700 dark:text-surface-300">{selectedConversation.project_path?.split('/').pop()}</span></div>
|
||||||
|
<div><span class="text-surface-400">Branch:</span> <span class="text-surface-700 dark:text-surface-300">{selectedConversation.git_branch || 'N/A'}</span></div>
|
||||||
|
<div><span class="text-surface-400">Messages:</span> <span class="text-surface-700 dark:text-surface-300">{selectedConversation.message_count}</span></div>
|
||||||
|
<div><span class="text-surface-400">Tokens:</span> <span class="text-surface-700 dark:text-surface-300">{formatTokens(selectedConversation.total_input_tokens + selectedConversation.total_output_tokens)}</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Messages -->
|
||||||
|
<div class="space-y-3">
|
||||||
|
{#each messages as msg}
|
||||||
|
<div class="bg-white dark:bg-surface-800 rounded-lg p-4 border border-surface-200 dark:border-surface-700">
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<span class="text-xs font-semibold uppercase {msg.role === 'user' ? 'text-blue-600' : 'text-green-600'}">{msg.role}</span>
|
||||||
|
<span class="text-xs text-surface-400">{msg.timestamp?.slice(11, 19)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-surface-700 dark:text-surface-300 whitespace-pre-wrap">{msg.content?.slice(0, 500)}{msg.content?.length > 500 ? '...' : ''}</div>
|
||||||
|
{#if msg.tool_calls?.length}
|
||||||
|
<div class="mt-2 pt-2 border-t border-surface-100 dark:border-surface-700">
|
||||||
|
<div class="text-xs font-medium text-surface-400 mb-1">Tools: {msg.tool_calls.map(t => t.name).join(', ')}</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if msg.input_tokens || msg.output_tokens}
|
||||||
|
<div class="mt-2 text-xs text-surface-400">
|
||||||
|
{msg.input_tokens ? `${msg.input_tokens} in` : ''} {msg.output_tokens ? `${msg.output_tokens} out` : ''}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{:else if view === "search"}
|
||||||
|
<!-- Search Results -->
|
||||||
|
<button onclick={() => view = "summary"} class="mb-4 text-sm text-primary-600 hover:text-primary-700">← Back</button>
|
||||||
|
<div class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 shadow-sm overflow-hidden">
|
||||||
|
<div class="px-4 py-2 text-xs font-semibold uppercase tracking-wider text-surface-400 border-b border-surface-100 dark:border-surface-700">
|
||||||
|
Search Results ({searchResults.length})
|
||||||
|
</div>
|
||||||
|
{#each searchResults as conv}
|
||||||
|
<button
|
||||||
|
onclick={() => selectConversation(conv.session_id)}
|
||||||
|
class="w-full px-4 py-3 hover:bg-surface-50 dark:hover:bg-surface-700 border-b border-surface-100 dark:border-surface-700 last:border-0 transition-colors text-left">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="text-sm font-medium text-surface-700 dark:text-surface-200 truncate">{conv.slug || 'Untitled'}</div>
|
||||||
|
<div class="text-xs text-surface-400">{conv.started_at?.slice(0, 10)} • {conv.project_path?.split('/').pop()}</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-surface-400">{conv.message_count} msgs</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
{#if searchResults.length === 0}
|
||||||
|
<div class="px-4 py-8 text-center text-sm text-surface-400">No results found</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{:else if view === "stats" && stats}
|
||||||
|
<!-- Statistics -->
|
||||||
|
<button onclick={() => view = "summary"} class="mb-4 text-sm text-primary-600 hover:text-primary-700">← Back</button>
|
||||||
|
<div class="grid grid-cols-3 gap-4 mb-6">
|
||||||
|
<div class="bg-white dark:bg-surface-800 rounded-lg p-4 border border-surface-200 dark:border-surface-700">
|
||||||
|
<div class="text-sm text-surface-400">Total Conversations</div>
|
||||||
|
<div class="text-2xl font-bold text-surface-900 dark:text-white">{stats.total_conversations}</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white dark:bg-surface-800 rounded-lg p-4 border border-surface-200 dark:border-surface-700">
|
||||||
|
<div class="text-sm text-surface-400">Total Messages</div>
|
||||||
|
<div class="text-2xl font-bold text-surface-900 dark:text-white">{stats.total_messages}</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white dark:bg-surface-800 rounded-lg p-4 border border-surface-200 dark:border-surface-700">
|
||||||
|
<div class="text-sm text-surface-400">Total Tokens</div>
|
||||||
|
<div class="text-2xl font-bold text-surface-900 dark:text-white">{formatTokens(stats.total_tokens)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div class="bg-white dark:bg-surface-800 rounded-lg p-4 border border-surface-200 dark:border-surface-700">
|
||||||
|
<h3 class="text-sm font-semibold text-surface-700 dark:text-surface-200 mb-3">Top Projects</h3>
|
||||||
|
{#each stats.top_projects as proj}
|
||||||
|
<div class="flex justify-between text-sm py-1">
|
||||||
|
<span class="text-surface-600 dark:text-surface-300 truncate">{proj.path?.split('/').pop()}</span>
|
||||||
|
<span class="text-surface-400">{proj.count}</span>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white dark:bg-surface-800 rounded-lg p-4 border border-surface-200 dark:border-surface-700">
|
||||||
|
<h3 class="text-sm font-semibold text-surface-700 dark:text-surface-200 mb-3">Top Tools</h3>
|
||||||
|
{#each stats.top_tools as tool}
|
||||||
|
<div class="flex justify-between text-sm py-1">
|
||||||
|
<span class="text-surface-600 dark:text-surface-300">{tool.name}</span>
|
||||||
|
<span class="text-surface-400">{tool.count}</span>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
Reference in New Issue
Block a user