"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 { JsonTreeViewer } from "@/components/json-tree-viewer" import { ProjectDialog } from "@/components/project-dialog" import { Button } from "@/components/ui/button" import { fetchAgentRunLog, type AgentRunLog } from "@/lib/api/admin" import { useI18n } from "@/i18n/provider" import { formatDateTime } from "@/lib/utils" type AgentRunLogDetailDialogProps = { open: boolean logId: number | null onOpenChange: (open: boolean) => void } type TFunction = (key: string, values?: Record) => string export function AgentRunLogDetailDialog({ open, logId, onOpenChange, }: AgentRunLogDetailDialogProps) { const t = useI18n() 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 : t("agentRunLog.loadDetailFailed")) onOpenChange(false) } } finally { if (!cancelled) { setLoading(false) } } } void loadDetail() return () => { cancelled = true } }, [logId, onOpenChange, open, t]) 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 ( {t("agentRunLog.detailTitle")} } description={t("agentRunLog.detailDescription")} size="xl" allowFullscreen defaultFullscreen bodyClassName="min-h-0" footer={ } > {loading ? (
{t("agentRunLog.loading")}
) : activeLog ? ( <> } title={t("agentRunLog.userMessage")} value={activeLog.userMessage} renderAsHtml /> } title={t("agentRunLog.botReply")} value={activeLog.replyText} /> ) : (
{t("agentRunLog.notFound")}
)}
) } function getHitlStatusLabel(status: string | undefined, t: TFunction) { switch (status) { case "pending": return t("agentRunLog.hitlPending") case "confirmed": return t("agentRunLog.hitlConfirmed") case "cancelled": return t("agentRunLog.hitlCancelled") case "expired": return t("agentRunLog.hitlExpired") case "triggered": return t("agentRunLog.hitlTriggered") default: return "" } } function getHitlSummary(status: string | undefined, t: TFunction) { switch (status) { case "pending": return t("agentRunLog.hitlPendingSummary") case "confirmed": return t("agentRunLog.hitlConfirmedSummary") case "cancelled": return t("agentRunLog.hitlCancelledSummary") case "expired": return t("agentRunLog.hitlExpiredSummary") case "triggered": return t("agentRunLog.hitlTriggeredSummary") default: return "" } } function safeParseJSON(value: string) { if (!value.trim()) { return null } try { return JSON.parse(value) } catch { return null } } function MetaStrip({ items, }: { items: Array<{ label: string; value: string }> }) { return (
{items.map((item) => (
{item.label} {item.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 JsonBlock({ title, jsonValue, fallbackValue, }: { title: string jsonValue: unknown fallbackValue?: string }) { const normalizedFallback = fallbackValue?.trim() || "" return (
{title}
{jsonValue ? ( ) : (
{normalizedFallback || "-"}
)}
) } 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 } }