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 {
|
@layer base {
|
||||||
* {
|
* {
|
||||||
@apply border-border outline-ring/50;
|
@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 {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
letter-spacing: 0.01em;
|
letter-spacing: 0.01em;
|
||||||
@@ -261,42 +298,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Explicit opt-in alias; base layer already styles all scrollbars. */
|
||||||
.scrollbar-subtle {
|
.scrollbar-subtle {
|
||||||
scrollbar-width: thin;
|
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>
|
||||||
</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">
|
<div className="space-y-1">
|
||||||
<NavButton
|
<NavButton
|
||||||
active={pathname === "/"}
|
active={pathname === "/"}
|
||||||
|
|||||||
@@ -534,6 +534,7 @@ export function ComponentsModelsPage() {
|
|||||||
loading={loading}
|
loading={loading}
|
||||||
error={loadError || null}
|
error={loadError || null}
|
||||||
onRetry={() => void load()}
|
onRetry={() => void load()}
|
||||||
|
onRowClick={openEdit}
|
||||||
empty={{
|
empty={{
|
||||||
title:
|
title:
|
||||||
resources.length === 0
|
resources.length === 0
|
||||||
@@ -635,14 +636,20 @@ export function ComponentsModelsPage() {
|
|||||||
key: "actions",
|
key: "actions",
|
||||||
header: "操作",
|
header: "操作",
|
||||||
align: "right",
|
align: "right",
|
||||||
cellClassName: "flex justify-end gap-2",
|
|
||||||
cell: (resource) => (
|
cell: (resource) => (
|
||||||
<>
|
<div
|
||||||
|
className="flex justify-end gap-2"
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
onPointerDown={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="gap-1.5 border-hairline-strong text-xs text-muted-foreground hover:text-foreground"
|
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} />
|
<Pencil size={14} />
|
||||||
编辑
|
编辑
|
||||||
@@ -654,6 +661,7 @@ export function ComponentsModelsPage() {
|
|||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
className="border-hairline-strong text-muted-foreground hover:text-foreground"
|
className="border-hairline-strong text-muted-foreground hover:text-foreground"
|
||||||
aria-label={`${resource.name} 更多操作`}
|
aria-label={`${resource.name} 更多操作`}
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
>
|
>
|
||||||
<MoreHorizontal size={15} />
|
<MoreHorizontal size={15} />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -682,8 +690,9 @@ export function ComponentsModelsPage() {
|
|||||||
className="rounded-lg"
|
className="rounded-lg"
|
||||||
disabled={deletingId === resource.id}
|
disabled={deletingId === resource.id}
|
||||||
onSelect={(event) => {
|
onSelect={(event) => {
|
||||||
|
// Defer confirm so row onRowClick does not fire afterward.
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
void remove(resource);
|
window.setTimeout(() => void remove(resource), 0);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{deletingId === resource.id ? (
|
{deletingId === resource.id ? (
|
||||||
@@ -695,7 +704,7 @@ export function ComponentsModelsPage() {
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
@@ -704,7 +713,7 @@ export function ComponentsModelsPage() {
|
|||||||
</ListPageLayout>
|
</ListPageLayout>
|
||||||
|
|
||||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
<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>
|
<DialogHeader>
|
||||||
<DialogTitle>{editingId ? "编辑模型资源" : "添加模型资源"}</DialogTitle>
|
<DialogTitle>{editingId ? "编辑模型资源" : "添加模型资源"}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
@@ -945,7 +954,7 @@ function FieldSection({
|
|||||||
<div
|
<div
|
||||||
className={[
|
className={[
|
||||||
"space-y-4 p-4",
|
"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" : "",
|
tall ? "lg:max-h-none lg:flex-1" : "",
|
||||||
fill ? "lg:min-h-0 lg:max-h-none lg:flex-1" : "",
|
fill ? "lg:min-h-0 lg:max-h-none lg:flex-1" : "",
|
||||||
].join(" ")}
|
].join(" ")}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
Activity,
|
Activity,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
Camera,
|
Camera,
|
||||||
|
ChartLine,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronUp,
|
ChevronUp,
|
||||||
@@ -15,6 +16,8 @@ import {
|
|||||||
MessageSquareText,
|
MessageSquareText,
|
||||||
MonitorSmartphone,
|
MonitorSmartphone,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
|
Pause,
|
||||||
|
Play,
|
||||||
Server,
|
Server,
|
||||||
Trash2,
|
Trash2,
|
||||||
Wrench,
|
Wrench,
|
||||||
@@ -23,6 +26,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
|
import { SectionAnchorTabs } from "@/components/editor/section-anchor-tabs";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { DataList, type DataListColumn } from "@/components/ui/data-list";
|
import { DataList, type DataListColumn } from "@/components/ui/data-list";
|
||||||
@@ -34,10 +38,8 @@ import {
|
|||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogClose,
|
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
@@ -61,6 +63,12 @@ const PAGE_SIZE = 5;
|
|||||||
const channelFilters = ["全部", "WebRTC", "WebSocket"] as const;
|
const channelFilters = ["全部", "WebRTC", "WebSocket"] as const;
|
||||||
type ChannelFilter = (typeof channelFilters)[number];
|
type ChannelFilter = (typeof channelFilters)[number];
|
||||||
type SortOrder = "newest" | "oldest";
|
type SortOrder = "newest" | "oldest";
|
||||||
|
type DetailTabId = "session" | "analysis";
|
||||||
|
|
||||||
|
const detailTabs = [
|
||||||
|
{ id: "session", label: "会话信息" },
|
||||||
|
{ id: "analysis", label: "分析" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
function formatDate(value?: string | null): string {
|
function formatDate(value?: string | null): string {
|
||||||
if (!value) return "—";
|
if (!value) return "—";
|
||||||
@@ -364,6 +372,7 @@ export function HistoryPage() {
|
|||||||
const [detail, setDetail] = useState<ConversationDetail | null>(null);
|
const [detail, setDetail] = useState<ConversationDetail | null>(null);
|
||||||
const [detailLoading, setDetailLoading] = useState(false);
|
const [detailLoading, setDetailLoading] = useState(false);
|
||||||
const [detailError, setDetailError] = useState("");
|
const [detailError, setDetailError] = useState("");
|
||||||
|
const [detailTab, setDetailTab] = useState<DetailTabId>("session");
|
||||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||||
const skipRowClickRef = useRef(false);
|
const skipRowClickRef = useRef(false);
|
||||||
|
|
||||||
@@ -401,6 +410,7 @@ export function HistoryPage() {
|
|||||||
|
|
||||||
const openDetail = useCallback(async (conversation: Conversation) => {
|
const openDetail = useCallback(async (conversation: Conversation) => {
|
||||||
setDialogOpen(true);
|
setDialogOpen(true);
|
||||||
|
setDetailTab("session");
|
||||||
setDetail(null);
|
setDetail(null);
|
||||||
setDetailError("");
|
setDetailError("");
|
||||||
setDetailLoading(true);
|
setDetailLoading(true);
|
||||||
@@ -680,8 +690,8 @@ export function HistoryPage() {
|
|||||||
</ListPageLayout>
|
</ListPageLayout>
|
||||||
|
|
||||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
<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">
|
<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>
|
<DialogHeader className="shrink-0 gap-1.5 px-5 py-4 sm:px-6">
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
<MessageSquareText size={18} />
|
<MessageSquareText size={18} />
|
||||||
{detail?.assistantName || "对话详情"}
|
{detail?.assistantName || "对话详情"}
|
||||||
@@ -693,44 +703,84 @@ export function HistoryPage() {
|
|||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</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">
|
||||||
{detailLoading && (
|
<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">
|
||||||
<div className="flex min-h-80 items-center justify-center gap-2 text-muted-foreground">
|
<SectionAnchorTabs
|
||||||
<Loader2 size={16} className="animate-spin" />
|
ariaLabel="会话详情分区"
|
||||||
正在加载对话内容
|
sections={detailTabs}
|
||||||
</div>
|
activeSectionId={detailTab}
|
||||||
)}
|
onSelect={(sectionId) =>
|
||||||
|
setDetailTab(sectionId as DetailTabId)
|
||||||
{!detailLoading && detailError && (
|
}
|
||||||
<div className="flex min-h-80 items-center justify-center text-destructive">
|
className="bg-popover px-3 pt-2 sm:px-4"
|
||||||
{detailError}
|
/>
|
||||||
</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">
|
||||||
{detail && (
|
<Loader2 size={16} className="animate-spin" />
|
||||||
<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>
|
||||||
<div className="grid gap-4 p-4 text-sm sm:grid-cols-2 lg:grid-cols-4">
|
)}
|
||||||
<Metadata label="接入通道" value={channelLabel(detail.channel)} />
|
{!detailLoading && detailError && (
|
||||||
<Metadata label="运行模式" value={detail.runtimeMode} />
|
<div className="flex min-h-48 items-center justify-center text-sm text-destructive">
|
||||||
<Metadata label="对话时长" value={durationLabel(detail)} />
|
{detailError}
|
||||||
<Metadata label="状态" value={statusLabel(detail.status)} />
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
)}
|
||||||
|
{detail && detailTab === "session" && (
|
||||||
<ConversationTimeline detail={detail} />
|
<SessionInfoPanel detail={detail} />
|
||||||
|
)}
|
||||||
|
{detail && detailTab === "analysis" && <AnalysisPanel />}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<HistoryPlaybackBar key={detail?.id ?? "empty"} detail={detail} />
|
||||||
<DialogClose asChild>
|
|
||||||
<Button variant="outline">关闭</Button>
|
|
||||||
</DialogClose>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</>
|
</>
|
||||||
@@ -746,26 +796,106 @@ function Metadata({ label, value }: { label: string; value: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type TimelineEntry =
|
function SessionInfoPanel({ detail }: { detail: ConversationDetail }) {
|
||||||
| {
|
const nodes = workflowNodes(detail);
|
||||||
kind: "message";
|
const trace = (detail.extra.workflowTrace ?? []).map(recordValue);
|
||||||
timestamp: string;
|
const toolCount = trace.filter(
|
||||||
order: number;
|
(event) => event.event === "action_started" || event.event === "tool_started",
|
||||||
message: ConversationMessage;
|
).length;
|
||||||
}
|
const transitionCount = trace.filter(
|
||||||
| {
|
(event) => event.event === "edge_selected",
|
||||||
kind: "trace";
|
).length;
|
||||||
timestamp: string;
|
const imageCount = detail.messages.reduce(
|
||||||
order: number;
|
(count, message) =>
|
||||||
event: TraceEvent;
|
count +
|
||||||
};
|
(message.artifacts ?? []).filter((item) => item.kind === "image").length,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
function timestampOrder(value: string): number {
|
return (
|
||||||
const parsed = Date.parse(value);
|
<div className="space-y-4">
|
||||||
return Number.isNaN(parsed) ? Number.MAX_SAFE_INTEGER : parsed;
|
<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 nodes = workflowNodes(detail);
|
||||||
const trace = (detail.extra.workflowTrace ?? []).map(recordValue);
|
const trace = (detail.extra.workflowTrace ?? []).map(recordValue);
|
||||||
const startedByInvocation = new Map<string, TraceEvent>();
|
const startedByInvocation = new Map<string, TraceEvent>();
|
||||||
@@ -795,72 +925,135 @@ function ConversationTimeline({ detail }: { detail: ConversationDetail }) {
|
|||||||
a.order - b.order,
|
a.order - b.order,
|
||||||
);
|
);
|
||||||
|
|
||||||
const imageCount = detail.messages.reduce(
|
if (entries.length === 0) {
|
||||||
(count, message) =>
|
return (
|
||||||
count + (message.artifacts ?? []).filter((item) => item.kind === "image").length,
|
<div className="flex min-h-48 items-center justify-center text-sm text-muted-foreground">
|
||||||
0,
|
本次会话没有产生可展示的记录
|
||||||
);
|
</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 (
|
return (
|
||||||
<section className="overflow-hidden rounded-xl border border-hairline bg-surface-strong/20">
|
<div className="space-y-4">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-hairline px-4 py-3">
|
{entries.map((entry, index) =>
|
||||||
<div className="text-sm font-medium">完整时间线</div>
|
entry.kind === "message" ? (
|
||||||
<div className="flex flex-wrap gap-2">
|
<TimelineMessage
|
||||||
<Badge variant="secondary" className="gap-1.5 bg-surface-strong text-muted-foreground">
|
key={`message-${entry.message.id}`}
|
||||||
<MessageSquareText size={12} />
|
message={entry.message}
|
||||||
{detail.messages.length} 条对话
|
/>
|
||||||
</Badge>
|
) : (
|
||||||
{imageCount > 0 && (
|
<TimelineTrace
|
||||||
<Badge variant="secondary" className="gap-1.5 bg-surface-strong text-muted-foreground">
|
key={`trace-${textValue(entry.event.eventId) || index}`}
|
||||||
<ImageIcon size={12} />
|
event={entry.event}
|
||||||
{imageCount} 张照片
|
nodes={nodes}
|
||||||
</Badge>
|
startedEvent={startedByInvocation.get(
|
||||||
)}
|
textValue(recordValue(entry.event.outcome).invocationId) ||
|
||||||
{toolCount > 0 && (
|
textValue(entry.event.invocationId),
|
||||||
<Badge variant="secondary" className="gap-1.5 bg-surface-strong text-muted-foreground">
|
)}
|
||||||
<Wrench size={12} />
|
/>
|
||||||
{toolCount} 次工具
|
),
|
||||||
</Badge>
|
)}
|
||||||
)}
|
</div>
|
||||||
{transitionCount > 0 && (
|
);
|
||||||
<Badge variant="secondary" className="gap-1.5 bg-surface-strong text-muted-foreground">
|
}
|
||||||
<GitBranch size={12} />
|
|
||||||
{transitionCount} 次转移
|
function formatPlaybackClock(seconds: number): string {
|
||||||
</Badge>
|
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>
|
</div>
|
||||||
|
</div>
|
||||||
<div className="space-y-4 p-4 sm:p-5">
|
|
||||||
{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),
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
{entries.length === 0 && (
|
|
||||||
<div className="py-12 text-center text-muted-foreground">
|
|
||||||
本次会话没有产生可展示的记录
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 }) {
|
function TimelineMessage({ message }: { message: ConversationMessage }) {
|
||||||
const isUser = message.role === "user";
|
const isUser = message.role === "user";
|
||||||
const images = (message.artifacts ?? []).filter((item) => item.kind === "image");
|
const images = (message.artifacts ?? []).filter((item) => item.kind === "image");
|
||||||
|
|||||||
Reference in New Issue
Block a user