debug_handlers.go

  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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
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`