// @ts-nocheck import { Chat, type UIMessage as MessageType } from '@ai-sdk/react' import { DefaultChatTransport, lastAssistantMessageIsCompleteWithApprovalResponses } from 'ai' import { LOCAL_STORAGE_KEYS } from 'common' import { DBSchema, IDBPDatabase, openDB } from 'idb' import { debounce } from 'lodash' import { createContext, PropsWithChildren, useContext, useEffect, useState } from 'react' import { v4 as uuidv4 } from 'uuid' import { proxy, ref, snapshot, subscribe, useSnapshot } from 'valtio' import { constructHeaders } from '@/data/fetchers' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' import { prepareMessagesForAPI } from '@/lib/ai/message-utils' import { isKnownAssistantModelId } from '@/lib/ai/model.utils' import type { AssistantModelId } from '@/lib/ai/model.utils' import { BASE_PATH, IS_PLATFORM } from '@/lib/constants' type SuggestionsType = { title: string prompts?: { label: string; description: string }[] } export type AssistantMessageType = MessageType export type SqlSnippet = string | { label: string; content: string } export type AssistantModel = AssistantModelId type ChatSession = { id: string name: string messages: AssistantMessageType[] createdAt: Date updatedAt: Date } export type AiAssistantContext = { projectRef?: string orgSlug?: string connectionString?: string } type AiAssistantData = { initialInput: string sqlSnippets?: SqlSnippet[] suggestions?: SuggestionsType tables: { schema: string; name: string }[] chats: Record activeChatId?: string model?: AssistantModel context: AiAssistantContext } // Data structure stored in IndexedDB type StoredAiAssistantState = { projectRef: string activeChatId?: string chats: Record model?: AssistantModel } const INITIAL_AI_ASSISTANT: AiAssistantData = { initialInput: '', sqlSnippets: undefined, suggestions: undefined, tables: [], chats: {}, activeChatId: undefined, model: undefined, context: {}, } const DB_NAME = 'ai-assistant-db' const DB_VERSION = 1 const STORE_NAME = 'assistantState' interface AiAssistantDB extends DBSchema { [STORE_NAME]: { key: string value: StoredAiAssistantState } } async function openAiDb(): Promise> { return openDB(DB_NAME, DB_VERSION, { upgrade(db) { if (!db.objectStoreNames.contains(STORE_NAME)) { db.createObjectStore(STORE_NAME, { keyPath: 'projectRef' }) } }, }) } async function getAiState(projectRef: string): Promise { if (!projectRef) return undefined try { const db = await openAiDb() return await db.get(STORE_NAME, projectRef) } catch (error) { console.error('Failed to get AI state from IndexedDB:', error) return undefined } } async function saveAiState(state: StoredAiAssistantState): Promise { if (!state.projectRef) return try { const db = await openAiDb() await db.put(STORE_NAME, state) } catch (error) { console.error('Failed to save AI state to IndexedDB:', error) } } async function clearStorage(): Promise { try { const db = await openAiDb() await db.clear(STORE_NAME) } catch (error) { console.error('Failed to clear AI state from IndexedDB:', error) } } // Helper function to sanitize objects to ensure they're cloneable // Issue due to addToolResult function sanitizeForCloning(obj: any): any { if (obj === null || obj === undefined) return obj if (typeof obj !== 'object') return obj return JSON.parse(JSON.stringify(obj)) } // Helper function to load state from IndexedDB async function loadFromIndexedDB(projectRef: string): Promise { try { const persistedState = await getAiState(projectRef) if (persistedState) { // Revive dates and sanitize message data Object.values(persistedState.chats).forEach((chat: ChatSession) => { if (chat && typeof chat === 'object') { chat.createdAt = new Date(chat.createdAt) chat.updatedAt = new Date(chat.updatedAt) // Sanitize message parts to remove proxy objects if (chat.messages) { chat.messages.forEach((message: any) => { if (message.parts) { message.parts = message.parts.map((part: any) => sanitizeForCloning(part)) } }) } } }) return persistedState } } catch (error) { console.error('Error loading AI state from IndexedDB:', error) } return null } // Helper function to attempt migration from localStorage async function tryMigrateFromLocalStorage( projectRef: string ): Promise { const stored = localStorage.getItem(LOCAL_STORAGE_KEYS.AI_ASSISTANT_STATE(projectRef)) if (!stored) { return null } let migratedState: StoredAiAssistantState | null = null try { const parsedFromLocalStorage = JSON.parse(stored, (key, value) => { if ((key === 'createdAt' || key === 'updatedAt') && value) { return new Date(value) } return value }) if (parsedFromLocalStorage && typeof parsedFromLocalStorage.chats === 'object') { migratedState = { projectRef: projectRef, activeChatId: parsedFromLocalStorage.activeChatId, chats: parsedFromLocalStorage.chats, model: parsedFromLocalStorage.model ?? INITIAL_AI_ASSISTANT.model, } } else { console.warn('Data in localStorage is not in the expected format, ignoring.') // Clean up invalid data localStorage.removeItem(LOCAL_STORAGE_KEYS.AI_ASSISTANT_STATE(projectRef)) } } catch (error) { console.error('Failed to parse state from localStorage:', error) // Clear potentially corrupted data localStorage.removeItem(LOCAL_STORAGE_KEYS.AI_ASSISTANT_STATE(projectRef)) } if (migratedState) { try { await saveAiState(migratedState) localStorage.removeItem(LOCAL_STORAGE_KEYS.AI_ASSISTANT_STATE(projectRef)) return migratedState } catch (saveError) { console.error('Failed to save migrated state to IndexedDB:', saveError) return null } } return null } // Helper function to ensure an active chat exists or initialize a new one function ensureActiveChatOrInitialize(state: AiAssistantState) { // Ensure an active chat exists after loading/migration if (!state.activeChatId || !state.chats[state.activeChatId]) { const chatIds = Object.keys(state.chats) if (chatIds.length > 0) { // Select the most recently updated chat state.activeChatId = chatIds.sort( (a, b) => (state.chats[b].updatedAt?.getTime() || 0) - (state.chats[a].updatedAt?.getTime() || 0) )[0] } else { // If loaded/migrated state had no chats, create a new one state.newChat() } } } function createChatInstance( state: AiAssistantState, options: { id: string; initialMessages: MessageType[] } ) { return new Chat({ id: options.id, messages: options.initialMessages.map((message) => sanitizeForCloning(message)), sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithApprovalResponses, transport: new DefaultChatTransport({ api: `${BASE_PATH}/api/ai/sql/generate-v4`, fetch: async (url, init) => { const response = await globalThis.fetch(url as RequestInfo, init) const spanId = response.headers.get('x-braintrust-span-id') if (spanId) { state.pendingSpanIds[options.id] = spanId } return response }, async prepareSendMessagesRequest({ messages, ...opts }) { const cleanedMessages = prepareMessagesForAPI(messages) const headerData = await constructHeaders() const authorizationHeader = headerData.get('Authorization') // Get the chat specific to this request to ensure we have the correct name const chat = state.chats[options.id] return { ...opts, body: { messages: cleanedMessages, projectRef: state.context.projectRef, connectionString: state.context.connectionString, chatId: options.id, chatName: chat?.name, orgSlug: state.context.orgSlug, context: state.context, model: state.model, ...opts.body, }, ...(IS_PLATFORM ? { headers: { Authorization: authorizationHeader ?? '' } } : {}), } }, }), async onToolCall({ toolCall }) { if (toolCall.dynamic) { return } if (toolCall.toolName === 'rename_chat') { const { newName } = toolCall.input as { newName: string } if (options.id && newName?.trim()) { state.renameChat(options.id, newName.trim()) } } }, onFinish(_result) { // Sync messages back to state const chatInstance = state.chatInstances[options.id] if (chatInstance) { const messages = chatInstance.messages const chat = state.chats[options.id] if (chat) { chat.messages = messages chat.updatedAt = new Date() } // Associate pending span ID with the last assistant message const pendingSpanId = state.pendingSpanIds[options.id] if (pendingSpanId) { const lastAssistantMsg = [...messages].reverse().find((m) => m.role === 'assistant') if (lastAssistantMsg) { state.messageSpanIds[lastAssistantMsg.id] = pendingSpanId } delete state.pendingSpanIds[options.id] } } }, }) } export const createAiAssistantState = (): AiAssistantState => { // Initialize with defaults, loading happens asynchronously in the provider const initialState = { ...INITIAL_AI_ASSISTANT } const state: AiAssistantState = proxy({ ...initialState, // Spread initial values directly chatInstances: {}, pendingSpanIds: {}, messageSpanIds: {}, setContext: (context: Partial) => { state.context = { ...state.context, ...context } }, resetAiAssistantPanel: () => { Object.assign(state, INITIAL_AI_ASSISTANT) }, setModel: (model: AssistantModel) => { state.model = model }, // Chat management get activeChat(): ChatSession | undefined { return state.activeChatId ? state.chats[state.activeChatId] : undefined }, newChat: ( options?: { name?: string; initialMessage?: string } & Partial< Pick > ) => { const chatId = uuidv4() const newChat: ChatSession = { id: chatId, name: options?.name ?? 'New chat', messages: [], createdAt: new Date(), updatedAt: new Date(), } state.chats = { ...state.chats, [chatId]: newChat, } state.activeChatId = chatId // Create new chat instance const chatInstance = createChatInstance(state, { id: chatId, initialMessages: [] }) state.chatInstances[chatId] = ref(chatInstance) // If initialMessage is provided, append it to the chat instance if (options?.initialMessage) { chatInstance.sendMessage({ text: options.initialMessage, }) } // Update non-chat related state based on options, falling back to current state, then initial state.initialInput = options?.initialInput ?? INITIAL_AI_ASSISTANT.initialInput state.sqlSnippets = options?.sqlSnippets ?? INITIAL_AI_ASSISTANT.sqlSnippets state.suggestions = options?.suggestions ?? INITIAL_AI_ASSISTANT.suggestions state.tables = options?.tables ?? INITIAL_AI_ASSISTANT.tables return chatId }, selectChat: (id: string) => { if (id !== state.activeChatId) { state.activeChatId = id const chat = state.chats[id] if (chat) { if (!state.chatInstances[id]) { state.chatInstances[id] = ref( createChatInstance(state, { id, initialMessages: chat.messages }) ) } } } }, deleteChat: (id: string) => { const { [id]: _, ...remainingChats } = state.chats state.chats = remainingChats if (id === state.activeChatId) { const remainingChatIds = Object.keys(remainingChats) state.activeChatId = remainingChatIds.length > 0 ? remainingChatIds[0] : undefined if (state.activeChatId) { const chat = state.chats[state.activeChatId] if (!state.chatInstances[state.activeChatId]) { state.chatInstances[state.activeChatId] = ref( createChatInstance(state, { id: state.activeChatId, initialMessages: chat.messages }) ) } } } }, renameChat: (id: string, name: string) => { const chat = state.chats[id] if (chat && chat.name !== name) { chat.name = name chat.updatedAt = new Date() } }, clearMessages: () => { const chat = state.activeChat if (chat) { chat.messages = [] chat.updatedAt = new Date() state.suggestions = undefined state.sqlSnippets = [] state.initialInput = '' } }, deleteMessagesAfter: (id: string, { includeSelf = true } = {}) => { const chat = state.activeChat if (!chat) return const messageIndex = chat.messages.findIndex((msg) => msg.id === id) if (messageIndex === -1) return // Delete all messages from the target message (optionally including) to the end const startIndex = includeSelf ? messageIndex : messageIndex + 1 chat.messages.splice(startIndex) chat.updatedAt = new Date() }, saveMessage: (message: MessageType | MessageType[]) => { const chat = state.activeChat if (!chat) return const incomingMessages = Array.isArray(message) ? message : [message] const messagesToAdd: AssistantMessageType[] = [] incomingMessages.forEach((msg) => { const index = chat.messages.findIndex((existing) => existing.id === msg.id) if (index !== -1) { state.updateMessage(msg) } else { messagesToAdd.push(msg) } }) if (messagesToAdd.length > 0) { chat.messages.push(...messagesToAdd) chat.updatedAt = new Date() } }, updateMessage: (updatedMessage: MessageType) => { const chat = state.activeChat if (!chat) return const messageIndex = chat.messages.findIndex((msg) => msg.id === updatedMessage.id) if (messageIndex !== -1) { chat.messages[messageIndex] = updatedMessage chat.updatedAt = new Date() } }, setSqlSnippets: (snippets: SqlSnippet[]) => { state.sqlSnippets = snippets }, clearSqlSnippets: () => { state.sqlSnippets = undefined state.suggestions = undefined }, // --- New function to load persisted state --- loadPersistedState: (persistedState: StoredAiAssistantState) => { state.chats = persistedState.chats state.activeChatId = persistedState.activeChatId const storedModel = persistedState.model state.model = storedModel && isKnownAssistantModelId(storedModel) ? storedModel : INITIAL_AI_ASSISTANT.model // Ensure an active chat exists after loading if (!state.activeChat) { const chatIds = Object.keys(state.chats) if (chatIds.length > 0) { // Select the most recently updated chat state.activeChatId = chatIds.sort( (a, b) => (state.chats[b].updatedAt?.getTime() || 0) - (state.chats[a].updatedAt?.getTime() || 0) )[0] } else { // If loaded state had no chats, create a new one state.newChat() } } // Initialize chat instance for the active chat if ( state.activeChatId && state.chats[state.activeChatId] && !state.chatInstances[state.activeChatId] ) { state.chatInstances[state.activeChatId] = ref( createChatInstance(state, { id: state.activeChatId, initialMessages: state.chats[state.activeChatId].messages, }) ) } }, clearStorage: async () => { await clearStorage() }, }) return state } export type AiAssistantState = AiAssistantData & { resetAiAssistantPanel: () => void activeChat: ChatSession | undefined chatInstances: Record> pendingSpanIds: Record messageSpanIds: Record setContext: (context: Partial) => void setModel: (model: AssistantModel) => void newChat: ( options?: { name?: string; initialMessage?: string } & Partial< Pick > ) => string selectChat: (id: string) => void deleteChat: (id: string) => void renameChat: (id: string, name: string) => void clearMessages: () => void deleteMessagesAfter: (id: string, options?: { includeSelf?: boolean }) => void saveMessage: (message: MessageType | MessageType[]) => void updateMessage: (message: MessageType) => void setSqlSnippets: (snippets: SqlSnippet[]) => void clearSqlSnippets: () => void loadPersistedState: (persistedState: StoredAiAssistantState) => void clearStorage: () => Promise } export const AiAssistantStateContext = createContext(createAiAssistantState()) export const AiAssistantStateContextProvider = ({ children }: PropsWithChildren) => { const { data: project } = useSelectedProjectQuery() // Initialize state. createAiAssistantState now just sets defaults. const [state] = useState(() => createAiAssistantState()) // Effect to load state from IndexedDB on mount or projectRef change useEffect(() => { let isMounted = true async function loadAndInitializeState() { if (!project?.ref || typeof window === 'undefined') { if (project?.ref === undefined) { state.resetAiAssistantPanel() } return // Don't load if no projectRef or not in browser } let loadedState: StoredAiAssistantState | null = null // 1. Try loading from IndexedDB loadedState = await loadFromIndexedDB(project?.ref) // 2. If not in IndexedDB, try migrating from localStorage if (!loadedState) { loadedState = await tryMigrateFromLocalStorage(project?.ref) } if (!isMounted) return // Component unmounted during async operations // 3. If state was loaded or migrated, update the valtio state if (loadedState) { state.loadPersistedState(loadedState) } // 4. Ensure an active chat exists and handle URL overrides ensureActiveChatOrInitialize(state) } loadAndInitializeState() return () => { isMounted = false } }, [project?.ref, state]) // Effect to save state to IndexedDB on changes useEffect(() => { if (typeof window !== 'undefined' && project?.ref) { // Create a debounced version of saveAiState const debouncedSaveAiState = debounce(saveAiState, 500) const unsubscribe = subscribe(state, () => { const snap = snapshot(state) // Prepare state for IndexedDB const stateToSave: StoredAiAssistantState = { projectRef: project?.ref, activeChatId: snap.activeChatId, model: snap.model, chats: snap.chats ? Object.entries(snap.chats).reduce((acc, [chatId, chat]) => { // Limit messages before saving return { ...acc, [chatId]: { ...chat, messages: chat.messages?.slice(-20) || [], }, } }, {}) : {}, } debouncedSaveAiState(stateToSave) }) // Clean up subscription and cancel any pending saves on unmount or projectRef change return () => { debouncedSaveAiState.cancel() unsubscribe() } } return undefined }, [state, project?.ref]) return ( {children} ) } export const useAiAssistantStateSnapshot = (options?: Parameters[1]) => { const state = useContext(AiAssistantStateContext) return useSnapshot(state, options) } export const useAiAssistantState = () => { const state = useContext(AiAssistantStateContext) return state }