diff --git a/web/app/(console)/agent-run-logs/_components/detail.tsx b/web/app/(console)/agent-run-logs/_components/detail.tsx new file mode 100644 index 0000000..345ea45 --- /dev/null +++ b/web/app/(console)/agent-run-logs/_components/detail.tsx @@ -0,0 +1,373 @@ +"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 + } +} diff --git a/web/app/(console)/agent-run-logs/page.tsx b/web/app/(console)/agent-run-logs/page.tsx index 0951fc1..d826971 100644 --- a/web/app/(console)/agent-run-logs/page.tsx +++ b/web/app/(console)/agent-run-logs/page.tsx @@ -1,15 +1,9 @@ "use client" -import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react" -import { - BotMessageSquareIcon, - RefreshCwIcon, - SearchIcon, - WorkflowIcon, -} from "lucide-react" +import { useCallback, useEffect, useMemo, useState } from "react" +import { RefreshCwIcon, SearchIcon } from "lucide-react" import { toast } from "sonner" -import { ImMessageHTML } from "@/components/im-message-html" import { ListPagination } from "@/components/list-pagination" import { OptionCombobox } from "@/components/option-combobox" import { Badge } from "@/components/ui/badge" @@ -21,14 +15,6 @@ import { CardHeader, CardTitle, } from "@/components/ui/card" -import { - Drawer, - DrawerContent, - DrawerDescription, - DrawerFooter, - DrawerHeader, - DrawerTitle, -} from "@/components/ui/drawer" import { Input } from "@/components/ui/input" import { Table, @@ -38,14 +24,14 @@ import { TableHeader, TableRow, } from "@/components/ui/table" +import { AgentRunLogDetailDialog } from "./_components/detail" import { - fetchAgentRunLog, fetchAgentRunGraphSummary, fetchAgentRunLogs, fetchAIAgentsAll, type AgentRunGraphSummary, - type AgentRunLog, type AIAgent, + type AgentRunLog, type PageResult, } from "@/lib/api/admin" import { formatDateTime } from "@/lib/utils" @@ -130,9 +116,8 @@ export default function DashboardAgentRunLogsPage() { const [limit, setLimit] = useState(20) const [loading, setLoading] = useState(true) const [summaryLoading, setSummaryLoading] = useState(true) - const [detailLoading, setDetailLoading] = useState(false) const [detailOpen, setDetailOpen] = useState(false) - const [activeLog, setActiveLog] = useState(null) + const [activeLogId, setActiveLogId] = useState(null) const [result, setResult] = useState>({ results: [], page: { page: 1, limit: 20, total: 0 }, @@ -149,18 +134,6 @@ export default function DashboardAgentRunLogsPage() { handoffCount: 0, }) const [aiAgents, setAiAgents] = useState([]) - const activeTraceData = useMemo( - () => safeParseJSON(activeLog?.traceData ?? ""), - [activeLog?.traceData] - ) - const activeToolSearchTrace = useMemo( - () => safeParseJSON(activeLog?.toolSearchTrace ?? ""), - [activeLog?.toolSearchTrace] - ) - const activeGraphToolTrace = useMemo( - () => safeParseJSON(activeLog?.graphToolTrace ?? ""), - [activeLog?.graphToolTrace] - ) const aiAgentOptions = useMemo( () => [ @@ -245,20 +218,6 @@ export default function DashboardAgentRunLogsPage() { applyFilters() } - async function openDetail(id: number) { - setDetailLoading(true) - setDetailOpen(true) - try { - const data = await fetchAgentRunLog(id) - setActiveLog(data) - } catch (error) { - toast.error(error instanceof Error ? error.message : "加载日志详情失败") - setDetailOpen(false) - } finally { - setDetailLoading(false) - } - } - return ( <>
@@ -472,7 +431,14 @@ export default function DashboardAgentRunLogsPage() { {item.latencyMs} ms - @@ -495,127 +461,16 @@ export default function DashboardAgentRunLogsPage() {
- - { setDetailOpen(open) if (!open) { - setActiveLog(null) + setActiveLogId(null) } }} - > - - - - - Agent 运行详情 - - - 查看 planner 选择、最终动作、回复内容与错误信息。 - - -
- {detailLoading ? ( -
加载中...
- ) : activeLog ? ( - <> -
- - - - -
- - - - - - - - } - title="用户问题" - value={activeLog.userMessage} - renderAsHtml - /> - } - title="机器人回复" - value={activeLog.replyText} - /> - - - - ) : ( -
未找到详情数据
- )} -
- - - -
-
+ /> ) } @@ -646,86 +501,6 @@ function SummaryCard({ ) } -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 UserMessagePreview({ value }: { value?: string }) { const preview = useMemo(() => summarizeUserMessage(value), [value]) @@ -765,95 +540,3 @@ function extractTextFromHTML(value: string) { const doc = new DOMParser().parseFromString(value, "text/html") return doc.body.textContent || "" } - -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 value = attr.value.trim() - if (name.startsWith("on") || !allowedAttrs.has(name)) { - element.removeAttribute(attr.name) - continue - } - if ((name === "href" || name === "src") && !isSafeURL(value)) { - element.removeAttribute(attr.name) - continue - } - } - - 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 - } -}