| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659 |
- // @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<string, ChatSession>
- activeChatId?: string
- model?: AssistantModel
- context: AiAssistantContext
- }
- // Data structure stored in IndexedDB
- type StoredAiAssistantState = {
- projectRef: string
- activeChatId?: string
- chats: Record<string, ChatSession>
- 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<IDBPDatabase<AiAssistantDB>> {
- return openDB<AiAssistantDB>(DB_NAME, DB_VERSION, {
- upgrade(db) {
- if (!db.objectStoreNames.contains(STORE_NAME)) {
- db.createObjectStore(STORE_NAME, { keyPath: 'projectRef' })
- }
- },
- })
- }
- async function getAiState(projectRef: string): Promise<StoredAiAssistantState | undefined> {
- 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<void> {
- 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<void> {
- 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<StoredAiAssistantState | null> {
- 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<StoredAiAssistantState | null> {
- 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<MessageType>({
- 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<AiAssistantContext>) => {
- 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<AiAssistantData, 'initialInput' | 'sqlSnippets' | 'suggestions' | 'tables'>
- >
- ) => {
- 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<string, Chat<MessageType>>
- pendingSpanIds: Record<string, string>
- messageSpanIds: Record<string, string>
- setContext: (context: Partial<AiAssistantContext>) => void
- setModel: (model: AssistantModel) => void
- newChat: (
- options?: { name?: string; initialMessage?: string } & Partial<
- Pick<AiAssistantData, 'initialInput' | 'sqlSnippets' | 'suggestions' | 'tables'>
- >
- ) => 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<void>
- }
- export const AiAssistantStateContext = createContext<AiAssistantState>(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 (
- <AiAssistantStateContext.Provider value={state}>{children}</AiAssistantStateContext.Provider>
- )
- }
- export const useAiAssistantStateSnapshot = (options?: Parameters<typeof useSnapshot>[1]) => {
- const state = useContext(AiAssistantStateContext)
- return useSnapshot(state, options)
- }
- export const useAiAssistantState = () => {
- const state = useContext(AiAssistantStateContext)
- return state
- }
|