Ctrl K
ComunidadGuías…

components/message-actions.tsx

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
'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}
      />
    </>
  )
}