Files
supabase/apps/studio/state/ai-assistant-state.tsx
Carel de WaalandAlaister Young 0acc0eb8b3 feat: Support Form - Sync AI assistant conversation to Front (#46778)
# Sync AI assistant conversation to Front

## What & why

When a user submits a support ticket, an AI assistant chat opens so they
get help
immediately while waiting for a human agent. This PR mirrors every turn
of that chat into
the Front conversation the support form already created, so the support
team sees the full
context and Front automations (routing, emails, CSAT) can act on it.

Studio holds no Front credentials — it calls the platform endpoints (see
the platform PR)
to do the syncing. The assistant card is gated behind the
`supportAssistantFollowUp`
ConfigCat flag.

## How it works

1. **Submit** — `SupportFormV3` generates a stable `threadRef` (via the
`uuid` package —
`crypto.randomUUID()` is `undefined` in insecure contexts like
non-localhost HTTP and
would throw, silently aborting the submit) and sends it on
`/platform/feedback/send`.
The response returns the Front `conversationId`. Both are stored on
`SubmittedSupportRequest`.
2. **Open chat** — `SupportAssistantSuccessCardContent` opens a chat
seeded with
`supportMetadata` (`threadRef`, `frontConversationId`, subject,
category, severity, …).
   The first message is a `<support>…</support>` XML block.
3. **First user message** — the chat is tagged `isSupportChat = true`;
the `onFinish` hook
   fires `syncSupportChatToFront`.
4. **Subsequent turns** — each `onFinish` slices the unsynced delta,
strips the XML
metadata block from the seed message, and posts to the platform messages
endpoint.
5. **Escalation / resolve** — the `escalate_to_human` /
`resolve_support_conversation` tools
(and manual **Escalate**/**Resolve** buttons in the assistant input)
flip lifecycle status
via `setSupportLifecycleStatus` → `syncSupportLifecycleToFront`, which
calls the
escalation/resolve endpoints. Front rules act on `ai_support_status`.
The assistant only
   resolves after the user explicitly confirms the issue is fixed.

## Key design decisions

- **`threadRef` as the shared key** — one UUID travels as `threadRef` on
submit and as
`chatId` on every sync, so all messages thread into a single Front
conversation.
- **`conversationId` from the form response** — passed to all
sync/lifecycle calls so the
  platform skips lazy derivation and PATCHes custom fields directly.
- **Delta-only sync** — `lastSyncedMessageCount` tracks what's been
sent; the boundary is
snapshotted before the async call to avoid skipping messages that arrive
mid-flight.
- **Server-side de-dup** — stable `external_id` (`chatId:msg.id`) means
retries don't
  duplicate in Front.
- **Fire-and-forget** — sync failures log to Sentry, never break the
chat; `isSyncing`
resets on rehydration so the next `onFinish` retries the same delta.
Message and lifecycle
syncs use separate guards (`isSyncing` / `isLifecycleSyncing`) so an
in-flight message
  sync can't drop an escalate/resolve.
- **Lifecycle queued until the conversation exists** — if a lifecycle
transition is requested
before the initial message sync has returned a `frontConversationId`,
it's stored as
`pendingLifecycleStatus` and flushed once the id is assigned, rather
than dropped.
- **Tools return immediately** — the lifecycle tools return a stub to
the AI SDK; the real
Front call happens in `onFinish`, keeping async I/O out of the tool
execute path.
- **XML seed stripped before sync** — only the user's actual `<message>`
is sent to Front
  (or dropped entirely if the form already created the conversation).

## Changes

| Area | File(s) |
| --- | --- |
| Support form state | `SupportForm.state.ts` — `threadRef` /
`frontConversationId` on `SubmittedSupportRequest` |
| Support form submit | `support-ticket-send.ts` — sends `threadRef`,
reads `conversationId` |
| Support form UI | `SupportFormV3.tsx` — generates `threadRef`, stores
`conversationId` |
| AI assistant state | `ai-assistant-state.tsx` — `SupportChatMetadata`,
`setSupportLifecycleStatus`, `onFinish` wiring, tool handling |
| Message sync | `state/ai-chat-front-sync.ts` — delta tracking, message
filtering, initial vs. incremental |
| API data layer | `data/feedback/ai-chat-front-sync.ts` — typed
platform-client wrappers for the three conversation endpoints |
| Support tools | `lib/ai/tools/support-tools.ts` — `escalate_to_human`,
`resolve_support_conversation` |
| Tool integration | `lib/ai/tool-filter.ts`, `tools/index.ts`,
`generate-assistant-response.ts` |
| Success card | `SupportAssistantSuccessCardContent.tsx` — tags chat on
first engagement |
| Assistant panel UI | `AIAssistant.tsx` — Escalate/Resolve buttons,
disabled input on closed chats, support placeholders |



<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

## Summary by CodeRabbit

- **New Features**
  - Support chats now include “Escalate to human” and “Resolve” actions.
- Support submissions can be associated with a stable Front thread via a
generated `threadRef`, preserving linkage across follow-ups.
- AI assistant responses and input hints adapt when support mode is
active.

- **Bug Fixes**
- Improved support chat state management and lifecycle handling to keep
conversation metadata and message history synchronized more reliably
with Front.

- **Chores**
- Added/updated coverage to reflect the new support-chat state and
syncing behavior.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Alaister Young <10985857+alaister@users.noreply.github.com>
2026-07-08 12:41:53 +02:00

744 lines
24 KiB
TypeScript

import { Chat, type UIMessage as MessageType } from '@ai-sdk/react'
import { DefaultChatTransport, lastAssistantMessageIsCompleteWithApprovalResponses } from 'ai'
import { LOCAL_STORAGE_KEYS, safeLocalStorage } 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 type { AiSupportStatus } from '@/data/feedback/ai-chat-front-sync'
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
export type SupportChatMetadata = {
subject: string
category: string
severity: string
organizationSlug?: string
projectRef?: string
library?: string
affectedServices?: string
allowSupportAccess: boolean
browserInformation?: string
frontConversationId?: string
// Front thread_ref shared with the submit-time conversation. Sent as the sync
// API `chatId` so AI messages thread into that same Front conversation.
threadRef?: string
isSupportChat: true
lifecycleStatus: AiSupportStatus
// A lifecycle transition requested before the Front conversation id existed
// (e.g. the assistant resolved the chat before the first message sync
// returned an id). Flushed by syncSupportChatToFront once the id is assigned.
pendingLifecycleStatus?: AiSupportStatus
lifecycleClosedAt?: string
lastSyncedMessageCount: number
// Guards message syncs; lifecycle syncs use `isLifecycleSyncing` so the two
// never block each other.
isSyncing: boolean
isLifecycleSyncing: boolean
}
type ChatSession = {
id: string
name: string
messages: AssistantMessageType[]
createdAt: Date
updatedAt: Date
supportMetadata?: SupportChatMetadata
}
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 = safeLocalStorage.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
safeLocalStorage.removeItem(LOCAL_STORAGE_KEYS.AI_ASSISTANT_STATE(projectRef))
}
} catch (error) {
console.error('Failed to parse state from localStorage:', error)
// Clear potentially corrupted data
safeLocalStorage.removeItem(LOCAL_STORAGE_KEYS.AI_ASSISTANT_STATE(projectRef))
}
if (migratedState) {
try {
await saveAiState(migratedState)
safeLocalStorage.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,
supportMode: chat?.supportMetadata?.isSupportChat ?? false,
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 === 'escalate_to_human') {
state.setSupportLifecycleStatus(options.id, 'escalated')
return
}
if (toolCall.toolName === 'resolve_support_conversation') {
state.setSupportLifecycleStatus(options.id, 'bot_resolved')
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]
}
// Sync support chat messages to Front (fire-and-forget, dynamic import to avoid SSR issues)
if (chat?.supportMetadata) {
import('@/state/ai-chat-front-sync')
.then(({ syncSupportChatToFront }) => syncSupportChatToFront(options.id, state))
.catch(() => {})
}
}
},
})
}
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
},
setSupportLifecycleStatus: (chatId: string, status: AiSupportStatus) => {
const chat = state.chats[chatId]
if (!chat?.supportMetadata) return
chat.supportMetadata.lifecycleStatus = status
if (status !== 'bot_active') {
chat.supportMetadata.lifecycleClosedAt = new Date().toISOString()
}
// No Front conversation yet (the initial message sync hasn't returned an
// id). Queue the transition so syncSupportChatToFront can flush it once the
// id is assigned, rather than silently dropping it.
if (!chat.supportMetadata.frontConversationId) {
chat.supportMetadata.pendingLifecycleStatus = status
return
}
import('@/state/ai-chat-front-sync').then(({ syncSupportLifecycleToFront }) => {
syncSupportLifecycleToFront(chatId, state, status).catch(() => {})
})
},
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
// Reset sync guards on any support chats (can't be mid-sync after reload)
Object.values(state.chats).forEach((chat) => {
if (chat.supportMetadata) {
if (!chat.supportMetadata.isSupportChat) {
chat.supportMetadata.isSupportChat = true
}
if (!chat.supportMetadata.lifecycleStatus) {
chat.supportMetadata.lifecycleStatus = 'bot_active'
}
chat.supportMetadata.isSyncing = false
chat.supportMetadata.isLifecycleSyncing = false
}
})
// 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
setSupportLifecycleStatus: (chatId: string, status: AiSupportStatus) => void
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
}