Add canvas interface (#461)
This commit is contained in:
parent
1a74a5ca9a
commit
b3cb0ea755
44 changed files with 7454 additions and 4691 deletions
43
components/custom/canvas-stream-handler.tsx
Normal file
43
components/custom/canvas-stream-handler.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { JSONValue } from 'ai';
|
||||
import { Dispatch, memo, SetStateAction } from 'react';
|
||||
|
||||
import { UICanvas } from './canvas';
|
||||
import { useCanvasStream } from './use-canvas-stream';
|
||||
|
||||
interface CanvasStreamHandlerProps {
|
||||
setCanvas: Dispatch<SetStateAction<UICanvas | null>>;
|
||||
streamingData: JSONValue[] | undefined;
|
||||
}
|
||||
|
||||
export function PureCanvasStreamHandler({
|
||||
setCanvas,
|
||||
streamingData,
|
||||
}: CanvasStreamHandlerProps) {
|
||||
useCanvasStream({
|
||||
streamingData,
|
||||
setCanvas,
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function areEqual(
|
||||
prevProps: CanvasStreamHandlerProps,
|
||||
nextProps: CanvasStreamHandlerProps
|
||||
) {
|
||||
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 CanvasStreamHandler = memo(PureCanvasStreamHandler, areEqual);
|
||||
545
components/custom/canvas.tsx
Normal file
545
components/custom/canvas.tsx
Normal file
|
|
@ -0,0 +1,545 @@
|
|||
import { Attachment, ChatRequestOptions, CreateMessage, Message } from 'ai';
|
||||
import cx from 'classnames';
|
||||
import { formatDistance, isAfter } from 'date-fns';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import {
|
||||
Dispatch,
|
||||
SetStateAction,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useState,
|
||||
} from 'react';
|
||||
import useSWR, { useSWRConfig } from 'swr';
|
||||
|
||||
import { Document, Suggestion } from '@/db/schema';
|
||||
import { fetcher } from '@/lib/utils';
|
||||
|
||||
import { DiffView } from './diffview';
|
||||
import { DocumentSkeleton } from './document-skeleton';
|
||||
import { Editor } from './editor';
|
||||
import { CrossIcon, DeltaIcon, RedoIcon, UndoIcon } from './icons';
|
||||
import { Message as PreviewMessage } from './message';
|
||||
import { MultimodalInput } from './multimodal-input';
|
||||
import { Toolbar } from './toolbar';
|
||||
import { useDebounce } from './use-debounce';
|
||||
import { useScrollToBottom } from './use-scroll-to-bottom';
|
||||
import useWindowSize from './use-window-size';
|
||||
import { Button } from '../ui/button';
|
||||
|
||||
export interface UICanvas {
|
||||
title: string;
|
||||
documentId: string;
|
||||
content: string;
|
||||
isVisible: boolean;
|
||||
status: 'streaming' | 'idle';
|
||||
boundingBox: {
|
||||
top: number;
|
||||
left: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
}
|
||||
|
||||
export function Canvas({
|
||||
input,
|
||||
setInput,
|
||||
handleSubmit,
|
||||
isLoading,
|
||||
stop,
|
||||
attachments,
|
||||
setAttachments,
|
||||
append,
|
||||
canvas,
|
||||
setCanvas,
|
||||
messages,
|
||||
setMessages,
|
||||
}: {
|
||||
input: string;
|
||||
setInput: (input: string) => void;
|
||||
isLoading: boolean;
|
||||
stop: () => void;
|
||||
attachments: Array<Attachment>;
|
||||
setAttachments: Dispatch<SetStateAction<Array<Attachment>>>;
|
||||
canvas: UICanvas;
|
||||
setCanvas: Dispatch<SetStateAction<UICanvas | null>>;
|
||||
messages: Array<Message>;
|
||||
setMessages: Dispatch<SetStateAction<Array<Message>>>;
|
||||
append: (
|
||||
message: Message | CreateMessage,
|
||||
chatRequestOptions?: ChatRequestOptions
|
||||
) => Promise<string | null | undefined>;
|
||||
handleSubmit: (
|
||||
event?: {
|
||||
preventDefault?: () => void;
|
||||
},
|
||||
chatRequestOptions?: ChatRequestOptions
|
||||
) => void;
|
||||
}) {
|
||||
const [messagesContainerRef, messagesEndRef] =
|
||||
useScrollToBottom<HTMLDivElement>();
|
||||
|
||||
const {
|
||||
data: documents,
|
||||
isLoading: isDocumentsFetching,
|
||||
isValidating: isDocumentsValidating,
|
||||
mutate: mutateDocuments,
|
||||
} = useSWR<Array<Document>>(
|
||||
canvas && canvas.status !== 'streaming'
|
||||
? `/api/document?id=${canvas.documentId}`
|
||||
: null,
|
||||
fetcher
|
||||
);
|
||||
|
||||
const { data: suggestions } = useSWR<Array<Suggestion>>(
|
||||
documents && canvas && canvas.status !== 'streaming'
|
||||
? `/api/suggestions?documentId=${canvas.documentId}`
|
||||
: null,
|
||||
fetcher,
|
||||
{
|
||||
dedupingInterval: 5000,
|
||||
}
|
||||
);
|
||||
|
||||
const [mode, setMode] = useState<'edit' | 'diff'>('edit');
|
||||
const [document, setDocument] = useState<Document | null>(null);
|
||||
const [currentVersionIndex, setCurrentVersionIndex] = useState(-1);
|
||||
|
||||
useEffect(() => {
|
||||
if (documents && documents.length > 0) {
|
||||
const mostRecentDocument = documents.at(-1);
|
||||
|
||||
if (mostRecentDocument) {
|
||||
setDocument(mostRecentDocument);
|
||||
setCurrentVersionIndex(documents.length - 1);
|
||||
setCanvas((currentCanvas) =>
|
||||
currentCanvas
|
||||
? {
|
||||
...currentCanvas,
|
||||
content: mostRecentDocument.content ?? '',
|
||||
}
|
||||
: null
|
||||
);
|
||||
}
|
||||
}
|
||||
}, [documents, setCanvas]);
|
||||
|
||||
useEffect(() => {
|
||||
mutateDocuments();
|
||||
}, [canvas.status, mutateDocuments]);
|
||||
|
||||
const { mutate } = useSWRConfig();
|
||||
const [isContentDirty, setIsContentDirty] = useState(false);
|
||||
|
||||
const handleContentChange = useCallback(
|
||||
(updatedContent: string) => {
|
||||
if (!canvas) return;
|
||||
|
||||
mutate<Array<Document>>(
|
||||
`/api/document?id=${canvas.documentId}`,
|
||||
async (currentDocuments) => {
|
||||
if (!currentDocuments) return undefined;
|
||||
|
||||
const currentDocument = currentDocuments.at(-1);
|
||||
|
||||
if (!currentDocument || !currentDocument.content) {
|
||||
setIsContentDirty(false);
|
||||
return currentDocuments;
|
||||
}
|
||||
|
||||
if (currentDocument.content !== updatedContent) {
|
||||
await fetch(`/api/document?id=${canvas.documentId}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
title: canvas.title,
|
||||
content: updatedContent,
|
||||
}),
|
||||
});
|
||||
|
||||
setIsContentDirty(false);
|
||||
|
||||
const newDocument = {
|
||||
...currentDocument,
|
||||
content: updatedContent,
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
return [...currentDocuments, newDocument];
|
||||
} else {
|
||||
return currentDocuments;
|
||||
}
|
||||
},
|
||||
{ revalidate: false }
|
||||
);
|
||||
},
|
||||
[canvas, mutate]
|
||||
);
|
||||
|
||||
const debouncedHandleEditorChange = useCallback(
|
||||
useDebounce(handleContentChange, 4000),
|
||||
[handleContentChange]
|
||||
);
|
||||
|
||||
const handleEditorChange = useCallback(
|
||||
(updatedContent: string) => {
|
||||
if (document && updatedContent !== document.content) {
|
||||
debouncedHandleEditorChange(updatedContent);
|
||||
setIsContentDirty(true);
|
||||
}
|
||||
},
|
||||
[document, debouncedHandleEditorChange]
|
||||
);
|
||||
|
||||
function getDocumentContentById(index: number) {
|
||||
if (!documents) return '';
|
||||
if (!documents[index]) return '';
|
||||
return documents[index].content ?? '';
|
||||
}
|
||||
|
||||
function getDocumentTimestampById(index: number) {
|
||||
if (!documents) return '';
|
||||
return documents[index]?.createdAt ?? '';
|
||||
}
|
||||
|
||||
const handleVersionChange = (type: 'next' | 'prev' | 'toggle' | 'latest') => {
|
||||
if (!documents) return;
|
||||
|
||||
if (type === 'latest') {
|
||||
setCurrentVersionIndex(documents.length - 1);
|
||||
setMode('edit');
|
||||
}
|
||||
|
||||
if (type === 'toggle') {
|
||||
setMode((mode) => (mode === 'edit' ? 'diff' : 'edit'));
|
||||
}
|
||||
|
||||
if (type === 'prev') {
|
||||
if (currentVersionIndex > 0) {
|
||||
setCurrentVersionIndex((index) => index - 1);
|
||||
}
|
||||
} else if (type === 'next') {
|
||||
if (currentVersionIndex < documents.length - 1) {
|
||||
setCurrentVersionIndex((index) => index + 1);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const [isToolbarVisible, setIsToolbarVisible] = useState(false);
|
||||
|
||||
/*
|
||||
* NOTE: if there are no documents, or if
|
||||
* the documents are being fetched, then
|
||||
* we mark it as the current version.
|
||||
*/
|
||||
|
||||
const isCurrentVersion =
|
||||
isDocumentsFetching || isDocumentsValidating
|
||||
? true
|
||||
: documents && documents.length > 0
|
||||
? currentVersionIndex === documents.length - 1
|
||||
: true;
|
||||
|
||||
const { width: windowWidth, height: windowHeight } = useWindowSize();
|
||||
const isMobile = windowWidth ? windowWidth < 768 : false;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="flex flex-row h-dvh w-dvw fixed top-0 left-0 z-50 bg-muted"
|
||||
initial={{ opacity: 1 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0, transition: { delay: 0.4 } }}
|
||||
>
|
||||
{!isMobile && (
|
||||
<motion.div
|
||||
className="relative w-[400px] bg-muted dark:bg-background h-dvh shrink-0"
|
||||
initial={{ opacity: 0, x: 10, scale: 1 }}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
x: 0,
|
||||
scale: 1,
|
||||
transition: {
|
||||
delay: 0.2,
|
||||
type: 'spring',
|
||||
stiffness: 200,
|
||||
damping: 30,
|
||||
},
|
||||
}}
|
||||
exit={{ opacity: 0, x: 10, scale: 0.95, transition: { delay: 0 } }}
|
||||
>
|
||||
<div className="-right-12 w-12 bg-muted absolute h-dvh top-0" />
|
||||
<div className="flex flex-col h-full justify-between items-center gap-4">
|
||||
<div
|
||||
ref={messagesContainerRef}
|
||||
className="flex flex-col gap-4 h-full items-center overflow-y-scroll px-4 pt-20"
|
||||
>
|
||||
{messages.map((message) => (
|
||||
<PreviewMessage
|
||||
key={message.id}
|
||||
role={message.role}
|
||||
content={message.content}
|
||||
attachments={message.experimental_attachments}
|
||||
toolInvocations={message.toolInvocations}
|
||||
canvas={canvas}
|
||||
setCanvas={setCanvas}
|
||||
/>
|
||||
))}
|
||||
|
||||
<div
|
||||
ref={messagesEndRef}
|
||||
className="shrink-0 min-w-[24px] min-h-[24px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<form className="flex flex-row gap-2 relative items-end w-full px-4 pb-4">
|
||||
<MultimodalInput
|
||||
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 shadow-xl overflow-y-scroll"
|
||||
initial={
|
||||
isMobile
|
||||
? {
|
||||
opacity: 0,
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: windowWidth,
|
||||
height: windowHeight,
|
||||
borderRadius: 50,
|
||||
}
|
||||
: {
|
||||
opacity: 0,
|
||||
x: canvas.boundingBox.left,
|
||||
y: canvas.boundingBox.top,
|
||||
height: canvas.boundingBox.height,
|
||||
width: canvas.boundingBox.width,
|
||||
borderRadius: 50,
|
||||
}
|
||||
}
|
||||
animate={
|
||||
isMobile
|
||||
? {
|
||||
opacity: 1,
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: windowWidth,
|
||||
height: '100dvh',
|
||||
borderRadius: 0,
|
||||
transition: {
|
||||
delay: 0,
|
||||
type: 'spring',
|
||||
stiffness: 200,
|
||||
damping: 30,
|
||||
},
|
||||
}
|
||||
: {
|
||||
opacity: 1,
|
||||
x: 400,
|
||||
y: 0,
|
||||
height: windowHeight,
|
||||
width: windowWidth ? windowWidth - 400 : 'calc(100dvw-400px)',
|
||||
borderRadius: 0,
|
||||
transition: {
|
||||
delay: 0,
|
||||
type: 'spring',
|
||||
stiffness: 200,
|
||||
damping: 30,
|
||||
},
|
||||
}
|
||||
}
|
||||
exit={{
|
||||
opacity: 0,
|
||||
scale: 0.5,
|
||||
transition: {
|
||||
delay: 0.1,
|
||||
type: 'spring',
|
||||
stiffness: 600,
|
||||
damping: 30,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<div className="p-2 flex flex-row justify-between items-start">
|
||||
<div className="flex flex-row gap-4 items-start">
|
||||
<div
|
||||
className="cursor-pointer hover:bg-muted p-2 rounded-lg text-muted-foreground"
|
||||
onClick={() => {
|
||||
setCanvas(null);
|
||||
}}
|
||||
>
|
||||
<CrossIcon size={18} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col pt-1">
|
||||
<div className="font-medium">
|
||||
{document?.title ?? canvas.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>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row gap-1">
|
||||
<div
|
||||
className="cursor-pointer hover:bg-muted p-2 rounded-lg text-muted-foreground"
|
||||
onClick={() => {
|
||||
handleVersionChange('prev');
|
||||
}}
|
||||
>
|
||||
<UndoIcon size={18} />
|
||||
</div>
|
||||
<div
|
||||
className="cursor-pointer hover:bg-muted p-2 rounded-lg text-muted-foreground"
|
||||
onClick={() => {
|
||||
handleVersionChange('next');
|
||||
}}
|
||||
>
|
||||
<RedoIcon size={18} />
|
||||
</div>
|
||||
<div
|
||||
className={cx(
|
||||
'cursor-pointer hover:bg-muted p-2 rounded-lg text-muted-foreground',
|
||||
{ 'bg-muted': mode === 'diff' }
|
||||
)}
|
||||
onClick={() => {
|
||||
handleVersionChange('toggle');
|
||||
}}
|
||||
>
|
||||
<DeltaIcon size={18} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="prose dark:prose-invert dark:bg-muted bg-background h-full overflow-y-scroll px-4 py-8 md:p-20 !max-w-full pb-40 items-center">
|
||||
<div className="flex flex-row max-w-[600px] mx-auto">
|
||||
{isDocumentsFetching && !canvas.content ? (
|
||||
<DocumentSkeleton />
|
||||
) : mode === 'edit' ? (
|
||||
<Editor
|
||||
content={
|
||||
isCurrentVersion
|
||||
? canvas.content
|
||||
: getDocumentContentById(currentVersionIndex)
|
||||
}
|
||||
currentVersionIndex={currentVersionIndex}
|
||||
status={canvas.status ?? 'idle'}
|
||||
onChange={handleEditorChange}
|
||||
suggestions={isCurrentVersion ? (suggestions ?? []) : []}
|
||||
/>
|
||||
) : (
|
||||
<DiffView
|
||||
oldContent={getDocumentContentById(currentVersionIndex - 1)}
|
||||
newContent={getDocumentContentById(currentVersionIndex)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{suggestions ? (
|
||||
<div className="md:hidden h-dvh w-12 shrink-0" />
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{!isCurrentVersion && (
|
||||
<motion.div
|
||||
className="absolute flex flex-col gap-4 lg:flex-row bottom-0 bg-background p-4 w-full border-t z-50 justify-between"
|
||||
initial={{ y: isMobile ? 200 : 77 }}
|
||||
animate={{ y: 0 }}
|
||||
exit={{ y: isMobile ? 200 : 77 }}
|
||||
transition={{ type: 'spring', stiffness: 140, damping: 20 }}
|
||||
>
|
||||
<div>
|
||||
<div>You are viewing a previous version</div>
|
||||
<div className="text-muted-foreground text-sm">
|
||||
Restore this version to make edits
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row gap-4">
|
||||
<Button
|
||||
onClick={async () => {
|
||||
mutate(
|
||||
`/api/document?id=${canvas.documentId}`,
|
||||
await fetch(`/api/document?id=${canvas.documentId}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({
|
||||
timestamp:
|
||||
getDocumentTimestampById(currentVersionIndex),
|
||||
}),
|
||||
}),
|
||||
{
|
||||
optimisticData: documents
|
||||
? [
|
||||
...documents.filter((d) =>
|
||||
isAfter(
|
||||
new Date(d.createdAt),
|
||||
new Date(
|
||||
getDocumentTimestampById(
|
||||
currentVersionIndex
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
]
|
||||
: [],
|
||||
}
|
||||
);
|
||||
}}
|
||||
>
|
||||
Restore this version
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
handleVersionChange('latest');
|
||||
}}
|
||||
>
|
||||
Back to latest version
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
|
||||
<AnimatePresence>
|
||||
{isCurrentVersion && (
|
||||
<Toolbar
|
||||
isToolbarVisible={isToolbarVisible}
|
||||
setIsToolbarVisible={setIsToolbarVisible}
|
||||
append={append}
|
||||
isLoading={isLoading}
|
||||
stop={stop}
|
||||
setMessages={setMessages}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
|
@ -5,13 +5,8 @@ import { ModelSelector } from '@/components/custom/model-selector';
|
|||
import { SidebarToggle } from '@/components/custom/sidebar-toggle';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { BetterTooltip } from '@/components/ui/tooltip';
|
||||
import { Model } from '@/lib/model';
|
||||
|
||||
export function ChatHeader({
|
||||
selectedModelName,
|
||||
}: {
|
||||
selectedModelName: Model['name'];
|
||||
}) {
|
||||
export function ChatHeader({ selectedModelId }: { selectedModelId: string }) {
|
||||
return (
|
||||
<header className="flex h-16 sticky top-0 bg-background md:h-12 items-center px-2 md:px-2 z-10">
|
||||
<SidebarToggle />
|
||||
|
|
@ -28,7 +23,7 @@ export function ChatHeader({
|
|||
</Button>
|
||||
</BetterTooltip>
|
||||
<ModelSelector
|
||||
selectedModelName={selectedModelName}
|
||||
selectedModelId={selectedModelId}
|
||||
className="order-1 md:order-2"
|
||||
/>
|
||||
</header>
|
||||
|
|
|
|||
|
|
@ -2,33 +2,47 @@
|
|||
|
||||
import { Attachment, Message } from 'ai';
|
||||
import { useChat } from 'ai/react';
|
||||
import { AnimatePresence } from 'framer-motion';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Model } from '@/ai/models';
|
||||
import { ChatHeader } from '@/components/custom/chat-header';
|
||||
import { Message as PreviewMessage } from '@/components/custom/message';
|
||||
import { useScrollToBottom } from '@/components/custom/use-scroll-to-bottom';
|
||||
import { Model } from '@/lib/model';
|
||||
|
||||
import { Canvas, UICanvas } from './canvas';
|
||||
import { CanvasStreamHandler } from './canvas-stream-handler';
|
||||
import { MultimodalInput } from './multimodal-input';
|
||||
import { Overview } from './overview';
|
||||
|
||||
export function Chat({
|
||||
id,
|
||||
initialMessages,
|
||||
selectedModelName,
|
||||
selectedModelId,
|
||||
}: {
|
||||
id: string;
|
||||
initialMessages: Array<Message>;
|
||||
selectedModelName: Model['name'];
|
||||
selectedModelId: string;
|
||||
}) {
|
||||
const { messages, handleSubmit, input, setInput, append, isLoading, stop } =
|
||||
useChat({
|
||||
body: { id, model: selectedModelName },
|
||||
initialMessages,
|
||||
onFinish: () => {
|
||||
window.history.replaceState({}, '', `/chat/${id}`);
|
||||
},
|
||||
});
|
||||
const {
|
||||
messages,
|
||||
setMessages,
|
||||
handleSubmit,
|
||||
input,
|
||||
setInput,
|
||||
append,
|
||||
isLoading,
|
||||
stop,
|
||||
data: streamingData,
|
||||
} = useChat({
|
||||
body: { id, modelId: selectedModelId },
|
||||
initialMessages,
|
||||
onFinish: () => {
|
||||
window.history.replaceState({}, '', `/chat/${id}`);
|
||||
},
|
||||
});
|
||||
|
||||
const [canvas, setCanvas] = useState<UICanvas | null>(null);
|
||||
|
||||
const [messagesContainerRef, messagesEndRef] =
|
||||
useScrollToBottom<HTMLDivElement>();
|
||||
|
|
@ -36,42 +50,71 @@ export function Chat({
|
|||
const [attachments, setAttachments] = useState<Array<Attachment>>([]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col min-w-0 h-dvh bg-background">
|
||||
<ChatHeader selectedModelName={selectedModelName} />
|
||||
<div
|
||||
ref={messagesContainerRef}
|
||||
className="flex flex-col min-w-0 gap-6 flex-1 overflow-y-scroll"
|
||||
>
|
||||
{messages.length === 0 && <Overview />}
|
||||
|
||||
{messages.map((message) => (
|
||||
<PreviewMessage
|
||||
key={message.id}
|
||||
role={message.role}
|
||||
content={message.content}
|
||||
attachments={message.experimental_attachments}
|
||||
toolInvocations={message.toolInvocations}
|
||||
/>
|
||||
))}
|
||||
|
||||
<>
|
||||
<div className="flex flex-col min-w-0 h-dvh bg-background">
|
||||
<ChatHeader selectedModelId={selectedModelId} />
|
||||
<div
|
||||
ref={messagesEndRef}
|
||||
className="shrink-0 min-w-[24px] min-h-[24px]"
|
||||
/>
|
||||
ref={messagesContainerRef}
|
||||
className="flex flex-col min-w-0 gap-6 flex-1 overflow-y-scroll"
|
||||
>
|
||||
{messages.length === 0 && <Overview />}
|
||||
|
||||
{messages.map((message) => (
|
||||
<PreviewMessage
|
||||
key={message.id}
|
||||
role={message.role}
|
||||
content={message.content}
|
||||
attachments={message.experimental_attachments}
|
||||
toolInvocations={message.toolInvocations}
|
||||
canvas={canvas}
|
||||
setCanvas={setCanvas}
|
||||
/>
|
||||
))}
|
||||
|
||||
<div
|
||||
ref={messagesEndRef}
|
||||
className="shrink-0 min-w-[24px] min-h-[24px]"
|
||||
/>
|
||||
</div>
|
||||
<form className="flex mx-auto px-4 bg-background pb-4 md:pb-6 gap-2 w-full md:max-w-3xl">
|
||||
<MultimodalInput
|
||||
input={input}
|
||||
setInput={setInput}
|
||||
handleSubmit={handleSubmit}
|
||||
isLoading={isLoading}
|
||||
stop={stop}
|
||||
attachments={attachments}
|
||||
setAttachments={setAttachments}
|
||||
messages={messages}
|
||||
setMessages={setMessages}
|
||||
append={append}
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
<form className="flex mx-auto px-4 bg-background pb-4 md:pb-6 gap-2 w-full md:max-w-3xl">
|
||||
<MultimodalInput
|
||||
input={input}
|
||||
setInput={setInput}
|
||||
handleSubmit={handleSubmit}
|
||||
isLoading={isLoading}
|
||||
stop={stop}
|
||||
attachments={attachments}
|
||||
setAttachments={setAttachments}
|
||||
messages={messages}
|
||||
append={append}
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{canvas && canvas.isVisible && (
|
||||
<Canvas
|
||||
input={input}
|
||||
setInput={setInput}
|
||||
handleSubmit={handleSubmit}
|
||||
isLoading={isLoading}
|
||||
stop={stop}
|
||||
attachments={attachments}
|
||||
setAttachments={setAttachments}
|
||||
append={append}
|
||||
canvas={canvas}
|
||||
setCanvas={setCanvas}
|
||||
messages={messages}
|
||||
setMessages={setMessages}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<CanvasStreamHandler
|
||||
streamingData={streamingData}
|
||||
setCanvas={setCanvas}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
100
components/custom/diffview.tsx
Normal file
100
components/custom/diffview.tsx
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import OrderedMap from 'orderedmap';
|
||||
import {
|
||||
Schema,
|
||||
Node as ProsemirrorNode,
|
||||
MarkSpec,
|
||||
DOMParser,
|
||||
} from 'prosemirror-model';
|
||||
import { schema } from 'prosemirror-schema-basic';
|
||||
import { addListNodes } from 'prosemirror-schema-list';
|
||||
import { EditorState } from 'prosemirror-state';
|
||||
import { EditorView } from 'prosemirror-view';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { renderToString } from 'react-dom/server';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
|
||||
import { diffEditor, DiffType } from '@/lib/editor/index';
|
||||
|
||||
const diffSchema = new Schema({
|
||||
nodes: addListNodes(schema.spec.nodes, 'paragraph block*', 'block'),
|
||||
marks: OrderedMap.from({
|
||||
...schema.spec.marks.toObject(),
|
||||
diffMark: {
|
||||
attrs: { type: { default: '' } },
|
||||
toDOM(mark) {
|
||||
let className = '';
|
||||
|
||||
switch (mark.attrs.type) {
|
||||
case DiffType.Inserted:
|
||||
className =
|
||||
'bg-green-100 text-green-700 dark:bg-green-500/70 dark:text-green-300';
|
||||
break;
|
||||
case DiffType.Deleted:
|
||||
className =
|
||||
'bg-red-100 line-through text-red-600 dark:bg-red-500/70 dark:text-red-300';
|
||||
break;
|
||||
default:
|
||||
className = '';
|
||||
}
|
||||
return ['span', { class: className }, 0];
|
||||
},
|
||||
} as MarkSpec,
|
||||
}),
|
||||
});
|
||||
|
||||
function computeDiff(oldDoc: ProsemirrorNode, newDoc: ProsemirrorNode) {
|
||||
return diffEditor(diffSchema, oldDoc.toJSON(), newDoc.toJSON());
|
||||
}
|
||||
|
||||
type DiffEditorProps = {
|
||||
oldContent: string;
|
||||
newContent: string;
|
||||
};
|
||||
|
||||
export const DiffView = ({ oldContent, newContent }: DiffEditorProps) => {
|
||||
const editorRef = useRef<HTMLDivElement>(null);
|
||||
const viewRef = useRef<EditorView | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (editorRef.current && !viewRef.current) {
|
||||
const parser = DOMParser.fromSchema(diffSchema);
|
||||
|
||||
const oldHtmlContent = renderToString(
|
||||
<ReactMarkdown>{oldContent}</ReactMarkdown>
|
||||
);
|
||||
const newHtmlContent = renderToString(
|
||||
<ReactMarkdown>{newContent}</ReactMarkdown>
|
||||
);
|
||||
|
||||
const oldContainer = document.createElement('div');
|
||||
oldContainer.innerHTML = oldHtmlContent;
|
||||
|
||||
const newContainer = document.createElement('div');
|
||||
newContainer.innerHTML = newHtmlContent;
|
||||
|
||||
const oldDoc = parser.parse(oldContainer);
|
||||
const newDoc = parser.parse(newContainer);
|
||||
|
||||
const diffedDoc = computeDiff(oldDoc, newDoc);
|
||||
|
||||
const state = EditorState.create({
|
||||
doc: diffedDoc,
|
||||
plugins: [],
|
||||
});
|
||||
|
||||
viewRef.current = new EditorView(editorRef.current, {
|
||||
state,
|
||||
editable: () => false,
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (viewRef.current) {
|
||||
viewRef.current.destroy();
|
||||
viewRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [oldContent, newContent]);
|
||||
|
||||
return <div className="diff-editor" ref={editorRef} />;
|
||||
};
|
||||
15
components/custom/document-skeleton.tsx
Normal file
15
components/custom/document-skeleton.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
'use client';
|
||||
|
||||
export const DocumentSkeleton = () => {
|
||||
return (
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<div className="animate-pulse rounded-lg h-12 bg-muted-foreground/20 w-1/2" />
|
||||
<div className="animate-pulse rounded-lg h-5 bg-muted-foreground/20 w-full" />
|
||||
<div className="animate-pulse rounded-lg h-5 bg-muted-foreground/20 w-full" />
|
||||
<div className="animate-pulse rounded-lg h-5 bg-muted-foreground/20 w-1/3" />
|
||||
<div className="animate-pulse rounded-lg h-5 bg-transparent w-52" />
|
||||
<div className="animate-pulse rounded-lg h-8 bg-muted-foreground/20 w-52" />
|
||||
<div className="animate-pulse rounded-lg h-5 bg-muted-foreground/20 w-2/3" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
111
components/custom/document.tsx
Normal file
111
components/custom/document.tsx
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
import { SetStateAction } from 'react';
|
||||
|
||||
import { UICanvas } from './canvas';
|
||||
import { FileIcon, LoaderIcon, MessageIcon, PencilEditIcon } from './icons';
|
||||
|
||||
const getActionText = (type: 'create' | 'update' | 'request-suggestions') => {
|
||||
switch (type) {
|
||||
case 'create':
|
||||
return 'Creating';
|
||||
case 'update':
|
||||
return 'Updating';
|
||||
case 'request-suggestions':
|
||||
return 'Adding suggestions';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
interface DocumentToolResultProps {
|
||||
type: 'create' | 'update' | 'request-suggestions';
|
||||
result: any;
|
||||
canvas: UICanvas | null;
|
||||
setCanvas: (value: SetStateAction<UICanvas | null>) => void;
|
||||
}
|
||||
|
||||
export function DocumentToolResult({
|
||||
type,
|
||||
result,
|
||||
canvas,
|
||||
setCanvas,
|
||||
}: DocumentToolResultProps) {
|
||||
return (
|
||||
<div
|
||||
className="cursor-pointer border py-2 px-3 rounded-xl w-fit flex flex-row gap-3 items-start"
|
||||
onClick={(event) => {
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
|
||||
const boundingBox = {
|
||||
top: rect.top,
|
||||
left: rect.left,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
};
|
||||
|
||||
if (!canvas) {
|
||||
setCanvas({
|
||||
documentId: result.id,
|
||||
content: '',
|
||||
title: result.title,
|
||||
isVisible: true,
|
||||
status: 'idle',
|
||||
boundingBox,
|
||||
});
|
||||
} else {
|
||||
if (canvas.documentId !== result.id) {
|
||||
setCanvas({
|
||||
documentId: result.id,
|
||||
content: '',
|
||||
title: result.title,
|
||||
isVisible: true,
|
||||
status: 'idle',
|
||||
boundingBox,
|
||||
});
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="text-muted-foreground mt-1">
|
||||
{type === 'create' ? (
|
||||
<FileIcon />
|
||||
) : type === 'update' ? (
|
||||
<PencilEditIcon />
|
||||
) : type === 'request-suggestions' ? (
|
||||
<MessageIcon />
|
||||
) : null}
|
||||
</div>
|
||||
<div className="">
|
||||
{getActionText(type)} {result.title}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface DocumentToolCallProps {
|
||||
type: 'create' | 'update' | 'request-suggestions';
|
||||
args: any;
|
||||
}
|
||||
|
||||
export function DocumentToolCall({ type, args }: DocumentToolCallProps) {
|
||||
return (
|
||||
<div className="w-fit border py-2 px-3 rounded-xl flex flex-row items-start justify-between gap-3">
|
||||
<div className="flex flex-row gap-3 items-start">
|
||||
<div className="text-zinc-500 mt-1">
|
||||
{type === 'create' ? (
|
||||
<FileIcon />
|
||||
) : type === 'update' ? (
|
||||
<PencilEditIcon />
|
||||
) : type === 'request-suggestions' ? (
|
||||
<MessageIcon />
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="">
|
||||
{getActionText(type)} {args.title}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="animate-spin mt-1">{<LoaderIcon />}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
193
components/custom/editor.tsx
Normal file
193
components/custom/editor.tsx
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
'use client';
|
||||
|
||||
import { exampleSetup } from 'prosemirror-example-setup';
|
||||
import { inputRules, textblockTypeInputRule } from 'prosemirror-inputrules';
|
||||
import { defaultMarkdownSerializer } from 'prosemirror-markdown';
|
||||
import { Schema, DOMParser } from 'prosemirror-model';
|
||||
import { schema } from 'prosemirror-schema-basic';
|
||||
import { addListNodes } from 'prosemirror-schema-list';
|
||||
import { EditorState } from 'prosemirror-state';
|
||||
import { Decoration, DecorationSet, EditorView } from 'prosemirror-view';
|
||||
import React, { memo, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { renderToString } from 'react-dom/server';
|
||||
|
||||
import { Suggestion } from '@/db/schema';
|
||||
import {
|
||||
createSuggestionWidget,
|
||||
projectWithHighlights,
|
||||
suggestionsPlugin,
|
||||
suggestionsPluginKey,
|
||||
UISuggestion,
|
||||
} from '@/lib/editor/suggestions';
|
||||
|
||||
import { Markdown } from './markdown';
|
||||
|
||||
const mySchema = new Schema({
|
||||
nodes: addListNodes(schema.spec.nodes, 'paragraph block*', 'block'),
|
||||
// @ts-expect-error: TODO need to fix type mismatch
|
||||
marks: {
|
||||
...schema.spec.marks,
|
||||
},
|
||||
});
|
||||
|
||||
function headingRule(level: number) {
|
||||
return textblockTypeInputRule(
|
||||
new RegExp(`^(#{1,${level}})\\s$`),
|
||||
mySchema.nodes.heading,
|
||||
() => ({ level })
|
||||
);
|
||||
}
|
||||
|
||||
interface WidgetRoot {
|
||||
destroy: () => void;
|
||||
}
|
||||
|
||||
type EditorProps = {
|
||||
content: string;
|
||||
onChange: (updatedContent: string) => void;
|
||||
status: 'streaming' | 'idle';
|
||||
currentVersionIndex: number;
|
||||
suggestions: Array<Suggestion>;
|
||||
};
|
||||
|
||||
function PureEditor({
|
||||
content,
|
||||
onChange,
|
||||
suggestions: suggestionsWithoutHighlights,
|
||||
}: EditorProps) {
|
||||
const editorRef = useRef<HTMLDivElement>(null);
|
||||
const viewRef = useRef<EditorView | null>(null);
|
||||
const widgetRootsRef = useRef<Map<string, WidgetRoot>>(new Map());
|
||||
|
||||
useEffect(() => {
|
||||
if (editorRef.current && !viewRef.current) {
|
||||
if (mySchema) {
|
||||
const parser = DOMParser.fromSchema(mySchema);
|
||||
|
||||
const htmlContent = renderToString(<Markdown>{content}</Markdown>);
|
||||
|
||||
const container = document.createElement('div');
|
||||
container.innerHTML = htmlContent;
|
||||
|
||||
const state = EditorState.create({
|
||||
doc: parser.parse(container),
|
||||
plugins: [
|
||||
...exampleSetup({ schema: mySchema, menuBar: false }),
|
||||
inputRules({
|
||||
rules: [
|
||||
headingRule(1),
|
||||
headingRule(2),
|
||||
headingRule(3),
|
||||
headingRule(4),
|
||||
headingRule(5),
|
||||
headingRule(6),
|
||||
],
|
||||
}),
|
||||
suggestionsPlugin,
|
||||
],
|
||||
});
|
||||
|
||||
viewRef.current = new EditorView(editorRef.current, {
|
||||
state,
|
||||
dispatchTransaction: (transaction) => {
|
||||
const newState = viewRef.current!.state.apply(transaction);
|
||||
viewRef.current!.updateState(newState);
|
||||
|
||||
if (transaction.docChanged) {
|
||||
const content = defaultMarkdownSerializer.serialize(newState.doc);
|
||||
onChange(content);
|
||||
}
|
||||
},
|
||||
});
|
||||
} else {
|
||||
console.error('Schema is not properly initialized');
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (viewRef.current) {
|
||||
viewRef.current.destroy();
|
||||
viewRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [content, onChange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (viewRef.current && viewRef.current.state.doc && content) {
|
||||
const computedSuggestions = projectWithHighlights(
|
||||
viewRef.current.state.doc,
|
||||
suggestionsWithoutHighlights
|
||||
).filter(
|
||||
(suggestion) =>
|
||||
suggestion.selectionStart !== 0 && suggestion.selectionEnd !== 0
|
||||
);
|
||||
|
||||
const decorations = createDecorations(
|
||||
computedSuggestions,
|
||||
viewRef.current
|
||||
);
|
||||
const transaction = viewRef.current.state.tr;
|
||||
transaction.setMeta(suggestionsPluginKey, { decorations });
|
||||
viewRef.current.dispatch(transaction);
|
||||
}
|
||||
}, [suggestionsWithoutHighlights, content]);
|
||||
|
||||
const createDecorations = (suggestions: UISuggestion[], view: EditorView) => {
|
||||
const decorations: Decoration[] = [];
|
||||
|
||||
suggestions.forEach((suggestion) => {
|
||||
decorations.push(
|
||||
Decoration.inline(suggestion.selectionStart, suggestion.selectionEnd, {
|
||||
class:
|
||||
'suggestion-highlight bg-yellow-100 hover:bg-yellow-200 dark:hover:bg-yellow-400/50 dark:text-yellow-50 dark:bg-yellow-400/40',
|
||||
})
|
||||
);
|
||||
|
||||
decorations.push(
|
||||
Decoration.widget(suggestion.selectionStart, (view) => {
|
||||
const { dom, destroy } = createSuggestionWidget(suggestion, view);
|
||||
const key = `widget-${suggestion.id}`;
|
||||
widgetRootsRef.current.set(key, { destroy });
|
||||
return dom;
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
return DecorationSet.create(view.state.doc, decorations);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
widgetRootsRef.current.forEach((root) => {
|
||||
root.destroy();
|
||||
});
|
||||
|
||||
widgetRootsRef.current.clear();
|
||||
|
||||
if (viewRef.current) {
|
||||
viewRef.current.destroy();
|
||||
viewRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return <div className="relative prose dark:prose-invert" ref={editorRef} />;
|
||||
}
|
||||
|
||||
function areEqual(prevProps: EditorProps, nextProps: EditorProps) {
|
||||
if (prevProps.suggestions !== nextProps.suggestions) {
|
||||
return false;
|
||||
} else if (prevProps.content === '' && nextProps.content !== '') {
|
||||
return false;
|
||||
} else if (prevProps.currentVersionIndex !== nextProps.currentVersionIndex) {
|
||||
return false;
|
||||
} else if (prevProps.onChange !== nextProps.onChange) {
|
||||
return false;
|
||||
} else if (prevProps.status === 'idle') {
|
||||
return true;
|
||||
} else {
|
||||
return prevProps.content === nextProps.content;
|
||||
}
|
||||
}
|
||||
|
||||
export const Editor = memo(PureEditor, areEqual);
|
||||
|
|
@ -609,3 +609,105 @@ export const MessageIcon = ({ size = 16 }: { size?: number }) => {
|
|||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export const CrossIcon = ({ 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="M12.4697 13.5303L13 14.0607L14.0607 13L13.5303 12.4697L9.06065 7.99999L13.5303 3.53032L14.0607 2.99999L13 1.93933L12.4697 2.46966L7.99999 6.93933L3.53032 2.46966L2.99999 1.93933L1.93933 2.99999L2.46966 3.53032L6.93933 7.99999L2.46966 12.4697L1.93933 13L2.99999 14.0607L3.53032 13.5303L7.99999 9.06065L12.4697 13.5303Z"
|
||||
fill="currentColor"
|
||||
></path>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const UndoIcon = ({ 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="M13.5 8C13.5 4.96643 11.0257 2.5 7.96452 2.5C5.42843 2.5 3.29365 4.19393 2.63724 6.5H5.25H6V8H5.25H0.75C0.335787 8 0 7.66421 0 7.25V2.75V2H1.5V2.75V5.23347C2.57851 2.74164 5.06835 1 7.96452 1C11.8461 1 15 4.13001 15 8C15 11.87 11.8461 15 7.96452 15C5.62368 15 3.54872 13.8617 2.27046 12.1122L1.828 11.5066L3.03915 10.6217L3.48161 11.2273C4.48831 12.6051 6.12055 13.5 7.96452 13.5C11.0257 13.5 13.5 11.0336 13.5 8Z"
|
||||
fill="currentColor"
|
||||
></path>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const RedoIcon = ({ 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="M2.5 8C2.5 4.96643 4.97431 2.5 8.03548 2.5C10.5716 2.5 12.7064 4.19393 13.3628 6.5H10.75H10V8H10.75H15.25C15.6642 8 16 7.66421 16 7.25V2.75V2H14.5V2.75V5.23347C13.4215 2.74164 10.9316 1 8.03548 1C4.1539 1 1 4.13001 1 8C1 11.87 4.1539 15 8.03548 15C10.3763 15 12.4513 13.8617 13.7295 12.1122L14.172 11.5066L12.9609 10.6217L12.5184 11.2273C11.5117 12.6051 9.87945 13.5 8.03548 13.5C4.97431 13.5 2.5 11.0336 2.5 8Z"
|
||||
fill="currentColor"
|
||||
></path>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const DeltaIcon = ({ 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="M2.67705 15H1L1.75 13.5L6.16147 4.67705L6.15836 4.67082L6.16667 4.66667L7.16147 2.67705L8 1L8.83853 2.67705L14.25 13.5L15 15H13.3229H2.67705ZM7 6.3541L10.5729 13.5H3.42705L7 6.3541Z"
|
||||
fill="currentColor"
|
||||
></path>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const PenIcon = ({ 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="M8.75 0.189331L9.28033 0.719661L15.2803 6.71966L15.8107 7.24999L15.2803 7.78032L13.7374 9.32322C13.1911 9.8696 12.3733 9.97916 11.718 9.65188L9.54863 13.5568C8.71088 15.0648 7.12143 16 5.39639 16H0.75H0V15.25V10.6036C0 8.87856 0.935237 7.28911 2.4432 6.45136L6.34811 4.28196C6.02084 3.62674 6.13039 2.80894 6.67678 2.26255L8.21967 0.719661L8.75 0.189331ZM7.3697 5.43035L10.5696 8.63029L8.2374 12.8283C7.6642 13.8601 6.57668 14.5 5.39639 14.5H2.56066L5.53033 11.5303L4.46967 10.4697L1.5 13.4393V10.6036C1.5 9.42331 2.1399 8.33579 3.17166 7.76259L7.3697 5.43035ZM12.6768 8.26256C12.5791 8.36019 12.4209 8.36019 12.3232 8.26255L12.0303 7.96966L8.03033 3.96966L7.73744 3.67677C7.63981 3.57914 7.63981 3.42085 7.73744 3.32321L8.75 2.31065L13.6893 7.24999L12.6768 8.26256Z"
|
||||
fill="currentColor"
|
||||
></path>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const SummarizeIcon = ({ 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.75 12H1V10.5H1.75H5.25H6V12H5.25H1.75ZM1.75 7.75H1V6.25H1.75H4.25H5V7.75H4.25H1.75ZM1.75 3.5H1V2H1.75H7.25H8V3.5H7.25H1.75ZM12.5303 14.7803C12.2374 15.0732 11.7626 15.0732 11.4697 14.7803L9.21967 12.5303L8.68934 12L9.75 10.9393L10.2803 11.4697L11.25 12.4393V2.75V2H12.75V2.75V12.4393L13.7197 11.4697L14.25 10.9393L15.3107 12L14.7803 12.5303L12.5303 14.7803Z"
|
||||
fill="currentColor"
|
||||
></path>
|
||||
</svg>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -63,6 +63,48 @@ const NonMemoizedMarkdown = ({ children }: { children: string }) => {
|
|||
</Link>
|
||||
);
|
||||
},
|
||||
h1: ({ node, children, ...props }: any) => {
|
||||
return (
|
||||
<h1 className="text-3xl font-semibold mt-6 mb-2" {...props}>
|
||||
{children}
|
||||
</h1>
|
||||
);
|
||||
},
|
||||
h2: ({ node, children, ...props }: any) => {
|
||||
return (
|
||||
<h2 className="text-2xl font-semibold mt-6 mb-2" {...props}>
|
||||
{children}
|
||||
</h2>
|
||||
);
|
||||
},
|
||||
h3: ({ node, children, ...props }: any) => {
|
||||
return (
|
||||
<h3 className="text-xl font-semibold mt-6 mb-2" {...props}>
|
||||
{children}
|
||||
</h3>
|
||||
);
|
||||
},
|
||||
h4: ({ node, children, ...props }: any) => {
|
||||
return (
|
||||
<h4 className="text-lg font-semibold mt-6 mb-2" {...props}>
|
||||
{children}
|
||||
</h4>
|
||||
);
|
||||
},
|
||||
h5: ({ node, children, ...props }: any) => {
|
||||
return (
|
||||
<h5 className="text-base font-semibold mt-6 mb-2" {...props}>
|
||||
{children}
|
||||
</h5>
|
||||
);
|
||||
},
|
||||
h6: ({ node, children, ...props }: any) => {
|
||||
return (
|
||||
<h6 className="text-sm font-semibold mt-6 mb-2" {...props}>
|
||||
{children}
|
||||
</h6>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
'use client';
|
||||
|
||||
import { Attachment, ToolInvocation } from 'ai';
|
||||
import cx from 'classnames';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Sparkles } from 'lucide-react';
|
||||
import { ReactNode } from 'react';
|
||||
import { Dispatch, ReactNode, SetStateAction } from 'react';
|
||||
|
||||
import { UICanvas } from './canvas';
|
||||
import { DocumentToolCall, DocumentToolResult } from './document';
|
||||
import { Markdown } from './markdown';
|
||||
import { PreviewAttachment } from './preview-attachment';
|
||||
import { Weather } from './weather';
|
||||
|
|
@ -14,11 +17,15 @@ export const Message = ({
|
|||
content,
|
||||
toolInvocations,
|
||||
attachments,
|
||||
canvas,
|
||||
setCanvas,
|
||||
}: {
|
||||
role: string;
|
||||
content: string | ReactNode;
|
||||
toolInvocations: Array<ToolInvocation> | undefined;
|
||||
attachments?: Array<Attachment>;
|
||||
canvas: UICanvas | null;
|
||||
setCanvas: Dispatch<SetStateAction<UICanvas | null>>;
|
||||
}) => {
|
||||
return (
|
||||
<motion.div
|
||||
|
|
@ -27,7 +34,16 @@ export const Message = ({
|
|||
animate={{ y: 0, opacity: 1 }}
|
||||
data-role={role}
|
||||
>
|
||||
<div className="flex gap-4 group-data-[role=user]/message:px-5 w-full group-data-[role=user]/message:w-fit group-data-[role=user]/message:ml-auto group-data-[role=user]/message:max-w-2xl group-data-[role=user]/message:py-3.5 group-data-[role=user]/message:bg-muted rounded-xl">
|
||||
<div
|
||||
className={cx(
|
||||
'flex gap-4 group-data-[role=user]/message:px-5 w-full group-data-[role=user]/message:w-fit group-data-[role=user]/message:ml-auto group-data-[role=user]/message:max-w-2xl group-data-[role=user]/message:py-3.5 rounded-xl',
|
||||
{
|
||||
'group-data-[role=user]/message:bg-muted': !canvas,
|
||||
'group-data-[role=user]/message:bg-zinc-300 dark:group-data-[role=user]/message:bg-zinc-800':
|
||||
canvas,
|
||||
}
|
||||
)}
|
||||
>
|
||||
{role === 'assistant' && (
|
||||
<div className="size-8 flex items-center rounded-full justify-center ring-1 shrink-0 ring-border">
|
||||
<Sparkles className="size-4" />
|
||||
|
|
@ -40,10 +56,10 @@ export const Message = ({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{toolInvocations && toolInvocations.length > 0 ? (
|
||||
{toolInvocations && toolInvocations.length > 0 && (
|
||||
<div className="flex flex-col gap-4">
|
||||
{toolInvocations.map((toolInvocation) => {
|
||||
const { toolName, toolCallId, state } = toolInvocation;
|
||||
const { toolName, toolCallId, state, args } = toolInvocation;
|
||||
|
||||
if (state === 'result') {
|
||||
const { result } = toolInvocation;
|
||||
|
|
@ -52,19 +68,58 @@ export const Message = ({
|
|||
<div key={toolCallId}>
|
||||
{toolName === 'getWeather' ? (
|
||||
<Weather weatherAtLocation={result} />
|
||||
) : null}
|
||||
) : toolName === 'createDocument' ? (
|
||||
<DocumentToolResult
|
||||
type="create"
|
||||
result={result}
|
||||
canvas={canvas}
|
||||
setCanvas={setCanvas}
|
||||
/>
|
||||
) : toolName === 'updateDocument' ? (
|
||||
<DocumentToolResult
|
||||
type="update"
|
||||
result={result}
|
||||
canvas={canvas}
|
||||
setCanvas={setCanvas}
|
||||
/>
|
||||
) : toolName === 'requestSuggestions' ? (
|
||||
<DocumentToolResult
|
||||
type="request-suggestions"
|
||||
result={result}
|
||||
canvas={canvas}
|
||||
setCanvas={setCanvas}
|
||||
/>
|
||||
) : (
|
||||
<pre>{JSON.stringify(result, null, 2)}</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div key={toolCallId} className="skeleton">
|
||||
{toolName === 'getWeather' ? <Weather /> : null}
|
||||
<div
|
||||
key={toolCallId}
|
||||
className={cx({
|
||||
skeleton: ['getWeather'].includes(toolName),
|
||||
})}
|
||||
>
|
||||
{toolName === 'getWeather' ? (
|
||||
<Weather />
|
||||
) : toolName === 'createDocument' ? (
|
||||
<DocumentToolCall type="create" args={args} />
|
||||
) : toolName === 'updateDocument' ? (
|
||||
<DocumentToolCall type="update" args={args} />
|
||||
) : toolName === 'requestSuggestions' ? (
|
||||
<DocumentToolCall
|
||||
type="request-suggestions"
|
||||
args={args}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
)}
|
||||
|
||||
{attachments && (
|
||||
<div className="flex flex-row gap-2">
|
||||
|
|
|
|||
|
|
@ -3,7 +3,8 @@
|
|||
import { Check, ChevronDown } from 'lucide-react';
|
||||
import { startTransition, useMemo, useOptimistic, useState } from 'react';
|
||||
|
||||
import { saveModel } from '@/app/(chat)/actions';
|
||||
import { models } from '@/ai/models';
|
||||
import { saveModelId } from '@/app/(chat)/actions';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
|
|
@ -11,22 +12,21 @@ import {
|
|||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { type Model, models } from '@/lib/model';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export function ModelSelector({
|
||||
selectedModelName,
|
||||
selectedModelId,
|
||||
className,
|
||||
}: {
|
||||
selectedModelName: Model['name'];
|
||||
selectedModelId: string;
|
||||
} & React.ComponentProps<typeof Button>) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [optimisticModelName, setOptimisticModelName] =
|
||||
useOptimistic(selectedModelName);
|
||||
const [optimisticModelId, setOptimisticModelId] =
|
||||
useOptimistic(selectedModelId);
|
||||
|
||||
const selectModel = useMemo(
|
||||
() => models.find((model) => model.name === optimisticModelName),
|
||||
[optimisticModelName]
|
||||
() => models.find((model) => model.id === optimisticModelId),
|
||||
[optimisticModelId]
|
||||
);
|
||||
|
||||
return (
|
||||
|
|
@ -46,17 +46,17 @@ export function ModelSelector({
|
|||
<DropdownMenuContent align="start" className="min-w-[300px]">
|
||||
{models.map((model) => (
|
||||
<DropdownMenuItem
|
||||
key={model.name}
|
||||
key={model.id}
|
||||
onSelect={() => {
|
||||
setOpen(false);
|
||||
|
||||
startTransition(() => {
|
||||
setOptimisticModelName(model.name);
|
||||
saveModel(model.name);
|
||||
setOptimisticModelId(model.id);
|
||||
saveModelId(model.id);
|
||||
});
|
||||
}}
|
||||
className="gap-4 group/item"
|
||||
data-active={model.name === optimisticModelName}
|
||||
data-active={model.id === optimisticModelId}
|
||||
>
|
||||
<div className="flex flex-col gap-1 items-start">
|
||||
{model.label}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
'use client';
|
||||
|
||||
import { Attachment, ChatRequestOptions, CreateMessage, Message } from 'ai';
|
||||
import cx from 'classnames';
|
||||
import { motion } from 'framer-motion';
|
||||
import React, {
|
||||
useRef,
|
||||
|
|
@ -13,6 +14,8 @@ import React, {
|
|||
} from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { sanitizeUIMessages } from '@/lib/utils';
|
||||
|
||||
import { ArrowUpIcon, PaperclipIcon, StopIcon } from './icons';
|
||||
import { PreviewAttachment } from './preview-attachment';
|
||||
import useWindowSize from './use-window-size';
|
||||
|
|
@ -26,9 +29,9 @@ const suggestedActions = [
|
|||
action: 'What is the weather in San Francisco?',
|
||||
},
|
||||
{
|
||||
title: "Answer like I'm 5,",
|
||||
label: 'why is the sky blue?',
|
||||
action: "Answer like I'm 5, why is the sky blue?",
|
||||
title: 'Help me draft an essay',
|
||||
label: 'about Silicon Valley',
|
||||
action: 'Help me draft an essay about Silicon Valley',
|
||||
},
|
||||
];
|
||||
|
||||
|
|
@ -40,8 +43,10 @@ export function MultimodalInput({
|
|||
attachments,
|
||||
setAttachments,
|
||||
messages,
|
||||
setMessages,
|
||||
append,
|
||||
handleSubmit,
|
||||
className,
|
||||
}: {
|
||||
input: string;
|
||||
setInput: (value: string) => void;
|
||||
|
|
@ -50,6 +55,7 @@ export function MultimodalInput({
|
|||
attachments: Array<Attachment>;
|
||||
setAttachments: Dispatch<SetStateAction<Array<Attachment>>>;
|
||||
messages: Array<Message>;
|
||||
setMessages: Dispatch<SetStateAction<Array<Message>>>;
|
||||
append: (
|
||||
message: Message | CreateMessage,
|
||||
chatRequestOptions?: ChatRequestOptions
|
||||
|
|
@ -60,6 +66,7 @@ export function MultimodalInput({
|
|||
},
|
||||
chatRequestOptions?: ChatRequestOptions
|
||||
) => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const { width } = useWindowSize();
|
||||
|
|
@ -220,7 +227,10 @@ export function MultimodalInput({
|
|||
placeholder="Send a message..."
|
||||
value={input}
|
||||
onChange={handleInput}
|
||||
className="min-h-[24px] overflow-hidden resize-none rounded-xl p-4 focus-visible:ring-0 focus-visible:ring-offset-0 text-base bg-muted border-none"
|
||||
className={cx(
|
||||
'min-h-[24px] overflow-hidden resize-none rounded-lg text-base bg-muted',
|
||||
className
|
||||
)}
|
||||
rows={3}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
|
|
@ -241,6 +251,7 @@ export function MultimodalInput({
|
|||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
stop();
|
||||
setMessages((messages) => sanitizeUIMessages(messages));
|
||||
}}
|
||||
>
|
||||
<StopIcon size={14} />
|
||||
|
|
|
|||
59
components/custom/suggestion.tsx
Normal file
59
components/custom/suggestion.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { UISuggestion } from '@/lib/editor/suggestions';
|
||||
|
||||
import { CrossIcon, MessageIcon } from './icons';
|
||||
import { Button } from '../ui/button';
|
||||
|
||||
export const Suggestion = ({
|
||||
suggestion,
|
||||
onApply,
|
||||
}: {
|
||||
suggestion: UISuggestion;
|
||||
onApply: () => void;
|
||||
}) => {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
return !isExpanded ? (
|
||||
<div
|
||||
className="absolute cursor-pointer text-muted-foreground mt-1 -right-8"
|
||||
onClick={() => {
|
||||
setIsExpanded(true);
|
||||
}}
|
||||
>
|
||||
<MessageIcon size={14} />
|
||||
</div>
|
||||
) : (
|
||||
<motion.div
|
||||
className="absolute bg-background p-3 flex flex-col gap-3 rounded-2xl border text-sm w-56 shadow-xl z-50 -right-12 md:-right-20"
|
||||
transition={{ type: 'spring', stiffness: 500, damping: 30 }}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
>
|
||||
<div className="flex flex-row items-center justify-between">
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<div className="size-4 bg-muted-foreground/25 rounded-full" />
|
||||
<div className="font-medium">Assistant</div>
|
||||
</div>
|
||||
<div
|
||||
className="text-xs text-gray-500 cursor-pointer"
|
||||
onClick={() => {
|
||||
setIsExpanded(false);
|
||||
}}
|
||||
>
|
||||
<CrossIcon size={12} />
|
||||
</div>
|
||||
</div>
|
||||
<div>{suggestion.description}</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-fit py-1.5 px-3 rounded-full"
|
||||
onClick={onApply}
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
413
components/custom/toolbar.tsx
Normal file
413
components/custom/toolbar.tsx
Normal file
|
|
@ -0,0 +1,413 @@
|
|||
'use client';
|
||||
|
||||
import { ChatRequestOptions, CreateMessage, Message } from 'ai';
|
||||
import cx from 'classnames';
|
||||
import {
|
||||
AnimatePresence,
|
||||
motion,
|
||||
useMotionValue,
|
||||
useTransform,
|
||||
} from 'framer-motion';
|
||||
import { Dispatch, SetStateAction, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { sanitizeUIMessages } from '@/lib/utils';
|
||||
|
||||
import {
|
||||
ArrowUpIcon,
|
||||
MessageIcon,
|
||||
PenIcon,
|
||||
StopIcon,
|
||||
SummarizeIcon,
|
||||
} from './icons';
|
||||
import useClickOutside from './use-click-outside';
|
||||
|
||||
type ToolProps = {
|
||||
type: 'final-polish' | 'request-suggestions' | 'adjust-reading-level';
|
||||
description: string;
|
||||
icon: JSX.Element;
|
||||
selectedTool: string | null;
|
||||
setSelectedTool: Dispatch<SetStateAction<string | null>>;
|
||||
isToolbarVisible?: boolean;
|
||||
setIsToolbarVisible?: Dispatch<SetStateAction<boolean>>;
|
||||
isAnimating: boolean;
|
||||
append: (
|
||||
message: Message | CreateMessage,
|
||||
chatRequestOptions?: ChatRequestOptions
|
||||
) => Promise<string | null | undefined>;
|
||||
};
|
||||
|
||||
const Tool = ({
|
||||
type,
|
||||
description,
|
||||
icon,
|
||||
selectedTool,
|
||||
setSelectedTool,
|
||||
isToolbarVisible,
|
||||
setIsToolbarVisible,
|
||||
isAnimating,
|
||||
append,
|
||||
}: ToolProps) => {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedTool !== type) {
|
||||
setIsHovered(false);
|
||||
}
|
||||
}, [selectedTool, type]);
|
||||
|
||||
return (
|
||||
<Tooltip open={isHovered && !isAnimating}>
|
||||
<TooltipTrigger>
|
||||
<motion.div
|
||||
className={cx('p-3 rounded-full', {
|
||||
'bg-foreground !text-background': selectedTool === type,
|
||||
})}
|
||||
onHoverStart={() => {
|
||||
setIsHovered(true);
|
||||
}}
|
||||
onHoverEnd={() => {
|
||||
if (selectedTool !== type) setIsHovered(false);
|
||||
}}
|
||||
initial={{ scale: 1, opacity: 0 }}
|
||||
animate={{ opacity: 1, transition: { delay: 0.1 } }}
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
exit={{
|
||||
scale: 0.9,
|
||||
opacity: 0,
|
||||
transition: { duration: 0.1 },
|
||||
}}
|
||||
onClick={() => {
|
||||
if (!isToolbarVisible && setIsToolbarVisible) {
|
||||
setIsToolbarVisible(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedTool) {
|
||||
setIsHovered(true);
|
||||
setSelectedTool(type);
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedTool !== type) {
|
||||
setSelectedTool(type);
|
||||
} else {
|
||||
if (type === 'final-polish') {
|
||||
append({
|
||||
role: 'user',
|
||||
content:
|
||||
'Please add final polish and check for grammar, add section titles for better structure, and ensure everything reads smoothly.',
|
||||
});
|
||||
|
||||
setSelectedTool(null);
|
||||
} else if (type === 'request-suggestions') {
|
||||
append({
|
||||
role: 'user',
|
||||
content:
|
||||
'Please add suggestions you have that could improve the writing.',
|
||||
});
|
||||
|
||||
setSelectedTool(null);
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
{selectedTool === type ? <ArrowUpIcon /> : icon}
|
||||
</motion.div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="left"
|
||||
sideOffset={16}
|
||||
className="bg-foreground text-background rounded-2xl p-3 px-4"
|
||||
>
|
||||
{description}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
const ReadingLevelSelector = ({
|
||||
setSelectedTool,
|
||||
append,
|
||||
isAnimating,
|
||||
}: {
|
||||
setSelectedTool: Dispatch<SetStateAction<string | null>>;
|
||||
isAnimating: boolean;
|
||||
append: (
|
||||
message: Message | CreateMessage,
|
||||
chatRequestOptions?: ChatRequestOptions
|
||||
) => Promise<string | null | undefined>;
|
||||
}) => {
|
||||
const LEVELS = [
|
||||
'Elementary',
|
||||
'Middle School',
|
||||
'Keep current level',
|
||||
'High School',
|
||||
'College',
|
||||
'Graduate',
|
||||
];
|
||||
|
||||
const y = useMotionValue(-40 * 2);
|
||||
const dragConstraints = 5 * 40 + 2;
|
||||
const yToLevel = useTransform(y, [0, -dragConstraints], [0, 5]);
|
||||
|
||||
const [currentLevel, setCurrentLevel] = useState(2);
|
||||
const [hasUserSelectedLevel, setHasUserSelectedLevel] =
|
||||
useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = yToLevel.on('change', (latest) => {
|
||||
const level = Math.min(5, Math.max(0, Math.round(Math.abs(latest))));
|
||||
setCurrentLevel(level);
|
||||
});
|
||||
|
||||
return () => unsubscribe();
|
||||
}, [yToLevel]);
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-col justify-end items-center">
|
||||
{[...Array(6)].map((_, index) => (
|
||||
<motion.div
|
||||
key={`dot-${index}`}
|
||||
className="size-[40px] flex flex-row items-center justify-center"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
>
|
||||
<div className="size-2 rounded-full bg-muted-foreground/40" />
|
||||
</motion.div>
|
||||
))}
|
||||
|
||||
<TooltipProvider>
|
||||
<Tooltip open={!isAnimating}>
|
||||
<TooltipTrigger asChild>
|
||||
<motion.div
|
||||
className={cx(
|
||||
'absolute bg-background p-3 border rounded-full flex flex-row items-center',
|
||||
{
|
||||
'bg-foreground text-background': currentLevel !== 2,
|
||||
'bg-background text-foreground': currentLevel === 2,
|
||||
}
|
||||
)}
|
||||
style={{ y }}
|
||||
drag="y"
|
||||
dragElastic={0}
|
||||
dragMomentum={false}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
transition={{ duration: 0.1 }}
|
||||
dragConstraints={{ top: -dragConstraints, bottom: 0 }}
|
||||
onDragStart={() => {
|
||||
setHasUserSelectedLevel(false);
|
||||
}}
|
||||
onDragEnd={() => {
|
||||
if (currentLevel === 2) {
|
||||
setSelectedTool(null);
|
||||
} else {
|
||||
setHasUserSelectedLevel(true);
|
||||
}
|
||||
}}
|
||||
onClick={() => {
|
||||
if (currentLevel !== 2 && hasUserSelectedLevel) {
|
||||
append({
|
||||
role: 'user',
|
||||
content: `Please adjust the reading level to ${LEVELS[currentLevel]} level.`,
|
||||
});
|
||||
|
||||
setSelectedTool(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{currentLevel === 2 ? <SummarizeIcon /> : <ArrowUpIcon />}
|
||||
</motion.div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="left"
|
||||
sideOffset={16}
|
||||
className="bg-foreground text-background text-sm rounded-2xl p-3 px-4"
|
||||
>
|
||||
{LEVELS[currentLevel]}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const Toolbar = ({
|
||||
isToolbarVisible,
|
||||
setIsToolbarVisible,
|
||||
append,
|
||||
isLoading,
|
||||
stop,
|
||||
setMessages,
|
||||
}: {
|
||||
isToolbarVisible: boolean;
|
||||
setIsToolbarVisible: Dispatch<SetStateAction<boolean>>;
|
||||
isLoading: boolean;
|
||||
append: (
|
||||
message: Message | CreateMessage,
|
||||
chatRequestOptions?: ChatRequestOptions
|
||||
) => Promise<string | null | undefined>;
|
||||
stop: () => void;
|
||||
setMessages: Dispatch<SetStateAction<Message[]>>;
|
||||
}) => {
|
||||
const toolbarRef = useRef<HTMLDivElement>(null);
|
||||
const timeoutRef = useRef<NodeJS.Timeout>();
|
||||
|
||||
const [selectedTool, setSelectedTool] = useState<string | null>(null);
|
||||
const [isAnimating, setIsAnimating] = useState(false);
|
||||
|
||||
useClickOutside(toolbarRef, () => {
|
||||
setIsToolbarVisible(false);
|
||||
setSelectedTool(null);
|
||||
});
|
||||
|
||||
const Tools = (
|
||||
<>
|
||||
<AnimatePresence>
|
||||
{isToolbarVisible && (
|
||||
<>
|
||||
<Tool
|
||||
type="adjust-reading-level"
|
||||
description="Adjust reading level"
|
||||
icon={<SummarizeIcon />}
|
||||
selectedTool={selectedTool}
|
||||
setSelectedTool={setSelectedTool}
|
||||
append={append}
|
||||
isAnimating={isAnimating}
|
||||
/>
|
||||
|
||||
<Tool
|
||||
type="request-suggestions"
|
||||
description="Request suggestions"
|
||||
icon={<MessageIcon />}
|
||||
selectedTool={selectedTool}
|
||||
setSelectedTool={setSelectedTool}
|
||||
append={append}
|
||||
isAnimating={isAnimating}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<Tool
|
||||
type="final-polish"
|
||||
description="Add final polish"
|
||||
icon={<PenIcon />}
|
||||
selectedTool={selectedTool}
|
||||
setSelectedTool={setSelectedTool}
|
||||
isToolbarVisible={isToolbarVisible}
|
||||
setIsToolbarVisible={setIsToolbarVisible}
|
||||
append={append}
|
||||
isAnimating={isAnimating}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
const startCloseTimer = () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
setSelectedTool(null);
|
||||
setIsToolbarVisible(false);
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
const cancelCloseTimer = () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading) {
|
||||
setIsToolbarVisible(false);
|
||||
}
|
||||
}, [isLoading, setIsToolbarVisible]);
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<motion.div
|
||||
className="cursor-pointer fixed right-6 bottom-6 p-1.5 border rounded-full shadow-lg bg-background flex flex-col justify-end"
|
||||
initial={{ opacity: 0, y: -20, scale: 1 }}
|
||||
animate={
|
||||
isToolbarVisible
|
||||
? selectedTool === 'adjust-reading-level'
|
||||
? {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
height: 6 * 43,
|
||||
transition: { delay: 0 },
|
||||
scale: 0.95,
|
||||
}
|
||||
: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
height: 3 * 45,
|
||||
transition: { delay: 0 },
|
||||
scale: 1,
|
||||
}
|
||||
: { opacity: 1, y: 0, height: 54, transition: { delay: 0 } }
|
||||
}
|
||||
exit={{ opacity: 0, y: -20, transition: { duration: 0.1 } }}
|
||||
transition={{ type: 'spring', stiffness: 300, damping: 25 }}
|
||||
onHoverStart={() => {
|
||||
if (isLoading) return;
|
||||
|
||||
cancelCloseTimer();
|
||||
setIsToolbarVisible(true);
|
||||
}}
|
||||
onHoverEnd={() => {
|
||||
if (isLoading) return;
|
||||
|
||||
startCloseTimer();
|
||||
}}
|
||||
onAnimationStart={() => {
|
||||
setIsAnimating(true);
|
||||
}}
|
||||
onAnimationComplete={() => {
|
||||
setIsAnimating(false);
|
||||
}}
|
||||
ref={toolbarRef}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div
|
||||
className="p-3"
|
||||
onClick={() => {
|
||||
stop();
|
||||
setMessages((messages) => sanitizeUIMessages(messages));
|
||||
}}
|
||||
>
|
||||
<StopIcon />
|
||||
</div>
|
||||
) : selectedTool === 'adjust-reading-level' ? (
|
||||
<ReadingLevelSelector
|
||||
append={append}
|
||||
setSelectedTool={setSelectedTool}
|
||||
isAnimating={isAnimating}
|
||||
/>
|
||||
) : (
|
||||
Tools
|
||||
)}
|
||||
</motion.div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
};
|
||||
103
components/custom/use-canvas-stream.tsx
Normal file
103
components/custom/use-canvas-stream.tsx
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import { JSONValue } from 'ai';
|
||||
import { Dispatch, SetStateAction, useEffect, useState } from 'react';
|
||||
import { useSWRConfig } from 'swr';
|
||||
|
||||
import { Suggestion } from '@/db/schema';
|
||||
|
||||
import { UICanvas } from './canvas';
|
||||
|
||||
type StreamingDelta = {
|
||||
type: 'text-delta' | 'title' | 'id' | 'suggestion' | 'clear' | 'finish';
|
||||
content: string | Suggestion;
|
||||
};
|
||||
|
||||
export function useCanvasStream({
|
||||
streamingData,
|
||||
setCanvas,
|
||||
}: {
|
||||
streamingData: JSONValue[] | undefined;
|
||||
setCanvas: Dispatch<SetStateAction<UICanvas | null>>;
|
||||
}) {
|
||||
const { mutate } = useSWRConfig();
|
||||
const [optimisticSuggestions, setOptimisticSuggestions] = useState<
|
||||
Array<Suggestion>
|
||||
>([]);
|
||||
|
||||
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;
|
||||
|
||||
setCanvas((draftCanvas) => {
|
||||
if (!draftCanvas) {
|
||||
return {
|
||||
content: '',
|
||||
title: '',
|
||||
isVisible: false,
|
||||
documentId: delta.type === 'id' ? (delta.content as string) : '',
|
||||
status: 'idle',
|
||||
boundingBox: {
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
switch (delta.type) {
|
||||
case 'text-delta':
|
||||
return {
|
||||
...draftCanvas,
|
||||
content: draftCanvas.content + (delta.content as string),
|
||||
isVisible:
|
||||
draftCanvas.status === 'streaming'
|
||||
? true
|
||||
: draftCanvas.content.length > 200,
|
||||
status: 'streaming',
|
||||
};
|
||||
|
||||
case 'title':
|
||||
return {
|
||||
...draftCanvas,
|
||||
title: delta.content as string,
|
||||
};
|
||||
|
||||
case 'suggestion':
|
||||
setTimeout(() => {
|
||||
setOptimisticSuggestions((currentSuggestions) => [
|
||||
...currentSuggestions,
|
||||
delta.content as Suggestion,
|
||||
]);
|
||||
}, 0);
|
||||
|
||||
return draftCanvas;
|
||||
|
||||
case 'clear':
|
||||
return {
|
||||
...draftCanvas,
|
||||
content: '',
|
||||
status: 'streaming',
|
||||
};
|
||||
|
||||
case 'finish':
|
||||
return {
|
||||
...draftCanvas,
|
||||
status: 'idle',
|
||||
};
|
||||
|
||||
default:
|
||||
return draftCanvas;
|
||||
}
|
||||
});
|
||||
}, [streamingData, setCanvas]);
|
||||
}
|
||||
31
components/custom/use-click-outside.tsx
Normal file
31
components/custom/use-click-outside.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { useEffect, RefObject } from 'react';
|
||||
|
||||
type Handler = (event: MouseEvent | TouchEvent) => void;
|
||||
|
||||
function useClickOutside<T extends HTMLElement = HTMLElement>(
|
||||
ref: RefObject<T>,
|
||||
handler: Handler,
|
||||
mouseEvent: 'mousedown' | 'mouseup' = 'mousedown'
|
||||
): void {
|
||||
useEffect(() => {
|
||||
const listener = (event: MouseEvent | TouchEvent) => {
|
||||
const el = ref?.current;
|
||||
|
||||
if (!el || el.contains((event?.target as Node) || null)) {
|
||||
return;
|
||||
}
|
||||
|
||||
handler(event);
|
||||
};
|
||||
|
||||
document.addEventListener(mouseEvent, listener);
|
||||
document.addEventListener('touchstart', listener);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener(mouseEvent, listener);
|
||||
document.removeEventListener('touchstart', listener);
|
||||
};
|
||||
}, [ref, handler, mouseEvent]);
|
||||
}
|
||||
|
||||
export default useClickOutside;
|
||||
21
components/custom/use-debounce.tsx
Normal file
21
components/custom/use-debounce.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { useCallback, useRef } from "react";
|
||||
|
||||
export function useDebounce<T extends (...args: any[]) => any>(
|
||||
callback: T,
|
||||
delay: number,
|
||||
): (...args: Parameters<T>) => void {
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
return useCallback(
|
||||
(...args: Parameters<T>) => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
callback(...args);
|
||||
}, delay);
|
||||
},
|
||||
[callback, delay],
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue