import { useLayoutEffect, useRef, useState } from "react"; import { ChevronDown, ChevronUp } from "lucide-react"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import { MARKDOWN_COMPONENTS } from "@/client/components/Markdown"; type Props = { text: string; }; /** * Collapsed-state max height in px. Roughly 9 lines of body text — enough * to convey the shape of an answer without dominating the page when four * models are stacked. */ const COLLAPSED_MAX_PX = 240; /** * Render an LLM's markdown answer with explicit per-element Tailwind classes. * * Long answers collapse to ~12 lines with a fade-out gradient and a * "Read more" toggle so a side-by-side comparison of four models stays * scannable. We measure the rendered scroll height to decide whether the * toggle is needed. * * Anchor URLs are sanitized to http(s) only — LLMs can be coaxed into * emitting `javascript:` payloads. */ export function MarkdownAnswer({ text }: Props) { const contentRef = useRef(null); const [expanded, setExpanded] = useState(false); const [needsCollapse, setNeedsCollapse] = useState(false); const { thinking, body } = extractThinkingBlocks(text); const normalized = normalizeLlmMarkdown(body); useLayoutEffect(() => { const el = contentRef.current; if (!el) return; // scrollHeight reflects natural content height even when overflow is // clipped by max-h, so we can detect overflow without toggling state. setNeedsCollapse(el.scrollHeight > COLLAPSED_MAX_PX + 8); }, [normalized]); if (normalized.trim().length === 0 && thinking.length === 0) { return (

Model returned an empty response.

); } const isCollapsed = needsCollapse && !expanded; return (
{thinking.map((block, index) => ( ))} {normalized.trim().length > 0 ? (
{normalized}
{isCollapsed ? (
) : null}
) : null} {needsCollapse ? ( ) : null}
); } function ThinkingBlock({ text }: { text: string }) { return (
Model Thinking
        {text}
      
); } /** * Reasoning models (e.g. Perplexity sonar-reasoning-pro) wrap their chain of * thought in `...` tags inline with the answer. Pull those out * so we can render them in a separate, collapsible block. * * Tolerates an unclosed final `` (e.g. from a truncated stream) by * treating everything after it as a thinking block. */ function extractThinkingBlocks(text: string): { thinking: string[]; body: string; } { const thinking: string[] = []; let body = text; body = body.replace(/([\s\S]*?)<\/think>/gi, (_, inner: string) => { thinking.push(inner.trim()); return ""; }); body = body.replace(/([\s\S]*)$/i, (_, inner: string) => { thinking.push(inner.trim()); return ""; }); return { thinking, body }; } /** * Fix a class of malformed markdown we see from LLM responses: a list marker * (`-`, `*`, `+`, or `1.`) on a line by itself, followed by a blank line, * followed by the actual item content as a separate paragraph. Default * markdown correctly renders that as an empty bullet + detached paragraph, * which looks broken. Collapse the blank line so the marker and content * form a proper list item. */ function normalizeLlmMarkdown(text: string): string { return text.replace( /^([ \t]*)([-*+]|\d+\.)[ \t]*\r?\n[ \t]*\r?\n(?=\S)(?![ \t]*(?:[-*+]|\d+\.)[ \t])/gm, "$1$2 ", ); }