ai-assistant-state.tsx 20 KB

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