scrollIntoView walks up the DOM and scrolls every ancestor, including the panel with overflow:hidden — this pushed the header and messages area off-screen after long LLM responses. Using container.scrollTo limits scrolling to only the messages div.
459 lines
15 KiB
TypeScript
459 lines
15 KiB
TypeScript
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 (
|
|
<div className={`chat-bubble ${msg.role}`}>
|
|
{msg.role === 'assistant' ? (
|
|
<>
|
|
<span dangerouslySetInnerHTML={{ __html: renderMarkdown(msg.text) }} />
|
|
{isStreaming && <span className="chat-cursor" />}
|
|
</>
|
|
) : (
|
|
msg.text
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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<string | null>(null);
|
|
const pendingTimer = useRef<ReturnType<typeof setTimeout> | 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 (
|
|
<div className="chat-history-list">
|
|
<div className="chat-empty">No conversations yet</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="chat-history-list">
|
|
{conversations.map((c) => (
|
|
<button
|
|
key={c.id}
|
|
className={`chat-history-item ${c.id === activeId ? 'active' : ''}`}
|
|
onClick={() => {
|
|
setActive(c.id);
|
|
onBack();
|
|
}}
|
|
>
|
|
<MessageSquare size={14} />
|
|
<span className="chat-history-title">{c.title}</span>
|
|
<span className="chat-history-count">{c.messages.length}</span>
|
|
<span
|
|
className={`chat-history-delete ${pendingDeleteId === c.id ? 'confirm' : ''}`}
|
|
onClick={(e) => handleDelete(c.id, e)}
|
|
title={pendingDeleteId === c.id ? 'Click again to delete' : 'Delete conversation'}
|
|
>
|
|
<Trash2 size={12} />
|
|
</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── 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<HTMLDivElement>(null);
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
const abortRef = useRef<AbortController | null>(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 */}
|
|
<button
|
|
className={`chat-fab ${panelOpen ? 'active' : ''}`}
|
|
onClick={togglePanel}
|
|
aria-label="Toggle chat"
|
|
title="Circuit assistant"
|
|
>
|
|
{panelOpen ? <X size={20} /> : <Zap size={20} />}
|
|
</button>
|
|
|
|
{/* Chat panel */}
|
|
{panelOpen && (
|
|
<div className="chat-panel">
|
|
{/* Header */}
|
|
<div className="chat-header">
|
|
<Zap size={16} className="text-blue-500" />
|
|
<span className="chat-header-title">
|
|
{showHistory
|
|
? 'Conversations'
|
|
: activeConv?.title ?? 'Circuit Assistant'}
|
|
</span>
|
|
<button
|
|
className="chat-header-btn"
|
|
onClick={handleNewConversation}
|
|
title="New conversation"
|
|
>
|
|
<Plus size={16} />
|
|
</button>
|
|
<button
|
|
className="chat-header-btn"
|
|
onClick={() => setShowHistory(!showHistory)}
|
|
title={showHistory ? 'Back to chat' : 'Conversation history'}
|
|
>
|
|
{showHistory ? <MessageSquare size={16} /> : <History size={16} />}
|
|
</button>
|
|
<button
|
|
className="chat-header-btn"
|
|
onClick={closePanel}
|
|
title="Close"
|
|
>
|
|
<X size={16} />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Body */}
|
|
{showHistory ? (
|
|
<HistoryView onBack={() => setShowHistory(false)} />
|
|
) : (
|
|
<>
|
|
<div className="chat-messages">
|
|
{messages.length === 0 && !streaming && (
|
|
<div className="chat-empty">
|
|
Ask about circuits, netlists, or simulation results
|
|
</div>
|
|
)}
|
|
{messages.map((msg, i) => (
|
|
<MessageBubble
|
|
key={`${msg.timestamp}-${i}`}
|
|
msg={msg}
|
|
isStreaming={
|
|
streaming &&
|
|
i === messages.length - 1 &&
|
|
msg.role === 'assistant'
|
|
}
|
|
/>
|
|
))}
|
|
{reasoningText && streaming && (
|
|
<details className="chat-reasoning">
|
|
<summary>Thinking{reasoningTime > 0 ? ` (${reasoningTime}s)` : '…'}</summary>
|
|
<div className="chat-reasoning-body">{reasoningText}</div>
|
|
</details>
|
|
)}
|
|
{statusText && (
|
|
<div className="chat-status">{statusText}</div>
|
|
)}
|
|
<div ref={messagesEndRef} />
|
|
</div>
|
|
|
|
{/* Input */}
|
|
<div className="chat-input-bar">
|
|
<input
|
|
ref={inputRef}
|
|
className="chat-input"
|
|
type="text"
|
|
placeholder={
|
|
notebookId
|
|
? 'Ask about this circuit…'
|
|
: 'Ask a circuit question…'
|
|
}
|
|
value={input}
|
|
onChange={(e) => setInput(e.target.value)}
|
|
onKeyDown={handleKeyDown}
|
|
disabled={streaming}
|
|
/>
|
|
<button
|
|
className="chat-send-btn"
|
|
onClick={handleSend}
|
|
disabled={streaming || !input.trim()}
|
|
title="Send"
|
|
>
|
|
<Send size={16} />
|
|
</button>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
}
|