1278 lines
41 KiB
TypeScript
1278 lines
41 KiB
TypeScript
"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<string, unknown>;
|
||
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<string, unknown> {
|
||
return value && typeof value === "object" && !Array.isArray(value)
|
||
? (value as Record<string, unknown>)
|
||
: {};
|
||
}
|
||
|
||
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<string, WorkflowNode> {
|
||
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<string, WorkflowNode>, 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<string, WorkflowNode>,
|
||
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<Conversation[]>([]);
|
||
const [total, setTotal] = useState(0);
|
||
const [currentPage, setCurrentPage] = useState(1);
|
||
const [search, setSearch] = useState("");
|
||
const [filter, setFilter] = useState<ChannelFilter>("全部");
|
||
const [sortOrder, setSortOrder] = useState<SortOrder>("newest");
|
||
const [loading, setLoading] = useState(true);
|
||
const [loadError, setLoadError] = useState("");
|
||
|
||
const [dialogOpen, setDialogOpen] = useState(false);
|
||
const [detail, setDetail] = useState<ConversationDetail | null>(null);
|
||
const [detailLoading, setDetailLoading] = useState(false);
|
||
const [detailError, setDetailError] = useState("");
|
||
const [detailTab, setDetailTab] = useState<DetailTabId>("session");
|
||
const [deletingId, setDeletingId] = useState<string | null>(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<DataListColumn<Conversation>[]>(
|
||
() => [
|
||
{
|
||
key: "assistant",
|
||
header: "助手名称",
|
||
width: "md:w-[360px]",
|
||
cell: (row) => (
|
||
<>
|
||
<div className="truncate font-medium text-foreground">
|
||
{row.assistantName || "调试会话"}
|
||
</div>
|
||
<div className="mt-1 truncate font-mono text-xs text-muted-soft">
|
||
{row.id}
|
||
</div>
|
||
</>
|
||
),
|
||
},
|
||
{
|
||
key: "channel",
|
||
header: "接入通道",
|
||
width: "md:w-[150px]",
|
||
cell: (row) => (
|
||
<Badge
|
||
variant="secondary"
|
||
className="h-6 bg-surface-strong px-3 text-muted-foreground"
|
||
>
|
||
{channelLabel(row.channel)}
|
||
</Badge>
|
||
),
|
||
},
|
||
{
|
||
key: "status",
|
||
header: "状态",
|
||
width: "md:w-[120px]",
|
||
cell: (row) => (
|
||
<Badge
|
||
variant={row.status === "failed" ? "destructive" : "outline"}
|
||
className="h-6 px-3"
|
||
>
|
||
{statusLabel(row.status)}
|
||
</Badge>
|
||
),
|
||
},
|
||
{
|
||
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: (
|
||
<button
|
||
type="button"
|
||
onClick={(event) => {
|
||
event.stopPropagation();
|
||
toggleSortOrder();
|
||
}}
|
||
className="caption-label -mx-2 inline-flex items-center gap-1 rounded-md px-2 py-1 text-muted-soft transition-colors hover:bg-surface-strong hover:text-foreground"
|
||
aria-label={
|
||
sortOrder === "newest"
|
||
? "当前按最近开始排序,点击切换为最早开始"
|
||
: "当前按最早开始排序,点击切换为最近开始"
|
||
}
|
||
>
|
||
开始时间
|
||
{sortOrder === "newest" ? (
|
||
<ChevronDown size={13} />
|
||
) : (
|
||
<ChevronUp size={13} />
|
||
)}
|
||
</button>
|
||
),
|
||
cellClassName:
|
||
"whitespace-nowrap tabular-nums text-muted-foreground",
|
||
cell: (row) => formatDate(row.startedAt),
|
||
},
|
||
{
|
||
key: "actions",
|
||
header: "操作",
|
||
align: "right",
|
||
cell: (row) => (
|
||
<div
|
||
className="flex justify-end gap-2"
|
||
onClick={(event) => event.stopPropagation()}
|
||
onPointerDown={(event) => event.stopPropagation()}
|
||
>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
className="gap-1.5 border-hairline-strong text-xs text-muted-foreground hover:text-foreground"
|
||
onClick={(event) => {
|
||
event.stopPropagation();
|
||
void openDetail(row);
|
||
}}
|
||
>
|
||
<Eye size={14} />
|
||
查看
|
||
</Button>
|
||
<DropdownMenu>
|
||
<DropdownMenuTrigger asChild>
|
||
<Button
|
||
variant="outline"
|
||
size="icon-sm"
|
||
className="border-hairline-strong text-muted-foreground hover:text-foreground"
|
||
aria-label={`${row.assistantName || row.id} 更多操作`}
|
||
onClick={(event) => event.stopPropagation()}
|
||
>
|
||
<MoreHorizontal size={15} />
|
||
</Button>
|
||
</DropdownMenuTrigger>
|
||
<DropdownMenuContent
|
||
align="end"
|
||
className="w-32 min-w-32 rounded-xl border border-hairline bg-popover p-1"
|
||
>
|
||
<DropdownMenuItem
|
||
variant="destructive"
|
||
className="rounded-lg"
|
||
disabled={deletingId === row.id}
|
||
onSelect={(event) => {
|
||
event.preventDefault();
|
||
window.setTimeout(() => void remove(row), 0);
|
||
}}
|
||
>
|
||
{deletingId === row.id ? (
|
||
<Loader2 size={14} className="animate-spin" />
|
||
) : (
|
||
<Trash2 size={14} />
|
||
)}
|
||
删除
|
||
</DropdownMenuItem>
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
</div>
|
||
),
|
||
},
|
||
],
|
||
[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 (
|
||
<>
|
||
<ListPageLayout
|
||
title="历史记录"
|
||
description="按会话查看对话、照片、工具调用、用户操作与工作流运行轨迹。"
|
||
>
|
||
<ListPageSection>
|
||
<ListToolbar
|
||
filters={
|
||
<FilterPills
|
||
options={channelFilters}
|
||
value={filter}
|
||
onChange={changeFilter}
|
||
/>
|
||
}
|
||
search={
|
||
<SearchInput
|
||
value={search}
|
||
onChange={changeSearch}
|
||
placeholder="搜索助手名称或会话 ID"
|
||
className="lg:w-[320px]"
|
||
/>
|
||
}
|
||
/>
|
||
|
||
<DataList
|
||
columns={columns}
|
||
rows={rows}
|
||
rowKey={(row) => 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} 次会话`,
|
||
}}
|
||
/>
|
||
</ListPageSection>
|
||
</ListPageLayout>
|
||
|
||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||
<DialogContent className="flex h-[min(94vh,980px)] w-full max-w-[calc(100%-1.5rem)] flex-col gap-0 overflow-hidden p-0 sm:max-w-7xl">
|
||
<DialogHeader className="shrink-0 gap-1.5 px-5 py-4 sm:px-6">
|
||
<DialogTitle className="flex items-center gap-2">
|
||
<MessageSquareText size={18} />
|
||
{detail?.assistantName || "对话详情"}
|
||
</DialogTitle>
|
||
<DialogDescription>
|
||
{detail
|
||
? `${formatDate(detail.startedAt)} · ${channelLabel(detail.channel)} · ${detail.messageCount} 条消息`
|
||
: "查看本次会话的完整对话与运行记录。"}
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
|
||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden border-t border-hairline sm:flex-row">
|
||
<aside className="flex min-h-0 w-full shrink-0 flex-col border-b border-hairline sm:w-1/2 sm:border-b-0 sm:border-r">
|
||
<SectionAnchorTabs
|
||
ariaLabel="会话详情分区"
|
||
sections={detailTabs}
|
||
activeSectionId={detailTab}
|
||
onSelect={(sectionId) =>
|
||
setDetailTab(sectionId as DetailTabId)
|
||
}
|
||
className="bg-popover px-3 pt-2 sm:px-4"
|
||
/>
|
||
<div className="scrollbar-subtle min-h-0 flex-1 overflow-y-auto p-4 sm:p-5">
|
||
{detailLoading && (
|
||
<div className="flex min-h-48 items-center justify-center gap-2 text-muted-foreground">
|
||
<Loader2 size={16} className="animate-spin" />
|
||
正在加载
|
||
</div>
|
||
)}
|
||
{!detailLoading && detailError && (
|
||
<div className="flex min-h-48 items-center justify-center text-sm text-destructive">
|
||
{detailError}
|
||
</div>
|
||
)}
|
||
{detail && detailTab === "session" && (
|
||
<SessionInfoPanel detail={detail} />
|
||
)}
|
||
{detail && detailTab === "analysis" && (
|
||
<AnalysisPanel analysis={detail.analysis} />
|
||
)}
|
||
</div>
|
||
</aside>
|
||
|
||
<main className="flex min-h-0 min-w-0 w-full flex-1 flex-col sm:w-1/2">
|
||
<div className="flex shrink-0 items-center justify-between gap-3 border-b border-hairline px-4 py-2.5 sm:px-5">
|
||
<div className="text-sm font-medium text-foreground">
|
||
对话记录转写
|
||
</div>
|
||
{detail && (
|
||
<div className="flex flex-wrap gap-1.5">
|
||
<Badge
|
||
variant="secondary"
|
||
className="gap-1 bg-surface-strong text-muted-foreground"
|
||
>
|
||
<MessageSquareText size={11} />
|
||
{detail.messages.length}
|
||
</Badge>
|
||
{detail.messages.some((message) =>
|
||
(message.artifacts ?? []).some(
|
||
(item) => item.kind === "image",
|
||
),
|
||
) && (
|
||
<Badge
|
||
variant="secondary"
|
||
className="gap-1 bg-surface-strong text-muted-foreground"
|
||
>
|
||
<ImageIcon size={11} />
|
||
含图片
|
||
</Badge>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="scrollbar-subtle min-h-0 flex-1 overflow-y-auto p-4 sm:p-5">
|
||
{detailLoading && (
|
||
<div className="flex min-h-48 items-center justify-center gap-2 text-muted-foreground">
|
||
<Loader2 size={16} className="animate-spin" />
|
||
正在加载对话内容
|
||
</div>
|
||
)}
|
||
{!detailLoading && detailError && (
|
||
<div className="flex min-h-48 items-center justify-center text-destructive">
|
||
{detailError}
|
||
</div>
|
||
)}
|
||
{detail && <TranscriptPanel detail={detail} />}
|
||
</div>
|
||
</main>
|
||
</div>
|
||
|
||
<HistoryPlaybackBar key={detail?.id ?? "empty"} detail={detail} />
|
||
</DialogContent>
|
||
</Dialog>
|
||
</>
|
||
);
|
||
}
|
||
|
||
function Metadata({ label, value }: { label: string; value: string }) {
|
||
return (
|
||
<div className="min-w-0">
|
||
<div className="text-xs text-muted-soft">{label}</div>
|
||
<div className="mt-1 truncate font-medium text-foreground">{value}</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<div className="space-y-4">
|
||
<div className="grid gap-3 sm:grid-cols-2">
|
||
<Metadata label="接入通道" value={channelLabel(detail.channel)} />
|
||
<Metadata label="运行模式" value={detail.runtimeMode} />
|
||
<Metadata label="对话时长" value={durationLabel(detail)} />
|
||
<Metadata label="状态" value={statusLabel(detail.status)} />
|
||
<Metadata label="开始时间" value={formatDate(detail.startedAt)} />
|
||
<Metadata label="结束时间" value={formatDate(detail.endedAt)} />
|
||
<Metadata label="会话 ID" value={detail.id} />
|
||
<Metadata
|
||
label="助手 ID"
|
||
value={detail.assistantId || "调试会话"}
|
||
/>
|
||
</div>
|
||
|
||
<div className="border-t border-hairline pt-4">
|
||
<div className="caption-label mb-2 text-muted-soft">统计</div>
|
||
<div className="flex flex-wrap gap-2">
|
||
<Badge
|
||
variant="secondary"
|
||
className="gap-1.5 bg-surface-strong text-muted-foreground"
|
||
>
|
||
<MessageSquareText size={12} />
|
||
{detail.messages.length} 条对话
|
||
</Badge>
|
||
{imageCount > 0 && (
|
||
<Badge
|
||
variant="secondary"
|
||
className="gap-1.5 bg-surface-strong text-muted-foreground"
|
||
>
|
||
<ImageIcon size={12} />
|
||
{imageCount} 张照片
|
||
</Badge>
|
||
)}
|
||
{toolCount > 0 && (
|
||
<Badge
|
||
variant="secondary"
|
||
className="gap-1.5 bg-surface-strong text-muted-foreground"
|
||
>
|
||
<Wrench size={12} />
|
||
{toolCount} 次工具
|
||
</Badge>
|
||
)}
|
||
{transitionCount > 0 && (
|
||
<Badge
|
||
variant="secondary"
|
||
className="gap-1.5 bg-surface-strong text-muted-foreground"
|
||
>
|
||
<GitBranch size={12} />
|
||
{transitionCount} 次转移
|
||
</Badge>
|
||
)}
|
||
{nodes.size > 0 && (
|
||
<Badge
|
||
variant="secondary"
|
||
className="gap-1.5 bg-surface-strong text-muted-foreground"
|
||
>
|
||
工作流 {nodes.size} 节点
|
||
</Badge>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<div className="flex min-h-48 items-center justify-center gap-2 text-sm text-muted-foreground">
|
||
<Loader2 size={16} className="animate-spin" />
|
||
正在分析通话内容
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (analysis.status === "failed") {
|
||
return (
|
||
<div className="rounded-2xl border border-destructive/25 bg-destructive/5 px-4 py-4">
|
||
<div className="text-sm font-medium text-destructive">分析失败</div>
|
||
<p className="mt-1.5 break-words text-xs leading-5 text-muted-foreground">
|
||
{analysis.error || "分析服务没有返回有效结果。"}
|
||
</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (analysis.status === "completed") {
|
||
return (
|
||
<div className="space-y-4">
|
||
<div>
|
||
<div className="caption-label text-muted-soft">关键信息</div>
|
||
{analysis.completedAt && (
|
||
<div className="mt-1 text-xs text-muted-foreground">
|
||
完成于 {formatDate(analysis.completedAt)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="divide-y divide-hairline overflow-hidden rounded-2xl border border-hairline bg-background">
|
||
{analysis.fields.map((field) => (
|
||
<div
|
||
key={field.name}
|
||
className="grid grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)] gap-4 px-4 py-3"
|
||
>
|
||
<div className="min-w-0">
|
||
<div className="break-all text-sm font-medium text-foreground">
|
||
{field.name}
|
||
</div>
|
||
<div className="mt-0.5 text-[11px] text-muted-soft">
|
||
{field.type}
|
||
</div>
|
||
</div>
|
||
<div
|
||
className={cn(
|
||
"break-words text-sm text-foreground",
|
||
(field.value === null || field.value === undefined) &&
|
||
"text-muted-soft",
|
||
)}
|
||
>
|
||
{analysisValue(field.value)}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="flex min-h-48 flex-col items-center justify-center rounded-2xl border border-dashed border-hairline-strong bg-canvas-soft px-5 py-10 text-center">
|
||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-surface-strong text-foreground">
|
||
<ChartLine size={18} />
|
||
</div>
|
||
<div className="mt-4 text-sm font-medium text-foreground">
|
||
暂无分析结果
|
||
</div>
|
||
<p className="mt-1.5 max-w-xs text-xs leading-5 text-muted-foreground">
|
||
该会话未开启通话后分析,或没有可分析的对话内容。
|
||
</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function TranscriptPanel({ detail }: { detail: ConversationDetail }) {
|
||
const nodes = workflowNodes(detail);
|
||
const trace = (detail.extra.workflowTrace ?? []).map(recordValue);
|
||
const startedByInvocation = new Map<string, TraceEvent>();
|
||
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 (
|
||
<div className="flex min-h-48 items-center justify-center text-sm text-muted-foreground">
|
||
本次会话没有产生可展示的记录
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
{entries.map((entry, index) =>
|
||
entry.kind === "message" ? (
|
||
<TimelineMessage
|
||
key={`message-${entry.message.id}`}
|
||
message={entry.message}
|
||
/>
|
||
) : (
|
||
<TimelineTrace
|
||
key={`trace-${textValue(entry.event.eventId) || index}`}
|
||
event={entry.event}
|
||
nodes={nodes}
|
||
startedEvent={startedByInvocation.get(
|
||
textValue(recordValue(entry.event.outcome).invocationId) ||
|
||
textValue(entry.event.invocationId),
|
||
)}
|
||
/>
|
||
),
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<div className="shrink-0 border-t border-hairline bg-canvas-soft/40">
|
||
<div className="flex items-center gap-3 px-4 py-2 sm:px-5">
|
||
<Button
|
||
type="button"
|
||
size="icon-sm"
|
||
variant="outline"
|
||
className="shrink-0 border-hairline-strong"
|
||
disabled={!detail || durationSec <= 0}
|
||
aria-label={playing ? "暂停" : "播放"}
|
||
onClick={() => {
|
||
setPlaying((value) => !value);
|
||
}}
|
||
>
|
||
{playing ? <Pause size={14} /> : <Play size={14} />}
|
||
</Button>
|
||
|
||
<div className="min-w-0 flex-1">
|
||
<div className="flex items-center justify-between gap-3 text-[11px] tabular-nums text-muted-soft">
|
||
<span>{formatPlaybackClock(elapsed)}</span>
|
||
<span className="truncate text-muted-foreground">
|
||
{hasRecording ? "会话录音" : "录音回放(Mock,尚未接入录音文件)"}
|
||
</span>
|
||
<span>{formatPlaybackClock(durationSec)}</span>
|
||
</div>
|
||
<div className="mt-1 h-1 overflow-hidden rounded-full bg-surface-strong">
|
||
<div
|
||
className="h-full rounded-full bg-primary transition-[width] duration-150"
|
||
style={{ width: `${progress * 100}%` }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<div className={cn("flex", isUser ? "justify-end" : "justify-start")}>
|
||
<div className="max-w-[88%] sm:max-w-[78%]">
|
||
<div
|
||
className={cn(
|
||
"mb-1.5 flex items-center gap-1.5 text-xs text-muted-soft",
|
||
isUser && "justify-end",
|
||
)}
|
||
>
|
||
{isImageMessage && <Camera size={12} />}
|
||
{isUser ? "用户" : "助手"} · {formatDate(message.occurredAt)}
|
||
</div>
|
||
<div
|
||
className={cn(
|
||
"overflow-hidden rounded-2xl text-left leading-6",
|
||
isUser
|
||
? "rounded-br-md bg-primary text-primary-foreground"
|
||
: "rounded-bl-md border border-hairline bg-background text-foreground shadow-sm",
|
||
isImageMessage ? "p-1" : "px-4 py-3",
|
||
)}
|
||
>
|
||
{images.map((image) => {
|
||
const url = artifactUrl(image.contentUrl);
|
||
return (
|
||
<a
|
||
key={image.id}
|
||
href={url}
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
className="block overflow-hidden rounded-[0.8rem] bg-black/10"
|
||
title="在新窗口查看原图"
|
||
>
|
||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||
<img
|
||
src={url}
|
||
alt="用户在通话中拍摄的照片"
|
||
className="max-h-[28rem] w-full object-contain"
|
||
/>
|
||
</a>
|
||
);
|
||
})}
|
||
{isImageMessage && images.length === 0 && (
|
||
<div className="flex min-h-40 min-w-56 flex-col items-center justify-center gap-2 rounded-[0.8rem] bg-background/10 px-6 text-center text-sm opacity-75">
|
||
<ImageIcon size={22} />
|
||
图片附件不可用
|
||
</div>
|
||
)}
|
||
{message.content && (
|
||
<div className={isImageMessage ? "px-3 py-2" : ""}>{message.content}</div>
|
||
)}
|
||
</div>
|
||
{(message.extra.source || message.extra.node_id) && (
|
||
<div className={cn("mt-1.5 text-[11px] text-muted-soft", isUser && "text-right")}>
|
||
{[message.extra.source, message.extra.node_id].filter(Boolean).join(" · ")}
|
||
</div>
|
||
)}
|
||
{message.extra.interrupted && (
|
||
<div className="mt-1.5 text-xs text-muted-foreground">
|
||
回复在生成中被打断
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function TimelineTrace({
|
||
event,
|
||
nodes,
|
||
startedEvent,
|
||
}: {
|
||
event: TraceEvent;
|
||
nodes: Map<string, WorkflowNode>;
|
||
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 (
|
||
<div className="flex gap-3 rounded-xl border border-hairline-soft bg-background/65 p-3">
|
||
<div className={cn("flex size-8 shrink-0 items-center justify-center rounded-full", iconClass)}>
|
||
<Icon size={15} />
|
||
</div>
|
||
<div className="min-w-0 flex-1">
|
||
<div className="flex flex-wrap items-start justify-between gap-x-4 gap-y-1">
|
||
<div>
|
||
<div className="text-sm font-medium text-foreground">{presentation.title}</div>
|
||
<div className="mt-0.5 text-xs leading-5 text-muted-foreground">
|
||
{presentation.description}
|
||
</div>
|
||
</div>
|
||
<time className="shrink-0 text-[11px] tabular-nums text-muted-soft">
|
||
{formatDate(textValue(event.timestamp))}
|
||
</time>
|
||
</div>
|
||
<details className="mt-2 text-xs text-muted-foreground">
|
||
<summary className="w-fit cursor-pointer select-none hover:text-foreground">
|
||
详细数据
|
||
</summary>
|
||
<pre className="mt-2 max-h-64 overflow-auto rounded-lg bg-surface-strong p-3 font-mono text-[11px] leading-5 text-foreground">
|
||
{JSON.stringify(event, null, 2)}
|
||
</pre>
|
||
</details>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|