feat: add Claude Code conversation tracker
- Create claude-code-tracker service to monitor and parse Claude Code conversations - Parse JSONL format with messages, tool calls, and metadata - Store in SQLite with full-text search support - Generate daily summaries using Claude API - Add Conversations UI with search, stats, and conversation browsing - Integrate with dashboard backend and frontend - Add sync script for Mac to NAS file transfer
This commit is contained in:
@@ -54,6 +54,9 @@ CORS_ORIGINS = os.environ.get("CORS_ORIGINS", "https://nas.jimmygan.com,https://
|
||||
# Chat Summary
|
||||
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_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 (
|
||||
cc_connect,
|
||||
chat_summary,
|
||||
conversation_tracker,
|
||||
docker_router,
|
||||
files,
|
||||
gitea,
|
||||
@@ -296,6 +297,10 @@ app.include_router(
|
||||
prefix="/api/chat-summary",
|
||||
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(
|
||||
info_engine.router,
|
||||
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 Settings from "./routes/Settings.svelte";
|
||||
import ChatSummary from "./routes/ChatSummary.svelte";
|
||||
import Conversations from "./routes/Conversations.svelte";
|
||||
import Security from "./routes/Security.svelte";
|
||||
import LiteLLM from "./routes/LiteLLM.svelte";
|
||||
import CcConnect from "./routes/CcConnect.svelte";
|
||||
@@ -26,6 +27,7 @@
|
||||
"terminal",
|
||||
"openclaw",
|
||||
"chat-digest",
|
||||
"conversations",
|
||||
"settings",
|
||||
"security",
|
||||
"litellm",
|
||||
@@ -201,6 +203,8 @@
|
||||
<OpenClaw />
|
||||
{:else if page === "chat-digest" && hasPageAccess("chat-digest")}
|
||||
<ChatSummary />
|
||||
{:else if page === "conversations" && hasPageAccess("conversations")}
|
||||
<Conversations />
|
||||
{:else if page === "settings" && userRole === "admin"}
|
||||
<Settings />
|
||||
{:else if page === "security" && hasPageAccess("security")}
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
{ id: "openclaw", label: "OpenClaw", icon: "openclaw" },
|
||||
{ id: "cc-connect", label: "cc-connect", icon: "users" },
|
||||
{ id: "chat-digest", label: "Chat Digest", icon: "chat" },
|
||||
{ id: "conversations", label: "Code Sessions", icon: "code" },
|
||||
{ id: "gitea", label: "Repos", icon: "git" },
|
||||
{ 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 },
|
||||
@@ -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>
|
||||
{: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>
|
||||
{: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"}
|
||||
<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"}
|
||||
|
||||
@@ -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