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%
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