"use client"; import { Activity, ArrowRight, Camera, ChartLine, CheckCircle2, ChevronDown, ChevronUp, CircleDot, Eye, GitBranch, ImageIcon, Loader2, MessageSquareText, MonitorSmartphone, MoreHorizontal, Pause, Play, Server, Trash2, Wrench, XCircle, type LucideIcon, } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { SectionAnchorTabs } from "@/components/editor/section-anchor-tabs"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { DataList, type DataListColumn } from "@/components/ui/data-list"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { FilterPills } from "@/components/ui/filter-pills"; import { ListToolbar } from "@/components/ui/list-toolbar"; import { ListPageLayout, ListPageSection, } from "@/components/layout/list-page-layout"; import { SearchInput } from "@/components/ui/search-input"; import { API_BASE, conversationsApi, type Conversation, type ConversationDetail, type ConversationMessage, } from "@/lib/api"; import { cn } from "@/lib/utils"; const PAGE_SIZE = 5; const channelFilters = ["全部", "WebRTC", "WebSocket"] as const; type ChannelFilter = (typeof channelFilters)[number]; type SortOrder = "newest" | "oldest"; type DetailTabId = "session" | "analysis"; const detailTabs = [ { id: "session", label: "会话信息" }, { id: "analysis", label: "分析" }, ] as const; function formatDate(value?: string | null): string { if (!value) return "—"; const date = new Date(value); if (Number.isNaN(date.getTime())) return "—"; return date.toLocaleString("zh-CN", { year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit", }); } function durationLabel(row: Conversation): string { if (!row.endedAt) return "进行中"; const seconds = Math.max( 0, Math.round( (new Date(row.endedAt).getTime() - new Date(row.startedAt).getTime()) / 1000, ), ); const minutes = Math.floor(seconds / 60); return minutes ? `${minutes} 分 ${seconds % 60} 秒` : `${seconds} 秒`; } function channelLabel(channel: string): string { if (channel === "webrtc") return "WebRTC"; if (channel === "websocket") return "WebSocket"; return channel; } function channelValue(filter: ChannelFilter): string | undefined { if (filter === "WebRTC") return "webrtc"; if (filter === "WebSocket") return "websocket"; return undefined; } function statusLabel(status: string): string { if (status === "active") return "进行中"; if (status === "failed") return "失败"; return "已结束"; } type TraceEvent = Record; type WorkflowNode = { label: string; type: string }; type TraceTone = "default" | "success" | "error" | "user"; type TracePresentation = { title: string; description: string; icon: LucideIcon; tone: TraceTone; }; function recordValue(value: unknown): Record { return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : {}; } function textValue(value: unknown): string { return typeof value === "string" ? value : ""; } function numberValue(value: unknown): number | null { return typeof value === "number" && Number.isFinite(value) ? value : null; } function workflowNodes(detail: ConversationDetail): Map { const snapshot = recordValue(detail.extra.workflow?.snapshot); const nodes = Array.isArray(snapshot.nodes) ? snapshot.nodes : []; return new Map( nodes.flatMap((rawNode) => { const node = recordValue(rawNode); const id = textValue(node.id); if (!id) return []; const data = recordValue(node.data); const type = textValue(node.type) || "node"; return [ [ id, { label: textValue(data.name) || type, type, }, ] as const, ]; }), ); } function nodeLabel(nodes: Map, nodeId: unknown): string { const id = textValue(nodeId); const node = nodes.get(id); return node ? `「${node.label}」` : id ? `「${id}」` : "当前节点"; } function toolLabel(toolType: unknown): string { if (toolType === "client") return "客户端工具"; if (toolType === "http") return "HTTP 服务端工具"; if (toolType === "mcp") return "MCP 服务端工具"; if (toolType === "system") return "系统工具"; return "工具"; } function tracePresentation( event: TraceEvent, nodes: Map, startedEvent?: TraceEvent, ): TracePresentation { const eventName = textValue(event.event); const node = nodeLabel(nodes, event.nodeId); const outcome = recordValue(event.outcome); const duration = numberValue(outcome.durationMs); const tool = toolLabel(event.toolType ?? startedEvent?.toolType); switch (eventName) { case "node_entered": return { title: `进入节点 ${node}`, description: `节点类型:${textValue(event.nodeType) || nodes.get(textValue(event.nodeId))?.type || "未知"}`, icon: CircleDot, tone: "default", }; case "node_exited": return { title: `离开节点 ${node}`, description: "当前节点处理完成,准备选择下一步。", icon: ArrowRight, tone: "default", }; case "edge_selected": return { title: `节点转移:${nodeLabel(nodes, event.sourceNodeId)} → ${nodeLabel(nodes, event.targetNodeId)}`, description: `路由方式:${textValue(event.edgeMode) || "always"}`, icon: GitBranch, tone: "default", }; case "action_started": return { title: `开始调用${tool}`, description: `${node} · ${textValue(event.toolId) || "未命名工具"}`, icon: tool === "客户端工具" ? MonitorSmartphone : Server, tone: "default", }; case "action_completed": return { title: `${tool}执行成功`, description: `${node}${duration === null ? "" : ` · ${duration} ms`}`, icon: CheckCircle2, tone: "success", }; case "action_failed": case "action_cancelled": return { title: `${tool}${eventName === "action_failed" ? "执行失败" : "已取消"}`, description: textValue(recordValue(outcome.error).message) || node, icon: XCircle, tone: "error", }; case "tool_started": return { title: `开始调用${tool}`, description: `${node} · ${textValue(event.toolName) || textValue(event.functionName) || textValue(event.toolId) || "未命名工具"}`, icon: tool === "客户端工具" ? MonitorSmartphone : Server, tone: "default", }; case "tool_completed": return { title: `${tool}调用成功`, description: `${textValue(event.toolName) || textValue(event.functionName) || node}${numberValue(event.durationMs) === null ? "" : ` · ${numberValue(event.durationMs)} ms`}`, icon: CheckCircle2, tone: "success", }; case "tool_failed": return { title: `${tool}调用失败`, description: textValue(event.error) || textValue(event.toolName) || node, icon: XCircle, tone: "error", }; case "client_tool_started": return { title: event.functionName === "show_message" ? "向客户端显示交互消息" : "向客户端下发工具调用", description: textValue(event.functionName) || "客户端工具", icon: MonitorSmartphone, tone: "default", }; case "client_tool_completed": { const userAction = textValue(event.userAction); return { title: userAction === "confirmed" ? "用户已确认" : "客户端交互已完成", description: userAction ? `用户操作:${userAction}` : textValue(event.functionName) || textValue(event.status) || "执行成功", icon: CheckCircle2, tone: userAction ? "user" : "success", }; } case "client_tool_failed": return { title: "客户端交互失败", description: textValue(event.error) || textValue(event.functionName), icon: XCircle, tone: "error", }; case "message_started": return event.requiresConfirmation ? { title: "等待用户确认", description: `${node}向客户端显示了确认消息。`, icon: MonitorSmartphone, tone: "user", } : { title: "开始播放固定消息", description: node, icon: Activity, tone: "default", }; case "message_completed": { const action = textValue(event.action); return { title: action === "confirmed" ? "用户已确认" : "消息步骤已完成", description: `${node}${action ? ` · 操作:${action}` : ""}`, icon: CheckCircle2, tone: action ? "user" : "success", }; } case "message_interrupted": return { title: "用户输入打断消息", description: node, icon: Activity, tone: "user", }; case "message_failed": return { title: "消息步骤失败", description: textValue(event.error) || node, icon: XCircle, tone: "error", }; case "variables_updated": { const names = Array.isArray(event.variableNames) ? event.variableNames.filter((name): name is string => typeof name === "string") : []; return { title: "会话变量已更新", description: names.length ? names.join("、") : node, icon: Wrench, tone: "default", }; } case "variables_snapshot": return { title: "记录会话变量快照", description: node, icon: Wrench, tone: "default", }; case "workflow_ended": return { title: "工作流已结束", description: `${node} · ${textValue(event.outcome) || "success"}`, icon: CheckCircle2, tone: "success", }; default: return { title: eventName || "运行事件", description: node, icon: Activity, tone: "default", }; } } function artifactUrl(path: string): string { if (/^(https?:|data:|blob:)/.test(path)) return path; return `${API_BASE}${path.startsWith("/") ? path : `/${path}`}`; } export function HistoryPage() { const [rows, setRows] = useState([]); const [total, setTotal] = useState(0); const [currentPage, setCurrentPage] = useState(1); const [search, setSearch] = useState(""); const [filter, setFilter] = useState("全部"); const [sortOrder, setSortOrder] = useState("newest"); const [loading, setLoading] = useState(true); const [loadError, setLoadError] = useState(""); const [dialogOpen, setDialogOpen] = useState(false); const [detail, setDetail] = useState(null); const [detailLoading, setDetailLoading] = useState(false); const [detailError, setDetailError] = useState(""); const [detailTab, setDetailTab] = useState("session"); const [deletingId, setDeletingId] = useState(null); const skipRowClickRef = useRef(false); const clearSkipRowClick = useCallback(() => { window.setTimeout(() => { skipRowClickRef.current = false; }, 200); }, []); const load = useCallback(async () => { setLoading(true); setLoadError(""); try { const result = await conversationsApi.list({ page: currentPage, pageSize: PAGE_SIZE, search: search.trim() || undefined, channel: channelValue(filter), sortOrder, }); setRows(result.items); setTotal(result.total); } catch (error) { setLoadError(error instanceof Error ? error.message : "加载历史记录失败"); } finally { setLoading(false); } }, [currentPage, filter, search, sortOrder]); useEffect(() => { // Initial and query-driven external API synchronization. // eslint-disable-next-line react-hooks/set-state-in-effect void load(); }, [load]); const openDetail = useCallback(async (conversation: Conversation) => { setDialogOpen(true); setDetailTab("session"); setDetail(null); setDetailError(""); setDetailLoading(true); try { setDetail(await conversationsApi.get(conversation.id)); } catch (error) { setDetailError( error instanceof Error ? error.message : "对话内容加载失败", ); } finally { setDetailLoading(false); } }, []); useEffect(() => { if ( !dialogOpen || !detail || !["pending", "processing"].includes(detail.analysis.status) ) { return; } let stopped = false; let loadingLatest = false; const timer = window.setInterval(() => { if (loadingLatest) return; loadingLatest = true; void conversationsApi .get(detail.id) .then((latest) => { if (!stopped) setDetail(latest); }) .catch(() => undefined) .finally(() => { loadingLatest = false; }); }, 2000); return () => { stopped = true; window.clearInterval(timer); }; }, [detail, dialogOpen]); const remove = useCallback( async (conversation: Conversation) => { const label = conversation.assistantName || "调试会话"; skipRowClickRef.current = true; const confirmed = window.confirm(`确认删除对话“${label}”?`); if (!confirmed) { clearSkipRowClick(); return; } setDeletingId(conversation.id); try { await conversationsApi.remove(conversation.id); if (detail?.id === conversation.id) { setDialogOpen(false); setDetail(null); } if (rows.length === 1 && currentPage > 1) { setCurrentPage(currentPage - 1); } else { await load(); } } catch (error) { setLoadError(error instanceof Error ? error.message : "删除失败"); } finally { setDeletingId(null); clearSkipRowClick(); } }, [clearSkipRowClick, currentPage, detail, load, rows.length], ); const handleRowClick = useCallback( (conversation: Conversation) => { if (skipRowClickRef.current) return; void openDetail(conversation); }, [openDetail], ); function changeSearch(value: string) { setSearch(value); setCurrentPage(1); } function changeFilter(value: ChannelFilter) { setFilter(value); setCurrentPage(1); } function toggleSortOrder() { setSortOrder((value) => (value === "newest" ? "oldest" : "newest")); setCurrentPage(1); } const columns = useMemo[]>( () => [ { key: "assistant", header: "助手名称", width: "md:w-[360px]", cell: (row) => ( <>
{row.assistantName || "调试会话"}
{row.id}
), }, { key: "channel", header: "接入通道", width: "md:w-[150px]", cell: (row) => ( {channelLabel(row.channel)} ), }, { key: "status", header: "状态", width: "md:w-[120px]", cell: (row) => ( {statusLabel(row.status)} ), }, { key: "messages", header: "消息数", width: "md:w-[100px]", cellClassName: "tabular-nums text-muted-foreground", cell: (row) => `${row.messageCount} 条`, }, { key: "duration", header: "对话时长", width: "md:w-[130px]", cellClassName: "tabular-nums text-muted-foreground", cell: durationLabel, }, { key: "startedAt", width: "md:w-[190px]", header: ( ), cellClassName: "whitespace-nowrap tabular-nums text-muted-foreground", cell: (row) => formatDate(row.startedAt), }, { key: "actions", header: "操作", align: "right", cell: (row) => (
event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()} > { event.preventDefault(); window.setTimeout(() => void remove(row), 0); }} > {deletingId === row.id ? ( ) : ( )} 删除
), }, ], [deletingId, openDetail, remove, sortOrder], ); const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); const safeCurrentPage = Math.min(currentPage, totalPages); const pageStart = (safeCurrentPage - 1) * PAGE_SIZE; const pageEnd = pageStart + rows.length; return ( <> } search={ } /> row.id} loading={loading} error={loadError || null} onRetry={() => void load()} onRowClick={handleRowClick} empty={{ title: total === 0 && !search && filter === "全部" ? "暂无对话记录" : "未找到匹配的对话记录", description: total === 0 && !search && filter === "全部" ? "完成一次助手通话后,文本历史会显示在这里。" : "请调整关键词或筛选条件后再试。", }} pagination={{ page: safeCurrentPage, totalPages, onPageChange: setCurrentPage, summary: total === 0 ? "没有数据" : `显示 ${pageStart + 1}-${Math.min(pageEnd, total)} / 共 ${total} 次会话`, }} /> {detail?.assistantName || "对话详情"} {detail ? `${formatDate(detail.startedAt)} · ${channelLabel(detail.channel)} · ${detail.messageCount} 条消息` : "查看本次会话的完整对话与运行记录。"}
对话记录转写
{detail && (
{detail.messages.length} {detail.messages.some((message) => (message.artifacts ?? []).some( (item) => item.kind === "image", ), ) && ( 含图片 )}
)}
{detailLoading && (
正在加载对话内容
)} {!detailLoading && detailError && (
{detailError}
)} {detail && }
); } function Metadata({ label, value }: { label: string; value: string }) { return (
{label}
{value}
); } function SessionInfoPanel({ detail }: { detail: ConversationDetail }) { const nodes = workflowNodes(detail); const trace = (detail.extra.workflowTrace ?? []).map(recordValue); const toolCount = trace.filter( (event) => event.event === "action_started" || event.event === "tool_started", ).length; const transitionCount = trace.filter( (event) => event.event === "edge_selected", ).length; const imageCount = detail.messages.reduce( (count, message) => count + (message.artifacts ?? []).filter((item) => item.kind === "image").length, 0, ); return (
统计
{detail.messages.length} 条对话 {imageCount > 0 && ( {imageCount} 张照片 )} {toolCount > 0 && ( {toolCount} 次工具 )} {transitionCount > 0 && ( {transitionCount} 次转移 )} {nodes.size > 0 && ( 工作流 {nodes.size} 节点 )}
); } function analysisValue(value: unknown): string { if (value === null || value === undefined || value === "") return "未提取到"; if (typeof value === "boolean") return value ? "是" : "否"; if (typeof value === "object") return JSON.stringify(value); return String(value); } function AnalysisPanel({ analysis, }: { analysis: ConversationDetail["analysis"]; }) { if (analysis.status === "pending" || analysis.status === "processing") { return (
正在分析通话内容
); } if (analysis.status === "failed") { return (
分析失败

{analysis.error || "分析服务没有返回有效结果。"}

); } if (analysis.status === "completed") { return (
关键信息
{analysis.completedAt && (
完成于 {formatDate(analysis.completedAt)}
)}
{analysis.fields.map((field) => (
{field.name}
{field.type}
{analysisValue(field.value)}
))}
); } return (
暂无分析结果

该会话未开启通话后分析,或没有可分析的对话内容。

); } function TranscriptPanel({ detail }: { detail: ConversationDetail }) { const nodes = workflowNodes(detail); const trace = (detail.extra.workflowTrace ?? []).map(recordValue); const startedByInvocation = new Map(); trace.forEach((event) => { if (event.event === "action_started" || event.event === "tool_started") { const invocationId = textValue(event.invocationId); if (invocationId) startedByInvocation.set(invocationId, event); } }); const entries: TimelineEntry[] = [ ...detail.messages.map((message) => ({ kind: "message" as const, timestamp: message.occurredAt, order: message.sequence, message, })), ...trace.map((event, index) => ({ kind: "trace" as const, timestamp: textValue(event.timestamp), order: numberValue(event.sequence) ?? index + 1, event, })), ].sort( (a, b) => timestampOrder(a.timestamp) - timestampOrder(b.timestamp) || a.order - b.order, ); if (entries.length === 0) { return (
本次会话没有产生可展示的记录
); } return (
{entries.map((entry, index) => entry.kind === "message" ? ( ) : ( ), )}
); } function formatPlaybackClock(seconds: number): string { const total = Math.max(0, Math.floor(seconds)); const m = Math.floor(total / 60); const s = total % 60; return `${m}:${String(s).padStart(2, "0")}`; } /** 底部录音回放区(Mock:会话录音尚未接入后端时可演示进度)。 */ function HistoryPlaybackBar({ detail, }: { detail: ConversationDetail | null; }) { const durationSec = useMemo(() => { if (!detail?.endedAt) return 0; const ms = new Date(detail.endedAt).getTime() - new Date(detail.startedAt).getTime(); return Number.isFinite(ms) ? Math.max(0, Math.round(ms / 1000)) : 0; }, [detail]); const [playing, setPlaying] = useState(false); const [elapsed, setElapsed] = useState(0); const hasRecording = false; useEffect(() => { if (!playing || durationSec <= 0) return; const id = window.setInterval(() => { setElapsed((current) => { if (current + 0.2 >= durationSec) { window.clearInterval(id); queueMicrotask(() => setPlaying(false)); return durationSec; } return current + 0.2; }); }, 200); return () => window.clearInterval(id); }, [playing, durationSec]); const progress = durationSec > 0 ? Math.min(1, elapsed / durationSec) : 0; return (
{formatPlaybackClock(elapsed)} {hasRecording ? "会话录音" : "录音回放(Mock,尚未接入录音文件)"} {formatPlaybackClock(durationSec)}
); } type TimelineEntry = | { kind: "message"; timestamp: string; order: number; message: ConversationMessage; } | { kind: "trace"; timestamp: string; order: number; event: TraceEvent; }; function timestampOrder(value: string): number { const parsed = Date.parse(value); return Number.isNaN(parsed) ? Number.MAX_SAFE_INTEGER : parsed; } function TimelineMessage({ message }: { message: ConversationMessage }) { const isUser = message.role === "user"; const images = (message.artifacts ?? []).filter((item) => item.kind === "image"); const isImageMessage = message.contentType === "image" || images.length > 0; return (
{isImageMessage && } {isUser ? "用户" : "助手"} · {formatDate(message.occurredAt)}
{images.map((image) => { const url = artifactUrl(image.contentUrl); return ( {/* eslint-disable-next-line @next/next/no-img-element */} 用户在通话中拍摄的照片 ); })} {isImageMessage && images.length === 0 && (
图片附件不可用
)} {message.content && (
{message.content}
)}
{(message.extra.source || message.extra.node_id) && (
{[message.extra.source, message.extra.node_id].filter(Boolean).join(" · ")}
)} {message.extra.interrupted && (
回复在生成中被打断
)}
); } function TimelineTrace({ event, nodes, startedEvent, }: { event: TraceEvent; nodes: Map; startedEvent?: TraceEvent; }) { const presentation = tracePresentation(event, nodes, startedEvent); const Icon = presentation.icon; const iconClass = { default: "bg-surface-strong text-muted-foreground", success: "bg-success/10 text-success", error: "bg-destructive/10 text-destructive", user: "bg-primary/10 text-primary", }[presentation.tone]; return (
{presentation.title}
{presentation.description}
详细数据
            {JSON.stringify(event, null, 2)}
          
); }