'use client'
import { AlertCircle, ChevronDown, ChevronRight, FileText, MessageSquareText } from 'lucide-react'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Empty, EmptyHeader, EmptyMedia, EmptyTitle } from '@/components/ui/empty'
import { Skeleton } from '@/components/ui/skeleton'
import { Spinner } from '@/components/ui/spinner'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import type {
ConversationInterruptionCount,
InsightAtifTarget,
SessionInterruptionCount,
SessionSummary,
TraceSummary,
} from '@/hooks/insight/types'
import type {
InsightOverviewInterruptionsController,
InsightOverviewSessionsController,
InsightOverviewStatusController,
} from '@/hooks/insight/use-overview'
import { cn } from '@/lib/utils'
function formatNs(ns: number): string {
return new Date(ns / 1_000_000).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
}
function formatTokens(value: number): string {
return value.toLocaleString('zh-CN')
}
function shortId(value: string, maxLength = 18): string {
return value.length > maxLength ? `${value.slice(0, maxLength)}...` : value
}
function IdPill({ value, maxLength = 22 }: { value: string; maxLength?: number }) {
return (
<Badge
variant="outline"
title={value}
className="max-w-full justify-start rounded-md bg-muted/40 font-mono text-[11px] tabular-nums text-foreground/90"
>
{shortId(value, maxLength)}
</Badge>
)
}
function getInterruptionTone(
count: SessionInterruptionCount | ConversationInterruptionCount
): string {
if (count.by_severity.critical > 0 || count.by_severity.high > 0) {
return 'border-red-200 bg-red-50 text-red-700 hover:bg-red-100'
}
if (count.by_severity.medium > 0) {
return 'border-orange-200 bg-orange-50 text-orange-700 hover:bg-orange-100'
}
return 'border-sky-200 bg-sky-50 text-sky-700 hover:bg-sky-100'
}
function OverviewTableSkeleton() {
return (
<div className="space-y-3">
{Array.from({ length: 6 }).map((_, index) => (
<Skeleton key={index} className="h-12 w-full" />
))}
</div>
)
}
function InterruptionCountCell({
count,
loaded,
onClick,
}: {
count?: SessionInterruptionCount | ConversationInterruptionCount
loaded: boolean
onClick?: () => void
}) {
if (!loaded) {
return <span className="text-xs text-muted-foreground">—</span>
}
const total = count?.total ?? 0
if (total === 0) {
return (
<Badge variant="outline" className="min-w-10 justify-center text-muted-foreground">
0
</Badge>
)
}
return (
<Button
type="button"
variant="outline"
size="sm"
className={cn(
'h-7 rounded-full px-2.5 text-xs',
count ? getInterruptionTone(count) : undefined
)}
onClick={event => {
event.stopPropagation()
onClick?.()
}}
>
<AlertCircle className="h-3.5 w-3.5" />
详情
<span className="rounded-full bg-background/70 px-1.5 py-0.5 font-mono text-[11px] leading-none">
{total}
</span>
</Button>
)
}
function TraceSubtable({
traces,
loading,
error,
conversationInterruptionCounts,
conversationInterruptionCountsLoaded,
onOpenConversationInterruptions,
onOpenAtif,
}: {
traces: TraceSummary[]
loading: boolean
error: string | null
conversationInterruptionCounts: Record<string, ConversationInterruptionCount>
conversationInterruptionCountsLoaded: boolean
onOpenConversationInterruptions: (trace: TraceSummary) => void
onOpenAtif: (target: InsightAtifTarget) => void
}) {
if (loading) {
return (
<div className="flex items-center gap-2 px-4 py-6 text-sm text-muted-foreground">
<Spinner className="h-4 w-4" />
加载对话轨迹中...
</div>
)
}
if (error) {
return (
<Alert variant="destructive" className="rounded-none border-0">
<AlertCircle />
<AlertTitle>对话轨迹加载失败</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)
}
if (traces.length === 0) {
return (
<div className="px-4 py-6">
<Empty className="border-muted bg-background/60 py-8">
<EmptyHeader>
<EmptyMedia variant="icon">
<MessageSquareText />
</EmptyMedia>
<EmptyTitle className="text-base">当前会话暂无对话轨迹</EmptyTitle>
</EmptyHeader>
</Empty>
</div>
)
}
return (
<div className="px-4 py-4">
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead>Conversation ID</TableHead>
<TableHead className="min-w-[260px]">用户请求</TableHead>
<TableHead>输入</TableHead>
<TableHead>输出</TableHead>
<TableHead>异常中断</TableHead>
<TableHead className="w-24">详情</TableHead>
<TableHead>开始时间</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{traces.map(trace => (
<TableRow key={trace.trace_id} className="hover:bg-background/70">
<TableCell className="text-xs text-muted-foreground">
<IdPill value={trace.conversation_id} maxLength={20} />
</TableCell>
<TableCell className="max-w-[340px] whitespace-normal text-sm text-foreground">
{trace.user_query || (
<span className="text-muted-foreground">暂无用户请求摘要</span>
)}
</TableCell>
<TableCell className="text-sm font-medium text-sky-700">
{formatTokens(trace.total_input_tokens)}
</TableCell>
<TableCell className="text-sm font-medium text-emerald-700">
{formatTokens(trace.total_output_tokens)}
</TableCell>
<TableCell>
<InterruptionCountCell
count={conversationInterruptionCounts[trace.conversation_id]}
loaded={conversationInterruptionCountsLoaded}
onClick={() => {
onOpenConversationInterruptions(trace)
}}
/>
</TableCell>
<TableCell>
<Button
variant="outline"
size="sm"
className="h-7 px-2.5"
onClick={event => {
event.stopPropagation()
onOpenAtif({
source: 'conversation',
id: trace.conversation_id,
})
}}
>
<FileText className="h-3.5 w-3.5" />
详情
</Button>
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{formatNs(trace.start_ns)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)
}
function SessionRow({
session,
expanded,
onToggle,
traces,
traceLoading,
traceError,
sessionInterruptionCount,
sessionInterruptionCountsLoaded,
conversationInterruptionCounts,
conversationInterruptionCountsLoaded,
onOpenSessionInterruptions,
onOpenConversationInterruptions,
onOpenAtif,
}: {
session: SessionSummary
expanded: boolean
onToggle: () => void
traces: TraceSummary[]
traceLoading: boolean
traceError: string | null
sessionInterruptionCount?: SessionInterruptionCount
sessionInterruptionCountsLoaded: boolean
conversationInterruptionCounts: Record<string, ConversationInterruptionCount>
conversationInterruptionCountsLoaded: boolean
onOpenSessionInterruptions: (session: SessionSummary) => void
onOpenConversationInterruptions: (trace: TraceSummary) => void
onOpenAtif: (target: InsightAtifTarget) => void
}) {
return (
<>
<TableRow
className={cn('cursor-pointer', expanded && 'bg-accent/30 hover:bg-accent/30')}
onClick={onToggle}
>
<TableCell>
<Button
type="button"
variant="outline"
size="icon-sm"
className="text-muted-foreground"
onClick={event => {
event.stopPropagation()
onToggle()
}}
>
{expanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</Button>
</TableCell>
<TableCell className="text-xs text-foreground">
<IdPill value={session.session_id} maxLength={22} />
</TableCell>
<TableCell className="text-sm">
{session.agent_name || <span className="text-muted-foreground">未标记</span>}
</TableCell>
<TableCell>
{session.model ? (
<Badge className="max-w-[180px] truncate rounded-md border border-border/70 bg-muted/40 px-2.5 py-1 text-[11px] font-medium tracking-[0.01em] text-foreground">
{session.model}
</Badge>
) : (
<span className="text-muted-foreground">—</span>
)}
</TableCell>
<TableCell>{session.conversation_count}</TableCell>
<TableCell className="font-medium text-sky-700">
{formatTokens(session.total_input_tokens)}
</TableCell>
<TableCell className="font-medium text-emerald-700">
{formatTokens(session.total_output_tokens)}
</TableCell>
<TableCell>
<InterruptionCountCell
count={sessionInterruptionCount}
loaded={sessionInterruptionCountsLoaded}
onClick={() => {
onOpenSessionInterruptions(session)
}}
/>
</TableCell>
<TableCell>
<Button
variant="outline"
size="sm"
className="h-7 px-2.5"
onClick={event => {
event.stopPropagation()
onOpenAtif({
source: 'session',
id: session.session_id,
})
}}
>
<FileText className="h-3.5 w-3.5" />
详情
</Button>
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{formatNs(session.last_seen_ns)}
</TableCell>
</TableRow>
{expanded ? (
<TableRow className="bg-accent/10 hover:bg-accent/10">
<TableCell colSpan={10} className="p-0">
<TraceSubtable
traces={traces}
loading={traceLoading}
error={traceError}
conversationInterruptionCounts={conversationInterruptionCounts}
conversationInterruptionCountsLoaded={conversationInterruptionCountsLoaded}
onOpenConversationInterruptions={onOpenConversationInterruptions}
onOpenAtif={onOpenAtif}
/>
</TableCell>
</TableRow>
) : null}
</>
)
}
interface InsightTraceTableProps {
sessionsController: InsightOverviewSessionsController
interruptionsController: InsightOverviewInterruptionsController
status: InsightOverviewStatusController
onOpenAtif: (target: InsightAtifTarget) => void
}
export function InsightTraceTable({
sessionsController,
interruptionsController,
status,
onOpenAtif,
}: InsightTraceTableProps) {
return (
<Card className="overflow-hidden">
<CardHeader className="pb-3">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<CardTitle className="text-base">会话列表</CardTitle>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
{status.refreshing ? <Spinner className="h-4 w-4" /> : null}共{' '}
{sessionsController.sessions.length} 条
</div>
</div>
</CardHeader>
<CardContent>
{status.error ? (
<Alert variant="destructive">
<AlertCircle />
<AlertTitle>会话列表加载失败</AlertTitle>
<AlertDescription>{status.error}</AlertDescription>
</Alert>
) : status.loading ? (
<OverviewTableSkeleton />
) : sessionsController.visibleSessions.length === 0 ? (
<Empty className="border-muted bg-background/60">
<EmptyHeader>
<EmptyMedia variant="icon">
<MessageSquareText />
</EmptyMedia>
<EmptyTitle>当前没有可展示的会话</EmptyTitle>
</EmptyHeader>
</Empty>
) : (
<>
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="w-12" />
<TableHead>Session ID</TableHead>
<TableHead>Agent</TableHead>
<TableHead>模型</TableHead>
<TableHead>对话数</TableHead>
<TableHead>输入</TableHead>
<TableHead>输出</TableHead>
<TableHead>异常中断</TableHead>
<TableHead className="w-24">详情</TableHead>
<TableHead>最近活动</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sessionsController.visibleSessions.map(session => (
<SessionRow
key={session.session_id}
session={session}
expanded={sessionsController.expandedSessionId === session.session_id}
onToggle={() => {
void sessionsController.toggleSession(session.session_id)
}}
traces={sessionsController.tracesBySessionId[session.session_id] || []}
traceLoading={
sessionsController.traceLoadingBySessionId[session.session_id] || false
}
traceError={
sessionsController.traceErrorBySessionId[session.session_id] || null
}
sessionInterruptionCount={
interruptionsController.sessionInterruptionCounts[session.session_id]
}
sessionInterruptionCountsLoaded={
interruptionsController.sessionInterruptionCountsLoaded
}
conversationInterruptionCounts={
interruptionsController.conversationInterruptionCounts
}
conversationInterruptionCountsLoaded={
interruptionsController.conversationInterruptionCountsLoaded
}
onOpenSessionInterruptions={sessionValue => {
void interruptionsController.openSessionInterruptions(sessionValue)
}}
onOpenConversationInterruptions={trace => {
void interruptionsController.openConversationInterruptions(trace)
}}
onOpenAtif={onOpenAtif}
/>
))}
</TableBody>
</Table>
</div>
{sessionsController.sessionTotalPages > 1 ? (
<div className="mt-4 flex flex-col gap-3 border-t border-border pt-4 sm:flex-row sm:items-center sm:justify-between">
<div className="text-xs text-muted-foreground">
显示 {sessionsController.sessionPage * sessionsController.pageSize + 1}-
{Math.min(
(sessionsController.sessionPage + 1) * sessionsController.pageSize,
sessionsController.sessions.length
)}{' '}
/ {sessionsController.sessions.length}
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
disabled={sessionsController.sessionPage === 0}
onClick={() =>
sessionsController.setSessionPage(
Math.max(0, sessionsController.sessionPage - 1)
)
}
>
上一页
</Button>
<Button
variant="outline"
size="sm"
disabled={
sessionsController.sessionPage === sessionsController.sessionTotalPages - 1
}
onClick={() =>
sessionsController.setSessionPage(
Math.min(
sessionsController.sessionTotalPages - 1,
sessionsController.sessionPage + 1
)
)
}
>
下一页
</Button>
</div>
</div>
) : null}
</>
)}
</CardContent>
</Card>
)
}