Some checks failed
Generate Embeddings for Search / deploy (push) Has been cancelled
[Docs] Lint v2 (scheduled) / lint-all (push) Has been cancelled
[Docs] Update last-changed dates / deploy (push) Has been cancelled
Automatically label stale issues / build (push) Has been cancelled
Docs Production Smoke Tests / build (push) Has been cancelled
Publish to Image Registry / settings (push) Has been cancelled
Publish to Image Registry / release_x86 (push) Has been cancelled
Publish to Image Registry / release_arm (push) Has been cancelled
Publish to Image Registry / merge_manifest (push) Has been cancelled
Publish to Image Registry / publish (push) Has been cancelled
Update Mgmt Api Docs / update-docs (push) Has been cancelled
AI Unit Tests & Type Check / test (push) Has been cancelled
* update onboarding * update model and fix part issue * action orientated assistant * fix tool * lock * remove unused filter * fix tests * fix again * update package * update container * fix tests * refactor(ai assistant): break out message markdown and profile picture * wip * refactor(ai assistant): break up message component * refactor: break ai assistant message down into multiple files * refactor: simplify ReportBlock state * fix: styling of draggable report block header When the drag handle is showing, it overlaps with the block header. Decrease the opacity of the header so the handle can be seen and the two can be distinguished. * fix: minor tweaks to tool ui * refactor: simplify DisplayBlockRenderer state * fix: remove double deploy button in edge function block When the confirm footer is shown, the deploy button on the top right should be hidden (not just disabled) to avoid confusion. * refactor, test: message sanitization by opt-in level Refactor the message sanitization to have more type safety and be more testable. Add tests to ensure: - Message sanitization always runs on generate-v4 - Message sanitization correctly works by opt-in level * Fix conflicts in pnpm lock * Couple of nits and refactors * Revert casing for report block snippet * adjust sanitised prompt * Fix tests --------- Co-authored-by: Charis Lam <26616127+charislam@users.noreply.github.com> Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
548 lines
17 KiB
TypeScript
548 lines
17 KiB
TypeScript
import type { UIMessage as MessageType } from '@ai-sdk/react'
|
|
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, snapshot, subscribe, useSnapshot } from 'valtio'
|
|
|
|
import { LOCAL_STORAGE_KEYS } from 'common'
|
|
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
|
|
|
|
type SuggestionsType = {
|
|
title: string
|
|
prompts?: { label: string; description: string }[]
|
|
}
|
|
|
|
export type AssistantMessageType = MessageType & { results?: { [id: string]: any[] } }
|
|
|
|
export type SqlSnippet = string | { label: string; content: string }
|
|
|
|
type ChatSession = {
|
|
id: string
|
|
name: string
|
|
messages: AssistantMessageType[]
|
|
createdAt: Date
|
|
updatedAt: Date
|
|
}
|
|
|
|
type AiAssistantData = {
|
|
open: boolean
|
|
initialInput: string
|
|
sqlSnippets?: SqlSnippet[]
|
|
suggestions?: SuggestionsType
|
|
tables: { schema: string; name: string }[]
|
|
chats: Record<string, ChatSession>
|
|
activeChatId?: string
|
|
}
|
|
|
|
// Data structure stored in IndexedDB
|
|
type StoredAiAssistantState = {
|
|
projectRef: string
|
|
open: boolean
|
|
activeChatId?: string
|
|
chats: Record<string, ChatSession>
|
|
}
|
|
|
|
const INITIAL_AI_ASSISTANT: AiAssistantData = {
|
|
open: false,
|
|
initialInput: '',
|
|
sqlSnippets: undefined,
|
|
suggestions: undefined,
|
|
tables: [],
|
|
chats: {},
|
|
activeChatId: undefined,
|
|
}
|
|
|
|
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,
|
|
open: parsedFromLocalStorage.open ?? false,
|
|
activeChatId: parsedFromLocalStorage.activeChatId,
|
|
chats: parsedFromLocalStorage.chats,
|
|
}
|
|
} 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) {
|
|
// Check URL param again to override loaded 'open' state if present
|
|
if (typeof window !== 'undefined') {
|
|
const urlParams = new URLSearchParams(window.location.search)
|
|
const aiAssistantPanelOpenParam = urlParams.get('aiAssistantPanelOpen')
|
|
if (aiAssistantPanelOpenParam !== null) {
|
|
state.open = aiAssistantPanelOpenParam === 'true'
|
|
}
|
|
}
|
|
|
|
// 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()
|
|
}
|
|
}
|
|
}
|
|
|
|
export const createAiAssistantState = (): AiAssistantState => {
|
|
// Initialize with defaults, loading happens asynchronously in the provider
|
|
const initialState = { ...INITIAL_AI_ASSISTANT }
|
|
|
|
// Check URL params for initial 'open' state, overriding any loaded state later if present
|
|
if (typeof window !== 'undefined') {
|
|
const urlParams = new URLSearchParams(window.location.search)
|
|
const aiAssistantPanelOpenParam = urlParams.get('aiAssistantPanelOpen')
|
|
if (aiAssistantPanelOpenParam !== null) {
|
|
initialState.open = aiAssistantPanelOpenParam === 'true'
|
|
}
|
|
}
|
|
|
|
const state: AiAssistantState = proxy({
|
|
...initialState, // Spread initial values directly
|
|
|
|
resetAiAssistantPanel: () => {
|
|
Object.assign(state, INITIAL_AI_ASSISTANT)
|
|
},
|
|
|
|
// Panel visibility
|
|
openAssistant: () => {
|
|
state.open = true
|
|
},
|
|
|
|
closeAssistant: () => {
|
|
state.open = false
|
|
},
|
|
|
|
toggleAssistant: () => {
|
|
state.open = !state.open
|
|
},
|
|
|
|
// Chat management
|
|
get activeChat(): ChatSession | undefined {
|
|
return state.activeChatId ? state.chats[state.activeChatId] : undefined
|
|
},
|
|
|
|
newChat: (
|
|
options?: { name?: string } & Partial<
|
|
Pick<AiAssistantData, 'open' | '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
|
|
|
|
// Update non-chat related state based on options, falling back to current state, then initial
|
|
state.open = options?.open ?? state.open
|
|
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
|
|
}
|
|
},
|
|
|
|
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
|
|
}
|
|
},
|
|
|
|
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 as AssistantMessageType)
|
|
}
|
|
})
|
|
|
|
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 as AssistantMessageType
|
|
chat.updatedAt = new Date()
|
|
}
|
|
},
|
|
|
|
setSqlSnippets: (snippets: SqlSnippet[]) => {
|
|
state.sqlSnippets = snippets
|
|
},
|
|
|
|
clearSqlSnippets: () => {
|
|
state.sqlSnippets = undefined
|
|
state.suggestions = undefined
|
|
},
|
|
|
|
getCachedSQLResults: ({ messageId, snippetId }: { messageId: string; snippetId?: string }) => {
|
|
const chat = state.activeChat
|
|
if (!chat || !snippetId) return
|
|
|
|
const message = chat.messages.find((msg) => msg.id === messageId)
|
|
const results = (message?.results ?? {})[snippetId]
|
|
return results
|
|
},
|
|
|
|
// --- New function to load persisted state ---
|
|
loadPersistedState: (persistedState: StoredAiAssistantState) => {
|
|
state.open = persistedState.open
|
|
state.chats = persistedState.chats
|
|
state.activeChatId = persistedState.activeChatId
|
|
|
|
// Check URL param again to override loaded 'open' state if present
|
|
if (typeof window !== 'undefined') {
|
|
const urlParams = new URLSearchParams(window.location.search)
|
|
const aiAssistantPanelOpenParam = urlParams.get('aiAssistantPanelOpen')
|
|
if (aiAssistantPanelOpenParam !== null) {
|
|
state.open = aiAssistantPanelOpenParam === 'true'
|
|
}
|
|
}
|
|
|
|
// Ensure an active chat exists after loading
|
|
if (!state.activeChat) {
|
|
const chatIds = Object.keys(state.chats)
|
|
if (chatIds.length > 0) {
|
|
// Maybe select the most recently updated? For now, first.
|
|
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()
|
|
}
|
|
}
|
|
},
|
|
|
|
clearStorage: async () => {
|
|
await clearStorage()
|
|
},
|
|
})
|
|
|
|
return state
|
|
}
|
|
|
|
export type AiAssistantState = AiAssistantData & {
|
|
resetAiAssistantPanel: () => void
|
|
openAssistant: () => void
|
|
closeAssistant: () => void
|
|
toggleAssistant: () => void
|
|
activeChat: ChatSession | undefined
|
|
newChat: (
|
|
options?: { name?: string } & Partial<
|
|
Pick<AiAssistantData, 'open' | '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
|
|
getCachedSQLResults: (args: { messageId: string; snippetId?: string }) => any[] | undefined
|
|
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,
|
|
open: snap.open,
|
|
activeChatId: snap.activeChatId,
|
|
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)
|
|
}
|