ai-assistant-state.tsx659 lines · main
| 1 | // @ts-nocheck |
| 2 | import { Chat, type UIMessage as MessageType } from '@ai-sdk/react' |
| 3 | import { DefaultChatTransport, lastAssistantMessageIsCompleteWithApprovalResponses } from 'ai' |
| 4 | import { LOCAL_STORAGE_KEYS } from 'common' |
| 5 | import { DBSchema, IDBPDatabase, openDB } from 'idb' |
| 6 | import { debounce } from 'lodash' |
| 7 | import { createContext, PropsWithChildren, useContext, useEffect, useState } from 'react' |
| 8 | import { v4 as uuidv4 } from 'uuid' |
| 9 | import { proxy, ref, snapshot, subscribe, useSnapshot } from 'valtio' |
| 10 | |
| 11 | import { constructHeaders } from '@/data/fetchers' |
| 12 | import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' |
| 13 | import { prepareMessagesForAPI } from '@/lib/ai/message-utils' |
| 14 | import { isKnownAssistantModelId } from '@/lib/ai/model.utils' |
| 15 | import type { AssistantModelId } from '@/lib/ai/model.utils' |
| 16 | import { BASE_PATH, IS_PLATFORM } from '@/lib/constants' |
| 17 | |
| 18 | type SuggestionsType = { |
| 19 | title: string |
| 20 | prompts?: { label: string; description: string }[] |
| 21 | } |
| 22 | |
| 23 | export type AssistantMessageType = MessageType |
| 24 | |
| 25 | export type SqlSnippet = string | { label: string; content: string } |
| 26 | |
| 27 | export type AssistantModel = AssistantModelId |
| 28 | |
| 29 | type ChatSession = { |
| 30 | id: string |
| 31 | name: string |
| 32 | messages: AssistantMessageType[] |
| 33 | createdAt: Date |
| 34 | updatedAt: Date |
| 35 | } |
| 36 | |
| 37 | export type AiAssistantContext = { |
| 38 | projectRef?: string |
| 39 | orgSlug?: string |
| 40 | connectionString?: string |
| 41 | } |
| 42 | |
| 43 | type AiAssistantData = { |
| 44 | initialInput: string |
| 45 | sqlSnippets?: SqlSnippet[] |
| 46 | suggestions?: SuggestionsType |
| 47 | tables: { schema: string; name: string }[] |
| 48 | chats: Record<string, ChatSession> |
| 49 | activeChatId?: string |
| 50 | model?: AssistantModel |
| 51 | context: AiAssistantContext |
| 52 | } |
| 53 | |
| 54 | // Data structure stored in IndexedDB |
| 55 | type StoredAiAssistantState = { |
| 56 | projectRef: string |
| 57 | activeChatId?: string |
| 58 | chats: Record<string, ChatSession> |
| 59 | model?: AssistantModel |
| 60 | } |
| 61 | |
| 62 | const INITIAL_AI_ASSISTANT: AiAssistantData = { |
| 63 | initialInput: '', |
| 64 | sqlSnippets: undefined, |
| 65 | suggestions: undefined, |
| 66 | tables: [], |
| 67 | chats: {}, |
| 68 | activeChatId: undefined, |
| 69 | model: undefined, |
| 70 | context: {}, |
| 71 | } |
| 72 | |
| 73 | const DB_NAME = 'ai-assistant-db' |
| 74 | const DB_VERSION = 1 |
| 75 | const STORE_NAME = 'assistantState' |
| 76 | |
| 77 | interface AiAssistantDB extends DBSchema { |
| 78 | [STORE_NAME]: { |
| 79 | key: string |
| 80 | value: StoredAiAssistantState |
| 81 | } |
| 82 | } |
| 83 | |
| 84 | async function openAiDb(): Promise<IDBPDatabase<AiAssistantDB>> { |
| 85 | return openDB<AiAssistantDB>(DB_NAME, DB_VERSION, { |
| 86 | upgrade(db) { |
| 87 | if (!db.objectStoreNames.contains(STORE_NAME)) { |
| 88 | db.createObjectStore(STORE_NAME, { keyPath: 'projectRef' }) |
| 89 | } |
| 90 | }, |
| 91 | }) |
| 92 | } |
| 93 | |
| 94 | async function getAiState(projectRef: string): Promise<StoredAiAssistantState | undefined> { |
| 95 | if (!projectRef) return undefined |
| 96 | try { |
| 97 | const db = await openAiDb() |
| 98 | return await db.get(STORE_NAME, projectRef) |
| 99 | } catch (error) { |
| 100 | console.error('Failed to get AI state from IndexedDB:', error) |
| 101 | return undefined |
| 102 | } |
| 103 | } |
| 104 | |
| 105 | async function saveAiState(state: StoredAiAssistantState): Promise<void> { |
| 106 | if (!state.projectRef) return |
| 107 | try { |
| 108 | const db = await openAiDb() |
| 109 | await db.put(STORE_NAME, state) |
| 110 | } catch (error) { |
| 111 | console.error('Failed to save AI state to IndexedDB:', error) |
| 112 | } |
| 113 | } |
| 114 | |
| 115 | async function clearStorage(): Promise<void> { |
| 116 | try { |
| 117 | const db = await openAiDb() |
| 118 | await db.clear(STORE_NAME) |
| 119 | } catch (error) { |
| 120 | console.error('Failed to clear AI state from IndexedDB:', error) |
| 121 | } |
| 122 | } |
| 123 | |
| 124 | // Helper function to sanitize objects to ensure they're cloneable |
| 125 | // Issue due to addToolResult |
| 126 | function sanitizeForCloning(obj: any): any { |
| 127 | if (obj === null || obj === undefined) return obj |
| 128 | if (typeof obj !== 'object') return obj |
| 129 | return JSON.parse(JSON.stringify(obj)) |
| 130 | } |
| 131 | |
| 132 | // Helper function to load state from IndexedDB |
| 133 | async function loadFromIndexedDB(projectRef: string): Promise<StoredAiAssistantState | null> { |
| 134 | try { |
| 135 | const persistedState = await getAiState(projectRef) |
| 136 | if (persistedState) { |
| 137 | // Revive dates and sanitize message data |
| 138 | Object.values(persistedState.chats).forEach((chat: ChatSession) => { |
| 139 | if (chat && typeof chat === 'object') { |
| 140 | chat.createdAt = new Date(chat.createdAt) |
| 141 | chat.updatedAt = new Date(chat.updatedAt) |
| 142 | |
| 143 | // Sanitize message parts to remove proxy objects |
| 144 | if (chat.messages) { |
| 145 | chat.messages.forEach((message: any) => { |
| 146 | if (message.parts) { |
| 147 | message.parts = message.parts.map((part: any) => sanitizeForCloning(part)) |
| 148 | } |
| 149 | }) |
| 150 | } |
| 151 | } |
| 152 | }) |
| 153 | return persistedState |
| 154 | } |
| 155 | } catch (error) { |
| 156 | console.error('Error loading AI state from IndexedDB:', error) |
| 157 | } |
| 158 | return null |
| 159 | } |
| 160 | |
| 161 | // Helper function to attempt migration from localStorage |
| 162 | async function tryMigrateFromLocalStorage( |
| 163 | projectRef: string |
| 164 | ): Promise<StoredAiAssistantState | null> { |
| 165 | const stored = localStorage.getItem(LOCAL_STORAGE_KEYS.AI_ASSISTANT_STATE(projectRef)) |
| 166 | if (!stored) { |
| 167 | return null |
| 168 | } |
| 169 | |
| 170 | let migratedState: StoredAiAssistantState | null = null |
| 171 | try { |
| 172 | const parsedFromLocalStorage = JSON.parse(stored, (key, value) => { |
| 173 | if ((key === 'createdAt' || key === 'updatedAt') && value) { |
| 174 | return new Date(value) |
| 175 | } |
| 176 | return value |
| 177 | }) |
| 178 | |
| 179 | if (parsedFromLocalStorage && typeof parsedFromLocalStorage.chats === 'object') { |
| 180 | migratedState = { |
| 181 | projectRef: projectRef, |
| 182 | activeChatId: parsedFromLocalStorage.activeChatId, |
| 183 | chats: parsedFromLocalStorage.chats, |
| 184 | model: parsedFromLocalStorage.model ?? INITIAL_AI_ASSISTANT.model, |
| 185 | } |
| 186 | } else { |
| 187 | console.warn('Data in localStorage is not in the expected format, ignoring.') |
| 188 | // Clean up invalid data |
| 189 | localStorage.removeItem(LOCAL_STORAGE_KEYS.AI_ASSISTANT_STATE(projectRef)) |
| 190 | } |
| 191 | } catch (error) { |
| 192 | console.error('Failed to parse state from localStorage:', error) |
| 193 | // Clear potentially corrupted data |
| 194 | localStorage.removeItem(LOCAL_STORAGE_KEYS.AI_ASSISTANT_STATE(projectRef)) |
| 195 | } |
| 196 | |
| 197 | if (migratedState) { |
| 198 | try { |
| 199 | await saveAiState(migratedState) |
| 200 | localStorage.removeItem(LOCAL_STORAGE_KEYS.AI_ASSISTANT_STATE(projectRef)) |
| 201 | return migratedState |
| 202 | } catch (saveError) { |
| 203 | console.error('Failed to save migrated state to IndexedDB:', saveError) |
| 204 | return null |
| 205 | } |
| 206 | } |
| 207 | |
| 208 | return null |
| 209 | } |
| 210 | |
| 211 | // Helper function to ensure an active chat exists or initialize a new one |
| 212 | function ensureActiveChatOrInitialize(state: AiAssistantState) { |
| 213 | // Ensure an active chat exists after loading/migration |
| 214 | if (!state.activeChatId || !state.chats[state.activeChatId]) { |
| 215 | const chatIds = Object.keys(state.chats) |
| 216 | if (chatIds.length > 0) { |
| 217 | // Select the most recently updated chat |
| 218 | state.activeChatId = chatIds.sort( |
| 219 | (a, b) => |
| 220 | (state.chats[b].updatedAt?.getTime() || 0) - (state.chats[a].updatedAt?.getTime() || 0) |
| 221 | )[0] |
| 222 | } else { |
| 223 | // If loaded/migrated state had no chats, create a new one |
| 224 | state.newChat() |
| 225 | } |
| 226 | } |
| 227 | } |
| 228 | |
| 229 | function createChatInstance( |
| 230 | state: AiAssistantState, |
| 231 | options: { id: string; initialMessages: MessageType[] } |
| 232 | ) { |
| 233 | return new Chat<MessageType>({ |
| 234 | id: options.id, |
| 235 | messages: options.initialMessages.map((message) => sanitizeForCloning(message)), |
| 236 | sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithApprovalResponses, |
| 237 | transport: new DefaultChatTransport({ |
| 238 | api: `${BASE_PATH}/api/ai/sql/generate-v4`, |
| 239 | fetch: async (url, init) => { |
| 240 | const response = await globalThis.fetch(url as RequestInfo, init) |
| 241 | const spanId = response.headers.get('x-braintrust-span-id') |
| 242 | if (spanId) { |
| 243 | state.pendingSpanIds[options.id] = spanId |
| 244 | } |
| 245 | return response |
| 246 | }, |
| 247 | async prepareSendMessagesRequest({ messages, ...opts }) { |
| 248 | const cleanedMessages = prepareMessagesForAPI(messages) |
| 249 | const headerData = await constructHeaders() |
| 250 | const authorizationHeader = headerData.get('Authorization') |
| 251 | |
| 252 | // Get the chat specific to this request to ensure we have the correct name |
| 253 | const chat = state.chats[options.id] |
| 254 | |
| 255 | return { |
| 256 | ...opts, |
| 257 | body: { |
| 258 | messages: cleanedMessages, |
| 259 | projectRef: state.context.projectRef, |
| 260 | connectionString: state.context.connectionString, |
| 261 | chatId: options.id, |
| 262 | chatName: chat?.name, |
| 263 | orgSlug: state.context.orgSlug, |
| 264 | context: state.context, |
| 265 | model: state.model, |
| 266 | ...opts.body, |
| 267 | }, |
| 268 | ...(IS_PLATFORM ? { headers: { Authorization: authorizationHeader ?? '' } } : {}), |
| 269 | } |
| 270 | }, |
| 271 | }), |
| 272 | async onToolCall({ toolCall }) { |
| 273 | if (toolCall.dynamic) { |
| 274 | return |
| 275 | } |
| 276 | |
| 277 | if (toolCall.toolName === 'rename_chat') { |
| 278 | const { newName } = toolCall.input as { newName: string } |
| 279 | |
| 280 | if (options.id && newName?.trim()) { |
| 281 | state.renameChat(options.id, newName.trim()) |
| 282 | } |
| 283 | } |
| 284 | }, |
| 285 | onFinish(_result) { |
| 286 | // Sync messages back to state |
| 287 | const chatInstance = state.chatInstances[options.id] |
| 288 | if (chatInstance) { |
| 289 | const messages = chatInstance.messages |
| 290 | const chat = state.chats[options.id] |
| 291 | if (chat) { |
| 292 | chat.messages = messages |
| 293 | chat.updatedAt = new Date() |
| 294 | } |
| 295 | |
| 296 | // Associate pending span ID with the last assistant message |
| 297 | const pendingSpanId = state.pendingSpanIds[options.id] |
| 298 | if (pendingSpanId) { |
| 299 | const lastAssistantMsg = [...messages].reverse().find((m) => m.role === 'assistant') |
| 300 | if (lastAssistantMsg) { |
| 301 | state.messageSpanIds[lastAssistantMsg.id] = pendingSpanId |
| 302 | } |
| 303 | delete state.pendingSpanIds[options.id] |
| 304 | } |
| 305 | } |
| 306 | }, |
| 307 | }) |
| 308 | } |
| 309 | |
| 310 | export const createAiAssistantState = (): AiAssistantState => { |
| 311 | // Initialize with defaults, loading happens asynchronously in the provider |
| 312 | const initialState = { ...INITIAL_AI_ASSISTANT } |
| 313 | |
| 314 | const state: AiAssistantState = proxy({ |
| 315 | ...initialState, // Spread initial values directly |
| 316 | chatInstances: {}, |
| 317 | pendingSpanIds: {}, |
| 318 | messageSpanIds: {}, |
| 319 | |
| 320 | setContext: (context: Partial<AiAssistantContext>) => { |
| 321 | state.context = { ...state.context, ...context } |
| 322 | }, |
| 323 | |
| 324 | resetAiAssistantPanel: () => { |
| 325 | Object.assign(state, INITIAL_AI_ASSISTANT) |
| 326 | }, |
| 327 | |
| 328 | setModel: (model: AssistantModel) => { |
| 329 | state.model = model |
| 330 | }, |
| 331 | |
| 332 | // Chat management |
| 333 | get activeChat(): ChatSession | undefined { |
| 334 | return state.activeChatId ? state.chats[state.activeChatId] : undefined |
| 335 | }, |
| 336 | |
| 337 | newChat: ( |
| 338 | options?: { name?: string; initialMessage?: string } & Partial< |
| 339 | Pick<AiAssistantData, 'initialInput' | 'sqlSnippets' | 'suggestions' | 'tables'> |
| 340 | > |
| 341 | ) => { |
| 342 | const chatId = uuidv4() |
| 343 | const newChat: ChatSession = { |
| 344 | id: chatId, |
| 345 | name: options?.name ?? 'New chat', |
| 346 | messages: [], |
| 347 | createdAt: new Date(), |
| 348 | updatedAt: new Date(), |
| 349 | } |
| 350 | |
| 351 | state.chats = { |
| 352 | ...state.chats, |
| 353 | [chatId]: newChat, |
| 354 | } |
| 355 | state.activeChatId = chatId |
| 356 | |
| 357 | // Create new chat instance |
| 358 | const chatInstance = createChatInstance(state, { id: chatId, initialMessages: [] }) |
| 359 | |
| 360 | state.chatInstances[chatId] = ref(chatInstance) |
| 361 | |
| 362 | // If initialMessage is provided, append it to the chat instance |
| 363 | if (options?.initialMessage) { |
| 364 | chatInstance.sendMessage({ |
| 365 | text: options.initialMessage, |
| 366 | }) |
| 367 | } |
| 368 | |
| 369 | // Update non-chat related state based on options, falling back to current state, then initial |
| 370 | state.initialInput = options?.initialInput ?? INITIAL_AI_ASSISTANT.initialInput |
| 371 | state.sqlSnippets = options?.sqlSnippets ?? INITIAL_AI_ASSISTANT.sqlSnippets |
| 372 | state.suggestions = options?.suggestions ?? INITIAL_AI_ASSISTANT.suggestions |
| 373 | state.tables = options?.tables ?? INITIAL_AI_ASSISTANT.tables |
| 374 | |
| 375 | return chatId |
| 376 | }, |
| 377 | |
| 378 | selectChat: (id: string) => { |
| 379 | if (id !== state.activeChatId) { |
| 380 | state.activeChatId = id |
| 381 | const chat = state.chats[id] |
| 382 | if (chat) { |
| 383 | if (!state.chatInstances[id]) { |
| 384 | state.chatInstances[id] = ref( |
| 385 | createChatInstance(state, { id, initialMessages: chat.messages }) |
| 386 | ) |
| 387 | } |
| 388 | } |
| 389 | } |
| 390 | }, |
| 391 | |
| 392 | deleteChat: (id: string) => { |
| 393 | const { [id]: _, ...remainingChats } = state.chats |
| 394 | state.chats = remainingChats |
| 395 | |
| 396 | if (id === state.activeChatId) { |
| 397 | const remainingChatIds = Object.keys(remainingChats) |
| 398 | state.activeChatId = remainingChatIds.length > 0 ? remainingChatIds[0] : undefined |
| 399 | |
| 400 | if (state.activeChatId) { |
| 401 | const chat = state.chats[state.activeChatId] |
| 402 | if (!state.chatInstances[state.activeChatId]) { |
| 403 | state.chatInstances[state.activeChatId] = ref( |
| 404 | createChatInstance(state, { id: state.activeChatId, initialMessages: chat.messages }) |
| 405 | ) |
| 406 | } |
| 407 | } |
| 408 | } |
| 409 | }, |
| 410 | |
| 411 | renameChat: (id: string, name: string) => { |
| 412 | const chat = state.chats[id] |
| 413 | if (chat && chat.name !== name) { |
| 414 | chat.name = name |
| 415 | chat.updatedAt = new Date() |
| 416 | } |
| 417 | }, |
| 418 | |
| 419 | clearMessages: () => { |
| 420 | const chat = state.activeChat |
| 421 | if (chat) { |
| 422 | chat.messages = [] |
| 423 | chat.updatedAt = new Date() |
| 424 | state.suggestions = undefined |
| 425 | state.sqlSnippets = [] |
| 426 | state.initialInput = '' |
| 427 | } |
| 428 | }, |
| 429 | |
| 430 | deleteMessagesAfter: (id: string, { includeSelf = true } = {}) => { |
| 431 | const chat = state.activeChat |
| 432 | if (!chat) return |
| 433 | |
| 434 | const messageIndex = chat.messages.findIndex((msg) => msg.id === id) |
| 435 | if (messageIndex === -1) return |
| 436 | |
| 437 | // Delete all messages from the target message (optionally including) to the end |
| 438 | const startIndex = includeSelf ? messageIndex : messageIndex + 1 |
| 439 | chat.messages.splice(startIndex) |
| 440 | chat.updatedAt = new Date() |
| 441 | }, |
| 442 | |
| 443 | saveMessage: (message: MessageType | MessageType[]) => { |
| 444 | const chat = state.activeChat |
| 445 | if (!chat) return |
| 446 | |
| 447 | const incomingMessages = Array.isArray(message) ? message : [message] |
| 448 | |
| 449 | const messagesToAdd: AssistantMessageType[] = [] |
| 450 | |
| 451 | incomingMessages.forEach((msg) => { |
| 452 | const index = chat.messages.findIndex((existing) => existing.id === msg.id) |
| 453 | |
| 454 | if (index !== -1) { |
| 455 | state.updateMessage(msg) |
| 456 | } else { |
| 457 | messagesToAdd.push(msg) |
| 458 | } |
| 459 | }) |
| 460 | |
| 461 | if (messagesToAdd.length > 0) { |
| 462 | chat.messages.push(...messagesToAdd) |
| 463 | chat.updatedAt = new Date() |
| 464 | } |
| 465 | }, |
| 466 | |
| 467 | updateMessage: (updatedMessage: MessageType) => { |
| 468 | const chat = state.activeChat |
| 469 | if (!chat) return |
| 470 | |
| 471 | const messageIndex = chat.messages.findIndex((msg) => msg.id === updatedMessage.id) |
| 472 | if (messageIndex !== -1) { |
| 473 | chat.messages[messageIndex] = updatedMessage |
| 474 | chat.updatedAt = new Date() |
| 475 | } |
| 476 | }, |
| 477 | |
| 478 | setSqlSnippets: (snippets: SqlSnippet[]) => { |
| 479 | state.sqlSnippets = snippets |
| 480 | }, |
| 481 | |
| 482 | clearSqlSnippets: () => { |
| 483 | state.sqlSnippets = undefined |
| 484 | state.suggestions = undefined |
| 485 | }, |
| 486 | |
| 487 | // --- New function to load persisted state --- |
| 488 | loadPersistedState: (persistedState: StoredAiAssistantState) => { |
| 489 | state.chats = persistedState.chats |
| 490 | state.activeChatId = persistedState.activeChatId |
| 491 | const storedModel = persistedState.model |
| 492 | state.model = |
| 493 | storedModel && isKnownAssistantModelId(storedModel) |
| 494 | ? storedModel |
| 495 | : INITIAL_AI_ASSISTANT.model |
| 496 | |
| 497 | // Ensure an active chat exists after loading |
| 498 | if (!state.activeChat) { |
| 499 | const chatIds = Object.keys(state.chats) |
| 500 | if (chatIds.length > 0) { |
| 501 | // Select the most recently updated chat |
| 502 | state.activeChatId = chatIds.sort( |
| 503 | (a, b) => |
| 504 | (state.chats[b].updatedAt?.getTime() || 0) - |
| 505 | (state.chats[a].updatedAt?.getTime() || 0) |
| 506 | )[0] |
| 507 | } else { |
| 508 | // If loaded state had no chats, create a new one |
| 509 | state.newChat() |
| 510 | } |
| 511 | } |
| 512 | |
| 513 | // Initialize chat instance for the active chat |
| 514 | if ( |
| 515 | state.activeChatId && |
| 516 | state.chats[state.activeChatId] && |
| 517 | !state.chatInstances[state.activeChatId] |
| 518 | ) { |
| 519 | state.chatInstances[state.activeChatId] = ref( |
| 520 | createChatInstance(state, { |
| 521 | id: state.activeChatId, |
| 522 | initialMessages: state.chats[state.activeChatId].messages, |
| 523 | }) |
| 524 | ) |
| 525 | } |
| 526 | }, |
| 527 | |
| 528 | clearStorage: async () => { |
| 529 | await clearStorage() |
| 530 | }, |
| 531 | }) |
| 532 | |
| 533 | return state |
| 534 | } |
| 535 | |
| 536 | export type AiAssistantState = AiAssistantData & { |
| 537 | resetAiAssistantPanel: () => void |
| 538 | activeChat: ChatSession | undefined |
| 539 | chatInstances: Record<string, Chat<MessageType>> |
| 540 | pendingSpanIds: Record<string, string> |
| 541 | messageSpanIds: Record<string, string> |
| 542 | setContext: (context: Partial<AiAssistantContext>) => void |
| 543 | setModel: (model: AssistantModel) => void |
| 544 | newChat: ( |
| 545 | options?: { name?: string; initialMessage?: string } & Partial< |
| 546 | Pick<AiAssistantData, 'initialInput' | 'sqlSnippets' | 'suggestions' | 'tables'> |
| 547 | > |
| 548 | ) => string |
| 549 | selectChat: (id: string) => void |
| 550 | deleteChat: (id: string) => void |
| 551 | renameChat: (id: string, name: string) => void |
| 552 | clearMessages: () => void |
| 553 | deleteMessagesAfter: (id: string, options?: { includeSelf?: boolean }) => void |
| 554 | saveMessage: (message: MessageType | MessageType[]) => void |
| 555 | updateMessage: (message: MessageType) => void |
| 556 | setSqlSnippets: (snippets: SqlSnippet[]) => void |
| 557 | clearSqlSnippets: () => void |
| 558 | loadPersistedState: (persistedState: StoredAiAssistantState) => void |
| 559 | clearStorage: () => Promise<void> |
| 560 | } |
| 561 | |
| 562 | export const AiAssistantStateContext = createContext<AiAssistantState>(createAiAssistantState()) |
| 563 | |
| 564 | export const AiAssistantStateContextProvider = ({ children }: PropsWithChildren) => { |
| 565 | const { data: project } = useSelectedProjectQuery() |
| 566 | // Initialize state. createAiAssistantState now just sets defaults. |
| 567 | const [state] = useState(() => createAiAssistantState()) |
| 568 | |
| 569 | // Effect to load state from IndexedDB on mount or projectRef change |
| 570 | useEffect(() => { |
| 571 | let isMounted = true |
| 572 | |
| 573 | async function loadAndInitializeState() { |
| 574 | if (!project?.ref || typeof window === 'undefined') { |
| 575 | if (project?.ref === undefined) { |
| 576 | state.resetAiAssistantPanel() |
| 577 | } |
| 578 | return // Don't load if no projectRef or not in browser |
| 579 | } |
| 580 | |
| 581 | let loadedState: StoredAiAssistantState | null = null |
| 582 | |
| 583 | // 1. Try loading from IndexedDB |
| 584 | loadedState = await loadFromIndexedDB(project?.ref) |
| 585 | |
| 586 | // 2. If not in IndexedDB, try migrating from localStorage |
| 587 | if (!loadedState) { |
| 588 | loadedState = await tryMigrateFromLocalStorage(project?.ref) |
| 589 | } |
| 590 | |
| 591 | if (!isMounted) return // Component unmounted during async operations |
| 592 | |
| 593 | // 3. If state was loaded or migrated, update the valtio state |
| 594 | if (loadedState) { |
| 595 | state.loadPersistedState(loadedState) |
| 596 | } |
| 597 | |
| 598 | // 4. Ensure an active chat exists and handle URL overrides |
| 599 | ensureActiveChatOrInitialize(state) |
| 600 | } |
| 601 | |
| 602 | loadAndInitializeState() |
| 603 | |
| 604 | return () => { |
| 605 | isMounted = false |
| 606 | } |
| 607 | }, [project?.ref, state]) |
| 608 | |
| 609 | // Effect to save state to IndexedDB on changes |
| 610 | useEffect(() => { |
| 611 | if (typeof window !== 'undefined' && project?.ref) { |
| 612 | // Create a debounced version of saveAiState |
| 613 | const debouncedSaveAiState = debounce(saveAiState, 500) |
| 614 | |
| 615 | const unsubscribe = subscribe(state, () => { |
| 616 | const snap = snapshot(state) |
| 617 | // Prepare state for IndexedDB |
| 618 | const stateToSave: StoredAiAssistantState = { |
| 619 | projectRef: project?.ref, |
| 620 | activeChatId: snap.activeChatId, |
| 621 | model: snap.model, |
| 622 | chats: snap.chats |
| 623 | ? Object.entries(snap.chats).reduce((acc, [chatId, chat]) => { |
| 624 | // Limit messages before saving |
| 625 | return { |
| 626 | ...acc, |
| 627 | [chatId]: { |
| 628 | ...chat, |
| 629 | messages: chat.messages?.slice(-20) || [], |
| 630 | }, |
| 631 | } |
| 632 | }, {}) |
| 633 | : {}, |
| 634 | } |
| 635 | debouncedSaveAiState(stateToSave) |
| 636 | }) |
| 637 | // Clean up subscription and cancel any pending saves on unmount or projectRef change |
| 638 | return () => { |
| 639 | debouncedSaveAiState.cancel() |
| 640 | unsubscribe() |
| 641 | } |
| 642 | } |
| 643 | return undefined |
| 644 | }, [state, project?.ref]) |
| 645 | |
| 646 | return ( |
| 647 | <AiAssistantStateContext.Provider value={state}>{children}</AiAssistantStateContext.Provider> |
| 648 | ) |
| 649 | } |
| 650 | |
| 651 | export const useAiAssistantStateSnapshot = (options?: Parameters<typeof useSnapshot>[1]) => { |
| 652 | const state = useContext(AiAssistantStateContext) |
| 653 | return useSnapshot(state, options) |
| 654 | } |
| 655 | |
| 656 | export const useAiAssistantState = () => { |
| 657 | const state = useContext(AiAssistantStateContext) |
| 658 | return state |
| 659 | } |