import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Button, Segmented, Tooltip } from "antd"; import copyToClipboard from "copy-to-clipboard"; import { CheckCircle2, ChevronDown, CircleAlert, CircleDot, Copy, Trash2, TriangleAlert } from "lucide-react"; import { canvasThemes } from "@/lib/canvas-theme"; import type { AgentEventLog } from "@/stores/use-agent-store"; import { formatLogJson, formatLogText, type AgentLogContext } from "./agent-event-formatters"; import { AgentScrollToBottom } from "./agent-scroll-to-bottom"; type LogFilter = "all" | "error" | "warning" | "info"; type DisplayLog = AgentEventLog & { count: number; detail: string; displayText: string; level: Exclude; signature: string; success: boolean }; const SCROLL_BOTTOM_THRESHOLD = 48; export function AgentLogView({ logs, theme, context, onClear, onCopied, onCopyBlocked, }: { logs: AgentEventLog[]; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; context: AgentLogContext; onClear: () => void; onCopied: (text: string) => void; onCopyBlocked: (text: string) => void; }) { const [mode, setMode] = useState<"text" | "json">("text"); const [filter, setFilter] = useState("all"); const textareaRef = useRef(null); const listRef = useRef(null); const followLogsRef = useRef(true); const [showScrollToBottom, setShowScrollToBottom] = useState(false); const [newLogCount, setNewLogCount] = useState(0); const content = mode === "text" ? formatLogText(logs, context) : formatLogJson(logs, context); const displayLogs = useMemo(() => prepareLogs(logs), [logs]); const counts = useMemo( () => ({ all: displayLogs.reduce((sum, item) => sum + item.count, 0), error: displayLogs.filter((item) => item.level === "error").reduce((sum, item) => sum + item.count, 0), warning: displayLogs.filter((item) => item.level === "warning").reduce((sum, item) => sum + item.count, 0), info: displayLogs.filter((item) => item.level === "info").reduce((sum, item) => sum + item.count, 0), }), [displayLogs], ); const visibleLogs = filter === "all" ? displayLogs : displayLogs.filter((item) => item.level === filter); const visibleLogCount = visibleLogs.reduce((sum, item) => sum + item.count, 0); const previousVisibleCountRef = useRef(visibleLogCount); const lastError = [...logs].reverse().find((item) => logLevel(item) === "error"); const updateScrollState = useCallback(() => { const list = listRef.current; if (!list) return; const atBottom = list.scrollHeight - list.scrollTop - list.clientHeight <= SCROLL_BOTTOM_THRESHOLD; followLogsRef.current = atBottom; setShowScrollToBottom(!atBottom); if (atBottom) setNewLogCount(0); }, []); const scrollToBottom = useCallback((behavior: ScrollBehavior = "smooth") => { const list = listRef.current; if (!list) return; followLogsRef.current = true; list.scrollTo({ top: list.scrollHeight, behavior }); setShowScrollToBottom(false); setNewLogCount(0); }, []); const handleLastLogToggle = useCallback((open: boolean) => { if (!open) { requestAnimationFrame(updateScrollState); return; } if (!followLogsRef.current) return; requestAnimationFrame(() => requestAnimationFrame(() => scrollToBottom("auto"))); }, [scrollToBottom, updateScrollState]); useEffect(() => { if (mode !== "text") return; const frame = requestAnimationFrame(() => scrollToBottom("auto")); return () => cancelAnimationFrame(frame); }, [filter, mode, scrollToBottom]); useEffect(() => { const previousCount = previousVisibleCountRef.current; const addedCount = Math.max(0, visibleLogCount - previousCount); previousVisibleCountRef.current = visibleLogCount; if (mode !== "text") return; if (visibleLogCount < previousCount) setNewLogCount(0); const frame = requestAnimationFrame(() => { if (followLogsRef.current) scrollToBottom("auto"); else { if (addedCount) setNewLogCount((count) => count + addedCount); updateScrollState(); } }); return () => cancelAnimationFrame(frame); }, [mode, scrollToBottom, updateScrollState, visibleLogCount]); const copy = async (value = content, tip = "日志已复制") => { if (await copyToClipboard(value)) { onCopied(tip); return; } textareaRef.current?.focus(); textareaRef.current?.select(); onCopyBlocked(mode === "json" ? "已选中日志,请手动复制" : "复制失败,请切换到原始 JSON 后手动复制"); }; const connectionLabel = context.connected ? "在线" : context.enabled ? "连接中" : "未启用"; return (
运行日志
setMode(value as "text" | "json")} options={[ { label: "排查日志", value: "text" }, { label: "原始 JSON", value: "json" }, ]} />
{connectionLabel} {context.activity}
{context.endpoint}
{context.messages} 条消息
{context.pendingTool ? `工具:${context.pendingTool}` : "无待处理工具"}
{mode === "text" ? ( <>
setFilter(value as LogFilter)} options={[ { label: `全部 ${counts.all}`, value: "all" }, { label: `错误 ${counts.error}`, value: "error" }, { label: `警告 ${counts.warning}`, value: "warning" }, { label: `信息 ${counts.info}`, value: "info" }, ]} /> void copy(value, tip)} context={context} />
{visibleLogs.map((item, index) => ( ))} {!visibleLogs.length ? (
{logs.length ? "当前筛选下没有日志" : "暂无事件日志"}
) : null}
{showScrollToBottom ? ( scrollToBottom()} /> ) : null}
) : ( <>
完整诊断数据 · {logs.length} 条 void copy(value, tip)} context={context} />