"use client"; import { useEffect, useRef, useState, type ReactNode } from "react"; import "./stream-reply.css"; /** * Stream Reply * An assistant answer arriving token by token. Each word settles in from a soft blur instead of * popping, code types into its block with highlighting already in place, a glowing caret rides * the end of the text, and you can stop it mid-sentence and regenerate. */ export type Block = | { type: "p"; text: string } | { type: "list"; items: string[] } | { type: "code"; lang: string; code: { t: string; k?: "kw" | "str" | "fn" | "num" | "com" }[] }; type Props = { blocks: Block[]; question?: string; tokensPerSecond?: number; theme?: "paper" | "night"; motion?: "full" | "reduced"; className?: string }; /** Split blocks into tokens: words for prose, small chunks for code. */ function tokenise(blocks: Block[]) { const out: { b: number; i: number; j: number; text: string }[] = []; blocks.forEach((bl, b) => { if (bl.type === "p") bl.text.split(/(?<=\s)/).forEach((w, j) => out.push({ b, i: 0, j, text: w })); else if (bl.type === "list") bl.items.forEach((it, i) => it.split(/(?<=\s)/).forEach((w, j) => out.push({ b, i, j, text: w }))); else bl.code.forEach((c, i) => c.t.match(/.{1,4}/gs)!.forEach((ch, j) => out.push({ b, i, j, text: ch }))); }); return out; } export function StreamReply({ blocks, question, tokensPerSecond = 38, theme = "paper", motion = "full", className = "" }: Props) { const tokens = useRef(tokenise(blocks)).current; const index = useRef(new Map(tokens.map((t, k) => [`${t.b}.${t.i}.${t.j}`, k]))).current; const stopped = useRef(false); const [n, setN] = useState(0); const [state, setState] = useState<"streaming" | "stopped" | "done">("streaming"); const [run, setRun] = useState(0); const [rate, setRate] = useState(0); useEffect(() => { const reduced = motion === "reduced" || matchMedia("(prefers-reduced-motion: reduce)").matches; if (reduced) { setN(tokens.length); setState("done"); return; } setN(0); setState("streaming"); stopped.current = false; let i = 0, alive = true; const t0 = performance.now(); const step = () => { if (!alive || stopped.current) return; // a little burstiness, like a real stream i = Math.min(tokens.length, i + 1 + (Math.random() < 0.25 ? 1 : 0)); setN(i); setRate(Math.round(i / Math.max(0.2, (performance.now() - t0) / 1000))); if (i >= tokens.length) return setState("done"); timer = setTimeout(step, (1000 / tokensPerSecond) * (0.6 + Math.random() * 0.9)); }; let timer = setTimeout(step, 450); return () => { alive = false; clearTimeout(timer); }; }, [run, tokens, tokensPerSecond, motion]); const stop = () => { stopped.current = true; setState("stopped"); }; const visible = (b: number, i: number, j: number) => (index.get(`${b}.${i}.${j}`) ?? Infinity) < n; const last = tokens[n - 1]; const caret = (b: number, i: number, j: number): ReactNode => state === "streaming" && last && last.b === b && last.i === i && last.j === j ? : null; return (
{question}
}{bl.text.split(/(?<=\s)/).map((w, j) => visible(b, 0, j) ? ( {w} {caret(b, 0, j)} ) : null, )}
); if (bl.type === "list") return (
{bl.code.map((c, i) =>
c.t.match(/.{1,4}/gs)!.map((ch, j) =>
visible(b, i, j) ? (
{ch}
{caret(b, i, j)}
) : null,
),
)}
{state === "stopped" ? "Response stopped." : "Response complete."}
}