Add message actions (#482)

This commit is contained in:
Jeremy 2024-11-05 17:15:51 +03:00 committed by GitHub
parent 94f563f179
commit 171914941e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 1011 additions and 150 deletions

View file

@ -42,7 +42,7 @@ export function AppSidebar({ user }: { user: User | undefined }) {
<span className="text-lg font-semibold px-2">Chatbot</span>
</Link>
<BetterTooltip content="New Chat" align="start">
<Button variant="ghost" className="p-2 h-fit">
<Button variant="ghost" className="p-2 h-fit" asChild>
<Link href="/" onClick={() => setOpenMobile(false)}>
<PlusIcon />
</Link>

View file

@ -17,15 +17,14 @@ import {
useWindowSize,
} from 'usehooks-ts';
import { Document, Suggestion } from '@/db/schema';
import { Document, Suggestion, Vote } from '@/db/schema';
import { fetcher } from '@/lib/utils';
import { DiffView } from './diffview';
import { DocumentSkeleton } from './document-skeleton';
import { Editor } from './editor';
import { CopyIcon, CrossIcon, DeltaIcon, RedoIcon, UndoIcon } from './icons';
import { Markdown } from './markdown';
import { Message as PreviewMessage } from './message';
import { PreviewMessage } from './message';
import { MultimodalInput } from './multimodal-input';
import { Toolbar } from './toolbar';
import { useScrollToBottom } from './use-scroll-to-bottom';
@ -46,6 +45,7 @@ export interface UICanvas {
}
export function Canvas({
chatId,
input,
setInput,
handleSubmit,
@ -58,7 +58,9 @@ export function Canvas({
setCanvas,
messages,
setMessages,
votes,
}: {
chatId: string;
input: string;
setInput: (input: string) => void;
isLoading: boolean;
@ -69,6 +71,7 @@ export function Canvas({
setCanvas: Dispatch<SetStateAction<UICanvas>>;
messages: Array<Message>;
setMessages: Dispatch<SetStateAction<Array<Message>>>;
votes: Array<Vote> | undefined;
append: (
message: Message | CreateMessage,
chatRequestOptions?: ChatRequestOptions
@ -286,15 +289,19 @@ export function Canvas({
ref={messagesContainerRef}
className="flex flex-col gap-4 h-full items-center overflow-y-scroll px-4 pt-20"
>
{messages.map((message) => (
{messages.map((message, index) => (
<PreviewMessage
chatId={chatId}
key={message.id}
role={message.role}
content={message.content}
attachments={message.experimental_attachments}
toolInvocations={message.toolInvocations}
message={message}
canvas={canvas}
setCanvas={setCanvas}
isLoading={isLoading && index === messages.length - 1}
vote={
votes
? votes.find((vote) => vote.messageId === message.id)
: undefined
}
/>
))}
@ -306,6 +313,7 @@ export function Canvas({
<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}

View file

@ -4,11 +4,14 @@ import { Attachment, Message } from 'ai';
import { useChat } from 'ai/react';
import { AnimatePresence } from 'framer-motion';
import { useState } from 'react';
import useSWR, { useSWRConfig } from 'swr';
import { useWindowSize } from 'usehooks-ts';
import { ChatHeader } from '@/components/custom/chat-header';
import { Message as PreviewMessage } from '@/components/custom/message';
import { PreviewMessage } from '@/components/custom/message';
import { useScrollToBottom } from '@/components/custom/use-scroll-to-bottom';
import { Vote } from '@/db/schema';
import { fetcher } from '@/lib/utils';
import { Canvas, UICanvas } from './canvas';
import { CanvasStreamHandler } from './canvas-stream-handler';
@ -24,6 +27,8 @@ export function Chat({
initialMessages: Array<Message>;
selectedModelId: string;
}) {
const { mutate } = useSWRConfig();
const {
messages,
setMessages,
@ -38,7 +43,7 @@ export function Chat({
body: { id, modelId: selectedModelId },
initialMessages,
onFinish: () => {
window.history.replaceState({}, '', `/chat/${id}`);
mutate('/api/history');
},
});
@ -59,6 +64,11 @@ export function Chat({
},
});
const { data: votes } = useSWR<Array<Vote>>(
`/api/vote?chatId=${id}`,
fetcher
);
const [messagesContainerRef, messagesEndRef] =
useScrollToBottom<HTMLDivElement>();
@ -74,15 +84,19 @@ export function Chat({
>
{messages.length === 0 && <Overview />}
{messages.map((message) => (
{messages.map((message, index) => (
<PreviewMessage
key={message.id}
role={message.role}
content={message.content}
attachments={message.experimental_attachments}
toolInvocations={message.toolInvocations}
chatId={id}
message={message}
canvas={canvas}
setCanvas={setCanvas}
isLoading={isLoading && messages.length - 1 === index}
vote={
votes
? votes.find((vote) => vote.messageId === message.id)
: undefined
}
/>
))}
@ -93,6 +107,7 @@ export function Chat({
</div>
<form className="flex mx-auto px-4 bg-background pb-4 md:pb-6 gap-2 w-full md:max-w-3xl">
<MultimodalInput
chatId={id}
input={input}
setInput={setInput}
handleSubmit={handleSubmit}
@ -110,6 +125,7 @@ export function Chat({
<AnimatePresence>
{canvas && canvas.isVisible && (
<Canvas
chatId={id}
input={input}
setInput={setInput}
handleSubmit={handleSubmit}
@ -122,6 +138,7 @@ export function Chat({
setCanvas={setCanvas}
messages={messages}
setMessages={setMessages}
votes={votes}
/>
)}
</AnimatePresence>

View file

@ -763,7 +763,7 @@ export const CopyIcon = ({ size = 16 }: { size?: number }) => (
</svg>
);
export const ChevronDownIcon = ({ size = 16 }: { size?: number }) => (
export const ThumbUpIcon = ({ size = 16 }: { size?: number }) => (
<svg
height={size}
strokeLinejoin="round"
@ -772,6 +772,42 @@ export const ChevronDownIcon = ({ size = 16 }: { size?: number }) => (
style={{ color: 'currentcolor' }}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M6.89531 2.23972C6.72984 2.12153 6.5 2.23981 6.5 2.44315V5.25001C6.5 6.21651 5.7165 7.00001 4.75 7.00001H2.5V13.5H12.1884C12.762 13.5 13.262 13.1096 13.4011 12.5532L14.4011 8.55318C14.5984 7.76425 14.0017 7.00001 13.1884 7.00001H9.25H8.5V6.25001V3.51458C8.5 3.43384 8.46101 3.35807 8.39531 3.31114L6.89531 2.23972ZM5 2.44315C5 1.01975 6.6089 0.191779 7.76717 1.01912L9.26717 2.09054C9.72706 2.41904 10 2.94941 10 3.51458V5.50001H13.1884C14.9775 5.50001 16.2903 7.18133 15.8563 8.91698L14.8563 12.917C14.5503 14.1412 13.4503 15 12.1884 15H1.75H1V14.25V6.25001V5.50001H1.75H4.75C4.88807 5.50001 5 5.38808 5 5.25001V2.44315Z"
fill="currentColor"
></path>
</svg>
);
export const ThumbDownIcon = ({ 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="M6.89531 13.7603C6.72984 13.8785 6.5 13.7602 6.5 13.5569V10.75C6.5 9.7835 5.7165 9 4.75 9H2.5V2.5H12.1884C12.762 2.5 13.262 2.89037 13.4011 3.44683L14.4011 7.44683C14.5984 8.23576 14.0017 9 13.1884 9H9.25H8.5V9.75V12.4854C8.5 12.5662 8.46101 12.6419 8.39531 12.6889L6.89531 13.7603ZM5 13.5569C5 14.9803 6.6089 15.8082 7.76717 14.9809L9.26717 13.9095C9.72706 13.581 10 13.0506 10 12.4854V10.5H13.1884C14.9775 10.5 16.2903 8.81868 15.8563 7.08303L14.8563 3.08303C14.5503 1.85882 13.4503 1 12.1884 1H1.75H1V1.75V9.75V10.5H1.75H4.75C4.88807 10.5 5 10.6119 5 10.75V13.5569Z"
fill="currentColor"
></path>
</svg>
);
export const ChevronDownIcon = ({ 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.0607 6.74999L11.5303 7.28032L8.7071 10.1035C8.31657 10.4941 7.68341 10.4941 7.29288 10.1035L4.46966 7.28032L3.93933 6.74999L4.99999 5.68933L5.53032 6.21966L7.99999 8.68933L10.4697 6.21966L11 5.68933L12.0607 6.74999Z"
fill="currentColor"
></path>

View file

@ -0,0 +1,166 @@
import { Message } from 'ai';
import { toast } from 'sonner';
import { useSWRConfig } from 'swr';
import { useCopyToClipboard } from 'usehooks-ts';
import { Vote } from '@/db/schema';
import { getMessageIdFromAnnotations } from '@/lib/utils';
import { CopyIcon, ThumbDownIcon, ThumbUpIcon } from './icons';
import { Button } from '../ui/button';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '../ui/tooltip';
export function MessageActions({
chatId,
message,
vote,
isLoading,
}: {
chatId: string;
message: Message;
vote: Vote | undefined;
isLoading: boolean;
}) {
const { mutate } = useSWRConfig();
const [_, copyToClipboard] = useCopyToClipboard();
if (isLoading) return null;
if (message.role === 'user') return null;
if (message.toolInvocations && message.toolInvocations.length > 0)
return null;
return (
<TooltipProvider delayDuration={0}>
<div className="flex flex-row gap-2">
<Tooltip>
<TooltipTrigger asChild>
<Button
className="py-1 px-2 h-fit text-muted-foreground"
variant="outline"
onClick={async () => {
await copyToClipboard(message.content as string);
toast.success('Copied to clipboard!');
}}
>
<CopyIcon />
</Button>
</TooltipTrigger>
<TooltipContent>Copy</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
className="py-1 px-2 h-fit text-muted-foreground !pointer-events-auto"
disabled={vote && vote.isUpvoted}
variant="outline"
onClick={async () => {
const messageId = getMessageIdFromAnnotations(message);
const upvote = fetch('/api/vote', {
method: 'PATCH',
body: JSON.stringify({
chatId,
messageId,
type: 'up',
}),
});
toast.promise(upvote, {
loading: 'Upvoting Response...',
success: () => {
mutate<Array<Vote>>(
`/api/vote?chatId=${chatId}`,
(currentVotes) => {
if (!currentVotes) return [];
const votesWithoutCurrent = currentVotes.filter(
(vote) => vote.messageId !== message.id
);
return [
...votesWithoutCurrent,
{
chatId,
messageId: message.id,
isUpvoted: true,
},
];
},
{ revalidate: false }
);
return 'Upvoted Response!';
},
error: 'Failed to upvote response.',
});
}}
>
<ThumbUpIcon />
</Button>
</TooltipTrigger>
<TooltipContent>Upvote Response</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
className="py-1 px-2 h-fit text-muted-foreground !pointer-events-auto"
variant="outline"
disabled={vote && !vote.isUpvoted}
onClick={async () => {
const messageId = getMessageIdFromAnnotations(message);
const downvote = fetch('/api/vote', {
method: 'PATCH',
body: JSON.stringify({
chatId,
messageId,
type: 'down',
}),
});
toast.promise(downvote, {
loading: 'Downvoting Response...',
success: () => {
mutate<Array<Vote>>(
`/api/vote?chatId=${chatId}`,
(currentVotes) => {
if (!currentVotes) return [];
const votesWithoutCurrent = currentVotes.filter(
(vote) => vote.messageId !== message.id
);
return [
...votesWithoutCurrent,
{
chatId,
messageId: message.id,
isUpvoted: false,
},
];
},
{ revalidate: false }
);
return 'Downvoted Response!';
},
error: 'Failed to downvote response.',
});
}}
>
<ThumbDownIcon />
</Button>
</TooltipTrigger>
<TooltipContent>Downvote Response</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
);
}

View file

@ -1,42 +1,45 @@
'use client';
import { Attachment, ToolInvocation } from 'ai';
import { Message } from 'ai';
import cx from 'classnames';
import { motion } from 'framer-motion';
import { Sparkles } from 'lucide-react';
import { Dispatch, ReactNode, SetStateAction } from 'react';
import { Dispatch, SetStateAction } from 'react';
import { Vote } from '@/db/schema';
import { UICanvas } from './canvas';
import { DocumentToolCall, DocumentToolResult } from './document';
import { Markdown } from './markdown';
import { MessageActions } from './message-actions';
import { PreviewAttachment } from './preview-attachment';
import { Weather } from './weather';
export const Message = ({
role,
content,
toolInvocations,
attachments,
export const PreviewMessage = ({
chatId,
message,
canvas,
setCanvas,
vote,
isLoading,
}: {
role: string;
content: string | ReactNode;
toolInvocations: Array<ToolInvocation> | undefined;
attachments?: Array<Attachment>;
chatId: string;
message: Message;
canvas: UICanvas;
setCanvas: Dispatch<SetStateAction<UICanvas>>;
vote: Vote | undefined;
isLoading: boolean;
}) => {
return (
<motion.div
className="w-full mx-auto max-w-3xl px-4 group/message "
initial={{ y: 5, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
data-role={role}
data-role={message.role}
>
<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',
'flex gap-4 group-data-[role=user]/message:px-3 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-2 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':
@ -44,21 +47,22 @@ export const Message = ({
}
)}
>
{role === 'assistant' && (
{message.role === 'assistant' && (
<div className="size-8 flex items-center rounded-full justify-center ring-1 shrink-0 ring-border">
<Sparkles className="size-4" />
</div>
)}
<div className="flex flex-col gap-2 w-full">
{content && (
{message.content && (
<div className="flex flex-col gap-4">
<Markdown>{content as string}</Markdown>
<Markdown>{message.content as string}</Markdown>
</div>
)}
{toolInvocations && toolInvocations.length > 0 && (
{message.toolInvocations && message.toolInvocations.length > 0 && (
<div className="flex flex-col gap-4">
{toolInvocations.map((toolInvocation) => {
{message.toolInvocations.map((toolInvocation) => {
const { toolName, toolCallId, state, args } = toolInvocation;
if (state === 'result') {
@ -121,9 +125,9 @@ export const Message = ({
</div>
)}
{attachments && (
{message.experimental_attachments && (
<div className="flex flex-row gap-2">
{attachments.map((attachment) => (
{message.experimental_attachments.map((attachment) => (
<PreviewAttachment
key={attachment.url}
attachment={attachment}
@ -131,6 +135,14 @@ export const Message = ({
))}
</div>
)}
<MessageActions
key={`action-${message.id}`}
chatId={chatId}
message={message}
vote={vote}
isLoading={isLoading}
/>
</div>
</div>
</motion.div>

View file

@ -36,6 +36,7 @@ const suggestedActions = [
];
export function MultimodalInput({
chatId,
input,
setInput,
isLoading,
@ -48,6 +49,7 @@ export function MultimodalInput({
handleSubmit,
className,
}: {
chatId: string;
input: string;
setInput: (value: string) => void;
isLoading: boolean;
@ -114,6 +116,8 @@ export function MultimodalInput({
const [uploadQueue, setUploadQueue] = useState<Array<string>>([]);
const submitForm = useCallback(() => {
window.history.replaceState({}, '', `/chat/${chatId}`);
handleSubmit(undefined, {
experimental_attachments: attachments,
});
@ -124,7 +128,14 @@ export function MultimodalInput({
if (width && width > 768) {
textareaRef.current?.focus();
}
}, [attachments, handleSubmit, setAttachments, setLocalStorageInput, width]);
}, [
attachments,
handleSubmit,
setAttachments,
setLocalStorageInput,
width,
chatId,
]);
const uploadFile = async (file: File) => {
const formData = new FormData();

View file

@ -39,7 +39,7 @@ import {
useSidebar,
} from '@/components/ui/sidebar';
import { Chat } from '@/db/schema';
import { fetcher, getTitleFromChat } from '@/lib/utils';
import { fetcher } from '@/lib/utils';
type GroupedChats = {
today: Chat[];
@ -63,7 +63,7 @@ const ChatItem = ({
<SidebarMenuItem>
<SidebarMenuButton asChild isActive={isActive}>
<Link href={`/chat/${chat.id}`} onClick={() => setOpenMobile(false)}>
<span>{getTitleFromChat(chat)}</span>
<span>{chat.title}</span>
</Link>
</SidebarMenuButton>
<DropdownMenu modal={true}>