调整目录

This commit is contained in:
mlogclub
2026-04-24 12:01:30 +08:00
parent 7889ecb0d2
commit a9275c2d4a
209 changed files with 0 additions and 0 deletions
@@ -0,0 +1,402 @@
"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 { 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 ? (
<>
<MetaStrip
items={[
{ label: "日志ID", value: String(activeLog.id) },
{ label: "会话ID", value: String(activeLog.conversationId || "-") },
{ label: "消息ID", value: String(activeLog.messageId || "-") },
{ label: "AI Agent", value: String(activeLog.aiAgentId || "-") },
]}
/>
<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)}`,
]}
/>
<JsonBlock
title="动态工具选择"
jsonValue={activeToolSearchTrace}
fallbackValue={activeLog.toolSearchTrace}
/>
<JsonBlock
title="Graph Tool 调用"
jsonValue={activeGraphToolTrace}
fallbackValue={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" />
<JsonBlock
title="链路 Trace"
jsonValue={activeTraceData}
fallbackValue={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 MetaStrip({
items,
}: {
items: Array<{ label: string; value: string }>
}) {
return (
<div className="rounded-lg border bg-muted/20 px-4 py-3">
<div className="flex flex-wrap gap-x-6 gap-y-2 text-sm">
{items.map((item) => (
<div key={item.label} className="flex min-w-0 items-center gap-2">
<span className="shrink-0 text-xs text-muted-foreground">{item.label}</span>
<span className="min-w-0 truncate font-medium">{item.value}</span>
</div>
))}
</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 JsonBlock({
title,
jsonValue,
fallbackValue,
}: {
title: string
jsonValue: unknown
fallbackValue?: string
}) {
const normalizedFallback = fallbackValue?.trim() || ""
return (
<div className="rounded-lg border p-4">
<div className="text-sm font-medium">{title}</div>
{jsonValue ? (
<JsonTreeViewer value={jsonValue} className="mt-3" />
) : (
<div className="mt-3 select-text whitespace-pre-wrap wrap-break-word text-sm text-muted-foreground">
{normalizedFallback || "-"}
</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
}
}
+422
View File
@@ -0,0 +1,422 @@
"use client"
import { useCallback, useEffect, useMemo, useState } from "react"
import { RefreshCwIcon, SearchIcon } from "lucide-react"
import { toast } from "sonner"
import { ListPagination } from "@/components/list-pagination"
import { OptionCombobox } from "@/components/option-combobox"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { AgentRunLogDetailDialog } from "./_components/detail"
import {
fetchAgentRunLogs,
fetchAIAgentsAll,
type AIAgent,
type AgentRunLog,
type PageResult,
} from "@/lib/api/admin"
import { formatDateTime } from "@/lib/utils"
const actionOptions = [
{ value: "all", label: "全部动作" },
{ value: "rag", label: "RAG" },
{ value: "skill", label: "Skill" },
{ value: "tool", label: "Tool" },
{ value: "graph", label: "Graph" },
{ value: "handoff", label: "转人工" },
{ value: "reply", label: "回复" },
{ value: "fallback", label: "兜底" },
]
const finalStatusOptions = [
{ value: "all", label: "全部状态" },
{ value: "completed", label: "completed" },
{ value: "interrupted", label: "interrupted" },
{ value: "expired", label: "expired" },
{ value: "error", label: "error" },
{ value: "fallback", label: "fallback" },
]
const hitlStatusOptions = [
{ value: "all", label: "全部 HITL" },
{ value: "pending", label: "等待确认" },
{ value: "confirmed", label: "已确认" },
{ value: "cancelled", label: "已取消" },
{ value: "expired", label: "已过期" },
{ value: "triggered", label: "已触发" },
]
function actionBadgeVariant(action: string) {
switch (action) {
case "handoff":
return "destructive" as const
case "skill":
return "default" as const
case "tool":
return "default" as const
case "graph":
return "default" as const
case "rag":
return "secondary" as const
case "fallback":
return "outline" as const
default:
return "secondary" as const
}
}
function hitlBadgeVariant(status: string) {
switch (status) {
case "pending":
return "secondary" as const
case "confirmed":
return "default" as const
case "cancelled":
return "outline" as const
case "expired":
return "destructive" as const
default:
return "secondary" as const
}
}
export default function DashboardAgentRunLogsPage() {
const [keywordInput, setKeywordInput] = useState("")
const [plannedActionInput, setPlannedActionInput] = useState("all")
const [finalActionInput, setFinalActionInput] = useState("all")
const [finalStatusInput, setFinalStatusInput] = useState("all")
const [hitlStatusInput, setHitlStatusInput] = useState("all")
const [aiAgentIdInput, setAiAgentIdInput] = useState("all")
const [keyword, setKeyword] = useState("")
const [plannedAction, setPlannedAction] = useState("all")
const [finalAction, setFinalAction] = useState("all")
const [finalStatus, setFinalStatus] = useState("all")
const [hitlStatus, setHitlStatus] = useState("all")
const [aiAgentId, setAiAgentId] = useState("all")
const [page, setPage] = useState(1)
const [limit, setLimit] = useState(20)
const [loading, setLoading] = useState(true)
const [detailOpen, setDetailOpen] = useState(false)
const [activeLogId, setActiveLogId] = useState<number | null>(null)
const [result, setResult] = useState<PageResult<AgentRunLog>>({
results: [],
page: { page: 1, limit: 20, total: 0 },
})
const [aiAgents, setAiAgents] = useState<AIAgent[]>([])
const aiAgentOptions = useMemo(
() => [
{ value: "all", label: "全部 Agent" },
...aiAgents.map((item) => ({
value: String(item.id),
label: item.name,
})),
],
[aiAgents]
)
const loadData = useCallback(async () => {
setLoading(true)
try {
const data = await fetchAgentRunLogs({
userMessage: keyword.trim() || undefined,
plannedAction: plannedAction === "all" ? undefined : plannedAction,
finalAction: finalAction === "all" ? undefined : finalAction,
finalStatus: finalStatus === "all" ? undefined : finalStatus,
hitlStatus: hitlStatus === "all" ? undefined : hitlStatus,
aiAgentId: aiAgentId === "all" ? undefined : aiAgentId,
page,
limit,
})
setResult(data)
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载 Agent 运行日志失败")
} finally {
setLoading(false)
}
}, [aiAgentId, finalAction, finalStatus, hitlStatus, keyword, limit, page, plannedAction])
useEffect(() => {
void loadData()
}, [loadData])
useEffect(() => {
async function loadAIAgents() {
try {
const data = await fetchAIAgentsAll()
setAiAgents(data)
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载 AI Agent 列表失败")
}
}
void loadAIAgents()
}, [])
function applyFilters() {
setKeyword(keywordInput)
setPlannedAction(plannedActionInput)
setFinalAction(finalActionInput)
setFinalStatus(finalStatusInput)
setHitlStatus(hitlStatusInput)
setAiAgentId(aiAgentIdInput)
setPage(1)
}
function handleFilterKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {
if (event.key !== "Enter") {
return
}
event.preventDefault()
applyFilters()
}
return (
<>
<div className="flex flex-1 flex-col gap-6 p-4 lg:p-6">
<div className="grid grid-cols-1 gap-2 md:grid-cols-2 xl:grid-cols-[minmax(0,1.6fr)_repeat(5,minmax(0,0.7fr))_auto_auto] xl:items-center">
<div className="relative min-w-0">
<SearchIcon className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={keywordInput}
onChange={(event) => setKeywordInput(event.target.value)}
onKeyDown={handleFilterKeyDown}
placeholder="按用户问题筛选"
className="pl-9"
/>
</div>
<div className="min-w-0">
<OptionCombobox
value={plannedActionInput}
options={actionOptions}
placeholder="规划动作"
searchPlaceholder="搜索动作"
emptyText="未找到动作"
onChange={(value) => setPlannedActionInput(value || "all")}
/>
</div>
<div className="min-w-0">
<OptionCombobox
value={finalActionInput}
options={actionOptions}
placeholder="最终动作"
searchPlaceholder="搜索动作"
emptyText="未找到动作"
onChange={(value) => setFinalActionInput(value || "all")}
/>
</div>
<div className="min-w-0">
<OptionCombobox
value={finalStatusInput}
options={finalStatusOptions}
placeholder="最终状态"
searchPlaceholder="搜索状态"
emptyText="未找到状态"
onChange={(value) => setFinalStatusInput(value || "all")}
/>
</div>
<div className="min-w-0">
<OptionCombobox
value={hitlStatusInput}
options={hitlStatusOptions}
placeholder="HITL 状态"
searchPlaceholder="搜索 HITL 状态"
emptyText="未找到状态"
onChange={(value) => setHitlStatusInput(value || "all")}
/>
</div>
<div className="min-w-0">
<OptionCombobox
value={aiAgentIdInput}
options={aiAgentOptions}
placeholder="选择 Agent"
searchPlaceholder="搜索 Agent"
emptyText="未找到 Agent"
onChange={(value) => setAiAgentIdInput(value || "all")}
/>
</div>
<Button variant="outline" onClick={applyFilters} disabled={loading} className="w-full xl:w-auto">
<SearchIcon />
</Button>
<Button
variant="outline"
onClick={() => void loadData()}
disabled={loading}
className="w-full xl:w-auto"
>
<RefreshCwIcon className={loading ? "animate-spin" : ""} />
</Button>
</div>
<div className="overflow-hidden rounded-lg border bg-background">
{!loading && result.results.length === 0 ? (
<div className="py-14 text-center text-sm text-muted-foreground">
Agent
</div>
) : (
<div>
<div className="hidden grid-cols-[160px_minmax(0,1.8fr)_110px_minmax(0,1.2fr)_130px_90px_76px] gap-3 border-b bg-muted/40 px-4 py-3 text-sm text-muted-foreground lg:grid">
<div></div>
<div></div>
<div></div>
<div>Skill / Tool</div>
<div></div>
<div className="text-right"></div>
<div className="text-right"></div>
</div>
<div className="divide-y">
{result.results.map((item) => (
<article
key={item.id}
className="grid grid-cols-1 gap-2 px-4 py-3 lg:grid-cols-[160px_minmax(0,1.8fr)_110px_minmax(0,1.2fr)_130px_90px_76px] lg:items-center lg:gap-3"
>
<div className="min-w-0 text-sm text-muted-foreground">
{formatDateTime(item.createdAt)}
</div>
<div className="min-w-0">
<UserMessagePreview value={item.userMessage} />
{item.errorMessage ? (
<div className="truncate text-xs text-destructive">{item.errorMessage}</div>
) : null}
</div>
<div className="min-w-0">
<Badge variant={actionBadgeVariant(item.plannedAction)}>
{item.plannedAction || "-"}
</Badge>
</div>
<div className="min-w-0 text-sm">
{item.plannedSkillCode || item.graphToolCode || item.plannedToolCode ? (
<div className="min-w-0 space-y-1">
<div className="truncate font-medium">
{item.plannedSkillCode || item.graphToolCode || item.plannedToolCode}
</div>
{item.plannedSkillName ? (
<div className="truncate text-xs text-muted-foreground">
{item.plannedSkillName}
</div>
) : item.handoffReason ? (
<div className="truncate text-xs text-muted-foreground">
{item.handoffReason}
</div>
) : item.recommendedAction ? (
<div className="truncate text-xs text-muted-foreground">
{item.recommendedAction}
{item.riskLevel ? ` / ${item.riskLevel} risk` : ""}
{item.ticketDraftReady ? " / 草稿已就绪" : ""}
</div>
) : null}
</div>
) : (
<span className="text-muted-foreground">-</span>
)}
</div>
<div className="min-w-0">
<div className="space-y-1">
<Badge variant={actionBadgeVariant(item.finalAction)}>
{item.finalAction || "-"}
</Badge>
<div className="truncate text-xs text-muted-foreground">
{item.hitlStatusName
? `${item.hitlStatusName} / ${item.finalStatus || "-"}`
: item.finalStatus || "-"}
</div>
</div>
</div>
<div className="text-sm text-muted-foreground lg:text-right">
{item.latencyMs} ms
</div>
<div className="lg:text-right">
<Button
variant="outline"
size="sm"
onClick={() => {
setActiveLogId(item.id)
setDetailOpen(true)
}}
>
</Button>
</div>
</article>
))}
</div>
</div>
)}
<div className="border-t px-4 py-3">
<ListPagination
page={result.page.page}
total={result.page.total}
limit={limit}
loading={loading}
onPageChange={setPage}
onLimitChange={(nextLimit) => {
setLimit(nextLimit)
setPage(1)
}}
/>
</div>
</div>
</div>
<AgentRunLogDetailDialog
open={detailOpen}
logId={activeLogId}
onOpenChange={(open) => {
setDetailOpen(open)
if (!open) {
setActiveLogId(null)
}
}}
/>
</>
)
}
function UserMessagePreview({ value }: { value?: string }) {
const preview = useMemo(() => summarizeUserMessage(value), [value])
return (
<div className="truncate text-sm text-foreground">
{preview}
</div>
)
}
function summarizeUserMessage(value?: string) {
const normalized = value?.trim()
if (!normalized) {
return "-"
}
const text = extractTextFromHTML(normalized).replace(/\s+/g, " ").trim()
if (text) {
return text
}
if (containsHTML(normalized)) {
if (/<img[\s>]/i.test(normalized)) {
return "[图片]"
}
return "[富文本消息]"
}
return normalized
}
function containsHTML(value: string) {
return /<[^>]+>/.test(value)
}
function extractTextFromHTML(value: string) {
if (typeof window === "undefined") {
return value
}
const doc = new DOMParser().parseFromString(value, "text/html")
return doc.body.textContent || ""
}