spicebook/frontend/src/lib/chat-store.ts
Ryan Malloy 1242683a21 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

171 lines
4.8 KiB
TypeScript

/**
* Zustand store for SpiceBook chat conversations.
* Persists to localStorage with conversation history management.
*/
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
export interface ChatMessage {
role: 'user' | 'assistant';
text: string;
timestamp: number;
}
export interface Conversation {
id: string;
title: string;
messages: ChatMessage[];
createdAt: number;
updatedAt: number;
}
const MAX_CONVERSATIONS = 20;
const MAX_MESSAGES = 50;
interface ChatStore {
conversations: Conversation[];
activeConversationId: string | null;
panelOpen: boolean;
streaming: boolean;
// Actions
openPanel: () => void;
closePanel: () => void;
togglePanel: () => void;
createConversation: () => string;
setActiveConversation: (id: string) => void;
addUserMessage: (text: string) => void;
addAssistantMessage: (text: string) => void;
appendToLastAssistant: (text: string) => void;
setStreaming: (streaming: boolean) => void;
deleteConversation: (id: string) => void;
getActiveConversation: () => Conversation | null;
}
function generateId(): string {
return crypto.randomUUID().slice(0, 8);
}
function titleFromQuestion(question: string): string {
const trimmed = question.trim().slice(0, 50);
return trimmed.length < question.trim().length ? `${trimmed}` : trimmed;
}
export const useChatStore = create<ChatStore>()(
persist(
(set, get) => ({
conversations: [],
activeConversationId: null,
panelOpen: false,
streaming: false,
openPanel: () => set({ panelOpen: true }),
closePanel: () => set({ panelOpen: false }),
togglePanel: () => set((s) => ({ panelOpen: !s.panelOpen })),
createConversation: () => {
const id = generateId();
const now = Date.now();
const conv: Conversation = {
id,
title: 'New conversation',
messages: [],
createdAt: now,
updatedAt: now,
};
set((s) => {
const conversations = [conv, ...s.conversations].slice(0, MAX_CONVERSATIONS);
return { conversations, activeConversationId: id };
});
return id;
},
setActiveConversation: (id: string) => set({ activeConversationId: id }),
addUserMessage: (text: string) => {
const now = Date.now();
set((s) => {
const convId = s.activeConversationId;
if (!convId) return s;
return {
conversations: s.conversations.map((c) => {
if (c.id !== convId) return c;
const messages = [...c.messages, { role: 'user' as const, text, timestamp: now }]
.slice(-MAX_MESSAGES);
const title = c.messages.length === 0 ? titleFromQuestion(text) : c.title;
return { ...c, messages, title, updatedAt: now };
}),
};
});
},
addAssistantMessage: (text: string) => {
const now = Date.now();
set((s) => {
const convId = s.activeConversationId;
if (!convId) return s;
return {
conversations: s.conversations.map((c) => {
if (c.id !== convId) return c;
const messages = [
...c.messages,
{ role: 'assistant' as const, text, timestamp: now },
].slice(-MAX_MESSAGES);
return { ...c, messages, updatedAt: now };
}),
};
});
},
appendToLastAssistant: (text: string) => {
set((s) => {
const convId = s.activeConversationId;
if (!convId) return s;
return {
conversations: s.conversations.map((c) => {
if (c.id !== convId) return c;
const messages = [...c.messages];
const last = messages[messages.length - 1];
if (last && last.role === 'assistant') {
messages[messages.length - 1] = { ...last, text: last.text + text };
}
return { ...c, messages, updatedAt: Date.now() };
}),
};
});
},
setStreaming: (streaming: boolean) => set({ streaming }),
deleteConversation: (id: string) => {
set((s) => {
const conversations = s.conversations.filter((c) => c.id !== id);
const activeConversationId =
s.activeConversationId === id
? conversations[0]?.id ?? null
: s.activeConversationId;
return { conversations, activeConversationId };
});
},
getActiveConversation: () => {
const s = get();
return s.conversations.find((c) => c.id === s.activeConversationId) ?? null;
},
}),
{
name: 'spicebook-chat',
partialize: (state) => ({
conversations: state.conversations,
activeConversationId: state.activeConversationId,
}),
},
),
);