feat(frontend): improve history detail and model list UX
Open model resources via row click, restyle history detail with session/analysis panels and mock playback, and theme scrollbars globally with navy light/dark tokens. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -180,7 +180,44 @@
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
/* Theme-aware scrollbars (light/dark navy tokens) */
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: color-mix(
|
||||
in srgb,
|
||||
var(--muted-soft) 48%,
|
||||
transparent
|
||||
)
|
||||
transparent;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
var(--muted-soft) 42%,
|
||||
transparent
|
||||
);
|
||||
border: 3px solid transparent;
|
||||
border-radius: 999px;
|
||||
background-clip: content-box;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb:hover {
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
var(--muted-soft) 64%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
letter-spacing: 0.01em;
|
||||
@@ -261,42 +298,9 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Explicit opt-in alias; base layer already styles all scrollbars. */
|
||||
.scrollbar-subtle {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: color-mix(
|
||||
in srgb,
|
||||
var(--muted-soft) 48%,
|
||||
transparent
|
||||
)
|
||||
transparent;
|
||||
}
|
||||
|
||||
.scrollbar-subtle::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
.scrollbar-subtle::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.scrollbar-subtle::-webkit-scrollbar-thumb {
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
var(--muted-soft) 42%,
|
||||
transparent
|
||||
);
|
||||
border: 3px solid transparent;
|
||||
border-radius: 999px;
|
||||
background-clip: content-box;
|
||||
}
|
||||
|
||||
.scrollbar-subtle::-webkit-scrollbar-thumb:hover {
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
var(--muted-soft) 64%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
/* 手机通话页「开始通话」引导光晕:柔和向外扩散的涟漪环 */
|
||||
|
||||
@@ -98,7 +98,7 @@ export function Sidebar({ collapsed, onToggle }: SidebarProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-3 py-5 pr-2 [scrollbar-width:thin] [scrollbar-color:var(--hairline-strong)_transparent]">
|
||||
<nav className="scrollbar-subtle min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-3 py-5 pr-2">
|
||||
<div className="space-y-1">
|
||||
<NavButton
|
||||
active={pathname === "/"}
|
||||
|
||||
@@ -534,6 +534,7 @@ export function ComponentsModelsPage() {
|
||||
loading={loading}
|
||||
error={loadError || null}
|
||||
onRetry={() => void load()}
|
||||
onRowClick={openEdit}
|
||||
empty={{
|
||||
title:
|
||||
resources.length === 0
|
||||
@@ -635,14 +636,20 @@ export function ComponentsModelsPage() {
|
||||
key: "actions",
|
||||
header: "操作",
|
||||
align: "right",
|
||||
cellClassName: "flex justify-end gap-2",
|
||||
cell: (resource) => (
|
||||
<>
|
||||
<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={() => openEdit(resource)}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
openEdit(resource);
|
||||
}}
|
||||
>
|
||||
<Pencil size={14} />
|
||||
编辑
|
||||
@@ -654,6 +661,7 @@ export function ComponentsModelsPage() {
|
||||
size="icon-sm"
|
||||
className="border-hairline-strong text-muted-foreground hover:text-foreground"
|
||||
aria-label={`${resource.name} 更多操作`}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<MoreHorizontal size={15} />
|
||||
</Button>
|
||||
@@ -682,8 +690,9 @@ export function ComponentsModelsPage() {
|
||||
className="rounded-lg"
|
||||
disabled={deletingId === resource.id}
|
||||
onSelect={(event) => {
|
||||
// Defer confirm so row onRowClick does not fire afterward.
|
||||
event.preventDefault();
|
||||
void remove(resource);
|
||||
window.setTimeout(() => void remove(resource), 0);
|
||||
}}
|
||||
>
|
||||
{deletingId === resource.id ? (
|
||||
@@ -695,7 +704,7 @@ export function ComponentsModelsPage() {
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
@@ -704,7 +713,7 @@ export function ComponentsModelsPage() {
|
||||
</ListPageLayout>
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="max-h-[calc(100vh-3rem)] overflow-y-auto sm:max-w-6xl lg:max-w-[88rem] lg:overflow-hidden">
|
||||
<DialogContent className="scrollbar-subtle max-h-[calc(100vh-3rem)] overflow-y-auto sm:max-w-6xl lg:max-w-[88rem] lg:overflow-hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingId ? "编辑模型资源" : "添加模型资源"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
@@ -945,7 +954,7 @@ function FieldSection({
|
||||
<div
|
||||
className={[
|
||||
"space-y-4 p-4",
|
||||
scrollable ? "max-h-72 overflow-y-auto" : "",
|
||||
scrollable ? "scrollbar-subtle max-h-72 overflow-y-auto" : "",
|
||||
tall ? "lg:max-h-none lg:flex-1" : "",
|
||||
fill ? "lg:min-h-0 lg:max-h-none lg:flex-1" : "",
|
||||
].join(" ")}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
Activity,
|
||||
ArrowRight,
|
||||
Camera,
|
||||
ChartLine,
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
@@ -15,6 +16,8 @@ import {
|
||||
MessageSquareText,
|
||||
MonitorSmartphone,
|
||||
MoreHorizontal,
|
||||
Pause,
|
||||
Play,
|
||||
Server,
|
||||
Trash2,
|
||||
Wrench,
|
||||
@@ -23,6 +26,7 @@ import {
|
||||
} 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";
|
||||
@@ -34,10 +38,8 @@ import {
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
@@ -61,6 +63,12 @@ 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 "—";
|
||||
@@ -364,6 +372,7 @@ export function HistoryPage() {
|
||||
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);
|
||||
|
||||
@@ -401,6 +410,7 @@ export function HistoryPage() {
|
||||
|
||||
const openDetail = useCallback(async (conversation: Conversation) => {
|
||||
setDialogOpen(true);
|
||||
setDetailTab("session");
|
||||
setDetail(null);
|
||||
setDetailError("");
|
||||
setDetailLoading(true);
|
||||
@@ -680,8 +690,8 @@ export function HistoryPage() {
|
||||
</ListPageLayout>
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="max-h-[calc(100vh-3rem)] grid-rows-[auto_minmax(0,1fr)_auto] overflow-hidden sm:max-w-5xl">
|
||||
<DialogHeader>
|
||||
<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 || "对话详情"}
|
||||
@@ -693,44 +703,84 @@ export function HistoryPage() {
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="min-h-0 overflow-y-auto">
|
||||
<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-80 items-center justify-center gap-2 text-muted-foreground">
|
||||
<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 />}
|
||||
</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-80 items-center justify-center text-destructive">
|
||||
<div className="flex min-h-48 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">
|
||||
会话信息
|
||||
{detail && <TranscriptPanel detail={detail} />}
|
||||
</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>
|
||||
|
||||
<ConversationTimeline detail={detail} />
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline">关闭</Button>
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
<HistoryPlaybackBar key={detail?.id ?? "empty"} detail={detail} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
@@ -746,26 +796,106 @@ function Metadata({ label, value }: { label: string; value: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
type TimelineEntry =
|
||||
| {
|
||||
kind: "message";
|
||||
timestamp: string;
|
||||
order: number;
|
||||
message: ConversationMessage;
|
||||
}
|
||||
| {
|
||||
kind: "trace";
|
||||
timestamp: string;
|
||||
order: number;
|
||||
event: TraceEvent;
|
||||
};
|
||||
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,
|
||||
);
|
||||
|
||||
function timestampOrder(value: string): number {
|
||||
const parsed = Date.parse(value);
|
||||
return Number.isNaN(parsed) ? Number.MAX_SAFE_INTEGER : parsed;
|
||||
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 ConversationTimeline({ detail }: { detail: ConversationDetail }) {
|
||||
function AnalysisPanel() {
|
||||
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>();
|
||||
@@ -795,50 +925,22 @@ function ConversationTimeline({ detail }: { detail: ConversationDetail }) {
|
||||
a.order - b.order,
|
||||
);
|
||||
|
||||
const imageCount = detail.messages.reduce(
|
||||
(count, message) =>
|
||||
count + (message.artifacts ?? []).filter((item) => item.kind === "image").length,
|
||||
0,
|
||||
if (entries.length === 0) {
|
||||
return (
|
||||
<div className="flex min-h-48 items-center justify-center text-sm text-muted-foreground">
|
||||
本次会话没有产生可展示的记录
|
||||
</div>
|
||||
);
|
||||
const toolCount = trace.filter(
|
||||
(event) => event.event === "action_started" || event.event === "tool_started",
|
||||
).length;
|
||||
const transitionCount = trace.filter((event) => event.event === "edge_selected").length;
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="overflow-hidden rounded-xl border border-hairline bg-surface-strong/20">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-hairline px-4 py-3">
|
||||
<div className="text-sm font-medium">完整时间线</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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 p-4 sm:p-5">
|
||||
<div className="space-y-4">
|
||||
{entries.map((entry, index) =>
|
||||
entry.kind === "message" ? (
|
||||
<TimelineMessage key={`message-${entry.message.id}`} message={entry.message} />
|
||||
<TimelineMessage
|
||||
key={`message-${entry.message.id}`}
|
||||
message={entry.message}
|
||||
/>
|
||||
) : (
|
||||
<TimelineTrace
|
||||
key={`trace-${textValue(entry.event.eventId) || index}`}
|
||||
@@ -851,16 +953,107 @@ function ConversationTimeline({ detail }: { detail: ConversationDetail }) {
|
||||
/>
|
||||
),
|
||||
)}
|
||||
{entries.length === 0 && (
|
||||
<div className="py-12 text-center text-muted-foreground">
|
||||
本次会话没有产生可展示的记录
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
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");
|
||||
|
||||
Reference in New Issue
Block a user