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

1278 lines
41 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}