Leon4gr45's picture
Upload folder using huggingface_hub
eeb9404 verified
Raw
History Blame Contribute Delete
11.1 kB
'use client';
import { useState, useEffect, useRef, useMemo, useSyncExternalStore } from 'react';
import { Button } from '@/components/ui/button';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { ChevronDown, ChevronUp, ChevronsUpDown, ChevronsDownUp, Bug, Trash2, Copy, Download } from 'lucide-react';
import { PanelContainer, PanelHeader } from '@/components/ui/panel';
import { MemoryMonitor } from './memory-monitor';
import { MessagesTab, type ForceState } from './messages-tab';
import { configManager } from '@/lib/config/storage';
import { requestSnapshotStore } from '@/lib/llm/request-snapshot';
import { toast } from 'sonner';
import type { DebugEvent } from '@/lib/stores/types';
interface DebugPanelProps {
events: DebugEvent[];
onClear?: () => void;
onClose?: () => void;
}
export function DebugPanel({ events, onClear, onClose }: DebugPanelProps) {
const [activeTab, setActiveTab] = useState<'events' | 'messages'>('events');
const [filter, setFilter] = useState<string>('');
const eventsEndRef = useRef<HTMLDivElement>(null);
const [autoScroll, setAutoScroll] = useState(true);
const [streamDebug, setStreamDebug] = useState<boolean>(() => configManager.getDebugStreamEnabled());
const [eventsForce, setEventsForce] = useState<ForceState>(null);
const messageSnapshot = useSyncExternalStore(
(l) => requestSnapshotStore.subscribe(l),
() => requestSnapshotStore.getSnapshot(),
() => requestSnapshotStore.getSnapshot(),
);
// Compress consecutive assistant_delta, tool_param_delta, and reasoning_delta events
// Only store count, not individual events - prevents O(N²) memory growth
const compressedEvents = useMemo(() => {
const result: DebugEvent[] = [];
let currentDeltaGroup: DebugEvent | null = null;
const COMPRESSIBLE_EVENTS = new Set(['assistant_delta', 'tool_param_delta', 'reasoning_delta']);
for (const event of events) {
const shouldCompress = COMPRESSIBLE_EVENTS.has(event.event);
if (shouldCompress) {
// If we're already in a group of the same type, just increment count
if (currentDeltaGroup && currentDeltaGroup.event === event.event) {
currentDeltaGroup.count = (currentDeltaGroup.count || 1) + 1;
// Don't accumulate data.all - that causes O(N²) memory usage
// The count is sufficient for debugging purposes
} else {
// Start a new group
if (currentDeltaGroup) {
result.push(currentDeltaGroup);
}
currentDeltaGroup = { ...event, count: 1 };
}
} else {
// Non-compressible event, flush any current group and add this event
if (currentDeltaGroup) {
result.push(currentDeltaGroup);
currentDeltaGroup = null;
}
result.push(event);
}
}
// Flush any remaining group
if (currentDeltaGroup) {
result.push(currentDeltaGroup);
}
return result;
}, [events]);
// Scroll to bottom when new events arrive
useEffect(() => {
if (autoScroll && eventsEndRef.current) {
eventsEndRef.current.scrollIntoView({ behavior: 'smooth' });
}
}, [compressedEvents, autoScroll]);
// Clear all events
const handleClear = () => {
onClear?.();
};
// Export events as JSON
const handleExport = () => {
const json = JSON.stringify(events, null, 2);
const blob = new Blob([json], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `debug-events-${Date.now()}.json`;
a.click();
URL.revokeObjectURL(url);
};
const handleCopyEvents = async () => {
try {
await navigator.clipboard.writeText(JSON.stringify(events, null, 2));
toast.success('Events copied');
} catch {
toast.error('Copy failed');
}
};
// Filter events
const filteredEvents = filter
? compressedEvents.filter(e => e.event.toLowerCase().includes(filter.toLowerCase()))
: compressedEvents;
// Group events by type (use original events for accurate counts)
const eventCounts = events.reduce((acc, e) => {
acc[e.event] = (acc[e.event] || 0) + 1;
return acc;
}, {} as Record<string, number>);
return (
<PanelContainer>
<PanelHeader
icon={Bug}
title="Debug Events"
onClose={onClose}
panelKey="debug"
>
<MemoryMonitor />
</PanelHeader>
{/* Tabs */}
<div className="flex border-b border-border text-xs">
{([
['events', `Events (${filteredEvents.length}/${events.length})`],
['messages', messageSnapshot ? `Messages (${messageSnapshot.messages.length})` : 'Messages'],
] as const).map(([key, label]) => (
<button
key={key}
onClick={() => setActiveTab(key)}
className={`px-3 py-1.5 border-b-2 -mb-px whitespace-nowrap ${
activeTab === key
? 'border-primary text-foreground font-semibold'
: 'border-transparent text-muted-foreground hover:text-foreground'
}`}
>
{label}
</button>
))}
</div>
{activeTab === 'messages' ? (
<MessagesTab />
) : (
<>
{/* Event Counts */}
<div className="p-2 border-b border-border bg-muted/20 text-xs">
<div className="flex flex-wrap gap-2">
{Object.entries(eventCounts).map(([event, count]) => (
<button
key={event}
onClick={() => setFilter(filter === event ? '' : event)}
className={`px-2 py-1 rounded ${
filter === event
? 'bg-primary text-primary-foreground'
: 'bg-muted hover:bg-muted/80'
}`}
>
{event} ({count})
</button>
))}
</div>
</div>
{/* Filter Input */}
<div className="p-2 border-b border-border">
<input
type="text"
placeholder="Filter events..."
value={filter}
onChange={(e) => setFilter(e.target.value)}
className="w-full px-2 py-1 text-xs rounded bg-background border border-border"
/>
</div>
{/* Toggles + actions */}
<div className="p-2 border-b border-border flex items-center gap-3 flex-wrap">
<label className="text-xs flex items-center gap-1 cursor-pointer">
<input
type="checkbox"
checked={autoScroll}
onChange={(e) => setAutoScroll(e.target.checked)}
className="rounded"
/>
Auto-scroll
</label>
<label
className="text-xs flex items-center gap-1 cursor-pointer"
title="Emit llm_request and stream_raw_chunk events. Ephemeral, not persisted."
>
<input
type="checkbox"
checked={streamDebug}
onChange={(e) => {
setStreamDebug(e.target.checked);
configManager.setDebugStreamEnabled(e.target.checked);
}}
className="rounded"
/>
Stream debug
</label>
<div className="ml-auto flex items-center">
<Button variant="ghost" size="sm" className="h-5 w-5 p-0" title="Expand all"
onClick={() => setEventsForce(v => ({ open: true, v: (v?.v ?? 0) + 1 }))}>
<ChevronsUpDown className="h-3 w-3" />
</Button>
<Button variant="ghost" size="sm" className="h-5 w-5 p-0" title="Collapse all"
onClick={() => setEventsForce(v => ({ open: false, v: (v?.v ?? 0) + 1 }))}>
<ChevronsDownUp className="h-3 w-3" />
</Button>
<Button variant="ghost" size="sm" className="h-5 w-5 p-0" title="Copy events as JSON" onClick={handleCopyEvents}>
<Copy className="h-3 w-3" />
</Button>
<Button variant="ghost" size="sm" className="h-5 w-5 p-0" title="Export to JSON file" onClick={handleExport}>
<Download className="h-3 w-3" />
</Button>
<Button variant="ghost" size="sm" className="h-5 w-5 p-0" title="Clear all events" onClick={handleClear}>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
{/* Events List */}
<div className="flex-1 overflow-y-auto p-2 space-y-1">
{filteredEvents.length === 0 ? (
<div className="text-xs text-muted-foreground text-center p-4">
No events yet. Events will appear here as they occur.
</div>
) : (
filteredEvents.map((event) => (
<EventItem key={event.id} event={event} force={eventsForce} />
))
)}
<div ref={eventsEndRef} />
</div>
</>
)}
</PanelContainer>
);
}
function EventItem({ event, force }: { event: DebugEvent; force?: ForceState }) {
const [isOpen, setIsOpen] = useState(false);
// Expand/collapse-all override — rows remain individually toggleable after
useEffect(() => {
if (force) setIsOpen(force.open);
}, [force]);
const time = new Date(event.timestamp).toLocaleTimeString();
// Color code by event type
const getEventColor = (eventType: string) => {
if (eventType.includes('error') || eventType.includes('failed')) return 'text-red-500';
if (eventType.includes('retry')) return 'text-yellow-500';
if (eventType.includes('completed') || eventType.includes('success')) return 'text-green-500';
if (eventType.includes('tool')) return 'text-blue-500';
if (eventType.includes('agent')) return 'text-purple-500';
if (eventType.includes('plan')) return 'text-orange-500';
return 'text-foreground';
};
return (
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
<CollapsibleTrigger className="w-full text-left">
<div className="flex items-center gap-2 p-1.5 rounded hover:bg-muted/50 text-xs">
{isOpen ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
<span className="text-muted-foreground font-mono">{time}</span>
<span className={`font-semibold ${getEventColor(event.event)}`}>
{event.event}
</span>
{event.count && event.count > 1 && (
<span className="text-muted-foreground font-mono">
({event.count})
</span>
)}
</div>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="ml-6 p-2 bg-muted/30 rounded text-xs font-mono overflow-x-auto">
<pre>{JSON.stringify(event.data, null, 2)}</pre>
</div>
</CollapsibleContent>
</Collapsible>
);
}