207 lines
8.1 KiB
TypeScript
207 lines
8.1 KiB
TypeScript
'use client';
|
|
|
|
import React, { useEffect, useMemo, useState } from 'react';
|
|
import { AlertCircle, Bot, Braces, ChevronDown, Loader2, MessageSquare, RefreshCw, TerminalSquare } from 'lucide-react';
|
|
|
|
import { getChatLogs } from '@/lib/api';
|
|
import { pickAppText } from '@/lib/i18n/core';
|
|
import { useAppI18n } from '@/lib/i18n/provider';
|
|
import type { ChatLogEvent, ChatLogSession } from '@/types';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Card, CardContent } from '@/components/ui/card';
|
|
import { containedJsonTextClass } from '@/lib/text-wrapping';
|
|
|
|
function eventLabel(event: ChatLogEvent): string {
|
|
return event.event_type || event.role || 'event';
|
|
}
|
|
|
|
function eventIcon(event: ChatLogEvent) {
|
|
if (event.event_type === 'llm_request_snapshotted') return Braces;
|
|
if (event.role === 'assistant') return Bot;
|
|
if (event.role === 'tool') return TerminalSquare;
|
|
return MessageSquare;
|
|
}
|
|
|
|
function formatPayload(value: unknown): string {
|
|
if (value === null || value === undefined || value === '') return '';
|
|
if (typeof value === 'string') return value;
|
|
try {
|
|
return JSON.stringify(value, null, 2);
|
|
} catch {
|
|
return String(value);
|
|
}
|
|
}
|
|
|
|
function eventBody(event: ChatLogEvent): string {
|
|
const content = event.content?.trim();
|
|
if (content) return content;
|
|
return formatPayload(event.event_payload);
|
|
}
|
|
|
|
function timestampLabel(value?: string | null): string {
|
|
if (!value) return '';
|
|
const date = new Date(value);
|
|
if (Number.isNaN(date.getTime())) return value;
|
|
return date.toLocaleString();
|
|
}
|
|
|
|
export default function LogsPage() {
|
|
const { locale } = useAppI18n();
|
|
const [sessions, setSessions] = useState<ChatLogSession[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [expandedRuns, setExpandedRuns] = useState<Set<string>>(() => new Set());
|
|
|
|
const runs = useMemo(
|
|
() =>
|
|
sessions.flatMap((session) =>
|
|
session.runs.map((run) => ({
|
|
...run,
|
|
sessionTitle: session.title || session.session_id,
|
|
}))
|
|
),
|
|
[sessions]
|
|
);
|
|
|
|
const loadLogs = async () => {
|
|
setLoading(true);
|
|
setError(null);
|
|
try {
|
|
const data = await getChatLogs(80);
|
|
setSessions(data.sessions || []);
|
|
const firstRun = data.sessions?.[0]?.runs?.[0]?.run_id;
|
|
if (firstRun) {
|
|
setExpandedRuns((current) => (current.size ? current : new Set([firstRun])));
|
|
}
|
|
} catch (err: any) {
|
|
setError(err.message || pickAppText(locale, '读取日志失败', 'Failed to load logs'));
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
loadLogs();
|
|
}, []);
|
|
|
|
const toggleRun = (runId: string) => {
|
|
setExpandedRuns((current) => {
|
|
const next = new Set(current);
|
|
if (next.has(runId)) next.delete(runId);
|
|
else next.add(runId);
|
|
return next;
|
|
});
|
|
};
|
|
|
|
return (
|
|
<div className="mx-auto max-w-7xl p-6 space-y-6">
|
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
|
<div>
|
|
<h1 className="text-2xl font-bold">{pickAppText(locale, '运行日志', 'Runtime Logs')}</h1>
|
|
<p className="mt-1 text-sm text-muted-foreground">
|
|
{pickAppText(
|
|
locale,
|
|
'按每一次用户输入分组,查看 LLM 请求、回复、工具结果和隐藏运行快照。',
|
|
'Grouped by each user input, with LLM requests, responses, tool results, and hidden runtime snapshots.'
|
|
)}
|
|
</p>
|
|
</div>
|
|
<Button onClick={loadLogs} variant="outline" size="sm" disabled={loading}>
|
|
{loading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <RefreshCw className="mr-2 h-4 w-4" />}
|
|
{pickAppText(locale, '刷新', 'Refresh')}
|
|
</Button>
|
|
</div>
|
|
|
|
{error ? (
|
|
<Card className="border-destructive">
|
|
<CardContent className="flex items-start gap-3 pt-6 text-destructive">
|
|
<AlertCircle className="mt-0.5 h-5 w-5" />
|
|
<div>
|
|
<p className="text-sm font-medium">{pickAppText(locale, '无法读取日志', 'Unable to load logs')}</p>
|
|
<p className="mt-1 text-sm text-muted-foreground">{error}</p>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
) : null}
|
|
|
|
{loading && !runs.length ? (
|
|
<div className="flex items-center justify-center py-20">
|
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
|
</div>
|
|
) : null}
|
|
|
|
{!loading && !runs.length && !error ? (
|
|
<Card>
|
|
<CardContent className="py-12 text-center text-sm text-muted-foreground">
|
|
{pickAppText(locale, '还没有可展示的运行日志。', 'No runtime logs are available yet.')}
|
|
</CardContent>
|
|
</Card>
|
|
) : null}
|
|
|
|
<div className="space-y-3">
|
|
{runs.map((run) => {
|
|
const expanded = expandedRuns.has(run.run_id);
|
|
return (
|
|
<Card key={`${run.session_id}:${run.run_id}`} className="overflow-hidden">
|
|
<button
|
|
type="button"
|
|
onClick={() => toggleRun(run.run_id)}
|
|
className="flex w-full items-start justify-between gap-4 border-b px-5 py-4 text-left transition-colors hover:bg-muted/40"
|
|
>
|
|
<span className="min-w-0 space-y-2">
|
|
<span className="flex flex-wrap items-center gap-2">
|
|
<Badge variant={run.task_mode ? 'default' : 'secondary'}>
|
|
{run.task_mode ? 'Task' : 'Chat'}
|
|
</Badge>
|
|
{run.source ? <Badge variant="outline">{run.source}</Badge> : null}
|
|
{run.attempt_index ? <Badge variant="outline">attempt {run.attempt_index}</Badge> : null}
|
|
<span className="text-xs text-muted-foreground">{timestampLabel(run.started_at)}</span>
|
|
</span>
|
|
<span className="block truncate text-sm font-semibold text-foreground">
|
|
{run.user_input || run.title || run.run_id}
|
|
</span>
|
|
<span className="block truncate text-xs text-muted-foreground">
|
|
{run.sessionTitle} · {run.run_id}
|
|
</span>
|
|
</span>
|
|
<ChevronDown className={`mt-1 h-4 w-4 shrink-0 text-muted-foreground transition-transform ${expanded ? 'rotate-180' : ''}`} />
|
|
</button>
|
|
|
|
{expanded ? (
|
|
<CardContent className="space-y-3 p-5">
|
|
{run.events.map((event, index) => {
|
|
const Icon = eventIcon(event);
|
|
const body = eventBody(event);
|
|
return (
|
|
<div
|
|
key={`${event.message_id ?? index}:${event.event_type}`}
|
|
className="min-w-0 max-w-full overflow-hidden rounded-lg border border-border bg-background"
|
|
>
|
|
<div className="flex flex-wrap items-center justify-between gap-2 border-b px-3 py-2">
|
|
<div className="flex min-w-0 items-center gap-2">
|
|
<Icon className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
<span className="truncate text-sm font-medium">{eventLabel(event)}</span>
|
|
<Badge variant={event.context_visible ? 'secondary' : 'outline'}>
|
|
{event.context_visible ? 'visible' : 'hidden'}
|
|
</Badge>
|
|
{event.tool_name ? <Badge variant="outline">{event.tool_name}</Badge> : null}
|
|
</div>
|
|
<span className="text-xs text-muted-foreground">{timestampLabel(event.timestamp)}</span>
|
|
</div>
|
|
<pre className={`max-h-[520px] overflow-auto p-3 text-xs leading-5 text-foreground ${containedJsonTextClass}`}>
|
|
{body || formatPayload(event)}
|
|
</pre>
|
|
</div>
|
|
);
|
|
})}
|
|
</CardContent>
|
|
) : null}
|
|
</Card>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|