store.ts
Language TypeScript12.4 KB391 lines
import { create } from 'zustand'import { useShallow } from 'zustand/react/shallow'import type { IpcClient } from '../ipc/client'import type { AppInfo } from '../ipc/gen/AppInfo'import type { AttachmentMeta } from '../ipc/gen/AttachmentMeta'import type { ConversationSummary } from '../ipc/gen/ConversationSummary'import type { ConversationView } from '../ipc/gen/ConversationView'import type { Message } from '../ipc/gen/Message'import type { ModelInfo } from '../ipc/gen/ModelInfo'import type { ModelsResponse } from '../ipc/gen/ModelsResponse'import type { Preset } from '../ipc/gen/Preset'import type { Provider } from '../ipc/gen/Provider'import type { Settings } from '../ipc/gen/Settings'import type { SpendSummary } from '../ipc/gen/SpendSummary'import type { StreamEvent } from '../ipc/gen/StreamEvent'export type Panel = null | 'search' | 'settings' | 'usage'export interface Streaming {streamId: stringassistantId: stringtext: stringreasoning: string}export interface Toast {id: numbertext: stringtone: 'info' | 'error'}interface State {client: IpcClient | nullinfo: AppInfo | nullsettings: Settings | nullproviders: Provider[]models: Record<string, ModelsResponse>keys: Record<string, boolean>presets: Preset[]conversations: ConversationSummary[]view: ConversationView | nullstreaming: Streaming | nulldrafts: AttachmentMeta[]spend: SpendSummary | nullpanel: Paneltoasts: Toast[]/** For a new chat not yet created: the model it will use. */pending: { providerId: string; modelId: string; presetId: string | null }init(client: IpcClient): Promise<void>toast(text: string, tone?: Toast['tone']): voidsetPanel(p: Panel): voidrefreshConversations(): Promise<void>refreshSpend(): Promise<void>refreshModels(providerId: string, live?: boolean): Promise<void>refreshProviders(): Promise<void>open(id: string): Promise<void>newChat(): voidcurrentModel(): { providerId: string; modelId: string; info: ModelInfo | null }chooseModel(providerId: string, modelId: string): Promise<void>applyPreset(preset: Preset | null): Promise<void>send(text: string): Promise<boolean>stop(): Promise<void>regenerate(): Promise<void>editAndResend(messageId: string, text: string): Promise<void>addDrafts(a: AttachmentMeta[]): voidremoveDraft(id: string): Promise<void>saveSettings(s: Settings): Promise<void>}let toastSeq = 0// Deltas pile up here and reach React once per frame.let pendingText = ''let pendingReasoning = ''let frame = 0export const useStore = create<State>((set, get) => {const c = () => {const client = get().clientif (!client) throw new Error('not initialized')return client}const flush = () => {frame = 0const s = get().streamingif (!s || (!pendingText && !pendingReasoning)) returnset({ streaming: { ...s, text: s.text + pendingText, reasoning: s.reasoning + pendingReasoning } })pendingText = ''pendingReasoning = ''}const schedule = () => {if (frame) returnframe =typeof requestAnimationFrame === 'function'? requestAnimationFrame(flush): (setTimeout(flush, 16) as unknown as number)}const finish = (message: Message) => {flush()const v = get().viewif (v && v.conversation.id === message.conversation_id) {set({ view: { ...v, messages: v.messages.map((m) => (m.id === message.id ? message : m)) } })}set({ streaming: null })void get().refreshConversations()void get().refreshSpend()}const onEvent = (e: StreamEvent) => {switch (e.type) {case 'delta':pendingText += e.textschedule()breakcase 'reasoning':pendingReasoning += e.textschedule()breakcase 'usage':breakcase 'done':finish(e.message)breakcase 'error':finish(e.message)get().toast(e.error, 'error')break}}const startTurn = async (conversationId: string, text: string, attachmentIds: string[]) => {pendingText = ''pendingReasoning = ''const ack = await c().sendMessage({ conversation_id: conversationId, text, attachment_ids: attachmentIds }, onEvent)const v = get().viewif (v && v.conversation.id === conversationId) {const add = [ack.user_message, ack.assistant_message].filter((m): m is Message => !!m)set({ view: { ...v, messages: [...v.messages, ...add] } })}// A fast stream can finish before the ack; only mark it live if it has not.const live = get().view?.messages.find((m) => m.id === ack.assistant_message.id)if (!live || live.status === 'streaming') {set({streaming: {streamId: ack.stream_id,assistantId: ack.assistant_message.id,text: pendingText,reasoning: pendingReasoning,},})pendingText = ''pendingReasoning = ''}}return {client: null,info: null,settings: null,providers: [],models: {},keys: {},presets: [],conversations: [],view: null,streaming: null,drafts: [],spend: null,panel: null,toasts: [],pending: { providerId: 'deepinfra', modelId: '', presetId: null },async init(client) {set({ client })// Listen first: the shell refreshes model prices at startup and may// announce it before the first reads below have finished.await Promise.all([client.on('conversations-changed', () => void get().refreshConversations()),client.on('models-updated', () => get().providers.forEach((p) => void get().refreshModels(p.id))),client.on('settings-changed', (s) => set({ settings: s as Settings })),client.on('open-conversation', (id) => void get().open(id as string)),client.on('new-chat', () => get().newChat()),client.on('open-settings', () => set({ panel: 'settings' })),])const [info, settings, providers, presets] = await Promise.all([client.appInfo(),client.getSettings(),client.listProviders(),client.listPresets(),])set({info,settings,providers,presets,pending: { providerId: settings.default_provider_id, modelId: settings.default_model_id, presetId: null },})await Promise.all([...providers.map((p) => get().refreshModels(p.id)),get().refreshProviders(),get().refreshConversations(),get().refreshSpend(),])},toast(text, tone = 'info') {const id = ++toastSeqset({ toasts: [...get().toasts, { id, text, tone }] })setTimeout(() => set({ toasts: get().toasts.filter((t) => t.id !== id) }), tone === 'error' ? 7000 : 3500)},setPanel(panel) {set({ panel })},async refreshConversations() {const conversations = await c().listConversations(false)set({ conversations })// The chat is titled from its first message on the Rust side; keep the// open one in step, or its header says "New chat" until reopened.const v = get().viewconst mine = v && conversations.find((x) => x.id === v.conversation.id)if (v && mine && (mine.title !== v.conversation.title || mine.pinned !== v.conversation.pinned)) {set({ view: { ...v, conversation: { ...v.conversation, title: mine.title, pinned: mine.pinned } } })}},async refreshSpend() {set({ spend: await c().spendSummary() })},async refreshModels(providerId, live = false) {const r = live ? await c().refreshModels(providerId) : await c().listModels(providerId)set({ models: { ...get().models, [providerId]: r } })if (live && r.warning) get().toast(`Could not refresh models: ${r.warning}`, 'error')},async refreshProviders() {const providers = await c().listProviders()const keys: Record<string, boolean> = {}await Promise.all(providers.map(async (p) => (keys[p.id] = await c().hasApiKey(p.id))))set({ providers, keys })},async open(id) {const view = await c().getConversation(id)set({ view, panel: null })},newChat() {const s = get().settingsconst cur = get().currentModel()set({view: null,drafts: [],panel: null,pending: {providerId: cur.providerId || s?.default_provider_id || 'deepinfra',modelId: cur.modelId || s?.default_model_id || '',presetId: null,},})},currentModel() {const v = get().viewconst providerId = v ? v.conversation.provider_id : get().pending.providerIdconst modelId = v ? v.conversation.model_id : get().pending.modelIdconst info = get().models[providerId]?.models.find((m) => m.id === modelId) ?? nullreturn { providerId, modelId, info }},async chooseModel(providerId, modelId) {const v = get().viewif (v) {await c().setConversationModel(v.conversation.id, providerId, modelId)set({ view: { ...v, conversation: { ...v.conversation, provider_id: providerId, model_id: modelId } } })} else {set({ pending: { ...get().pending, providerId, modelId } })}},async applyPreset(preset) {const v = get().viewif (v) {await c().setSystemPrompt(v.conversation.id, preset?.system_prompt ?? '', preset?.id ?? null)if (preset?.provider_id && preset.model_id)await c().setConversationModel(v.conversation.id, preset.provider_id, preset.model_id)await get().open(v.conversation.id)} else {set({pending: {providerId: preset?.provider_id ?? get().pending.providerId,modelId: preset?.model_id ?? get().pending.modelId,presetId: preset?.id ?? null,},})}},async send(text) {if (get().streaming) return falseconst drafts = get().draftsif (!text.trim() && !drafts.length) return falseconst s = get().settingsconst spend = get().spendif (s?.confirm_over_budget &&spend?.budget_level === 2 &&!window.confirm("You are over this month's budget. Send anyway?"))return falsetry {let v = get().viewif (!v) {const p = get().pendingconst conv = await c().createConversation(p.providerId, p.modelId, p.presetId, 'main')v = { conversation: conv, messages: [] }set({ view: v })}set({ drafts: [] })await startTurn(v.conversation.id,text,drafts.map((d) => d.id),)void get().refreshConversations()return true} catch (err) {set({ drafts })get().toast(String(err), 'error')return false}},async stop() {const s = get().streamingif (s) await c().cancelStream(s.streamId)},async regenerate() {const v = get().viewif (!v || get().streaming) returnconst last = [...v.messages].reverse().find((m) => m.role === 'assistant')if (!last) returnawait c().truncateFrom(v.conversation.id, last.id)await get().open(v.conversation.id)try {await startTurn(v.conversation.id, '', [])} catch (err) {get().toast(String(err), 'error')}},async editAndResend(messageId, text) {const v = get().viewif (!v || get().streaming) returnconst original = v.messages.find((m) => m.id === messageId)await c().truncateFrom(v.conversation.id, messageId)await get().open(v.conversation.id)try {await startTurn(v.conversation.id, text, original?.attachments.map((a) => a.id) ?? [])} catch (err) {get().toast(String(err), 'error')}},addDrafts(a) {set({ drafts: [...get().drafts, ...a] })},async removeDraft(id) {set({ drafts: get().drafts.filter((d) => d.id !== id) })await c().discardAttachment(id)},async saveSettings(s) {const r = await c().saveSettings(s)set({ settings: r.settings })if (r.shortcut_error) get().toast(r.shortcut_error, 'error')void get().refreshSpend()},}})/** The model the current (or next) chat uses. Compared field by field, so it* only re-renders when the model actually changes. */export const useCurrentModel = () => useStore(useShallow((s) => s.currentModel()))