mattrguerra/cortexmainPrivate on GitHub, published here in full

store.ts

Language TypeScript12.4 KB391 lines

src/state/store.ts

  1. import { create } from 'zustand'
  2. import { useShallow } from 'zustand/react/shallow'
  3. import type { IpcClient } from '../ipc/client'
  4. import type { AppInfo } from '../ipc/gen/AppInfo'
  5. import type { AttachmentMeta } from '../ipc/gen/AttachmentMeta'
  6. import type { ConversationSummary } from '../ipc/gen/ConversationSummary'
  7. import type { ConversationView } from '../ipc/gen/ConversationView'
  8. import type { Message } from '../ipc/gen/Message'
  9. import type { ModelInfo } from '../ipc/gen/ModelInfo'
  10. import type { ModelsResponse } from '../ipc/gen/ModelsResponse'
  11. import type { Preset } from '../ipc/gen/Preset'
  12. import type { Provider } from '../ipc/gen/Provider'
  13. import type { Settings } from '../ipc/gen/Settings'
  14. import type { SpendSummary } from '../ipc/gen/SpendSummary'
  15. import type { StreamEvent } from '../ipc/gen/StreamEvent'
  16. export type Panel = null | 'search' | 'settings' | 'usage'
  17. export interface Streaming {
  18. streamId: string
  19. assistantId: string
  20. text: string
  21. reasoning: string
  22. }
  23. export interface Toast {
  24. id: number
  25. text: string
  26. tone: 'info' | 'error'
  27. }
  28. interface State {
  29. client: IpcClient | null
  30. info: AppInfo | null
  31. settings: Settings | null
  32. providers: Provider[]
  33. models: Record<string, ModelsResponse>
  34. keys: Record<string, boolean>
  35. presets: Preset[]
  36. conversations: ConversationSummary[]
  37. view: ConversationView | null
  38. streaming: Streaming | null
  39. drafts: AttachmentMeta[]
  40. spend: SpendSummary | null
  41. panel: Panel
  42. toasts: Toast[]
  43. /** For a new chat not yet created: the model it will use. */
  44. pending: { providerId: string; modelId: string; presetId: string | null }
  45. init(client: IpcClient): Promise<void>
  46. toast(text: string, tone?: Toast['tone']): void
  47. setPanel(p: Panel): void
  48. refreshConversations(): Promise<void>
  49. refreshSpend(): Promise<void>
  50. refreshModels(providerId: string, live?: boolean): Promise<void>
  51. refreshProviders(): Promise<void>
  52. open(id: string): Promise<void>
  53. newChat(): void
  54. currentModel(): { providerId: string; modelId: string; info: ModelInfo | null }
  55. chooseModel(providerId: string, modelId: string): Promise<void>
  56. applyPreset(preset: Preset | null): Promise<void>
  57. send(text: string): Promise<boolean>
  58. stop(): Promise<void>
  59. regenerate(): Promise<void>
  60. editAndResend(messageId: string, text: string): Promise<void>
  61. addDrafts(a: AttachmentMeta[]): void
  62. removeDraft(id: string): Promise<void>
  63. saveSettings(s: Settings): Promise<void>
  64. }
  65. let toastSeq = 0
  66. // Deltas pile up here and reach React once per frame.
  67. let pendingText = ''
  68. let pendingReasoning = ''
  69. let frame = 0
  70. export const useStore = create<State>((set, get) => {
  71. const c = () => {
  72. const client = get().client
  73. if (!client) throw new Error('not initialized')
  74. return client
  75. }
  76. const flush = () => {
  77. frame = 0
  78. const s = get().streaming
  79. if (!s || (!pendingText && !pendingReasoning)) return
  80. set({ streaming: { ...s, text: s.text + pendingText, reasoning: s.reasoning + pendingReasoning } })
  81. pendingText = ''
  82. pendingReasoning = ''
  83. }
  84. const schedule = () => {
  85. if (frame) return
  86. frame =
  87. typeof requestAnimationFrame === 'function'
  88. ? requestAnimationFrame(flush)
  89. : (setTimeout(flush, 16) as unknown as number)
  90. }
  91. const finish = (message: Message) => {
  92. flush()
  93. const v = get().view
  94. if (v && v.conversation.id === message.conversation_id) {
  95. set({ view: { ...v, messages: v.messages.map((m) => (m.id === message.id ? message : m)) } })
  96. }
  97. set({ streaming: null })
  98. void get().refreshConversations()
  99. void get().refreshSpend()
  100. }
  101. const onEvent = (e: StreamEvent) => {
  102. switch (e.type) {
  103. case 'delta':
  104. pendingText += e.text
  105. schedule()
  106. break
  107. case 'reasoning':
  108. pendingReasoning += e.text
  109. schedule()
  110. break
  111. case 'usage':
  112. break
  113. case 'done':
  114. finish(e.message)
  115. break
  116. case 'error':
  117. finish(e.message)
  118. get().toast(e.error, 'error')
  119. break
  120. }
  121. }
  122. const startTurn = async (conversationId: string, text: string, attachmentIds: string[]) => {
  123. pendingText = ''
  124. pendingReasoning = ''
  125. const ack = await c().sendMessage({ conversation_id: conversationId, text, attachment_ids: attachmentIds }, onEvent)
  126. const v = get().view
  127. if (v && v.conversation.id === conversationId) {
  128. const add = [ack.user_message, ack.assistant_message].filter((m): m is Message => !!m)
  129. set({ view: { ...v, messages: [...v.messages, ...add] } })
  130. }
  131. // A fast stream can finish before the ack; only mark it live if it has not.
  132. const live = get().view?.messages.find((m) => m.id === ack.assistant_message.id)
  133. if (!live || live.status === 'streaming') {
  134. set({
  135. streaming: {
  136. streamId: ack.stream_id,
  137. assistantId: ack.assistant_message.id,
  138. text: pendingText,
  139. reasoning: pendingReasoning,
  140. },
  141. })
  142. pendingText = ''
  143. pendingReasoning = ''
  144. }
  145. }
  146. return {
  147. client: null,
  148. info: null,
  149. settings: null,
  150. providers: [],
  151. models: {},
  152. keys: {},
  153. presets: [],
  154. conversations: [],
  155. view: null,
  156. streaming: null,
  157. drafts: [],
  158. spend: null,
  159. panel: null,
  160. toasts: [],
  161. pending: { providerId: 'deepinfra', modelId: '', presetId: null },
  162. async init(client) {
  163. set({ client })
  164. // Listen first: the shell refreshes model prices at startup and may
  165. // announce it before the first reads below have finished.
  166. await Promise.all([
  167. client.on('conversations-changed', () => void get().refreshConversations()),
  168. client.on('models-updated', () => get().providers.forEach((p) => void get().refreshModels(p.id))),
  169. client.on('settings-changed', (s) => set({ settings: s as Settings })),
  170. client.on('open-conversation', (id) => void get().open(id as string)),
  171. client.on('new-chat', () => get().newChat()),
  172. client.on('open-settings', () => set({ panel: 'settings' })),
  173. ])
  174. const [info, settings, providers, presets] = await Promise.all([
  175. client.appInfo(),
  176. client.getSettings(),
  177. client.listProviders(),
  178. client.listPresets(),
  179. ])
  180. set({
  181. info,
  182. settings,
  183. providers,
  184. presets,
  185. pending: { providerId: settings.default_provider_id, modelId: settings.default_model_id, presetId: null },
  186. })
  187. await Promise.all([
  188. ...providers.map((p) => get().refreshModels(p.id)),
  189. get().refreshProviders(),
  190. get().refreshConversations(),
  191. get().refreshSpend(),
  192. ])
  193. },
  194. toast(text, tone = 'info') {
  195. const id = ++toastSeq
  196. set({ toasts: [...get().toasts, { id, text, tone }] })
  197. setTimeout(() => set({ toasts: get().toasts.filter((t) => t.id !== id) }), tone === 'error' ? 7000 : 3500)
  198. },
  199. setPanel(panel) {
  200. set({ panel })
  201. },
  202. async refreshConversations() {
  203. const conversations = await c().listConversations(false)
  204. set({ conversations })
  205. // The chat is titled from its first message on the Rust side; keep the
  206. // open one in step, or its header says "New chat" until reopened.
  207. const v = get().view
  208. const mine = v && conversations.find((x) => x.id === v.conversation.id)
  209. if (v && mine && (mine.title !== v.conversation.title || mine.pinned !== v.conversation.pinned)) {
  210. set({ view: { ...v, conversation: { ...v.conversation, title: mine.title, pinned: mine.pinned } } })
  211. }
  212. },
  213. async refreshSpend() {
  214. set({ spend: await c().spendSummary() })
  215. },
  216. async refreshModels(providerId, live = false) {
  217. const r = live ? await c().refreshModels(providerId) : await c().listModels(providerId)
  218. set({ models: { ...get().models, [providerId]: r } })
  219. if (live && r.warning) get().toast(`Could not refresh models: ${r.warning}`, 'error')
  220. },
  221. async refreshProviders() {
  222. const providers = await c().listProviders()
  223. const keys: Record<string, boolean> = {}
  224. await Promise.all(providers.map(async (p) => (keys[p.id] = await c().hasApiKey(p.id))))
  225. set({ providers, keys })
  226. },
  227. async open(id) {
  228. const view = await c().getConversation(id)
  229. set({ view, panel: null })
  230. },
  231. newChat() {
  232. const s = get().settings
  233. const cur = get().currentModel()
  234. set({
  235. view: null,
  236. drafts: [],
  237. panel: null,
  238. pending: {
  239. providerId: cur.providerId || s?.default_provider_id || 'deepinfra',
  240. modelId: cur.modelId || s?.default_model_id || '',
  241. presetId: null,
  242. },
  243. })
  244. },
  245. currentModel() {
  246. const v = get().view
  247. const providerId = v ? v.conversation.provider_id : get().pending.providerId
  248. const modelId = v ? v.conversation.model_id : get().pending.modelId
  249. const info = get().models[providerId]?.models.find((m) => m.id === modelId) ?? null
  250. return { providerId, modelId, info }
  251. },
  252. async chooseModel(providerId, modelId) {
  253. const v = get().view
  254. if (v) {
  255. await c().setConversationModel(v.conversation.id, providerId, modelId)
  256. set({ view: { ...v, conversation: { ...v.conversation, provider_id: providerId, model_id: modelId } } })
  257. } else {
  258. set({ pending: { ...get().pending, providerId, modelId } })
  259. }
  260. },
  261. async applyPreset(preset) {
  262. const v = get().view
  263. if (v) {
  264. await c().setSystemPrompt(v.conversation.id, preset?.system_prompt ?? '', preset?.id ?? null)
  265. if (preset?.provider_id && preset.model_id)
  266. await c().setConversationModel(v.conversation.id, preset.provider_id, preset.model_id)
  267. await get().open(v.conversation.id)
  268. } else {
  269. set({
  270. pending: {
  271. providerId: preset?.provider_id ?? get().pending.providerId,
  272. modelId: preset?.model_id ?? get().pending.modelId,
  273. presetId: preset?.id ?? null,
  274. },
  275. })
  276. }
  277. },
  278. async send(text) {
  279. if (get().streaming) return false
  280. const drafts = get().drafts
  281. if (!text.trim() && !drafts.length) return false
  282. const s = get().settings
  283. const spend = get().spend
  284. if (
  285. s?.confirm_over_budget &&
  286. spend?.budget_level === 2 &&
  287. !window.confirm("You are over this month's budget. Send anyway?")
  288. )
  289. return false
  290. try {
  291. let v = get().view
  292. if (!v) {
  293. const p = get().pending
  294. const conv = await c().createConversation(p.providerId, p.modelId, p.presetId, 'main')
  295. v = { conversation: conv, messages: [] }
  296. set({ view: v })
  297. }
  298. set({ drafts: [] })
  299. await startTurn(
  300. v.conversation.id,
  301. text,
  302. drafts.map((d) => d.id),
  303. )
  304. void get().refreshConversations()
  305. return true
  306. } catch (err) {
  307. set({ drafts })
  308. get().toast(String(err), 'error')
  309. return false
  310. }
  311. },
  312. async stop() {
  313. const s = get().streaming
  314. if (s) await c().cancelStream(s.streamId)
  315. },
  316. async regenerate() {
  317. const v = get().view
  318. if (!v || get().streaming) return
  319. const last = [...v.messages].reverse().find((m) => m.role === 'assistant')
  320. if (!last) return
  321. await c().truncateFrom(v.conversation.id, last.id)
  322. await get().open(v.conversation.id)
  323. try {
  324. await startTurn(v.conversation.id, '', [])
  325. } catch (err) {
  326. get().toast(String(err), 'error')
  327. }
  328. },
  329. async editAndResend(messageId, text) {
  330. const v = get().view
  331. if (!v || get().streaming) return
  332. const original = v.messages.find((m) => m.id === messageId)
  333. await c().truncateFrom(v.conversation.id, messageId)
  334. await get().open(v.conversation.id)
  335. try {
  336. await startTurn(v.conversation.id, text, original?.attachments.map((a) => a.id) ?? [])
  337. } catch (err) {
  338. get().toast(String(err), 'error')
  339. }
  340. },
  341. addDrafts(a) {
  342. set({ drafts: [...get().drafts, ...a] })
  343. },
  344. async removeDraft(id) {
  345. set({ drafts: get().drafts.filter((d) => d.id !== id) })
  346. await c().discardAttachment(id)
  347. },
  348. async saveSettings(s) {
  349. const r = await c().saveSettings(s)
  350. set({ settings: r.settings })
  351. if (r.shortcut_error) get().toast(r.shortcut_error, 'error')
  352. void get().refreshSpend()
  353. },
  354. }
  355. })
  356. /** The model the current (or next) chat uses. Compared field by field, so it
  357. * only re-renders when the model actually changes. */
  358. export const useCurrentModel = () => useStore(useShallow((s) => s.currentModel()))