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 '../../styles/chat-widget.css'; // ── Lightweight markdown ──────────────────────────────── function escapeHtml(text: string): string { return text .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } function renderMarkdown(text: string): string { let html = escapeHtml(text); // Code blocks (``` ... ```) html = html.replace(/```(\w*)\n([\s\S]*?)```/g, (_m, _lang, code) => { return `
${code.trim()}
`; }); html = html.replace(/\*\*(.+?)\*\*/g, '$1'); html = html.replace(/(?$1'); html = html.replace(/`([^`]+?)`/g, '$1'); html = html.replace( /\[([^\]]+?)\]\((https?:\/\/[^)]+?)\)/g, '$1', ); html = html.replace(/\n/g, '
'); return html; } // ── 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); const activeConv = getActiveConversation(); const messages = activeConv?.messages ?? []; // Auto-scroll on new messages useEffect(() => { messagesEndRef.current?.scrollIntoView({ 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': appendToLastAssistant((evt.data as { text: string }).text); 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 { 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} />
)}
)} ); }