import type { Attachment, ChatRequestOptions, CreateMessage, Message, } from 'ai'; import cx from 'classnames'; import { formatDistance } from 'date-fns'; import { AnimatePresence, motion } from 'framer-motion'; import { type Dispatch, type SetStateAction, useCallback, useEffect, useState, } from 'react'; import { toast } from 'sonner'; import useSWR, { useSWRConfig } from 'swr'; import { useCopyToClipboard, useDebounceCallback, useWindowSize, } from 'usehooks-ts'; import type { Document, Suggestion, Vote } from '@/lib/db/schema'; import { fetcher } from '@/lib/utils'; import { DiffView } from './diffview'; import { DocumentSkeleton } from './document-skeleton'; import { Editor } from './editor'; import { CopyIcon, CrossIcon, DeltaIcon, RedoIcon, UndoIcon } from './icons'; import { PreviewMessage } from './message'; import { MultimodalInput } from './multimodal-input'; import { Toolbar } from './toolbar'; import { Button } from './ui/button'; import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip'; import { useScrollToBottom } from './use-scroll-to-bottom'; import { VersionFooter } from './version-footer'; export interface UIBlock { title: string; documentId: string; content: string; isVisible: boolean; status: 'streaming' | 'idle'; boundingBox: { top: number; left: number; width: number; height: number; }; } export function Block({ chatId, input, setInput, handleSubmit, isLoading, stop, attachments, setAttachments, append, block, setBlock, messages, setMessages, votes, }: { chatId: string; input: string; setInput: (input: string) => void; isLoading: boolean; stop: () => void; attachments: Array; setAttachments: Dispatch>>; block: UIBlock; setBlock: Dispatch>; messages: Array; setMessages: Dispatch>>; votes: Array | undefined; append: ( message: Message | CreateMessage, chatRequestOptions?: ChatRequestOptions ) => Promise; handleSubmit: ( event?: { preventDefault?: () => void; }, chatRequestOptions?: ChatRequestOptions ) => void; }) { const [messagesContainerRef, messagesEndRef] = useScrollToBottom(); const { data: documents, isLoading: isDocumentsFetching, mutate: mutateDocuments, } = useSWR>( block && block.status !== 'streaming' ? `/api/document?id=${block.documentId}` : null, fetcher ); const { data: suggestions } = useSWR>( documents && block && block.status !== 'streaming' ? `/api/suggestions?documentId=${block.documentId}` : null, fetcher, { dedupingInterval: 5000, } ); const [mode, setMode] = useState<'edit' | 'diff'>('edit'); const [document, setDocument] = useState(null); const [currentVersionIndex, setCurrentVersionIndex] = useState(-1); useEffect(() => { if (documents && documents.length > 0) { const mostRecentDocument = documents.at(-1); if (mostRecentDocument) { setDocument(mostRecentDocument); setCurrentVersionIndex(documents.length - 1); setBlock((currentBlock) => ({ ...currentBlock, content: mostRecentDocument.content ?? '', })); } } }, [documents, setBlock]); useEffect(() => { mutateDocuments(); }, [block.status, mutateDocuments]); const { mutate } = useSWRConfig(); const [isContentDirty, setIsContentDirty] = useState(false); const handleContentChange = useCallback( (updatedContent: string) => { if (!block) return; mutate>( `/api/document?id=${block.documentId}`, async (currentDocuments) => { if (!currentDocuments) return undefined; const currentDocument = currentDocuments.at(-1); if (!currentDocument || !currentDocument.content) { setIsContentDirty(false); return currentDocuments; } if (currentDocument.content !== updatedContent) { await fetch(`/api/document?id=${block.documentId}`, { method: 'POST', body: JSON.stringify({ title: block.title, content: updatedContent, }), }); setIsContentDirty(false); const newDocument = { ...currentDocument, content: updatedContent, createdAt: new Date(), }; return [...currentDocuments, newDocument]; } return currentDocuments; }, { revalidate: false } ); }, [block, mutate] ); const debouncedHandleContentChange = useDebounceCallback( handleContentChange, 2000 ); const saveContent = useCallback( (updatedContent: string, debounce: boolean) => { if (document && updatedContent !== document.content) { setIsContentDirty(true); if (debounce) { debouncedHandleContentChange(updatedContent); } else { handleContentChange(updatedContent); } } }, [document, debouncedHandleContentChange, handleContentChange] ); function getDocumentContentById(index: number) { if (!documents) return ''; if (!documents[index]) return ''; return documents[index].content ?? ''; } const handleVersionChange = (type: 'next' | 'prev' | 'toggle' | 'latest') => { if (!documents) return; if (type === 'latest') { setCurrentVersionIndex(documents.length - 1); setMode('edit'); } if (type === 'toggle') { setMode((mode) => (mode === 'edit' ? 'diff' : 'edit')); } if (type === 'prev') { if (currentVersionIndex > 0) { setCurrentVersionIndex((index) => index - 1); } } else if (type === 'next') { if (currentVersionIndex < documents.length - 1) { setCurrentVersionIndex((index) => index + 1); } } }; const [isToolbarVisible, setIsToolbarVisible] = useState(false); /* * NOTE: if there are no documents, or if * the documents are being fetched, then * we mark it as the current version. */ const isCurrentVersion = documents && documents.length > 0 ? currentVersionIndex === documents.length - 1 : true; const { width: windowWidth, height: windowHeight } = useWindowSize(); const isMobile = windowWidth ? windowWidth < 768 : false; const [_, copyToClipboard] = useCopyToClipboard(); return ( {!isMobile && ( {!isCurrentVersion && ( )}
{messages.map((message, index) => ( vote.messageId === message.id) : undefined } /> ))}
)}
{document?.title ?? block.title}
{isContentDirty ? (
Saving changes...
) : document ? (
{`Updated ${formatDistance( new Date(document.createdAt), new Date(), { addSuffix: true, } )}`}
) : (
)}
Copy to clipboard View Previous version View Next version View changes
{isDocumentsFetching && !block.content ? ( ) : mode === 'edit' ? ( ) : ( )} {suggestions ? (
) : null} {isCurrentVersion && ( )}
{!isCurrentVersion && ( )} ); }