mirror of
https://github.com/supabase/supabase.git
synced 2026-10-10 11:55:05 +03:00
# 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>
744 lines
24 KiB
TypeScript
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
|
|
}
|