- 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.
399 lines
13 KiB
TypeScript
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, '<')
|
|
.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 `<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>
|
|
)}
|
|
</>
|
|
);
|
|
}
|