shelley: /debug/conversations page with CSV export for token visualization

Philip Zeyliger and Shelley created

Prompt: I want a /debug/conversations view in Shelley which is just a list of conversations (with their slugs), with a way to download a compatible CSV of message costs.

- Add /debug/conversations route
- Lists all conversations with slug, date, message count
- Searchable by slug
- Download CSV button for each conversation
- CSV format: input_tokens,cache_write_tokens,cache_read_tokens,output_tokens
- CSV can be used with standalone token spend visualizer

Co-authored-by: Shelley <shelley@exe.dev>

Change summary

server/debug_handlers.go     |  18 +++
server/server.go             |   1 
ui/public/conversations.html | 194 ++++++++++++++++++++++++++++++++++++++
3 files changed, 213 insertions(+)

Detailed changes

server/debug_handlers.go 🔗

@@ -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")

server/server.go 🔗

@@ -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))

ui/public/conversations.html 🔗

@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
+    }
+    
+    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>