diff --git a/web/src/components/agent/agent-log-view.tsx b/web/src/components/agent/agent-log-view.tsx index 10e0825..1e77116 100644 --- a/web/src/components/agent/agent-log-view.tsx +++ b/web/src/components/agent/agent-log-view.tsx @@ -1,11 +1,16 @@ -import { useRef, useState } from "react"; -import { Button, Segmented } from "antd"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Button, Segmented, Tooltip } from "antd"; import copyToClipboard from "copy-to-clipboard"; -import { Copy, Trash2 } from "lucide-react"; +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, @@ -23,9 +28,71 @@ export function AgentLogView({ 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 lastError = [...logs].reverse().find((item) => /错误|失败|error/i.test(`${item.title}\n${item.text}`)); + 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); @@ -33,15 +100,14 @@ export function AgentLogView({ } textareaRef.current?.focus(); textareaRef.current?.select(); - onCopyBlocked("已选中日志,请手动复制"); + onCopyBlocked(mode === "json" ? "已选中日志,请手动复制" : "复制失败,请切换到原始 JSON 后手动复制"); }; + const connectionLabel = context.connected ? "在线" : context.enabled ? "连接中" : "未启用"; return ( -
-
-
+
+
+
运行日志
-
-
-
- - {logs.length} 条 - - - - +
+ +
+
+ +
+
+ {connectionLabel} + + {context.activity} + +
+
+ {context.endpoint} +
+
+
+
{context.messages} 条消息
+
{context.pendingTool ? `工具:${context.pendingTool}` : "无待处理工具"}
+
-