"use client"; import { useId, useState } from "react"; import { CheckIcon, CopyIcon, PencilIcon, RotateCwIcon } from "lucide-react"; import { Markdown } from "yt-dlp-transcript-common/components/Markdown"; import type { UiMessage } from "../lib/askConversation"; import { CitationSources, linkifyCitations, makeCitationLink } from "./citations"; import { PipelineStatus } from "./PipelineStatus"; function CopyButton({ text }: { text: string }) { const [state, setState] = useState<"idle" | "copied" | "failed">("idle"); const copy = async () => { try { // navigator.clipboard is undefined on insecure origins — surface a failure // instead of silently no-op'ing (the old optional-chain swallowed it). if (!navigator.clipboard) throw new Error("clipboard unavailable"); await navigator.clipboard.writeText(text); setState("copied"); } catch { setState("failed"); } setTimeout(() => setState("idle"), 1500); }; return ( ); } // One chat message. Assistant turns show the live pipeline while gathering, // stream as plain text with a caret (so partial Markdown never flickers), then // re-render as Markdown once complete. Sources cite the videos the answer drew // from, with the searches that found them. export function MessageBubble({ message, markdownOn, onRetry, onEdit, onOpenCitation, }: { message: UiMessage; markdownOn: boolean; onRetry?: () => void; onEdit?: () => void; // Open the transcript modal seeked to a cited line. Undefined in hub /ask // (no PlayerProvider), where citations fall back to scroll-to-source. onOpenCitation?: (slug: string, seconds?: number) => void; }) { const isUser = message.role === "user"; // Only true streaming shows the caret bubble; the pre-token "answering" state is // covered solely by PipelineStatus so there aren't two "it's coming" indicators. const streaming = message.phase === "streaming"; const settled = message.phase === "done" || message.phase === "stopped"; const emptyAnswer = !isUser && message.phase === "done" && !message.error && !message.content; const renderMarkdown = !isUser && markdownOn && !message.error && settled && !!message.content; const nonFetchSteps = message.searchSteps?.filter((s) => s.kind !== "fetch") ?? []; // Keep the "Searched:" recap on stopped/errored turns too, so stopping or a // failure after searching doesn't erase what was looked up. const showSearched = !isUser && (settled || message.error) && nonFetchSteps.length > 0; // A completed answer can be regenerated; a failed/stopped/empty one retried. const canRerun = !isUser && !!onRetry && (message.error || message.phase === "stopped" || emptyAnswer || (message.phase === "done" && !!message.content)); const rerunLabel = message.phase === "done" && message.content ? "Regenerate" : "Retry"; // Stable per-message id so citation anchors don't collide across messages. const cid = useId().replace(/:/g, ""); const sources = message.sources ?? []; const sourceCount = sources.length; const CitationLink = makeCitationLink(cid, sources, onOpenCitation); return (
{!isUser && } {(message.content || streaming) && (
{renderMarkdown ? ( {linkifyCitations(message.content, sourceCount, cid)} ) : ( {message.content} {streaming && } )}
)} {emptyAnswer && (message.finishReason === "safety" || message.blockReason ? (

The model returned no answer — blocked {message.blockReason ? ` (${message.blockReason})` : " for safety"}. Try rephrasing.

) : (

No answer was returned — retry, or rephrase your question.

))} {!isUser && !!message.content && message.finishReason === "length" && (

⚠ Cut off at the model's output limit. Raise{" "} Max answer length in settings, or turn on Report mode for long reports.

)} {showSearched && (

Searched: {nonFetchSteps.map((s) => s.query).join(" · ")}

)} {isUser && onEdit && ( )} {!isUser && settled && message.content && (
{message.model && ( {message.model} )}
)} {canRerun && (
{rerunLabel === "Retry" && (message.sources?.length ?? 0) > 0 && ( reuses the excerpts already found )}
)} {!isUser && message.sources && message.sources.length > 0 && ( )}
); }