feat: add inline block document previews (#637)

This commit is contained in:
Jeremy 2024-12-19 17:05:04 +05:30 committed by GitHub
parent b659dcce68
commit bb03c516b5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 1122 additions and 805 deletions

View file

@ -3,6 +3,7 @@ import { cookies } from 'next/headers';
import { Chat } from '@/components/chat';
import { DEFAULT_MODEL_NAME, models } from '@/lib/ai/models';
import { generateUUID } from '@/lib/utils';
import { DataStreamHandler } from '@/components/data-stream-handler';
export default async function Page() {
const id = generateUUID();
@ -15,6 +16,7 @@ export default async function Page() {
DEFAULT_MODEL_NAME;
return (
<>
<Chat
key={id}
id={id}
@ -23,5 +25,7 @@ export default async function Page() {
selectedVisibilityType="private"
isReadonly={false}
/>
<DataStreamHandler id={id} />
</>
);
}

View file

@ -45,7 +45,10 @@
"noSvgWithoutTitle": "off", // We do not intend to adhere to this rule
"useMediaCaption": "off", // We would need a cultural change to turn this on
"noAutofocus": "off", // We're highly intentional about when we use autofocus
"noBlankTarget": "off" // Covered by Conformance
"noBlankTarget": "off", // Covered by Conformance
"useFocusableInteractive": "off", // Disable focusable interactive element requirement
"useAriaPropsForRole": "off", // Disable required ARIA attributes check
"useKeyWithClickEvents": "off" // Disable keyboard event requirement with click events
},
"complexity": {
"noUselessStringConcat": "warn", // Not in recommended ruleset, turning on manually

View file

@ -1,23 +1,24 @@
import { memo, SetStateAction } from 'react';
import { memo } from 'react';
import { CrossIcon } from './icons';
import { Button } from './ui/button';
import { UIBlock } from './block';
import equal from 'fast-deep-equal';
import { initialBlockData, useBlock } from '@/hooks/use-block';
interface BlockCloseButtonProps {
setBlock: (value: SetStateAction<UIBlock>) => void;
}
function PureBlockCloseButton() {
const { setBlock } = useBlock();
function PureBlockCloseButton({ setBlock }: BlockCloseButtonProps) {
return (
<Button
variant="outline"
className="h-fit p-2 dark:hover:bg-zinc-700"
onClick={() => {
setBlock((currentBlock) => ({
setBlock((currentBlock) =>
currentBlock.status === 'streaming'
? {
...currentBlock,
isVisible: false,
}));
}
: { ...initialBlockData, status: 'idle' },
);
}}
>
<CrossIcon size={18} />

View file

@ -1,14 +1,13 @@
import { Dispatch, memo, SetStateAction } from 'react';
import { UIBlock } from './block';
import { PreviewMessage } from './message';
import { useScrollToBottom } from './use-scroll-to-bottom';
import { Vote } from '@/lib/db/schema';
import { ChatRequestOptions, Message } from 'ai';
import { memo } from 'react';
import equal from 'fast-deep-equal';
import { UIBlock } from './block';
interface BlockMessagesProps {
chatId: string;
block: UIBlock;
setBlock: Dispatch<SetStateAction<UIBlock>>;
isLoading: boolean;
votes: Array<Vote> | undefined;
messages: Array<Message>;
@ -19,12 +18,11 @@ interface BlockMessagesProps {
chatRequestOptions?: ChatRequestOptions,
) => Promise<string | null | undefined>;
isReadonly: boolean;
blockStatus: UIBlock['status'];
}
function PureBlockMessages({
chatId,
block,
setBlock,
isLoading,
votes,
messages,
@ -45,8 +43,6 @@ function PureBlockMessages({
chatId={chatId}
key={message.id}
message={message}
block={block}
setBlock={setBlock}
isLoading={isLoading && index === messages.length - 1}
vote={
votes
@ -72,13 +68,17 @@ function areEqual(
nextProps: BlockMessagesProps,
) {
if (
prevProps.block.status === 'streaming' &&
nextProps.block.status === 'streaming'
) {
prevProps.blockStatus === 'streaming' &&
nextProps.blockStatus === 'streaming'
)
return true;
if (prevProps.isLoading !== nextProps.isLoading) return false;
if (prevProps.isLoading && nextProps.isLoading) return false;
if (prevProps.messages.length !== nextProps.messages.length) return false;
if (!equal(prevProps.votes, nextProps.votes)) return false;
return true;
}
return false;
}
export const BlockMessages = memo(PureBlockMessages, areEqual);

View file

@ -1,43 +0,0 @@
import type { JSONValue } from 'ai';
import { type Dispatch, memo, type SetStateAction } from 'react';
import type { UIBlock } from './block';
import { useBlockStream } from './use-block-stream';
interface BlockStreamHandlerProps {
setBlock: Dispatch<SetStateAction<UIBlock>>;
streamingData: JSONValue[] | undefined;
}
function PureBlockStreamHandler({
setBlock,
streamingData,
}: BlockStreamHandlerProps) {
useBlockStream({
streamingData,
setBlock,
});
return null;
}
function areEqual(
prevProps: BlockStreamHandlerProps,
nextProps: BlockStreamHandlerProps,
) {
if (!prevProps.streamingData && !nextProps.streamingData) {
return true;
}
if (!prevProps.streamingData || !nextProps.streamingData) {
return false;
}
if (prevProps.streamingData.length !== nextProps.streamingData.length) {
return false;
}
return true;
}
export const BlockStreamHandler = memo(PureBlockStreamHandler, areEqual);

View file

@ -31,6 +31,9 @@ import { BlockCloseButton } from './block-close-button';
import { BlockMessages } from './block-messages';
import { CodeEditor } from './code-editor';
import { Console } from './console';
import { useSidebar } from './ui/sidebar';
import { useBlock } from '@/hooks/use-block';
import equal from 'fast-deep-equal';
export type BlockKind = 'text' | 'code';
@ -65,8 +68,6 @@ function PureBlock({
attachments,
setAttachments,
append,
block,
setBlock,
messages,
setMessages,
reload,
@ -80,8 +81,6 @@ function PureBlock({
stop: () => void;
attachments: Array<Attachment>;
setAttachments: Dispatch<SetStateAction<Array<Attachment>>>;
block: UIBlock;
setBlock: Dispatch<SetStateAction<UIBlock>>;
messages: Array<Message>;
setMessages: Dispatch<SetStateAction<Array<Message>>>;
votes: Array<Vote> | undefined;
@ -100,12 +99,14 @@ function PureBlock({
) => Promise<string | null | undefined>;
isReadonly: boolean;
}) {
const { block, setBlock } = useBlock();
const {
data: documents,
isLoading: isDocumentsFetching,
mutate: mutateDocuments,
} = useSWR<Array<Document>>(
block && block.status !== 'streaming'
block.documentId !== 'init' && block.status !== 'streaming'
? `/api/document?id=${block.documentId}`
: null,
fetcher,
@ -128,6 +129,8 @@ function PureBlock({
[],
);
const { open: isSidebarOpen } = useSidebar();
useEffect(() => {
if (documents && documents.length > 0) {
const mostRecentDocument = documents.at(-1);
@ -260,12 +263,29 @@ function PureBlock({
const isMobile = windowWidth ? windowWidth < 768 : false;
return (
<AnimatePresence>
{block.isVisible && (
<motion.div
className="flex flex-row h-dvh w-dvw fixed top-0 left-0 z-50 bg-muted"
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"
@ -284,8 +304,8 @@ function PureBlock({
exit={{
opacity: 0,
x: 0,
scale: 0.95,
transition: { delay: 0 },
scale: 1,
transition: { duration: 0 },
}}
>
<AnimatePresence>
@ -302,14 +322,13 @@ function PureBlock({
<div className="flex flex-col h-full justify-between items-center gap-4">
<BlockMessages
chatId={chatId}
block={block}
isLoading={isLoading}
setBlock={setBlock}
votes={votes}
messages={messages}
setMessages={setMessages}
reload={reload}
isReadonly={isReadonly}
blockStatus={block.status}
/>
<form className="flex flex-row gap-2 relative items-end w-full px-4 pb-4">
@ -333,19 +352,19 @@ function PureBlock({
)}
<motion.div
className="fixed dark:bg-muted bg-background h-dvh flex flex-col overflow-y-scroll"
className="fixed dark:bg-muted bg-background h-dvh flex flex-col overflow-y-scroll border-l dark:border-zinc-700 border-zinc-200"
initial={
isMobile
? {
opacity: 0,
x: 0,
y: 0,
width: windowWidth,
height: windowHeight,
opacity: 1,
x: block.boundingBox.left,
y: block.boundingBox.top,
height: block.boundingBox.height,
width: block.boundingBox.width,
borderRadius: 50,
}
: {
opacity: 0,
opacity: 1,
x: block.boundingBox.left,
y: block.boundingBox.top,
height: block.boundingBox.height,
@ -359,14 +378,15 @@ function PureBlock({
opacity: 1,
x: 0,
y: 0,
width: windowWidth,
height: '100dvh',
height: windowHeight,
width: windowWidth ? windowWidth : 'calc(100dvw)',
borderRadius: 0,
transition: {
delay: 0,
type: 'spring',
stiffness: 200,
damping: 30,
duration: 5000,
},
}
: {
@ -374,13 +394,16 @@ function PureBlock({
x: 400,
y: 0,
height: windowHeight,
width: windowWidth ? windowWidth - 400 : 'calc(100dvw-400px)',
width: windowWidth
? windowWidth - 400
: 'calc(100dvw-400px)',
borderRadius: 0,
transition: {
delay: 0,
type: 'spring',
stiffness: 200,
damping: 30,
duration: 5000,
},
}
}
@ -397,7 +420,7 @@ function PureBlock({
>
<div className="p-2 flex flex-row justify-between items-start">
<div className="flex flex-row gap-4 items-start">
<BlockCloseButton setBlock={setBlock} />
<BlockCloseButton />
<div className="flex flex-col">
<div className="font-medium">
@ -436,7 +459,7 @@ function PureBlock({
<div
className={cn(
'prose dark:prose-invert dark:bg-muted bg-background h-full overflow-y-scroll !max-w-full pb-40 items-center',
'dark:bg-muted bg-background h-full overflow-y-scroll !max-w-full pb-40 items-center',
{
'py-2 px-2': block.kind === 'code',
'py-8 md:p-20 px-4': block.kind === 'text',
@ -480,7 +503,9 @@ function PureBlock({
/>
) : (
<DiffView
oldContent={getDocumentContentById(currentVersionIndex - 1)}
oldContent={getDocumentContentById(
currentVersionIndex - 1,
)}
newContent={getDocumentContentById(currentVersionIndex)}
/>
)
@ -509,7 +534,6 @@ function PureBlock({
<AnimatePresence>
{!isCurrentVersion && (
<VersionFooter
block={block}
currentVersionIndex={currentVersionIndex}
documents={documents}
handleVersionChange={handleVersionChange}
@ -525,9 +549,16 @@ function PureBlock({
</AnimatePresence>
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
}
export const Block = memo(PureBlock, (prevProps, nextProps) => {
return false;
if (prevProps.isLoading !== nextProps.isLoading) 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;
return true;
});

View file

@ -2,20 +2,18 @@
import type { Attachment, Message } from 'ai';
import { useChat } from 'ai/react';
import { AnimatePresence } from 'framer-motion';
import { useState } from 'react';
import useSWR, { useSWRConfig } from 'swr';
import { useWindowSize } from 'usehooks-ts';
import { ChatHeader } from '@/components/chat-header';
import type { Vote } from '@/lib/db/schema';
import { fetcher } from '@/lib/utils';
import { Block, type UIBlock } from './block';
import { BlockStreamHandler } from './block-stream-handler';
import { Block } from './block';
import { MultimodalInput } from './multimodal-input';
import { Messages } from './messages';
import { VisibilityType } from './visibility-selector';
import { useBlockSelector } from '@/hooks/use-block';
export function Chat({
id,
@ -42,40 +40,23 @@ export function Chat({
isLoading,
stop,
reload,
data: streamingData,
} = useChat({
id,
body: { id, modelId: selectedModelId },
initialMessages,
experimental_throttle: 100,
onFinish: () => {
mutate('/api/history');
},
});
const { width: windowWidth = 1920, height: windowHeight = 1080 } =
useWindowSize();
const [block, setBlock] = useState<UIBlock>({
documentId: 'init',
content: '',
kind: 'text',
title: '',
status: 'idle',
isVisible: false,
boundingBox: {
top: windowHeight / 4,
left: windowWidth / 4,
width: 250,
height: 50,
},
});
const { data: votes } = useSWR<Array<Vote>>(
`/api/vote?chatId=${id}`,
fetcher,
);
const [attachments, setAttachments] = useState<Array<Attachment>>([]);
const isBlockVisible = useBlockSelector((state) => state.isVisible);
return (
<>
@ -89,14 +70,13 @@ export function Chat({
<Messages
chatId={id}
block={block}
setBlock={setBlock}
isLoading={isLoading}
votes={votes}
messages={messages}
setMessages={setMessages}
reload={reload}
isReadonly={isReadonly}
isBlockVisible={isBlockVisible}
/>
<form className="flex mx-auto px-4 bg-background pb-4 md:pb-6 gap-2 w-full md:max-w-3xl">
@ -118,8 +98,6 @@ export function Chat({
</form>
</div>
<AnimatePresence>
{block?.isVisible && (
<Block
chatId={id}
input={input}
@ -130,18 +108,12 @@ export function Chat({
attachments={attachments}
setAttachments={setAttachments}
append={append}
block={block}
setBlock={setBlock}
messages={messages}
setMessages={setMessages}
reload={reload}
votes={votes}
isReadonly={isReadonly}
/>
)}
</AnimatePresence>
<BlockStreamHandler streamingData={streamingData} setBlock={setBlock} />
</>
);
}

View file

@ -40,6 +40,8 @@ function PureCodeEditor({ content, saveContent, status }: EditorProps) {
editorRef.current = null;
}
};
// NOTE: we only want to run this effect once
// eslint-disable-next-line
}, []);
useEffect(() => {

View file

@ -63,6 +63,8 @@ export function Console({ consoleOutputs, setConsoleOutputs }: ConsoleProps) {
className="h-2 w-full fixed cursor-ns-resize z-50"
onMouseDown={startResizing}
style={{ bottom: height - 4 }}
role="slider"
aria-valuenow={minHeight}
/>
<div

View file

@ -0,0 +1,117 @@
'use client';
import { useChat } from 'ai/react';
import { useEffect, useRef } from 'react';
import { BlockKind } from './block';
import { Suggestion } from '@/lib/db/schema';
import { initialBlockData, useBlock } from '@/hooks/use-block';
import { useUserMessageId } from '@/hooks/use-user-message-id';
import { cx } from 'class-variance-authority';
type DataStreamDelta = {
type:
| 'text-delta'
| 'code-delta'
| 'title'
| 'id'
| 'suggestion'
| 'clear'
| 'finish'
| 'user-message-id'
| 'kind';
content: string | Suggestion;
};
export function DataStreamHandler({ id }: { id: string }) {
const { data: dataStream } = useChat({ id });
const { setUserMessageIdFromServer } = useUserMessageId();
const { setBlock } = useBlock();
const lastProcessedIndex = useRef(-1);
useEffect(() => {
if (!dataStream?.length) return;
const newDeltas = dataStream.slice(lastProcessedIndex.current + 1);
lastProcessedIndex.current = dataStream.length - 1;
(newDeltas as DataStreamDelta[]).forEach((delta: DataStreamDelta) => {
if (delta.type === 'user-message-id') {
setUserMessageIdFromServer(delta.content as string);
return;
}
setBlock((draftBlock) => {
if (!draftBlock) {
return { ...initialBlockData, status: 'streaming' };
}
switch (delta.type) {
case 'id':
return {
...draftBlock,
documentId: delta.content as string,
status: 'streaming',
};
case 'title':
return {
...draftBlock,
title: delta.content as string,
status: 'streaming',
};
case 'kind':
return {
...draftBlock,
kind: delta.content as BlockKind,
status: 'streaming',
};
case 'text-delta':
return {
...draftBlock,
content: draftBlock.content + (delta.content as string),
isVisible:
draftBlock.status === 'streaming' &&
draftBlock.content.length > 400 &&
draftBlock.content.length < 450
? true
: draftBlock.isVisible,
status: 'streaming',
};
case 'code-delta':
return {
...draftBlock,
content: delta.content as string,
isVisible:
draftBlock.status === 'streaming' &&
draftBlock.content.length > 300 &&
draftBlock.content.length < 310
? true
: draftBlock.isVisible,
status: 'streaming',
};
case 'clear':
return {
...draftBlock,
content: '',
status: 'streaming',
};
case 'finish':
return {
...draftBlock,
status: 'idle',
};
default:
return draftBlock;
}
});
});
}, [dataStream, setBlock, setUserMessageIdFromServer]);
return null;
}

View file

@ -0,0 +1,245 @@
'use client';
import {
memo,
MouseEvent,
useCallback,
useEffect,
useMemo,
useRef,
} from 'react';
import { UIBlock } from './block';
import { FileIcon, FullscreenIcon, LoaderIcon } from './icons';
import { cn, fetcher } from '@/lib/utils';
import { Document } from '@/lib/db/schema';
import { InlineDocumentSkeleton } from './document-skeleton';
import useSWR from 'swr';
import { Editor } from './editor';
import { DocumentToolCall, DocumentToolResult } from './document';
import { CodeEditor } from './code-editor';
import { useBlock } from '@/hooks/use-block';
import equal from 'fast-deep-equal';
interface DocumentPreviewProps {
isReadonly: boolean;
result?: any;
args?: any;
}
export function DocumentPreview({
isReadonly,
result,
args,
}: DocumentPreviewProps) {
const { block, setBlock } = useBlock();
const { data: documents, isLoading: isDocumentsFetching } = useSWR<
Array<Document>
>(result ? `/api/document?id=${result.id}` : null, fetcher);
const previewDocument = useMemo(() => documents?.[0], [documents]);
const hitboxRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const boundingBox = hitboxRef.current?.getBoundingClientRect();
if (block.documentId && boundingBox) {
setBlock((block) => ({
...block,
boundingBox: {
left: boundingBox.x,
top: boundingBox.y,
width: boundingBox.width,
height: boundingBox.height,
},
}));
}
}, [block.documentId, setBlock]);
if (block.isVisible) {
if (result) {
return (
<DocumentToolResult
type="create"
result={{ id: result.id, title: result.title, kind: result.kind }}
isReadonly={isReadonly}
/>
);
}
if (args) {
return (
<DocumentToolCall
type="create"
args={{ title: args.title }}
isReadonly={isReadonly}
/>
);
}
}
if (isDocumentsFetching) {
return <LoadingSkeleton />;
}
const document: Document | null = previewDocument
? previewDocument
: block.status === 'streaming'
? {
title: block.title,
kind: block.kind,
content: block.content,
id: block.documentId,
createdAt: new Date(),
userId: 'noop',
}
: null;
if (!document) return <LoadingSkeleton />;
return (
<div className="relative w-full cursor-pointer">
<HitboxLayer hitboxRef={hitboxRef} result={result} setBlock={setBlock} />
<DocumentHeader
title={document.title}
isStreaming={block.status === 'streaming'}
/>
<DocumentContent document={document} />
</div>
);
}
const LoadingSkeleton = () => (
<div className="w-full">
<div className="p-4 border rounded-t-2xl flex flex-row gap-2 items-center justify-between dark:bg-muted h-[57px] dark:border-zinc-700 border-b-0">
<div className="flex flex-row items-center gap-3">
<div className="text-muted-foreground">
<div className="animate-pulse rounded-md size-4 bg-muted-foreground/20" />
</div>
<div className="animate-pulse rounded-lg h-4 bg-muted-foreground/20 w-24" />
</div>
<div>
<FullscreenIcon />
</div>
</div>
<div className="overflow-y-scroll border rounded-b-2xl p-8 pt-4 bg-muted border-t-0 dark:border-zinc-700">
<InlineDocumentSkeleton />
</div>
</div>
);
const PureHitboxLayer = ({
hitboxRef,
result,
setBlock,
}: {
hitboxRef: React.RefObject<HTMLDivElement>;
result: any;
setBlock: (updaterFn: UIBlock | ((currentBlock: UIBlock) => UIBlock)) => void;
}) => {
const handleClick = useCallback(
(event: MouseEvent<HTMLElement>) => {
const boundingBox = event.currentTarget.getBoundingClientRect();
setBlock((block) =>
block.status === 'streaming'
? { ...block, isVisible: true }
: {
...block,
documentId: result.id,
kind: result.kind,
isVisible: true,
boundingBox: {
left: boundingBox.x,
top: boundingBox.y,
width: boundingBox.width,
height: boundingBox.height,
},
},
);
},
[setBlock, result],
);
return (
<div
className="size-full absolute top-0 left-0 rounded-xl z-10"
ref={hitboxRef}
onClick={handleClick}
role="presentation"
aria-hidden="true"
/>
);
};
const HitboxLayer = memo(PureHitboxLayer, (prevProps, nextProps) => {
if (!equal(prevProps.result, nextProps.result)) return false;
return true;
});
const PureDocumentHeader = ({
title,
isStreaming,
}: {
title: string;
isStreaming: boolean;
}) => (
<div className="p-4 border rounded-t-2xl flex flex-row gap-2 items-start sm:items-center justify-between dark:bg-muted border-b-0 dark:border-zinc-700">
<div className="flex flex-row items-start sm:items-center gap-3">
<div className="text-muted-foreground">
{isStreaming ? (
<div className="animate-spin">
<LoaderIcon />
</div>
) : (
<FileIcon />
)}
</div>
<div className="-translate-y-1 sm:translate-y-0 font-medium">{title}</div>
</div>
<div>
<FullscreenIcon />
</div>
</div>
);
const DocumentHeader = memo(PureDocumentHeader, (prevProps, nextProps) => {
if (prevProps.title !== nextProps.title) return false;
if (prevProps.isStreaming !== nextProps.isStreaming) return false;
return true;
});
const DocumentContent = ({ document }: { document: Document }) => {
const { block } = useBlock();
const containerClassName = cn(
'h-[257px] overflow-y-scroll border rounded-b-2xl dark:bg-muted border-t-0 dark:border-zinc-700',
{
'p-4 sm:px-14 sm:py-16': document.kind === 'text',
'p-0': document.kind === 'code',
},
);
const commonProps = {
content: document.content ?? '',
isCurrentVersion: true,
currentVersionIndex: 0,
status: block.status,
saveContent: () => {},
suggestions: [],
};
return (
<div className={containerClassName}>
{document.kind === 'text' ? (
<Editor {...commonProps} />
) : document.kind === 'code' ? (
<div className="flex flex-1 relative w-full">
<div className="absolute inset-0">
<CodeEditor {...commonProps} />
</div>
</div>
) : null}
</div>
);
};

View file

@ -13,3 +13,17 @@ export const DocumentSkeleton = () => {
</div>
);
};
export const InlineDocumentSkeleton = () => {
return (
<div className="flex flex-col gap-4 w-full">
<div className="animate-pulse rounded-lg h-4 bg-muted-foreground/20 w-48" />
<div className="animate-pulse rounded-lg h-4 bg-muted-foreground/20 w-3/4" />
<div className="animate-pulse rounded-lg h-4 bg-muted-foreground/20 w-1/2" />
<div className="animate-pulse rounded-lg h-4 bg-muted-foreground/20 w-64" />
<div className="animate-pulse rounded-lg h-4 bg-muted-foreground/20 w-40" />
<div className="animate-pulse rounded-lg h-4 bg-muted-foreground/20 w-36" />
<div className="animate-pulse rounded-lg h-4 bg-muted-foreground/20 w-64" />
</div>
);
};

View file

@ -1,8 +1,9 @@
import { memo, type SetStateAction } from 'react';
import { memo } from 'react';
import type { BlockKind, UIBlock } from './block';
import type { BlockKind } from './block';
import { FileIcon, LoaderIcon, MessageIcon, PencilEditIcon } from './icons';
import { toast } from 'sonner';
import { useBlock } from '@/hooks/use-block';
const getActionText = (
type: 'create' | 'update' | 'request-suggestions',
@ -25,17 +26,16 @@ const getActionText = (
interface DocumentToolResultProps {
type: 'create' | 'update' | 'request-suggestions';
result: { id: string; title: string; kind: BlockKind };
block: UIBlock;
setBlock: (value: SetStateAction<UIBlock>) => void;
isReadonly: boolean;
}
function PureDocumentToolResult({
type,
result,
setBlock,
isReadonly,
}: DocumentToolResultProps) {
const { setBlock } = useBlock();
return (
<button
type="button"
@ -89,16 +89,16 @@ export const DocumentToolResult = memo(PureDocumentToolResult, () => true);
interface DocumentToolCallProps {
type: 'create' | 'update' | 'request-suggestions';
args: { title: string };
setBlock: (value: SetStateAction<UIBlock>) => void;
isReadonly: boolean;
}
function PureDocumentToolCall({
type,
args,
setBlock,
isReadonly,
}: DocumentToolCallProps) {
const { setBlock } = useBlock();
return (
<button
type="button"

View file

@ -1102,3 +1102,20 @@ export const ImageIcon = ({ size = 16 }: { size?: number }) => {
</svg>
);
};
export const FullscreenIcon = ({ size = 16 }: { size?: number }) => (
<svg
height={size}
strokeLinejoin="round"
viewBox="0 0 16 16"
width={size}
style={{ color: 'currentcolor' }}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M1 5.25V6H2.5V5.25V2.5H5.25H6V1H5.25H2C1.44772 1 1 1.44772 1 2V5.25ZM5.25 14.9994H6V13.4994H5.25H2.5V10.7494V9.99939H1V10.7494V13.9994C1 14.5517 1.44772 14.9994 2 14.9994H5.25ZM15 10V10.75V14C15 14.5523 14.5523 15 14 15H10.75H10V13.5H10.75H13.5V10.75V10H15ZM10.75 1H10V2.5H10.75H13.5V5.25V6H15V5.25V2C15 1.44772 14.5523 1 14 1H10.75Z"
fill="currentColor"
></path>
</svg>
);

View file

@ -1,10 +1,9 @@
import Link from 'next/link';
import React, { memo } from 'react';
import React, { memo, useMemo, useState } from 'react';
import ReactMarkdown, { type Components } from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { CodeBlock } from './code-block';
const NonMemoizedMarkdown = ({ children }: { children: string }) => {
const components: Partial<Components> = {
// @ts-expect-error
code: CodeBlock,
@ -94,8 +93,11 @@ const NonMemoizedMarkdown = ({ children }: { children: string }) => {
},
};
const remarkPlugins = [remarkGfm];
const NonMemoizedMarkdown = ({ children }: { children: string }) => {
return (
<ReactMarkdown remarkPlugins={[remarkGfm]} components={components}>
<ReactMarkdown remarkPlugins={remarkPlugins} components={components}>
{children}
</ReactMarkdown>
);

View file

@ -2,12 +2,11 @@
import type { ChatRequestOptions, Message } from 'ai';
import cx from 'classnames';
import { motion } from 'framer-motion';
import { memo, useState, type Dispatch, type SetStateAction } from 'react';
import { AnimatePresence, motion } from 'framer-motion';
import { memo, useMemo, useState } from 'react';
import type { Vote } from '@/lib/db/schema';
import type { UIBlock } from './block';
import { DocumentToolCall, DocumentToolResult } from './document';
import { PencilEditIcon, SparklesIcon } from './icons';
import { Markdown } from './markdown';
@ -19,12 +18,11 @@ import { cn } from '@/lib/utils';
import { Button } from './ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
import { MessageEditor } from './message-editor';
import { DocumentPreview } from './document-preview';
const PurePreviewMessage = ({
chatId,
message,
block,
setBlock,
vote,
isLoading,
setMessages,
@ -33,8 +31,6 @@ const PurePreviewMessage = ({
}: {
chatId: string;
message: Message;
block: UIBlock;
setBlock: Dispatch<SetStateAction<UIBlock>>;
vote: Vote | undefined;
isLoading: boolean;
setMessages: (
@ -48,6 +44,7 @@ const PurePreviewMessage = ({
const [mode, setMode] = useState<'view' | 'edit'>('view');
return (
<AnimatePresence>
<motion.div
className="w-full mx-auto max-w-3xl px-4 group/message"
initial={{ y: 5, opacity: 0 }}
@ -138,27 +135,20 @@ const PurePreviewMessage = ({
{toolName === 'getWeather' ? (
<Weather weatherAtLocation={result} />
) : toolName === 'createDocument' ? (
<DocumentToolResult
type="create"
result={result}
block={block}
setBlock={setBlock}
<DocumentPreview
isReadonly={isReadonly}
result={result}
/>
) : toolName === 'updateDocument' ? (
<DocumentToolResult
type="update"
result={result}
block={block}
setBlock={setBlock}
isReadonly={isReadonly}
/>
) : toolName === 'requestSuggestions' ? (
<DocumentToolResult
type="request-suggestions"
result={result}
block={block}
setBlock={setBlock}
isReadonly={isReadonly}
/>
) : (
@ -177,24 +167,17 @@ const PurePreviewMessage = ({
{toolName === 'getWeather' ? (
<Weather />
) : toolName === 'createDocument' ? (
<DocumentToolCall
type="create"
args={args}
setBlock={setBlock}
isReadonly={isReadonly}
/>
<DocumentPreview isReadonly={isReadonly} args={args} />
) : toolName === 'updateDocument' ? (
<DocumentToolCall
type="update"
args={args}
setBlock={setBlock}
isReadonly={isReadonly}
/>
) : toolName === 'requestSuggestions' ? (
<DocumentToolCall
type="request-suggestions"
args={args}
setBlock={setBlock}
isReadonly={isReadonly}
/>
) : null}
@ -216,6 +199,7 @@ const PurePreviewMessage = ({
</div>
</div>
</motion.div>
</AnimatePresence>
);
};

View file

@ -2,15 +2,12 @@ import { ChatRequestOptions, Message } from 'ai';
import { PreviewMessage, ThinkingMessage } from './message';
import { useScrollToBottom } from './use-scroll-to-bottom';
import { Overview } from './overview';
import { UIBlock } from './block';
import { Dispatch, memo, SetStateAction } from 'react';
import { memo } from 'react';
import { Vote } from '@/lib/db/schema';
import equal from 'fast-deep-equal';
interface MessagesProps {
chatId: string;
block: UIBlock;
setBlock: Dispatch<SetStateAction<UIBlock>>;
isLoading: boolean;
votes: Array<Vote> | undefined;
messages: Array<Message>;
@ -21,12 +18,11 @@ interface MessagesProps {
chatRequestOptions?: ChatRequestOptions,
) => Promise<string | null | undefined>;
isReadonly: boolean;
isBlockVisible: boolean;
}
function PureMessages({
chatId,
block,
setBlock,
isLoading,
votes,
messages,
@ -42,15 +38,13 @@ function PureMessages({
ref={messagesContainerRef}
className="flex flex-col min-w-0 gap-6 flex-1 overflow-y-scroll pt-4"
>
{messages.length === 0 && <Overview />}
{/* {messages.length === 0 && <Overview />} */}
{messages.map((message, index) => (
<PreviewMessage
key={message.id}
chatId={chatId}
message={message}
block={block}
setBlock={setBlock}
isLoading={isLoading && messages.length - 1 === index}
vote={
votes
@ -76,6 +70,8 @@ function PureMessages({
}
export const Messages = memo(PureMessages, (prevProps, nextProps) => {
if (prevProps.isBlockVisible && nextProps.isBlockVisible) return true;
if (prevProps.isLoading !== nextProps.isLoading) return false;
if (prevProps.isLoading && nextProps.isLoading) return false;
if (prevProps.messages.length !== nextProps.messages.length) return false;

View file

@ -21,9 +21,9 @@ function PureSuggestedActions({ chatId, append }: SuggestedActionsProps) {
action: 'What is the weather in San Francisco?',
},
{
title: 'Help me write code to',
label: 'demonstrate the djikstra algorithm!',
action: 'Help me write code to demonstrate the djikstra algorithm!',
title: 'Write code that',
label: `demonstrates djikstra's algorithm!`,
action: `Write code that demonstrates djikstra's algorithm!`,
},
];

View file

@ -1,132 +0,0 @@
import type { JSONValue } from 'ai';
import { type Dispatch, type SetStateAction, useEffect, useState } from 'react';
import { useSWRConfig } from 'swr';
import type { Suggestion } from '@/lib/db/schema';
import type { BlockKind, UIBlock } from './block';
import { useUserMessageId } from '@/hooks/use-user-message-id';
type StreamingDelta = {
type:
| 'text-delta'
| 'code-delta'
| 'title'
| 'id'
| 'suggestion'
| 'clear'
| 'finish'
| 'user-message-id'
| 'kind';
content: string | Suggestion;
};
export function useBlockStream({
streamingData,
setBlock,
}: {
streamingData: JSONValue[] | undefined;
setBlock: Dispatch<SetStateAction<UIBlock>>;
}) {
const { mutate } = useSWRConfig();
const [optimisticSuggestions, setOptimisticSuggestions] = useState<
Array<Suggestion>
>([]);
const { setUserMessageIdFromServer } = useUserMessageId();
useEffect(() => {
if (optimisticSuggestions && optimisticSuggestions.length > 0) {
const [optimisticSuggestion] = optimisticSuggestions;
const url = `/api/suggestions?documentId=${optimisticSuggestion.documentId}`;
mutate(url, optimisticSuggestions, false);
}
}, [optimisticSuggestions, mutate]);
useEffect(() => {
const mostRecentDelta = streamingData?.at(-1);
if (!mostRecentDelta) return;
const delta = mostRecentDelta as StreamingDelta;
if (delta.type === 'user-message-id') {
setUserMessageIdFromServer(delta.content as string);
return;
}
setBlock((draftBlock) => {
switch (delta.type) {
case 'id':
return {
...draftBlock,
documentId: delta.content as string,
};
case 'title':
return {
...draftBlock,
title: delta.content as string,
};
case 'kind':
return {
...draftBlock,
kind: delta.content as BlockKind,
};
case 'text-delta':
return {
...draftBlock,
content: draftBlock.content + (delta.content as string),
isVisible:
draftBlock.status === 'streaming' &&
draftBlock.content.length > 200 &&
draftBlock.content.length < 250
? true
: draftBlock.isVisible,
status: 'streaming',
};
case 'code-delta':
return {
...draftBlock,
content: delta.content as string,
isVisible:
draftBlock.status === 'streaming' &&
draftBlock.content.length > 20 &&
draftBlock.content.length < 30
? true
: draftBlock.isVisible,
status: 'streaming',
};
case 'suggestion':
setTimeout(() => {
setOptimisticSuggestions((currentSuggestions) => [
...currentSuggestions,
delta.content as Suggestion,
]);
}, 0);
return draftBlock;
case 'clear':
return {
...draftBlock,
content: '',
status: 'streaming',
};
case 'finish':
return {
...draftBlock,
status: 'idle',
};
default:
return draftBlock;
}
});
}, [streamingData, setBlock, setUserMessageIdFromServer]);
}

View file

@ -12,20 +12,21 @@ import { getDocumentTimestampByIndex } from '@/lib/utils';
import type { UIBlock } from './block';
import { LoaderIcon } from './icons';
import { Button } from './ui/button';
import { useBlock } from '@/hooks/use-block';
interface VersionFooterProps {
block: UIBlock;
handleVersionChange: (type: 'next' | 'prev' | 'toggle' | 'latest') => void;
documents: Array<Document> | undefined;
currentVersionIndex: number;
}
export const VersionFooter = ({
block,
handleVersionChange,
documents,
currentVersionIndex,
}: VersionFooterProps) => {
const { block } = useBlock();
const { width } = useWindowSize();
const isMobile = width < 768;

68
hooks/use-block.ts Normal file
View file

@ -0,0 +1,68 @@
'use client';
import { UIBlock } from '@/components/block';
import { useCallback, useMemo } from 'react';
import useSWR from 'swr';
export const initialBlockData: UIBlock = {
documentId: 'init',
content: '',
kind: 'text',
title: '',
status: 'idle',
isVisible: false,
boundingBox: {
top: 0,
left: 0,
width: 0,
height: 0,
},
};
// Add type for selector function
type Selector<T> = (state: UIBlock) => T;
export function useBlockSelector<Selected>(selector: Selector<Selected>) {
const { data: localBlock } = useSWR<UIBlock>('block', null, {
fallbackData: initialBlockData,
});
const selectedValue = useMemo(() => {
if (!localBlock) return selector(initialBlockData);
return selector(localBlock);
}, [localBlock, selector]);
return selectedValue;
}
export function useBlock() {
const { data: localBlock, mutate: setLocalBlock } = useSWR<UIBlock>(
'block',
null,
{
fallbackData: initialBlockData,
},
);
const block = useMemo(() => {
if (!localBlock) return initialBlockData;
return localBlock;
}, [localBlock]);
const setBlock = useCallback(
(updaterFn: UIBlock | ((currentBlock: UIBlock) => UIBlock)) => {
setLocalBlock((currentBlock) => {
const blockToUpdate = currentBlock || initialBlockData;
if (typeof updaterFn === 'function') {
return updaterFn(blockToUpdate);
}
return updaterFn;
});
},
[setLocalBlock],
);
return useMemo(() => ({ block, setBlock }), [block, setBlock]);
}

View file

@ -1,7 +1,7 @@
export const blocksPrompt = `
Blocks is a special user interface mode that helps users with writing, editing, and other content creation tasks. When block is open, it is on the right side of the screen, while the conversation is on the left side. When creating or updating documents, changes are reflected in real-time on the blocks and visible to the user.
When writing code, specify the language in the backticks, e.g. \`\`\`python\`code here\`\`\`. The default language is Python. Other languages are not yet supported, so let the user know if they request a different language.
When asked to write code, always use blocks. When writing code, specify the language in the backticks, e.g. \`\`\`python\`code here\`\`\`. The default language is Python. Other languages are not yet supported, so let the user know if they request a different language.
This is a guide for using blocks tools: \`createDocument\` and \`updateDocument\`, which render content on a blocks beside the conversation.

View file

@ -37,7 +37,7 @@
"@vercel/analytics": "^1.3.1",
"@vercel/blob": "^0.24.1",
"@vercel/postgres": "^0.10.0",
"ai": "4.0.10",
"ai": "4.0.20",
"bcrypt-ts": "^5.0.2",
"class-variance-authority": "^0.7.0",
"classnames": "^2.5.1",

71
pnpm-lock.yaml generated
View file

@ -66,8 +66,8 @@ importers:
specifier: ^0.10.0
version: 0.10.0
ai:
specifier: 4.0.10
version: 4.0.10(react@19.0.0-rc-45804af1-20241021)(zod@3.23.8)
specifier: 4.0.20
version: 4.0.20(react@19.0.0-rc-45804af1-20241021)(zod@3.23.8)
bcrypt-ts:
specifier: ^5.0.2
version: 5.0.2
@ -255,12 +255,25 @@ packages:
zod:
optional: true
'@ai-sdk/provider-utils@2.0.4':
resolution: {integrity: sha512-GMhcQCZbwM6RoZCri0MWeEWXRt/T+uCxsmHEsTwNvEH3GDjNzchfX25C8ftry2MeEOOn6KfqCLSKomcgK6RoOg==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.0.0
peerDependenciesMeta:
zod:
optional: true
'@ai-sdk/provider@1.0.1':
resolution: {integrity: sha512-mV+3iNDkzUsZ0pR2jG0sVzU6xtQY5DtSCBy3JFycLp6PwjyLw/iodfL3MwdmMCRJWgs3dadcHejRnMvF9nGTBg==}
engines: {node: '>=18'}
'@ai-sdk/react@1.0.3':
resolution: {integrity: sha512-Mak7qIRlbgtP4I7EFoNKRIQTlABJHhgwrN8SV2WKKdmsfWK2RwcubQWz1hp88cQ0bpF6KxxjSY1UUnS/S9oR5g==}
'@ai-sdk/provider@1.0.2':
resolution: {integrity: sha512-YYtP6xWQyaAf5LiWLJ+ycGTOeBLWrED7LUrvc+SQIWhGaneylqbaGsyQL7VouQUeQ4JZ1qKYZuhmi3W56HADPA==}
engines: {node: '>=18'}
'@ai-sdk/react@1.0.6':
resolution: {integrity: sha512-8Hkserq0Ge6AEi7N4hlv2FkfglAGbkoAXEZ8YSp255c3PbnZz6+/5fppw+aROmZMOfNwallSRuy1i/iPa2rBpQ==}
engines: {node: '>=18'}
peerDependencies:
react: ^18 || ^19 || ^19.0.0-rc
@ -271,8 +284,8 @@ packages:
zod:
optional: true
'@ai-sdk/ui-utils@1.0.2':
resolution: {integrity: sha512-hHrUdeThGHu/rsGZBWQ9PjrAU9Htxgbo9MFyR5B/aWoNbBeXn1HLMY1+uMEnXL5pRPlmyVRjgIavWg7UgeNDOw==}
'@ai-sdk/ui-utils@1.0.5':
resolution: {integrity: sha512-DGJSbDf+vJyWmFNexSPUsS1AAy7gtsmFmoSyNbNbJjwl9hRIf2dknfA1V0ahx6pg3NNklNYFm53L8Nphjovfvg==}
engines: {node: '>=18'}
peerDependencies:
zod: ^3.0.0
@ -1613,8 +1626,8 @@ packages:
engines: {node: '>=0.4.0'}
hasBin: true
ai@4.0.10:
resolution: {integrity: sha512-40GaEGLbp7if1F50zp3Kr03vcqyGS8svyJWpbkgec7G5Ik2rEtnbDWiUoOJuAVqgP5/iy4NgZQfvX3jRmOyQrw==}
ai@4.0.20:
resolution: {integrity: sha512-dYevYKtREcjSVopBDFWVNca7WJEI1p9Vr9eo7V7fZHzi2vXGDyEa2WYatjFbpR6z6gpVAxKHsof8EoN+B1IAsA==}
engines: {node: '>=18'}
peerDependencies:
react: ^18 || ^19 || ^19.0.0-rc
@ -2876,6 +2889,11 @@ packages:
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
nanoid@3.3.8:
resolution: {integrity: sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
nanoid@5.0.8:
resolution: {integrity: sha512-TcJPw+9RV9dibz1hHUzlLVy8N4X9TnwirAjrU08Juo6BNKggzVfP2ZJ/3ZUSq15Xl5i85i+Z89XBO90pB2PghQ==}
engines: {node: ^18 || >=20}
@ -3748,24 +3766,37 @@ snapshots:
optionalDependencies:
zod: 3.23.8
'@ai-sdk/provider-utils@2.0.4(zod@3.23.8)':
dependencies:
'@ai-sdk/provider': 1.0.2
eventsource-parser: 3.0.0
nanoid: 3.3.8
secure-json-parse: 2.7.0
optionalDependencies:
zod: 3.23.8
'@ai-sdk/provider@1.0.1':
dependencies:
json-schema: 0.4.0
'@ai-sdk/react@1.0.3(react@19.0.0-rc-45804af1-20241021)(zod@3.23.8)':
'@ai-sdk/provider@1.0.2':
dependencies:
'@ai-sdk/provider-utils': 2.0.2(zod@3.23.8)
'@ai-sdk/ui-utils': 1.0.2(zod@3.23.8)
json-schema: 0.4.0
'@ai-sdk/react@1.0.6(react@19.0.0-rc-45804af1-20241021)(zod@3.23.8)':
dependencies:
'@ai-sdk/provider-utils': 2.0.4(zod@3.23.8)
'@ai-sdk/ui-utils': 1.0.5(zod@3.23.8)
swr: 2.2.5(react@19.0.0-rc-45804af1-20241021)
throttleit: 2.1.0
optionalDependencies:
react: 19.0.0-rc-45804af1-20241021
zod: 3.23.8
'@ai-sdk/ui-utils@1.0.2(zod@3.23.8)':
'@ai-sdk/ui-utils@1.0.5(zod@3.23.8)':
dependencies:
'@ai-sdk/provider': 1.0.1
'@ai-sdk/provider-utils': 2.0.2(zod@3.23.8)
'@ai-sdk/provider': 1.0.2
'@ai-sdk/provider-utils': 2.0.4(zod@3.23.8)
zod-to-json-schema: 3.23.5(zod@3.23.8)
optionalDependencies:
zod: 3.23.8
@ -4865,12 +4896,12 @@ snapshots:
acorn@8.14.0: {}
ai@4.0.10(react@19.0.0-rc-45804af1-20241021)(zod@3.23.8):
ai@4.0.20(react@19.0.0-rc-45804af1-20241021)(zod@3.23.8):
dependencies:
'@ai-sdk/provider': 1.0.1
'@ai-sdk/provider-utils': 2.0.2(zod@3.23.8)
'@ai-sdk/react': 1.0.3(react@19.0.0-rc-45804af1-20241021)(zod@3.23.8)
'@ai-sdk/ui-utils': 1.0.2(zod@3.23.8)
'@ai-sdk/provider': 1.0.2
'@ai-sdk/provider-utils': 2.0.4(zod@3.23.8)
'@ai-sdk/react': 1.0.6(react@19.0.0-rc-45804af1-20241021)(zod@3.23.8)
'@ai-sdk/ui-utils': 1.0.5(zod@3.23.8)
'@opentelemetry/api': 1.9.0
jsondiffpatch: 0.6.0
zod-to-json-schema: 3.23.5(zod@3.23.8)
@ -6484,6 +6515,8 @@ snapshots:
nanoid@3.3.7: {}
nanoid@3.3.8: {}
nanoid@5.0.8: {}
natural-compare@1.4.0: {}