components/answer-section.tsx
Rama main · réplica de lectura del repositorio original.
'use client'
import { useEffect, useRef, useState } from 'react'
import Link from 'next/link'
import { UseChatHelpers } from '@ai-sdk/react'
import {
IconBookmark as Bookmark,
IconMessageCirclePlus as MessageCirclePlus
} from '@tabler/icons-react'
import { ChatRequestOptions } from 'ai'
import { toast } from 'sonner'
import { saveNote } from '@/lib/actions/notes'
import { captureClient } from '@/lib/analytics/posthog-client'
import { useAuthCapabilities } from '@/lib/contexts/app-user-context'
import { stripSourceContextBlocks } from '@/lib/render/strip-source-context-blocks'
import type { SearchResultItem } from '@/lib/types'
import type {
UIDataTypes,
UIMessage,
UIMessageMetadata,
UITools
} from '@/lib/types/ai'
import { useLibrary } from './library/library-context'
import { Button } from './ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from './ui/dialog'
import { CollapsibleMessage } from './collapsible-message'
import { MarkdownMessage } from './message'
import { MessageActions } from './message-actions'
export type AnswerSectionProps = {
content: string
isOpen: boolean
onOpenChange: (open: boolean) => void
chatId?: string
showActions?: boolean
messageId: string
metadata?: UIMessageMetadata
status?: UseChatHelpers<UIMessage<unknown, UIDataTypes, UITools>>['status']
reload?: (
messageId: string,
options?: ChatRequestOptions
) => Promise<void | string | null | undefined>
citationMaps?: Record<string, Record<number, SearchResultItem>>
isGuest?: boolean
isCloudDeployment?: boolean
libraryAvailable?: boolean
onQuoteContext?: (text: string) => void
}
export function AnswerSection({
content,
isOpen,
onOpenChange,
chatId,
showActions = true, // Default to true for backward compatibility
messageId,
metadata,
status,
reload,
citationMaps,
isGuest = false,
isCloudDeployment = false,
libraryAvailable = true,
onQuoteContext
}: AnswerSectionProps) {
const displayContent = stripSourceContextBlocks(content)
const contentRef = useRef<HTMLDivElement>(null)
const [selection, setSelection] = useState<{
text: string
top: number
left: number
} | null>(null)
const [isSavingSelection, setIsSavingSelection] = useState(false)
const [authPromptOpen, setAuthPromptOpen] = useState(false)
const lastTrackedSelectionKeyRef = useRef<string | null>(null)
const { openLibrary, upsertCachedNote } = useLibrary()
// Sharing records the chat under the signed-in user, so it needs a real
// auth provider (supabase or better-auth), not the anonymous one. The
// opt-in flag (ENABLE_SHARE) is folded into this capability server-side
// by the root layout, so a prebuilt image can still enable sharing at
// runtime; shareChat enforces the same flag again.
const capabilities = useAuthCapabilities()
const enableShare = capabilities.share && !isGuest
const showSelectionSaveButton =
libraryAvailable && (!isGuest || isCloudDeployment)
const showSelectionDeepDiveButton = Boolean(onQuoteContext)
useEffect(() => {
if (!selection) return
const clearSelection = () => {
setSelection(null)
lastTrackedSelectionKeyRef.current = null
}
const handleSelectionChange = () => {
const text = window.getSelection()?.toString().trim()
if (!text) {
clearSelection()
}
}
const handleMouseDown = (event: MouseEvent) => {
const target = event.target
if (!(target instanceof Node)) return
if (contentRef.current?.contains(target)) return
if (
target instanceof Element &&
target.closest('[data-selection-actions]')
) {
return
}
clearSelection()
}
document.addEventListener('selectionchange', handleSelectionChange)
document.addEventListener('mousedown', handleMouseDown)
window.addEventListener('scroll', clearSelection, true)
return () => {
document.removeEventListener('selectionchange', handleSelectionChange)
document.removeEventListener('mousedown', handleMouseDown)
window.removeEventListener('scroll', clearSelection, true)
}
}, [selection])
const handleReload = () => {
if (reload) {
return reload(messageId)
}
return Promise.resolve(undefined)
}
const updateSelection = () => {
const currentSelection = window.getSelection()
const text = currentSelection?.toString().trim()
if (!text || !currentSelection || currentSelection.rangeCount === 0) {
setSelection(null)
lastTrackedSelectionKeyRef.current = null
return
}
const range = currentSelection.getRangeAt(0)
const container = contentRef.current
if (
!container ||
!container.contains(range.commonAncestorContainer) ||
text.length < 2
) {
setSelection(null)
lastTrackedSelectionKeyRef.current = null
return
}
const rect = range.getBoundingClientRect()
const trackingKey = `${messageId}:${text.length}:${Math.round(rect.top)}:${Math.round(rect.left)}`
if (lastTrackedSelectionKeyRef.current !== trackingKey) {
lastTrackedSelectionKeyRef.current = trackingKey
captureClient('selection_note_actions_shown', {
chatId,
chars: text.length,
isGuest,
isCloudDeployment
})
}
setSelection({
text,
top: Math.max(8, rect.top - 42),
left: Math.max(8, Math.min(window.innerWidth - 220, rect.left))
})
}
async function handleSaveSelection() {
if (!selection || isSavingSelection) return
captureClient('note_save_clicked', {
source: 'selection',
chatId,
chars: selection.text.length,
isGuest,
isCloudDeployment
})
if (isGuest) {
setAuthPromptOpen(true)
captureClient('library_auth_prompt_opened', {
source: 'selection_save',
chatId
})
return
}
setIsSavingSelection(true)
try {
const result = await saveNote({
content: selection.text,
chatId,
sourceMessageId: messageId
})
if (!result.success) {
captureClient('note_save_failed', {
source: 'selection',
chatId,
reason: result.error ?? 'unknown'
})
toast.error(result.error ?? 'Failed to save note')
return
}
if (result.note) {
upsertCachedNote(result.note)
}
captureClient('note_saved', {
source: 'selection',
chatId,
chars: selection.text.length
})
toast.success('Saved to library', {
action: {
label: 'Open',
onClick: () => {
openLibrary()
captureClient('library_opened', { source: 'toast' })
}
}
})
setSelection(null)
window.getSelection()?.removeAllRanges()
} catch (error) {
console.error('Error saving selected note:', error)
captureClient('note_save_failed', {
source: 'selection',
chatId,
reason: 'exception'
})
toast.error('Failed to save note')
} finally {
setIsSavingSelection(false)
}
}
function handleQuoteSelection() {
if (!selection) return
onQuoteContext?.(selection.text)
captureClient('selection_followup', {
chatId,
chars: selection.text.length
})
setSelection(null)
window.getSelection()?.removeAllRanges()
}
return (
<CollapsibleMessage
role="assistant"
isCollapsible={false}
isOpen={isOpen}
onOpenChange={onOpenChange}
showBorder={false}
showIcon={false}
>
{displayContent && (
<div className="flex flex-col gap-1">
<div
ref={contentRef}
onMouseUp={updateSelection}
onKeyUp={updateSelection}
>
<MarkdownMessage
message={displayContent}
citationMaps={citationMaps}
/>
</div>
{selection &&
(showSelectionSaveButton || showSelectionDeepDiveButton) && (
<div
data-selection-actions
className="fixed z-40 flex items-center gap-1 rounded-md border bg-popover p-1 text-popover-foreground shadow-md"
style={{ top: selection.top, left: selection.left }}
onMouseDown={event => event.preventDefault()}
>
{showSelectionSaveButton && (
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 gap-1.5 px-2 text-xs"
disabled={isSavingSelection}
onClick={handleSaveSelection}
>
<Bookmark className="size-3.5" />
Save
</Button>
)}
{showSelectionDeepDiveButton && (
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 gap-1.5 px-2 text-xs"
onClick={handleQuoteSelection}
>
<MessageCirclePlus className="size-3.5" />
Deep dive
</Button>
)}
</div>
)}
<MessageActions
message={content} // Provide original message; copy path remaps citations
messageId={messageId}
traceId={metadata?.traceId}
feedbackScore={metadata?.feedbackScore}
chatId={chatId}
enableShare={enableShare}
isGuest={isGuest}
isCloudDeployment={isCloudDeployment}
libraryAvailable={libraryAvailable}
reload={handleReload}
status={status}
visible={showActions}
citationMaps={citationMaps}
/>
</div>
)}
<Dialog open={authPromptOpen} onOpenChange={setAuthPromptOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<div className="mx-auto mb-4 flex size-12 items-center justify-center rounded-full bg-muted">
<Bookmark className="size-6 text-muted-foreground" />
</div>
<DialogTitle className="text-center text-xl font-semibold">
Save notes to your library
</DialogTitle>
<DialogDescription className="text-center text-muted-foreground">
Sign in or create an account to save selected text to your
Library.
</DialogDescription>
</DialogHeader>
<DialogFooter className="flex-col gap-2">
<Button asChild className="w-full">
<Link
href="/auth/sign-up"
onClick={() =>
captureClient('library_auth_prompt_cta_clicked', {
source: 'selection_save',
target: 'sign_up',
chatId
})
}
>
Sign Up
</Link>
</Button>
<Button asChild variant="outline" className="w-full">
<Link
href="/auth/login"
onClick={() =>
captureClient('library_auth_prompt_cta_clicked', {
source: 'selection_save',
target: 'sign_in',
chatId
})
}
>
Sign In
</Link>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</CollapsibleMessage>
)
}