model-panel.tsx
3,523 bytes
| 1 | 'use client'; |
|---|---|
| 2 | |
| 3 | import { AlertTriangle, ChevronDown, Clock, Coins } from 'lucide-react'; |
| 4 | import { Badge } from '@/components/ui/badge'; |
| 5 | import { |
| 6 | Collapsible, |
| 7 | CollapsibleContent, |
| 8 | CollapsibleTrigger, |
| 9 | } from '@/components/ui/collapsible'; |
| 10 | import { RichText } from '@/components/debate/rich-text'; |
| 11 | import type { AnswerRecord, StageType } from '@/core/types'; |
| 12 | import { participantColor, participantTag } from '@/lib/model-visuals'; |
| 13 | import { cn, formatLatency, formatUsd } from '@/lib/utils'; |
| 14 | |
| 15 | interface ModelPanelProps { |
| 16 | index: number; |
| 17 | displayName: string; |
| 18 | model: string; |
| 19 | answer?: AnswerRecord; |
| 20 | streamingText?: string; |
| 21 | workingStage?: StageType; |
| 22 | dropped?: boolean; |
| 23 | } |
| 24 | |
| 25 | const STAGE_LABEL: Record<StageType, string> = { |
| 26 | answer: 'Answering', |
| 27 | critique: 'Critiquing peers', |
| 28 | revision: 'Revising', |
| 29 | convergence: 'Assessing', |
| 30 | synthesis: 'Synthesizing', |
| 31 | provenance: 'Auditing claims', |
| 32 | }; |
| 33 | |
| 34 | export function ModelPanel({ |
| 35 | index, |
| 36 | displayName, |
| 37 | model, |
| 38 | answer, |
| 39 | streamingText, |
| 40 | workingStage, |
| 41 | dropped, |
| 42 | }: ModelPanelProps) { |
| 43 | const color = participantColor(index); |
| 44 | const text = answer?.content ?? streamingText ?? ''; |
| 45 | const isStreaming = workingStage === 'answer' && !answer; |
| 46 | |
| 47 | return ( |
| 48 | <Collapsible defaultOpen className="flex flex-col rounded-xl border bg-card"> |
| 49 | <CollapsibleTrigger className="group flex items-center gap-2 p-3 text-left"> |
| 50 | <span |
| 51 | className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-xs font-bold text-white" |
| 52 | style={{ backgroundColor: color }} |
| 53 | > |
| 54 | {participantTag(index)} |
| 55 | </span> |
| 56 | <div className="min-w-0 flex-1"> |
| 57 | <div className="truncate text-sm font-semibold">{displayName}</div> |
| 58 | <div className="truncate font-mono text-[11px] text-muted-foreground">{model}</div> |
| 59 | </div> |
| 60 | {dropped ? ( |
| 61 | <Badge variant="destructive" className="gap-1"> |
| 62 | <AlertTriangle className="h-3 w-3" /> dropped |
| 63 | </Badge> |
| 64 | ) : workingStage ? ( |
| 65 | <Badge variant="warning" className="animate-pulse-subtle"> |
| 66 | {STAGE_LABEL[workingStage]} |
| 67 | </Badge> |
| 68 | ) : answer ? ( |
| 69 | <Badge variant="secondary">round {answer.round}</Badge> |
| 70 | ) : null} |
| 71 | <ChevronDown className="h-4 w-4 text-muted-foreground transition-transform group-data-[state=closed]:-rotate-90" /> |
| 72 | </CollapsibleTrigger> |
| 73 | |
| 74 | <CollapsibleContent> |
| 75 | <div className="scrollbar-thin max-h-[420px] overflow-y-auto border-t px-4 py-3"> |
| 76 | {text ? ( |
| 77 | <RichText text={text} className={cn(isStreaming && 'streaming-caret')} /> |
| 78 | ) : dropped ? ( |
| 79 | <p className="text-sm text-muted-foreground">This model dropped out of the debate.</p> |
| 80 | ) : ( |
| 81 | <p className="text-sm text-muted-foreground"> |
| 82 | {workingStage ? `${STAGE_LABEL[workingStage]}...` : 'Waiting...'} |
| 83 | </p> |
| 84 | )} |
| 85 | </div> |
| 86 | {answer && ( |
| 87 | <div className="flex items-center gap-4 border-t px-4 py-2 text-[11px] text-muted-foreground"> |
| 88 | <span className="flex items-center gap-1"> |
| 89 | <Clock className="h-3 w-3" /> {formatLatency(answer.latencyMs)} |
| 90 | </span> |
| 91 | <span className="flex items-center gap-1"> |
| 92 | <Coins className="h-3 w-3" /> {formatUsd(answer.usage.costUsd)} |
| 93 | </span> |
| 94 | <span>{answer.usage.totalTokens} tok</span> |
| 95 | </div> |
| 96 | )} |
| 97 | </CollapsibleContent> |
| 98 | </Collapsible> |
| 99 | ); |
| 100 | } |
| 101 | |