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 { Chat } from '@/components/chat';
|
||||||
import { DEFAULT_MODEL_NAME, models } from '@/lib/ai/models';
|
import { DEFAULT_MODEL_NAME, models } from '@/lib/ai/models';
|
||||||
import { generateUUID } from '@/lib/utils';
|
import { generateUUID } from '@/lib/utils';
|
||||||
|
import { DataStreamHandler } from '@/components/data-stream-handler';
|
||||||
|
|
||||||
export default async function Page() {
|
export default async function Page() {
|
||||||
const id = generateUUID();
|
const id = generateUUID();
|
||||||
|
|
@ -15,13 +16,16 @@ export default async function Page() {
|
||||||
DEFAULT_MODEL_NAME;
|
DEFAULT_MODEL_NAME;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Chat
|
<>
|
||||||
key={id}
|
<Chat
|
||||||
id={id}
|
key={id}
|
||||||
initialMessages={[]}
|
id={id}
|
||||||
selectedModelId={selectedModelId}
|
initialMessages={[]}
|
||||||
selectedVisibilityType="private"
|
selectedModelId={selectedModelId}
|
||||||
isReadonly={false}
|
selectedVisibilityType="private"
|
||||||
/>
|
isReadonly={false}
|
||||||
|
/>
|
||||||
|
<DataStreamHandler id={id} />
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,10 @@
|
||||||
"noSvgWithoutTitle": "off", // We do not intend to adhere to this rule
|
"noSvgWithoutTitle": "off", // We do not intend to adhere to this rule
|
||||||
"useMediaCaption": "off", // We would need a cultural change to turn this on
|
"useMediaCaption": "off", // We would need a cultural change to turn this on
|
||||||
"noAutofocus": "off", // We're highly intentional about when we use autofocus
|
"noAutofocus": "off", // We're highly intentional about when we use autofocus
|
||||||
"noBlankTarget": "off" // Covered by Conformance
|
"noBlankTarget": "off", // Covered by Conformance
|
||||||
|
"useFocusableInteractive": "off", // Disable focusable interactive element requirement
|
||||||
|
"useAriaPropsForRole": "off", // Disable required ARIA attributes check
|
||||||
|
"useKeyWithClickEvents": "off" // Disable keyboard event requirement with click events
|
||||||
},
|
},
|
||||||
"complexity": {
|
"complexity": {
|
||||||
"noUselessStringConcat": "warn", // Not in recommended ruleset, turning on manually
|
"noUselessStringConcat": "warn", // Not in recommended ruleset, turning on manually
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,24 @@
|
||||||
import { memo, SetStateAction } from 'react';
|
import { memo } from 'react';
|
||||||
import { CrossIcon } from './icons';
|
import { CrossIcon } from './icons';
|
||||||
import { Button } from './ui/button';
|
import { Button } from './ui/button';
|
||||||
import { UIBlock } from './block';
|
import { initialBlockData, useBlock } from '@/hooks/use-block';
|
||||||
import equal from 'fast-deep-equal';
|
|
||||||
|
|
||||||
interface BlockCloseButtonProps {
|
function PureBlockCloseButton() {
|
||||||
setBlock: (value: SetStateAction<UIBlock>) => void;
|
const { setBlock } = useBlock();
|
||||||
}
|
|
||||||
|
|
||||||
function PureBlockCloseButton({ setBlock }: BlockCloseButtonProps) {
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="h-fit p-2 dark:hover:bg-zinc-700"
|
className="h-fit p-2 dark:hover:bg-zinc-700"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setBlock((currentBlock) => ({
|
setBlock((currentBlock) =>
|
||||||
...currentBlock,
|
currentBlock.status === 'streaming'
|
||||||
isVisible: false,
|
? {
|
||||||
}));
|
...currentBlock,
|
||||||
|
isVisible: false,
|
||||||
|
}
|
||||||
|
: { ...initialBlockData, status: 'idle' },
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<CrossIcon size={18} />
|
<CrossIcon size={18} />
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,13 @@
|
||||||
import { Dispatch, memo, SetStateAction } from 'react';
|
|
||||||
import { UIBlock } from './block';
|
|
||||||
import { PreviewMessage } from './message';
|
import { PreviewMessage } from './message';
|
||||||
import { useScrollToBottom } from './use-scroll-to-bottom';
|
import { useScrollToBottom } from './use-scroll-to-bottom';
|
||||||
import { Vote } from '@/lib/db/schema';
|
import { Vote } from '@/lib/db/schema';
|
||||||
import { ChatRequestOptions, Message } from 'ai';
|
import { ChatRequestOptions, Message } from 'ai';
|
||||||
|
import { memo } from 'react';
|
||||||
|
import equal from 'fast-deep-equal';
|
||||||
|
import { UIBlock } from './block';
|
||||||
|
|
||||||
interface BlockMessagesProps {
|
interface BlockMessagesProps {
|
||||||
chatId: string;
|
chatId: string;
|
||||||
block: UIBlock;
|
|
||||||
setBlock: Dispatch<SetStateAction<UIBlock>>;
|
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
votes: Array<Vote> | undefined;
|
votes: Array<Vote> | undefined;
|
||||||
messages: Array<Message>;
|
messages: Array<Message>;
|
||||||
|
|
@ -19,12 +18,11 @@ interface BlockMessagesProps {
|
||||||
chatRequestOptions?: ChatRequestOptions,
|
chatRequestOptions?: ChatRequestOptions,
|
||||||
) => Promise<string | null | undefined>;
|
) => Promise<string | null | undefined>;
|
||||||
isReadonly: boolean;
|
isReadonly: boolean;
|
||||||
|
blockStatus: UIBlock['status'];
|
||||||
}
|
}
|
||||||
|
|
||||||
function PureBlockMessages({
|
function PureBlockMessages({
|
||||||
chatId,
|
chatId,
|
||||||
block,
|
|
||||||
setBlock,
|
|
||||||
isLoading,
|
isLoading,
|
||||||
votes,
|
votes,
|
||||||
messages,
|
messages,
|
||||||
|
|
@ -45,8 +43,6 @@ function PureBlockMessages({
|
||||||
chatId={chatId}
|
chatId={chatId}
|
||||||
key={message.id}
|
key={message.id}
|
||||||
message={message}
|
message={message}
|
||||||
block={block}
|
|
||||||
setBlock={setBlock}
|
|
||||||
isLoading={isLoading && index === messages.length - 1}
|
isLoading={isLoading && index === messages.length - 1}
|
||||||
vote={
|
vote={
|
||||||
votes
|
votes
|
||||||
|
|
@ -72,13 +68,17 @@ function areEqual(
|
||||||
nextProps: BlockMessagesProps,
|
nextProps: BlockMessagesProps,
|
||||||
) {
|
) {
|
||||||
if (
|
if (
|
||||||
prevProps.block.status === 'streaming' &&
|
prevProps.blockStatus === 'streaming' &&
|
||||||
nextProps.block.status === 'streaming'
|
nextProps.blockStatus === 'streaming'
|
||||||
) {
|
)
|
||||||
return true;
|
return true;
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
if (prevProps.isLoading !== nextProps.isLoading) return false;
|
||||||
|
if (prevProps.isLoading && nextProps.isLoading) return false;
|
||||||
|
if (prevProps.messages.length !== nextProps.messages.length) return false;
|
||||||
|
if (!equal(prevProps.votes, nextProps.votes)) return false;
|
||||||
|
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const BlockMessages = memo(PureBlockMessages, areEqual);
|
export const BlockMessages = memo(PureBlockMessages, areEqual);
|
||||||
|
|
|
||||||
|
|
@ -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 { BlockMessages } from './block-messages';
|
||||||
import { CodeEditor } from './code-editor';
|
import { CodeEditor } from './code-editor';
|
||||||
import { Console } from './console';
|
import { Console } from './console';
|
||||||
|
import { useSidebar } from './ui/sidebar';
|
||||||
|
import { useBlock } from '@/hooks/use-block';
|
||||||
|
import equal from 'fast-deep-equal';
|
||||||
|
|
||||||
export type BlockKind = 'text' | 'code';
|
export type BlockKind = 'text' | 'code';
|
||||||
|
|
||||||
|
|
@ -65,8 +68,6 @@ function PureBlock({
|
||||||
attachments,
|
attachments,
|
||||||
setAttachments,
|
setAttachments,
|
||||||
append,
|
append,
|
||||||
block,
|
|
||||||
setBlock,
|
|
||||||
messages,
|
messages,
|
||||||
setMessages,
|
setMessages,
|
||||||
reload,
|
reload,
|
||||||
|
|
@ -80,8 +81,6 @@ function PureBlock({
|
||||||
stop: () => void;
|
stop: () => void;
|
||||||
attachments: Array<Attachment>;
|
attachments: Array<Attachment>;
|
||||||
setAttachments: Dispatch<SetStateAction<Array<Attachment>>>;
|
setAttachments: Dispatch<SetStateAction<Array<Attachment>>>;
|
||||||
block: UIBlock;
|
|
||||||
setBlock: Dispatch<SetStateAction<UIBlock>>;
|
|
||||||
messages: Array<Message>;
|
messages: Array<Message>;
|
||||||
setMessages: Dispatch<SetStateAction<Array<Message>>>;
|
setMessages: Dispatch<SetStateAction<Array<Message>>>;
|
||||||
votes: Array<Vote> | undefined;
|
votes: Array<Vote> | undefined;
|
||||||
|
|
@ -100,12 +99,14 @@ function PureBlock({
|
||||||
) => Promise<string | null | undefined>;
|
) => Promise<string | null | undefined>;
|
||||||
isReadonly: boolean;
|
isReadonly: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
const { block, setBlock } = useBlock();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: documents,
|
data: documents,
|
||||||
isLoading: isDocumentsFetching,
|
isLoading: isDocumentsFetching,
|
||||||
mutate: mutateDocuments,
|
mutate: mutateDocuments,
|
||||||
} = useSWR<Array<Document>>(
|
} = useSWR<Array<Document>>(
|
||||||
block && block.status !== 'streaming'
|
block.documentId !== 'init' && block.status !== 'streaming'
|
||||||
? `/api/document?id=${block.documentId}`
|
? `/api/document?id=${block.documentId}`
|
||||||
: null,
|
: null,
|
||||||
fetcher,
|
fetcher,
|
||||||
|
|
@ -128,6 +129,8 @@ function PureBlock({
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const { open: isSidebarOpen } = useSidebar();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (documents && documents.length > 0) {
|
if (documents && documents.length > 0) {
|
||||||
const mostRecentDocument = documents.at(-1);
|
const mostRecentDocument = documents.at(-1);
|
||||||
|
|
@ -260,274 +263,302 @@ function PureBlock({
|
||||||
const isMobile = windowWidth ? windowWidth < 768 : false;
|
const isMobile = windowWidth ? windowWidth < 768 : false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<AnimatePresence>
|
||||||
className="flex flex-row h-dvh w-dvw fixed top-0 left-0 z-50 bg-muted"
|
{block.isVisible && (
|
||||||
initial={{ opacity: 1 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
exit={{ opacity: 0, transition: { delay: 0.4 } }}
|
|
||||||
>
|
|
||||||
{!isMobile && (
|
|
||||||
<motion.div
|
<motion.div
|
||||||
className="relative w-[400px] bg-muted dark:bg-background h-dvh shrink-0"
|
className="flex flex-row h-dvh w-dvw fixed top-0 left-0 z-50 bg-transparent"
|
||||||
initial={{ opacity: 0, x: 10, scale: 1 }}
|
initial={{ opacity: 1 }}
|
||||||
animate={{
|
animate={{ opacity: 1 }}
|
||||||
opacity: 1,
|
exit={{ opacity: 0, transition: { delay: 0.4 } }}
|
||||||
x: 0,
|
|
||||||
scale: 1,
|
|
||||||
transition: {
|
|
||||||
delay: 0.2,
|
|
||||||
type: 'spring',
|
|
||||||
stiffness: 200,
|
|
||||||
damping: 30,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
exit={{
|
|
||||||
opacity: 0,
|
|
||||||
x: 0,
|
|
||||||
scale: 0.95,
|
|
||||||
transition: { delay: 0 },
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<AnimatePresence>
|
{!isMobile && (
|
||||||
{!isCurrentVersion && (
|
<motion.div
|
||||||
<motion.div
|
className="fixed bg-background h-dvh"
|
||||||
className="left-0 absolute h-dvh w-[400px] top-0 bg-zinc-900/50 z-50"
|
initial={{
|
||||||
initial={{ opacity: 0 }}
|
width: isSidebarOpen ? windowWidth - 256 : windowWidth,
|
||||||
animate={{ opacity: 1 }}
|
right: 0,
|
||||||
exit={{ opacity: 0 }}
|
}}
|
||||||
/>
|
animate={{ width: windowWidth, right: 0 }}
|
||||||
)}
|
exit={{
|
||||||
</AnimatePresence>
|
width: isSidebarOpen ? windowWidth - 256 : windowWidth,
|
||||||
|
right: 0,
|
||||||
<div className="flex flex-col h-full justify-between items-center gap-4">
|
}}
|
||||||
<BlockMessages
|
|
||||||
chatId={chatId}
|
|
||||||
block={block}
|
|
||||||
isLoading={isLoading}
|
|
||||||
setBlock={setBlock}
|
|
||||||
votes={votes}
|
|
||||||
messages={messages}
|
|
||||||
setMessages={setMessages}
|
|
||||||
reload={reload}
|
|
||||||
isReadonly={isReadonly}
|
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<form className="flex flex-row gap-2 relative items-end w-full px-4 pb-4">
|
{!isMobile && (
|
||||||
<MultimodalInput
|
<motion.div
|
||||||
chatId={chatId}
|
className="relative w-[400px] bg-muted dark:bg-background h-dvh shrink-0"
|
||||||
input={input}
|
initial={{ opacity: 0, x: 10, scale: 1 }}
|
||||||
setInput={setInput}
|
animate={{
|
||||||
handleSubmit={handleSubmit}
|
|
||||||
isLoading={isLoading}
|
|
||||||
stop={stop}
|
|
||||||
attachments={attachments}
|
|
||||||
setAttachments={setAttachments}
|
|
||||||
messages={messages}
|
|
||||||
append={append}
|
|
||||||
className="bg-background dark:bg-muted"
|
|
||||||
setMessages={setMessages}
|
|
||||||
/>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<motion.div
|
|
||||||
className="fixed dark:bg-muted bg-background h-dvh flex flex-col overflow-y-scroll"
|
|
||||||
initial={
|
|
||||||
isMobile
|
|
||||||
? {
|
|
||||||
opacity: 0,
|
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
width: windowWidth,
|
|
||||||
height: windowHeight,
|
|
||||||
borderRadius: 50,
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
opacity: 0,
|
|
||||||
x: block.boundingBox.left,
|
|
||||||
y: block.boundingBox.top,
|
|
||||||
height: block.boundingBox.height,
|
|
||||||
width: block.boundingBox.width,
|
|
||||||
borderRadius: 50,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
animate={
|
|
||||||
isMobile
|
|
||||||
? {
|
|
||||||
opacity: 1,
|
opacity: 1,
|
||||||
x: 0,
|
x: 0,
|
||||||
y: 0,
|
scale: 1,
|
||||||
width: windowWidth,
|
|
||||||
height: '100dvh',
|
|
||||||
borderRadius: 0,
|
|
||||||
transition: {
|
transition: {
|
||||||
delay: 0,
|
delay: 0.2,
|
||||||
type: 'spring',
|
type: 'spring',
|
||||||
stiffness: 200,
|
stiffness: 200,
|
||||||
damping: 30,
|
damping: 30,
|
||||||
},
|
},
|
||||||
}
|
}}
|
||||||
: {
|
exit={{
|
||||||
opacity: 1,
|
opacity: 0,
|
||||||
x: 400,
|
x: 0,
|
||||||
y: 0,
|
scale: 1,
|
||||||
height: windowHeight,
|
transition: { duration: 0 },
|
||||||
width: windowWidth ? windowWidth - 400 : 'calc(100dvw-400px)',
|
}}
|
||||||
borderRadius: 0,
|
>
|
||||||
transition: {
|
<AnimatePresence>
|
||||||
delay: 0,
|
{!isCurrentVersion && (
|
||||||
type: 'spring',
|
<motion.div
|
||||||
stiffness: 200,
|
className="left-0 absolute h-dvh w-[400px] top-0 bg-zinc-900/50 z-50"
|
||||||
damping: 30,
|
initial={{ opacity: 0 }}
|
||||||
},
|
animate={{ opacity: 1 }}
|
||||||
}
|
exit={{ opacity: 0 }}
|
||||||
}
|
/>
|
||||||
exit={{
|
)}
|
||||||
opacity: 0,
|
</AnimatePresence>
|
||||||
scale: 0.5,
|
|
||||||
transition: {
|
|
||||||
delay: 0.1,
|
|
||||||
type: 'spring',
|
|
||||||
stiffness: 600,
|
|
||||||
damping: 30,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="p-2 flex flex-row justify-between items-start">
|
|
||||||
<div className="flex flex-row gap-4 items-start">
|
|
||||||
<BlockCloseButton setBlock={setBlock} />
|
|
||||||
|
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col h-full justify-between items-center gap-4">
|
||||||
<div className="font-medium">
|
<BlockMessages
|
||||||
{document?.title ?? block.title}
|
chatId={chatId}
|
||||||
|
isLoading={isLoading}
|
||||||
|
votes={votes}
|
||||||
|
messages={messages}
|
||||||
|
setMessages={setMessages}
|
||||||
|
reload={reload}
|
||||||
|
isReadonly={isReadonly}
|
||||||
|
blockStatus={block.status}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<form className="flex flex-row gap-2 relative items-end w-full px-4 pb-4">
|
||||||
|
<MultimodalInput
|
||||||
|
chatId={chatId}
|
||||||
|
input={input}
|
||||||
|
setInput={setInput}
|
||||||
|
handleSubmit={handleSubmit}
|
||||||
|
isLoading={isLoading}
|
||||||
|
stop={stop}
|
||||||
|
attachments={attachments}
|
||||||
|
setAttachments={setAttachments}
|
||||||
|
messages={messages}
|
||||||
|
append={append}
|
||||||
|
className="bg-background dark:bg-muted"
|
||||||
|
setMessages={setMessages}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
className="fixed dark:bg-muted bg-background h-dvh flex flex-col overflow-y-scroll border-l dark:border-zinc-700 border-zinc-200"
|
||||||
|
initial={
|
||||||
|
isMobile
|
||||||
|
? {
|
||||||
|
opacity: 1,
|
||||||
|
x: block.boundingBox.left,
|
||||||
|
y: block.boundingBox.top,
|
||||||
|
height: block.boundingBox.height,
|
||||||
|
width: block.boundingBox.width,
|
||||||
|
borderRadius: 50,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
opacity: 1,
|
||||||
|
x: block.boundingBox.left,
|
||||||
|
y: block.boundingBox.top,
|
||||||
|
height: block.boundingBox.height,
|
||||||
|
width: block.boundingBox.width,
|
||||||
|
borderRadius: 50,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
animate={
|
||||||
|
isMobile
|
||||||
|
? {
|
||||||
|
opacity: 1,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
height: windowHeight,
|
||||||
|
width: windowWidth ? windowWidth : 'calc(100dvw)',
|
||||||
|
borderRadius: 0,
|
||||||
|
transition: {
|
||||||
|
delay: 0,
|
||||||
|
type: 'spring',
|
||||||
|
stiffness: 200,
|
||||||
|
damping: 30,
|
||||||
|
duration: 5000,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
opacity: 1,
|
||||||
|
x: 400,
|
||||||
|
y: 0,
|
||||||
|
height: windowHeight,
|
||||||
|
width: windowWidth
|
||||||
|
? windowWidth - 400
|
||||||
|
: 'calc(100dvw-400px)',
|
||||||
|
borderRadius: 0,
|
||||||
|
transition: {
|
||||||
|
delay: 0,
|
||||||
|
type: 'spring',
|
||||||
|
stiffness: 200,
|
||||||
|
damping: 30,
|
||||||
|
duration: 5000,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exit={{
|
||||||
|
opacity: 0,
|
||||||
|
scale: 0.5,
|
||||||
|
transition: {
|
||||||
|
delay: 0.1,
|
||||||
|
type: 'spring',
|
||||||
|
stiffness: 600,
|
||||||
|
damping: 30,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="p-2 flex flex-row justify-between items-start">
|
||||||
|
<div className="flex flex-row gap-4 items-start">
|
||||||
|
<BlockCloseButton />
|
||||||
|
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<div className="font-medium">
|
||||||
|
{document?.title ?? block.title}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isContentDirty ? (
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
Saving changes...
|
||||||
|
</div>
|
||||||
|
) : document ? (
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{`Updated ${formatDistance(
|
||||||
|
new Date(document.createdAt),
|
||||||
|
new Date(),
|
||||||
|
{
|
||||||
|
addSuffix: true,
|
||||||
|
},
|
||||||
|
)}`}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="w-32 h-3 mt-2 bg-muted-foreground/20 rounded-md animate-pulse" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isContentDirty ? (
|
<BlockActions
|
||||||
<div className="text-sm text-muted-foreground">
|
block={block}
|
||||||
Saving changes...
|
|
||||||
</div>
|
|
||||||
) : document ? (
|
|
||||||
<div className="text-sm text-muted-foreground">
|
|
||||||
{`Updated ${formatDistance(
|
|
||||||
new Date(document.createdAt),
|
|
||||||
new Date(),
|
|
||||||
{
|
|
||||||
addSuffix: true,
|
|
||||||
},
|
|
||||||
)}`}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="w-32 h-3 mt-2 bg-muted-foreground/20 rounded-md animate-pulse" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<BlockActions
|
|
||||||
block={block}
|
|
||||||
currentVersionIndex={currentVersionIndex}
|
|
||||||
handleVersionChange={handleVersionChange}
|
|
||||||
isCurrentVersion={isCurrentVersion}
|
|
||||||
mode={mode}
|
|
||||||
setConsoleOutputs={setConsoleOutputs}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'prose dark:prose-invert dark:bg-muted bg-background h-full overflow-y-scroll !max-w-full pb-40 items-center',
|
|
||||||
{
|
|
||||||
'py-2 px-2': block.kind === 'code',
|
|
||||||
'py-8 md:p-20 px-4': block.kind === 'text',
|
|
||||||
},
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={cn('flex flex-row', {
|
|
||||||
'': block.kind === 'code',
|
|
||||||
'mx-auto max-w-[600px]': block.kind === 'text',
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{isDocumentsFetching && !block.content ? (
|
|
||||||
<DocumentSkeleton />
|
|
||||||
) : block.kind === 'code' ? (
|
|
||||||
<CodeEditor
|
|
||||||
content={
|
|
||||||
isCurrentVersion
|
|
||||||
? block.content
|
|
||||||
: getDocumentContentById(currentVersionIndex)
|
|
||||||
}
|
|
||||||
isCurrentVersion={isCurrentVersion}
|
|
||||||
currentVersionIndex={currentVersionIndex}
|
currentVersionIndex={currentVersionIndex}
|
||||||
suggestions={suggestions ?? []}
|
handleVersionChange={handleVersionChange}
|
||||||
status={block.status}
|
isCurrentVersion={isCurrentVersion}
|
||||||
saveContent={saveContent}
|
mode={mode}
|
||||||
|
setConsoleOutputs={setConsoleOutputs}
|
||||||
/>
|
/>
|
||||||
) : block.kind === 'text' ? (
|
</div>
|
||||||
mode === 'edit' ? (
|
|
||||||
<Editor
|
|
||||||
content={
|
|
||||||
isCurrentVersion
|
|
||||||
? block.content
|
|
||||||
: getDocumentContentById(currentVersionIndex)
|
|
||||||
}
|
|
||||||
isCurrentVersion={isCurrentVersion}
|
|
||||||
currentVersionIndex={currentVersionIndex}
|
|
||||||
status={block.status}
|
|
||||||
saveContent={saveContent}
|
|
||||||
suggestions={isCurrentVersion ? (suggestions ?? []) : []}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<DiffView
|
|
||||||
oldContent={getDocumentContentById(currentVersionIndex - 1)}
|
|
||||||
newContent={getDocumentContentById(currentVersionIndex)}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{suggestions ? (
|
<div
|
||||||
<div className="md:hidden h-dvh w-12 shrink-0" />
|
className={cn(
|
||||||
) : null}
|
'dark:bg-muted bg-background h-full overflow-y-scroll !max-w-full pb-40 items-center',
|
||||||
|
{
|
||||||
|
'py-2 px-2': block.kind === 'code',
|
||||||
|
'py-8 md:p-20 px-4': block.kind === 'text',
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn('flex flex-row', {
|
||||||
|
'': block.kind === 'code',
|
||||||
|
'mx-auto max-w-[600px]': block.kind === 'text',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{isDocumentsFetching && !block.content ? (
|
||||||
|
<DocumentSkeleton />
|
||||||
|
) : block.kind === 'code' ? (
|
||||||
|
<CodeEditor
|
||||||
|
content={
|
||||||
|
isCurrentVersion
|
||||||
|
? block.content
|
||||||
|
: getDocumentContentById(currentVersionIndex)
|
||||||
|
}
|
||||||
|
isCurrentVersion={isCurrentVersion}
|
||||||
|
currentVersionIndex={currentVersionIndex}
|
||||||
|
suggestions={suggestions ?? []}
|
||||||
|
status={block.status}
|
||||||
|
saveContent={saveContent}
|
||||||
|
/>
|
||||||
|
) : block.kind === 'text' ? (
|
||||||
|
mode === 'edit' ? (
|
||||||
|
<Editor
|
||||||
|
content={
|
||||||
|
isCurrentVersion
|
||||||
|
? block.content
|
||||||
|
: getDocumentContentById(currentVersionIndex)
|
||||||
|
}
|
||||||
|
isCurrentVersion={isCurrentVersion}
|
||||||
|
currentVersionIndex={currentVersionIndex}
|
||||||
|
status={block.status}
|
||||||
|
saveContent={saveContent}
|
||||||
|
suggestions={isCurrentVersion ? (suggestions ?? []) : []}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<DiffView
|
||||||
|
oldContent={getDocumentContentById(
|
||||||
|
currentVersionIndex - 1,
|
||||||
|
)}
|
||||||
|
newContent={getDocumentContentById(currentVersionIndex)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{suggestions ? (
|
||||||
|
<div className="md:hidden h-dvh w-12 shrink-0" />
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{isCurrentVersion && (
|
||||||
|
<Toolbar
|
||||||
|
isToolbarVisible={isToolbarVisible}
|
||||||
|
setIsToolbarVisible={setIsToolbarVisible}
|
||||||
|
append={append}
|
||||||
|
isLoading={isLoading}
|
||||||
|
stop={stop}
|
||||||
|
setMessages={setMessages}
|
||||||
|
blockKind={block.kind}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{isCurrentVersion && (
|
{!isCurrentVersion && (
|
||||||
<Toolbar
|
<VersionFooter
|
||||||
isToolbarVisible={isToolbarVisible}
|
currentVersionIndex={currentVersionIndex}
|
||||||
setIsToolbarVisible={setIsToolbarVisible}
|
documents={documents}
|
||||||
append={append}
|
handleVersionChange={handleVersionChange}
|
||||||
isLoading={isLoading}
|
|
||||||
stop={stop}
|
|
||||||
setMessages={setMessages}
|
|
||||||
blockKind={block.kind}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{!isCurrentVersion && (
|
<Console
|
||||||
<VersionFooter
|
consoleOutputs={consoleOutputs}
|
||||||
block={block}
|
setConsoleOutputs={setConsoleOutputs}
|
||||||
currentVersionIndex={currentVersionIndex}
|
/>
|
||||||
documents={documents}
|
</AnimatePresence>
|
||||||
handleVersionChange={handleVersionChange}
|
</motion.div>
|
||||||
/>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
||||||
<AnimatePresence>
|
|
||||||
<Console
|
|
||||||
consoleOutputs={consoleOutputs}
|
|
||||||
setConsoleOutputs={setConsoleOutputs}
|
|
||||||
/>
|
|
||||||
</AnimatePresence>
|
|
||||||
</motion.div>
|
|
||||||
</motion.div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Block = memo(PureBlock, (prevProps, nextProps) => {
|
export const Block = memo(PureBlock, (prevProps, nextProps) => {
|
||||||
return false;
|
if (prevProps.isLoading !== nextProps.isLoading) return false;
|
||||||
|
if (!equal(prevProps.votes, nextProps.votes)) return false;
|
||||||
|
if (prevProps.input !== nextProps.input) return false;
|
||||||
|
if (!equal(prevProps.messages, nextProps.messages.length)) return false;
|
||||||
|
|
||||||
|
return true;
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -2,20 +2,18 @@
|
||||||
|
|
||||||
import type { Attachment, Message } from 'ai';
|
import type { Attachment, Message } from 'ai';
|
||||||
import { useChat } from 'ai/react';
|
import { useChat } from 'ai/react';
|
||||||
import { AnimatePresence } from 'framer-motion';
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import useSWR, { useSWRConfig } from 'swr';
|
import useSWR, { useSWRConfig } from 'swr';
|
||||||
import { useWindowSize } from 'usehooks-ts';
|
|
||||||
|
|
||||||
import { ChatHeader } from '@/components/chat-header';
|
import { ChatHeader } from '@/components/chat-header';
|
||||||
import type { Vote } from '@/lib/db/schema';
|
import type { Vote } from '@/lib/db/schema';
|
||||||
import { fetcher } from '@/lib/utils';
|
import { fetcher } from '@/lib/utils';
|
||||||
|
|
||||||
import { Block, type UIBlock } from './block';
|
import { Block } from './block';
|
||||||
import { BlockStreamHandler } from './block-stream-handler';
|
|
||||||
import { MultimodalInput } from './multimodal-input';
|
import { MultimodalInput } from './multimodal-input';
|
||||||
import { Messages } from './messages';
|
import { Messages } from './messages';
|
||||||
import { VisibilityType } from './visibility-selector';
|
import { VisibilityType } from './visibility-selector';
|
||||||
|
import { useBlockSelector } from '@/hooks/use-block';
|
||||||
|
|
||||||
export function Chat({
|
export function Chat({
|
||||||
id,
|
id,
|
||||||
|
|
@ -42,40 +40,23 @@ export function Chat({
|
||||||
isLoading,
|
isLoading,
|
||||||
stop,
|
stop,
|
||||||
reload,
|
reload,
|
||||||
data: streamingData,
|
|
||||||
} = useChat({
|
} = useChat({
|
||||||
id,
|
id,
|
||||||
body: { id, modelId: selectedModelId },
|
body: { id, modelId: selectedModelId },
|
||||||
initialMessages,
|
initialMessages,
|
||||||
|
experimental_throttle: 100,
|
||||||
onFinish: () => {
|
onFinish: () => {
|
||||||
mutate('/api/history');
|
mutate('/api/history');
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { width: windowWidth = 1920, height: windowHeight = 1080 } =
|
|
||||||
useWindowSize();
|
|
||||||
|
|
||||||
const [block, setBlock] = useState<UIBlock>({
|
|
||||||
documentId: 'init',
|
|
||||||
content: '',
|
|
||||||
kind: 'text',
|
|
||||||
title: '',
|
|
||||||
status: 'idle',
|
|
||||||
isVisible: false,
|
|
||||||
boundingBox: {
|
|
||||||
top: windowHeight / 4,
|
|
||||||
left: windowWidth / 4,
|
|
||||||
width: 250,
|
|
||||||
height: 50,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: votes } = useSWR<Array<Vote>>(
|
const { data: votes } = useSWR<Array<Vote>>(
|
||||||
`/api/vote?chatId=${id}`,
|
`/api/vote?chatId=${id}`,
|
||||||
fetcher,
|
fetcher,
|
||||||
);
|
);
|
||||||
|
|
||||||
const [attachments, setAttachments] = useState<Array<Attachment>>([]);
|
const [attachments, setAttachments] = useState<Array<Attachment>>([]);
|
||||||
|
const isBlockVisible = useBlockSelector((state) => state.isVisible);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
@ -89,14 +70,13 @@ export function Chat({
|
||||||
|
|
||||||
<Messages
|
<Messages
|
||||||
chatId={id}
|
chatId={id}
|
||||||
block={block}
|
|
||||||
setBlock={setBlock}
|
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
votes={votes}
|
votes={votes}
|
||||||
messages={messages}
|
messages={messages}
|
||||||
setMessages={setMessages}
|
setMessages={setMessages}
|
||||||
reload={reload}
|
reload={reload}
|
||||||
isReadonly={isReadonly}
|
isReadonly={isReadonly}
|
||||||
|
isBlockVisible={isBlockVisible}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<form className="flex mx-auto px-4 bg-background pb-4 md:pb-6 gap-2 w-full md:max-w-3xl">
|
<form className="flex mx-auto px-4 bg-background pb-4 md:pb-6 gap-2 w-full md:max-w-3xl">
|
||||||
|
|
@ -118,30 +98,22 @@ export function Chat({
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AnimatePresence>
|
<Block
|
||||||
{block?.isVisible && (
|
chatId={id}
|
||||||
<Block
|
input={input}
|
||||||
chatId={id}
|
setInput={setInput}
|
||||||
input={input}
|
handleSubmit={handleSubmit}
|
||||||
setInput={setInput}
|
isLoading={isLoading}
|
||||||
handleSubmit={handleSubmit}
|
stop={stop}
|
||||||
isLoading={isLoading}
|
attachments={attachments}
|
||||||
stop={stop}
|
setAttachments={setAttachments}
|
||||||
attachments={attachments}
|
append={append}
|
||||||
setAttachments={setAttachments}
|
messages={messages}
|
||||||
append={append}
|
setMessages={setMessages}
|
||||||
block={block}
|
reload={reload}
|
||||||
setBlock={setBlock}
|
votes={votes}
|
||||||
messages={messages}
|
isReadonly={isReadonly}
|
||||||
setMessages={setMessages}
|
/>
|
||||||
reload={reload}
|
|
||||||
votes={votes}
|
|
||||||
isReadonly={isReadonly}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
|
|
||||||
<BlockStreamHandler streamingData={streamingData} setBlock={setBlock} />
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,8 @@ function PureCodeEditor({ content, saveContent, status }: EditorProps) {
|
||||||
editorRef.current = null;
|
editorRef.current = null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
// NOTE: we only want to run this effect once
|
||||||
|
// eslint-disable-next-line
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,8 @@ export function Console({ consoleOutputs, setConsoleOutputs }: ConsoleProps) {
|
||||||
className="h-2 w-full fixed cursor-ns-resize z-50"
|
className="h-2 w-full fixed cursor-ns-resize z-50"
|
||||||
onMouseDown={startResizing}
|
onMouseDown={startResizing}
|
||||||
style={{ bottom: height - 4 }}
|
style={{ bottom: height - 4 }}
|
||||||
|
role="slider"
|
||||||
|
aria-valuenow={minHeight}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
|
|
||||||
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>
|
</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 { FileIcon, LoaderIcon, MessageIcon, PencilEditIcon } from './icons';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import { useBlock } from '@/hooks/use-block';
|
||||||
|
|
||||||
const getActionText = (
|
const getActionText = (
|
||||||
type: 'create' | 'update' | 'request-suggestions',
|
type: 'create' | 'update' | 'request-suggestions',
|
||||||
|
|
@ -25,17 +26,16 @@ const getActionText = (
|
||||||
interface DocumentToolResultProps {
|
interface DocumentToolResultProps {
|
||||||
type: 'create' | 'update' | 'request-suggestions';
|
type: 'create' | 'update' | 'request-suggestions';
|
||||||
result: { id: string; title: string; kind: BlockKind };
|
result: { id: string; title: string; kind: BlockKind };
|
||||||
block: UIBlock;
|
|
||||||
setBlock: (value: SetStateAction<UIBlock>) => void;
|
|
||||||
isReadonly: boolean;
|
isReadonly: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function PureDocumentToolResult({
|
function PureDocumentToolResult({
|
||||||
type,
|
type,
|
||||||
result,
|
result,
|
||||||
setBlock,
|
|
||||||
isReadonly,
|
isReadonly,
|
||||||
}: DocumentToolResultProps) {
|
}: DocumentToolResultProps) {
|
||||||
|
const { setBlock } = useBlock();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -89,16 +89,16 @@ export const DocumentToolResult = memo(PureDocumentToolResult, () => true);
|
||||||
interface DocumentToolCallProps {
|
interface DocumentToolCallProps {
|
||||||
type: 'create' | 'update' | 'request-suggestions';
|
type: 'create' | 'update' | 'request-suggestions';
|
||||||
args: { title: string };
|
args: { title: string };
|
||||||
setBlock: (value: SetStateAction<UIBlock>) => void;
|
|
||||||
isReadonly: boolean;
|
isReadonly: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function PureDocumentToolCall({
|
function PureDocumentToolCall({
|
||||||
type,
|
type,
|
||||||
args,
|
args,
|
||||||
setBlock,
|
|
||||||
isReadonly,
|
isReadonly,
|
||||||
}: DocumentToolCallProps) {
|
}: DocumentToolCallProps) {
|
||||||
|
const { setBlock } = useBlock();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
|
||||||
|
|
@ -1102,3 +1102,20 @@ export const ImageIcon = ({ size = 16 }: { size?: number }) => {
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const FullscreenIcon = ({ size = 16 }: { size?: number }) => (
|
||||||
|
<svg
|
||||||
|
height={size}
|
||||||
|
strokeLinejoin="round"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
width={size}
|
||||||
|
style={{ color: 'currentcolor' }}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
clipRule="evenodd"
|
||||||
|
d="M1 5.25V6H2.5V5.25V2.5H5.25H6V1H5.25H2C1.44772 1 1 1.44772 1 2V5.25ZM5.25 14.9994H6V13.4994H5.25H2.5V10.7494V9.99939H1V10.7494V13.9994C1 14.5517 1.44772 14.9994 2 14.9994H5.25ZM15 10V10.75V14C15 14.5523 14.5523 15 14 15H10.75H10V13.5H10.75H13.5V10.75V10H15ZM10.75 1H10V2.5H10.75H13.5V5.25V6H15V5.25V2C15 1.44772 14.5523 1 14 1H10.75Z"
|
||||||
|
fill="currentColor"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,101 +1,103 @@
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import React, { memo } from 'react';
|
import React, { memo, useMemo, useState } from 'react';
|
||||||
import ReactMarkdown, { type Components } from 'react-markdown';
|
import ReactMarkdown, { type Components } from 'react-markdown';
|
||||||
import remarkGfm from 'remark-gfm';
|
import remarkGfm from 'remark-gfm';
|
||||||
import { CodeBlock } from './code-block';
|
import { CodeBlock } from './code-block';
|
||||||
|
|
||||||
const NonMemoizedMarkdown = ({ children }: { children: string }) => {
|
const components: Partial<Components> = {
|
||||||
const components: Partial<Components> = {
|
// @ts-expect-error
|
||||||
// @ts-expect-error
|
code: CodeBlock,
|
||||||
code: CodeBlock,
|
pre: ({ children }) => <>{children}</>,
|
||||||
pre: ({ children }) => <>{children}</>,
|
ol: ({ node, children, ...props }) => {
|
||||||
ol: ({ node, children, ...props }) => {
|
return (
|
||||||
return (
|
<ol className="list-decimal list-outside ml-4" {...props}>
|
||||||
<ol className="list-decimal list-outside ml-4" {...props}>
|
{children}
|
||||||
{children}
|
</ol>
|
||||||
</ol>
|
);
|
||||||
);
|
},
|
||||||
},
|
li: ({ node, children, ...props }) => {
|
||||||
li: ({ node, children, ...props }) => {
|
return (
|
||||||
return (
|
<li className="py-1" {...props}>
|
||||||
<li className="py-1" {...props}>
|
{children}
|
||||||
{children}
|
</li>
|
||||||
</li>
|
);
|
||||||
);
|
},
|
||||||
},
|
ul: ({ node, children, ...props }) => {
|
||||||
ul: ({ node, children, ...props }) => {
|
return (
|
||||||
return (
|
<ul className="list-decimal list-outside ml-4" {...props}>
|
||||||
<ul className="list-decimal list-outside ml-4" {...props}>
|
{children}
|
||||||
{children}
|
</ul>
|
||||||
</ul>
|
);
|
||||||
);
|
},
|
||||||
},
|
strong: ({ node, children, ...props }) => {
|
||||||
strong: ({ node, children, ...props }) => {
|
return (
|
||||||
return (
|
<span className="font-semibold" {...props}>
|
||||||
<span className="font-semibold" {...props}>
|
{children}
|
||||||
{children}
|
</span>
|
||||||
</span>
|
);
|
||||||
);
|
},
|
||||||
},
|
a: ({ node, children, ...props }) => {
|
||||||
a: ({ node, children, ...props }) => {
|
return (
|
||||||
return (
|
// @ts-expect-error
|
||||||
// @ts-expect-error
|
<Link
|
||||||
<Link
|
className="text-blue-500 hover:underline"
|
||||||
className="text-blue-500 hover:underline"
|
target="_blank"
|
||||||
target="_blank"
|
rel="noreferrer"
|
||||||
rel="noreferrer"
|
{...props}
|
||||||
{...props}
|
>
|
||||||
>
|
{children}
|
||||||
{children}
|
</Link>
|
||||||
</Link>
|
);
|
||||||
);
|
},
|
||||||
},
|
h1: ({ node, children, ...props }) => {
|
||||||
h1: ({ node, children, ...props }) => {
|
return (
|
||||||
return (
|
<h1 className="text-3xl font-semibold mt-6 mb-2" {...props}>
|
||||||
<h1 className="text-3xl font-semibold mt-6 mb-2" {...props}>
|
{children}
|
||||||
{children}
|
</h1>
|
||||||
</h1>
|
);
|
||||||
);
|
},
|
||||||
},
|
h2: ({ node, children, ...props }) => {
|
||||||
h2: ({ node, children, ...props }) => {
|
return (
|
||||||
return (
|
<h2 className="text-2xl font-semibold mt-6 mb-2" {...props}>
|
||||||
<h2 className="text-2xl font-semibold mt-6 mb-2" {...props}>
|
{children}
|
||||||
{children}
|
</h2>
|
||||||
</h2>
|
);
|
||||||
);
|
},
|
||||||
},
|
h3: ({ node, children, ...props }) => {
|
||||||
h3: ({ node, children, ...props }) => {
|
return (
|
||||||
return (
|
<h3 className="text-xl font-semibold mt-6 mb-2" {...props}>
|
||||||
<h3 className="text-xl font-semibold mt-6 mb-2" {...props}>
|
{children}
|
||||||
{children}
|
</h3>
|
||||||
</h3>
|
);
|
||||||
);
|
},
|
||||||
},
|
h4: ({ node, children, ...props }) => {
|
||||||
h4: ({ node, children, ...props }) => {
|
return (
|
||||||
return (
|
<h4 className="text-lg font-semibold mt-6 mb-2" {...props}>
|
||||||
<h4 className="text-lg font-semibold mt-6 mb-2" {...props}>
|
{children}
|
||||||
{children}
|
</h4>
|
||||||
</h4>
|
);
|
||||||
);
|
},
|
||||||
},
|
h5: ({ node, children, ...props }) => {
|
||||||
h5: ({ node, children, ...props }) => {
|
return (
|
||||||
return (
|
<h5 className="text-base font-semibold mt-6 mb-2" {...props}>
|
||||||
<h5 className="text-base font-semibold mt-6 mb-2" {...props}>
|
{children}
|
||||||
{children}
|
</h5>
|
||||||
</h5>
|
);
|
||||||
);
|
},
|
||||||
},
|
h6: ({ node, children, ...props }) => {
|
||||||
h6: ({ node, children, ...props }) => {
|
return (
|
||||||
return (
|
<h6 className="text-sm font-semibold mt-6 mb-2" {...props}>
|
||||||
<h6 className="text-sm font-semibold mt-6 mb-2" {...props}>
|
{children}
|
||||||
{children}
|
</h6>
|
||||||
</h6>
|
);
|
||||||
);
|
},
|
||||||
},
|
};
|
||||||
};
|
|
||||||
|
|
||||||
|
const remarkPlugins = [remarkGfm];
|
||||||
|
|
||||||
|
const NonMemoizedMarkdown = ({ children }: { children: string }) => {
|
||||||
return (
|
return (
|
||||||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={components}>
|
<ReactMarkdown remarkPlugins={remarkPlugins} components={components}>
|
||||||
{children}
|
{children}
|
||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,11 @@
|
||||||
|
|
||||||
import type { ChatRequestOptions, Message } from 'ai';
|
import type { ChatRequestOptions, Message } from 'ai';
|
||||||
import cx from 'classnames';
|
import cx from 'classnames';
|
||||||
import { motion } from 'framer-motion';
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
import { memo, useState, type Dispatch, type SetStateAction } from 'react';
|
import { memo, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import type { Vote } from '@/lib/db/schema';
|
import type { Vote } from '@/lib/db/schema';
|
||||||
|
|
||||||
import type { UIBlock } from './block';
|
|
||||||
import { DocumentToolCall, DocumentToolResult } from './document';
|
import { DocumentToolCall, DocumentToolResult } from './document';
|
||||||
import { PencilEditIcon, SparklesIcon } from './icons';
|
import { PencilEditIcon, SparklesIcon } from './icons';
|
||||||
import { Markdown } from './markdown';
|
import { Markdown } from './markdown';
|
||||||
|
|
@ -19,12 +18,11 @@ import { cn } from '@/lib/utils';
|
||||||
import { Button } from './ui/button';
|
import { Button } from './ui/button';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
|
||||||
import { MessageEditor } from './message-editor';
|
import { MessageEditor } from './message-editor';
|
||||||
|
import { DocumentPreview } from './document-preview';
|
||||||
|
|
||||||
const PurePreviewMessage = ({
|
const PurePreviewMessage = ({
|
||||||
chatId,
|
chatId,
|
||||||
message,
|
message,
|
||||||
block,
|
|
||||||
setBlock,
|
|
||||||
vote,
|
vote,
|
||||||
isLoading,
|
isLoading,
|
||||||
setMessages,
|
setMessages,
|
||||||
|
|
@ -33,8 +31,6 @@ const PurePreviewMessage = ({
|
||||||
}: {
|
}: {
|
||||||
chatId: string;
|
chatId: string;
|
||||||
message: Message;
|
message: Message;
|
||||||
block: UIBlock;
|
|
||||||
setBlock: Dispatch<SetStateAction<UIBlock>>;
|
|
||||||
vote: Vote | undefined;
|
vote: Vote | undefined;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
setMessages: (
|
setMessages: (
|
||||||
|
|
@ -48,174 +44,162 @@ const PurePreviewMessage = ({
|
||||||
const [mode, setMode] = useState<'view' | 'edit'>('view');
|
const [mode, setMode] = useState<'view' | 'edit'>('view');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<AnimatePresence>
|
||||||
className="w-full mx-auto max-w-3xl px-4 group/message"
|
<motion.div
|
||||||
initial={{ y: 5, opacity: 0 }}
|
className="w-full mx-auto max-w-3xl px-4 group/message"
|
||||||
animate={{ y: 0, opacity: 1 }}
|
initial={{ y: 5, opacity: 0 }}
|
||||||
data-role={message.role}
|
animate={{ y: 0, opacity: 1 }}
|
||||||
>
|
data-role={message.role}
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'flex gap-4 w-full group-data-[role=user]/message:ml-auto group-data-[role=user]/message:max-w-2xl',
|
|
||||||
{
|
|
||||||
'w-full': mode === 'edit',
|
|
||||||
'group-data-[role=user]/message:w-fit': mode !== 'edit',
|
|
||||||
},
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{message.role === 'assistant' && (
|
<div
|
||||||
<div className="size-8 flex items-center rounded-full justify-center ring-1 shrink-0 ring-border bg-background">
|
className={cn(
|
||||||
<SparklesIcon size={14} />
|
'flex gap-4 w-full group-data-[role=user]/message:ml-auto group-data-[role=user]/message:max-w-2xl',
|
||||||
</div>
|
{
|
||||||
)}
|
'w-full': mode === 'edit',
|
||||||
|
'group-data-[role=user]/message:w-fit': mode !== 'edit',
|
||||||
<div className="flex flex-col gap-2 w-full">
|
},
|
||||||
{message.experimental_attachments && (
|
)}
|
||||||
<div className="flex flex-row justify-end gap-2">
|
>
|
||||||
{message.experimental_attachments.map((attachment) => (
|
{message.role === 'assistant' && (
|
||||||
<PreviewAttachment
|
<div className="size-8 flex items-center rounded-full justify-center ring-1 shrink-0 ring-border bg-background">
|
||||||
key={attachment.url}
|
<SparklesIcon size={14} />
|
||||||
attachment={attachment}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{message.content && mode === 'view' && (
|
<div className="flex flex-col gap-2 w-full">
|
||||||
<div className="flex flex-row gap-2 items-start">
|
{message.experimental_attachments && (
|
||||||
{message.role === 'user' && !isReadonly && (
|
<div className="flex flex-row justify-end gap-2">
|
||||||
<Tooltip>
|
{message.experimental_attachments.map((attachment) => (
|
||||||
<TooltipTrigger asChild>
|
<PreviewAttachment
|
||||||
<Button
|
key={attachment.url}
|
||||||
variant="ghost"
|
attachment={attachment}
|
||||||
className="px-2 h-fit rounded-full text-muted-foreground opacity-0 group-hover/message:opacity-100"
|
/>
|
||||||
onClick={() => {
|
))}
|
||||||
setMode('edit');
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<PencilEditIcon />
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>Edit message</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div
|
|
||||||
className={cn('flex flex-col gap-4', {
|
|
||||||
'bg-primary text-primary-foreground px-3 py-2 rounded-xl':
|
|
||||||
message.role === 'user',
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<Markdown>{message.content as string}</Markdown>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
|
||||||
|
|
||||||
{message.content && mode === 'edit' && (
|
{message.content && mode === 'view' && (
|
||||||
<div className="flex flex-row gap-2 items-start">
|
<div className="flex flex-row gap-2 items-start">
|
||||||
<div className="size-8" />
|
{message.role === 'user' && !isReadonly && (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="px-2 h-fit rounded-full text-muted-foreground opacity-0 group-hover/message:opacity-100"
|
||||||
|
onClick={() => {
|
||||||
|
setMode('edit');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PencilEditIcon />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Edit message</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
|
||||||
<MessageEditor
|
<div
|
||||||
key={message.id}
|
className={cn('flex flex-col gap-4', {
|
||||||
message={message}
|
'bg-primary text-primary-foreground px-3 py-2 rounded-xl':
|
||||||
setMode={setMode}
|
message.role === 'user',
|
||||||
setMessages={setMessages}
|
})}
|
||||||
reload={reload}
|
>
|
||||||
/>
|
<Markdown>{message.content as string}</Markdown>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{message.toolInvocations && message.toolInvocations.length > 0 && (
|
{message.content && mode === 'edit' && (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-row gap-2 items-start">
|
||||||
{message.toolInvocations.map((toolInvocation) => {
|
<div className="size-8" />
|
||||||
const { toolName, toolCallId, state, args } = toolInvocation;
|
|
||||||
|
|
||||||
if (state === 'result') {
|
<MessageEditor
|
||||||
const { result } = toolInvocation;
|
key={message.id}
|
||||||
|
message={message}
|
||||||
|
setMode={setMode}
|
||||||
|
setMessages={setMessages}
|
||||||
|
reload={reload}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{message.toolInvocations && message.toolInvocations.length > 0 && (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{message.toolInvocations.map((toolInvocation) => {
|
||||||
|
const { toolName, toolCallId, state, args } = toolInvocation;
|
||||||
|
|
||||||
|
if (state === 'result') {
|
||||||
|
const { result } = toolInvocation;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={toolCallId}>
|
||||||
|
{toolName === 'getWeather' ? (
|
||||||
|
<Weather weatherAtLocation={result} />
|
||||||
|
) : toolName === 'createDocument' ? (
|
||||||
|
<DocumentPreview
|
||||||
|
isReadonly={isReadonly}
|
||||||
|
result={result}
|
||||||
|
/>
|
||||||
|
) : toolName === 'updateDocument' ? (
|
||||||
|
<DocumentToolResult
|
||||||
|
type="update"
|
||||||
|
result={result}
|
||||||
|
isReadonly={isReadonly}
|
||||||
|
/>
|
||||||
|
) : toolName === 'requestSuggestions' ? (
|
||||||
|
<DocumentToolResult
|
||||||
|
type="request-suggestions"
|
||||||
|
result={result}
|
||||||
|
isReadonly={isReadonly}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<pre>{JSON.stringify(result, null, 2)}</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<div key={toolCallId}>
|
<div
|
||||||
|
key={toolCallId}
|
||||||
|
className={cx({
|
||||||
|
skeleton: ['getWeather'].includes(toolName),
|
||||||
|
})}
|
||||||
|
>
|
||||||
{toolName === 'getWeather' ? (
|
{toolName === 'getWeather' ? (
|
||||||
<Weather weatherAtLocation={result} />
|
<Weather />
|
||||||
) : toolName === 'createDocument' ? (
|
) : toolName === 'createDocument' ? (
|
||||||
<DocumentToolResult
|
<DocumentPreview isReadonly={isReadonly} args={args} />
|
||||||
type="create"
|
|
||||||
result={result}
|
|
||||||
block={block}
|
|
||||||
setBlock={setBlock}
|
|
||||||
isReadonly={isReadonly}
|
|
||||||
/>
|
|
||||||
) : toolName === 'updateDocument' ? (
|
) : toolName === 'updateDocument' ? (
|
||||||
<DocumentToolResult
|
<DocumentToolCall
|
||||||
type="update"
|
type="update"
|
||||||
result={result}
|
args={args}
|
||||||
block={block}
|
|
||||||
setBlock={setBlock}
|
|
||||||
isReadonly={isReadonly}
|
isReadonly={isReadonly}
|
||||||
/>
|
/>
|
||||||
) : toolName === 'requestSuggestions' ? (
|
) : toolName === 'requestSuggestions' ? (
|
||||||
<DocumentToolResult
|
<DocumentToolCall
|
||||||
type="request-suggestions"
|
type="request-suggestions"
|
||||||
result={result}
|
args={args}
|
||||||
block={block}
|
|
||||||
setBlock={setBlock}
|
|
||||||
isReadonly={isReadonly}
|
isReadonly={isReadonly}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : null}
|
||||||
<pre>{JSON.stringify(result, null, 2)}</pre>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
})}
|
||||||
return (
|
</div>
|
||||||
<div
|
)}
|
||||||
key={toolCallId}
|
|
||||||
className={cx({
|
|
||||||
skeleton: ['getWeather'].includes(toolName),
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{toolName === 'getWeather' ? (
|
|
||||||
<Weather />
|
|
||||||
) : toolName === 'createDocument' ? (
|
|
||||||
<DocumentToolCall
|
|
||||||
type="create"
|
|
||||||
args={args}
|
|
||||||
setBlock={setBlock}
|
|
||||||
isReadonly={isReadonly}
|
|
||||||
/>
|
|
||||||
) : toolName === 'updateDocument' ? (
|
|
||||||
<DocumentToolCall
|
|
||||||
type="update"
|
|
||||||
args={args}
|
|
||||||
setBlock={setBlock}
|
|
||||||
isReadonly={isReadonly}
|
|
||||||
/>
|
|
||||||
) : toolName === 'requestSuggestions' ? (
|
|
||||||
<DocumentToolCall
|
|
||||||
type="request-suggestions"
|
|
||||||
args={args}
|
|
||||||
setBlock={setBlock}
|
|
||||||
isReadonly={isReadonly}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!isReadonly && (
|
{!isReadonly && (
|
||||||
<MessageActions
|
<MessageActions
|
||||||
key={`action-${message.id}`}
|
key={`action-${message.id}`}
|
||||||
chatId={chatId}
|
chatId={chatId}
|
||||||
message={message}
|
message={message}
|
||||||
vote={vote}
|
vote={vote}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</motion.div>
|
||||||
</motion.div>
|
</AnimatePresence>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,15 +2,12 @@ import { ChatRequestOptions, Message } from 'ai';
|
||||||
import { PreviewMessage, ThinkingMessage } from './message';
|
import { PreviewMessage, ThinkingMessage } from './message';
|
||||||
import { useScrollToBottom } from './use-scroll-to-bottom';
|
import { useScrollToBottom } from './use-scroll-to-bottom';
|
||||||
import { Overview } from './overview';
|
import { Overview } from './overview';
|
||||||
import { UIBlock } from './block';
|
import { memo } from 'react';
|
||||||
import { Dispatch, memo, SetStateAction } from 'react';
|
|
||||||
import { Vote } from '@/lib/db/schema';
|
import { Vote } from '@/lib/db/schema';
|
||||||
import equal from 'fast-deep-equal';
|
import equal from 'fast-deep-equal';
|
||||||
|
|
||||||
interface MessagesProps {
|
interface MessagesProps {
|
||||||
chatId: string;
|
chatId: string;
|
||||||
block: UIBlock;
|
|
||||||
setBlock: Dispatch<SetStateAction<UIBlock>>;
|
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
votes: Array<Vote> | undefined;
|
votes: Array<Vote> | undefined;
|
||||||
messages: Array<Message>;
|
messages: Array<Message>;
|
||||||
|
|
@ -21,12 +18,11 @@ interface MessagesProps {
|
||||||
chatRequestOptions?: ChatRequestOptions,
|
chatRequestOptions?: ChatRequestOptions,
|
||||||
) => Promise<string | null | undefined>;
|
) => Promise<string | null | undefined>;
|
||||||
isReadonly: boolean;
|
isReadonly: boolean;
|
||||||
|
isBlockVisible: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function PureMessages({
|
function PureMessages({
|
||||||
chatId,
|
chatId,
|
||||||
block,
|
|
||||||
setBlock,
|
|
||||||
isLoading,
|
isLoading,
|
||||||
votes,
|
votes,
|
||||||
messages,
|
messages,
|
||||||
|
|
@ -42,15 +38,13 @@ function PureMessages({
|
||||||
ref={messagesContainerRef}
|
ref={messagesContainerRef}
|
||||||
className="flex flex-col min-w-0 gap-6 flex-1 overflow-y-scroll pt-4"
|
className="flex flex-col min-w-0 gap-6 flex-1 overflow-y-scroll pt-4"
|
||||||
>
|
>
|
||||||
{messages.length === 0 && <Overview />}
|
{/* {messages.length === 0 && <Overview />} */}
|
||||||
|
|
||||||
{messages.map((message, index) => (
|
{messages.map((message, index) => (
|
||||||
<PreviewMessage
|
<PreviewMessage
|
||||||
key={message.id}
|
key={message.id}
|
||||||
chatId={chatId}
|
chatId={chatId}
|
||||||
message={message}
|
message={message}
|
||||||
block={block}
|
|
||||||
setBlock={setBlock}
|
|
||||||
isLoading={isLoading && messages.length - 1 === index}
|
isLoading={isLoading && messages.length - 1 === index}
|
||||||
vote={
|
vote={
|
||||||
votes
|
votes
|
||||||
|
|
@ -76,6 +70,8 @@ function PureMessages({
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Messages = memo(PureMessages, (prevProps, nextProps) => {
|
export const Messages = memo(PureMessages, (prevProps, nextProps) => {
|
||||||
|
if (prevProps.isBlockVisible && nextProps.isBlockVisible) return true;
|
||||||
|
|
||||||
if (prevProps.isLoading !== nextProps.isLoading) return false;
|
if (prevProps.isLoading !== nextProps.isLoading) return false;
|
||||||
if (prevProps.isLoading && nextProps.isLoading) return false;
|
if (prevProps.isLoading && nextProps.isLoading) return false;
|
||||||
if (prevProps.messages.length !== nextProps.messages.length) return false;
|
if (prevProps.messages.length !== nextProps.messages.length) return false;
|
||||||
|
|
|
||||||
|
|
@ -21,9 +21,9 @@ function PureSuggestedActions({ chatId, append }: SuggestedActionsProps) {
|
||||||
action: 'What is the weather in San Francisco?',
|
action: 'What is the weather in San Francisco?',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Help me write code to',
|
title: 'Write code that',
|
||||||
label: 'demonstrate the djikstra algorithm!',
|
label: `demonstrates djikstra's algorithm!`,
|
||||||
action: 'Help me write code to demonstrate the djikstra algorithm!',
|
action: `Write code that demonstrates djikstra's algorithm!`,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 type { UIBlock } from './block';
|
||||||
import { LoaderIcon } from './icons';
|
import { LoaderIcon } from './icons';
|
||||||
import { Button } from './ui/button';
|
import { Button } from './ui/button';
|
||||||
|
import { useBlock } from '@/hooks/use-block';
|
||||||
|
|
||||||
interface VersionFooterProps {
|
interface VersionFooterProps {
|
||||||
block: UIBlock;
|
|
||||||
handleVersionChange: (type: 'next' | 'prev' | 'toggle' | 'latest') => void;
|
handleVersionChange: (type: 'next' | 'prev' | 'toggle' | 'latest') => void;
|
||||||
documents: Array<Document> | undefined;
|
documents: Array<Document> | undefined;
|
||||||
currentVersionIndex: number;
|
currentVersionIndex: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const VersionFooter = ({
|
export const VersionFooter = ({
|
||||||
block,
|
|
||||||
handleVersionChange,
|
handleVersionChange,
|
||||||
documents,
|
documents,
|
||||||
currentVersionIndex,
|
currentVersionIndex,
|
||||||
}: VersionFooterProps) => {
|
}: VersionFooterProps) => {
|
||||||
|
const { block } = useBlock();
|
||||||
|
|
||||||
const { width } = useWindowSize();
|
const { width } = useWindowSize();
|
||||||
const isMobile = width < 768;
|
const isMobile = width < 768;
|
||||||
|
|
||||||
|
|
|
||||||
68
hooks/use-block.ts
Normal file
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 = `
|
export const blocksPrompt = `
|
||||||
Blocks is a special user interface mode that helps users with writing, editing, and other content creation tasks. When block is open, it is on the right side of the screen, while the conversation is on the left side. When creating or updating documents, changes are reflected in real-time on the blocks and visible to the user.
|
Blocks is a special user interface mode that helps users with writing, editing, and other content creation tasks. When block is open, it is on the right side of the screen, while the conversation is on the left side. When creating or updating documents, changes are reflected in real-time on the blocks and visible to the user.
|
||||||
|
|
||||||
When writing code, specify the language in the backticks, e.g. \`\`\`python\`code here\`\`\`. The default language is Python. Other languages are not yet supported, so let the user know if they request a different language.
|
When asked to write code, always use blocks. When writing code, specify the language in the backticks, e.g. \`\`\`python\`code here\`\`\`. The default language is Python. Other languages are not yet supported, so let the user know if they request a different language.
|
||||||
|
|
||||||
This is a guide for using blocks tools: \`createDocument\` and \`updateDocument\`, which render content on a blocks beside the conversation.
|
This is a guide for using blocks tools: \`createDocument\` and \`updateDocument\`, which render content on a blocks beside the conversation.
|
||||||
|
|
||||||
**When to use \`createDocument\`:**
|
**When to use \`createDocument\`:**
|
||||||
- For substantial content (>10 lines) or code
|
- For substantial content (>10 lines) or code
|
||||||
- For content users will likely save/reuse (emails, code, essays, etc.)
|
- For content users will likely save/reuse (emails, code, essays, etc.)
|
||||||
- When explicitly requested to create a document
|
- When explicitly requested to create a document
|
||||||
- For when content contains a single code snippet
|
- For when content contains a single code snippet
|
||||||
|
|
||||||
**When NOT to use \`createDocument\`:**
|
**When NOT to use \`createDocument\`:**
|
||||||
- For informational/explanatory content
|
- For informational/explanatory content
|
||||||
- For conversational responses
|
- For conversational responses
|
||||||
- When asked to keep it in chat
|
- When asked to keep it in chat
|
||||||
|
|
||||||
**Using \`updateDocument\`:**
|
**Using \`updateDocument\`:**
|
||||||
- Default to full document rewrites for major changes
|
- Default to full document rewrites for major changes
|
||||||
- Use targeted updates only for specific, isolated changes
|
- Use targeted updates only for specific, isolated changes
|
||||||
- Follow user instructions for which parts to modify
|
- Follow user instructions for which parts to modify
|
||||||
|
|
||||||
Do not update document right after creating it. Wait for user feedback or request to update it.
|
Do not update document right after creating it. Wait for user feedback or request to update it.
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const regularPrompt =
|
export const regularPrompt =
|
||||||
'You are a friendly assistant! Keep your responses concise and helpful.';
|
'You are a friendly assistant! Keep your responses concise and helpful.';
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@
|
||||||
"@vercel/analytics": "^1.3.1",
|
"@vercel/analytics": "^1.3.1",
|
||||||
"@vercel/blob": "^0.24.1",
|
"@vercel/blob": "^0.24.1",
|
||||||
"@vercel/postgres": "^0.10.0",
|
"@vercel/postgres": "^0.10.0",
|
||||||
"ai": "4.0.10",
|
"ai": "4.0.20",
|
||||||
"bcrypt-ts": "^5.0.2",
|
"bcrypt-ts": "^5.0.2",
|
||||||
"class-variance-authority": "^0.7.0",
|
"class-variance-authority": "^0.7.0",
|
||||||
"classnames": "^2.5.1",
|
"classnames": "^2.5.1",
|
||||||
|
|
|
||||||
71
pnpm-lock.yaml
generated
71
pnpm-lock.yaml
generated
|
|
@ -66,8 +66,8 @@ importers:
|
||||||
specifier: ^0.10.0
|
specifier: ^0.10.0
|
||||||
version: 0.10.0
|
version: 0.10.0
|
||||||
ai:
|
ai:
|
||||||
specifier: 4.0.10
|
specifier: 4.0.20
|
||||||
version: 4.0.10(react@19.0.0-rc-45804af1-20241021)(zod@3.23.8)
|
version: 4.0.20(react@19.0.0-rc-45804af1-20241021)(zod@3.23.8)
|
||||||
bcrypt-ts:
|
bcrypt-ts:
|
||||||
specifier: ^5.0.2
|
specifier: ^5.0.2
|
||||||
version: 5.0.2
|
version: 5.0.2
|
||||||
|
|
@ -255,12 +255,25 @@ packages:
|
||||||
zod:
|
zod:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@ai-sdk/provider-utils@2.0.4':
|
||||||
|
resolution: {integrity: sha512-GMhcQCZbwM6RoZCri0MWeEWXRt/T+uCxsmHEsTwNvEH3GDjNzchfX25C8ftry2MeEOOn6KfqCLSKomcgK6RoOg==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
peerDependencies:
|
||||||
|
zod: ^3.0.0
|
||||||
|
peerDependenciesMeta:
|
||||||
|
zod:
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@ai-sdk/provider@1.0.1':
|
'@ai-sdk/provider@1.0.1':
|
||||||
resolution: {integrity: sha512-mV+3iNDkzUsZ0pR2jG0sVzU6xtQY5DtSCBy3JFycLp6PwjyLw/iodfL3MwdmMCRJWgs3dadcHejRnMvF9nGTBg==}
|
resolution: {integrity: sha512-mV+3iNDkzUsZ0pR2jG0sVzU6xtQY5DtSCBy3JFycLp6PwjyLw/iodfL3MwdmMCRJWgs3dadcHejRnMvF9nGTBg==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
'@ai-sdk/react@1.0.3':
|
'@ai-sdk/provider@1.0.2':
|
||||||
resolution: {integrity: sha512-Mak7qIRlbgtP4I7EFoNKRIQTlABJHhgwrN8SV2WKKdmsfWK2RwcubQWz1hp88cQ0bpF6KxxjSY1UUnS/S9oR5g==}
|
resolution: {integrity: sha512-YYtP6xWQyaAf5LiWLJ+ycGTOeBLWrED7LUrvc+SQIWhGaneylqbaGsyQL7VouQUeQ4JZ1qKYZuhmi3W56HADPA==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
'@ai-sdk/react@1.0.6':
|
||||||
|
resolution: {integrity: sha512-8Hkserq0Ge6AEi7N4hlv2FkfglAGbkoAXEZ8YSp255c3PbnZz6+/5fppw+aROmZMOfNwallSRuy1i/iPa2rBpQ==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^18 || ^19 || ^19.0.0-rc
|
react: ^18 || ^19 || ^19.0.0-rc
|
||||||
|
|
@ -271,8 +284,8 @@ packages:
|
||||||
zod:
|
zod:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@ai-sdk/ui-utils@1.0.2':
|
'@ai-sdk/ui-utils@1.0.5':
|
||||||
resolution: {integrity: sha512-hHrUdeThGHu/rsGZBWQ9PjrAU9Htxgbo9MFyR5B/aWoNbBeXn1HLMY1+uMEnXL5pRPlmyVRjgIavWg7UgeNDOw==}
|
resolution: {integrity: sha512-DGJSbDf+vJyWmFNexSPUsS1AAy7gtsmFmoSyNbNbJjwl9hRIf2dknfA1V0ahx6pg3NNklNYFm53L8Nphjovfvg==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
zod: ^3.0.0
|
zod: ^3.0.0
|
||||||
|
|
@ -1613,8 +1626,8 @@ packages:
|
||||||
engines: {node: '>=0.4.0'}
|
engines: {node: '>=0.4.0'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
ai@4.0.10:
|
ai@4.0.20:
|
||||||
resolution: {integrity: sha512-40GaEGLbp7if1F50zp3Kr03vcqyGS8svyJWpbkgec7G5Ik2rEtnbDWiUoOJuAVqgP5/iy4NgZQfvX3jRmOyQrw==}
|
resolution: {integrity: sha512-dYevYKtREcjSVopBDFWVNca7WJEI1p9Vr9eo7V7fZHzi2vXGDyEa2WYatjFbpR6z6gpVAxKHsof8EoN+B1IAsA==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^18 || ^19 || ^19.0.0-rc
|
react: ^18 || ^19 || ^19.0.0-rc
|
||||||
|
|
@ -2876,6 +2889,11 @@ packages:
|
||||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
nanoid@3.3.8:
|
||||||
|
resolution: {integrity: sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==}
|
||||||
|
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
nanoid@5.0.8:
|
nanoid@5.0.8:
|
||||||
resolution: {integrity: sha512-TcJPw+9RV9dibz1hHUzlLVy8N4X9TnwirAjrU08Juo6BNKggzVfP2ZJ/3ZUSq15Xl5i85i+Z89XBO90pB2PghQ==}
|
resolution: {integrity: sha512-TcJPw+9RV9dibz1hHUzlLVy8N4X9TnwirAjrU08Juo6BNKggzVfP2ZJ/3ZUSq15Xl5i85i+Z89XBO90pB2PghQ==}
|
||||||
engines: {node: ^18 || >=20}
|
engines: {node: ^18 || >=20}
|
||||||
|
|
@ -3748,24 +3766,37 @@ snapshots:
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
zod: 3.23.8
|
zod: 3.23.8
|
||||||
|
|
||||||
|
'@ai-sdk/provider-utils@2.0.4(zod@3.23.8)':
|
||||||
|
dependencies:
|
||||||
|
'@ai-sdk/provider': 1.0.2
|
||||||
|
eventsource-parser: 3.0.0
|
||||||
|
nanoid: 3.3.8
|
||||||
|
secure-json-parse: 2.7.0
|
||||||
|
optionalDependencies:
|
||||||
|
zod: 3.23.8
|
||||||
|
|
||||||
'@ai-sdk/provider@1.0.1':
|
'@ai-sdk/provider@1.0.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
json-schema: 0.4.0
|
json-schema: 0.4.0
|
||||||
|
|
||||||
'@ai-sdk/react@1.0.3(react@19.0.0-rc-45804af1-20241021)(zod@3.23.8)':
|
'@ai-sdk/provider@1.0.2':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@ai-sdk/provider-utils': 2.0.2(zod@3.23.8)
|
json-schema: 0.4.0
|
||||||
'@ai-sdk/ui-utils': 1.0.2(zod@3.23.8)
|
|
||||||
|
'@ai-sdk/react@1.0.6(react@19.0.0-rc-45804af1-20241021)(zod@3.23.8)':
|
||||||
|
dependencies:
|
||||||
|
'@ai-sdk/provider-utils': 2.0.4(zod@3.23.8)
|
||||||
|
'@ai-sdk/ui-utils': 1.0.5(zod@3.23.8)
|
||||||
swr: 2.2.5(react@19.0.0-rc-45804af1-20241021)
|
swr: 2.2.5(react@19.0.0-rc-45804af1-20241021)
|
||||||
throttleit: 2.1.0
|
throttleit: 2.1.0
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
react: 19.0.0-rc-45804af1-20241021
|
react: 19.0.0-rc-45804af1-20241021
|
||||||
zod: 3.23.8
|
zod: 3.23.8
|
||||||
|
|
||||||
'@ai-sdk/ui-utils@1.0.2(zod@3.23.8)':
|
'@ai-sdk/ui-utils@1.0.5(zod@3.23.8)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@ai-sdk/provider': 1.0.1
|
'@ai-sdk/provider': 1.0.2
|
||||||
'@ai-sdk/provider-utils': 2.0.2(zod@3.23.8)
|
'@ai-sdk/provider-utils': 2.0.4(zod@3.23.8)
|
||||||
zod-to-json-schema: 3.23.5(zod@3.23.8)
|
zod-to-json-schema: 3.23.5(zod@3.23.8)
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
zod: 3.23.8
|
zod: 3.23.8
|
||||||
|
|
@ -4865,12 +4896,12 @@ snapshots:
|
||||||
|
|
||||||
acorn@8.14.0: {}
|
acorn@8.14.0: {}
|
||||||
|
|
||||||
ai@4.0.10(react@19.0.0-rc-45804af1-20241021)(zod@3.23.8):
|
ai@4.0.20(react@19.0.0-rc-45804af1-20241021)(zod@3.23.8):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@ai-sdk/provider': 1.0.1
|
'@ai-sdk/provider': 1.0.2
|
||||||
'@ai-sdk/provider-utils': 2.0.2(zod@3.23.8)
|
'@ai-sdk/provider-utils': 2.0.4(zod@3.23.8)
|
||||||
'@ai-sdk/react': 1.0.3(react@19.0.0-rc-45804af1-20241021)(zod@3.23.8)
|
'@ai-sdk/react': 1.0.6(react@19.0.0-rc-45804af1-20241021)(zod@3.23.8)
|
||||||
'@ai-sdk/ui-utils': 1.0.2(zod@3.23.8)
|
'@ai-sdk/ui-utils': 1.0.5(zod@3.23.8)
|
||||||
'@opentelemetry/api': 1.9.0
|
'@opentelemetry/api': 1.9.0
|
||||||
jsondiffpatch: 0.6.0
|
jsondiffpatch: 0.6.0
|
||||||
zod-to-json-schema: 3.23.5(zod@3.23.8)
|
zod-to-json-schema: 3.23.5(zod@3.23.8)
|
||||||
|
|
@ -6484,6 +6515,8 @@ snapshots:
|
||||||
|
|
||||||
nanoid@3.3.7: {}
|
nanoid@3.3.7: {}
|
||||||
|
|
||||||
|
nanoid@3.3.8: {}
|
||||||
|
|
||||||
nanoid@5.0.8: {}
|
nanoid@5.0.8: {}
|
||||||
|
|
||||||
natural-compare@1.4.0: {}
|
natural-compare@1.4.0: {}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue