Spaces:
Sleeping
Sleeping
| 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<SetStateAction<string>>; | |
| status: UseChatHelpers<ChatMessage>['status']; | |
| stop: UseChatHelpers<ChatMessage>['stop']; | |
| attachments: Attachment[]; | |
| setAttachments: Dispatch<SetStateAction<Attachment[]>>; | |
| messages: ChatMessage[]; | |
| setMessages: UseChatHelpers<ChatMessage>['setMessages']; | |
| votes: Array<Vote> | undefined; | |
| sendMessage: UseChatHelpers<ChatMessage>['sendMessage']; | |
| regenerate: UseChatHelpers<ChatMessage>['regenerate']; | |
| isReadonly: boolean; | |
| selectedVisibilityType: VisibilityType; | |
| }) { | |
| const { artifact, setArtifact, metadata, setMetadata } = useArtifact(); | |
| const { | |
| data: documents, | |
| isLoading: isDocumentsFetching, | |
| mutate: mutateDocuments, | |
| } = useSWR<Array<Document>>( | |
| artifact.documentId !== 'init' && artifact.status !== 'streaming' | |
| ? `/api/document?id=${artifact.documentId}` | |
| : null, | |
| fetcher, | |
| ); | |
| const [mode, setMode] = useState<'edit' | 'diff'>('edit'); | |
| const [document, setDocument] = useState<Document | null>(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<Array<Document>>( | |
| `/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 ( | |
| <AnimatePresence> | |
| {artifact.isVisible && ( | |
| <motion.div | |
| data-testid="artifact" | |
| className="flex flex-row h-dvh w-dvw fixed top-0 left-0 z-50 bg-transparent" | |
| initial={{ opacity: 1 }} | |
| animate={{ opacity: 1 }} | |
| exit={{ opacity: 0, transition: { delay: 0.4 } }} | |
| > | |
| {!isMobile && ( | |
| <motion.div | |
| className="fixed bg-background h-dvh" | |
| initial={{ | |
| width: isSidebarOpen ? windowWidth - 256 : windowWidth, | |
| right: 0, | |
| }} | |
| animate={{ width: windowWidth, right: 0 }} | |
| exit={{ | |
| width: isSidebarOpen ? windowWidth - 256 : windowWidth, | |
| right: 0, | |
| }} | |
| /> | |
| )} | |
| {!isMobile && ( | |
| <motion.div | |
| className="relative w-[400px] bg-muted dark:bg-background h-dvh shrink-0" | |
| initial={{ opacity: 0, x: 10, scale: 1 }} | |
| animate={{ | |
| opacity: 1, | |
| x: 0, | |
| scale: 1, | |
| transition: { | |
| delay: 0.2, | |
| type: 'spring', | |
| stiffness: 200, | |
| damping: 30, | |
| }, | |
| }} | |
| exit={{ | |
| opacity: 0, | |
| x: 0, | |
| scale: 1, | |
| transition: { duration: 0 }, | |
| }} | |
| > | |
| <AnimatePresence> | |
| {!isCurrentVersion && ( | |
| <motion.div | |
| className="left-0 absolute h-dvh w-[400px] top-0 bg-zinc-900/50 z-50" | |
| initial={{ opacity: 0 }} | |
| animate={{ opacity: 1 }} | |
| exit={{ opacity: 0 }} | |
| /> | |
| )} | |
| </AnimatePresence> | |
| <div className="flex flex-col h-full justify-between items-center"> | |
| <ArtifactMessages | |
| chatId={chatId} | |
| status={status} | |
| votes={votes} | |
| messages={messages} | |
| setMessages={setMessages} | |
| regenerate={regenerate} | |
| isReadonly={isReadonly} | |
| artifactStatus={artifact.status} | |
| /> | |
| <form className="flex flex-row gap-2 relative items-end w-full px-4 pb-4"> | |
| <MultimodalInput | |
| chatId={chatId} | |
| input={input} | |
| setInput={setInput} | |
| status={status} | |
| stop={stop} | |
| attachments={attachments} | |
| setAttachments={setAttachments} | |
| messages={messages} | |
| sendMessage={sendMessage} | |
| className="bg-background dark:bg-muted" | |
| setMessages={setMessages} | |
| selectedVisibilityType={selectedVisibilityType} | |
| /> | |
| </form> | |
| </div> | |
| </motion.div> | |
| )} | |
| <motion.div | |
| className="fixed dark:bg-muted bg-background h-dvh flex flex-col overflow-y-scroll md:border-l dark:border-zinc-700 border-zinc-200" | |
| initial={ | |
| isMobile | |
| ? { | |
| opacity: 1, | |
| x: artifact.boundingBox.left, | |
| y: artifact.boundingBox.top, | |
| height: artifact.boundingBox.height, | |
| width: artifact.boundingBox.width, | |
| borderRadius: 50, | |
| } | |
| : { | |
| opacity: 1, | |
| x: artifact.boundingBox.left, | |
| y: artifact.boundingBox.top, | |
| height: artifact.boundingBox.height, | |
| width: artifact.boundingBox.width, | |
| borderRadius: 50, | |
| } | |
| } | |
| animate={ | |
| isMobile | |
| ? { | |
| opacity: 1, | |
| x: 0, | |
| y: 0, | |
| height: windowHeight, | |
| width: windowWidth ? windowWidth : 'calc(100dvw)', | |
| borderRadius: 0, | |
| transition: { | |
| delay: 0, | |
| type: 'spring', | |
| stiffness: 200, | |
| damping: 30, | |
| duration: 5000, | |
| }, | |
| } | |
| : { | |
| opacity: 1, | |
| x: 400, | |
| y: 0, | |
| height: windowHeight, | |
| width: windowWidth | |
| ? windowWidth - 400 | |
| : 'calc(100dvw-400px)', | |
| borderRadius: 0, | |
| transition: { | |
| delay: 0, | |
| type: 'spring', | |
| stiffness: 200, | |
| damping: 30, | |
| duration: 5000, | |
| }, | |
| } | |
| } | |
| exit={{ | |
| opacity: 0, | |
| scale: 0.5, | |
| transition: { | |
| delay: 0.1, | |
| type: 'spring', | |
| stiffness: 600, | |
| damping: 30, | |
| }, | |
| }} | |
| > | |
| <div className="p-2 flex flex-row justify-between items-start"> | |
| <div className="flex flex-row gap-4 items-start"> | |
| <ArtifactCloseButton /> | |
| <div className="flex flex-col"> | |
| <div className="font-medium">{artifact.title}</div> | |
| {isContentDirty ? ( | |
| <div className="text-sm text-muted-foreground"> | |
| Saving changes... | |
| </div> | |
| ) : document ? ( | |
| <div className="text-sm text-muted-foreground"> | |
| {`Updated ${formatDistance( | |
| new Date(document.createdAt), | |
| new Date(), | |
| { | |
| addSuffix: true, | |
| }, | |
| )}`} | |
| </div> | |
| ) : ( | |
| <div className="w-32 h-3 mt-2 bg-muted-foreground/20 rounded-md animate-pulse" /> | |
| )} | |
| </div> | |
| </div> | |
| <ArtifactActions | |
| artifact={artifact} | |
| currentVersionIndex={currentVersionIndex} | |
| handleVersionChange={handleVersionChange} | |
| isCurrentVersion={isCurrentVersion} | |
| mode={mode} | |
| metadata={metadata} | |
| setMetadata={setMetadata} | |
| /> | |
| </div> | |
| <div className="dark:bg-muted bg-background h-full overflow-y-scroll !max-w-full items-center"> | |
| <artifactDefinition.content | |
| title={artifact.title} | |
| content={ | |
| isCurrentVersion | |
| ? artifact.content | |
| : getDocumentContentById(currentVersionIndex) | |
| } | |
| mode={mode} | |
| status={artifact.status} | |
| currentVersionIndex={currentVersionIndex} | |
| suggestions={[]} | |
| onSaveContent={saveContent} | |
| isInline={false} | |
| isCurrentVersion={isCurrentVersion} | |
| getDocumentContentById={getDocumentContentById} | |
| isLoading={isDocumentsFetching && !artifact.content} | |
| metadata={metadata} | |
| setMetadata={setMetadata} | |
| /> | |
| <AnimatePresence> | |
| {isCurrentVersion && ( | |
| <Toolbar | |
| isToolbarVisible={isToolbarVisible} | |
| setIsToolbarVisible={setIsToolbarVisible} | |
| sendMessage={sendMessage} | |
| status={status} | |
| stop={stop} | |
| setMessages={setMessages} | |
| artifactKind={artifact.kind} | |
| /> | |
| )} | |
| </AnimatePresence> | |
| </div> | |
| <AnimatePresence> | |
| {!isCurrentVersion && ( | |
| <VersionFooter | |
| currentVersionIndex={currentVersionIndex} | |
| documents={documents} | |
| handleVersionChange={handleVersionChange} | |
| /> | |
| )} | |
| </AnimatePresence> | |
| </motion.div> | |
| </motion.div> | |
| )} | |
| </AnimatePresence> | |
| ); | |
| } | |
| 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; | |
| }); | |