mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
Joshen/fe 3932 support branching conversations (#48519)
## Context Adds support for branching off from an Assistant's Response - which creates a new chat with all the previous messages including from where we're branching off from <img width="204" height="97" alt="image" src="https://github.com/user-attachments/assets/0b171ae6-f2b4-4b58-87fa-0010ad45f777" /> Branched conversations will have an indication of where it was branched off from <img width="404" height="427" alt="image" src="https://github.com/user-attachments/assets/bed8502f-3f83-4f76-bc00-feac86fa57a6" /> ## Other changes - Also added support for copying an Assistant's Response <img width="190" height="115" alt="image" src="https://github.com/user-attachments/assets/5e4aa0b8-eb6e-485f-80c0-3028b95720f7" /> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Branch conversations from assistant messages into a new chat. * View the originating conversation and navigate back to it. * Copy assistant message content with visual confirmation. * Access branching and copying actions from message controls. * **UI Updates** * Added “Branched from” indicators for branched conversations. * Updated the assistant disclaimer text to “The Assistant.” <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
1 parent
fc5e03f9e3
commit
0e92a9574c
4 files changed
+138
-23
No files matched your search
@@ -6,13 +6,14 @@ import { useParams, useSearchParamsShallow } from 'common/hooks'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { Eraser, Pencil, X } from 'lucide-react'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Button, cn, KeyboardShortcut } from 'ui'
|
||||
import { Admonition } from 'ui-patterns/Admonition'
|
||||
|
||||
import { AlertError } from '../AlertError'
|
||||
import { ButtonTooltip } from '../ButtonTooltip'
|
||||
import { ErrorBoundary } from '../ErrorBoundary/ErrorBoundary'
|
||||
import { InlineLinkClassName } from '../InlineLink'
|
||||
import { ASSISTANT_ERRORS } from './AiAssistant.constants'
|
||||
import type { SqlSnippet } from './AIAssistant.types'
|
||||
import {
|
||||
@@ -186,6 +187,9 @@ export const AIAssistant = ({ className }: AIAssistantProps) => {
|
||||
const isChatInputDisabled =
|
||||
!isApiKeySet || disablePrompts || isLoadingOrganization || isSupportChatClosed
|
||||
|
||||
const branchedFrom = snap.activeChat?.branchedFrom
|
||||
const branchedConversation = branchedFrom ? snap.chats[branchedFrom.chatId] : undefined
|
||||
|
||||
const deleteMessageFromHere = useCallback(
|
||||
(messageId: string) => {
|
||||
// Find the message index in current chatMessages
|
||||
@@ -289,22 +293,39 @@ export const AIAssistant = ({ className }: AIAssistantProps) => {
|
||||
const isLastMessage = index === chatMessages.length - 1
|
||||
|
||||
return (
|
||||
<Message
|
||||
id={message.id}
|
||||
key={message.id}
|
||||
message={message}
|
||||
isLoading={chatStatus === 'submitted' || chatStatus === 'streaming'}
|
||||
readOnly={message.role === 'user'}
|
||||
addToolApprovalResponse={addToolApprovalResponse}
|
||||
onDelete={deleteMessageFromHere}
|
||||
onEdit={editMessage}
|
||||
isAfterEditedMessage={isAfterEditedMessage}
|
||||
isBeingEdited={isBeingEdited}
|
||||
onCancelEdit={cancelEdit}
|
||||
isLastMessage={isLastMessage}
|
||||
onRate={handleRateMessage}
|
||||
rating={messageRatings[message.id] ?? null}
|
||||
/>
|
||||
<Fragment key={message.id}>
|
||||
<Message
|
||||
id={message.id}
|
||||
message={message}
|
||||
isLoading={chatStatus === 'submitted' || chatStatus === 'streaming'}
|
||||
readOnly={message.role === 'user'}
|
||||
addToolApprovalResponse={addToolApprovalResponse}
|
||||
onDelete={deleteMessageFromHere}
|
||||
onEdit={editMessage}
|
||||
isAfterEditedMessage={isAfterEditedMessage}
|
||||
isBeingEdited={isBeingEdited}
|
||||
onCancelEdit={cancelEdit}
|
||||
isLastMessage={isLastMessage}
|
||||
onRate={handleRateMessage}
|
||||
rating={messageRatings[message.id] ?? null}
|
||||
/>
|
||||
{branchedConversation && branchedFrom?.messageId === message.id && (
|
||||
<div className="flex items-center gap-2 mt-6">
|
||||
<div className="flex-1 border-t border-strong" />
|
||||
<div className="flex items-center gap-1 max-w-[80%] text-xs text-foreground-lighter">
|
||||
<span className="shrink-0">Branched from</span>
|
||||
<button
|
||||
tabIndex={0}
|
||||
className={cn(InlineLinkClassName, 'cursor-pointer truncate min-w-0')}
|
||||
onClick={() => snap.selectChat(branchedConversation.id)}
|
||||
>
|
||||
{branchedConversation.name}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 border-t border-strong" />
|
||||
</div>
|
||||
)}
|
||||
</Fragment>
|
||||
)
|
||||
}),
|
||||
[
|
||||
@@ -317,6 +338,9 @@ export const AIAssistant = ({ className }: AIAssistantProps) => {
|
||||
addToolApprovalResponse,
|
||||
handleRateMessage,
|
||||
messageRatings,
|
||||
branchedConversation,
|
||||
branchedFrom,
|
||||
snap,
|
||||
]
|
||||
)
|
||||
|
||||
@@ -491,8 +515,9 @@ export const AIAssistant = ({ className }: AIAssistantProps) => {
|
||||
className="inline-block w-1.5 h-4 bg-foreground-lighter mt-4"
|
||||
/>
|
||||
)}
|
||||
|
||||
<p className="text-center text-xs text-foreground-muted mt-6">
|
||||
Supabase AI may not always produce correct answers. Double check responses.
|
||||
The Assistant can make mistakes. Double check responses.
|
||||
</p>
|
||||
</ConversationContent>
|
||||
<ConversationScrollButton />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { Pencil, ThumbsDown, ThumbsUp, Trash2 } from 'lucide-react'
|
||||
import { Check, Copy, Pencil, Split, ThumbsDown, ThumbsUp, Trash2 } from 'lucide-react'
|
||||
import { useEffect, useState, type PropsWithChildren } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import {
|
||||
@@ -65,6 +65,42 @@ function MessageActionsDelete({ onClick }: { onClick: () => void }) {
|
||||
}
|
||||
MessageActions.Delete = MessageActionsDelete
|
||||
|
||||
function MessageActionsBranch({ onClick }: { onClick: () => void }) {
|
||||
return (
|
||||
<ButtonTooltip
|
||||
variant="text"
|
||||
icon={<Split size={14} strokeWidth={1.5} />}
|
||||
onClick={onClick}
|
||||
className="text-foreground-light hover:text-foreground p-1 rounded-sm"
|
||||
title="Branch in new chat"
|
||||
aria-label="Branch in new chat"
|
||||
tooltip={{ content: { side: 'bottom', text: 'Branch in new chat' } }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
MessageActions.Branch = MessageActionsBranch
|
||||
|
||||
function MessageActionsCopy({ onClick }: { onClick: (onSuccess: () => void) => void }) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (copied) setTimeout(() => setCopied(false), 1000)
|
||||
}, [copied])
|
||||
|
||||
return (
|
||||
<ButtonTooltip
|
||||
variant="text"
|
||||
icon={copied ? <Check size={14} strokeWidth={2} /> : <Copy size={14} strokeWidth={1.5} />}
|
||||
onClick={() => onClick(() => setCopied(true))}
|
||||
className="text-foreground-light hover:text-foreground p-1 rounded-sm"
|
||||
title="Copy response"
|
||||
aria-label="Copy response"
|
||||
tooltip={{ content: { side: 'bottom', text: 'Copy response' } }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
MessageActions.Copy = MessageActionsCopy
|
||||
|
||||
function MessageActionsThumbsUp({
|
||||
onClick,
|
||||
isActive,
|
||||
|
||||
@@ -1,22 +1,32 @@
|
||||
import { UIMessage as VercelMessage } from '@ai-sdk/react'
|
||||
import { useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { cn } from 'ui'
|
||||
import { cn, copyToClipboard } from 'ui'
|
||||
|
||||
import { DeleteMessageConfirmModal } from './DeleteMessageConfirmModal'
|
||||
import { MessageActions } from './Message.Actions'
|
||||
import type { AddToolApprovalResponse, MessageInfo } from './Message.Context'
|
||||
import { MessageProvider, useMessageActionsContext, useMessageInfoContext } from './Message.Context'
|
||||
import { MessageDisplay } from './Message.Display'
|
||||
import { useAiAssistantStateSnapshot } from '@/state/ai-assistant-state'
|
||||
|
||||
function AssistantMessage({ message }: { message: VercelMessage }) {
|
||||
const { id, variant, state, isLastMessage, readOnly, rating, isLoading } = useMessageInfoContext()
|
||||
const snap = useAiAssistantStateSnapshot()
|
||||
const { onCancelEdit, onRate } = useMessageActionsContext()
|
||||
const { id, variant, state, isLastMessage, readOnly, rating, isLoading } = useMessageInfoContext()
|
||||
|
||||
const handleRate = (newRating: 'positive' | 'negative', reason?: string) => {
|
||||
onRate?.(id, newRating, reason)
|
||||
}
|
||||
|
||||
const handleCopy = (onSuccess: () => void) => {
|
||||
const response = message.parts
|
||||
.filter((x) => x.type === 'text')
|
||||
.map((x) => x.text)
|
||||
.join('\n')
|
||||
copyToClipboard(response, onSuccess)
|
||||
}
|
||||
|
||||
return (
|
||||
<MessageDisplay.Container
|
||||
className={cn(
|
||||
@@ -28,8 +38,9 @@ function AssistantMessage({ message }: { message: VercelMessage }) {
|
||||
<MessageDisplay.MainArea>
|
||||
<MessageDisplay.Content message={message} />
|
||||
</MessageDisplay.MainArea>
|
||||
{!readOnly && isLastMessage && onRate && !isLoading && (
|
||||
<MessageActions alwaysShow>
|
||||
{!readOnly && onRate && !isLoading && (
|
||||
<MessageActions alwaysShow={isLastMessage}>
|
||||
<MessageActions.Copy onClick={handleCopy} />
|
||||
<MessageActions.ThumbsUp
|
||||
onClick={() => handleRate('positive')}
|
||||
isActive={rating === 'positive'}
|
||||
@@ -40,6 +51,7 @@ function AssistantMessage({ message }: { message: VercelMessage }) {
|
||||
isActive={rating === 'negative'}
|
||||
disabled={!!rating}
|
||||
/>
|
||||
<MessageActions.Branch onClick={() => snap.branchChat(id)} />
|
||||
</MessageActions>
|
||||
)}
|
||||
</MessageDisplay.Container>
|
||||
|
||||
@@ -61,6 +61,7 @@ type ChatSession = {
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
supportMetadata?: SupportChatMetadata
|
||||
branchedFrom?: { chatId: string; messageId: string }
|
||||
}
|
||||
|
||||
export type AiAssistantContext = {
|
||||
@@ -424,6 +425,46 @@ export const createAiAssistantState = (): AiAssistantState => {
|
||||
return chatId
|
||||
},
|
||||
|
||||
branchChat: (messageId: string) => {
|
||||
const sourceChat = state.activeChat
|
||||
if (!sourceChat) return
|
||||
|
||||
const messageIndex = sourceChat.messages.findIndex((msg) => msg.id === messageId)
|
||||
if (messageIndex === -1) return
|
||||
|
||||
const branchedMessages = sourceChat.messages
|
||||
.slice(0, messageIndex + 1)
|
||||
.map((message) => sanitizeForCloning(message))
|
||||
|
||||
const chatId = uuidv4()
|
||||
const newChat: ChatSession = {
|
||||
id: chatId,
|
||||
name: `Branch - ${sourceChat.name}`,
|
||||
messages: branchedMessages,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
branchedFrom: { chatId: sourceChat.id, messageId },
|
||||
}
|
||||
|
||||
state.chats = {
|
||||
...state.chats,
|
||||
[chatId]: newChat,
|
||||
}
|
||||
state.activeChatId = chatId
|
||||
|
||||
state.chatInstances[chatId] = ref(
|
||||
createChatInstance(state, { id: chatId, initialMessages: branchedMessages })
|
||||
)
|
||||
|
||||
const initialAiAssistantData = createInitialAiAssistantData()
|
||||
state.initialInput = initialAiAssistantData.initialInput
|
||||
state.sqlSnippets = initialAiAssistantData.sqlSnippets
|
||||
state.suggestions = initialAiAssistantData.suggestions
|
||||
state.tables = initialAiAssistantData.tables
|
||||
|
||||
return chatId
|
||||
},
|
||||
|
||||
setSupportLifecycleStatus: (chatId: string, status: AiSupportStatus) => {
|
||||
const chat = state.chats[chatId]
|
||||
if (!chat?.supportMetadata) return
|
||||
@@ -608,6 +649,7 @@ export type AiAssistantState = AiAssistantData & {
|
||||
Pick<AiAssistantData, 'initialInput' | 'sqlSnippets' | 'suggestions' | 'tables'>
|
||||
>
|
||||
) => string
|
||||
branchChat: (messageId: string) => string | undefined
|
||||
setSupportLifecycleStatus: (chatId: string, status: AiSupportStatus) => void
|
||||
selectChat: (id: string) => void
|
||||
deleteChat: (id: string) => void
|
||||
|
||||
Reference in new issue
Block a user