d2l-ui / components /chat /Message.tsx
Berkkirik's picture
feat: streaming + multi-turn + view source + doc-scoped + UX fixes
df790cc
"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";
// Inline citation renderer: replaces [1] [2] markers with clickable badges.
// Numbers are 1-indexed against `sources` (clamped to length).
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];
};
// Wrap a markdown children array, expanding string nodes through citation regex.
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>
{/* Answer */}
<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 */}
{sources.length > 0 && (
<SourceChips
docs={sources}
onOpenSource={onOpenSource}
rejected={rejected}
threshold={r._threshold}
/>
)}
{/* Spec sheet — collapsed by default */}
<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>
);
}