Files
ai-video-fullstack/frontend/src/components/pages/HistoryPage.tsx
T
2026-08-07 10:59:47 +08:00

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>
);
}