124 lines
4.3 KiB
TypeScript
124 lines
4.3 KiB
TypeScript
'use client'
|
|
import { useState } from 'react'
|
|
import { cn } from '@/lib/utils'
|
|
|
|
interface RawEntry {
|
|
id: number
|
|
ts: string
|
|
call_type: string
|
|
direction: string
|
|
provider?: string
|
|
model?: string
|
|
content?: string
|
|
title?: string
|
|
site?: string
|
|
tokens_prompt?: number
|
|
tokens_completion?: number
|
|
verdict?: string
|
|
status_code?: number
|
|
}
|
|
|
|
export default function AILogCard({ entry }: { entry: RawEntry }) {
|
|
const [expanded, setExpanded] = useState(false)
|
|
|
|
const isRequest = entry.direction === 'request'
|
|
const isResponse = entry.direction === 'response'
|
|
const isError = entry.direction === 'error'
|
|
const isMatch = entry.verdict === 'YES'
|
|
const isReject = entry.verdict === 'NO'
|
|
|
|
const totalTokens = (entry.tokens_prompt ?? 0) + (entry.tokens_completion ?? 0)
|
|
|
|
return (
|
|
<div className={cn(
|
|
'rounded-lg border text-sm transition-colors',
|
|
isError ? 'border-g-red/20 bg-g-red/4' :
|
|
isMatch ? 'border-g-green/20 bg-g-green/4' :
|
|
isReject ? 'border-g-line' :
|
|
'border-g-border/60',
|
|
)}>
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between gap-3 flex-wrap px-3.5 py-2.5">
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
{/* Direction badge */}
|
|
<span className={cn(
|
|
'g-badge',
|
|
isRequest ? 'g-badge-neutral' :
|
|
isResponse ? (isMatch ? 'g-badge-green' : isReject ? 'g-badge-red' : 'g-badge-neutral') :
|
|
'g-badge-red'
|
|
)}>
|
|
{isRequest ? '→ Prompt' : isResponse ? '← Response' : '⚠ Error'}
|
|
</span>
|
|
<span className="g-badge g-badge-neutral">{entry.call_type}</span>
|
|
{entry.provider && <span className="text-xs text-g-faint">{entry.provider}</span>}
|
|
{entry.model && (
|
|
<span className="text-xs text-g-faint truncate max-w-[160px]">{entry.model}</span>
|
|
)}
|
|
</div>
|
|
<span className="text-[11px] text-g-faint tabular-nums shrink-0">
|
|
{new Date(entry.ts).toLocaleTimeString()}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Lot info */}
|
|
{(entry.title || entry.site) && (
|
|
<div className="px-3.5 pb-2 flex items-center gap-2 flex-wrap">
|
|
{entry.title && (
|
|
<span className="text-xs text-g-text leading-relaxed">
|
|
{entry.title.length > 72 ? entry.title.slice(0, 72) + '…' : entry.title}
|
|
</span>
|
|
)}
|
|
{entry.site && <span className="text-xs text-g-faint">· {entry.site}</span>}
|
|
</div>
|
|
)}
|
|
|
|
{/* Verdict */}
|
|
{isResponse && entry.verdict && (
|
|
<div className={cn(
|
|
'px-3.5 pb-2 flex items-center gap-1.5 text-xs font-medium',
|
|
isMatch ? 'text-g-green' : 'text-g-red'
|
|
)}>
|
|
<span className={cn('w-1.5 h-1.5 rounded-full flex-shrink-0', isMatch ? 'bg-g-green' : 'bg-g-red')} />
|
|
{isMatch ? 'Match — lot accepted' : 'Reject — lot filtered out'}
|
|
</div>
|
|
)}
|
|
|
|
{/* Expandable content */}
|
|
{entry.content && (
|
|
<div className="px-3.5 pb-3">
|
|
<pre className={cn(
|
|
'whitespace-pre-wrap break-words text-[11px] leading-relaxed font-mono',
|
|
'bg-g-base/80 border border-g-border/40 p-3 rounded-md',
|
|
isRequest ? 'text-g-faint' : 'text-g-muted',
|
|
!expanded && 'max-h-20 overflow-hidden',
|
|
)}>
|
|
{entry.content}
|
|
</pre>
|
|
{entry.content.length > 180 && (
|
|
<button
|
|
onClick={() => setExpanded(!expanded)}
|
|
className="text-g-faint hover:text-g-muted text-[11px] mt-1 transition-colors"
|
|
>
|
|
{expanded ? '▲ Collapse' : `▼ Expand (${entry.content.length} chars)`}
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Token counts */}
|
|
{(entry.tokens_prompt != null || entry.tokens_completion != null) && (
|
|
<div className="px-3.5 pb-2.5 flex items-center gap-2 text-[11px] text-g-faint">
|
|
<span className="g-badge g-badge-neutral">
|
|
{entry.tokens_prompt ?? '?'} + {entry.tokens_completion ?? '?'} = {totalTokens} tokens
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Error */}
|
|
{isError && entry.status_code && (
|
|
<div className="px-3.5 pb-2 text-xs text-g-red font-mono">HTTP {entry.status_code}</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|