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

View File

@@ -230,6 +230,7 @@ export function useVoicePreview(
const dataChannelRef = useRef<RTCDataChannel | null>(null);
const localStreamRef = useRef<MediaStream | null>(null);
const startingRef = useRef(false);
const negotiationInProgressRef = useRef(false);
const messageSeqRef = useRef(0);
const networkStatsRef = useRef<NetworkStatsSample | null>(null);
const videoBitrateRef = useRef(INITIAL_VIDEO_BITRATE);
@@ -341,6 +342,7 @@ export function useVoicePreview(
localStreamRef.current = null;
if (audioRef.current) audioRef.current.srcObject = null;
startingRef.current = false;
negotiationInProgressRef.current = false;
onNodeActiveRef.current?.(null);
}, []);
@@ -436,10 +438,18 @@ export function useVoicePreview(
try {
const msg = JSON.parse(event.data);
if (msg.type === "answer") {
await pcRef.current?.setRemoteDescription({
type: "answer",
sdp: msg.payload.sdp,
});
const currentPc = pcRef.current;
if (!currentPc) return;
try {
await currentPc.setRemoteDescription({
type: "answer",
sdp: msg.payload.sdp,
});
} finally {
if (pcRef.current === currentPc) {
negotiationInProgressRef.current = false;
}
}
} else if (msg.type === "ice-candidate" && msg.payload?.candidate) {
// 后端当前不主动 trickle,留兼容
try {
@@ -478,6 +488,42 @@ export function useVoicePreview(
const pc = new RTCPeerConnection({ iceServers });
pcRef.current = pc;
const sendOffer = async () => {
if (
pcRef.current !== pc ||
ws.readyState !== WebSocket.OPEN ||
pc.signalingState !== "stable" ||
negotiationInProgressRef.current
) {
return;
}
negotiationInProgressRef.current = true;
try {
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
const localDescription = pc.localDescription;
if (!localDescription?.sdp) {
throw new Error("浏览器无法创建 WebRTC offer。");
}
ws.send(
JSON.stringify({
type: "offer",
payload: {
pc_id: pcId,
sdp: localDescription.sdp,
type: localDescription.type,
assistant_id: assistantId,
vision_enabled: Boolean(options.visionEnabled),
},
}),
);
} catch (offerError) {
negotiationInProgressRef.current = false;
throw offerError;
}
};
pc.onicecandidate = (e) => {
if (ws.readyState !== WebSocket.OPEN) return;
ws.send(
@@ -503,10 +549,15 @@ export function useVoicePreview(
channel.onopen = () => {
channel.send(JSON.stringify({ type: "client-ready" }));
};
channel.onmessage = (event) => {
channel.onmessage = async (event) => {
try {
const msg = JSON.parse(event.data);
if (
msg?.type === "signalling" &&
msg?.message?.type === "renegotiate"
) {
await sendOffer();
} else if (
msg?.type === "assistant-text-start" &&
typeof msg.turn_id === "string"
) {
@@ -631,25 +682,8 @@ export function useVoicePreview(
}
}
// 4) 生成 offer 并发给后端(assistant_id 在 payload 顶层)
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
const localDescription = pc.localDescription;
if (!localDescription?.sdp) {
throw new Error("浏览器无法创建 WebRTC offer。");
}
ws.send(
JSON.stringify({
type: "offer",
payload: {
pc_id: pcId,
sdp: localDescription.sdp,
type: localDescription.type,
assistant_id: assistantId,
vision_enabled: Boolean(options.visionEnabled),
},
}),
);
// 4) 生成 offer 并发给后端;同一方法也响应 SmallWebRTC 的重新协商请求。
await sendOffer();
} catch (connectionError) {
fail(errorMessage(connectionError, "无法连接语音服务。"));
} finally {

View File

@@ -194,6 +194,63 @@ export const assistantsApi = {
request<{ ok: boolean }>(`/api/assistants/${id}`, { method: "DELETE" }),
};
// ---------- 对话历史 ----------
export type Conversation = {
id: string;
assistantId: string | null;
assistantName: string;
channel: string;
runtimeMode: string;
status: string;
messageCount: number;
startedAt: string;
endedAt: string | null;
};
export type ConversationMessage = {
id: string;
sequence: number;
role: "user" | "assistant";
contentType: string;
content: string;
occurredAt: string;
extra: { interrupted?: boolean; turn_id?: string };
};
export type ConversationDetail = Conversation & {
messages: ConversationMessage[];
};
export type ConversationList = {
items: Conversation[];
total: number;
page: number;
pageSize: number;
};
export const conversationsApi = {
list: (params: {
page?: number;
pageSize?: number;
search?: string;
channel?: string;
status?: string;
sortOrder?: "newest" | "oldest";
} = {}) => {
const query = new URLSearchParams({
page: String(params.page ?? 1),
page_size: String(params.pageSize ?? 20),
sort_order: params.sortOrder ?? "newest",
});
if (params.search) query.set("search", params.search);
if (params.channel) query.set("channel", params.channel);
if (params.status) query.set("status", params.status);
return request<ConversationList>(`/api/conversations?${query}`);
},
get: (id: string) =>
request<ConversationDetail>(`/api/conversations/${id}`),
};
// ---------- 工具 ----------
export type ToolStatus = "active" | "archived" | "draft";
export type ToolParameter = {