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 { Chat } from '@/components/chat';
import { DEFAULT_MODEL_NAME, models } from '@/lib/ai/models'; import { DEFAULT_MODEL_NAME, models } from '@/lib/ai/models';
import { generateUUID } from '@/lib/utils'; import { generateUUID } from '@/lib/utils';
import { DataStreamHandler } from '@/components/data-stream-handler';
export default async function Page() { export default async function Page() {
const id = generateUUID(); const id = generateUUID();
@ -15,13 +16,16 @@ export default async function Page() {
DEFAULT_MODEL_NAME; DEFAULT_MODEL_NAME;
return ( return (
<Chat <>
key={id} <Chat
id={id} key={id}
initialMessages={[]} id={id}
selectedModelId={selectedModelId} initialMessages={[]}
selectedVisibilityType="private" selectedModelId={selectedModelId}
isReadonly={false} selectedVisibilityType="private"
/> isReadonly={false}
/>
<DataStreamHandler id={id} />
</>
); );
} }

View file

@ -45,7 +45,10 @@
"noSvgWithoutTitle": "off", // We do not intend to adhere to this rule "noSvgWithoutTitle": "off", // We do not intend to adhere to this rule
"useMediaCaption": "off", // We would need a cultural change to turn this on "useMediaCaption": "off", // We would need a cultural change to turn this on
"noAutofocus": "off", // We're highly intentional about when we use autofocus "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": { "complexity": {
"noUselessStringConcat": "warn", // Not in recommended ruleset, turning on manually "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 { CrossIcon } from './icons';
import { Button } from './ui/button'; import { Button } from './ui/button';
import { UIBlock } from './block'; import { initialBlockData, useBlock } from '@/hooks/use-block';
import equal from 'fast-deep-equal';
interface BlockCloseButtonProps { function PureBlockCloseButton() {
setBlock: (value: SetStateAction<UIBlock>) => void; const { setBlock } = useBlock();
}
function PureBlockCloseButton({ setBlock }: BlockCloseButtonProps) {
return ( return (
<Button <Button
variant="outline" variant="outline"
className="h-fit p-2 dark:hover:bg-zinc-700" className="h-fit p-2 dark:hover:bg-zinc-700"
onClick={() => { onClick={() => {
setBlock((currentBlock) => ({ setBlock((currentBlock) =>
...currentBlock, currentBlock.status === 'streaming'
isVisible: false, ? {
})); ...currentBlock,
isVisible: false,
}
: { ...initialBlockData, status: 'idle' },
);
}} }}
> >
<CrossIcon size={18} /> <CrossIcon size={18} />

View file

@ -1,14 +1,13 @@
import { Dispatch, memo, SetStateAction } from 'react';
import { UIBlock } from './block';
import { PreviewMessage } from './message'; import { PreviewMessage } from './message';
import { useScrollToBottom } from './use-scroll-to-bottom'; import { useScrollToBottom } from './use-scroll-to-bottom';
import { Vote } from '@/lib/db/schema'; import { Vote } from '@/lib/db/schema';
import { ChatRequestOptions, Message } from 'ai'; import { ChatRequestOptions, Message } from 'ai';
import { memo } from 'react';
import equal from 'fast-deep-equal';
import { UIBlock } from './block';
interface BlockMessagesProps { interface BlockMessagesProps {
chatId: string; chatId: string;
block: UIBlock;
setBlock: Dispatch<SetStateAction<UIBlock>>;
isLoading: boolean; isLoading: boolean;
votes: Array<Vote> | undefined; votes: Array<Vote> | undefined;
messages: Array<Message>; messages: Array<Message>;
@ -19,12 +18,11 @@ interface BlockMessagesProps {
chatRequestOptions?: ChatRequestOptions, chatRequestOptions?: ChatRequestOptions,
) => Promise<string | null | undefined>; ) => Promise<string | null | undefined>;
isReadonly: boolean; isReadonly: boolean;
blockStatus: UIBlock['status'];
} }
function PureBlockMessages({ function PureBlockMessages({
chatId, chatId,
block,
setBlock,
isLoading, isLoading,
votes, votes,
messages, messages,
@ -45,8 +43,6 @@ function PureBlockMessages({
chatId={chatId} chatId={chatId}
key={message.id} key={message.id}
message={message} message={message}
block={block}
setBlock={setBlock}
isLoading={isLoading && index === messages.length - 1} isLoading={isLoading && index === messages.length - 1}
vote={ vote={
votes votes
@ -72,13 +68,17 @@ function areEqual(
nextProps: BlockMessagesProps, nextProps: BlockMessagesProps,
) { ) {
if ( if (
prevProps.block.status === 'streaming' && prevProps.blockStatus === 'streaming' &&
nextProps.block.status === 'streaming' nextProps.blockStatus === 'streaming'
) { )
return true; return true;
}
return false; 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;
} }
export const BlockMessages = memo(PureBlockMessages, areEqual); 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 { BlockMessages } from './block-messages';
import { CodeEditor } from './code-editor'; import { CodeEditor } from './code-editor';
import { Console } from './console'; 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'; export type BlockKind = 'text' | 'code';
@ -65,8 +68,6 @@ function PureBlock({
attachments, attachments,
setAttachments, setAttachments,
append, append,
block,
setBlock,
messages, messages,
setMessages, setMessages,
reload, reload,
@ -80,8 +81,6 @@ function PureBlock({
stop: () => void; stop: () => void;
attachments: Array<Attachment>; attachments: Array<Attachment>;
setAttachments: Dispatch<SetStateAction<Array<Attachment>>>; setAttachments: Dispatch<SetStateAction<Array<Attachment>>>;
block: UIBlock;
setBlock: Dispatch<SetStateAction<UIBlock>>;
messages: Array<Message>; messages: Array<Message>;
setMessages: Dispatch<SetStateAction<Array<Message>>>; setMessages: Dispatch<SetStateAction<Array<Message>>>;
votes: Array<Vote> | undefined; votes: Array<Vote> | undefined;
@ -100,12 +99,14 @@ function PureBlock({
) => Promise<string | null | undefined>; ) => Promise<string | null | undefined>;
isReadonly: boolean; isReadonly: boolean;
}) { }) {
const { block, setBlock } = useBlock();
const { const {
data: documents, data: documents,
isLoading: isDocumentsFetching, isLoading: isDocumentsFetching,
mutate: mutateDocuments, mutate: mutateDocuments,
} = useSWR<Array<Document>>( } = useSWR<Array<Document>>(
block && block.status !== 'streaming' block.documentId !== 'init' && block.status !== 'streaming'
? `/api/document?id=${block.documentId}` ? `/api/document?id=${block.documentId}`
: null, : null,
fetcher, fetcher,
@ -128,6 +129,8 @@ function PureBlock({
[], [],
); );
const { open: isSidebarOpen } = useSidebar();
useEffect(() => { useEffect(() => {
if (documents && documents.length > 0) { if (documents && documents.length > 0) {
const mostRecentDocument = documents.at(-1); const mostRecentDocument = documents.at(-1);
@ -260,274 +263,302 @@ function PureBlock({
const isMobile = windowWidth ? windowWidth < 768 : false; const isMobile = windowWidth ? windowWidth < 768 : false;
return ( return (
<motion.div <AnimatePresence>
className="flex flex-row h-dvh w-dvw fixed top-0 left-0 z-50 bg-muted" {block.isVisible && (
initial={{ opacity: 1 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0, transition: { delay: 0.4 } }}
>
{!isMobile && (
<motion.div <motion.div
className="relative w-[400px] bg-muted dark:bg-background h-dvh shrink-0" className="flex flex-row h-dvh w-dvw fixed top-0 left-0 z-50 bg-transparent"
initial={{ opacity: 0, x: 10, scale: 1 }} initial={{ opacity: 1 }}
animate={{ animate={{ opacity: 1 }}
opacity: 1, exit={{ opacity: 0, transition: { delay: 0.4 } }}
x: 0,
scale: 1,
transition: {
delay: 0.2,
type: 'spring',
stiffness: 200,
damping: 30,
},
}}
exit={{
opacity: 0,
x: 0,
scale: 0.95,
transition: { delay: 0 },
}}
> >
<AnimatePresence> {!isMobile && (
{!isCurrentVersion && ( <motion.div
<motion.div className="fixed bg-background h-dvh"
className="left-0 absolute h-dvh w-[400px] top-0 bg-zinc-900/50 z-50" initial={{
initial={{ opacity: 0 }} width: isSidebarOpen ? windowWidth - 256 : windowWidth,
animate={{ opacity: 1 }} right: 0,
exit={{ opacity: 0 }} }}
/> animate={{ width: windowWidth, right: 0 }}
)} exit={{
</AnimatePresence> width: isSidebarOpen ? windowWidth - 256 : windowWidth,
right: 0,
<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}
/> />
)}
<form className="flex flex-row gap-2 relative items-end w-full px-4 pb-4"> {!isMobile && (
<MultimodalInput <motion.div
chatId={chatId} className="relative w-[400px] bg-muted dark:bg-background h-dvh shrink-0"
input={input} initial={{ opacity: 0, x: 10, scale: 1 }}
setInput={setInput} animate={{
handleSubmit={handleSubmit}
isLoading={isLoading}
stop={stop}
attachments={attachments}
setAttachments={setAttachments}
messages={messages}
append={append}
className="bg-background dark:bg-muted"
setMessages={setMessages}
/>
</form>
</div>
</motion.div>
)}
<motion.div
className="fixed dark:bg-muted bg-background h-dvh flex flex-col overflow-y-scroll"
initial={
isMobile
? {
opacity: 0,
x: 0,
y: 0,
width: windowWidth,
height: windowHeight,
borderRadius: 50,
}
: {
opacity: 0,
x: block.boundingBox.left,
y: block.boundingBox.top,
height: block.boundingBox.height,
width: block.boundingBox.width,
borderRadius: 50,
}
}
animate={
isMobile
? {
opacity: 1, opacity: 1,
x: 0, x: 0,
y: 0, scale: 1,
width: windowWidth,
height: '100dvh',
borderRadius: 0,
transition: { transition: {
delay: 0, delay: 0.2,
type: 'spring', type: 'spring',
stiffness: 200, stiffness: 200,
damping: 30, damping: 30,
}, },
} }}
: { exit={{
opacity: 1, opacity: 0,
x: 400, x: 0,
y: 0, scale: 1,
height: windowHeight, transition: { duration: 0 },
width: windowWidth ? windowWidth - 400 : 'calc(100dvw-400px)', }}
borderRadius: 0, >
transition: { <AnimatePresence>
delay: 0, {!isCurrentVersion && (
type: 'spring', <motion.div
stiffness: 200, className="left-0 absolute h-dvh w-[400px] top-0 bg-zinc-900/50 z-50"
damping: 30, initial={{ opacity: 0 }}
}, animate={{ opacity: 1 }}
} exit={{ opacity: 0 }}
} />
exit={{ )}
opacity: 0, </AnimatePresence>
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">
<BlockCloseButton setBlock={setBlock} />
<div className="flex flex-col"> <div className="flex flex-col h-full justify-between items-center gap-4">
<div className="font-medium"> <BlockMessages
{document?.title ?? block.title} chatId={chatId}
isLoading={isLoading}
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">
<MultimodalInput
chatId={chatId}
input={input}
setInput={setInput}
handleSubmit={handleSubmit}
isLoading={isLoading}
stop={stop}
attachments={attachments}
setAttachments={setAttachments}
messages={messages}
append={append}
className="bg-background dark:bg-muted"
setMessages={setMessages}
/>
</form>
</div>
</motion.div>
)}
<motion.div
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: 1,
x: block.boundingBox.left,
y: block.boundingBox.top,
height: block.boundingBox.height,
width: block.boundingBox.width,
borderRadius: 50,
}
: {
opacity: 1,
x: block.boundingBox.left,
y: block.boundingBox.top,
height: block.boundingBox.height,
width: block.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">
<BlockCloseButton />
<div className="flex flex-col">
<div className="font-medium">
{document?.title ?? block.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> </div>
{isContentDirty ? ( <BlockActions
<div className="text-sm text-muted-foreground"> block={block}
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>
<BlockActions
block={block}
currentVersionIndex={currentVersionIndex}
handleVersionChange={handleVersionChange}
isCurrentVersion={isCurrentVersion}
mode={mode}
setConsoleOutputs={setConsoleOutputs}
/>
</div>
<div
className={cn(
'prose dark:prose-invert 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',
},
)}
>
<div
className={cn('flex flex-row', {
'': block.kind === 'code',
'mx-auto max-w-[600px]': block.kind === 'text',
})}
>
{isDocumentsFetching && !block.content ? (
<DocumentSkeleton />
) : block.kind === 'code' ? (
<CodeEditor
content={
isCurrentVersion
? block.content
: getDocumentContentById(currentVersionIndex)
}
isCurrentVersion={isCurrentVersion}
currentVersionIndex={currentVersionIndex} currentVersionIndex={currentVersionIndex}
suggestions={suggestions ?? []} handleVersionChange={handleVersionChange}
status={block.status} isCurrentVersion={isCurrentVersion}
saveContent={saveContent} mode={mode}
setConsoleOutputs={setConsoleOutputs}
/> />
) : block.kind === 'text' ? ( </div>
mode === 'edit' ? (
<Editor
content={
isCurrentVersion
? block.content
: getDocumentContentById(currentVersionIndex)
}
isCurrentVersion={isCurrentVersion}
currentVersionIndex={currentVersionIndex}
status={block.status}
saveContent={saveContent}
suggestions={isCurrentVersion ? (suggestions ?? []) : []}
/>
) : (
<DiffView
oldContent={getDocumentContentById(currentVersionIndex - 1)}
newContent={getDocumentContentById(currentVersionIndex)}
/>
)
) : null}
{suggestions ? ( <div
<div className="md:hidden h-dvh w-12 shrink-0" /> className={cn(
) : null} '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',
},
)}
>
<div
className={cn('flex flex-row', {
'': block.kind === 'code',
'mx-auto max-w-[600px]': block.kind === 'text',
})}
>
{isDocumentsFetching && !block.content ? (
<DocumentSkeleton />
) : block.kind === 'code' ? (
<CodeEditor
content={
isCurrentVersion
? block.content
: getDocumentContentById(currentVersionIndex)
}
isCurrentVersion={isCurrentVersion}
currentVersionIndex={currentVersionIndex}
suggestions={suggestions ?? []}
status={block.status}
saveContent={saveContent}
/>
) : block.kind === 'text' ? (
mode === 'edit' ? (
<Editor
content={
isCurrentVersion
? block.content
: getDocumentContentById(currentVersionIndex)
}
isCurrentVersion={isCurrentVersion}
currentVersionIndex={currentVersionIndex}
status={block.status}
saveContent={saveContent}
suggestions={isCurrentVersion ? (suggestions ?? []) : []}
/>
) : (
<DiffView
oldContent={getDocumentContentById(
currentVersionIndex - 1,
)}
newContent={getDocumentContentById(currentVersionIndex)}
/>
)
) : null}
{suggestions ? (
<div className="md:hidden h-dvh w-12 shrink-0" />
) : null}
<AnimatePresence>
{isCurrentVersion && (
<Toolbar
isToolbarVisible={isToolbarVisible}
setIsToolbarVisible={setIsToolbarVisible}
append={append}
isLoading={isLoading}
stop={stop}
setMessages={setMessages}
blockKind={block.kind}
/>
)}
</AnimatePresence>
</div>
</div>
<AnimatePresence> <AnimatePresence>
{isCurrentVersion && ( {!isCurrentVersion && (
<Toolbar <VersionFooter
isToolbarVisible={isToolbarVisible} currentVersionIndex={currentVersionIndex}
setIsToolbarVisible={setIsToolbarVisible} documents={documents}
append={append} handleVersionChange={handleVersionChange}
isLoading={isLoading}
stop={stop}
setMessages={setMessages}
blockKind={block.kind}
/> />
)} )}
</AnimatePresence> </AnimatePresence>
</div>
</div>
<AnimatePresence> <AnimatePresence>
{!isCurrentVersion && ( <Console
<VersionFooter consoleOutputs={consoleOutputs}
block={block} setConsoleOutputs={setConsoleOutputs}
currentVersionIndex={currentVersionIndex} />
documents={documents} </AnimatePresence>
handleVersionChange={handleVersionChange} </motion.div>
/> </motion.div>
)} )}
</AnimatePresence> </AnimatePresence>
<AnimatePresence>
<Console
consoleOutputs={consoleOutputs}
setConsoleOutputs={setConsoleOutputs}
/>
</AnimatePresence>
</motion.div>
</motion.div>
); );
} }
export const Block = memo(PureBlock, (prevProps, nextProps) => { 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 type { Attachment, Message } from 'ai';
import { useChat } from 'ai/react'; import { useChat } from 'ai/react';
import { AnimatePresence } from 'framer-motion';
import { useState } from 'react'; import { useState } from 'react';
import useSWR, { useSWRConfig } from 'swr'; import useSWR, { useSWRConfig } from 'swr';
import { useWindowSize } from 'usehooks-ts';
import { ChatHeader } from '@/components/chat-header'; import { ChatHeader } from '@/components/chat-header';
import type { Vote } from '@/lib/db/schema'; import type { Vote } from '@/lib/db/schema';
import { fetcher } from '@/lib/utils'; import { fetcher } from '@/lib/utils';
import { Block, type UIBlock } from './block'; import { Block } from './block';
import { BlockStreamHandler } from './block-stream-handler';
import { MultimodalInput } from './multimodal-input'; import { MultimodalInput } from './multimodal-input';
import { Messages } from './messages'; import { Messages } from './messages';
import { VisibilityType } from './visibility-selector'; import { VisibilityType } from './visibility-selector';
import { useBlockSelector } from '@/hooks/use-block';
export function Chat({ export function Chat({
id, id,
@ -42,40 +40,23 @@ export function Chat({
isLoading, isLoading,
stop, stop,
reload, reload,
data: streamingData,
} = useChat({ } = useChat({
id, id,
body: { id, modelId: selectedModelId }, body: { id, modelId: selectedModelId },
initialMessages, initialMessages,
experimental_throttle: 100,
onFinish: () => { onFinish: () => {
mutate('/api/history'); 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>>( const { data: votes } = useSWR<Array<Vote>>(
`/api/vote?chatId=${id}`, `/api/vote?chatId=${id}`,
fetcher, fetcher,
); );
const [attachments, setAttachments] = useState<Array<Attachment>>([]); const [attachments, setAttachments] = useState<Array<Attachment>>([]);
const isBlockVisible = useBlockSelector((state) => state.isVisible);
return ( return (
<> <>
@ -89,14 +70,13 @@ export function Chat({
<Messages <Messages
chatId={id} chatId={id}
block={block}
setBlock={setBlock}
isLoading={isLoading} isLoading={isLoading}
votes={votes} votes={votes}
messages={messages} messages={messages}
setMessages={setMessages} setMessages={setMessages}
reload={reload} reload={reload}
isReadonly={isReadonly} 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"> <form className="flex mx-auto px-4 bg-background pb-4 md:pb-6 gap-2 w-full md:max-w-3xl">
@ -118,30 +98,22 @@ export function Chat({
</form> </form>
</div> </div>
<AnimatePresence> <Block
{block?.isVisible && ( chatId={id}
<Block input={input}
chatId={id} setInput={setInput}
input={input} handleSubmit={handleSubmit}
setInput={setInput} isLoading={isLoading}
handleSubmit={handleSubmit} stop={stop}
isLoading={isLoading} attachments={attachments}
stop={stop} setAttachments={setAttachments}
attachments={attachments} append={append}
setAttachments={setAttachments} messages={messages}
append={append} setMessages={setMessages}
block={block} reload={reload}
setBlock={setBlock} votes={votes}
messages={messages} isReadonly={isReadonly}
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; editorRef.current = null;
} }
}; };
// NOTE: we only want to run this effect once
// eslint-disable-next-line
}, []); }, []);
useEffect(() => { useEffect(() => {

View file

@ -63,6 +63,8 @@ export function Console({ consoleOutputs, setConsoleOutputs }: ConsoleProps) {
className="h-2 w-full fixed cursor-ns-resize z-50" className="h-2 w-full fixed cursor-ns-resize z-50"
onMouseDown={startResizing} onMouseDown={startResizing}
style={{ bottom: height - 4 }} style={{ bottom: height - 4 }}
role="slider"
aria-valuenow={minHeight}
/> />
<div <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> </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 { FileIcon, LoaderIcon, MessageIcon, PencilEditIcon } from './icons';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useBlock } from '@/hooks/use-block';
const getActionText = ( const getActionText = (
type: 'create' | 'update' | 'request-suggestions', type: 'create' | 'update' | 'request-suggestions',
@ -25,17 +26,16 @@ const getActionText = (
interface DocumentToolResultProps { interface DocumentToolResultProps {
type: 'create' | 'update' | 'request-suggestions'; type: 'create' | 'update' | 'request-suggestions';
result: { id: string; title: string; kind: BlockKind }; result: { id: string; title: string; kind: BlockKind };
block: UIBlock;
setBlock: (value: SetStateAction<UIBlock>) => void;
isReadonly: boolean; isReadonly: boolean;
} }
function PureDocumentToolResult({ function PureDocumentToolResult({
type, type,
result, result,
setBlock,
isReadonly, isReadonly,
}: DocumentToolResultProps) { }: DocumentToolResultProps) {
const { setBlock } = useBlock();
return ( return (
<button <button
type="button" type="button"
@ -89,16 +89,16 @@ export const DocumentToolResult = memo(PureDocumentToolResult, () => true);
interface DocumentToolCallProps { interface DocumentToolCallProps {
type: 'create' | 'update' | 'request-suggestions'; type: 'create' | 'update' | 'request-suggestions';
args: { title: string }; args: { title: string };
setBlock: (value: SetStateAction<UIBlock>) => void;
isReadonly: boolean; isReadonly: boolean;
} }
function PureDocumentToolCall({ function PureDocumentToolCall({
type, type,
args, args,
setBlock,
isReadonly, isReadonly,
}: DocumentToolCallProps) { }: DocumentToolCallProps) {
const { setBlock } = useBlock();
return ( return (
<button <button
type="button" type="button"

View file

@ -1102,3 +1102,20 @@ export const ImageIcon = ({ size = 16 }: { size?: number }) => {
</svg> </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,101 +1,103 @@
import Link from 'next/link'; 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 ReactMarkdown, { type Components } from 'react-markdown';
import remarkGfm from 'remark-gfm'; import remarkGfm from 'remark-gfm';
import { CodeBlock } from './code-block'; import { CodeBlock } from './code-block';
const NonMemoizedMarkdown = ({ children }: { children: string }) => { const components: Partial<Components> = {
const components: Partial<Components> = { // @ts-expect-error
// @ts-expect-error code: CodeBlock,
code: CodeBlock, pre: ({ children }) => <>{children}</>,
pre: ({ children }) => <>{children}</>, ol: ({ node, children, ...props }) => {
ol: ({ node, children, ...props }) => { return (
return ( <ol className="list-decimal list-outside ml-4" {...props}>
<ol className="list-decimal list-outside ml-4" {...props}> {children}
{children} </ol>
</ol> );
); },
}, li: ({ node, children, ...props }) => {
li: ({ node, children, ...props }) => { return (
return ( <li className="py-1" {...props}>
<li className="py-1" {...props}> {children}
{children} </li>
</li> );
); },
}, ul: ({ node, children, ...props }) => {
ul: ({ node, children, ...props }) => { return (
return ( <ul className="list-decimal list-outside ml-4" {...props}>
<ul className="list-decimal list-outside ml-4" {...props}> {children}
{children} </ul>
</ul> );
); },
}, strong: ({ node, children, ...props }) => {
strong: ({ node, children, ...props }) => { return (
return ( <span className="font-semibold" {...props}>
<span className="font-semibold" {...props}> {children}
{children} </span>
</span> );
); },
}, a: ({ node, children, ...props }) => {
a: ({ node, children, ...props }) => { return (
return ( // @ts-expect-error
// @ts-expect-error <Link
<Link className="text-blue-500 hover:underline"
className="text-blue-500 hover:underline" target="_blank"
target="_blank" rel="noreferrer"
rel="noreferrer" {...props}
{...props} >
> {children}
{children} </Link>
</Link> );
); },
}, h1: ({ node, children, ...props }) => {
h1: ({ node, children, ...props }) => { return (
return ( <h1 className="text-3xl font-semibold mt-6 mb-2" {...props}>
<h1 className="text-3xl font-semibold mt-6 mb-2" {...props}> {children}
{children} </h1>
</h1> );
); },
}, h2: ({ node, children, ...props }) => {
h2: ({ node, children, ...props }) => { return (
return ( <h2 className="text-2xl font-semibold mt-6 mb-2" {...props}>
<h2 className="text-2xl font-semibold mt-6 mb-2" {...props}> {children}
{children} </h2>
</h2> );
); },
}, h3: ({ node, children, ...props }) => {
h3: ({ node, children, ...props }) => { return (
return ( <h3 className="text-xl font-semibold mt-6 mb-2" {...props}>
<h3 className="text-xl font-semibold mt-6 mb-2" {...props}> {children}
{children} </h3>
</h3> );
); },
}, h4: ({ node, children, ...props }) => {
h4: ({ node, children, ...props }) => { return (
return ( <h4 className="text-lg font-semibold mt-6 mb-2" {...props}>
<h4 className="text-lg font-semibold mt-6 mb-2" {...props}> {children}
{children} </h4>
</h4> );
); },
}, h5: ({ node, children, ...props }) => {
h5: ({ node, children, ...props }) => { return (
return ( <h5 className="text-base font-semibold mt-6 mb-2" {...props}>
<h5 className="text-base font-semibold mt-6 mb-2" {...props}> {children}
{children} </h5>
</h5> );
); },
}, h6: ({ node, children, ...props }) => {
h6: ({ node, children, ...props }) => { return (
return ( <h6 className="text-sm font-semibold mt-6 mb-2" {...props}>
<h6 className="text-sm font-semibold mt-6 mb-2" {...props}> {children}
{children} </h6>
</h6> );
); },
}, };
};
const remarkPlugins = [remarkGfm];
const NonMemoizedMarkdown = ({ children }: { children: string }) => {
return ( return (
<ReactMarkdown remarkPlugins={[remarkGfm]} components={components}> <ReactMarkdown remarkPlugins={remarkPlugins} components={components}>
{children} {children}
</ReactMarkdown> </ReactMarkdown>
); );

View file

@ -2,12 +2,11 @@
import type { ChatRequestOptions, Message } from 'ai'; import type { ChatRequestOptions, Message } from 'ai';
import cx from 'classnames'; import cx from 'classnames';
import { motion } from 'framer-motion'; import { AnimatePresence, motion } from 'framer-motion';
import { memo, useState, type Dispatch, type SetStateAction } from 'react'; import { memo, useMemo, useState } from 'react';
import type { Vote } from '@/lib/db/schema'; import type { Vote } from '@/lib/db/schema';
import type { UIBlock } from './block';
import { DocumentToolCall, DocumentToolResult } from './document'; import { DocumentToolCall, DocumentToolResult } from './document';
import { PencilEditIcon, SparklesIcon } from './icons'; import { PencilEditIcon, SparklesIcon } from './icons';
import { Markdown } from './markdown'; import { Markdown } from './markdown';
@ -19,12 +18,11 @@ import { cn } from '@/lib/utils';
import { Button } from './ui/button'; import { Button } from './ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip'; import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
import { MessageEditor } from './message-editor'; import { MessageEditor } from './message-editor';
import { DocumentPreview } from './document-preview';
const PurePreviewMessage = ({ const PurePreviewMessage = ({
chatId, chatId,
message, message,
block,
setBlock,
vote, vote,
isLoading, isLoading,
setMessages, setMessages,
@ -33,8 +31,6 @@ const PurePreviewMessage = ({
}: { }: {
chatId: string; chatId: string;
message: Message; message: Message;
block: UIBlock;
setBlock: Dispatch<SetStateAction<UIBlock>>;
vote: Vote | undefined; vote: Vote | undefined;
isLoading: boolean; isLoading: boolean;
setMessages: ( setMessages: (
@ -48,174 +44,162 @@ const PurePreviewMessage = ({
const [mode, setMode] = useState<'view' | 'edit'>('view'); const [mode, setMode] = useState<'view' | 'edit'>('view');
return ( return (
<motion.div <AnimatePresence>
className="w-full mx-auto max-w-3xl px-4 group/message" <motion.div
initial={{ y: 5, opacity: 0 }} className="w-full mx-auto max-w-3xl px-4 group/message"
animate={{ y: 0, opacity: 1 }} initial={{ y: 5, opacity: 0 }}
data-role={message.role} animate={{ y: 0, opacity: 1 }}
> data-role={message.role}
<div
className={cn(
'flex gap-4 w-full group-data-[role=user]/message:ml-auto group-data-[role=user]/message:max-w-2xl',
{
'w-full': mode === 'edit',
'group-data-[role=user]/message:w-fit': mode !== 'edit',
},
)}
> >
{message.role === 'assistant' && ( <div
<div className="size-8 flex items-center rounded-full justify-center ring-1 shrink-0 ring-border bg-background"> className={cn(
<SparklesIcon size={14} /> 'flex gap-4 w-full group-data-[role=user]/message:ml-auto group-data-[role=user]/message:max-w-2xl',
</div> {
)} 'w-full': mode === 'edit',
'group-data-[role=user]/message:w-fit': mode !== 'edit',
<div className="flex flex-col gap-2 w-full"> },
{message.experimental_attachments && ( )}
<div className="flex flex-row justify-end gap-2"> >
{message.experimental_attachments.map((attachment) => ( {message.role === 'assistant' && (
<PreviewAttachment <div className="size-8 flex items-center rounded-full justify-center ring-1 shrink-0 ring-border bg-background">
key={attachment.url} <SparklesIcon size={14} />
attachment={attachment}
/>
))}
</div> </div>
)} )}
{message.content && mode === 'view' && ( <div className="flex flex-col gap-2 w-full">
<div className="flex flex-row gap-2 items-start"> {message.experimental_attachments && (
{message.role === 'user' && !isReadonly && ( <div className="flex flex-row justify-end gap-2">
<Tooltip> {message.experimental_attachments.map((attachment) => (
<TooltipTrigger asChild> <PreviewAttachment
<Button key={attachment.url}
variant="ghost" attachment={attachment}
className="px-2 h-fit rounded-full text-muted-foreground opacity-0 group-hover/message:opacity-100" />
onClick={() => { ))}
setMode('edit');
}}
>
<PencilEditIcon />
</Button>
</TooltipTrigger>
<TooltipContent>Edit message</TooltipContent>
</Tooltip>
)}
<div
className={cn('flex flex-col gap-4', {
'bg-primary text-primary-foreground px-3 py-2 rounded-xl':
message.role === 'user',
})}
>
<Markdown>{message.content as string}</Markdown>
</div> </div>
</div> )}
)}
{message.content && mode === 'edit' && ( {message.content && mode === 'view' && (
<div className="flex flex-row gap-2 items-start"> <div className="flex flex-row gap-2 items-start">
<div className="size-8" /> {message.role === 'user' && !isReadonly && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
className="px-2 h-fit rounded-full text-muted-foreground opacity-0 group-hover/message:opacity-100"
onClick={() => {
setMode('edit');
}}
>
<PencilEditIcon />
</Button>
</TooltipTrigger>
<TooltipContent>Edit message</TooltipContent>
</Tooltip>
)}
<MessageEditor <div
key={message.id} className={cn('flex flex-col gap-4', {
message={message} 'bg-primary text-primary-foreground px-3 py-2 rounded-xl':
setMode={setMode} message.role === 'user',
setMessages={setMessages} })}
reload={reload} >
/> <Markdown>{message.content as string}</Markdown>
</div> </div>
)} </div>
)}
{message.toolInvocations && message.toolInvocations.length > 0 && ( {message.content && mode === 'edit' && (
<div className="flex flex-col gap-4"> <div className="flex flex-row gap-2 items-start">
{message.toolInvocations.map((toolInvocation) => { <div className="size-8" />
const { toolName, toolCallId, state, args } = toolInvocation;
if (state === 'result') { <MessageEditor
const { result } = toolInvocation; key={message.id}
message={message}
setMode={setMode}
setMessages={setMessages}
reload={reload}
/>
</div>
)}
{message.toolInvocations && message.toolInvocations.length > 0 && (
<div className="flex flex-col gap-4">
{message.toolInvocations.map((toolInvocation) => {
const { toolName, toolCallId, state, args } = toolInvocation;
if (state === 'result') {
const { result } = toolInvocation;
return (
<div key={toolCallId}>
{toolName === 'getWeather' ? (
<Weather weatherAtLocation={result} />
) : toolName === 'createDocument' ? (
<DocumentPreview
isReadonly={isReadonly}
result={result}
/>
) : toolName === 'updateDocument' ? (
<DocumentToolResult
type="update"
result={result}
isReadonly={isReadonly}
/>
) : toolName === 'requestSuggestions' ? (
<DocumentToolResult
type="request-suggestions"
result={result}
isReadonly={isReadonly}
/>
) : (
<pre>{JSON.stringify(result, null, 2)}</pre>
)}
</div>
);
}
return ( return (
<div key={toolCallId}> <div
key={toolCallId}
className={cx({
skeleton: ['getWeather'].includes(toolName),
})}
>
{toolName === 'getWeather' ? ( {toolName === 'getWeather' ? (
<Weather weatherAtLocation={result} /> <Weather />
) : toolName === 'createDocument' ? ( ) : toolName === 'createDocument' ? (
<DocumentToolResult <DocumentPreview isReadonly={isReadonly} args={args} />
type="create"
result={result}
block={block}
setBlock={setBlock}
isReadonly={isReadonly}
/>
) : toolName === 'updateDocument' ? ( ) : toolName === 'updateDocument' ? (
<DocumentToolResult <DocumentToolCall
type="update" type="update"
result={result} args={args}
block={block}
setBlock={setBlock}
isReadonly={isReadonly} isReadonly={isReadonly}
/> />
) : toolName === 'requestSuggestions' ? ( ) : toolName === 'requestSuggestions' ? (
<DocumentToolResult <DocumentToolCall
type="request-suggestions" type="request-suggestions"
result={result} args={args}
block={block}
setBlock={setBlock}
isReadonly={isReadonly} isReadonly={isReadonly}
/> />
) : ( ) : null}
<pre>{JSON.stringify(result, null, 2)}</pre>
)}
</div> </div>
); );
} })}
return ( </div>
<div )}
key={toolCallId}
className={cx({
skeleton: ['getWeather'].includes(toolName),
})}
>
{toolName === 'getWeather' ? (
<Weather />
) : toolName === 'createDocument' ? (
<DocumentToolCall
type="create"
args={args}
setBlock={setBlock}
isReadonly={isReadonly}
/>
) : toolName === 'updateDocument' ? (
<DocumentToolCall
type="update"
args={args}
setBlock={setBlock}
isReadonly={isReadonly}
/>
) : toolName === 'requestSuggestions' ? (
<DocumentToolCall
type="request-suggestions"
args={args}
setBlock={setBlock}
isReadonly={isReadonly}
/>
) : null}
</div>
);
})}
</div>
)}
{!isReadonly && ( {!isReadonly && (
<MessageActions <MessageActions
key={`action-${message.id}`} key={`action-${message.id}`}
chatId={chatId} chatId={chatId}
message={message} message={message}
vote={vote} vote={vote}
isLoading={isLoading} isLoading={isLoading}
/> />
)} )}
</div>
</div> </div>
</div> </motion.div>
</motion.div> </AnimatePresence>
); );
}; };

View file

@ -2,15 +2,12 @@ import { ChatRequestOptions, Message } from 'ai';
import { PreviewMessage, ThinkingMessage } from './message'; import { PreviewMessage, ThinkingMessage } from './message';
import { useScrollToBottom } from './use-scroll-to-bottom'; import { useScrollToBottom } from './use-scroll-to-bottom';
import { Overview } from './overview'; import { Overview } from './overview';
import { UIBlock } from './block'; import { memo } from 'react';
import { Dispatch, memo, SetStateAction } from 'react';
import { Vote } from '@/lib/db/schema'; import { Vote } from '@/lib/db/schema';
import equal from 'fast-deep-equal'; import equal from 'fast-deep-equal';
interface MessagesProps { interface MessagesProps {
chatId: string; chatId: string;
block: UIBlock;
setBlock: Dispatch<SetStateAction<UIBlock>>;
isLoading: boolean; isLoading: boolean;
votes: Array<Vote> | undefined; votes: Array<Vote> | undefined;
messages: Array<Message>; messages: Array<Message>;
@ -21,12 +18,11 @@ interface MessagesProps {
chatRequestOptions?: ChatRequestOptions, chatRequestOptions?: ChatRequestOptions,
) => Promise<string | null | undefined>; ) => Promise<string | null | undefined>;
isReadonly: boolean; isReadonly: boolean;
isBlockVisible: boolean;
} }
function PureMessages({ function PureMessages({
chatId, chatId,
block,
setBlock,
isLoading, isLoading,
votes, votes,
messages, messages,
@ -42,15 +38,13 @@ function PureMessages({
ref={messagesContainerRef} ref={messagesContainerRef}
className="flex flex-col min-w-0 gap-6 flex-1 overflow-y-scroll pt-4" 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) => ( {messages.map((message, index) => (
<PreviewMessage <PreviewMessage
key={message.id} key={message.id}
chatId={chatId} chatId={chatId}
message={message} message={message}
block={block}
setBlock={setBlock}
isLoading={isLoading && messages.length - 1 === index} isLoading={isLoading && messages.length - 1 === index}
vote={ vote={
votes votes
@ -76,6 +70,8 @@ function PureMessages({
} }
export const Messages = memo(PureMessages, (prevProps, nextProps) => { 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.isLoading && nextProps.isLoading) return false; if (prevProps.isLoading && nextProps.isLoading) return false;
if (prevProps.messages.length !== nextProps.messages.length) 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?', action: 'What is the weather in San Francisco?',
}, },
{ {
title: 'Help me write code to', title: 'Write code that',
label: 'demonstrate the djikstra algorithm!', label: `demonstrates djikstra's algorithm!`,
action: 'Help me write code to demonstrate the djikstra 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 type { UIBlock } from './block';
import { LoaderIcon } from './icons'; import { LoaderIcon } from './icons';
import { Button } from './ui/button'; import { Button } from './ui/button';
import { useBlock } from '@/hooks/use-block';
interface VersionFooterProps { interface VersionFooterProps {
block: UIBlock;
handleVersionChange: (type: 'next' | 'prev' | 'toggle' | 'latest') => void; handleVersionChange: (type: 'next' | 'prev' | 'toggle' | 'latest') => void;
documents: Array<Document> | undefined; documents: Array<Document> | undefined;
currentVersionIndex: number; currentVersionIndex: number;
} }
export const VersionFooter = ({ export const VersionFooter = ({
block,
handleVersionChange, handleVersionChange,
documents, documents,
currentVersionIndex, currentVersionIndex,
}: VersionFooterProps) => { }: VersionFooterProps) => {
const { block } = useBlock();
const { width } = useWindowSize(); const { width } = useWindowSize();
const isMobile = width < 768; 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,28 +1,28 @@
export const blocksPrompt = ` 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. 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. This is a guide for using blocks tools: \`createDocument\` and \`updateDocument\`, which render content on a blocks beside the conversation.
**When to use \`createDocument\`:** **When to use \`createDocument\`:**
- For substantial content (>10 lines) or code - For substantial content (>10 lines) or code
- For content users will likely save/reuse (emails, code, essays, etc.) - For content users will likely save/reuse (emails, code, essays, etc.)
- When explicitly requested to create a document - When explicitly requested to create a document
- For when content contains a single code snippet - For when content contains a single code snippet
**When NOT to use \`createDocument\`:** **When NOT to use \`createDocument\`:**
- For informational/explanatory content - For informational/explanatory content
- For conversational responses - For conversational responses
- When asked to keep it in chat - When asked to keep it in chat
**Using \`updateDocument\`:** **Using \`updateDocument\`:**
- Default to full document rewrites for major changes - Default to full document rewrites for major changes
- Use targeted updates only for specific, isolated changes - Use targeted updates only for specific, isolated changes
- Follow user instructions for which parts to modify - Follow user instructions for which parts to modify
Do not update document right after creating it. Wait for user feedback or request to update it. Do not update document right after creating it. Wait for user feedback or request to update it.
`; `;
export const regularPrompt = export const regularPrompt =
'You are a friendly assistant! Keep your responses concise and helpful.'; 'You are a friendly assistant! Keep your responses concise and helpful.';

View file

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

71
pnpm-lock.yaml generated
View file

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