| "use client"; |
|
|
| import { create } from "zustand"; |
| import { persist, createJSONStorage } from "zustand/middleware"; |
| import type { AskSmartRequest, AskSmartResponse } from "./types"; |
|
|
| export type ChatTurn = { |
| id: string; |
| question: string; |
| response?: AskSmartResponse; |
| error?: { status: number; message: string }; |
| pending?: boolean; |
| timestamp: number; |
| }; |
|
|
| export type Conversation = { |
| id: string; |
| title: string; |
| turns: ChatTurn[]; |
| createdAt: number; |
| updatedAt: number; |
| |
| pinnedDocId?: string | null; |
| pinnedDocName?: string | null; |
| }; |
|
|
| export type Settings = Required< |
| Omit<AskSmartRequest, "question" | "history" | "stream"> |
| >; |
|
|
| export const DEFAULT_SETTINGS: Settings = { |
| top_k: 1, |
| max_new_tokens: 200, |
| similarity_threshold: 0.45, |
| rerank_threshold: 0.6, |
| anchor_threshold: 0.2, |
| use_grounding: true, |
| repetition_penalty: 1.15, |
| no_repeat_ngram_size: 4, |
| scaler: 1.0, |
| bias_scaler: 1.0, |
| }; |
|
|
| type ChatStore = { |
| conversations: Record<string, Conversation>; |
| activeId: string | null; |
| settings: Settings; |
| sidebarOpen: boolean; |
|
|
| |
| hydrated: boolean; |
| syncStatus: "idle" | "saving" | "error"; |
| lastSavedAt: number; |
|
|
| |
| active: () => Conversation | null; |
| list: () => Conversation[]; |
|
|
| |
| newConversation: () => string; |
| newConversationPinned: (doc_id: string, doc_name: string) => string; |
| openConversation: (id: string) => void; |
| deleteConversation: (id: string) => void; |
| renameConversation: (id: string, title: string) => void; |
| pinDoc: (id: string, doc_id: string | null, doc_name?: string | null) => void; |
| appendTurn: (turn: ChatTurn) => void; |
| patchTurn: (id: string, patch: Partial<ChatTurn>) => void; |
| clearActive: () => void; |
|
|
| setSettings: (next: Settings) => void; |
| resetSettings: () => void; |
| toggleSidebar: () => void; |
| setSidebarOpen: (open: boolean) => void; |
|
|
| |
| _setHydrated: (v: boolean) => void; |
| _setSyncStatus: (v: "idle" | "saving" | "error") => void; |
| _hydrateFromRemote: (payload: { |
| conversations: Record<string, Conversation>; |
| activeId: string | null; |
| }) => void; |
| }; |
|
|
| const newId = () => |
| (typeof crypto !== "undefined" && crypto.randomUUID |
| ? crypto.randomUUID() |
| : Math.random().toString(36).slice(2, 12)); |
|
|
| |
| |
| |
| |
| export function buildHistory( |
| conv: Conversation | null | undefined, |
| max = 5 |
| ): { question: string; answer: string }[] { |
| if (!conv) return []; |
| const pairs: { question: string; answer: string }[] = []; |
| for (const t of conv.turns) { |
| if (t.pending || t.error || !t.response) continue; |
| pairs.push({ question: t.question, answer: t.response.answer }); |
| } |
| return pairs.slice(-max); |
| } |
|
|
| const seedConversation = (): Conversation => ({ |
| id: newId(), |
| title: "New conversation", |
| turns: [], |
| createdAt: Date.now(), |
| updatedAt: Date.now(), |
| }); |
|
|
| export const useChatStore = create<ChatStore>()( |
| persist( |
| (set, get) => ({ |
| conversations: {}, |
| activeId: null, |
| settings: DEFAULT_SETTINGS, |
| sidebarOpen: true, |
| hydrated: false, |
| syncStatus: "idle", |
| lastSavedAt: 0, |
|
|
| active: () => { |
| const { activeId, conversations } = get(); |
| return activeId ? conversations[activeId] ?? null : null; |
| }, |
|
|
| list: () => |
| Object.values(get().conversations).sort( |
| (a, b) => b.updatedAt - a.updatedAt |
| ), |
|
|
| newConversation: () => { |
| const c = seedConversation(); |
| set((s) => ({ |
| conversations: { ...s.conversations, [c.id]: c }, |
| activeId: c.id, |
| })); |
| return c.id; |
| }, |
|
|
| newConversationPinned: (doc_id, doc_name) => { |
| const c: Conversation = { |
| ...seedConversation(), |
| title: doc_name ? `Scoped: ${doc_name}` : "Scoped conversation", |
| pinnedDocId: doc_id, |
| pinnedDocName: doc_name ?? null, |
| }; |
| set((s) => ({ |
| conversations: { ...s.conversations, [c.id]: c }, |
| activeId: c.id, |
| })); |
| return c.id; |
| }, |
|
|
| pinDoc: (id, doc_id, doc_name) => |
| set((s) => { |
| const conv = s.conversations[id]; |
| if (!conv) return s; |
| return { |
| conversations: { |
| ...s.conversations, |
| [id]: { |
| ...conv, |
| pinnedDocId: doc_id, |
| pinnedDocName: doc_name ?? conv.pinnedDocName ?? null, |
| updatedAt: Date.now(), |
| }, |
| }, |
| }; |
| }), |
|
|
| openConversation: (id) => set({ activeId: id }), |
|
|
| deleteConversation: (id) => |
| set((s) => { |
| const next = { ...s.conversations }; |
| delete next[id]; |
| const remainingIds = Object.keys(next); |
| return { |
| conversations: next, |
| activeId: |
| s.activeId === id |
| ? remainingIds[0] ?? null |
| : s.activeId, |
| }; |
| }), |
|
|
| renameConversation: (id, title) => |
| set((s) => ({ |
| conversations: { |
| ...s.conversations, |
| [id]: s.conversations[id] |
| ? { ...s.conversations[id], title, updatedAt: Date.now() } |
| : s.conversations[id], |
| }, |
| })), |
|
|
| appendTurn: (turn) => |
| set((s) => { |
| let { activeId } = s; |
| let conversations = s.conversations; |
| if (!activeId || !conversations[activeId]) { |
| const c = seedConversation(); |
| activeId = c.id; |
| conversations = { ...conversations, [c.id]: c }; |
| } |
| const conv = conversations[activeId]; |
| const isFirstTurn = conv.turns.length === 0; |
| const updated: Conversation = { |
| ...conv, |
| title: isFirstTurn |
| ? turn.question.slice(0, 60).trim() || conv.title |
| : conv.title, |
| turns: [...conv.turns, turn], |
| updatedAt: Date.now(), |
| }; |
| return { |
| activeId, |
| conversations: { ...conversations, [activeId]: updated }, |
| }; |
| }), |
|
|
| patchTurn: (id, patch) => |
| set((s) => { |
| if (!s.activeId) return s; |
| const conv = s.conversations[s.activeId]; |
| if (!conv) return s; |
| const turns = conv.turns.map((t) => |
| t.id === id ? { ...t, ...patch } : t |
| ); |
| return { |
| conversations: { |
| ...s.conversations, |
| [s.activeId]: { ...conv, turns, updatedAt: Date.now() }, |
| }, |
| }; |
| }), |
|
|
| clearActive: () => |
| set((s) => { |
| if (!s.activeId) return s; |
| const conv = s.conversations[s.activeId]; |
| if (!conv) return s; |
| return { |
| conversations: { |
| ...s.conversations, |
| [s.activeId]: { ...conv, turns: [], updatedAt: Date.now() }, |
| }, |
| }; |
| }), |
|
|
| setSettings: (next) => set({ settings: next }), |
| resetSettings: () => set({ settings: DEFAULT_SETTINGS }), |
| toggleSidebar: () => set((s) => ({ sidebarOpen: !s.sidebarOpen })), |
| setSidebarOpen: (open) => set({ sidebarOpen: open }), |
|
|
| |
| _setHydrated: (v) => set({ hydrated: v }), |
| _setSyncStatus: (v) => set({ syncStatus: v }), |
| _hydrateFromRemote: ({ conversations, activeId }) => |
| set((s) => { |
| |
| |
| |
| |
| const remoteIds = Object.keys(conversations); |
| if (remoteIds.length === 0) { |
| |
| return { hydrated: true }; |
| } |
| |
| const merged: Record<string, Conversation> = { ...conversations }; |
| for (const [id, local] of Object.entries(s.conversations)) { |
| if (!merged[id]) { |
| merged[id] = local; |
| continue; |
| } |
| const localPending = local.turns.filter((t) => t.pending); |
| if (localPending.length === 0) continue; |
| const serverTurnIds = new Set(merged[id].turns.map((t) => t.id)); |
| const extras = localPending.filter( |
| (t) => !serverTurnIds.has(t.id) |
| ); |
| if (extras.length > 0) { |
| merged[id] = { |
| ...merged[id], |
| turns: [...merged[id].turns, ...extras], |
| }; |
| } |
| } |
| const nextActive = |
| activeId && merged[activeId] |
| ? activeId |
| : s.activeId && merged[s.activeId] |
| ? s.activeId |
| : Object.keys(merged)[0] ?? null; |
| return { |
| conversations: merged, |
| activeId: nextActive, |
| hydrated: true, |
| }; |
| }), |
| }), |
| { |
| name: "etiya-d2l-chat", |
| storage: createJSONStorage(() => localStorage), |
| partialize: (state) => ({ |
| conversations: state.conversations, |
| activeId: state.activeId, |
| settings: state.settings, |
| sidebarOpen: state.sidebarOpen, |
| }), |
| } |
| ) |
| ); |
|
|