"use client"; import { ArrowUpRightIcon, CheckIcon, CopyIcon, CornerDownRightIcon, RefreshCwIcon, SparklesIcon, ThumbsDownIcon, ThumbsUpIcon, } from "lucide-react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { useEffect, useMemo, useState } from "react"; import { PreviewCard, PreviewCardPopup, PreviewCardTrigger, } from "@/components/ui/preview-card"; import { cn } from "@/lib/utils"; // Stays false until mounted so the server render and the first client render match. function useSafeReducedMotion() { const prefers = useReducedMotion(); const [mounted, setMounted] = useState(false); useEffect(() => setMounted(true), []); return mounted && !!prefers; } export interface ReplySource { title: string; domain: string; url?: string; snippet: string; } export interface StreamedReplyProps { question?: string; answer?: string; sources?: ReplySource[]; followUps?: string[]; speed?: number; onFollowUp?: (question: string) => void; onFeedback?: (value: "up" | "down" | null) => void; className?: string; } const defaultSources: ReplySource[] = [ { title: "Why sourdough rises slower in winter", domain: "crumbjournal.com", snippet: "Wild yeast activity roughly halves for every 8\u00a0°C drop, so a cold kitchen can double bulk fermentation time.", }, { title: "Dough temperature, explained", domain: "bakersfield.guide", snippet: "Aim for a final dough temperature of 24–26\u00a0°C. Use warmer water to compensate for a cool room or cold flour.", }, { title: "Proofing boxes vs. the oven-light trick", domain: "hearthandloaf.co", snippet: "An oven with only the light on sits around 27–30\u00a0°C, close to a proofing box for most home bakers.", }, ]; const defaultAnswer = `Your starter is probably fine. Wild yeast slows down a lot in the cold, so a chilly kitchen can easily double how long bulk fermentation takes [1]. Try mixing with warmer water so the dough lands around 25\u00a0°C [2], then let it rise somewhere snug: an oven with just the light on works surprisingly well [3]. Judge by the dough, not the clock: look for a 50–75% rise, a domed top and bubbles along the sides.`; const defaultFollowUps = [ "How do I know my starter is active?", "Can I proof overnight in the fridge?", ]; const easeOut = [0.23, 1, 0.32, 1] as const; const tints = [ "bg-orange-500", "bg-sky-500", "bg-emerald-500", "bg-violet-500", "bg-rose-500", ]; const press = "transition-[background-color,color,scale] duration-150 ease-out active:scale-[0.97]"; type Part = { kind: "text"; text: string } | { kind: "cite"; n: number }; type Token = { kind: "space" } | { kind: "word"; parts: Part[] }; function tokenize(answer: string): Token[][] { return answer .trim() .replace(/[ \t]+(\[\d+\])/g, "$1") .split(/\n\s*\n/) .map((para) => para .split(/([ \t\n]+)/) .filter(Boolean) .map((chunk): Token => { if (/^[ \t\n]+$/.test(chunk)) return { kind: "space" }; const parts = chunk .split(/(\[\d+\])/) .filter(Boolean) .map((part): Part => { const cite = /^\[(\d+)\]$/.exec(part); return cite ? { kind: "cite", n: Number(cite[1]) } : { kind: "text", text: part }; }); const last = parts.at(-1); const prev = parts.at(-2); if ( last?.kind === "text" && prev?.kind === "cite" && /^[.,;:!?]+$/.test(last.text) ) { let i = parts.length - 2; while (parts[i - 1]?.kind === "cite") i--; parts.splice(parts.length - 1, 1); const before = parts[i - 1]; if (before?.kind === "text") before.text += last.text; else parts.splice(i, 0, last); } return { kind: "word", parts }; }), ); } export function StreamedReply({ question = "My sourdough barely rose overnight. Is my starter dead?", answer = defaultAnswer, sources = defaultSources, followUps = defaultFollowUps, speed = 38, onFollowUp, onFeedback, className, }: StreamedReplyProps) { const reduce = useSafeReducedMotion(); const paragraphs = useMemo(() => tokenize(answer), [answer]); const total = useMemo( () => paragraphs.reduce((n, p) => n + p.length, 0), [paragraphs], ); const [run, setRun] = useState(0); const [phase, setPhase] = useState<"searching" | "streaming" | "done">( "searching", ); const [shown, setShown] = useState(0); useEffect(() => { if (reduce) { setShown(total); setPhase("done"); return; } setShown(0); setPhase("searching"); let timer: ReturnType; let count = 0; const tick = () => { count = Math.min(total, count + 1 + Math.round(Math.random() * 2)); setShown(count); if (count >= total) { setPhase("done"); return; } timer = setTimeout(tick, speed + Math.random() * speed); }; timer = setTimeout(() => { setPhase("streaming"); tick(); }, 1100); return () => clearTimeout(timer); }, [run, total, speed, reduce]); const plain = answer.replace(/\s?\[\d+\]/g, ""); const done = phase === "done"; let index = 0; return (

{question}

{sources.map((src, i) => ( !src.url && e.preventDefault()} role="listitem" transition={{ duration: 0.3, ease: easeOut, delay: 0.1 + i * 0.04 }} > {src.domain} {i + 1} {src.title} ))}
{/* An invisible copy of the full answer sizes the card from the first frame. */}
{paragraphs.map((tokens, pi) => (

{tokens.map((tok, ti) => tok.kind === "space" ? ( ) : ( {tok.parts.map((part, k) => part.kind === "cite" ? ( sources[part.n - 1] ? ( {part.n} ) : null ) : ( {part.text} ), )} ), )}

))}
{phase === "searching" ? (
{`Reading ${sources.length} sources…`}
{["w-full", "w-11/12", "w-3/5"].map((w) => ( ))}
) : ( paragraphs.map((tokens, pi) => (

{tokens.map((tok, ti) => { const at = index++; if (at >= shown) return null; const key = `${pi}-${ti}`; if (tok.kind === "space") return ; return ( {tok.parts.map((part, k) => { if (part.kind === "cite") { const src = sources[part.n - 1]; return src ? ( ) : null; } return ( {part.text} ); })} ); })} {phase === "streaming" && caretInParagraph(pi, paragraphs, shown) && ( )}

)) )}
setRun((r) => r + 1)} run={run} text={plain} /> {followUps.length > 0 && (
{followUps.map((q, i) => ( onFollowUp?.(q)} transition={{ duration: 0.25, ease: easeOut, delay: done ? 0.06 + i * 0.04 : 0, }} type="button" > {q} ))}
)}
); } function caretInParagraph(pi: number, paragraphs: Token[][], shown: number) { let start = 0; for (let i = 0; i < paragraphs.length; i++) { const end = start + paragraphs[i].length; if (shown <= end || i === paragraphs.length - 1) return i === pi; start = end; } return false; } const chip = "ms-0.5 inline-flex h-4 min-w-4 items-center justify-center rounded-full px-1 align-[2px] font-medium font-mono text-[10px] tabular-nums"; function Favicon({ domain, i }: { domain: string; i: number }) { return ( {domain[0]} ); } function Citation({ n, source }: { n: number; source: ReplySource }) { const reduce = useSafeReducedMotion(); return ( !source.url && e.preventDefault()} render={ } > {n} {source.domain} [{n}] {source.title} {source.snippet} ); } function Caret() { return ( ); } function Shimmer({ children }: { children: string }) { return ( {children} ); } function Actions({ text, run, onRegenerate, onFeedback, }: { text: string; run: number; onRegenerate: () => void; onFeedback?: (value: "up" | "down" | null) => void; }) { const reduce = useSafeReducedMotion(); const [copied, setCopied] = useState(false); const [voted, setVoted] = useState<{ run: number; v: "up" | "down" }>(); const vote = voted?.run === run ? voted.v : null; const [spins, setSpins] = useState(0); useEffect(() => { if (!copied) return; const t = setTimeout(() => setCopied(false), 1600); return () => clearTimeout(t); }, [copied]); const copy = async () => { try { await navigator.clipboard.writeText(text); } catch {} setCopied(true); }; const castVote = (v: "up" | "down") => { const next = vote === v ? null : v; setVoted(next ? { run, v: next } : undefined); onFeedback?.(next); }; const btn = cn( "relative grid size-8 place-items-center rounded-lg text-muted-foreground outline-none hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring", press, ); return (
{(["up", "down"] as const).map((v) => { const Icon = v === "up" ? ThumbsUpIcon : ThumbsDownIcon; const on = vote === v; return ( ); })} {vote && ( Thanks for the feedback )}
); }