feat: implement agent run log detail dialog and refactor log detail handling

This commit is contained in:
mlogclub
2026-04-14 23:30:58 +08:00
parent 7ab8d4fac6
commit 1ccdd182b4
2 changed files with 390 additions and 334 deletions
@@ -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<AgentRunLog | null>(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 (
<ProjectDialog
open={open}
onOpenChange={onOpenChange}
title={
<span className="flex items-center gap-2">
<WorkflowIcon className="size-4" />
Agent
</span>
}
description="查看 planner 选择、最终动作、回复内容与错误信息。"
size="xl"
allowFullscreen
defaultFullscreen
bodyClassName="min-h-0"
footer={
<Button variant="outline" onClick={() => onOpenChange(false)}>
</Button>
}
>
{loading ? (
<div className="py-10 text-sm text-muted-foreground">...</div>
) : activeLog ? (
<>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<MetricCard label="日志ID" value={String(activeLog.id)} />
<MetricCard label="会话ID" value={String(activeLog.conversationId || "-")} />
<MetricCard label="消息ID" value={String(activeLog.messageId || "-")} />
<MetricCard label="AI Agent" value={String(activeLog.aiAgentId || "-")} />
</div>
<InfoBlock
title="规划阶段"
lines={[
`plannedAction: ${activeLog.plannedAction || "-"}`,
`plannedSkillCode: ${activeLog.plannedSkillCode || "-"}`,
`plannedSkillName: ${activeLog.plannedSkillName || "-"}`,
`graphToolCode: ${activeLog.graphToolCode || "-"}`,
`recommendedAction: ${activeLog.recommendedAction || "-"}`,
`riskLevel: ${activeLog.riskLevel || "-"}`,
`ticketDraftReady: ${activeLog.ticketDraftReady ? "true" : "false"}`,
`plannedToolCode: ${activeLog.plannedToolCode || "-"}`,
`planReason: ${activeLog.planReason || "-"}`,
`handoffReason: ${activeLog.handoffReason || "-"}`,
`skillRouteTrace: ${activeLog.skillRouteTrace || "-"}`,
]}
/>
<InfoBlock
title="HITL 状态"
lines={[
`hitlStatus: ${activeLog.hitlStatus || "-"}`,
`hitlStatusName: ${activeLog.hitlStatusName || "-"}`,
`hitlSummary: ${activeLog.hitlSummary || "-"}`,
]}
/>
<InfoBlock
title="执行结果"
lines={[
`finalAction: ${activeLog.finalAction || "-"}`,
`finalStatus: ${activeLog.finalStatus || "-"}`,
`interruptType: ${activeLog.interruptType || "-"}`,
`resumeSource: ${activeLog.resumeSource || "-"}`,
`latencyMs: ${activeLog.latencyMs} ms`,
`createdAt: ${formatDateTime(activeLog.createdAt)}`,
]}
/>
<TextBlock
title="动态工具选择"
value={
activeToolSearchTrace
? JSON.stringify(activeToolSearchTrace, null, 2)
: activeLog.toolSearchTrace
}
/>
<TextBlock
title="Graph Tool 调用"
value={
activeGraphToolTrace
? JSON.stringify(activeGraphToolTrace, null, 2)
: activeLog.graphToolTrace
}
/>
<TextBlock
icon={<BotMessageSquareIcon className="size-4" />}
title="用户问题"
value={activeLog.userMessage}
renderAsHtml
/>
<TextBlock
icon={<WorkflowIcon className="size-4" />}
title="机器人回复"
value={activeLog.replyText}
/>
<TextBlock title="错误信息" value={activeLog.errorMessage} tone="danger" />
<TextBlock
title="链路 Trace"
value={
activeTraceData
? JSON.stringify(activeTraceData, null, 2)
: activeLog.traceData
}
/>
</>
) : (
<div className="py-10 text-sm text-muted-foreground"></div>
)}
</ProjectDialog>
)
}
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 (
<div className="rounded-lg border bg-muted/20 p-4">
<div className="text-xs text-muted-foreground">{label}</div>
<div className="mt-2 text-lg font-semibold">{value}</div>
</div>
)
}
function InfoBlock({ title, lines }: { title: string; lines: string[] }) {
return (
<div className="rounded-lg border p-4">
<div className="text-sm font-medium">{title}</div>
<div className="mt-3 space-y-2 text-sm text-muted-foreground">
{lines.map((line) => (
<div key={line}>{line}</div>
))}
</div>
</div>
)
}
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 (
<div className="rounded-lg border p-4">
<div className="flex items-center gap-2 text-sm font-medium">
{icon}
{title}
</div>
{renderAsHtml && normalizedValue ? (
<ImMessageHTML
html={html}
className="mt-3 select-text text-muted-foreground"
/>
) : (
<div
className={
tone === "danger"
? "mt-3 select-text whitespace-pre-wrap wrap-break-word text-sm text-destructive"
: "mt-3 select-text whitespace-pre-wrap wrap-break-word text-sm text-muted-foreground"
}
>
{normalizedValue || "-"}
</div>
)}
</div>
)
}
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
}
}
+17 -334
View File
@@ -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<AgentRunLog | null>(null)
const [activeLogId, setActiveLogId] = useState<number | null>(null)
const [result, setResult] = useState<PageResult<AgentRunLog>>({
results: [],
page: { page: 1, limit: 20, total: 0 },
@@ -149,18 +134,6 @@ export default function DashboardAgentRunLogsPage() {
handoffCount: 0,
})
const [aiAgents, setAiAgents] = useState<AIAgent[]>([])
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 (
<>
<div className="flex flex-1 flex-col gap-6 p-4 lg:p-6">
@@ -472,7 +431,14 @@ export default function DashboardAgentRunLogsPage() {
{item.latencyMs} ms
</TableCell>
<TableCell className="text-right">
<Button variant="outline" size="sm" onClick={() => void openDetail(item.id)}>
<Button
variant="outline"
size="sm"
onClick={() => {
setActiveLogId(item.id)
setDetailOpen(true)
}}
>
</Button>
</TableCell>
@@ -495,127 +461,16 @@ export default function DashboardAgentRunLogsPage() {
</div>
</div>
</div>
<Drawer
<AgentRunLogDetailDialog
open={detailOpen}
direction="right"
logId={activeLogId}
onOpenChange={(open) => {
setDetailOpen(open)
if (!open) {
setActiveLog(null)
setActiveLogId(null)
}
}}
>
<DrawerContent className="min-w-180">
<DrawerHeader>
<DrawerTitle className="flex items-center gap-2">
<WorkflowIcon className="size-4" />
Agent
</DrawerTitle>
<DrawerDescription>
planner
</DrawerDescription>
</DrawerHeader>
<div className="space-y-6 px-6 pb-6 overflow-auto">
{detailLoading ? (
<div className="py-10 text-sm text-muted-foreground">...</div>
) : activeLog ? (
<>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<MetricCard label="日志ID" value={String(activeLog.id)} />
<MetricCard label="会话ID" value={String(activeLog.conversationId || "-")} />
<MetricCard label="消息ID" value={String(activeLog.messageId || "-")} />
<MetricCard label="AI Agent" value={String(activeLog.aiAgentId || "-")} />
</div>
<InfoBlock
title="规划阶段"
lines={[
`plannedAction: ${activeLog.plannedAction || "-"}`,
`plannedSkillCode: ${activeLog.plannedSkillCode || "-"}`,
`plannedSkillName: ${activeLog.plannedSkillName || "-"}`,
`graphToolCode: ${activeLog.graphToolCode || "-"}`,
`recommendedAction: ${activeLog.recommendedAction || "-"}`,
`riskLevel: ${activeLog.riskLevel || "-"}`,
`ticketDraftReady: ${activeLog.ticketDraftReady ? "true" : "false"}`,
`plannedToolCode: ${activeLog.plannedToolCode || "-"}`,
`planReason: ${activeLog.planReason || "-"}`,
`handoffReason: ${activeLog.handoffReason || "-"}`,
`skillRouteTrace: ${activeLog.skillRouteTrace || "-"}`,
]}
/>
<InfoBlock
title="HITL 状态"
lines={[
`hitlStatus: ${activeLog.hitlStatus || "-"}`,
`hitlStatusName: ${activeLog.hitlStatusName || "-"}`,
`hitlSummary: ${activeLog.hitlSummary || "-"}`,
]}
/>
<InfoBlock
title="执行结果"
lines={[
`finalAction: ${activeLog.finalAction || "-"}`,
`finalStatus: ${activeLog.finalStatus || "-"}`,
`interruptType: ${activeLog.interruptType || "-"}`,
`resumeSource: ${activeLog.resumeSource || "-"}`,
`latencyMs: ${activeLog.latencyMs} ms`,
`createdAt: ${formatDateTime(activeLog.createdAt)}`,
]}
/>
<TextBlock
title="动态工具选择"
value={
activeToolSearchTrace
? JSON.stringify(activeToolSearchTrace, null, 2)
: activeLog.toolSearchTrace
}
/>
<TextBlock
title="Graph Tool 调用"
value={
activeGraphToolTrace
? JSON.stringify(activeGraphToolTrace, null, 2)
: activeLog.graphToolTrace
}
/>
<TextBlock
icon={<BotMessageSquareIcon className="size-4" />}
title="用户问题"
value={activeLog.userMessage}
renderAsHtml
/>
<TextBlock
icon={<WorkflowIcon className="size-4" />}
title="机器人回复"
value={activeLog.replyText}
/>
<TextBlock
title="错误信息"
value={activeLog.errorMessage}
tone="danger"
/>
<TextBlock
title="链路 Trace"
value={
activeTraceData
? JSON.stringify(activeTraceData, null, 2)
: activeLog.traceData
}
/>
</>
) : (
<div className="py-10 text-sm text-muted-foreground"></div>
)}
</div>
<DrawerFooter>
<Button variant="outline" onClick={() => setDetailOpen(false)}>
</Button>
</DrawerFooter>
</DrawerContent>
</Drawer>
/>
</>
)
}
@@ -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 (
<div className="rounded-lg border bg-muted/20 p-4">
<div className="text-xs text-muted-foreground">{label}</div>
<div className="mt-2 text-lg font-semibold">{value}</div>
</div>
)
}
function InfoBlock({ title, lines }: { title: string; lines: string[] }) {
return (
<div className="rounded-lg border p-4">
<div className="text-sm font-medium">{title}</div>
<div className="mt-3 space-y-2 text-sm text-muted-foreground">
{lines.map((line) => (
<div key={line}>{line}</div>
))}
</div>
</div>
)
}
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 (
<div className="rounded-lg border p-4">
<div className="flex items-center gap-2 text-sm font-medium">
{icon}
{title}
</div>
{renderAsHtml && normalizedValue ? (
<ImMessageHTML
html={html}
className="mt-3 select-text text-muted-foreground"
/>
) : (
<div
className={
tone === "danger"
? "mt-3 select-text whitespace-pre-wrap wrap-break-word text-sm text-destructive"
: "mt-3 select-text whitespace-pre-wrap wrap-break-word text-sm text-muted-foreground"
}
>
{normalizedValue || "-"}
</div>
)}
</div>
)
}
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
}
}