import { formatDistance } from 'date-fns'; import { AnimatePresence, motion } from 'framer-motion'; import { type Dispatch, memo, type SetStateAction, useCallback, useEffect, useState, } from 'react'; import useSWR, { useSWRConfig } from 'swr'; import { useDebounceCallback, useWindowSize } from 'usehooks-ts'; import type { Document, Vote } from '@/lib/db/schema'; import { fetcher } from '@/lib/utils'; import { MultimodalInput } from './multimodal-input'; import { Toolbar } from './toolbar'; import { VersionFooter } from './version-footer'; import { ArtifactActions } from './artifact-actions'; import { ArtifactCloseButton } from './artifact-close-button'; import { ArtifactMessages } from './artifact-messages'; import { useSidebar } from './ui/sidebar'; import { useArtifact } from '@/hooks/use-artifact'; import { imageArtifact } from '@/artifacts/image/client'; import { codeArtifact } from '@/artifacts/code/client'; import { sheetArtifact } from '@/artifacts/sheet/client'; import { textArtifact } from '@/artifacts/text/client'; import equal from 'fast-deep-equal'; import type { UseChatHelpers } from '@ai-sdk/react'; import type { VisibilityType } from './visibility-selector'; import type { Attachment, ChatMessage } from '@/lib/types'; export const artifactDefinitions = [ textArtifact, codeArtifact, imageArtifact, sheetArtifact, ]; export type ArtifactKind = (typeof artifactDefinitions)[number]['kind']; export interface UIArtifact { title: string; documentId: string; kind: ArtifactKind; content: string; isVisible: boolean; status: 'streaming' | 'idle'; boundingBox: { top: number; left: number; width: number; height: number; }; } function PureArtifact({ chatId, input, setInput, status, stop, attachments, setAttachments, sendMessage, messages, setMessages, regenerate, votes, isReadonly, selectedVisibilityType, }: { chatId: string; input: string; setInput: Dispatch>; status: UseChatHelpers['status']; stop: UseChatHelpers['stop']; attachments: Attachment[]; setAttachments: Dispatch>; messages: ChatMessage[]; setMessages: UseChatHelpers['setMessages']; votes: Array | undefined; sendMessage: UseChatHelpers['sendMessage']; regenerate: UseChatHelpers['regenerate']; isReadonly: boolean; selectedVisibilityType: VisibilityType; }) { const { artifact, setArtifact, metadata, setMetadata } = useArtifact(); const { data: documents, isLoading: isDocumentsFetching, mutate: mutateDocuments, } = useSWR>( artifact.documentId !== 'init' && artifact.status !== 'streaming' ? `/api/document?id=${artifact.documentId}` : null, fetcher, ); const [mode, setMode] = useState<'edit' | 'diff'>('edit'); const [document, setDocument] = useState(null); const [currentVersionIndex, setCurrentVersionIndex] = useState(-1); const { open: isSidebarOpen } = useSidebar(); useEffect(() => { if (documents && documents.length > 0) { const mostRecentDocument = documents.at(-1); if (mostRecentDocument) { setDocument(mostRecentDocument); setCurrentVersionIndex(documents.length - 1); setArtifact((currentArtifact) => ({ ...currentArtifact, content: mostRecentDocument.content ?? '', })); } } }, [documents, setArtifact]); useEffect(() => { mutateDocuments(); }, [artifact.status, mutateDocuments]); const { mutate } = useSWRConfig(); const [isContentDirty, setIsContentDirty] = useState(false); const handleContentChange = useCallback( (updatedContent: string) => { if (!artifact) return; mutate>( `/api/document?id=${artifact.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=${artifact.documentId}`, { method: 'POST', body: JSON.stringify({ title: artifact.title, content: updatedContent, kind: artifact.kind, }), }); setIsContentDirty(false); const newDocument = { ...currentDocument, content: updatedContent, createdAt: new Date(), }; return [...currentDocuments, newDocument]; } return currentDocuments; }, { revalidate: false }, ); }, [artifact, 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 artifactDefinition = artifactDefinitions.find( (definition) => definition.kind === artifact.kind, ); if (!artifactDefinition) { throw new Error('Artifact definition not found!'); } useEffect(() => { if (artifact.documentId !== 'init') { if (artifactDefinition.initialize) { artifactDefinition.initialize({ documentId: artifact.documentId, setMetadata, }); } } }, [artifact.documentId, artifactDefinition, setMetadata]); return ( {artifact.isVisible && ( {!isMobile && ( )} {!isMobile && ( {!isCurrentVersion && ( )}
)}
{artifact.title}
{isContentDirty ? (
Saving changes...
) : document ? (
{`Updated ${formatDistance( new Date(document.createdAt), new Date(), { addSuffix: true, }, )}`}
) : (
)}
{isCurrentVersion && ( )}
{!isCurrentVersion && ( )} )} ); } export const Artifact = memo(PureArtifact, (prevProps, nextProps) => { if (prevProps.status !== nextProps.status) return false; if (!equal(prevProps.votes, nextProps.votes)) return false; if (prevProps.input !== nextProps.input) return false; if (!equal(prevProps.messages, nextProps.messages.length)) return false; if (prevProps.selectedVisibilityType !== nextProps.selectedVisibilityType) return false; return true; });