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

@ -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) => ({
...currentBlock,
isVisible: false,
}));
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;
}
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);

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,274 +263,302 @@ function PureBlock({
const isMobile = windowWidth ? windowWidth < 768 : false;
return (
<motion.div
className="flex flex-row h-dvh w-dvw fixed top-0 left-0 z-50 bg-muted"
initial={{ opacity: 1 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0, transition: { delay: 0.4 } }}
>
{!isMobile && (
<AnimatePresence>
{block.isVisible && (
<motion.div
className="relative w-[400px] bg-muted dark:bg-background h-dvh shrink-0"
initial={{ opacity: 0, x: 10, scale: 1 }}
animate={{
opacity: 1,
x: 0,
scale: 1,
transition: {
delay: 0.2,
type: 'spring',
stiffness: 200,
damping: 30,
},
}}
exit={{
opacity: 0,
x: 0,
scale: 0.95,
transition: { delay: 0 },
}}
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 } }}
>
<AnimatePresence>
{!isCurrentVersion && (
<motion.div
className="left-0 absolute h-dvh w-[400px] top-0 bg-zinc-900/50 z-50"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
/>
)}
</AnimatePresence>
<div className="flex flex-col h-full justify-between items-center gap-4">
<BlockMessages
chatId={chatId}
block={block}
isLoading={isLoading}
setBlock={setBlock}
votes={votes}
messages={messages}
setMessages={setMessages}
reload={reload}
isReadonly={isReadonly}
{!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,
}}
/>
)}
<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"
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
? {
{!isMobile && (
<motion.div
className="relative w-[400px] bg-muted dark:bg-background h-dvh shrink-0"
initial={{ opacity: 0, x: 10, scale: 1 }}
animate={{
opacity: 1,
x: 0,
y: 0,
width: windowWidth,
height: '100dvh',
borderRadius: 0,
scale: 1,
transition: {
delay: 0,
delay: 0.2,
type: 'spring',
stiffness: 200,
damping: 30,
},
}
: {
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,
},
}
}
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 setBlock={setBlock} />
}}
exit={{
opacity: 0,
x: 0,
scale: 1,
transition: { duration: 0 },
}}
>
<AnimatePresence>
{!isCurrentVersion && (
<motion.div
className="left-0 absolute h-dvh w-[400px] top-0 bg-zinc-900/50 z-50"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
/>
)}
</AnimatePresence>
<div className="flex flex-col">
<div className="font-medium">
{document?.title ?? block.title}
<div className="flex flex-col h-full justify-between items-center gap-4">
<BlockMessages
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>
{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>
<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}
<BlockActions
block={block}
currentVersionIndex={currentVersionIndex}
suggestions={suggestions ?? []}
status={block.status}
saveContent={saveContent}
handleVersionChange={handleVersionChange}
isCurrentVersion={isCurrentVersion}
mode={mode}
setConsoleOutputs={setConsoleOutputs}
/>
) : 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}
</div>
{suggestions ? (
<div className="md:hidden h-dvh w-12 shrink-0" />
) : null}
<div
className={cn(
'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>
{isCurrentVersion && (
<Toolbar
isToolbarVisible={isToolbarVisible}
setIsToolbarVisible={setIsToolbarVisible}
append={append}
isLoading={isLoading}
stop={stop}
setMessages={setMessages}
blockKind={block.kind}
{!isCurrentVersion && (
<VersionFooter
currentVersionIndex={currentVersionIndex}
documents={documents}
handleVersionChange={handleVersionChange}
/>
)}
</AnimatePresence>
</div>
</div>
<AnimatePresence>
{!isCurrentVersion && (
<VersionFooter
block={block}
currentVersionIndex={currentVersionIndex}
documents={documents}
handleVersionChange={handleVersionChange}
/>
)}
</AnimatePresence>
<AnimatePresence>
<Console
consoleOutputs={consoleOutputs}
setConsoleOutputs={setConsoleOutputs}
/>
</AnimatePresence>
</motion.div>
</motion.div>
<AnimatePresence>
<Console
consoleOutputs={consoleOutputs}
setConsoleOutputs={setConsoleOutputs}
/>
</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,30 +98,22 @@ export function Chat({
</form>
</div>
<AnimatePresence>
{block?.isVisible && (
<Block
chatId={id}
input={input}
setInput={setInput}
handleSubmit={handleSubmit}
isLoading={isLoading}
stop={stop}
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} />
<Block
chatId={id}
input={input}
setInput={setInput}
handleSubmit={handleSubmit}
isLoading={isLoading}
stop={stop}
attachments={attachments}
setAttachments={setAttachments}
append={append}
messages={messages}
setMessages={setMessages}
reload={reload}
votes={votes}
isReadonly={isReadonly}
/>
</>
);
}

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,101 +1,103 @@
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,
pre: ({ children }) => <>{children}</>,
ol: ({ node, children, ...props }) => {
return (
<ol className="list-decimal list-outside ml-4" {...props}>
{children}
</ol>
);
},
li: ({ node, children, ...props }) => {
return (
<li className="py-1" {...props}>
{children}
</li>
);
},
ul: ({ node, children, ...props }) => {
return (
<ul className="list-decimal list-outside ml-4" {...props}>
{children}
</ul>
);
},
strong: ({ node, children, ...props }) => {
return (
<span className="font-semibold" {...props}>
{children}
</span>
);
},
a: ({ node, children, ...props }) => {
return (
// @ts-expect-error
<Link
className="text-blue-500 hover:underline"
target="_blank"
rel="noreferrer"
{...props}
>
{children}
</Link>
);
},
h1: ({ node, children, ...props }) => {
return (
<h1 className="text-3xl font-semibold mt-6 mb-2" {...props}>
{children}
</h1>
);
},
h2: ({ node, children, ...props }) => {
return (
<h2 className="text-2xl font-semibold mt-6 mb-2" {...props}>
{children}
</h2>
);
},
h3: ({ node, children, ...props }) => {
return (
<h3 className="text-xl font-semibold mt-6 mb-2" {...props}>
{children}
</h3>
);
},
h4: ({ node, children, ...props }) => {
return (
<h4 className="text-lg font-semibold mt-6 mb-2" {...props}>
{children}
</h4>
);
},
h5: ({ node, children, ...props }) => {
return (
<h5 className="text-base font-semibold mt-6 mb-2" {...props}>
{children}
</h5>
);
},
h6: ({ node, children, ...props }) => {
return (
<h6 className="text-sm font-semibold mt-6 mb-2" {...props}>
{children}
</h6>
);
},
};
const components: Partial<Components> = {
// @ts-expect-error
code: CodeBlock,
pre: ({ children }) => <>{children}</>,
ol: ({ node, children, ...props }) => {
return (
<ol className="list-decimal list-outside ml-4" {...props}>
{children}
</ol>
);
},
li: ({ node, children, ...props }) => {
return (
<li className="py-1" {...props}>
{children}
</li>
);
},
ul: ({ node, children, ...props }) => {
return (
<ul className="list-decimal list-outside ml-4" {...props}>
{children}
</ul>
);
},
strong: ({ node, children, ...props }) => {
return (
<span className="font-semibold" {...props}>
{children}
</span>
);
},
a: ({ node, children, ...props }) => {
return (
// @ts-expect-error
<Link
className="text-blue-500 hover:underline"
target="_blank"
rel="noreferrer"
{...props}
>
{children}
</Link>
);
},
h1: ({ node, children, ...props }) => {
return (
<h1 className="text-3xl font-semibold mt-6 mb-2" {...props}>
{children}
</h1>
);
},
h2: ({ node, children, ...props }) => {
return (
<h2 className="text-2xl font-semibold mt-6 mb-2" {...props}>
{children}
</h2>
);
},
h3: ({ node, children, ...props }) => {
return (
<h3 className="text-xl font-semibold mt-6 mb-2" {...props}>
{children}
</h3>
);
},
h4: ({ node, children, ...props }) => {
return (
<h4 className="text-lg font-semibold mt-6 mb-2" {...props}>
{children}
</h4>
);
},
h5: ({ node, children, ...props }) => {
return (
<h5 className="text-base font-semibold mt-6 mb-2" {...props}>
{children}
</h5>
);
},
h6: ({ node, children, ...props }) => {
return (
<h6 className="text-sm font-semibold mt-6 mb-2" {...props}>
{children}
</h6>
);
},
};
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,174 +44,162 @@ const PurePreviewMessage = ({
const [mode, setMode] = useState<'view' | 'edit'>('view');
return (
<motion.div
className="w-full mx-auto max-w-3xl px-4 group/message"
initial={{ y: 5, opacity: 0 }}
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',
},
)}
<AnimatePresence>
<motion.div
className="w-full mx-auto max-w-3xl px-4 group/message"
initial={{ y: 5, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
data-role={message.role}
>
{message.role === 'assistant' && (
<div className="size-8 flex items-center rounded-full justify-center ring-1 shrink-0 ring-border bg-background">
<SparklesIcon size={14} />
</div>
)}
<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) => (
<PreviewAttachment
key={attachment.url}
attachment={attachment}
/>
))}
<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 className="size-8 flex items-center rounded-full justify-center ring-1 shrink-0 ring-border bg-background">
<SparklesIcon size={14} />
</div>
)}
{message.content && mode === 'view' && (
<div className="flex flex-row gap-2 items-start">
{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>
)}
<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 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) => (
<PreviewAttachment
key={attachment.url}
attachment={attachment}
/>
))}
</div>
</div>
)}
)}
{message.content && mode === 'edit' && (
<div className="flex flex-row gap-2 items-start">
<div className="size-8" />
{message.content && mode === 'view' && (
<div className="flex flex-row gap-2 items-start">
{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
key={message.id}
message={message}
setMode={setMode}
setMessages={setMessages}
reload={reload}
/>
</div>
)}
<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>
)}
{message.toolInvocations && message.toolInvocations.length > 0 && (
<div className="flex flex-col gap-4">
{message.toolInvocations.map((toolInvocation) => {
const { toolName, toolCallId, state, args } = toolInvocation;
{message.content && mode === 'edit' && (
<div className="flex flex-row gap-2 items-start">
<div className="size-8" />
if (state === 'result') {
const { result } = toolInvocation;
<MessageEditor
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 (
<div key={toolCallId}>
<div
key={toolCallId}
className={cx({
skeleton: ['getWeather'].includes(toolName),
})}
>
{toolName === 'getWeather' ? (
<Weather weatherAtLocation={result} />
<Weather />
) : toolName === 'createDocument' ? (
<DocumentToolResult
type="create"
result={result}
block={block}
setBlock={setBlock}
isReadonly={isReadonly}
/>
<DocumentPreview isReadonly={isReadonly} args={args} />
) : toolName === 'updateDocument' ? (
<DocumentToolResult
<DocumentToolCall
type="update"
result={result}
block={block}
setBlock={setBlock}
args={args}
isReadonly={isReadonly}
/>
) : toolName === 'requestSuggestions' ? (
<DocumentToolResult
<DocumentToolCall
type="request-suggestions"
result={result}
block={block}
setBlock={setBlock}
args={args}
isReadonly={isReadonly}
/>
) : (
<pre>{JSON.stringify(result, null, 2)}</pre>
)}
) : null}
</div>
);
}
return (
<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>
)}
})}
</div>
)}
{!isReadonly && (
<MessageActions
key={`action-${message.id}`}
chatId={chatId}
message={message}
vote={vote}
isLoading={isLoading}
/>
)}
{!isReadonly && (
<MessageActions
key={`action-${message.id}`}
chatId={chatId}
message={message}
vote={vote}
isLoading={isLoading}
/>
)}
</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 { 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;