import { useState, useRef, useEffect, useCallback } from "react"; import { Streamdown } from "streamdown"; import { createMathPlugin } from "@streamdown/math"; import { Pencil, X, Check, RotateCcw, Copy, ClipboardCheck, } from "lucide-react"; import { useLLM } from "../hooks/useLLM"; import { ReasoningBlock } from "./ReasoningBlock"; import type { ChatMessage } from "../hooks/LLMContext"; const math = createMathPlugin({singleDollarTextMath: true}) interface MessageBubbleProps { msg: ChatMessage; index: number; isStreaming?: boolean; thinkingSeconds?: number; isGenerating: boolean; } // LaTeX commands to auto-wrap with $…$ when found outside math context. // `args` is the number of consecutive {…} groups the command consumes. const MATH_COMMANDS: { prefix: string; args: number }[] = [ { prefix: "\\boxed{", args: 1 }, { prefix: "\\text{", args: 1 }, { prefix: "\\textbf{", args: 1 }, { prefix: "\\mathbf{", args: 1 }, { prefix: "\\mathrm{", args: 1 }, { prefix: "\\frac{", args: 2 }, ]; /** Advance past a single `{…}` group (including nested braces). */ function skipBraceGroup(content: string, start: number): number { let depth = 1; let j = start; while (j < content.length && depth > 0) { if (content[j] === "{") depth++; else if (content[j] === "}") depth--; j++; } return j; } function wrapLatexMath(content: string): string { let result = ""; let i = 0; // Track math context: null = not in math, "$" = inline, "$$" = display let mathContext: null | "$" | "$$" = null; while (i < content.length) { const cmd = !mathContext ? MATH_COMMANDS.find((c) => content.startsWith(c.prefix, i)) : undefined; if (cmd) { let j = skipBraceGroup(content, i + cmd.prefix.length); for (let a = 1; a < cmd.args; a++) { if (content[j] === "{") { j = skipBraceGroup(content, j + 1); } } const expr = content.slice(i, j); result += "$" + expr + "$"; i = j; } else if (content[i] === "$") { // Check for $$ (display math) vs $ (inline math) const isDouble = content[i + 1] === "$"; const token = isDouble ? "$$" : "$"; if (mathContext === token) { mathContext = null; // closing delimiter } else if (!mathContext) { mathContext = token; // opening delimiter } result += token; i += token.length; } else { result += content[i]; i++; } } return result; } function prepareForMathDisplay(content: string): string { return wrapLatexMath( content .replace(/(?(null); const handleCopy = useCallback(async () => { await navigator.clipboard.writeText(msg.content); setCopied(true); setTimeout(() => setCopied(false), 2000); }, [msg.content]); useEffect(() => { if (isEditing && textareaRef.current) { textareaRef.current.focus(); textareaRef.current.style.height = "auto"; textareaRef.current.style.height = textareaRef.current.scrollHeight + "px"; } }, [isEditing]); const handleEdit = useCallback(() => { setEditValue(msg.content); setIsEditing(true); }, [msg.content]); const handleCancel = useCallback(() => { setIsEditing(false); setEditValue(msg.content); }, [msg.content]); const handleSave = useCallback(() => { const trimmed = editValue.trim(); if (!trimmed) return; setIsEditing(false); editMessage(index, trimmed); }, [editValue, editMessage, index]); const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { if (e.key === "Escape") handleCancel(); if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleSave(); } }, [handleCancel, handleSave], ); if (isEditing) { return (
No response
) : null}