feat: DebugPayloadViewer component with copy, export, collapsible sections

This commit is contained in:
jlightner 2026-03-30 19:01:17 +00:00
parent 0c4208bc07
commit ff1b24867f
3 changed files with 206 additions and 0 deletions

View file

@ -2977,3 +2977,106 @@ a.app-footer__repo:hover {
max-height: 300px;
overflow-y: auto;
}
/* ── Debug Payload Viewer ───────────────────────────────────────────────── */
.debug-viewer {
margin-top: 0.375rem;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-bg-surface);
overflow: hidden;
}
.debug-viewer__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.375rem 0.625rem;
border-bottom: 1px solid var(--color-border);
}
.debug-viewer__label {
font-size: 0.7rem;
font-weight: 600;
color: var(--color-accent);
text-transform: uppercase;
letter-spacing: 0.03em;
}
.debug-viewer__export {
background: none;
border: 1px solid var(--color-border);
border-radius: 3px;
color: var(--color-text-secondary);
font-size: 0.7rem;
cursor: pointer;
padding: 0.125rem 0.5rem;
font-family: inherit;
transition: color 0.15s, border-color 0.15s;
}
.debug-viewer__export:hover {
color: var(--color-accent);
border-color: var(--color-accent);
}
.debug-viewer__section {
border-top: 1px solid var(--color-border);
}
.debug-viewer__section:first-of-type {
border-top: none;
}
.debug-viewer__section-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.25rem 0.625rem;
}
.debug-viewer__section-toggle {
background: none;
border: none;
color: var(--color-text-primary);
font-size: 0.75rem;
cursor: pointer;
padding: 0;
font-family: inherit;
}
.debug-viewer__section-toggle:hover {
color: var(--color-accent);
}
.debug-viewer__copy {
background: none;
border: none;
color: var(--color-text-secondary);
font-size: 0.675rem;
cursor: pointer;
padding: 0.125rem 0.375rem;
font-family: inherit;
border-radius: 3px;
transition: color 0.15s, background 0.15s;
}
.debug-viewer__copy:hover {
color: var(--color-accent);
background: var(--color-accent-subtle);
}
.debug-viewer__content {
margin: 0;
padding: 0.5rem 0.75rem;
background: var(--color-bg-transcript);
color: var(--color-text-secondary);
font-size: 0.75rem;
line-height: 1.5;
overflow-x: auto;
max-height: 400px;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-word;
}

View file

@ -389,6 +389,9 @@ export interface PipelineEvent {
model: string | null;
duration_ms: number | null;
payload: Record<string, unknown> | null;
system_prompt_text: string | null;
user_prompt_text: string | null;
response_text: string | null;
created_at: string | null;
}

View file

@ -95,6 +95,105 @@ function JsonViewer({ data }: { data: Record<string, unknown> | null }) {
);
}
// ── Debug Payload Viewer ─────────────────────────────────────────────────────
interface DebugSection {
label: string;
content: string;
}
function DebugPayloadViewer({ event }: { event: PipelineEvent }) {
const sections: DebugSection[] = [];
if (event.system_prompt_text) sections.push({ label: "System Prompt", content: event.system_prompt_text });
if (event.user_prompt_text) sections.push({ label: "User Prompt", content: event.user_prompt_text });
if (event.response_text) sections.push({ label: "Response", content: event.response_text });
const [openSections, setOpenSections] = useState<Record<string, boolean>>({});
const [copiedKey, setCopiedKey] = useState<string | null>(null);
if (sections.length === 0) return null;
const toggleSection = (label: string) => {
setOpenSections((prev) => ({ ...prev, [label]: !prev[label] }));
};
const copyToClipboard = async (label: string, text: string) => {
try {
await navigator.clipboard.writeText(text);
setCopiedKey(label);
setTimeout(() => setCopiedKey(null), 1500);
} catch {
// Fallback for non-HTTPS contexts
const ta = document.createElement("textarea");
ta.value = text;
ta.style.position = "fixed";
ta.style.opacity = "0";
document.body.appendChild(ta);
ta.select();
document.execCommand("copy");
document.body.removeChild(ta);
setCopiedKey(label);
setTimeout(() => setCopiedKey(null), 1500);
}
};
const exportAsJson = () => {
const data: Record<string, string | null> = {
event_id: event.id,
stage: event.stage,
event_type: event.event_type,
model: event.model,
system_prompt_text: event.system_prompt_text,
user_prompt_text: event.user_prompt_text,
response_text: event.response_text,
};
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `debug-${event.stage}-${event.id.slice(0, 8)}.json`;
a.click();
URL.revokeObjectURL(url);
};
return (
<div className="debug-viewer">
<div className="debug-viewer__header">
<span className="debug-viewer__label">LLM Debug</span>
<button className="debug-viewer__export" onClick={exportAsJson} title="Export debug payload as JSON">
JSON
</button>
</div>
{sections.map((sec) => {
const isOpen = !!openSections[sec.label];
return (
<div key={sec.label} className="debug-viewer__section">
<div className="debug-viewer__section-header">
<button
className="debug-viewer__section-toggle"
onClick={() => toggleSection(sec.label)}
aria-expanded={isOpen}
>
{isOpen ? "▾" : "▸"} {sec.label}
</button>
<button
className="debug-viewer__copy"
onClick={() => void copyToClipboard(sec.label, sec.content)}
title={`Copy ${sec.label}`}
>
{copiedKey === sec.label ? "✓ Copied" : "Copy"}
</button>
</div>
{isOpen && (
<pre className="debug-viewer__content">{sec.content}</pre>
)}
</div>
);
})}
</div>
);
}
// ── Event Log ────────────────────────────────────────────────────────────────
function EventLog({ videoId }: { videoId: string }) {
@ -177,6 +276,7 @@ function EventLog({ videoId }: { videoId: string }) {
<span className="pipeline-event__time">{formatDate(evt.created_at)}</span>
</div>
<JsonViewer data={evt.payload} />
<DebugPayloadViewer event={evt} />
</div>
))}
</div>