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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user