AIAssistant.tsx 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604
  1. // @ts-nocheck
  2. import type { UIMessage as MessageType } from '@ai-sdk/react'
  3. import { useChat } from '@ai-sdk/react'
  4. import { lastAssistantMessageIsCompleteWithApprovalResponses } from 'ai'
  5. import { LOCAL_STORAGE_KEYS, useFlag } from 'common'
  6. import { useParams, useSearchParamsShallow } from 'common/hooks'
  7. import { AnimatePresence, motion } from 'framer-motion'
  8. import { Eraser, Pencil, X } from 'lucide-react'
  9. import { useRouter } from 'next/router'
  10. import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
  11. import { Button, cn, KeyboardShortcut } from 'ui'
  12. import { Admonition } from 'ui-patterns'
  13. import AlertError from '../AlertError'
  14. import { ButtonTooltip } from '../ButtonTooltip'
  15. import { ErrorBoundary } from '../ErrorBoundary/ErrorBoundary'
  16. import { ASSISTANT_ERRORS } from './AiAssistant.constants'
  17. import type { SqlSnippet } from './AIAssistant.types'
  18. import {
  19. hasPendingToolApproval,
  20. onErrorChat,
  21. resolvePendingToolApprovalsAsDenied,
  22. } from './AIAssistant.utils'
  23. import { AIAssistantHeader } from './AIAssistantHeader'
  24. import { AIOnboarding } from './AIOnboarding'
  25. import { AssistantChatForm } from './AssistantChatForm'
  26. import {
  27. Conversation,
  28. ConversationContent,
  29. ConversationScrollButton,
  30. } from './elements/Conversation'
  31. import { Message } from './Message'
  32. import { Markdown } from '@/components/interfaces/Markdown'
  33. import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider'
  34. import { useCheckOpenAIKeyQuery } from '@/data/ai/check-api-key-query'
  35. import { useRateMessageMutation } from '@/data/ai/rate-message-mutation'
  36. import { useTablesQuery } from '@/data/tables/tables-query'
  37. import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements'
  38. import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage'
  39. import { useOrgAiOptInLevel } from '@/hooks/misc/useOrgOptedIntoAi'
  40. import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
  41. import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
  42. import {
  43. DEFAULT_ASSISTANT_BASE_MODEL_ID,
  44. defaultAssistantModelId,
  45. isAssistantBaseModelId,
  46. isKnownAssistantModelId,
  47. } from '@/lib/ai/model.utils'
  48. import { IS_PLATFORM } from '@/lib/constants'
  49. import { uuidv4 } from '@/lib/helpers'
  50. import { useTrack } from '@/lib/telemetry/track'
  51. import type { AssistantModel } from '@/state/ai-assistant-state'
  52. import { useAiAssistantState, useAiAssistantStateSnapshot } from '@/state/ai-assistant-state'
  53. import { SHORTCUT_IDS } from '@/state/shortcuts/registry'
  54. import { useShortcut } from '@/state/shortcuts/useShortcut'
  55. import { useSidebarManagerSnapshot } from '@/state/sidebar-manager-state'
  56. import { useSqlEditorV2StateSnapshot } from '@/state/sql-editor-v2'
  57. interface AIAssistantProps {
  58. initialMessages?: MessageType[] | undefined
  59. className?: string
  60. }
  61. export const AIAssistant = ({ className }: AIAssistantProps) => {
  62. const router = useRouter()
  63. const { id: entityId } = useParams()
  64. const { data: project } = useSelectedProjectQuery()
  65. const searchParams = useSearchParamsShallow()
  66. const { data: selectedOrganization, isPending: isLoadingOrganization } =
  67. useSelectedOrganizationQuery()
  68. useShortcut(SHORTCUT_IDS.AI_ASSISTANT_CANCEL_EDIT, () => cancelEdit())
  69. const disablePrompts = useFlag('disableAssistantPrompts')
  70. const { snippets } = useSqlEditorV2StateSnapshot()
  71. const snap = useAiAssistantStateSnapshot()
  72. const state = useAiAssistantState()
  73. const { activeSidebar, closeSidebar } = useSidebarManagerSnapshot()
  74. const { hasAccess: hasAccessToAdvanceModel, isLoading: isLoadingEntitlements } =
  75. useCheckEntitlements('assistant.advance_model')
  76. const selectedModel = useMemo<AssistantModel>(() => {
  77. // While entitlements are loading, use the stored model without enforcing access
  78. if (isLoadingEntitlements) {
  79. return snap.model ?? DEFAULT_ASSISTANT_BASE_MODEL_ID
  80. }
  81. const defaultModel = defaultAssistantModelId(hasAccessToAdvanceModel)
  82. const model = snap.model ?? defaultModel
  83. if (!isKnownAssistantModelId(model)) return defaultModel
  84. if (!hasAccessToAdvanceModel && !isAssistantBaseModelId(model)) {
  85. return DEFAULT_ASSISTANT_BASE_MODEL_ID
  86. }
  87. return model
  88. }, [isLoadingEntitlements, hasAccessToAdvanceModel, snap.model])
  89. const [updatedOptInSinceMCP] = useLocalStorageQuery(
  90. LOCAL_STORAGE_KEYS.AI_ASSISTANT_MCP_OPT_IN,
  91. false
  92. )
  93. const inputRef = useRef<HTMLTextAreaElement>(null)
  94. const { aiOptInLevel, isHipaaProjectDisallowed } = useOrgAiOptInLevel()
  95. const showMetadataWarning =
  96. IS_PLATFORM &&
  97. !!selectedOrganization &&
  98. (aiOptInLevel === 'disabled' || aiOptInLevel === 'schema')
  99. // Add a ref to store the last user message
  100. const lastUserMessageRef = useRef<MessageType | null>(null)
  101. // Keep latest selected organization to avoid stale values in useChat transport
  102. const selectedOrganizationRef = useRef(selectedOrganization)
  103. useEffect(() => {
  104. selectedOrganizationRef.current = selectedOrganization
  105. }, [selectedOrganization])
  106. const [value, setValue] = useState<string>(snap.initialInput || '')
  107. const [editingMessageId, setEditingMessageId] = useState<string | null>(null)
  108. const [isResubmitting, setIsResubmitting] = useState(false)
  109. const [messageRatings, setMessageRatings] = useState<Record<string, 'positive' | 'negative'>>({})
  110. const { data: check, isSuccess } = useCheckOpenAIKeyQuery()
  111. const isApiKeySet = !!check?.hasKey
  112. const { mutateAsync: rateMessage } = useRateMessageMutation()
  113. const isInSQLEditor = router.pathname.includes('/sql/[id]')
  114. const snippet = snippets[entityId ?? '']
  115. const snippetContent = snippet?.snippet?.content?.unchecked_sql
  116. const { data: tables } = useTablesQuery(
  117. {
  118. projectRef: project?.ref,
  119. connectionString: project?.connectionString,
  120. schema: 'public',
  121. },
  122. { enabled: isApiKeySet }
  123. )
  124. const currentTable = tables?.find((t) => t.id.toString() === entityId)
  125. const currentSchema = searchParams?.get('schema') ?? 'public'
  126. // Update context in state
  127. useEffect(() => {
  128. state.setContext({
  129. projectRef: project?.ref,
  130. orgSlug: selectedOrganizationRef.current?.slug,
  131. connectionString: project?.connectionString ?? '',
  132. })
  133. }, [project?.ref, project?.connectionString, selectedOrganizationRef.current?.slug, state])
  134. const track = useTrack()
  135. const {
  136. messages: chatMessages,
  137. status: chatStatus,
  138. error,
  139. sendMessage,
  140. setMessages,
  141. addToolApprovalResponse,
  142. stop,
  143. regenerate,
  144. } = useChat({
  145. id: snap.activeChatId,
  146. ...(snap.activeChatId && snap.chatInstances[snap.activeChatId]
  147. ? { chat: snap.chatInstances[snap.activeChatId] }
  148. : {}),
  149. sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithApprovalResponses,
  150. onError: onErrorChat,
  151. })
  152. const isChatLoading = chatStatus === 'submitted' || chatStatus === 'streaming'
  153. const hasPendingApproval = hasPendingToolApproval(chatMessages)
  154. const isChatInputDisabled = !isApiKeySet || disablePrompts || isLoadingOrganization
  155. const deleteMessageFromHere = useCallback(
  156. (messageId: string) => {
  157. // Find the message index in current chatMessages
  158. const messageIndex = chatMessages.findIndex((msg) => msg.id === messageId)
  159. if (messageIndex === -1) return
  160. if (isChatLoading) stop()
  161. snap.deleteMessagesAfter(messageId, { includeSelf: true })
  162. const updatedMessages = chatMessages.slice(0, messageIndex)
  163. setMessages(updatedMessages)
  164. },
  165. [snap, setMessages, chatMessages, isChatLoading, stop]
  166. )
  167. const editMessage = useCallback(
  168. (messageId: string) => {
  169. const messageIndex = chatMessages.findIndex((msg) => msg.id === messageId)
  170. if (messageIndex === -1) return
  171. // Target message
  172. const messageToEdit = chatMessages[messageIndex]
  173. // Activate editing mode
  174. setEditingMessageId(messageId)
  175. const textContent =
  176. messageToEdit.parts
  177. ?.filter((part) => part.type === 'text')
  178. .map((part) => part.text)
  179. .join('') ?? ''
  180. setValue(textContent)
  181. setTimeout(() => {
  182. if (inputRef.current) {
  183. inputRef?.current?.focus()
  184. // [Joshen] This is just to make the cursor go to the end of the text when focusing
  185. const val = inputRef.current.value
  186. inputRef.current.value = ''
  187. inputRef.current.value = val
  188. }
  189. }, 100)
  190. },
  191. [chatMessages, setValue]
  192. )
  193. const cancelEdit = useCallback(() => {
  194. setEditingMessageId(null)
  195. setValue('')
  196. }, [setValue])
  197. const handleRateMessage = useCallback(
  198. async (messageId: string, rating: 'positive' | 'negative', reason?: string) => {
  199. if (!project?.ref || !selectedOrganization?.slug) return
  200. // Optimistically update UI
  201. setMessageRatings((prev) => ({ ...prev, [messageId]: rating }))
  202. try {
  203. const result = await rateMessage({
  204. rating,
  205. messages: chatMessages,
  206. messageId,
  207. projectRef: project.ref,
  208. orgSlug: selectedOrganization.slug,
  209. reason,
  210. spanId: state.messageSpanIds[messageId],
  211. })
  212. track('assistant_message_rating_submitted', {
  213. rating,
  214. category: result.category,
  215. ...(reason && { reason }),
  216. chatId: state.activeChatId,
  217. })
  218. } catch (error) {
  219. console.error('Failed to rate message:', error)
  220. // Rollback on error
  221. setMessageRatings((prev) => {
  222. const { [messageId]: _, ...rest } = prev
  223. return rest
  224. })
  225. }
  226. },
  227. [chatMessages, project?.ref, selectedOrganization?.slug, rateMessage, track, state]
  228. )
  229. const isContextExceededError =
  230. error &&
  231. (error.message?.includes('context_length_exceeded') ||
  232. error.message?.includes('exceeds the context window'))
  233. const renderedMessages = useMemo(
  234. () =>
  235. chatMessages.map((message, index) => {
  236. const isBeingEdited = editingMessageId === message.id
  237. const isAfterEditedMessage = editingMessageId
  238. ? chatMessages.findIndex((m) => m.id === editingMessageId) < index
  239. : false
  240. const isLastMessage = index === chatMessages.length - 1
  241. return (
  242. <Message
  243. id={message.id}
  244. key={message.id}
  245. message={message}
  246. isLoading={chatStatus === 'submitted' || chatStatus === 'streaming'}
  247. readOnly={message.role === 'user'}
  248. addToolApprovalResponse={addToolApprovalResponse}
  249. onDelete={deleteMessageFromHere}
  250. onEdit={editMessage}
  251. isAfterEditedMessage={isAfterEditedMessage}
  252. isBeingEdited={isBeingEdited}
  253. onCancelEdit={cancelEdit}
  254. isLastMessage={isLastMessage}
  255. onRate={handleRateMessage}
  256. rating={messageRatings[message.id] ?? null}
  257. />
  258. )
  259. }),
  260. [
  261. chatMessages,
  262. deleteMessageFromHere,
  263. editMessage,
  264. cancelEdit,
  265. editingMessageId,
  266. chatStatus,
  267. addToolApprovalResponse,
  268. handleRateMessage,
  269. messageRatings,
  270. ]
  271. )
  272. const hasMessages = chatMessages.length > 0
  273. const sendMessageToAssistant = (finalContent: string) => {
  274. if (editingMessageId) {
  275. // Handling when the user is in edit mode
  276. // delete the message(s) from the chat just like the delete button
  277. setIsResubmitting(true)
  278. deleteMessageFromHere(editingMessageId)
  279. setEditingMessageId(null)
  280. }
  281. const payload = {
  282. role: 'user',
  283. createdAt: new Date(),
  284. parts: [{ type: 'text', text: finalContent }],
  285. id: uuidv4(),
  286. } as MessageType
  287. snap.clearSqlSnippets()
  288. lastUserMessageRef.current = payload
  289. if (hasPendingApproval && !editingMessageId) {
  290. setMessages(resolvePendingToolApprovalsAsDenied(chatMessages))
  291. }
  292. sendMessage(payload, {
  293. body: {
  294. schema: currentSchema,
  295. table: currentTable?.name,
  296. },
  297. })
  298. setValue('')
  299. if (finalContent.includes('Help me to debug')) {
  300. track('assistant_debug_submitted', { chatId: snap.activeChatId })
  301. } else {
  302. track('assistant_prompt_submitted', { chatId: snap.activeChatId })
  303. }
  304. }
  305. const handleClearMessages = () => {
  306. if (isChatLoading) stop()
  307. snap.clearMessages()
  308. setMessages([])
  309. lastUserMessageRef.current = null
  310. setEditingMessageId(null)
  311. }
  312. useEffect(() => {
  313. // Keep "Thinking" visible while stopping and resubmitting during edit
  314. // Only clear once the new response actually starts streaming (or errors)
  315. if (isResubmitting && (chatStatus === 'streaming' || !!error)) {
  316. setIsResubmitting(false)
  317. }
  318. }, [isResubmitting, chatStatus, error])
  319. useEffect(() => {
  320. setValue(snap.initialInput || '')
  321. if (inputRef.current && snap.initialInput) {
  322. inputRef.current.focus()
  323. inputRef.current.setSelectionRange(snap.initialInput.length, snap.initialInput.length)
  324. }
  325. }, [snap.initialInput])
  326. useEffect(() => {
  327. const isOpen = activeSidebar?.id === SIDEBAR_KEYS.AI_ASSISTANT
  328. if (isOpen && isInSQLEditor && !!snippetContent) {
  329. snap.setSqlSnippets([{ label: 'Current Query', content: snippetContent }])
  330. }
  331. // eslint-disable-next-line react-hooks/exhaustive-deps
  332. }, [activeSidebar?.id, isInSQLEditor, snippetContent])
  333. return (
  334. <ErrorBoundary
  335. message="Something went wrong with the AI Assistant"
  336. sentryContext={{
  337. component: 'AIAssistant',
  338. feature: 'AI Assistant Panel',
  339. projectRef: project?.ref,
  340. organizationSlug: selectedOrganization?.slug,
  341. }}
  342. actions={[
  343. {
  344. label: 'Clear messages and refresh',
  345. onClick: () => {
  346. handleClearMessages()
  347. window.location.reload()
  348. },
  349. },
  350. ]}
  351. >
  352. <div className={cn('flex flex-col h-full w-full md:h-full max-h-dvh', className)}>
  353. <AIAssistantHeader
  354. isChatLoading={isChatLoading}
  355. onNewChat={snap.newChat}
  356. onCloseAssistant={() => closeSidebar(SIDEBAR_KEYS.AI_ASSISTANT)}
  357. showMetadataWarning={showMetadataWarning}
  358. updatedOptInSinceMCP={updatedOptInSinceMCP}
  359. isHipaaProjectDisallowed={isHipaaProjectDisallowed}
  360. aiOptInLevel={aiOptInLevel}
  361. />
  362. {hasMessages ? (
  363. <Conversation className={cn('flex-1')}>
  364. <ConversationContent className="w-full px-7 py-8 mb-10">
  365. {renderedMessages}
  366. {error && (
  367. <>
  368. <AlertError
  369. error={
  370. isContextExceededError
  371. ? ASSISTANT_ERRORS['context-exceeded']
  372. : IS_PLATFORM
  373. ? ASSISTANT_ERRORS['default']
  374. : error
  375. }
  376. showErrorPrefix={false}
  377. showInstructions={false}
  378. subject="Sorry, I'm having trouble responding right now."
  379. additionalActions={
  380. <div className="flex items-center gap-x-2 mr-auto">
  381. {isContextExceededError ? (
  382. <Button
  383. type="default"
  384. size="tiny"
  385. onClick={() => snap.newChat()}
  386. className="text-xs"
  387. >
  388. New chat
  389. </Button>
  390. ) : (
  391. <>
  392. <Button
  393. type="default"
  394. size="tiny"
  395. onClick={() => regenerate()}
  396. className="text-xs"
  397. >
  398. Retry
  399. </Button>
  400. <ButtonTooltip
  401. type="default"
  402. size="tiny"
  403. onClick={handleClearMessages}
  404. className="w-7 h-7"
  405. icon={<Eraser />}
  406. tooltip={{ content: { side: 'bottom', text: 'Clear messages' } }}
  407. />
  408. </>
  409. )}
  410. </div>
  411. }
  412. />
  413. </>
  414. )}
  415. {isChatLoading && (
  416. <motion.span
  417. animate={{ opacity: [1, 0] }}
  418. transition={{ duration: 1, repeat: Infinity, ease: 'linear' }}
  419. className="inline-block w-1.5 h-4 bg-foreground-lighter mt-4"
  420. />
  421. )}
  422. <p className="text-center text-xs text-foreground-muted mt-6">
  423. Briven AI may not always produce correct answers. Double check responses.
  424. </p>
  425. </ConversationContent>
  426. <ConversationScrollButton />
  427. </Conversation>
  428. ) : (
  429. <AIOnboarding
  430. sqlSnippets={snap.sqlSnippets as SqlSnippet[] | undefined}
  431. suggestions={
  432. snap.suggestions as
  433. | { title?: string; prompts?: { label: string; description: string }[] }
  434. | undefined
  435. }
  436. onValueChange={(val) => setValue(val)}
  437. onFocusInput={() => inputRef.current?.focus()}
  438. />
  439. )}
  440. <AnimatePresence>
  441. {editingMessageId && (
  442. <motion.div
  443. initial={{ opacity: 0 }}
  444. animate={{ opacity: 1 }}
  445. exit={{ opacity: 0 }}
  446. className="pointer-events-none z-10 -mt-24"
  447. >
  448. <div className="h-24 w-full bg-linear-to-t from-background to-transparent relative">
  449. <motion.div
  450. className="absolute left-1/2 z-20 bottom-8 pointer-events-auto"
  451. variants={{
  452. hidden: { y: 5, opacity: 0 },
  453. show: { y: 0, opacity: 1 },
  454. }}
  455. transition={{ duration: 0.1 }}
  456. initial="hidden"
  457. animate="show"
  458. exit="hidden"
  459. >
  460. <div className="-translate-x-1/2 bg-alternative dark:bg-muted border rounded-md px-3 py-2 min-w-[180px] flex items-center justify-between gap-x-2">
  461. <div className="flex items-center gap-x-2 text-sm text-foreground">
  462. <Pencil size={14} />
  463. <span>Editing message</span>
  464. </div>
  465. <ButtonTooltip
  466. type="outline"
  467. size="tiny"
  468. icon={<X size={14} />}
  469. onClick={cancelEdit}
  470. className="w-6 h-6 p-0"
  471. title="Cancel editing"
  472. aria-label="Cancel editing"
  473. tooltip={{
  474. content: { side: 'top', text: <KeyboardShortcut keys={['Meta', 'Esc']} /> },
  475. }}
  476. />
  477. </div>
  478. </motion.div>
  479. </div>
  480. </motion.div>
  481. )}
  482. </AnimatePresence>
  483. <div className="px-3 pb-3 z-20 relative">
  484. {disablePrompts && (
  485. <Admonition
  486. showIcon={false}
  487. type="default"
  488. title="Assistant has been temporarily disabled"
  489. description="We're currently looking into getting it back online"
  490. />
  491. )}
  492. {isSuccess && !isApiKeySet && (
  493. <Admonition
  494. type="default"
  495. title="OpenAI API key not set"
  496. description={
  497. <Markdown
  498. content={
  499. 'Add your `OPENAI_API_KEY` to your environment variables to use the AI Assistant.'
  500. }
  501. />
  502. }
  503. />
  504. )}
  505. <AssistantChatForm
  506. textAreaRef={inputRef}
  507. className={cn(
  508. 'z-20 [&>form>textarea]:text-base [&>form>textarea]:md:text-sm [&>form>textarea]:border [&>form>textarea]:rounded-md [&>form>textarea]:outline-hidden! [&>form>textarea]:ring-offset-0! [&>form>textarea]:ring-0!'
  509. )}
  510. loading={isChatLoading}
  511. isEditing={!!editingMessageId}
  512. disabled={isChatInputDisabled}
  513. placeholder={
  514. hasMessages
  515. ? 'Ask a follow up question...'
  516. : (snap.sqlSnippets ?? [])?.length > 0
  517. ? 'Ask a question or make a change...'
  518. : 'Chat to Postgres...'
  519. }
  520. value={value}
  521. onValueChange={(e) => setValue(e.target.value)}
  522. onSubmit={(finalMessage) => {
  523. sendMessageToAssistant(finalMessage)
  524. }}
  525. onStop={() => {
  526. stop()
  527. // to save partial responses from the AI
  528. const lastMessage = chatMessages[chatMessages.length - 1]
  529. if (lastMessage && lastMessage.role === 'assistant') {
  530. state.updateMessage(lastMessage)
  531. }
  532. }}
  533. sqlSnippets={snap.sqlSnippets as SqlSnippet[] | undefined}
  534. onRemoveSnippet={(index) => {
  535. const newSnippets = [...(snap.sqlSnippets ?? [])]
  536. newSnippets.splice(index, 1)
  537. snap.setSqlSnippets(newSnippets)
  538. }}
  539. includeSnippetsInMessage={aiOptInLevel !== 'disabled'}
  540. selectedModel={selectedModel}
  541. onSelectModel={(model) => snap.setModel(model)}
  542. />
  543. </div>
  544. </div>
  545. </ErrorBoundary>
  546. )
  547. }