import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import remarkMath from "remark-math"; import rehypeKatex from "rehype-katex"; import "katex/dist/katex.min.css"; import { repairMarkdownTables, splitStreaming } from "../utils/streamRender.js"; // Full CommonMark + GFM (tables, strikethrough, task lists, autolinks) via // react-markdown β€” replaces the old hand-rolled regex renderer, so any // standard markdown the model emits renders correctly without per-syntax // patches. Links open in a new tab. const MD_PLUGINS = [remarkGfm, [remarkMath, { singleDollarTextMath: false }]]; const REHYPE_PLUGINS = [[rehypeKatex, { strict: false, throwOnError: false }]]; const MD_COMPONENTS = { a: (props) => , table: ({ node, ...props }) => (
), pre: ({ node, ...props }) =>
,
};

function Markdown({ children }) {
  return (
    
      {repairMarkdownTables(children || "")}
    
  );
}

/**
 * One message β€” Claude Desktop style.
 *
 *   user message:        right-aligned, subtle warm-gray bubble
 *   assistant message:   full-width prose, no bubble (just clean markdown)
 *
 * Assistant messages carry response_id + rendered_format. The feedback row
 * (πŸ‘/πŸ‘Ž) appears only on the most recent assistant message and routes to
 * Path B with that exact response_id.
 */
export default function Message({ message, isLastAssistant, showMeta, onFeedback, onRegenerate, ratedSignal }) {
  const isUser = message.role === "user";
  const isPlaceholder = !!message._placeholder;
  const thumbsLocked = !!ratedSignal;

  // User messages: plain text in the bubble (React escapes automatically).
  // Assistant messages: full markdown via react-markdown.
  //
  // While streaming we render LINE BY LINE: every complete line is safe to
  // markdown-render, while the partially-typed last line is held back and
  // sanitized (see splitStreaming). That keeps raw syntax off the screen
  // without freezing a whole table as plain text until it finishes.
  return (
    
{isUser ? message.content : isPlaceholder ? : {message.content}}
{!isUser && showMeta && message.meta && (
{renderMetaChips(message)}
)} {!isUser && isLastAssistant && !isPlaceholder && message.response_id && (
)}
); } /** * The in-flight assistant bubble. splitStreaming decides which prefix of the * buffer is safe to parse (buffering half-open tables/fences) and sanitizes * the still-typing line; this component just renders those two pieces. */ function StreamingContent({ content }) { const { thinking, committed, tail, liveCode } = splitStreaming(content); if (thinking) { return (
Thinking…
); } return ( <> {committed ? {committed} : null} {liveCode ? : null} {tail ?
{tail}
: null} ); } function LiveCodeBlock({ language, code }) { const label = language || "code"; return (
{label} Streaming
{code || " "}
); } // ---------- Chip rendering ---------- function renderMetaChips(msg) { const meta = msg.meta || {}; const chips = []; // Topic is disabled in the bandit key (collapsed to "_all") β€” don't show it. if (meta.topic && meta.topic !== "_all") chips.push(chip("topic: " + meta.topic)); if (meta.intent) chips.push(chip("intent: " + meta.intent)); if (meta.selected_strategy) chips.push(chip("strategy: " + meta.selected_strategy)); if (msg.rendered_format) chips.push(chip("rendered: " + msg.rendered_format)); // Selection score of the chosen strategy β€” computed LIVE server-side from // the current cell state (matches the Bandit State tab), not a cached // snapshot. Round-robin cold-start picks have no meaningful score. // `meta.ucb_at_selection` is the historical fallback for older messages. const selMethod = msg.selection_method || meta.selection_method; const liveScore = msg.live_selection_score != null ? msg.live_selection_score : meta.ucb_at_selection; if (selMethod === "round_robin") { chips.push(chip("pick: round-robin")); } else if (liveScore != null) { chips.push(chip(`selection score: ${Number(liveScore).toFixed(2)}`)); } // Applied reward verdict β€” joined from ape_turn_record by the messages // API. Shows BOTH reward axes of the two-axis model: // format β€” what the bandit consumed (explicit Β±2 / inferred Β±1) // content β€” recorded evidence about the answer's substance if (msg.reward_status === "APPLIED") { const hasFormat = msg.normalized_reward != null; const hasContent = msg.content_reward != null; if (msg.applied_signal && msg.applied_signal !== "no_signal") { chips.push(chip(`signal: ${msg.applied_signal}`)); } if (hasContent) { const c = Number(msg.content_reward); chips.push(chip( `content: ${c > 0 ? "+" : ""}${c} (${tierLabel(msg.content_category)})`, c > 0 ? "pos" : "neg", )); } if (hasFormat) { const f = Number(msg.normalized_reward); chips.push(chip( `format: ${f > 0 ? "+" : ""}${f} (${tierLabel(msg.reward_category)})`, f > 0 ? "pos" : "neg", )); } if (!hasFormat && !hasContent && msg.applied_signal && msg.applied_signal !== "no_signal") { chips.push(chip("no reward (axis not recorded)")); } } else if (msg.reward_status === "PENDING" && msg.response_id) { chips.push(chip("reward: pending")); } return <>{chips}; } function chip(text, klass = "") { return {text}; } // "explicit_positive" -> "explicit", "inferred_negative" -> "inferred" function tierLabel(category) { if (!category) return "?"; return String(category).split("_")[0]; } // ---------- Icons ---------- function ThumbUpIcon() { return ( ); } function ThumbDownIcon() { return ( ); } function CopyIcon() { return ( ); } function RegenIcon() { return ( ); }