| import { useState } from "react"; | |
| import type { EpisodeData, TranscriptTurn } from "../types"; | |
| import { highlightTrace } from "../utils/traceHighlight"; | |
| export interface DragHandleProps { | |
| draggable: true; | |
| onDragStart: (e: React.DragEvent) => void; | |
| onDragEnd: (e: React.DragEvent) => void; | |
| } | |
| interface TranscriptPanelProps { | |
| datasetName: string; | |
| repoName?: string; | |
| data: EpisodeData | undefined; | |
| dragHandleProps?: DragHandleProps; | |
| } | |
| const OUTCOME_STYLES = { | |
| win: { bg: "bg-green-900", text: "text-green-300", label: "WIN" }, | |
| loss: { bg: "bg-red-900", text: "text-red-300", label: "LOSS" }, | |
| error: { bg: "bg-yellow-900", text: "text-yellow-300", label: "ERROR" }, | |
| unknown: { bg: "bg-gray-700", text: "text-gray-300", label: "?" }, | |
| }; | |
| const PLAYER_COLORS: Record<number, { bubble: string; label: string; name: string }> = { | |
| 0: { bubble: "bg-purple-900/60 border-purple-700", label: "text-purple-400", name: "Player 0" }, | |
| 1: { bubble: "bg-orange-900/60 border-orange-700", label: "text-orange-400", name: "Player 1" }, | |
| 2: { bubble: "bg-purple-900/60 border-purple-700", label: "text-purple-400", name: "Player 2" }, | |
| 3: { bubble: "bg-teal-900/60 border-teal-700", label: "text-teal-400", name: "Player 3" }, | |
| }; | |
| function getPlayerColor(playerId: number) { | |
| return PLAYER_COLORS[playerId] || PLAYER_COLORS[0]; | |
| } | |
| export default function TranscriptPanel({ datasetName, repoName, data, dragHandleProps }: TranscriptPanelProps) { | |
| if (!data) { | |
| return ( | |
| <div className="h-full border border-gray-700 rounded-lg flex items-center justify-center"> | |
| <div className="text-gray-500 text-sm">No data</div> | |
| </div> | |
| ); | |
| } | |
| const outcomeStyle = OUTCOME_STYLES[data.outcome]; | |
| const borderColor = data.outcome === "win" ? "border-green-600" | |
| : data.outcome === "loss" ? "border-red-600" | |
| : data.outcome === "error" ? "border-yellow-600" | |
| : "border-gray-700"; | |
| return ( | |
| <div className={`h-full border-2 ${borderColor} rounded-lg flex flex-col bg-gray-900/50`}> | |
| {/* Header */} | |
| <div className="px-3 py-2 border-b border-gray-700 shrink-0"> | |
| <div className="flex items-center justify-between mb-1"> | |
| <div className="flex items-center gap-2 min-w-0"> | |
| <span className="text-sm font-semibold text-gray-200 truncate" title={repoName || datasetName}> | |
| {datasetName} | |
| </span> | |
| <span className={`px-1.5 py-0.5 text-[10px] rounded font-medium ${outcomeStyle.bg} ${outcomeStyle.text}`}> | |
| {outcomeStyle.label} | |
| </span> | |
| </div> | |
| <div className="flex items-center gap-1.5 shrink-0 ml-2"> | |
| {dragHandleProps && ( | |
| <span | |
| {...dragHandleProps} | |
| title="Drag to reorder" | |
| className="drag-handle text-gray-600 hover:text-gray-400 transition-colors" | |
| > | |
| <svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"> | |
| <circle cx="5" cy="3" r="1.5" /> | |
| <circle cx="11" cy="3" r="1.5" /> | |
| <circle cx="5" cy="8" r="1.5" /> | |
| <circle cx="11" cy="8" r="1.5" /> | |
| <circle cx="5" cy="13" r="1.5" /> | |
| <circle cx="11" cy="13" r="1.5" /> | |
| </svg> | |
| </span> | |
| )} | |
| </div> | |
| </div> | |
| <div className="flex items-center gap-3 text-[10px] text-gray-500"> | |
| <span>{data.model}</span> | |
| <span>{data.num_turns} turns</span> | |
| {data.reward !== null && <span>reward: {data.reward}</span>} | |
| {data.opponent_model && <span>vs {data.opponent_model}</span>} | |
| </div> | |
| </div> | |
| {/* Error banner */} | |
| {data.error && ( | |
| <div className="px-3 py-1.5 bg-red-900/30 border-b border-red-800/50 text-xs text-red-300"> | |
| Error: {data.error} | |
| </div> | |
| )} | |
| {/* Transcript chat */} | |
| <div className="flex-1 overflow-y-auto transcript-scroll px-3 py-2 space-y-3"> | |
| {/* System prompt */} | |
| {data.system_prompt && ( | |
| <SystemPromptBubble text={data.system_prompt} /> | |
| )} | |
| {data.transcript.map((turn, i) => ( | |
| <TurnBubble | |
| key={i} | |
| turn={turn} | |
| turnIndex={i} | |
| allTurns={data.transcript} | |
| systemPrompt={data.system_prompt} | |
| hasMultiplePlayers={data.opponent_model !== null} | |
| /> | |
| ))} | |
| </div> | |
| </div> | |
| ); | |
| } | |
| function SystemPromptBubble({ text }: { text: string }) { | |
| const [expanded, setExpanded] = useState(true); | |
| return ( | |
| <div className="mb-2"> | |
| <button | |
| onClick={() => setExpanded(!expanded)} | |
| className="flex items-center gap-1.5 text-[10px] text-purple-400 hover:text-purple-300 transition-colors mb-1 font-semibold uppercase tracking-wider" | |
| > | |
| <span>{expanded ? "\u25BC" : "\u25B6"}</span> | |
| <span>System Prompt</span> | |
| </button> | |
| {expanded && ( | |
| <div className="bg-purple-950/40 border border-purple-800/50 rounded-lg px-3 py-2"> | |
| <pre className="text-xs leading-relaxed whitespace-pre-wrap font-mono text-purple-200"> | |
| {text} | |
| </pre> | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| } | |
| interface TurnBubbleProps { | |
| turn: TranscriptTurn; | |
| turnIndex: number; | |
| allTurns: TranscriptTurn[]; | |
| systemPrompt: string | null; | |
| hasMultiplePlayers: boolean; | |
| } | |
| function buildRawPrompt( | |
| turnIndex: number, | |
| allTurns: TranscriptTurn[], | |
| systemPrompt: string | null, | |
| ): object[] { | |
| const messages: object[] = []; | |
| if (systemPrompt) { | |
| messages.push({ role: "system", content: systemPrompt }); | |
| } | |
| for (let i = 0; i < turnIndex; i++) { | |
| messages.push({ role: "user", content: allTurns[i].observation }); | |
| messages.push({ role: "assistant", content: allTurns[i].action }); | |
| } | |
| messages.push({ role: "user", content: allTurns[turnIndex].observation }); | |
| return messages; | |
| } | |
| function TurnBubble({ turn, turnIndex, allTurns, systemPrompt, hasMultiplePlayers }: TurnBubbleProps) { | |
| const [thinkExpanded, setThinkExpanded] = useState(false); | |
| const [rawPromptExpanded, setRawPromptExpanded] = useState(false); | |
| const playerColor = getPlayerColor(turn.player_id); | |
| const thinkSegments = highlightTrace(turn.think_text); | |
| return ( | |
| <div> | |
| {/* Turn number marker */} | |
| <div className="text-[10px] text-gray-600 mb-1">Turn {turn.turn}</div> | |
| {/* Observation (environment message) — left aligned */} | |
| {turn.observation && ( | |
| <div className="flex justify-start mb-1.5"> | |
| <div className="max-w-[90%]"> | |
| <div className="text-[10px] text-gray-500 mb-0.5 font-semibold uppercase tracking-wider">ENV</div> | |
| <div className="bg-gray-800 border border-gray-700 rounded-lg rounded-tl-none px-3 py-2"> | |
| <pre className="text-xs leading-relaxed whitespace-pre-wrap font-mono text-gray-300"> | |
| {turn.observation} | |
| </pre> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| {/* Action (model response) — right aligned */} | |
| <div className="flex justify-end"> | |
| <div className="max-w-[90%]"> | |
| <div className={`text-[10px] mb-0.5 font-semibold uppercase tracking-wider text-right ${playerColor.label}`}> | |
| {hasMultiplePlayers ? `${playerColor.name} (${turn.player_id === 0 ? "model" : "opponent"})` : "Model"} | |
| </div> | |
| <div className={`border rounded-lg rounded-tr-none px-3 py-2 ${playerColor.bubble}`}> | |
| {/* Raw Prompt — collapsible, shows full messages sent to API */} | |
| <div className="mb-2"> | |
| <button | |
| onClick={() => setRawPromptExpanded(!rawPromptExpanded)} | |
| className="flex items-center gap-1 text-[10px] text-gray-500 hover:text-gray-400 transition-colors mb-1" | |
| > | |
| <span>{rawPromptExpanded ? "\u25BC" : "\u25B6"}</span> | |
| <span>Raw Prompt ({turnIndex * 2 + 1 + (systemPrompt ? 1 : 0)} messages)</span> | |
| </button> | |
| {rawPromptExpanded && ( | |
| <pre className="text-[10px] leading-relaxed whitespace-pre-wrap font-mono bg-gray-950/80 border border-gray-800 rounded px-2 py-1.5 text-gray-400 max-h-80 overflow-y-auto"> | |
| {JSON.stringify(buildRawPrompt(turnIndex, allTurns, systemPrompt), null, 2)} | |
| </pre> | |
| )} | |
| </div> | |
| {/* Think section — collapsible */} | |
| {turn.think_text && ( | |
| <div className="mb-2"> | |
| <button | |
| onClick={() => setThinkExpanded(!thinkExpanded)} | |
| className="flex items-center gap-1 text-[10px] text-gray-500 hover:text-gray-400 transition-colors mb-1" | |
| > | |
| <span>{thinkExpanded ? "\u25BC" : "\u25B6"}</span> | |
| <span>Thinking ({turn.think_len.toLocaleString()} chars{turn.backtracks > 0 ? `, ${turn.backtracks} backtracks` : ""})</span> | |
| </button> | |
| {thinkExpanded && ( | |
| <pre className="text-xs leading-relaxed whitespace-pre-wrap font-mono border-l-2 border-gray-600 pl-2 mt-1"> | |
| {thinkSegments.map((seg, i) => ( | |
| <span key={i} className={seg.className}>{seg.text}</span> | |
| ))} | |
| </pre> | |
| )} | |
| </div> | |
| )} | |
| {/* Action text (the actual game move) */} | |
| <pre className="text-xs leading-relaxed whitespace-pre-wrap font-mono text-gray-100 font-medium"> | |
| {turn.action_text || turn.action || "(no action)"} | |
| </pre> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| } | |