DocDoeAI / src /components /study-chat /PlainAssistantMessage.tsx
asnannp's picture
deploy: sync backend to Space root (learn-lesson HF cache fix)
65d68f5
Raw History Blame Contribute Delete
3.16 kB
"use client";
import { useState } from "react";
import ReactMarkdown from "react-markdown";
import rehypeKatex from "rehype-katex";
import remarkGfm from "remark-gfm";
import remarkMath from "remark-math";
import { Copy } from "lucide-react";
import { markdownComponents } from "./StudyCardPrimitives";
import { cleanLegacySourceSafetyText } from "./utils";
// Markdown tokens would otherwise be read aloud as raw symbols ("hash", "asterisk").
function plainAnnouncementText(text: string) {
return text
.replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1")
.replace(/[*_`>#]+/g, " ")
.replace(/\s+/g, " ")
.trim();
}
/**
* A streamed answer grows one token at a time. Announcing that raw string would
* make a screen reader repeat itself on every token, so we announce only the
* sentences that are already complete. Each finished sentence becomes its own
* node in the live region below, so assistive tech reads just the new sentence
* and never a half-typed word.
*/
function settledSentences(text: string): string[] {
const sentences: string[] = [];
let start = 0;
const boundary = /[.!?](?=\s|$)|\n{2,}/g;
let match: RegExpExecArray | null;
while ((match = boundary.exec(text)) !== null) {
const end = match.index + match[0].length;
const sentence = plainAnnouncementText(text.slice(start, end));
if (sentence) sentences.push(sentence);
start = end;
}
return sentences;
}
export function PlainAssistantMessage({ text, streaming = false }: { text: string; streaming?: boolean }) {
const [copied, setCopied] = useState(false);
const displayText = cleanLegacySourceSafetyText(text);
const announcement = streaming ? settledSentences(displayText) : [];
async function copyText() {
try {
await navigator.clipboard.writeText(displayText);
setCopied(true);
window.setTimeout(() => setCopied(false), 1200);
} catch {
setCopied(false);
}
}
return (
<div className="animate-msg-assistant max-w-[min(48rem,100%)] rounded-[1.25rem] border border-white/[0.085] bg-white/[0.035] px-4 py-3 text-sm leading-7 text-white/76 shadow-[0_14px_50px_rgba(0,0,0,0.2)]">
{streaming ? (
<div role="status" aria-live="polite" aria-atomic="false" className="sr-only">
{announcement.map((sentence, index) => (
<span key={index}>{sentence} </span>
))}
</div>
) : null}
<div className={streaming ? "kb-stream-cursor" : undefined}>
<ReactMarkdown remarkPlugins={[remarkGfm, remarkMath]} rehypePlugins={[rehypeKatex]} components={markdownComponents}>
{displayText || (streaming ? " " : "")}
</ReactMarkdown>
</div>
{!streaming ? (
<button
type="button"
onClick={copyText}
className="mt-3 inline-flex items-center gap-1.5 rounded-full border border-white/[0.08] px-2.5 py-1 text-[11px] font-semibold text-white/44 transition hover:text-white/74 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/20"
>
<Copy className="size-3" />
{copied ? "Copied" : "Copy"}
</button>
) : null}
</div>
);
}