@@ -2,10 +2,28 @@ package server
import (
"encoding/json"
+ "io"
"net/http"
"strconv"
+
+ "shelley.exe.dev/ui"
)
+// handleDebugConversationsPage serves the conversations list debug page
+func (s *Server) handleDebugConversationsPage(w http.ResponseWriter, r *http.Request) {
+ fsys := ui.Assets()
+ file, err := fsys.Open("/conversations.html")
+ if err != nil {
+ http.Error(w, "conversations.html not found", http.StatusNotFound)
+ return
+ }
+ defer file.Close()
+
+ w.Header().Set("Content-Type", "text/html")
+ w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
+ io.Copy(w, file)
+}
+
// handleDebugLLMRequests serves the debug page for LLM requests
func (s *Server) handleDebugLLMRequests(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
@@ -285,6 +285,7 @@ func (s *Server) RegisterRoutes(mux *http.ServeMux) {
mux.Handle("POST /exit", http.HandlerFunc(s.handleExit))
// Debug endpoints
+ mux.Handle("GET /debug/conversations", http.HandlerFunc(s.handleDebugConversationsPage))
mux.Handle("GET /debug/llm_requests", http.HandlerFunc(s.handleDebugLLMRequests))
mux.Handle("GET /debug/llm_requests/api", http.HandlerFunc(s.handleDebugLLMRequestsAPI))
mux.Handle("GET /debug/llm_requests/{id}/request", http.HandlerFunc(s.handleDebugLLMRequestBody))
@@ -0,0 +1,194 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>Debug: Conversations</title>
+ <style>
+ * { box-sizing: border-box; }
+ body {
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+ margin: 0;
+ padding: 20px;
+ background: #fff;
+ color: #1a1a1a;
+ }
+ h1 { margin: 0 0 20px 0; font-size: 24px; }
+ .search-box {
+ margin-bottom: 16px;
+ }
+ .search-box input {
+ padding: 8px 12px;
+ font-size: 14px;
+ border: 1px solid #ccc;
+ border-radius: 4px;
+ width: 300px;
+ }
+ .search-box input:focus {
+ outline: none;
+ border-color: #1976d2;
+ }
+ table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 14px;
+ }
+ th, td {
+ padding: 10px 12px;
+ text-align: left;
+ border-bottom: 1px solid #e0e0e0;
+ }
+ th {
+ background: #f5f5f5;
+ font-weight: 600;
+ position: sticky;
+ top: 0;
+ }
+ tr:hover { background: #f8f8f8; }
+ .slug { font-family: 'SF Mono', Monaco, monospace; font-size: 13px; }
+ .date { color: #666; font-size: 13px; }
+ .btn {
+ background: #f5f5f5;
+ border: 1px solid #ccc;
+ color: #333;
+ padding: 6px 12px;
+ border-radius: 4px;
+ cursor: pointer;
+ font-size: 13px;
+ text-decoration: none;
+ display: inline-block;
+ }
+ .btn:hover { background: #e8e8e8; }
+ .btn:disabled { opacity: 0.5; cursor: not-allowed; }
+ .loading { color: #666; font-style: italic; }
+ .info {
+ margin-top: 20px;
+ padding: 12px;
+ background: #e3f2fd;
+ border-radius: 4px;
+ font-size: 13px;
+ }
+ .info code {
+ background: #fff;
+ padding: 2px 6px;
+ border-radius: 3px;
+ font-family: 'SF Mono', Monaco, monospace;
+ }
+ </style>
+</head>
+<body>
+ <h1>Conversations</h1>
+
+ <div class="search-box">
+ <input type="text" id="search" placeholder="Filter by slug..." oninput="filterTable()">
+ </div>
+
+ <table id="conversations-table">
+ <thead>
+ <tr>
+ <th>Slug</th>
+ <th>Created</th>
+ <th>Actions</th>
+ </tr>
+ </thead>
+ <tbody id="conversations-body">
+ <tr><td colspan="3" class="loading">Loading...</td></tr>
+ </tbody>
+ </table>
+
+ <div class="info">
+ <strong>CSV Format:</strong> <code>input_tokens,cache_write_tokens,cache_read_tokens,output_tokens</code><br>
+ One row per agent message. Use with the standalone <a href="https://phil-dev.exe.xyz:8000/">Token Spend Visualizer</a>.
+ </div>
+
+ <script>
+ let allConversations = [];
+
+ async function loadConversations() {
+ try {
+ const response = await fetch('/api/conversations?limit=500');
+ allConversations = await response.json();
+ renderTable(allConversations);
+ } catch (e) {
+ document.getElementById('conversations-body').innerHTML =
+ '<tr><td colspan="3" class="loading">Error loading conversations</td></tr>';
+ }
+ }
+
+ function renderTable(conversations) {
+ const tbody = document.getElementById('conversations-body');
+ if (!conversations || conversations.length === 0) {
+ tbody.innerHTML = '<tr><td colspan="3">No conversations found</td></tr>';
+ return;
+ }
+
+ tbody.innerHTML = conversations.map(conv => {
+ const slug = conv.slug || conv.conversation_id.slice(0, 8);
+ const date = new Date(conv.created_at).toLocaleString();
+ return `
+ <tr data-slug="${slug.toLowerCase()}">
+ <td class="slug">${escapeHtml(slug)}</td>
+ <td class="date">${date}</td>
+ <td>
+ <button class="btn" onclick="downloadCSV('${conv.conversation_id}', '${escapeHtml(slug)}')">Download CSV</button>
+ </td>
+ </tr>
+ `;
+ }).join('');
+ }
+
+ function filterTable() {
+ const query = document.getElementById('search').value.toLowerCase();
+ const rows = document.querySelectorAll('#conversations-body tr');
+ rows.forEach(row => {
+ const slug = row.dataset.slug || '';
+ row.style.display = slug.includes(query) ? '' : 'none';
+ });
+ }
+
+ function escapeHtml(str) {
+ return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
+ }
+
+ async function downloadCSV(conversationId, slug) {
+ try {
+ const response = await fetch(`/api/conversation/${conversationId}`);
+ const data = await response.json();
+
+ const rows = ['input_tokens,cache_write_tokens,cache_read_tokens,output_tokens'];
+
+ for (const msg of data.messages) {
+ if (msg.type !== 'agent' || !msg.usage_data) continue;
+
+ let usage;
+ try {
+ usage = JSON.parse(msg.usage_data);
+ } catch (e) {
+ continue;
+ }
+
+ rows.push([
+ usage.input_tokens || 0,
+ usage.cache_creation_input_tokens || 0,
+ usage.cache_read_input_tokens || 0,
+ usage.output_tokens || 0
+ ].join(','));
+ }
+
+ const csv = rows.join('\n');
+ const blob = new Blob([csv], { type: 'text/csv' });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = `${slug}-tokens.csv`;
+ a.click();
+ URL.revokeObjectURL(url);
+ } catch (e) {
+ console.error('Failed to download CSV:', e);
+ }
+ }
+
+ loadConversations();
+ </script>
+</body>
+</html>