← back to Open Seo

src/client/features/ai-search/components/MarkdownAnswer.tsx

169 lines

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<HTMLDivElement>(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 (
      <p className="text-sm text-base-content/60 italic">
        Model returned an empty response.
      </p>
    );
  }

  const isCollapsed = needsCollapse && !expanded;

  return (
    <div className="text-sm leading-relaxed">
      {thinking.map((block, index) => (
        <ThinkingBlock key={index} text={block} />
      ))}

      {normalized.trim().length > 0 ? (
        <div className="relative">
          <div
            ref={contentRef}
            style={
              isCollapsed ? { maxHeight: `${COLLAPSED_MAX_PX}px` } : undefined
            }
            className={isCollapsed ? "overflow-hidden" : undefined}
          >
            <ReactMarkdown
              remarkPlugins={[remarkGfm]}
              components={MARKDOWN_COMPONENTS}
            >
              {normalized}
            </ReactMarkdown>
          </div>

          {isCollapsed ? (
            <div
              aria-hidden
              className="pointer-events-none absolute inset-x-0 bottom-0 h-16 bg-gradient-to-t from-base-100 to-transparent"
            />
          ) : null}
        </div>
      ) : null}

      {needsCollapse ? (
        <button
          type="button"
          onClick={() => setExpanded((prev) => !prev)}
          className="mt-2 inline-flex items-center gap-1 text-xs font-medium text-primary hover:underline"
          aria-expanded={expanded}
        >
          {expanded ? (
            <>
              <ChevronUp className="size-3.5" />
              Show less
            </>
          ) : (
            <>
              <ChevronDown className="size-3.5" />
              Read more
            </>
          )}
        </button>
      ) : null}
    </div>
  );
}

function ThinkingBlock({ text }: { text: string }) {
  return (
    <details
      open
      className="group mb-3 rounded-lg border border-base-300 bg-base-200/40"
    >
      <summary className="flex cursor-pointer list-none items-center gap-2 px-3 py-2 text-xs font-medium text-base-content/70 hover:text-base-content">
        <ChevronDown className="size-3.5 transition-transform group-open:rotate-180" />
        Model Thinking
      </summary>
      <pre className="overflow-x-auto whitespace-pre-wrap break-words rounded-b-lg border-t border-base-300 bg-base-200/60 px-3 py-2.5 text-xs font-mono text-base-content/80">
        {text}
      </pre>
    </details>
  );
}

/**
 * Reasoning models (e.g. Perplexity sonar-reasoning-pro) wrap their chain of
 * thought in `<think>...</think>` tags inline with the answer. Pull those out
 * so we can render them in a separate, collapsible block.
 *
 * Tolerates an unclosed final `<think>` (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(/<think>([\s\S]*?)<\/think>/gi, (_, inner: string) => {
    thinking.push(inner.trim());
    return "";
  });

  body = body.replace(/<think>([\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 ",
  );
}