SPB Git

spb/zyquo-cloud-web Public MIT

Zyquo Cloud Web — every cloud model, one beautiful chat, entirely in your browser.

TypeScript 81.9% CSS 8.9% JavaScript 7.5% Shell 1.1% HTML 0.6%
20.9 KB · 627 lines typescript
Raw Blame History
1/*2 *  store.ts3 *  Zyquo Cloud Web4 *5 *  Author: Simon-Pierre Boucher6 *  Mail: contact@spboucher.ai7 *8 *  The app store (Zustand): settings, keys status, conversations, streaming9 *  state, and UI state. All persistence goes through storage/; all networking10 *  through providers/. Conversation behaviors ported from the native11 *  ConversationStore (send/stream/stop/regenerate/edit-resend/auto-title),12 *  extended with web-only variants + branching.13 */1415import { create } from 'zustand'16import { cheapestModel, defaultModel, findModel, pricedCost } from '../features/catalogHelpers'17import { autoTrimForContext } from '../features/conversationTools'18import { clientFor } from '../providers/registry'19import { ProviderError } from '../providers/types'20import {21  deleteConversation as dbDeleteConversation,22  loadConversations,23  saveConversation,24} from '../storage/conversations'25import { configuredProviders, getKey } from '../storage/keys'26import { loadSettings, saveSettings } from '../storage/settings'27import { isLocked } from '../storage/vault'28import type {29  AIModel,30  Attachment,31  ChatParameters,32  Conversation,33  Message,34  MessageVariant,35  Provider,36  Settings,37} from '../types'3839export function uid(): string {40  return crypto.randomUUID()41}4243export interface Toast {44  id: string45  text: string46  kind: 'info' | 'error' | 'success'47  /** Optional undo action (soft deletes). */48  undo?: () => void49}5051interface StreamingState {52  /** Message id currently being streamed into. */53  messageID: string54  abort: AbortController55  startedAt: number56  outputChars: number57}5859export interface ZyquoState {60  // Settings61  settings: Settings62  updateSettings: (patch: Partial<Settings>) => void6364  // Keys (presence only — values read from storage on demand)65  keyedProviders: Provider[]66  refreshKeyedProviders: () => void67  vaultLocked: boolean68  setVaultLocked: (locked: boolean) => void6970  // Conversations71  conversations: Conversation[]72  selectedID: string | null73  searchText: string74  loaded: boolean75  loadAll: () => Promise<void>76  select: (id: string | null) => void77  setSearchText: (text: string) => void78  newConversation: (opts?: {79    model?: AIModel80    systemPrompt?: string81    personaID?: string82    parameters?: ChatParameters83  }) => Conversation84  updateConversation: (id: string, patch: Partial<Conversation>) => void85  removeConversation: (id: string) => void86  restoreConversation: (conversation: Conversation) => void8788  // Chat actions89  send: (conversationID: string, text: string, attachments: Attachment[], overrideModel?: AIModel) => Promise<void>90  regenerate: (conversationID: string, messageID: string, overrideModel?: AIModel) => Promise<void>91  editAndResend: (conversationID: string, messageID: string, newText: string) => Promise<void>92  deleteMessage: (conversationID: string, messageID: string) => void93  stop: (conversationID: string) => void94  continueGeneration: (conversationID: string) => Promise<void>95  branchFrom: (conversationID: string, messageID: string) => Conversation | null96  setActiveVariant: (conversationID: string, messageID: string, index: number) => void9798  // Streaming99  streaming: Record<string, StreamingState>100101  // UI102  /** Prompt text to preload into the next-opened conversation's input. */103  draftSeed: string | null104  setDraftSeed: (seed: string | null) => void105  sidebarOpen: boolean106  setSidebarOpen: (open: boolean) => void107  modelMenuOpen: boolean108  setModelMenuOpen: (open: boolean) => void109  settingsOpen: boolean110  settingsTab: string111  openSettings: (tab?: string) => void112  closeSettings: () => void113  paletteOpen: boolean114  setPaletteOpen: (open: boolean) => void115  compareOpen: boolean116  setCompareOpen: (open: boolean) => void117  toasts: Toast[]118  toast: (text: string, kind?: Toast['kind'], undo?: () => void) => void119  dismissToast: (id: string) => void120}121122function applyThemeToDocument(settings: Settings): void {123  const root = document.documentElement124  const dark =125    settings.theme === 'dark' ||126    (settings.theme === 'system' && matchMedia('(prefers-color-scheme: dark)').matches)127  if (dark) root.setAttribute('data-theme', 'dark')128  else root.removeAttribute('data-theme')129  if (settings.accent !== 'indigo') root.setAttribute('data-accent', settings.accent)130  else root.removeAttribute('data-accent')131  root.style.setProperty('--z-chat-font-size', `${settings.chatFontSize}px`)132}133134export const useStore = create<ZyquoState>((set, get) => {135  const initialSettings = loadSettings()136  applyThemeToDocument(initialSettings)137138  const persist = (conversation: Conversation) => {139    void saveConversation(conversation).catch(() => {140      get().toast('Failed to save conversation locally', 'error')141    })142  }143144  const mutateConversation = (145    id: string,146    mutate: (c: Conversation) => Conversation,147    save = true148  ) => {149    set((state) => {150      const conversations = state.conversations.map((c) => {151        if (c.id !== id) return c152        const next = mutate(c)153        if (save) persist(next)154        return next155      })156      return { conversations }157    })158  }159160  const modelFor = (conversation: Conversation): AIModel | undefined =>161    findModel(conversation.provider, conversation.modelID)162163  /** Streams a reply into a fresh assistant message appended to the conversation. */164  const generateReply = async (conversationID: string, overrideModel?: AIModel) => {165    const state = get()166    const conversation = state.conversations.find((c) => c.id === conversationID)167    if (!conversation) return168    if (state.streaming[conversationID]) state.stop(conversationID)169170    const model = overrideModel ?? modelFor(conversation)171    if (!model) {172      state.toast(`Model ${conversation.modelID} not found in catalog`, 'error')173      return174    }175    const apiKey = getKey(model.provider)176    if (!apiKey) {177      state.toast(ProviderError.missingAPIKey(model.provider).message, 'error')178      get().openSettings('providers')179      return180    }181    if (overrideModel) {182      mutateConversation(conversationID, (c) => ({183        ...c,184        modelID: overrideModel.id,185        provider: overrideModel.provider,186      }))187    }188189    const placeholder: Message = {190      id: uid(),191      role: 'assistant',192      text: '',193      modelID: model.id,194      provider: model.provider,195      createdAt: Date.now(),196    }197    mutateConversation(198      conversationID,199      (c) => ({ ...c, messages: [...c.messages, placeholder], updatedAt: Date.now() }),200      false201    )202203    const abort = new AbortController()204    set((s) => ({205      streaming: {206        ...s.streaming,207        [conversationID]: {208          messageID: placeholder.id,209          abort,210          startedAt: Date.now(),211          outputChars: 0,212        },213      },214    }))215216    const source = get().conversations.find((c) => c.id === conversationID)217    if (!source) return218    // Request context: all messages except the placeholder and errored turns,219    // auto-trimmed (oldest first, system prompt kept) when near the window.220    const { messages: trimmedMessages, trimmed } = autoTrimForContext({221      ...source,222      messages: source.messages.filter((m) => m.id !== placeholder.id),223    })224    if (trimmed > 0) {225      get().toast(`Context nearly full — trimmed ${trimmed} oldest turn${trimmed > 1 ? 's' : ''}`)226    }227    const requestMessages = trimmedMessages228    const proxy = get().settings.proxyBaseURLs[model.provider]229230    const patchMessage = (patch: (m: Message) => Message, save = false) => {231      mutateConversation(232        conversationID,233        (c) => ({234          ...c,235          messages: c.messages.map((m) => (m.id === placeholder.id ? patch(m) : m)),236        }),237        save238      )239    }240241    try {242      const client = clientFor(model)243      const events = client.streamChat(244        {245          model,246          ...(source.systemPrompt ? { systemPrompt: source.systemPrompt } : {}),247          messages: requestMessages,248          parameters: source.parameters,249          stream: true,250          ...(proxy ? { baseURLOverride: proxy } : {}),251          ...(model.customBaseURL ? { baseURLOverride: model.customBaseURL } : {}),252        },253        apiKey,254        abort.signal255      )256      for await (const event of events) {257        switch (event.type) {258          case 'textDelta':259            set((s) => {260              const st = s.streaming[conversationID]261              return st262                ? {263                    streaming: {264                      ...s.streaming,265                      [conversationID]: { ...st, outputChars: st.outputChars + event.text.length },266                    },267                  }268                : {}269            })270            patchMessage((m) => ({ ...m, text: m.text + event.text }))271            break272          case 'reasoningDelta':273            patchMessage((m) => ({ ...m, reasoning: (m.reasoning ?? '') + event.text }))274            break275          case 'citations':276            patchMessage((m) => ({ ...m, citations: event.citations }))277            break278          case 'usage':279            patchMessage((m) => ({280              ...m,281              usage: event.usage,282              ...(model.pricing283                ? { estimatedCost: pricedCost(model, event.usage) }284                : {}),285            }))286            break287          case 'finished':288            break289        }290      }291      patchMessage((m) => m, true) // final persist292      void autoTitleIfNeeded(conversationID)293    } catch (err) {294      const cancelled = err instanceof ProviderError && err.kind === 'cancelled'295      if (!cancelled) {296        const text = err instanceof Error ? err.message : String(err)297        patchMessage((m) => ({ ...m, errorText: text }), true)298        get().toast(text, 'error')299      } else {300        patchMessage((m) => m, true)301      }302    } finally {303      set((s) => {304        const streaming = { ...s.streaming }305        delete streaming[conversationID]306        return { streaming }307      })308    }309  }310311  /** Native auto-title behavior: after the first completed exchange. */312  const autoTitleIfNeeded = async (conversationID: string) => {313    const conversation = get().conversations.find((c) => c.id === conversationID)314    if (!conversation || !conversation.hasAutoTitle) return315    const assistantTurns = conversation.messages.filter(316      (m) => m.role === 'assistant' && m.text.trim() !== ''317    )318    if (assistantTurns.length !== 1) return319    const model = cheapestModel(conversation.provider)320    const apiKey = model ? getKey(model.provider) : undefined321    if (!model || !apiKey) return322    const firstUser = conversation.messages.find((m) => m.role === 'user')?.text ?? ''323    const lastAssistant = assistantTurns[assistantTurns.length - 1]?.text ?? ''324    try {325      const result = await clientFor(model).complete(326        {327          model,328          messages: [329            {330              id: uid(),331              role: 'user',332              text:333                'Write a title of at most 5 words for this conversation. Reply with the title only, no quotes.\n' +334                `User: ${firstUser.slice(0, 500)}\nAssistant: ${lastAssistant.slice(0, 500)}`,335              createdAt: Date.now(),336            },337          ],338          parameters: { maxTokens: 24 },339          stream: false,340        },341        apiKey342      )343      const title = result.text344        .trim()345        .replace(/^["'“”]+|["'“”]+$/g, '')346        .slice(0, 60)347      const current = get().conversations.find((c) => c.id === conversationID)348      if (title !== '' && current?.hasAutoTitle) {349        mutateConversation(conversationID, (c) => ({ ...c, title }))350      }351    } catch {352      // Silent failure — title stays "New Chat".353    }354  }355356  return {357    settings: initialSettings,358    updateSettings: (patch) => {359      const settings = { ...get().settings, ...patch }360      saveSettings(settings)361      applyThemeToDocument(settings)362      set({ settings })363    },364365    keyedProviders: [],366    refreshKeyedProviders: () => set({ keyedProviders: configuredProviders() }),367    vaultLocked: isLocked(),368    setVaultLocked: (locked) => set({ vaultLocked: locked }),369370    conversations: [],371    selectedID: null,372    searchText: '',373    loaded: false,374    loadAll: async () => {375      const conversations = await loadConversations()376      set({377        conversations,378        loaded: true,379        selectedID: conversations[0]?.id ?? null,380        keyedProviders: configuredProviders(),381      })382    },383    select: (id) => set({ selectedID: id, sidebarOpen: false }),384    setSearchText: (text) => set({ searchText: text }),385386    newConversation: (opts = {}) => {387      const settings = get().settings388      const model =389        opts.model ??390        findModel(settings.defaultProvider, settings.defaultModelID) ??391        defaultModel()392      const systemPrompt = opts.systemPrompt ?? settings.defaultSystemPrompt393      const conversation: Conversation = {394        id: uid(),395        title: 'New Chat',396        createdAt: Date.now(),397        updatedAt: Date.now(),398        modelID: model.id,399        provider: model.provider,400        ...(systemPrompt !== '' ? { systemPrompt } : {}),401        parameters: opts.parameters ?? { ...settings.defaultParameters },402        messages: [],403        pinned: false,404        hasAutoTitle: true,405        ...(opts.personaID ? { personaID: opts.personaID } : {}),406      }407      set((s) => ({408        conversations: [conversation, ...s.conversations],409        selectedID: conversation.id,410        sidebarOpen: false,411      }))412      persist(conversation)413      return conversation414    },415416    updateConversation: (id, patch) => {417      mutateConversation(id, (c) => ({ ...c, ...patch, updatedAt: Date.now() }))418    },419420    removeConversation: (id) => {421      const conversation = get().conversations.find((c) => c.id === id)422      get().stop(id)423      set((s) => {424        const conversations = s.conversations.filter((c) => c.id !== id)425        return {426          conversations,427          selectedID: s.selectedID === id ? (conversations[0]?.id ?? null) : s.selectedID,428        }429      })430      void dbDeleteConversation(id)431      if (conversation) {432        get().toast(`Deleted “${conversation.title}”`, 'info', () =>433          get().restoreConversation(conversation)434        )435      }436    },437438    restoreConversation: (conversation) => {439      set((s) => ({440        conversations: [conversation, ...s.conversations].sort(441          (a, b) => b.updatedAt - a.updatedAt442        ),443        selectedID: conversation.id,444      }))445      persist(conversation)446    },447448    send: async (conversationID, text, attachments, overrideModel) => {449      const conversation = get().conversations.find((c) => c.id === conversationID)450      if (!conversation) return451      const model = overrideModel ?? findModel(conversation.provider, conversation.modelID)452      const userMessage: Message = {453        id: uid(),454        role: 'user',455        text,456        ...(attachments.length > 0 ? { attachments } : {}),457        ...(model ? { modelID: model.id, provider: model.provider } : {}),458        createdAt: Date.now(),459      }460      mutateConversation(conversationID, (c) => ({461        ...c,462        messages: [...c.messages, userMessage],463        updatedAt: Date.now(),464      }))465      await generateReply(conversationID, overrideModel)466    },467468    regenerate: async (conversationID, messageID, overrideModel) => {469      const conversation = get().conversations.find((c) => c.id === conversationID)470      if (!conversation) return471      const index = conversation.messages.findIndex((m) => m.id === messageID)472      const message = conversation.messages[index]473      if (!message || message.role !== 'assistant') return474      // Web improvement over native: keep the old answer as a variant instead475      // of dropping it, unless it was empty/errored.476      const keepVariant = message.text.trim() !== '' && !message.errorText477      if (index === conversation.messages.length - 1) {478        mutateConversation(conversationID, (c) => {479          const messages = c.messages.slice(0, -1)480          return { ...c, messages }481        })482        const old: MessageVariant | null = keepVariant483          ? {484              text: message.text,485              ...(message.reasoning ? { reasoning: message.reasoning } : {}),486              ...(message.citations ? { citations: message.citations } : {}),487              ...(message.modelID ? { modelID: message.modelID } : {}),488              ...(message.provider ? { provider: message.provider } : {}),489              ...(message.usage ? { usage: message.usage } : {}),490              ...(message.estimatedCost !== undefined491                ? { estimatedCost: message.estimatedCost }492                : {}),493              createdAt: message.createdAt,494            }495          : null496        await generateReply(conversationID, overrideModel)497        if (old) {498          // Attach the previous answer as a variant of the fresh message.499          const fresh = get().conversations.find((c) => c.id === conversationID)500          const last = fresh?.messages[fresh.messages.length - 1]501          if (last && last.role === 'assistant') {502            mutateConversation(conversationID, (c) => ({503              ...c,504              messages: c.messages.map((m) =>505                m.id === last.id506                  ? {507                      ...m,508                      variants: [...(message.variants ?? []), old],509                    }510                  : m511              ),512            }))513          }514        }515      }516    },517518    editAndResend: async (conversationID, messageID, newText) => {519      const conversation = get().conversations.find((c) => c.id === conversationID)520      if (!conversation) return521      const index = conversation.messages.findIndex((m) => m.id === messageID)522      if (index === -1) return523      mutateConversation(conversationID, (c) => ({524        ...c,525        messages: c.messages526          .slice(0, index + 1)527          .map((m) => (m.id === messageID ? { ...m, text: newText } : m)),528        updatedAt: Date.now(),529      }))530      await generateReply(conversationID)531    },532533    deleteMessage: (conversationID, messageID) => {534      mutateConversation(conversationID, (c) => ({535        ...c,536        messages: c.messages.filter((m) => m.id !== messageID),537        updatedAt: Date.now(),538      }))539    },540541    stop: (conversationID) => {542      const st = get().streaming[conversationID]543      if (st) st.abort.abort()544    },545546    continueGeneration: async (conversationID) => {547      const conversation = get().conversations.find((c) => c.id === conversationID)548      if (!conversation) return549      const continueMessage: Message = {550        id: uid(),551        role: 'user',552        text: 'Continue exactly where you left off.',553        createdAt: Date.now(),554      }555      mutateConversation(conversationID, (c) => ({556        ...c,557        messages: [...c.messages, continueMessage],558        updatedAt: Date.now(),559      }))560      await generateReply(conversationID)561    },562563    branchFrom: (conversationID, messageID) => {564      const conversation = get().conversations.find((c) => c.id === conversationID)565      if (!conversation) return null566      const index = conversation.messages.findIndex((m) => m.id === messageID)567      if (index === -1) return null568      const branch: Conversation = {569        ...conversation,570        id: uid(),571        title: `${conversation.title} (branch)`,572        createdAt: Date.now(),573        updatedAt: Date.now(),574        messages: conversation.messages.slice(0, index + 1).map((m) => ({ ...m, id: uid() })),575        pinned: false,576        hasAutoTitle: false,577        branchedFrom: { conversationID, messageID },578      }579      set((s) => ({580        conversations: [branch, ...s.conversations],581        selectedID: branch.id,582      }))583      persist(branch)584      return branch585    },586587    setActiveVariant: (conversationID, messageID, index) => {588      mutateConversation(conversationID, (c) => ({589        ...c,590        messages: c.messages.map((m) => {591          if (m.id !== messageID || !m.variants) return m592          const variants = m.variants593          const target = index === -1 ? null : variants[index]594          if (index === -1 || !target) return { ...m, activeVariant: undefined }595          // Swap: current content goes into the variant slot, variant becomes active.596          return { ...m, activeVariant: index }597        }),598      }))599    },600601    streaming: {},602603    draftSeed: null,604    setDraftSeed: (seed) => set({ draftSeed: seed }),605    sidebarOpen: false,606    setSidebarOpen: (open) => set({ sidebarOpen: open }),607    modelMenuOpen: false,608    setModelMenuOpen: (open) => set({ modelMenuOpen: open }),609    settingsOpen: false,610    settingsTab: 'providers',611    openSettings: (tab = 'providers') => set({ settingsOpen: true, settingsTab: tab }),612    closeSettings: () => set({ settingsOpen: false }),613    paletteOpen: false,614    setPaletteOpen: (open) => set({ paletteOpen: open }),615    compareOpen: false,616    setCompareOpen: (open) => set({ compareOpen: open }),617618    toasts: [],619    toast: (text, kind = 'info', undo) => {620      const id = uid()621      set((s) => ({ toasts: [...s.toasts, { id, text, kind, ...(undo ? { undo } : {}) }] }))622      setTimeout(() => get().dismissToast(id), 6000)623    },624    dismissToast: (id) => set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) })),625  }626})627