Files
beaver_project/app-instance/frontend/app/(app)/logs/page.tsx

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>
);
}