SPB Git

spb/search-box Public

Agentic web research engine — hypotheses, verbatim evidence, contradictions, sourced answers streamed live. Claude Opus 5 + Firecrawl + PostgreSQL.

TypeScript 76.9% CSS 18.7% SQL 2.1% JavaScript 1.8% Shell 0.5%
2.5 KB · 85 lines tsx
Raw Blame History
1/**2 * Search-box.ai3 * Author: Simon-Pierre Boucher4 * Contact: contact@spboucher.ai5 * File: apps/web/components/AnswerPanel.tsx6 * Description: Streaming answer renderer — sanitized markdown, citation links, copy action.7 */89"use client";1011import { useMemo, useState } from "react";12import { marked } from "marked";13import DOMPurify from "dompurify";14import { Pastille } from "./Pastille";1516export function AnswerPanel({17  markdown,18  done,19  live20}: {21  markdown: string;22  done: boolean;23  live: boolean;24}) {25  const [copied, setCopied] = useState(false);2627  const html = useMemo(() => {28    if (!markdown) return "";29    const raw = marked.parse(markdown, { async: false, gfm: true });30    const safe = DOMPurify.sanitize(raw, { USE_PROFILES: { html: true } });31    // Citations are mechanical [n] markers from state — link them to the sources board.32    return safe.replace(33      /\[(\d{1,3})\]/g,34      (_m, n: string) => `<sup class="cite"><a href="#src-${n}" title="source ${n}">[${n}]</a></sup>`35    );36  }, [markdown]);3738  async function copy() {39    try {40      await navigator.clipboard.writeText(markdown);41      setCopied(true);42      setTimeout(() => setCopied(false), 1600);43    } catch {44      // clipboard unavailable — ignore45    }46  }4748  return (49    <div className="answer-panel">50      <div className="answer-head">51        <Pastille kind="synthesis" size="sm" spinning={markdown.length > 0 && !done} />52        <span className="lbl">{done ? "final answer" : markdown ? "writing…" : "answer"}</span>53        {done && (54          <button className="btn-ghost" onClick={() => void copy()}>55            {copied ? "copied ✓" : "copy markdown"}56          </button>57        )}58      </div>59      <div className="answer-body">60        {!markdown ? (61          <div className="answer-waiting">62            <p>63              {live64                ? "The answer is written here, token by token, once the evidence base is ready."65                : "No answer was produced for this session."}66            </p>67            {live && (68              <>69                <span className="skel" style={{ width: "82%" }} />70                <span className="skel" style={{ width: "95%" }} />71                <span className="skel" style={{ width: "64%" }} />72              </>73            )}74          </div>75        ) : (76          <>77            <div className="answer-md" dangerouslySetInnerHTML={{ __html: html }} />78            {!done && <span className="caret" aria-hidden />}79          </>80        )}81      </div>82    </div>83  );84}85