Ryan Malloy 99d1ca28d2 Add MCP server and chat assistant
- Mount FastMCP at /mcp with tools for notebook CRUD, simulation,
  and cell execution. Includes status resource and circuit_assistant
  prompt.
- Add SSE streaming chat endpoint at /api/chat/stream backed by
  GPU LLM gateway (qwen3). Chat widget sends notebook context
  (SPICE cells, markdown notes) so the assistant can reference the
  user's circuit.
- React floating chat panel with zustand-persisted conversation
  history, streaming token display, reasoning collapse, and
  keyboard shortcuts.
- Refactor main.py from deprecated on_event("startup") to lifespan
  context manager with combine_lifespans for MCP integration.
- Add notebook_id path traversal validation, decouple get_engine()
  from HTTPException for MCP compatibility, fix HTTP client init
  race condition with asyncio.Lock.
- Update Caddy labels for /mcp/* routing and SSE streaming on
  backend reverse proxy.
2026-02-22 16:49:15 -07:00

399 lines
13 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 '../../styles/chat-widget.css';
// ── Lightweight markdown ────────────────────────────────
function escapeHtml(text: string): string {
return text
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function renderMarkdown(text: string): string {
let html = escapeHtml(text);
// Code blocks (``` ... ```)
html = html.replace(/```(\w*)\n([\s\S]*?)```/g, (_m, _lang, code) => {
return `<pre><code>${code.trim()}</code></pre>`;
});
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
html = html.replace(/(?<!\*)\*([^*]+?)\*(?!\*)/g, '<em>$1</em>');
html = html.replace(/`([^`]+?)`/g, '<code>$1</code>');
html = html.replace(
/\[([^\]]+?)\]\((https?:\/\/[^)]+?)\)/g,
'<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>',
);
html = html.replace(/\n/g, '<br>');
return html;
}
// ── 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);
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 */}
<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>
)}
</>
);
}