File size: 3,158 Bytes
65d68f5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
"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>
  );
}