Add conversation history management and API endpoints

- Introduce new database models for conversation sessions, messages, and artifacts to support conversation history tracking.
- Implement API routes for listing conversations and retrieving detailed conversation data, enhancing user interaction with historical records.
- Add a conversation recorder service to persist conversation messages in real-time without disrupting ongoing calls.
- Update the frontend to display conversation history, including filtering and sorting options, improving user experience.
- Enhance the pipeline to integrate conversation history recording seamlessly during interactions.
This commit is contained in:
Xin Wang
2026-07-10 16:25:06 +08:00
parent 5705726cfb
commit 059ad8162e
13 changed files with 1096 additions and 43 deletions

View File

@@ -1,10 +1,442 @@
import { PlaceholderPage } from "./PlaceholderPage";
"use client";
import {
ChevronDown,
ChevronUp,
Eye,
Loader2,
MessageSquareText,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { DataList, type DataListColumn } from "@/components/ui/data-list";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { FilterPills } from "@/components/ui/filter-pills";
import { ListToolbar } from "@/components/ui/list-toolbar";
import { PageHeader } from "@/components/ui/page-header";
import { SearchInput } from "@/components/ui/search-input";
import {
conversationsApi,
type Conversation,
type ConversationDetail,
} from "@/lib/api";
import { cn } from "@/lib/utils";
const PAGE_SIZE = 20;
const channelFilters = ["全部", "WebRTC", "WebSocket"] as const;
type ChannelFilter = (typeof channelFilters)[number];
type SortOrder = "newest" | "oldest";
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 "已结束";
}
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 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);
setDetail(null);
setDetailError("");
setDetailLoading(true);
try {
setDetail(await conversationsApi.get(conversation.id));
} catch (error) {
setDetailError(
error instanceof Error ? error.message : "对话内容加载失败",
);
} finally {
setDetailLoading(false);
}
}, []);
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",
cellClassName: "flex justify-end",
cell: (row) => (
<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>
),
},
],
[openDetail, 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 = Math.min(pageStart + rows.length, total);
return (
<PlaceholderPage
title="历史记录"
description="查看助手的对话历史、运行日志与调用明细。"
/>
<div className="mx-auto flex w-full max-w-[1440px] flex-col gap-8">
<PageHeader
title="历史记录"
description="查看每次语音或文字会话中最终确认的用户转写和助手回复。"
/>
<section className="rounded-2xl border border-hairline bg-card p-6 shadow-sm">
<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={(row) => void openDetail(row)}
empty={{
title:
total === 0 && !search && filter === "全部"
? "暂无对话记录"
: "未找到匹配的对话记录",
description:
total === 0 && !search && filter === "全部"
? "完成一次助手通话后,文本历史会显示在这里。"
: "请调整关键词或筛选条件后再试。",
}}
pagination={{
page: safeCurrentPage,
totalPages,
onPageChange: setCurrentPage,
summary:
total === 0
? "没有数据"
: `显示 ${pageStart + 1}-${pageEnd} / 共 ${total} 次会话`,
}}
/>
</section>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="max-h-[calc(100vh-3rem)] grid-rows-[auto_minmax(0,1fr)_auto] overflow-hidden sm:max-w-4xl">
<DialogHeader>
<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="min-h-0 overflow-y-auto">
{detailLoading && (
<div className="flex min-h-80 items-center justify-center gap-2 text-muted-foreground">
<Loader2 size={16} className="animate-spin" />
</div>
)}
{!detailLoading && detailError && (
<div className="flex min-h-80 items-center justify-center text-destructive">
{detailError}
</div>
)}
{detail && (
<div className="space-y-5">
<section className="rounded-xl border border-hairline bg-surface-strong/20">
<div className="border-b border-hairline px-4 py-3 text-sm font-medium">
</div>
<div className="grid gap-4 p-4 text-sm sm:grid-cols-2 lg:grid-cols-4">
<Metadata label="接入通道" value={channelLabel(detail.channel)} />
<Metadata label="运行模式" value={detail.runtimeMode} />
<Metadata label="对话时长" value={durationLabel(detail)} />
<Metadata label="状态" value={statusLabel(detail.status)} />
</div>
</section>
<section className="rounded-xl border border-hairline bg-surface-strong/20">
<div className="border-b border-hairline px-4 py-3 text-sm font-medium">
</div>
<div className="space-y-5 p-4 sm:p-5">
{detail.messages.map((message) => (
<div
key={message.id}
className={cn(
"flex",
message.role === "user"
? "justify-end"
: "justify-start",
)}
>
<div className="max-w-[85%]">
<div
className={cn(
"mb-1.5 text-xs text-muted-soft",
message.role === "user" && "text-right",
)}
>
{message.role === "user" ? "用户" : "助手"} ·{" "}
{formatDate(message.occurredAt)}
</div>
<div
className={cn(
"whitespace-pre-wrap rounded-2xl px-4 py-3 text-left leading-6",
message.role === "user"
? "rounded-br-md bg-primary text-primary-foreground"
: "rounded-bl-md border border-hairline bg-background text-foreground shadow-sm",
)}
>
{message.content}
</div>
{message.extra.interrupted && (
<div className="mt-1.5 text-xs text-muted-foreground">
</div>
)}
</div>
</div>
))}
{detail.messages.length === 0 && (
<div className="py-12 text-center text-muted-foreground">
</div>
)}
</div>
</section>
</div>
)}
</div>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline"></Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
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>
);
}