1package server
2
3import (
4 "encoding/json"
5 "io"
6 "net/http"
7 "strconv"
8
9 "shelley.exe.dev/ui"
10)
11
12// handleDebugConversationsPage serves the conversations list debug page
13func (s *Server) handleDebugConversationsPage(w http.ResponseWriter, r *http.Request) {
14 fsys := ui.Assets()
15 file, err := fsys.Open("/conversations.html")
16 if err != nil {
17 http.Error(w, "conversations.html not found", http.StatusNotFound)
18 return
19 }
20 defer file.Close()
21
22 w.Header().Set("Content-Type", "text/html")
23 w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
24 io.Copy(w, file)
25}
26
27// handleDebugLLMRequests serves the debug page for LLM requests
28func (s *Server) handleDebugLLMRequests(w http.ResponseWriter, r *http.Request) {
29 w.Header().Set("Content-Type", "text/html")
30 w.Write([]byte(debugLLMRequestsHTML))
31}
32
33// handleDebugLLMRequestsAPI returns recent LLM requests as JSON
34func (s *Server) handleDebugLLMRequestsAPI(w http.ResponseWriter, r *http.Request) {
35 ctx := r.Context()
36
37 limit := int64(100)
38 if limitStr := r.URL.Query().Get("limit"); limitStr != "" {
39 if l, err := strconv.ParseInt(limitStr, 10, 64); err == nil && l > 0 {
40 limit = l
41 }
42 }
43
44 requests, err := s.db.ListRecentLLMRequests(ctx, limit)
45 if err != nil {
46 s.logger.Error("Failed to list LLM requests", "error", err)
47 http.Error(w, "Internal server error", http.StatusInternalServerError)
48 return
49 }
50
51 w.Header().Set("Content-Type", "application/json")
52 json.NewEncoder(w).Encode(requests)
53}
54
55// handleDebugLLMRequestBody returns the request body for a specific LLM request
56func (s *Server) handleDebugLLMRequestBody(w http.ResponseWriter, r *http.Request) {
57 ctx := r.Context()
58
59 idStr := r.PathValue("id")
60 id, err := strconv.ParseInt(idStr, 10, 64)
61 if err != nil {
62 http.Error(w, "Invalid ID", http.StatusBadRequest)
63 return
64 }
65
66 body, err := s.db.GetLLMRequestBody(ctx, id)
67 if err != nil {
68 s.logger.Error("Failed to get LLM request body", "error", err, "id", id)
69 http.Error(w, "Not found", http.StatusNotFound)
70 return
71 }
72
73 if body == nil {
74 w.Header().Set("Content-Type", "application/json")
75 w.Write([]byte("null"))
76 return
77 }
78
79 w.Header().Set("Content-Type", "application/json")
80 w.Write([]byte(*body))
81}
82
83// handleDebugLLMResponseBody returns the response body for a specific LLM request
84func (s *Server) handleDebugLLMResponseBody(w http.ResponseWriter, r *http.Request) {
85 ctx := r.Context()
86
87 idStr := r.PathValue("id")
88 id, err := strconv.ParseInt(idStr, 10, 64)
89 if err != nil {
90 http.Error(w, "Invalid ID", http.StatusBadRequest)
91 return
92 }
93
94 body, err := s.db.GetLLMResponseBody(ctx, id)
95 if err != nil {
96 s.logger.Error("Failed to get LLM response body", "error", err, "id", id)
97 http.Error(w, "Not found", http.StatusNotFound)
98 return
99 }
100
101 if body == nil {
102 w.Header().Set("Content-Type", "application/json")
103 w.Write([]byte("null"))
104 return
105 }
106
107 w.Header().Set("Content-Type", "application/json")
108 w.Write([]byte(*body))
109}
110
111// handleDebugLLMRequestBodyFull returns the full reconstructed request body,
112// including prefix data from the prefix chain.
113func (s *Server) handleDebugLLMRequestBodyFull(w http.ResponseWriter, r *http.Request) {
114 ctx := r.Context()
115
116 idStr := r.PathValue("id")
117 id, err := strconv.ParseInt(idStr, 10, 64)
118 if err != nil {
119 http.Error(w, "Invalid ID", http.StatusBadRequest)
120 return
121 }
122
123 // Use the existing DB method to reconstruct the full body
124 fullBody, err := s.db.GetFullLLMRequestBody(ctx, id)
125 if err != nil {
126 s.logger.Error("Failed to get full LLM request body", "error", err, "id", id)
127 http.Error(w, "Not found", http.StatusNotFound)
128 return
129 }
130
131 if fullBody == "" {
132 w.Header().Set("Content-Type", "application/json")
133 w.Write([]byte("null"))
134 return
135 }
136
137 w.Header().Set("Content-Type", "application/json")
138 w.Write([]byte(fullBody))
139}
140
141const debugLLMRequestsHTML = `<!DOCTYPE html>
142<html lang="en">
143<head>
144<meta charset="UTF-8">
145<meta name="viewport" content="width=device-width, initial-scale=1.0">
146<title>Debug: LLM Requests</title>
147<style>
148* { box-sizing: border-box; }
149body {
150 font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
151 margin: 0;
152 padding: 20px;
153 background: #fff;
154 color: #1a1a1a;
155}
156h1 { margin: 0 0 20px 0; font-size: 24px; color: #000; }
157table {
158 width: 100%;
159 border-collapse: collapse;
160 font-size: 13px;
161}
162th, td {
163 padding: 8px 12px;
164 text-align: left;
165 border-bottom: 1px solid #e0e0e0;
166}
167th {
168 background: #f5f5f5;
169 font-weight: 600;
170 position: sticky;
171 top: 0;
172}
173tr:hover { background: #f8f8f8; }
174.mono { font-family: 'SF Mono', Monaco, monospace; font-size: 12px; }
175.error { color: #d32f2f; }
176.success { color: #2e7d32; }
177.btn {
178 background: #f5f5f5;
179 border: 1px solid #ccc;
180 color: #333;
181 padding: 4px 8px;
182 border-radius: 4px;
183 cursor: pointer;
184 font-size: 12px;
185}
186.btn:hover { background: #e8e8e8; }
187.btn:disabled { opacity: 0.5; cursor: not-allowed; }
188.btn.active { background: #1976d2; color: #fff; border-color: #1565c0; }
189.json-viewer {
190 background: #fafafa;
191 border: 1px solid #e0e0e0;
192 border-radius: 4px;
193 padding: 12px;
194 overflow-x: auto;
195 max-height: 600px;
196 overflow-y: auto;
197 flex: 1;
198 min-width: 0;
199}
200.json-viewer pre {
201 margin: 0;
202 font-family: 'SF Mono', Monaco, monospace;
203 font-size: 12px;
204 white-space: pre-wrap;
205 word-wrap: break-word;
206}
207.collapsed { display: none; }
208.size { color: #666; font-size: 11px; }
209.prefix { color: #f57c00; }
210.dedup-info { color: #1976d2; font-size: 11px; }
211.loading { color: #666; font-style: italic; }
212.expand-row { background: #fafafa; }
213.expand-row td { padding: 0; }
214.expand-content { padding: 12px; }
215.panels {
216 display: flex;
217 gap: 16px;
218}
219.panel {
220 flex: 1;
221 min-width: 0;
222 display: flex;
223 flex-direction: column;
224}
225.panel-header {
226 font-weight: 600;
227 margin-bottom: 8px;
228 color: #333;
229 display: flex;
230 align-items: center;
231 gap: 8px;
232}
233.panel-header .btn {
234 font-size: 11px;
235 padding: 2px 6px;
236}
237.model-display { color: #1976d2; }
238.model-id { color: #666; font-size: 11px; }
239.string { color: #2e7d32; }
240.number { color: #e65100; }
241.boolean { color: #0097a7; }
242.null { color: #7b1fa2; }
243.key { color: #c62828; }
244</style>
245</head>
246<body>
247<h1>LLM Requests</h1>
248<table id="requests-table">
249<thead>
250<tr>
251 <th>ID</th>
252 <th>Time</th>
253 <th>Model</th>
254 <th>Provider</th>
255 <th>Status</th>
256 <th>Duration</th>
257 <th>Request Size</th>
258 <th>Response Size</th>
259 <th>Prefix Info</th>
260 <th>Actions</th>
261</tr>
262</thead>
263<tbody id="requests-body">
264<tr><td colspan="10" class="loading">Loading...</td></tr>
265</tbody>
266</table>
267
268<script>
269const expandedRows = new Set();
270const loadedData = {};
271
272function formatSize(bytes) {
273 if (bytes === null || bytes === undefined) return '-';
274 if (bytes < 1024) return bytes + ' B';
275 if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
276 return (bytes / (1024 * 1024)).toFixed(2) + ' MB';
277}
278
279function formatDate(dateStr) {
280 const d = new Date(dateStr);
281 return d.toLocaleString();
282}
283
284function formatDuration(ms) {
285 if (ms === null || ms === undefined) return '-';
286 if (ms < 1000) return ms + 'ms';
287 return (ms / 1000).toFixed(2) + 's';
288}
289
290function formatModel(model, displayName) {
291 if (displayName) {
292 return '<span class="model-display">' + displayName + '</span> <span class="model-id">(' + model + ')</span>';
293 }
294 return model;
295}
296
297function syntaxHighlight(json) {
298 if (typeof json !== 'string') json = JSON.stringify(json, null, 2);
299 json = json.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
300 return json.replace(/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?)/g, function (match) {
301 let cls = 'number';
302 if (/^"/.test(match)) {
303 if (/:$/.test(match)) {
304 cls = 'key';
305 } else {
306 cls = 'string';
307 }
308 } else if (/true|false/.test(match)) {
309 cls = 'boolean';
310 } else if (/null/.test(match)) {
311 cls = 'null';
312 }
313 return '<span class="' + cls + '">' + match + '</span>';
314 });
315}
316
317async function loadRequests() {
318 try {
319 const resp = await fetch('/debug/llm_requests/api?limit=100');
320 const data = await resp.json();
321 renderTable(data);
322 } catch (e) {
323 document.getElementById('requests-body').innerHTML =
324 '<tr><td colspan="10" class="error">Error loading requests: ' + e.message + '</td></tr>';
325 }
326}
327
328function renderTable(requests) {
329 const tbody = document.getElementById('requests-body');
330 if (!requests || requests.length === 0) {
331 tbody.innerHTML = '<tr><td colspan="10">No requests found</td></tr>';
332 return;
333 }
334 tbody.innerHTML = '';
335 for (const req of requests) {
336 const tr = document.createElement('tr');
337 tr.id = 'row-' + req.id;
338
339 const statusClass = req.status_code && req.status_code >= 200 && req.status_code < 300 ? 'success' :
340 (req.status_code ? 'error' : '');
341
342 let prefixInfo = '-';
343 if (req.prefix_request_id) {
344 prefixInfo = '<span class="dedup-info">prefix from #' + req.prefix_request_id +
345 ' (' + formatSize(req.prefix_length) + ')</span>';
346 }
347
348 tr.innerHTML = ` + "`" + `
349 <td class="mono">${req.id}</td>
350 <td>${formatDate(req.created_at)}</td>
351 <td>${formatModel(req.model, req.model_display_name)}</td>
352 <td>${req.provider}</td>
353 <td class="${statusClass}">${req.status_code || '-'}${req.error ? ' ⚠' : ''}</td>
354 <td>${formatDuration(req.duration_ms)}</td>
355 <td class="size">${formatSize(req.request_body_length)}</td>
356 <td class="size">${formatSize(req.response_body_length)}</td>
357 <td>${prefixInfo}</td>
358 <td><button class="btn" onclick="toggleExpand(${req.id}, ${req.prefix_request_id !== null})">Expand</button></td>
359 ` + "`" + `;
360 tbody.appendChild(tr);
361 }
362}
363
364async function toggleExpand(id, hasPrefix) {
365 const existingExpand = document.getElementById('expand-' + id);
366 if (existingExpand) {
367 existingExpand.remove();
368 expandedRows.delete(id);
369 return;
370 }
371
372 expandedRows.add(id);
373 const row = document.getElementById('row-' + id);
374 const expandRow = document.createElement('tr');
375 expandRow.id = 'expand-' + id;
376 expandRow.className = 'expand-row';
377 expandRow.innerHTML = ` + "`" + `
378 <td colspan="10">
379 <div class="expand-content">
380 <div class="panels">
381 <div class="panel">
382 <div class="panel-header">
383 Request
384 ${hasPrefix ? '<button class="btn" id="toggle-full-' + id + '" onclick="toggleFullRequest(' + id + ')">Show Full</button>' : ''}
385 </div>
386 <div class="json-viewer" id="request-panel-${id}"><pre class="loading">Loading request...</pre></div>
387 </div>
388 <div class="panel">
389 <div class="panel-header">Response</div>
390 <div class="json-viewer" id="response-panel-${id}"><pre class="loading">Loading response...</pre></div>
391 </div>
392 </div>
393 </div>
394 </td>
395 ` + "`" + `;
396 row.after(expandRow);
397
398 // Load both request and response
399 loadBody(id, 'request');
400 loadBody(id, 'response');
401}
402
403async function loadBody(id, type) {
404 const key = id + '-' + type;
405 if (loadedData[key]) {
406 renderBody(id, type, loadedData[key]);
407 return;
408 }
409
410 try {
411 const url = type === 'request'
412 ? '/debug/llm_requests/' + id + '/request'
413 : '/debug/llm_requests/' + id + '/response';
414 const resp = await fetch(url);
415 const text = await resp.text();
416 let data;
417 try {
418 data = JSON.parse(text);
419 } catch {
420 data = text;
421 }
422 loadedData[key] = data;
423 renderBody(id, type, data);
424 } catch (e) {
425 const panelId = type === 'request' ? 'request-panel-' + id : 'response-panel-' + id;
426 const container = document.querySelector('#' + panelId + ' pre');
427 if (container) {
428 container.className = 'error';
429 container.textContent = 'Error loading: ' + e.message;
430 }
431 }
432}
433
434async function loadFullBody(id) {
435 const key = id + '-request-full';
436 if (loadedData[key]) {
437 return loadedData[key];
438 }
439
440 try {
441 const resp = await fetch('/debug/llm_requests/' + id + '/request_full');
442 const text = await resp.text();
443 let data;
444 try {
445 data = JSON.parse(text);
446 } catch {
447 data = text;
448 }
449 loadedData[key] = data;
450 return data;
451 } catch (e) {
452 throw e;
453 }
454}
455
456async function toggleFullRequest(id) {
457 const btn = document.getElementById('toggle-full-' + id);
458 if (!btn) return;
459
460 const isShowingFull = btn.classList.contains('active');
461
462 if (isShowingFull) {
463 // Switch back to suffix-only
464 btn.classList.remove('active');
465 btn.textContent = 'Show Full';
466 renderBody(id, 'request', loadedData[id + '-request']);
467 } else {
468 // Load and show full request
469 btn.textContent = 'Loading...';
470 try {
471 const fullData = await loadFullBody(id);
472 btn.classList.add('active');
473 btn.textContent = 'Show Suffix Only';
474 renderBody(id, 'request', fullData);
475 } catch (e) {
476 btn.textContent = 'Error';
477 setTimeout(() => { btn.textContent = 'Show Full'; }, 2000);
478 }
479 }
480}
481
482function renderBody(id, type, data) {
483 const panelId = type === 'request' ? 'request-panel-' + id : 'response-panel-' + id;
484 const container = document.querySelector('#' + panelId + ' pre');
485 if (!container) return;
486
487 if (data === null) {
488 container.className = '';
489 container.textContent = '(empty)';
490 return;
491 }
492
493 container.className = '';
494 if (typeof data === 'object') {
495 container.innerHTML = syntaxHighlight(JSON.stringify(data, null, 2));
496 } else {
497 container.textContent = data;
498 }
499}
500
501loadRequests();
502</script>
503</body>
504</html>
505`