- 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.
171 lines
4.8 KiB
TypeScript
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,
|
|
}),
|
|
},
|
|
),
|
|
);
|