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
|
|
@ -3,6 +3,7 @@ import { cookies } from 'next/headers';
|
|||
import { Chat } from '@/components/chat';
|
||||
import { DEFAULT_MODEL_NAME, models } from '@/lib/ai/models';
|
||||
import { generateUUID } from '@/lib/utils';
|
||||
import { DataStreamHandler } from '@/components/data-stream-handler';
|
||||
|
||||
export default async function Page() {
|
||||
const id = generateUUID();
|
||||
|
|
@ -15,6 +16,7 @@ export default async function Page() {
|
|||
DEFAULT_MODEL_NAME;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Chat
|
||||
key={id}
|
||||
id={id}
|
||||
|
|
@ -23,5 +25,7 @@ export default async function Page() {
|
|||
selectedVisibilityType="private"
|
||||
isReadonly={false}
|
||||
/>
|
||||
<DataStreamHandler id={id} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,7 +45,10 @@
|
|||
"noSvgWithoutTitle": "off", // We do not intend to adhere to this rule
|
||||
"useMediaCaption": "off", // We would need a cultural change to turn this on
|
||||
"noAutofocus": "off", // We're highly intentional about when we use autofocus
|
||||
"noBlankTarget": "off" // Covered by Conformance
|
||||
"noBlankTarget": "off", // Covered by Conformance
|
||||
"useFocusableInteractive": "off", // Disable focusable interactive element requirement
|
||||
"useAriaPropsForRole": "off", // Disable required ARIA attributes check
|
||||
"useKeyWithClickEvents": "off" // Disable keyboard event requirement with click events
|
||||
},
|
||||
"complexity": {
|
||||
"noUselessStringConcat": "warn", // Not in recommended ruleset, turning on manually
|
||||
|
|
|
|||
|
|
@ -1,23 +1,24 @@
|
|||
import { memo, SetStateAction } from 'react';
|
||||
import { memo } from 'react';
|
||||
import { CrossIcon } from './icons';
|
||||
import { Button } from './ui/button';
|
||||
import { UIBlock } from './block';
|
||||
import equal from 'fast-deep-equal';
|
||||
import { initialBlockData, useBlock } from '@/hooks/use-block';
|
||||
|
||||
interface BlockCloseButtonProps {
|
||||
setBlock: (value: SetStateAction<UIBlock>) => void;
|
||||
}
|
||||
function PureBlockCloseButton() {
|
||||
const { setBlock } = useBlock();
|
||||
|
||||
function PureBlockCloseButton({ setBlock }: BlockCloseButtonProps) {
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-fit p-2 dark:hover:bg-zinc-700"
|
||||
onClick={() => {
|
||||
setBlock((currentBlock) => ({
|
||||
setBlock((currentBlock) =>
|
||||
currentBlock.status === 'streaming'
|
||||
? {
|
||||
...currentBlock,
|
||||
isVisible: false,
|
||||
}));
|
||||
}
|
||||
: { ...initialBlockData, status: 'idle' },
|
||||
);
|
||||
}}
|
||||
>
|
||||
<CrossIcon size={18} />
|
||||
|
|
|
|||
|
|
@ -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,12 +263,29 @@ function PureBlock({
|
|||
const isMobile = windowWidth ? windowWidth < 768 : false;
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{block.isVisible && (
|
||||
<motion.div
|
||||
className="flex flex-row h-dvh w-dvw fixed top-0 left-0 z-50 bg-muted"
|
||||
className="flex flex-row h-dvh w-dvw fixed top-0 left-0 z-50 bg-transparent"
|
||||
initial={{ opacity: 1 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0, transition: { delay: 0.4 } }}
|
||||
>
|
||||
{!isMobile && (
|
||||
<motion.div
|
||||
className="fixed bg-background h-dvh"
|
||||
initial={{
|
||||
width: isSidebarOpen ? windowWidth - 256 : windowWidth,
|
||||
right: 0,
|
||||
}}
|
||||
animate={{ width: windowWidth, right: 0 }}
|
||||
exit={{
|
||||
width: isSidebarOpen ? windowWidth - 256 : windowWidth,
|
||||
right: 0,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isMobile && (
|
||||
<motion.div
|
||||
className="relative w-[400px] bg-muted dark:bg-background h-dvh shrink-0"
|
||||
|
|
@ -284,8 +304,8 @@ function PureBlock({
|
|||
exit={{
|
||||
opacity: 0,
|
||||
x: 0,
|
||||
scale: 0.95,
|
||||
transition: { delay: 0 },
|
||||
scale: 1,
|
||||
transition: { duration: 0 },
|
||||
}}
|
||||
>
|
||||
<AnimatePresence>
|
||||
|
|
@ -302,14 +322,13 @@ function PureBlock({
|
|||
<div className="flex flex-col h-full justify-between items-center gap-4">
|
||||
<BlockMessages
|
||||
chatId={chatId}
|
||||
block={block}
|
||||
isLoading={isLoading}
|
||||
setBlock={setBlock}
|
||||
votes={votes}
|
||||
messages={messages}
|
||||
setMessages={setMessages}
|
||||
reload={reload}
|
||||
isReadonly={isReadonly}
|
||||
blockStatus={block.status}
|
||||
/>
|
||||
|
||||
<form className="flex flex-row gap-2 relative items-end w-full px-4 pb-4">
|
||||
|
|
@ -333,19 +352,19 @@ function PureBlock({
|
|||
)}
|
||||
|
||||
<motion.div
|
||||
className="fixed dark:bg-muted bg-background h-dvh flex flex-col overflow-y-scroll"
|
||||
className="fixed dark:bg-muted bg-background h-dvh flex flex-col overflow-y-scroll border-l dark:border-zinc-700 border-zinc-200"
|
||||
initial={
|
||||
isMobile
|
||||
? {
|
||||
opacity: 0,
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: windowWidth,
|
||||
height: windowHeight,
|
||||
opacity: 1,
|
||||
x: block.boundingBox.left,
|
||||
y: block.boundingBox.top,
|
||||
height: block.boundingBox.height,
|
||||
width: block.boundingBox.width,
|
||||
borderRadius: 50,
|
||||
}
|
||||
: {
|
||||
opacity: 0,
|
||||
opacity: 1,
|
||||
x: block.boundingBox.left,
|
||||
y: block.boundingBox.top,
|
||||
height: block.boundingBox.height,
|
||||
|
|
@ -359,14 +378,15 @@ function PureBlock({
|
|||
opacity: 1,
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: windowWidth,
|
||||
height: '100dvh',
|
||||
height: windowHeight,
|
||||
width: windowWidth ? windowWidth : 'calc(100dvw)',
|
||||
borderRadius: 0,
|
||||
transition: {
|
||||
delay: 0,
|
||||
type: 'spring',
|
||||
stiffness: 200,
|
||||
damping: 30,
|
||||
duration: 5000,
|
||||
},
|
||||
}
|
||||
: {
|
||||
|
|
@ -374,13 +394,16 @@ function PureBlock({
|
|||
x: 400,
|
||||
y: 0,
|
||||
height: windowHeight,
|
||||
width: windowWidth ? windowWidth - 400 : 'calc(100dvw-400px)',
|
||||
width: windowWidth
|
||||
? windowWidth - 400
|
||||
: 'calc(100dvw-400px)',
|
||||
borderRadius: 0,
|
||||
transition: {
|
||||
delay: 0,
|
||||
type: 'spring',
|
||||
stiffness: 200,
|
||||
damping: 30,
|
||||
duration: 5000,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -397,7 +420,7 @@ function PureBlock({
|
|||
>
|
||||
<div className="p-2 flex flex-row justify-between items-start">
|
||||
<div className="flex flex-row gap-4 items-start">
|
||||
<BlockCloseButton setBlock={setBlock} />
|
||||
<BlockCloseButton />
|
||||
|
||||
<div className="flex flex-col">
|
||||
<div className="font-medium">
|
||||
|
|
@ -436,7 +459,7 @@ function PureBlock({
|
|||
|
||||
<div
|
||||
className={cn(
|
||||
'prose dark:prose-invert dark:bg-muted bg-background h-full overflow-y-scroll !max-w-full pb-40 items-center',
|
||||
'dark:bg-muted bg-background h-full overflow-y-scroll !max-w-full pb-40 items-center',
|
||||
{
|
||||
'py-2 px-2': block.kind === 'code',
|
||||
'py-8 md:p-20 px-4': block.kind === 'text',
|
||||
|
|
@ -480,7 +503,9 @@ function PureBlock({
|
|||
/>
|
||||
) : (
|
||||
<DiffView
|
||||
oldContent={getDocumentContentById(currentVersionIndex - 1)}
|
||||
oldContent={getDocumentContentById(
|
||||
currentVersionIndex - 1,
|
||||
)}
|
||||
newContent={getDocumentContentById(currentVersionIndex)}
|
||||
/>
|
||||
)
|
||||
|
|
@ -509,7 +534,6 @@ function PureBlock({
|
|||
<AnimatePresence>
|
||||
{!isCurrentVersion && (
|
||||
<VersionFooter
|
||||
block={block}
|
||||
currentVersionIndex={currentVersionIndex}
|
||||
documents={documents}
|
||||
handleVersionChange={handleVersionChange}
|
||||
|
|
@ -525,9 +549,16 @@ function PureBlock({
|
|||
</AnimatePresence>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
export const Block = memo(PureBlock, (prevProps, nextProps) => {
|
||||
return false;
|
||||
if (prevProps.isLoading !== nextProps.isLoading) return false;
|
||||
if (!equal(prevProps.votes, nextProps.votes)) return false;
|
||||
if (prevProps.input !== nextProps.input) return false;
|
||||
if (!equal(prevProps.messages, nextProps.messages.length)) return false;
|
||||
|
||||
return true;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,20 +2,18 @@
|
|||
|
||||
import type { Attachment, Message } from 'ai';
|
||||
import { useChat } from 'ai/react';
|
||||
import { AnimatePresence } from 'framer-motion';
|
||||
import { useState } from 'react';
|
||||
import useSWR, { useSWRConfig } from 'swr';
|
||||
import { useWindowSize } from 'usehooks-ts';
|
||||
|
||||
import { ChatHeader } from '@/components/chat-header';
|
||||
import type { Vote } from '@/lib/db/schema';
|
||||
import { fetcher } from '@/lib/utils';
|
||||
|
||||
import { Block, type UIBlock } from './block';
|
||||
import { BlockStreamHandler } from './block-stream-handler';
|
||||
import { Block } from './block';
|
||||
import { MultimodalInput } from './multimodal-input';
|
||||
import { Messages } from './messages';
|
||||
import { VisibilityType } from './visibility-selector';
|
||||
import { useBlockSelector } from '@/hooks/use-block';
|
||||
|
||||
export function Chat({
|
||||
id,
|
||||
|
|
@ -42,40 +40,23 @@ export function Chat({
|
|||
isLoading,
|
||||
stop,
|
||||
reload,
|
||||
data: streamingData,
|
||||
} = useChat({
|
||||
id,
|
||||
body: { id, modelId: selectedModelId },
|
||||
initialMessages,
|
||||
experimental_throttle: 100,
|
||||
onFinish: () => {
|
||||
mutate('/api/history');
|
||||
},
|
||||
});
|
||||
|
||||
const { width: windowWidth = 1920, height: windowHeight = 1080 } =
|
||||
useWindowSize();
|
||||
|
||||
const [block, setBlock] = useState<UIBlock>({
|
||||
documentId: 'init',
|
||||
content: '',
|
||||
kind: 'text',
|
||||
title: '',
|
||||
status: 'idle',
|
||||
isVisible: false,
|
||||
boundingBox: {
|
||||
top: windowHeight / 4,
|
||||
left: windowWidth / 4,
|
||||
width: 250,
|
||||
height: 50,
|
||||
},
|
||||
});
|
||||
|
||||
const { data: votes } = useSWR<Array<Vote>>(
|
||||
`/api/vote?chatId=${id}`,
|
||||
fetcher,
|
||||
);
|
||||
|
||||
const [attachments, setAttachments] = useState<Array<Attachment>>([]);
|
||||
const isBlockVisible = useBlockSelector((state) => state.isVisible);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -89,14 +70,13 @@ export function Chat({
|
|||
|
||||
<Messages
|
||||
chatId={id}
|
||||
block={block}
|
||||
setBlock={setBlock}
|
||||
isLoading={isLoading}
|
||||
votes={votes}
|
||||
messages={messages}
|
||||
setMessages={setMessages}
|
||||
reload={reload}
|
||||
isReadonly={isReadonly}
|
||||
isBlockVisible={isBlockVisible}
|
||||
/>
|
||||
|
||||
<form className="flex mx-auto px-4 bg-background pb-4 md:pb-6 gap-2 w-full md:max-w-3xl">
|
||||
|
|
@ -118,8 +98,6 @@ export function Chat({
|
|||
</form>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{block?.isVisible && (
|
||||
<Block
|
||||
chatId={id}
|
||||
input={input}
|
||||
|
|
@ -130,18 +108,12 @@ export function Chat({
|
|||
attachments={attachments}
|
||||
setAttachments={setAttachments}
|
||||
append={append}
|
||||
block={block}
|
||||
setBlock={setBlock}
|
||||
messages={messages}
|
||||
setMessages={setMessages}
|
||||
reload={reload}
|
||||
votes={votes}
|
||||
isReadonly={isReadonly}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<BlockStreamHandler streamingData={streamingData} setBlock={setBlock} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,11 +1,10 @@
|
|||
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> = {
|
||||
const components: Partial<Components> = {
|
||||
// @ts-expect-error
|
||||
code: CodeBlock,
|
||||
pre: ({ children }) => <>{children}</>,
|
||||
|
|
@ -92,10 +91,13 @@ const NonMemoizedMarkdown = ({ children }: { children: string }) => {
|
|||
</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,6 +44,7 @@ const PurePreviewMessage = ({
|
|||
const [mode, setMode] = useState<'view' | 'edit'>('view');
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
className="w-full mx-auto max-w-3xl px-4 group/message"
|
||||
initial={{ y: 5, opacity: 0 }}
|
||||
|
|
@ -138,27 +135,20 @@ const PurePreviewMessage = ({
|
|||
{toolName === 'getWeather' ? (
|
||||
<Weather weatherAtLocation={result} />
|
||||
) : toolName === 'createDocument' ? (
|
||||
<DocumentToolResult
|
||||
type="create"
|
||||
result={result}
|
||||
block={block}
|
||||
setBlock={setBlock}
|
||||
<DocumentPreview
|
||||
isReadonly={isReadonly}
|
||||
result={result}
|
||||
/>
|
||||
) : toolName === 'updateDocument' ? (
|
||||
<DocumentToolResult
|
||||
type="update"
|
||||
result={result}
|
||||
block={block}
|
||||
setBlock={setBlock}
|
||||
isReadonly={isReadonly}
|
||||
/>
|
||||
) : toolName === 'requestSuggestions' ? (
|
||||
<DocumentToolResult
|
||||
type="request-suggestions"
|
||||
result={result}
|
||||
block={block}
|
||||
setBlock={setBlock}
|
||||
isReadonly={isReadonly}
|
||||
/>
|
||||
) : (
|
||||
|
|
@ -177,24 +167,17 @@ const PurePreviewMessage = ({
|
|||
{toolName === 'getWeather' ? (
|
||||
<Weather />
|
||||
) : toolName === 'createDocument' ? (
|
||||
<DocumentToolCall
|
||||
type="create"
|
||||
args={args}
|
||||
setBlock={setBlock}
|
||||
isReadonly={isReadonly}
|
||||
/>
|
||||
<DocumentPreview isReadonly={isReadonly} args={args} />
|
||||
) : toolName === 'updateDocument' ? (
|
||||
<DocumentToolCall
|
||||
type="update"
|
||||
args={args}
|
||||
setBlock={setBlock}
|
||||
isReadonly={isReadonly}
|
||||
/>
|
||||
) : toolName === 'requestSuggestions' ? (
|
||||
<DocumentToolCall
|
||||
type="request-suggestions"
|
||||
args={args}
|
||||
setBlock={setBlock}
|
||||
isReadonly={isReadonly}
|
||||
/>
|
||||
) : null}
|
||||
|
|
@ -216,6 +199,7 @@ const PurePreviewMessage = ({
|
|||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
68
hooks/use-block.ts
Normal file
68
hooks/use-block.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
'use client';
|
||||
|
||||
import { UIBlock } from '@/components/block';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import useSWR from 'swr';
|
||||
|
||||
export const initialBlockData: UIBlock = {
|
||||
documentId: 'init',
|
||||
content: '',
|
||||
kind: 'text',
|
||||
title: '',
|
||||
status: 'idle',
|
||||
isVisible: false,
|
||||
boundingBox: {
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
},
|
||||
};
|
||||
|
||||
// Add type for selector function
|
||||
type Selector<T> = (state: UIBlock) => T;
|
||||
|
||||
export function useBlockSelector<Selected>(selector: Selector<Selected>) {
|
||||
const { data: localBlock } = useSWR<UIBlock>('block', null, {
|
||||
fallbackData: initialBlockData,
|
||||
});
|
||||
|
||||
const selectedValue = useMemo(() => {
|
||||
if (!localBlock) return selector(initialBlockData);
|
||||
return selector(localBlock);
|
||||
}, [localBlock, selector]);
|
||||
|
||||
return selectedValue;
|
||||
}
|
||||
|
||||
export function useBlock() {
|
||||
const { data: localBlock, mutate: setLocalBlock } = useSWR<UIBlock>(
|
||||
'block',
|
||||
null,
|
||||
{
|
||||
fallbackData: initialBlockData,
|
||||
},
|
||||
);
|
||||
|
||||
const block = useMemo(() => {
|
||||
if (!localBlock) return initialBlockData;
|
||||
return localBlock;
|
||||
}, [localBlock]);
|
||||
|
||||
const setBlock = useCallback(
|
||||
(updaterFn: UIBlock | ((currentBlock: UIBlock) => UIBlock)) => {
|
||||
setLocalBlock((currentBlock) => {
|
||||
const blockToUpdate = currentBlock || initialBlockData;
|
||||
|
||||
if (typeof updaterFn === 'function') {
|
||||
return updaterFn(blockToUpdate);
|
||||
}
|
||||
|
||||
return updaterFn;
|
||||
});
|
||||
},
|
||||
[setLocalBlock],
|
||||
);
|
||||
|
||||
return useMemo(() => ({ block, setBlock }), [block, setBlock]);
|
||||
}
|
||||
|
|
@ -1,28 +1,28 @@
|
|||
export const blocksPrompt = `
|
||||
Blocks is a special user interface mode that helps users with writing, editing, and other content creation tasks. When block is open, it is on the right side of the screen, while the conversation is on the left side. When creating or updating documents, changes are reflected in real-time on the blocks and visible to the user.
|
||||
Blocks is a special user interface mode that helps users with writing, editing, and other content creation tasks. When block is open, it is on the right side of the screen, while the conversation is on the left side. When creating or updating documents, changes are reflected in real-time on the blocks and visible to the user.
|
||||
|
||||
When writing code, specify the language in the backticks, e.g. \`\`\`python\`code here\`\`\`. The default language is Python. Other languages are not yet supported, so let the user know if they request a different language.
|
||||
When asked to write code, always use blocks. When writing code, specify the language in the backticks, e.g. \`\`\`python\`code here\`\`\`. The default language is Python. Other languages are not yet supported, so let the user know if they request a different language.
|
||||
|
||||
This is a guide for using blocks tools: \`createDocument\` and \`updateDocument\`, which render content on a blocks beside the conversation.
|
||||
This is a guide for using blocks tools: \`createDocument\` and \`updateDocument\`, which render content on a blocks beside the conversation.
|
||||
|
||||
**When to use \`createDocument\`:**
|
||||
- For substantial content (>10 lines) or code
|
||||
- For content users will likely save/reuse (emails, code, essays, etc.)
|
||||
- When explicitly requested to create a document
|
||||
- For when content contains a single code snippet
|
||||
**When to use \`createDocument\`:**
|
||||
- For substantial content (>10 lines) or code
|
||||
- For content users will likely save/reuse (emails, code, essays, etc.)
|
||||
- When explicitly requested to create a document
|
||||
- For when content contains a single code snippet
|
||||
|
||||
**When NOT to use \`createDocument\`:**
|
||||
- For informational/explanatory content
|
||||
- For conversational responses
|
||||
- When asked to keep it in chat
|
||||
**When NOT to use \`createDocument\`:**
|
||||
- For informational/explanatory content
|
||||
- For conversational responses
|
||||
- When asked to keep it in chat
|
||||
|
||||
**Using \`updateDocument\`:**
|
||||
- Default to full document rewrites for major changes
|
||||
- Use targeted updates only for specific, isolated changes
|
||||
- Follow user instructions for which parts to modify
|
||||
**Using \`updateDocument\`:**
|
||||
- Default to full document rewrites for major changes
|
||||
- Use targeted updates only for specific, isolated changes
|
||||
- Follow user instructions for which parts to modify
|
||||
|
||||
Do not update document right after creating it. Wait for user feedback or request to update it.
|
||||
`;
|
||||
Do not update document right after creating it. Wait for user feedback or request to update it.
|
||||
`;
|
||||
|
||||
export const regularPrompt =
|
||||
'You are a friendly assistant! Keep your responses concise and helpful.';
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@
|
|||
"@vercel/analytics": "^1.3.1",
|
||||
"@vercel/blob": "^0.24.1",
|
||||
"@vercel/postgres": "^0.10.0",
|
||||
"ai": "4.0.10",
|
||||
"ai": "4.0.20",
|
||||
"bcrypt-ts": "^5.0.2",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"classnames": "^2.5.1",
|
||||
|
|
|
|||
71
pnpm-lock.yaml
generated
71
pnpm-lock.yaml
generated
|
|
@ -66,8 +66,8 @@ importers:
|
|||
specifier: ^0.10.0
|
||||
version: 0.10.0
|
||||
ai:
|
||||
specifier: 4.0.10
|
||||
version: 4.0.10(react@19.0.0-rc-45804af1-20241021)(zod@3.23.8)
|
||||
specifier: 4.0.20
|
||||
version: 4.0.20(react@19.0.0-rc-45804af1-20241021)(zod@3.23.8)
|
||||
bcrypt-ts:
|
||||
specifier: ^5.0.2
|
||||
version: 5.0.2
|
||||
|
|
@ -255,12 +255,25 @@ packages:
|
|||
zod:
|
||||
optional: true
|
||||
|
||||
'@ai-sdk/provider-utils@2.0.4':
|
||||
resolution: {integrity: sha512-GMhcQCZbwM6RoZCri0MWeEWXRt/T+uCxsmHEsTwNvEH3GDjNzchfX25C8ftry2MeEOOn6KfqCLSKomcgK6RoOg==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
zod: ^3.0.0
|
||||
peerDependenciesMeta:
|
||||
zod:
|
||||
optional: true
|
||||
|
||||
'@ai-sdk/provider@1.0.1':
|
||||
resolution: {integrity: sha512-mV+3iNDkzUsZ0pR2jG0sVzU6xtQY5DtSCBy3JFycLp6PwjyLw/iodfL3MwdmMCRJWgs3dadcHejRnMvF9nGTBg==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@ai-sdk/react@1.0.3':
|
||||
resolution: {integrity: sha512-Mak7qIRlbgtP4I7EFoNKRIQTlABJHhgwrN8SV2WKKdmsfWK2RwcubQWz1hp88cQ0bpF6KxxjSY1UUnS/S9oR5g==}
|
||||
'@ai-sdk/provider@1.0.2':
|
||||
resolution: {integrity: sha512-YYtP6xWQyaAf5LiWLJ+ycGTOeBLWrED7LUrvc+SQIWhGaneylqbaGsyQL7VouQUeQ4JZ1qKYZuhmi3W56HADPA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@ai-sdk/react@1.0.6':
|
||||
resolution: {integrity: sha512-8Hkserq0Ge6AEi7N4hlv2FkfglAGbkoAXEZ8YSp255c3PbnZz6+/5fppw+aROmZMOfNwallSRuy1i/iPa2rBpQ==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
react: ^18 || ^19 || ^19.0.0-rc
|
||||
|
|
@ -271,8 +284,8 @@ packages:
|
|||
zod:
|
||||
optional: true
|
||||
|
||||
'@ai-sdk/ui-utils@1.0.2':
|
||||
resolution: {integrity: sha512-hHrUdeThGHu/rsGZBWQ9PjrAU9Htxgbo9MFyR5B/aWoNbBeXn1HLMY1+uMEnXL5pRPlmyVRjgIavWg7UgeNDOw==}
|
||||
'@ai-sdk/ui-utils@1.0.5':
|
||||
resolution: {integrity: sha512-DGJSbDf+vJyWmFNexSPUsS1AAy7gtsmFmoSyNbNbJjwl9hRIf2dknfA1V0ahx6pg3NNklNYFm53L8Nphjovfvg==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
zod: ^3.0.0
|
||||
|
|
@ -1613,8 +1626,8 @@ packages:
|
|||
engines: {node: '>=0.4.0'}
|
||||
hasBin: true
|
||||
|
||||
ai@4.0.10:
|
||||
resolution: {integrity: sha512-40GaEGLbp7if1F50zp3Kr03vcqyGS8svyJWpbkgec7G5Ik2rEtnbDWiUoOJuAVqgP5/iy4NgZQfvX3jRmOyQrw==}
|
||||
ai@4.0.20:
|
||||
resolution: {integrity: sha512-dYevYKtREcjSVopBDFWVNca7WJEI1p9Vr9eo7V7fZHzi2vXGDyEa2WYatjFbpR6z6gpVAxKHsof8EoN+B1IAsA==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
react: ^18 || ^19 || ^19.0.0-rc
|
||||
|
|
@ -2876,6 +2889,11 @@ packages:
|
|||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||
hasBin: true
|
||||
|
||||
nanoid@3.3.8:
|
||||
resolution: {integrity: sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==}
|
||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||
hasBin: true
|
||||
|
||||
nanoid@5.0.8:
|
||||
resolution: {integrity: sha512-TcJPw+9RV9dibz1hHUzlLVy8N4X9TnwirAjrU08Juo6BNKggzVfP2ZJ/3ZUSq15Xl5i85i+Z89XBO90pB2PghQ==}
|
||||
engines: {node: ^18 || >=20}
|
||||
|
|
@ -3748,24 +3766,37 @@ snapshots:
|
|||
optionalDependencies:
|
||||
zod: 3.23.8
|
||||
|
||||
'@ai-sdk/provider-utils@2.0.4(zod@3.23.8)':
|
||||
dependencies:
|
||||
'@ai-sdk/provider': 1.0.2
|
||||
eventsource-parser: 3.0.0
|
||||
nanoid: 3.3.8
|
||||
secure-json-parse: 2.7.0
|
||||
optionalDependencies:
|
||||
zod: 3.23.8
|
||||
|
||||
'@ai-sdk/provider@1.0.1':
|
||||
dependencies:
|
||||
json-schema: 0.4.0
|
||||
|
||||
'@ai-sdk/react@1.0.3(react@19.0.0-rc-45804af1-20241021)(zod@3.23.8)':
|
||||
'@ai-sdk/provider@1.0.2':
|
||||
dependencies:
|
||||
'@ai-sdk/provider-utils': 2.0.2(zod@3.23.8)
|
||||
'@ai-sdk/ui-utils': 1.0.2(zod@3.23.8)
|
||||
json-schema: 0.4.0
|
||||
|
||||
'@ai-sdk/react@1.0.6(react@19.0.0-rc-45804af1-20241021)(zod@3.23.8)':
|
||||
dependencies:
|
||||
'@ai-sdk/provider-utils': 2.0.4(zod@3.23.8)
|
||||
'@ai-sdk/ui-utils': 1.0.5(zod@3.23.8)
|
||||
swr: 2.2.5(react@19.0.0-rc-45804af1-20241021)
|
||||
throttleit: 2.1.0
|
||||
optionalDependencies:
|
||||
react: 19.0.0-rc-45804af1-20241021
|
||||
zod: 3.23.8
|
||||
|
||||
'@ai-sdk/ui-utils@1.0.2(zod@3.23.8)':
|
||||
'@ai-sdk/ui-utils@1.0.5(zod@3.23.8)':
|
||||
dependencies:
|
||||
'@ai-sdk/provider': 1.0.1
|
||||
'@ai-sdk/provider-utils': 2.0.2(zod@3.23.8)
|
||||
'@ai-sdk/provider': 1.0.2
|
||||
'@ai-sdk/provider-utils': 2.0.4(zod@3.23.8)
|
||||
zod-to-json-schema: 3.23.5(zod@3.23.8)
|
||||
optionalDependencies:
|
||||
zod: 3.23.8
|
||||
|
|
@ -4865,12 +4896,12 @@ snapshots:
|
|||
|
||||
acorn@8.14.0: {}
|
||||
|
||||
ai@4.0.10(react@19.0.0-rc-45804af1-20241021)(zod@3.23.8):
|
||||
ai@4.0.20(react@19.0.0-rc-45804af1-20241021)(zod@3.23.8):
|
||||
dependencies:
|
||||
'@ai-sdk/provider': 1.0.1
|
||||
'@ai-sdk/provider-utils': 2.0.2(zod@3.23.8)
|
||||
'@ai-sdk/react': 1.0.3(react@19.0.0-rc-45804af1-20241021)(zod@3.23.8)
|
||||
'@ai-sdk/ui-utils': 1.0.2(zod@3.23.8)
|
||||
'@ai-sdk/provider': 1.0.2
|
||||
'@ai-sdk/provider-utils': 2.0.4(zod@3.23.8)
|
||||
'@ai-sdk/react': 1.0.6(react@19.0.0-rc-45804af1-20241021)(zod@3.23.8)
|
||||
'@ai-sdk/ui-utils': 1.0.5(zod@3.23.8)
|
||||
'@opentelemetry/api': 1.9.0
|
||||
jsondiffpatch: 0.6.0
|
||||
zod-to-json-schema: 3.23.5(zod@3.23.8)
|
||||
|
|
@ -6484,6 +6515,8 @@ snapshots:
|
|||
|
||||
nanoid@3.3.7: {}
|
||||
|
||||
nanoid@3.3.8: {}
|
||||
|
||||
nanoid@5.0.8: {}
|
||||
|
||||
natural-compare@1.4.0: {}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue