"use client" import { useEffect, useMemo, useState, type ReactNode } from "react" import { BotMessageSquareIcon, WorkflowIcon } from "lucide-react" import { toast } from "sonner" import { ImMessageHTML } from "@/components/im-message-html" import { ProjectDialog } from "@/components/project-dialog" import { Button } from "@/components/ui/button" import { fetchAgentRunLog, type AgentRunLog } from "@/lib/api/admin" import { formatDateTime } from "@/lib/utils" type AgentRunLogDetailDialogProps = { open: boolean logId: number | null onOpenChange: (open: boolean) => void } export function AgentRunLogDetailDialog({ open, logId, onOpenChange, }: AgentRunLogDetailDialogProps) { const [loading, setLoading] = useState(false) const [activeLog, setActiveLog] = useState(null) useEffect(() => { if (!open || !logId) { return } let cancelled = false const currentLogId = logId async function loadDetail() { setLoading(true) try { const data = await fetchAgentRunLog(currentLogId) if (!cancelled) { setActiveLog(data) } } catch (error) { if (!cancelled) { toast.error(error instanceof Error ? error.message : "加载日志详情失败") onOpenChange(false) } } finally { if (!cancelled) { setLoading(false) } } } void loadDetail() return () => { cancelled = true } }, [logId, onOpenChange, open]) useEffect(() => { if (open) { return } setLoading(false) setActiveLog(null) }, [open]) const activeTraceData = useMemo( () => safeParseJSON(activeLog?.traceData ?? ""), [activeLog?.traceData] ) const activeToolSearchTrace = useMemo( () => safeParseJSON(activeLog?.toolSearchTrace ?? ""), [activeLog?.toolSearchTrace] ) const activeGraphToolTrace = useMemo( () => safeParseJSON(activeLog?.graphToolTrace ?? ""), [activeLog?.graphToolTrace] ) return ( Agent 运行详情 } description="查看 planner 选择、最终动作、回复内容与错误信息。" size="xl" allowFullscreen defaultFullscreen bodyClassName="min-h-0" footer={ } > {loading ? (
加载中...
) : activeLog ? ( <>
} title="用户问题" value={activeLog.userMessage} renderAsHtml /> } title="机器人回复" value={activeLog.replyText} /> ) : (
未找到详情数据
)}
) } function safeParseJSON(value: string) { if (!value.trim()) { return null } try { return JSON.parse(value) } catch { return null } } function MetricCard({ label, value }: { label: string; value: string }) { return (
{label}
{value}
) } function InfoBlock({ title, lines }: { title: string; lines: string[] }) { return (
{title}
{lines.map((line) => (
{line}
))}
) } function TextBlock({ title, value, icon, tone = "default", renderAsHtml = false, }: { title: string value?: string icon?: ReactNode tone?: "default" | "danger" renderAsHtml?: boolean }) { const normalizedValue = value?.trim() || "" const html = useMemo(() => { if (!renderAsHtml || !normalizedValue) { return "" } return sanitizeRichHTML(normalizedValue) }, [normalizedValue, renderAsHtml]) return (
{icon} {title}
{renderAsHtml && normalizedValue ? ( ) : (
{normalizedValue || "-"}
)}
) } function sanitizeRichHTML(value: string) { if (typeof window === "undefined") { return value } const doc = new DOMParser().parseFromString(value, "text/html") const allowedTags = new Set([ "a", "b", "blockquote", "br", "code", "div", "em", "h1", "h2", "h3", "h4", "h5", "h6", "hr", "img", "li", "ol", "p", "pre", "span", "strong", "table", "tbody", "td", "th", "thead", "tr", "u", "ul", ]) const allowedAttrs = new Set([ "alt", "class", "colspan", "href", "rel", "rowspan", "src", "target", "title", ]) const walker = doc.createTreeWalker(doc.body, NodeFilter.SHOW_ELEMENT) const elements: Element[] = [] while (walker.nextNode()) { elements.push(walker.currentNode as Element) } for (const element of elements) { const tag = element.tagName.toLowerCase() if (!allowedTags.has(tag)) { element.replaceWith(...Array.from(element.childNodes)) continue } for (const attr of Array.from(element.attributes)) { const name = attr.name.toLowerCase() const attrValue = attr.value.trim() if (name.startsWith("on") || !allowedAttrs.has(name)) { element.removeAttribute(attr.name) continue } if ((name === "href" || name === "src") && !isSafeURL(attrValue)) { element.removeAttribute(attr.name) } } if (tag === "a") { element.setAttribute("target", "_blank") element.setAttribute("rel", "noreferrer noopener") } } return doc.body.innerHTML } function isSafeURL(value: string) { if (!value) { return false } if (value.startsWith("/")) { return true } if (value.startsWith("data:image/")) { return true } try { const url = new URL(value, window.location.origin) return ["http:", "https:"].includes(url.protocol) } catch { return false } }