components/message-actions.tsx
Rama main · réplica de lectura del repositorio original.
'use client'
import { useMemo, useState } from 'react'
import { UseChatHelpers } from '@ai-sdk/react'
import {
IconBookmark as Bookmark,
IconCopy as Copy,
IconThumbDown as ThumbsDown,
IconThumbUp as ThumbsUp
} from '@tabler/icons-react'
import { toast } from 'sonner'
import { saveNote } from '@/lib/actions/notes'
import { captureClient } from '@/lib/analytics/posthog-client'
import { stripSourceContextBlocks } from '@/lib/render/strip-source-context-blocks'
import { stripSpecBlocks } from '@/lib/render/strip-spec-blocks'
import type { SearchResultItem } from '@/lib/types'
import type { UIDataTypes, UIMessage, UITools } from '@/lib/types/ai'
import { cn } from '@/lib/utils'
import { processCitations } from '@/lib/utils/citation'
import { useLibrary } from './library/library-context'
import { SaveNoteAuthDialog } from './library/save-note-auth-dialog'
import { Button } from './ui/button'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger
} from './ui/tooltip'
import { ChatShare } from './chat-share'
import { RetryButton } from './retry-button'
interface MessageActionsProps {
message: string
messageId: string
traceId?: string
feedbackScore?: number | null
reload?: () => Promise<void | string | null | undefined>
chatId?: string
enableShare?: boolean
isGuest?: boolean
isCloudDeployment?: boolean
libraryAvailable?: boolean
className?: string
status?: UseChatHelpers<UIMessage<unknown, UIDataTypes, UITools>>['status']
visible?: boolean
citationMaps?: Record<string, Record<number, SearchResultItem>>
}
export function MessageActions({
message,
messageId,
traceId,
feedbackScore: initialFeedbackScore,
reload,
chatId,
enableShare,
isGuest = false,
isCloudDeployment = false,
libraryAvailable = true,
className,
status,
visible = true,
citationMaps
}: MessageActionsProps) {
const [feedbackScore, setFeedbackScore] = useState<number | null>(
initialFeedbackScore ?? null
)
const [isSavingNote, setIsSavingNote] = useState(false)
const [authPromptOpen, setAuthPromptOpen] = useState(false)
const { openLibrary, upsertCachedNote } = useLibrary()
const mappedMessage = useMemo(() => {
if (!message) return ''
return processCitations(message, citationMaps || {})
}, [message, citationMaps])
const [isSubmittingFeedback, setIsSubmittingFeedback] = useState(false)
const isLoading = status === 'submitted' || status === 'streaming'
const showSaveButton = libraryAvailable && (!isGuest || isCloudDeployment)
// Keep the element mounted during loading to preserve layout; otherwise skip rendering.
if (!visible && !isLoading) {
return null
}
async function handleCopy() {
await navigator.clipboard.writeText(
stripSpecBlocks(stripSourceContextBlocks(mappedMessage))
)
toast.success('Message copied to clipboard')
}
async function handleSaveNote() {
if (isSavingNote) return
const content = stripSpecBlocks(stripSourceContextBlocks(mappedMessage))
captureClient('note_save_clicked', {
source: 'button',
chatId,
chars: content.length,
isGuest,
isCloudDeployment
})
if (isGuest) {
setAuthPromptOpen(true)
captureClient('library_auth_prompt_opened', {
source: 'save_button',
chatId
})
return
}
setIsSavingNote(true)
try {
const result = await saveNote({
content,
chatId,
sourceMessageId: messageId
})
if (!result.success) {
captureClient('note_save_failed', {
source: 'button',
chatId,
reason: result.error ?? 'unknown'
})
toast.error(result.error ?? 'Failed to save note')
return
}
if (result.note) {
upsertCachedNote(result.note)
}
captureClient('note_saved', {
source: 'button',
chatId,
chars: content.length
})
toast.success('Saved to library', {
action: {
label: 'Open',
onClick: () => {
openLibrary()
captureClient('library_opened', { source: 'toast' })
}
}
})
} catch (error) {
console.error('Error saving note:', error)
captureClient('note_save_failed', {
source: 'button',
chatId,
reason: 'exception'
})
toast.error('Failed to save note')
} finally {
setIsSavingNote(false)
}
}
async function handleFeedback(score: number) {
if (isSubmittingFeedback || !traceId) return
captureClient('feedback_control_clicked', { score, chatId, isGuest })
setIsSubmittingFeedback(true)
try {
const response = await fetch('/api/feedback', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
traceId,
score,
messageId
})
})
if (response.ok) {
// The route answers 200 without writing a score when tracing is off, so
// this records that the request succeeded, not that a score exists.
captureClient('feedback_recorded', { score, chatId, isGuest })
setFeedbackScore(score)
toast.success(
score === 1
? 'Thanks for the feedback!'
: 'Thanks for letting us know!'
)
} else {
captureClient('feedback_failed', {
score,
chatId,
isGuest,
status: response.status,
reason: 'response'
})
console.error('Failed to submit feedback')
toast.error('Failed to submit feedback')
}
} catch (error) {
captureClient('feedback_failed', {
score,
chatId,
isGuest,
reason: 'exception'
})
console.error('Error submitting feedback:', error)
toast.error('Failed to submit feedback')
} finally {
setIsSubmittingFeedback(false)
}
}
return (
<>
<div
aria-hidden={!visible}
className={cn(
'flex w-full items-center gap-3 self-stretch transition-opacity duration-200',
visible ? 'opacity-100' : 'pointer-events-none opacity-0 invisible',
className
)}
>
<div className="flex items-center gap-0.5">
{reload && <RetryButton reload={reload} messageId={messageId} />}
<Button
variant="ghost"
size="icon"
onClick={handleCopy}
className="rounded-full"
>
<Copy size={14} />
</Button>
{enableShare && chatId && <ChatShare chatId={chatId} />}
{traceId && (
<>
{(feedbackScore === null || feedbackScore === 1) && (
<Button
variant="ghost"
size="icon"
onClick={() => handleFeedback(1)}
disabled={isSubmittingFeedback || feedbackScore === 1}
className="rounded-full"
aria-label="Good response"
>
<ThumbsUp
size={14}
className={feedbackScore === 1 ? 'fill-current' : ''}
/>
</Button>
)}
{(feedbackScore === null || feedbackScore === -1) && (
<Button
variant="ghost"
size="icon"
onClick={() => handleFeedback(-1)}
disabled={isSubmittingFeedback || feedbackScore === -1}
className="rounded-full"
aria-label="Bad response"
>
<ThumbsDown
size={14}
className={feedbackScore === -1 ? 'fill-current' : ''}
/>
</Button>
)}
</>
)}
{showSaveButton && (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={handleSaveNote}
disabled={isSavingNote}
className="rounded-full"
aria-label="Save to library"
>
<Bookmark size={14} />
</Button>
</TooltipTrigger>
<TooltipContent className="text-xs">
Save to library
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
</div>
<SaveNoteAuthDialog
open={authPromptOpen}
onOpenChange={setAuthPromptOpen}
source="save_button"
chatId={chatId}
/>
</>
)
}