| "use client"; |
|
|
| import type { ChatTurn } from "@/lib/chatStore"; |
| import type { SourceDoc } from "@/lib/types"; |
| import { GroundingPill } from "./GroundingPill"; |
| import { SourceChips } from "./SourceChips"; |
| import { useState, type ReactNode } from "react"; |
| import clsx from "clsx"; |
| import ReactMarkdown from "react-markdown"; |
|
|
| export function UserMessage({ text }: { text: string }) { |
| return ( |
| <div className="flex justify-end animate-fade-up"> |
| <div className="max-w-[82%] chat-bubble-user"> |
| <span className="whitespace-pre-wrap">{text}</span> |
| </div> |
| </div> |
| ); |
| } |
|
|
| export function AssistantMessage({ |
| turn, |
| onOpenSource, |
| }: { |
| turn: ChatTurn; |
| onOpenSource?: (doc: SourceDoc, index: number) => void; |
| }) { |
| const r = turn.response; |
| const [copied, setCopied] = useState(false); |
| if (!r) return null; |
|
|
| const copy = async () => { |
| try { |
| await navigator.clipboard.writeText(r.answer); |
| setCopied(true); |
| setTimeout(() => setCopied(false), 1500); |
| } catch {} |
| }; |
|
|
| const sources = r.source_docs ?? []; |
| const rejected = r._grounding_status === "rejected_low_similarity"; |
|
|
| |
| |
| const renderWithCitations = (text: string): ReactNode[] => { |
| if (!sources.length || !text) return [text]; |
| const out: ReactNode[] = []; |
| const re = /\[(\d{1,3})\]/g; |
| let last = 0; |
| let m: RegExpExecArray | null; |
| let key = 0; |
| while ((m = re.exec(text)) !== null) { |
| const idx = parseInt(m[1], 10); |
| if (idx >= 1 && idx <= sources.length) { |
| if (m.index > last) out.push(text.slice(last, m.index)); |
| const doc = sources[idx - 1]; |
| out.push( |
| <button |
| key={`cite-${key++}`} |
| onClick={() => onOpenSource?.(doc, idx - 1)} |
| className="inline-flex items-center justify-center align-baseline mx-0.5 px-1.5 min-w-[18px] h-[18px] rounded-full bg-amber/15 text-amber text-[11px] font-mono font-semibold hover:bg-amber hover:text-canvas transition-colors" |
| title={`View source: ${doc.name}`} |
| > |
| {idx} |
| </button> |
| ); |
| last = m.index + m[0].length; |
| } |
| } |
| if (last < text.length) out.push(text.slice(last)); |
| return out.length ? out : [text]; |
| }; |
|
|
| |
| const transformChildren = (children: ReactNode): ReactNode => { |
| if (typeof children === "string") return renderWithCitations(children); |
| if (Array.isArray(children)) |
| return children.map((c, i) => |
| typeof c === "string" ? ( |
| <span key={i}>{renderWithCitations(c)}</span> |
| ) : ( |
| c |
| ) |
| ); |
| return children; |
| }; |
|
|
| return ( |
| <div className="flex items-start gap-3 animate-fade-up"> |
| <Avatar /> |
| <article className="chat-bubble-assistant flex-1 min-w-0 max-w-[88%]"> |
| {/* Header: grounding + actions */} |
| <header className="flex items-center justify-between gap-3 mb-3"> |
| <GroundingPill status={r._grounding_status} /> |
| <div className="flex items-center gap-1"> |
| <button |
| onClick={copy} |
| className="text-micro text-ink-50 hover:text-ink hover:bg-glass rounded-md px-2 py-1 transition-colors flex items-center gap-1" |
| title="Copy answer" |
| > |
| {copied ? ( |
| <> |
| <svg width="11" height="11" viewBox="0 0 11 11" fill="none"> |
| <path d="M2 6 4.5 8.5 9 3" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" /> |
| </svg> |
| copied |
| </> |
| ) : ( |
| <> |
| <svg width="11" height="11" viewBox="0 0 11 11" fill="none"> |
| <rect x="2" y="2" width="6" height="6" rx="1" stroke="currentColor" strokeWidth="1.2" /> |
| <rect x="3.5" y="3.5" width="6" height="6" rx="1" stroke="currentColor" strokeWidth="1.2" /> |
| </svg> |
| copy |
| </> |
| )} |
| </button> |
| </div> |
| </header> |
|
|
| {} |
| <div className="text-ink leading-[1.65] markdown-body"> |
| <ReactMarkdown |
| components={{ |
| p: ({ children }) => ( |
| <p className="mb-3 last:mb-0">{transformChildren(children)}</p> |
| ), |
| ul: ({ children }) => ( |
| <ul className="list-disc pl-5 mb-3 last:mb-0 space-y-1 marker:text-amber/70"> |
| {children} |
| </ul> |
| ), |
| ol: ({ children }) => ( |
| <ol className="list-decimal pl-5 mb-3 last:mb-0 space-y-1 marker:text-amber/70 marker:font-mono"> |
| {children} |
| </ol> |
| ), |
| li: ({ children }) => <li className="pl-1">{transformChildren(children)}</li>, |
| strong: ({ children }) => ( |
| <strong className="font-semibold text-ink">{children}</strong> |
| ), |
| em: ({ children }) => ( |
| <em className="serif-italic text-amber-soft">{children}</em> |
| ), |
| code: ({ children }) => ( |
| <code |
| className="font-mono text-amber px-1.5 py-0.5 rounded text-[0.92em]" |
| style={{ background: "rgba(255,181,69,0.10)" }} |
| > |
| {children} |
| </code> |
| ), |
| pre: ({ children }) => ( |
| <pre |
| className="font-mono text-caption p-3 rounded-md overflow-x-auto my-3 leading-[1.55]" |
| style={{ |
| background: "rgba(0,0,0,0.28)", |
| boxShadow: "0 0 0 1px rgba(255,255,255,0.06)", |
| }} |
| > |
| {children} |
| </pre> |
| ), |
| h1: ({ children }) => ( |
| <h3 className="text-display-sm mt-4 mb-2">{children}</h3> |
| ), |
| h2: ({ children }) => ( |
| <h3 className="text-body-strong text-ink mt-3 mb-1.5">{children}</h3> |
| ), |
| h3: ({ children }) => ( |
| <h4 className="text-body-strong text-ink mt-3 mb-1.5">{children}</h4> |
| ), |
| a: ({ href, children }) => ( |
| <a |
| href={href} |
| className="text-amber underline decoration-amber/40 underline-offset-2 hover:text-amber-soft hover:decoration-amber transition-colors" |
| target="_blank" |
| rel="noopener noreferrer" |
| > |
| {children} |
| </a> |
| ), |
| blockquote: ({ children }) => ( |
| <blockquote |
| className="border-l-2 border-amber/50 pl-4 my-3 text-ink-70 serif-italic" |
| style={{ fontSize: "0.98em" }} |
| > |
| {children} |
| </blockquote> |
| ), |
| hr: () => <hr className="my-4 border-0 h-px bg-glass-border" />, |
| }} |
| > |
| {r.answer} |
| </ReactMarkdown> |
| </div> |
|
|
| {} |
| {sources.length > 0 && ( |
| <SourceChips |
| docs={sources} |
| onOpenSource={onOpenSource} |
| rejected={rejected} |
| threshold={r._threshold} |
| /> |
| )} |
|
|
| {} |
| <SpecSheet response={r} /> |
| </article> |
| </div> |
| ); |
| } |
|
|
| function SpecSheet({ response: r }: { response: NonNullable<ChatTurn["response"]> }) { |
| const [open, setOpen] = useState(false); |
| return ( |
| <footer className="mt-4 pt-3 border-t border-glass-border"> |
| <button |
| onClick={() => setOpen((o) => !o)} |
| className="w-full flex items-center justify-between text-micro text-ink-50 font-mono uppercase tracking-[0.10em] hover:text-ink transition-colors" |
| aria-expanded={open} |
| > |
| <span className="flex items-baseline gap-1.5"> |
| <span>total</span> |
| <span className="text-amber">{r.total_seconds.toFixed(2)}s</span> |
| </span> |
| <span className="flex items-baseline gap-3"> |
| <span className="hidden sm:inline"> |
| {open ? "Hide diagnostics" : "Show diagnostics"} |
| </span> |
| <span |
| className={clsx( |
| "transition-transform duration-200 inline-block", |
| open && "rotate-180" |
| )} |
| aria-hidden |
| > |
| <svg width="9" height="9" viewBox="0 0 9 9" fill="none"> |
| <path |
| d="m2 3 2.5 2.5L7 3" |
| stroke="currentColor" |
| strokeWidth="1.4" |
| strokeLinecap="round" |
| /> |
| </svg> |
| </span> |
| </span> |
| </button> |
| {open && ( |
| <div className="mt-3 grid grid-cols-3 sm:grid-cols-5 gap-x-5 gap-y-2 text-micro text-ink-50 font-mono uppercase tracking-[0.10em] animate-fade-in"> |
| <Spec label="top·sim" value={r._top_similarity?.toFixed(3) ?? "—"} /> |
| <Spec label="rerank" value={r._top_rerank_score?.toFixed(2) ?? "—"} /> |
| <Spec label="anchor" value={r._anchor_score?.toFixed(3) ?? "—"} /> |
| <Spec label="retrieve" value={`${r.retrieve_seconds.toFixed(2)}s`} /> |
| <Spec label="inference" value={`${r.inference_seconds.toFixed(2)}s`} /> |
| </div> |
| )} |
| </footer> |
| ); |
| } |
|
|
| export function ErrorMessage({ turn }: { turn: ChatTurn }) { |
| const e = turn.error; |
| if (!e) return null; |
| return ( |
| <div className="flex items-start gap-3 animate-fade-up"> |
| <Avatar tone="err" /> |
| <article |
| className="flex-1 min-w-0 max-w-[88%] rounded-2xl rounded-tl-sm px-5 py-4" |
| style={{ |
| background: "rgba(255, 122, 122, 0.06)", |
| boxShadow: "0 0 0 1px rgba(255, 122, 122, 0.28)", |
| }} |
| > |
| <div className="text-micro uppercase tracking-[0.12em] text-status-err font-mono"> |
| error · http {e.status || "—"} |
| </div> |
| <div className="text-body-strong text-ink mt-1.5">{e.message}</div> |
| {e.status === 502 && ( |
| <div className="text-caption text-ink-70 mt-2"> |
| Backend not reachable. Check the HF Space stage. |
| </div> |
| )} |
| </article> |
| </div> |
| ); |
| } |
|
|
| function Spec({ label, value }: { label: string; value: string }) { |
| return ( |
| <div className="flex flex-col"> |
| <span className="text-ink-50">{label}</span> |
| <span className="text-ink mt-0.5 normal-case tracking-normal">{value}</span> |
| </div> |
| ); |
| } |
|
|
| function Avatar({ tone = "amber" }: { tone?: "amber" | "err" }) { |
| const bg = |
| tone === "err" |
| ? "linear-gradient(135deg, rgba(255,122,122,0.95), rgba(195,70,70,1))" |
| : "linear-gradient(135deg, rgba(255,181,69,0.95), rgba(214,138,31,1))"; |
| const ring = |
| tone === "err" |
| ? "0 0 0 1px rgba(255,122,122,0.45), 0 0 14px -2px rgba(255,122,122,0.4)" |
| : "0 0 0 1px rgba(255,181,69,0.45), 0 0 14px -2px rgba(255,181,69,0.45)"; |
| return ( |
| <div |
| className="shrink-0 w-7 h-7 rounded-md flex items-center justify-center mt-1" |
| style={{ background: bg, boxShadow: ring }} |
| > |
| <svg width="14" height="14" viewBox="0 0 14 14" fill="none"> |
| <path |
| d="M2.5 7.5 4.8 9.8l6-6.6" |
| stroke="#0a0b10" |
| strokeWidth="1.8" |
| strokeLinecap="round" |
| strokeLinejoin="round" |
| /> |
| </svg> |
| </div> |
| ); |
| } |
|
|