import { useCallback, useEffect, useRef, useState } from 'react'; import { Zap, X, Plus, History, MessageSquare, Send, Trash2, } from 'lucide-react'; import { useChatStore } from '../../lib/chat-store'; import type { ChatMessage } from '../../lib/chat-store'; import { streamChat } from '../../lib/chat-api'; import { useNotebookStore } from '../../lib/notebook-store'; import { marked } from 'marked'; import markedKatex from 'marked-katex-extension'; import DOMPurify from 'dompurify'; import 'katex/dist/katex.min.css'; import '../../styles/chat-widget.css'; // ── Markdown rendering (marked + KaTeX + DOMPurify) ────── marked.setOptions({ breaks: true, gfm: true, }); marked.use(markedKatex({ throwOnError: false, nonStandard: true, })); // KaTeX generates SVG + spans with specific classes/attributes. // DOMPurify must allow these through. const KATEX_TAGS = [ 'math', 'semantics', 'mrow', 'mi', 'mo', 'mn', 'msup', 'msub', 'mfrac', 'munderover', 'mover', 'munder', 'msqrt', 'mroot', 'mtable', 'mtr', 'mtd', 'mspace', 'mtext', 'menclose', 'annotation', 'annotation-xml', ]; const KATEX_ATTRS = [ 'xmlns', 'encoding', 'mathvariant', 'displaystyle', 'scriptlevel', 'fence', 'stretchy', 'symmetric', 'lspace', 'rspace', 'linethickness', 'columnalign', 'rowalign', 'columnspacing', 'rowspacing', 'columnlines', 'rowlines', 'frame', 'framespacing', 'width', 'height', 'voffset', 'accent', 'accentunder', 'notation', 'minsize', 'maxsize', 'movablelimits', 'aria-hidden', 'focusable', 'role', 'tabindex', 'viewBox', 'preserveAspectRatio', 'd', 'fill', 'stroke', 'stroke-width', 'stroke-linecap', 'stroke-linejoin', 'transform', 'clip-path', ]; // LLMs often emit $$...$$ inline (e.g. "is:$$\frac{1}{s}$$\nNext") // but marked-katex-extension requires $$ on its own line for display mode. // This normalizer ensures $$ delimiters get their own lines. function normalizeDisplayMath(text: string): string { return text.replace(/\$\$([\s\S]*?)\$\$/g, (_match, inner: string) => { const trimmed = inner.trim(); return `\n$$\n${trimmed}\n$$\n`; }); } function renderMarkdown(text: string): string { const normalized = normalizeDisplayMath(text); const raw = marked.parse(normalized, { async: false }) as string; return DOMPurify.sanitize(raw, { ADD_TAGS: KATEX_TAGS, ADD_ATTR: [...KATEX_ATTRS, 'target', 'class', 'style'], }); } // ── Components ────────────────────────────────────────── function MessageBubble({ msg, isStreaming }: { msg: ChatMessage; isStreaming?: boolean }) { return (
{msg.role === 'assistant' ? ( <> {isStreaming && } ) : ( msg.text )}
); } function HistoryView({ onBack, }: { onBack: () => void; }) { const conversations = useChatStore((s) => s.conversations); const activeId = useChatStore((s) => s.activeConversationId); const setActive = useChatStore((s) => s.setActiveConversation); const deleteConv = useChatStore((s) => s.deleteConversation); const [pendingDeleteId, setPendingDeleteId] = useState(null); const pendingTimer = useRef | null>(null); const handleDelete = useCallback( (id: string, e: React.MouseEvent) => { e.stopPropagation(); if (pendingDeleteId === id) { // Second click — confirm if (pendingTimer.current) clearTimeout(pendingTimer.current); setPendingDeleteId(null); deleteConv(id); } else { // First click — arm if (pendingTimer.current) clearTimeout(pendingTimer.current); setPendingDeleteId(id); pendingTimer.current = setTimeout(() => setPendingDeleteId(null), 3000); } }, [pendingDeleteId, deleteConv], ); useEffect(() => { return () => { if (pendingTimer.current) clearTimeout(pendingTimer.current); }; }, []); if (conversations.length === 0) { return (
No conversations yet
); } return (
{conversations.map((c) => ( ))}
); } // ── Main Widget ───────────────────────────────────────── export default function ChatWidget() { const panelOpen = useChatStore((s) => s.panelOpen); const streaming = useChatStore((s) => s.streaming); const togglePanel = useChatStore((s) => s.togglePanel); const closePanel = useChatStore((s) => s.closePanel); const createConversation = useChatStore((s) => s.createConversation); const addUserMessage = useChatStore((s) => s.addUserMessage); const addAssistantMessage = useChatStore((s) => s.addAssistantMessage); const appendToLastAssistant = useChatStore((s) => s.appendToLastAssistant); const setStreaming = useChatStore((s) => s.setStreaming); const getActiveConversation = useChatStore((s) => s.getActiveConversation); const activeConversationId = useChatStore((s) => s.activeConversationId); // Notebook context from the notebook editor (if on a notebook page) const notebookId = useNotebookStore((s) => s.notebookId); const notebook = useNotebookStore((s) => s.notebook); const [input, setInput] = useState(''); const [statusText, setStatusText] = useState(''); const [showHistory, setShowHistory] = useState(false); const [reasoningText, setReasoningText] = useState(''); const [reasoningTime, setReasoningTime] = useState(0); const messagesEndRef = useRef(null); const inputRef = useRef(null); const abortRef = useRef(null); // Token batching: accumulate tokens in a ref, flush to state once per animation frame. // Without this, React 19 batches all rapid set() calls from the SSE loop into one // render at stream end — so the user sees nothing until the LLM finishes. const pendingTokensRef = useRef(''); const flushRafRef = useRef(0); const activeConv = getActiveConversation(); const messages = activeConv?.messages ?? []; // Auto-scroll on new messages — use direct scrollTop instead of scrollIntoView // because scrollIntoView walks up the DOM and scrolls ALL ancestors (including // the panel with overflow:hidden), which pushes the header off-screen. useEffect(() => { const container = messagesEndRef.current?.parentElement; if (container) { container.scrollTo({ top: container.scrollHeight, behavior: 'smooth' }); } }, [messages.length, streaming]); // Focus input when panel opens useEffect(() => { if (panelOpen && !showHistory) { setTimeout(() => inputRef.current?.focus(), 100); } }, [panelOpen, showHistory]); // Keyboard: Escape to close useEffect(() => { function handleEscape(e: KeyboardEvent) { if (e.key === 'Escape' && panelOpen) { closePanel(); } } document.addEventListener('keydown', handleEscape); return () => document.removeEventListener('keydown', handleEscape); }, [panelOpen, closePanel]); const handleSend = useCallback(async () => { const question = input.trim(); if (!question || streaming) return; // Ensure there's an active conversation if (!activeConversationId) { createConversation(); } setInput(''); addUserMessage(question); addAssistantMessage(''); setStreaming(true); setStatusText(''); setReasoningText(''); setReasoningTime(0); const abortController = new AbortController(); abortRef.current = abortController; const reasoningStart = Date.now(); try { const notebookCtx = notebookId && notebook ? { notebook_id: notebookId, title: notebook.metadata.title, engine: notebook.metadata.engine, } : null; for await (const evt of streamChat({ question, notebook: notebookCtx, signal: abortController.signal, })) { switch (evt.event) { case 'status': setStatusText((evt.data as { text: string }).text); break; case 'token': // Accumulate in ref; flush to Zustand once per animation frame pendingTokensRef.current += (evt.data as { text: string }).text; if (!flushRafRef.current) { flushRafRef.current = requestAnimationFrame(() => { if (pendingTokensRef.current) { appendToLastAssistant(pendingTokensRef.current); pendingTokensRef.current = ''; } flushRafRef.current = 0; }); setStatusText(''); } break; case 'reasoning': setReasoningText((prev) => prev + (evt.data as { text: string }).text); setReasoningTime(Math.round((Date.now() - reasoningStart) / 1000)); break; case 'error': appendToLastAssistant( `\n\n*Error: ${(evt.data as { text: string }).text}*`, ); break; case 'done': break; } } } catch (err) { if (err instanceof DOMException && err.name === 'AbortError') { // User cancelled — that's fine } else { appendToLastAssistant( `\n\n*Error: ${err instanceof Error ? err.message : 'Connection failed'}*`, ); } } finally { // Flush any remaining buffered tokens if (flushRafRef.current) { cancelAnimationFrame(flushRafRef.current); flushRafRef.current = 0; } if (pendingTokensRef.current) { appendToLastAssistant(pendingTokensRef.current); pendingTokensRef.current = ''; } setStreaming(false); setStatusText(''); abortRef.current = null; } }, [ input, streaming, activeConversationId, notebookId, notebook, createConversation, addUserMessage, addAssistantMessage, appendToLastAssistant, setStreaming, ]); const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend(); } }, [handleSend], ); const handleNewConversation = useCallback(() => { // If streaming, abort first if (abortRef.current) { abortRef.current.abort(); abortRef.current = null; setStreaming(false); } createConversation(); setShowHistory(false); setReasoningText(''); setReasoningTime(0); setTimeout(() => inputRef.current?.focus(), 100); }, [createConversation, setStreaming]); return ( <> {/* Floating action button */} {/* Chat panel */} {panelOpen && (
{/* Header */}
{showHistory ? 'Conversations' : activeConv?.title ?? 'Circuit Assistant'}
{/* Body */} {showHistory ? ( setShowHistory(false)} /> ) : ( <>
{messages.length === 0 && !streaming && (
Ask about circuits, netlists, or simulation results
)} {messages.map((msg, i) => ( ))} {reasoningText && streaming && (
Thinking{reasoningTime > 0 ? ` (${reasoningTime}s)` : '…'}
{reasoningText}
)} {statusText && (
{statusText}
)}
{/* Input */}
setInput(e.target.value)} onKeyDown={handleKeyDown} disabled={streaming} />
)}
)} ); }