feat: add inline block document previews (#637)
This commit is contained in:
parent
b659dcce68
commit
bb03c516b5
24 changed files with 1122 additions and 805 deletions
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -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;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
117
components/data-stream-handler.tsx
Normal file
117
components/data-stream-handler.tsx
Normal 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;
|
||||
}
|
||||
245
components/document-preview.tsx
Normal file
245
components/document-preview.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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!`,
|
||||
},
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue