Add message actions (#482)
This commit is contained in:
parent
94f563f179
commit
171914941e
20 changed files with 1011 additions and 150 deletions
|
|
@ -1,8 +1,29 @@
|
|||
'use server';
|
||||
|
||||
import { CoreMessage, CoreUserMessage, generateText } from 'ai';
|
||||
import { cookies } from 'next/headers';
|
||||
|
||||
import { customModel } from '@/ai';
|
||||
|
||||
export async function saveModelId(model: string) {
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set('model-id', model);
|
||||
}
|
||||
|
||||
export async function generateTitleFromUserMessage({
|
||||
message,
|
||||
}: {
|
||||
message: CoreUserMessage;
|
||||
}) {
|
||||
const { text: title } = await generateText({
|
||||
model: customModel('gpt-3.5-turbo'),
|
||||
system: `\n
|
||||
- you will generate a short title based on the first message a user begins a conversation with
|
||||
- ensure it is not more than 80 characters long
|
||||
- the title should be a summary of the user's message
|
||||
- do not use quotes or colons`,
|
||||
prompt: JSON.stringify(message),
|
||||
});
|
||||
|
||||
return title;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import {
|
||||
convertToCoreMessages,
|
||||
generateObject,
|
||||
Message,
|
||||
StreamData,
|
||||
streamObject,
|
||||
|
|
@ -18,10 +17,17 @@ import {
|
|||
getDocumentById,
|
||||
saveChat,
|
||||
saveDocument,
|
||||
saveMessages,
|
||||
saveSuggestions,
|
||||
} from '@/db/queries';
|
||||
import { Suggestion } from '@/db/schema';
|
||||
import { generateUUID, sanitizeResponseMessages } from '@/lib/utils';
|
||||
import {
|
||||
generateUUID,
|
||||
getMostRecentUserMessage,
|
||||
sanitizeResponseMessages,
|
||||
} from '@/lib/utils';
|
||||
|
||||
import { generateTitleFromUserMessage } from '../../actions';
|
||||
|
||||
export const maxDuration = 60;
|
||||
|
||||
|
|
@ -49,7 +55,7 @@ export async function POST(request: Request) {
|
|||
|
||||
const session = await auth();
|
||||
|
||||
if (!session) {
|
||||
if (!session || !session.user || !session.user.id) {
|
||||
return new Response('Unauthorized', { status: 401 });
|
||||
}
|
||||
|
||||
|
|
@ -60,6 +66,25 @@ export async function POST(request: Request) {
|
|||
}
|
||||
|
||||
const coreMessages = convertToCoreMessages(messages);
|
||||
const userMessage = getMostRecentUserMessage(coreMessages);
|
||||
|
||||
if (!userMessage) {
|
||||
return new Response('No user message found', { status: 400 });
|
||||
}
|
||||
|
||||
const chat = await getChatById({ id });
|
||||
|
||||
if (!chat) {
|
||||
const title = await generateTitleFromUserMessage({ message: userMessage });
|
||||
await saveChat({ id, userId: session.user.id, title });
|
||||
}
|
||||
|
||||
await saveMessages({
|
||||
messages: [
|
||||
{ ...userMessage, id: generateUUID(), createdAt: new Date(), chatId: id },
|
||||
],
|
||||
});
|
||||
|
||||
const streamingData = new StreamData();
|
||||
|
||||
const result = await streamText({
|
||||
|
|
@ -298,13 +323,26 @@ export async function POST(request: Request) {
|
|||
const responseMessagesWithoutIncompleteToolCalls =
|
||||
sanitizeResponseMessages(responseMessages);
|
||||
|
||||
await saveChat({
|
||||
id,
|
||||
messages: [
|
||||
...coreMessages,
|
||||
...responseMessagesWithoutIncompleteToolCalls,
|
||||
],
|
||||
userId: session.user.id,
|
||||
await saveMessages({
|
||||
messages: responseMessagesWithoutIncompleteToolCalls.map(
|
||||
(message) => {
|
||||
const messageId = generateUUID();
|
||||
|
||||
if (message.role === 'assistant') {
|
||||
streamingData.appendMessageAnnotation({
|
||||
messageIdFromServer: messageId,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
id: messageId,
|
||||
chatId: id,
|
||||
role: message.role,
|
||||
content: message.content,
|
||||
createdAt: new Date(),
|
||||
};
|
||||
}
|
||||
),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to save chat');
|
||||
|
|
|
|||
48
app/(chat)/api/vote/route.ts
Normal file
48
app/(chat)/api/vote/route.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import { auth } from '@/app/(auth)/auth';
|
||||
import { getVotesByChatId, voteMessage } from '@/db/queries';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const chatId = searchParams.get('chatId');
|
||||
|
||||
if (!chatId) {
|
||||
return new Response('chatId is required', { status: 400 });
|
||||
}
|
||||
|
||||
const session = await auth();
|
||||
|
||||
if (!session || !session.user || !session.user.email) {
|
||||
return new Response('Unauthorized', { status: 401 });
|
||||
}
|
||||
|
||||
const votes = await getVotesByChatId({ id: chatId });
|
||||
|
||||
return Response.json(votes, { status: 200 });
|
||||
}
|
||||
|
||||
export async function PATCH(request: Request) {
|
||||
const {
|
||||
chatId,
|
||||
messageId,
|
||||
type,
|
||||
}: { chatId: string; messageId: string; type: 'up' | 'down' } =
|
||||
await request.json();
|
||||
|
||||
if (!chatId || !messageId || !type) {
|
||||
return new Response('messageId and type are required', { status: 400 });
|
||||
}
|
||||
|
||||
const session = await auth();
|
||||
|
||||
if (!session || !session.user || !session.user.email) {
|
||||
return new Response('Unauthorized', { status: 401 });
|
||||
}
|
||||
|
||||
await voteMessage({
|
||||
chatId,
|
||||
messageId,
|
||||
type: type,
|
||||
});
|
||||
|
||||
return new Response('Message voted', { status: 200 });
|
||||
}
|
||||
|
|
@ -5,25 +5,18 @@ import { notFound } from 'next/navigation';
|
|||
import { DEFAULT_MODEL_NAME, models } from '@/ai/models';
|
||||
import { auth } from '@/app/(auth)/auth';
|
||||
import { Chat as PreviewChat } from '@/components/custom/chat';
|
||||
import { getChatById } from '@/db/queries';
|
||||
import { Chat } from '@/db/schema';
|
||||
import { getChatById, getMessagesByChatId } from '@/db/queries';
|
||||
import { convertToUIMessages } from '@/lib/utils';
|
||||
|
||||
export default async function Page(props: { params: Promise<any> }) {
|
||||
const params = await props.params;
|
||||
const { id } = params;
|
||||
const chatFromDb = await getChatById({ id });
|
||||
const chat = await getChatById({ id });
|
||||
|
||||
if (!chatFromDb) {
|
||||
if (!chat) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
// type casting
|
||||
const chat: Chat = {
|
||||
...chatFromDb,
|
||||
messages: convertToUIMessages(chatFromDb.messages as Array<CoreMessage>),
|
||||
};
|
||||
|
||||
const session = await auth();
|
||||
|
||||
if (!session || !session.user) {
|
||||
|
|
@ -34,6 +27,10 @@ export default async function Page(props: { params: Promise<any> }) {
|
|||
return notFound();
|
||||
}
|
||||
|
||||
const messagesFromDb = await getMessagesByChatId({
|
||||
id,
|
||||
});
|
||||
|
||||
const cookieStore = await cookies();
|
||||
const modelIdFromCookie = cookieStore.get('model-id')?.value;
|
||||
const selectedModelId =
|
||||
|
|
@ -43,7 +40,7 @@ export default async function Page(props: { params: Promise<any> }) {
|
|||
return (
|
||||
<PreviewChat
|
||||
id={chat.id}
|
||||
initialMessages={chat.messages}
|
||||
initialMessages={convertToUIMessages(messagesFromDb)}
|
||||
selectedModelId={selectedModelId}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
166
components/custom/message-actions.tsx
Normal file
166
components/custom/message-actions.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
126
db/queries.ts
126
db/queries.ts
|
|
@ -1,11 +1,20 @@
|
|||
"server-only";
|
||||
'server-only';
|
||||
|
||||
import { genSaltSync, hashSync } from "bcrypt-ts";
|
||||
import { and, asc, desc, eq, gt } from "drizzle-orm";
|
||||
import { drizzle } from "drizzle-orm/postgres-js";
|
||||
import postgres from "postgres";
|
||||
import { genSaltSync, hashSync } from 'bcrypt-ts';
|
||||
import { and, asc, desc, eq, gt } from 'drizzle-orm';
|
||||
import { drizzle } from 'drizzle-orm/postgres-js';
|
||||
import postgres from 'postgres';
|
||||
|
||||
import { user, chat, User, document, Suggestion } from "./schema";
|
||||
import {
|
||||
user,
|
||||
chat,
|
||||
User,
|
||||
document,
|
||||
Suggestion,
|
||||
Message,
|
||||
message,
|
||||
vote,
|
||||
} from './schema';
|
||||
|
||||
// Optionally, if not using email/pass login, you can
|
||||
// use the Drizzle adapter for Auth.js / NextAuth
|
||||
|
|
@ -17,7 +26,7 @@ export async function getUser(email: string): Promise<Array<User>> {
|
|||
try {
|
||||
return await db.select().from(user).where(eq(user.email, email));
|
||||
} catch (error) {
|
||||
console.error("Failed to get user from database");
|
||||
console.error('Failed to get user from database');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
|
@ -29,40 +38,29 @@ export async function createUser(email: string, password: string) {
|
|||
try {
|
||||
return await db.insert(user).values({ email, password: hash });
|
||||
} catch (error) {
|
||||
console.error("Failed to create user in database");
|
||||
console.error('Failed to create user in database');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveChat({
|
||||
id,
|
||||
messages,
|
||||
userId,
|
||||
title,
|
||||
}: {
|
||||
id: string;
|
||||
messages: any;
|
||||
userId: string;
|
||||
title: string;
|
||||
}) {
|
||||
try {
|
||||
const selectedChats = await db.select().from(chat).where(eq(chat.id, id));
|
||||
|
||||
if (selectedChats.length > 0) {
|
||||
return await db
|
||||
.update(chat)
|
||||
.set({
|
||||
messages: JSON.stringify(messages),
|
||||
})
|
||||
.where(eq(chat.id, id));
|
||||
}
|
||||
|
||||
return await db.insert(chat).values({
|
||||
id,
|
||||
createdAt: new Date(),
|
||||
messages: JSON.stringify(messages),
|
||||
userId,
|
||||
title,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to save chat in database");
|
||||
console.error('Failed to save chat in database');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
|
@ -71,7 +69,7 @@ export async function deleteChatById({ id }: { id: string }) {
|
|||
try {
|
||||
return await db.delete(chat).where(eq(chat.id, id));
|
||||
} catch (error) {
|
||||
console.error("Failed to delete chat by id from database");
|
||||
console.error('Failed to delete chat by id from database');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
|
@ -84,7 +82,7 @@ export async function getChatsByUserId({ id }: { id: string }) {
|
|||
.where(eq(chat.userId, id))
|
||||
.orderBy(desc(chat.createdAt));
|
||||
} catch (error) {
|
||||
console.error("Failed to get chats by user from database");
|
||||
console.error('Failed to get chats by user from database');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
|
@ -94,7 +92,71 @@ export async function getChatById({ id }: { id: string }) {
|
|||
const [selectedChat] = await db.select().from(chat).where(eq(chat.id, id));
|
||||
return selectedChat;
|
||||
} catch (error) {
|
||||
console.error("Failed to get chat by id from database");
|
||||
console.error('Failed to get chat by id from database');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveMessages({ messages }: { messages: Array<Message> }) {
|
||||
try {
|
||||
return await db.insert(message).values(messages);
|
||||
} catch (error) {
|
||||
console.error('Failed to save messages in database', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getMessagesByChatId({ id }: { id: string }) {
|
||||
try {
|
||||
return await db
|
||||
.select()
|
||||
.from(message)
|
||||
.where(eq(message.chatId, id))
|
||||
.orderBy(asc(message.createdAt));
|
||||
} catch (error) {
|
||||
console.error('Failed to get messages by chat id from database', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function voteMessage({
|
||||
chatId,
|
||||
messageId,
|
||||
type,
|
||||
}: {
|
||||
chatId: string;
|
||||
messageId: string;
|
||||
type: 'up' | 'down';
|
||||
}) {
|
||||
try {
|
||||
const [existingVote] = await db
|
||||
.select()
|
||||
.from(vote)
|
||||
.where(and(eq(vote.messageId, messageId)));
|
||||
|
||||
if (existingVote) {
|
||||
return await db
|
||||
.update(vote)
|
||||
.set({ isUpvoted: type === 'up' ? true : false })
|
||||
.where(and(eq(vote.messageId, messageId), eq(vote.chatId, chatId)));
|
||||
} else {
|
||||
return await db.insert(vote).values({
|
||||
chatId,
|
||||
messageId,
|
||||
isUpvoted: type === 'up' ? true : false,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to upvote message in database', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getVotesByChatId({ id }: { id: string }) {
|
||||
try {
|
||||
return await db.select().from(vote).where(eq(vote.chatId, id));
|
||||
} catch (error) {
|
||||
console.error('Failed to get votes by chat id from database', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
|
@ -119,7 +181,7 @@ export async function saveDocument({
|
|||
createdAt: new Date(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to save document in database");
|
||||
console.error('Failed to save document in database');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
|
@ -134,7 +196,7 @@ export async function getDocumentsById({ id }: { id: string }) {
|
|||
|
||||
return documents;
|
||||
} catch (error) {
|
||||
console.error("Failed to get document by id from database");
|
||||
console.error('Failed to get document by id from database');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
|
@ -149,7 +211,7 @@ export async function getDocumentById({ id }: { id: string }) {
|
|||
|
||||
return selectedDocument;
|
||||
} catch (error) {
|
||||
console.error("Failed to get document by id from database");
|
||||
console.error('Failed to get document by id from database');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
|
@ -167,7 +229,7 @@ export async function deleteDocumentsByIdAfterTimestamp({
|
|||
.where(and(eq(document.id, id), gt(document.createdAt, timestamp)));
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"Failed to delete documents by id after timestamp from database",
|
||||
'Failed to delete documents by id after timestamp from database'
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
|
|
@ -181,7 +243,7 @@ export async function saveSuggestions({
|
|||
try {
|
||||
return await db.insert(Suggestion).values(suggestions);
|
||||
} catch (error) {
|
||||
console.error("Failed to save suggestions in database");
|
||||
console.error('Failed to save suggestions in database');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
|
@ -198,7 +260,7 @@ export async function getSuggestionsByDocumentId({
|
|||
.where(and(eq(Suggestion.documentId, documentId)));
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"Failed to get suggestions by document version from database",
|
||||
'Failed to get suggestions by document version from database'
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
|
|
|
|||
39
db/schema.ts
39
db/schema.ts
|
|
@ -1,4 +1,3 @@
|
|||
import { Message } from 'ai';
|
||||
import { InferSelectModel } from 'drizzle-orm';
|
||||
import {
|
||||
pgTable,
|
||||
|
|
@ -23,15 +22,45 @@ export type User = InferSelectModel<typeof user>;
|
|||
export const chat = pgTable('Chat', {
|
||||
id: uuid('id').primaryKey().notNull().defaultRandom(),
|
||||
createdAt: timestamp('createdAt').notNull(),
|
||||
messages: json('messages').notNull(),
|
||||
title: text('title').notNull(),
|
||||
userId: uuid('userId')
|
||||
.notNull()
|
||||
.references(() => user.id),
|
||||
});
|
||||
|
||||
export type Chat = Omit<InferSelectModel<typeof chat>, 'messages'> & {
|
||||
messages: Array<Message>;
|
||||
};
|
||||
export type Chat = InferSelectModel<typeof chat>;
|
||||
|
||||
export const message = pgTable('Message', {
|
||||
id: uuid('id').primaryKey().notNull().defaultRandom(),
|
||||
chatId: uuid('chatId')
|
||||
.notNull()
|
||||
.references(() => chat.id),
|
||||
role: varchar('role').notNull(),
|
||||
content: json('content').notNull(),
|
||||
createdAt: timestamp('createdAt').notNull(),
|
||||
});
|
||||
|
||||
export type Message = InferSelectModel<typeof message>;
|
||||
|
||||
export const vote = pgTable(
|
||||
'Vote',
|
||||
{
|
||||
chatId: uuid('chatId')
|
||||
.notNull()
|
||||
.references(() => chat.id),
|
||||
messageId: uuid('messageId')
|
||||
.notNull()
|
||||
.references(() => message.id),
|
||||
isUpvoted: boolean('isUpvoted').notNull(),
|
||||
},
|
||||
(table) => {
|
||||
return {
|
||||
pk: primaryKey({ columns: [table.chatId, table.messageId] }),
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
export type Vote = InferSelectModel<typeof vote>;
|
||||
|
||||
export const document = pgTable(
|
||||
'Document',
|
||||
|
|
|
|||
35
lib/drizzle/0002_wandering_riptide.sql
Normal file
35
lib/drizzle/0002_wandering_riptide.sql
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
CREATE TABLE IF NOT EXISTS "Message" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"chatId" uuid NOT NULL,
|
||||
"role" varchar NOT NULL,
|
||||
"content" json NOT NULL,
|
||||
"createdAt" timestamp NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "Vote" (
|
||||
"chatId" uuid NOT NULL,
|
||||
"messageId" uuid NOT NULL,
|
||||
"isUpvoted" boolean NOT NULL,
|
||||
CONSTRAINT "Vote_chatId_messageId_pk" PRIMARY KEY("chatId","messageId")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "Chat" ADD COLUMN "title" text NOT NULL;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "Message" ADD CONSTRAINT "Message_chatId_Chat_id_fk" FOREIGN KEY ("chatId") REFERENCES "public"."Chat"("id") ON DELETE no action ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "Vote" ADD CONSTRAINT "Vote_chatId_Chat_id_fk" FOREIGN KEY ("chatId") REFERENCES "public"."Chat"("id") ON DELETE no action ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "Vote" ADD CONSTRAINT "Vote_messageId_Message_id_fk" FOREIGN KEY ("messageId") REFERENCES "public"."Message"("id") ON DELETE no action ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "Chat" DROP COLUMN IF EXISTS "messages";
|
||||
377
lib/drizzle/meta/0002_snapshot.json
Normal file
377
lib/drizzle/meta/0002_snapshot.json
Normal file
|
|
@ -0,0 +1,377 @@
|
|||
{
|
||||
"id": "b5d8e862-936f-4419-a50f-97be3e7fe665",
|
||||
"prevId": "f3d3437c-4735-4c91-80af-1014048a904e",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.Suggestion": {
|
||||
"name": "Suggestion",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"documentId": {
|
||||
"name": "documentId",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"documentCreatedAt": {
|
||||
"name": "documentCreatedAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"originalText": {
|
||||
"name": "originalText",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"suggestedText": {
|
||||
"name": "suggestedText",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"isResolved": {
|
||||
"name": "isResolved",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"userId": {
|
||||
"name": "userId",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"createdAt": {
|
||||
"name": "createdAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"Suggestion_userId_User_id_fk": {
|
||||
"name": "Suggestion_userId_User_id_fk",
|
||||
"tableFrom": "Suggestion",
|
||||
"tableTo": "User",
|
||||
"columnsFrom": [
|
||||
"userId"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"Suggestion_documentId_documentCreatedAt_Document_id_createdAt_fk": {
|
||||
"name": "Suggestion_documentId_documentCreatedAt_Document_id_createdAt_fk",
|
||||
"tableFrom": "Suggestion",
|
||||
"tableTo": "Document",
|
||||
"columnsFrom": [
|
||||
"documentId",
|
||||
"documentCreatedAt"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id",
|
||||
"createdAt"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"Suggestion_id_pk": {
|
||||
"name": "Suggestion_id_pk",
|
||||
"columns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {}
|
||||
},
|
||||
"public.Chat": {
|
||||
"name": "Chat",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"createdAt": {
|
||||
"name": "createdAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"userId": {
|
||||
"name": "userId",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"Chat_userId_User_id_fk": {
|
||||
"name": "Chat_userId_User_id_fk",
|
||||
"tableFrom": "Chat",
|
||||
"tableTo": "User",
|
||||
"columnsFrom": [
|
||||
"userId"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {}
|
||||
},
|
||||
"public.Document": {
|
||||
"name": "Document",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"createdAt": {
|
||||
"name": "createdAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"content": {
|
||||
"name": "content",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"userId": {
|
||||
"name": "userId",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"Document_userId_User_id_fk": {
|
||||
"name": "Document_userId_User_id_fk",
|
||||
"tableFrom": "Document",
|
||||
"tableTo": "User",
|
||||
"columnsFrom": [
|
||||
"userId"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"Document_id_createdAt_pk": {
|
||||
"name": "Document_id_createdAt_pk",
|
||||
"columns": [
|
||||
"id",
|
||||
"createdAt"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {}
|
||||
},
|
||||
"public.Message": {
|
||||
"name": "Message",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"chatId": {
|
||||
"name": "chatId",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"role": {
|
||||
"name": "role",
|
||||
"type": "varchar",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"content": {
|
||||
"name": "content",
|
||||
"type": "json",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"createdAt": {
|
||||
"name": "createdAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"Message_chatId_Chat_id_fk": {
|
||||
"name": "Message_chatId_Chat_id_fk",
|
||||
"tableFrom": "Message",
|
||||
"tableTo": "Chat",
|
||||
"columnsFrom": [
|
||||
"chatId"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {}
|
||||
},
|
||||
"public.User": {
|
||||
"name": "User",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "varchar(64)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"password": {
|
||||
"name": "password",
|
||||
"type": "varchar(64)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {}
|
||||
},
|
||||
"public.Vote": {
|
||||
"name": "Vote",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"chatId": {
|
||||
"name": "chatId",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"messageId": {
|
||||
"name": "messageId",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"isUpvoted": {
|
||||
"name": "isUpvoted",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"Vote_chatId_Chat_id_fk": {
|
||||
"name": "Vote_chatId_Chat_id_fk",
|
||||
"tableFrom": "Vote",
|
||||
"tableTo": "Chat",
|
||||
"columnsFrom": [
|
||||
"chatId"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"Vote_messageId_Message_id_fk": {
|
||||
"name": "Vote_messageId_Message_id_fk",
|
||||
"tableFrom": "Vote",
|
||||
"tableTo": "Message",
|
||||
"columnsFrom": [
|
||||
"messageId"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"Vote_chatId_messageId_pk": {
|
||||
"name": "Vote_chatId_messageId_pk",
|
||||
"columns": [
|
||||
"chatId",
|
||||
"messageId"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {}
|
||||
}
|
||||
},
|
||||
"enums": {},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
|
|
@ -15,6 +15,13 @@
|
|||
"when": 1730207363999,
|
||||
"tag": "0001_sparkling_blue_marvel",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "7",
|
||||
"when": 1730725226313,
|
||||
"tag": "0002_wandering_riptide",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
47
lib/utils.ts
47
lib/utils.ts
|
|
@ -2,14 +2,13 @@ import {
|
|||
CoreAssistantMessage,
|
||||
CoreMessage,
|
||||
CoreToolMessage,
|
||||
generateId,
|
||||
Message,
|
||||
ToolInvocation,
|
||||
} from 'ai';
|
||||
import { clsx, type ClassValue } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
import { Chat, Document } from '@/db/schema';
|
||||
import { Message as DBMessage, Document } from '@/db/schema';
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
|
|
@ -86,7 +85,7 @@ function addToolMessageToChat({
|
|||
}
|
||||
|
||||
export function convertToUIMessages(
|
||||
messages: Array<CoreMessage>
|
||||
messages: Array<DBMessage>
|
||||
): Array<Message> {
|
||||
return messages.reduce((chatMessages: Array<Message>, message) => {
|
||||
if (message.role === 'tool') {
|
||||
|
|
@ -117,8 +116,8 @@ export function convertToUIMessages(
|
|||
}
|
||||
|
||||
chatMessages.push({
|
||||
id: generateId(),
|
||||
role: message.role,
|
||||
id: message.id,
|
||||
role: message.role as Message['role'],
|
||||
content: textContent,
|
||||
toolInvocations,
|
||||
});
|
||||
|
|
@ -127,29 +126,6 @@ export function convertToUIMessages(
|
|||
}, []);
|
||||
}
|
||||
|
||||
export function getTitleFromChat(chat: Chat) {
|
||||
const messages = convertToUIMessages(chat.messages as Array<CoreMessage>);
|
||||
const firstMessage = messages[0];
|
||||
|
||||
if (!firstMessage) {
|
||||
return 'Untitled';
|
||||
}
|
||||
|
||||
return firstMessage.content;
|
||||
}
|
||||
|
||||
const emptyAssistantMessage = [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function sanitizeResponseMessages(
|
||||
messages: Array<CoreToolMessage | CoreAssistantMessage>
|
||||
): Array<CoreToolMessage | CoreAssistantMessage> {
|
||||
|
|
@ -222,6 +198,11 @@ export function sanitizeUIMessages(messages: Array<Message>): Array<Message> {
|
|||
);
|
||||
}
|
||||
|
||||
export function getMostRecentUserMessage(messages: Array<CoreMessage>) {
|
||||
const userMessages = messages.filter((message) => message.role === 'user');
|
||||
return userMessages.at(-1);
|
||||
}
|
||||
|
||||
export function getDocumentTimestampByIndex(
|
||||
documents: Array<Document>,
|
||||
index: number
|
||||
|
|
@ -231,3 +212,13 @@ export function getDocumentTimestampByIndex(
|
|||
|
||||
return documents[index].createdAt;
|
||||
}
|
||||
|
||||
export function getMessageIdFromAnnotations(message: Message) {
|
||||
if (!message.annotations) return message.id;
|
||||
|
||||
const [annotation] = message.annotations;
|
||||
if (!annotation) return message.id;
|
||||
|
||||
// @ts-expect-error messageIdFromServer is not defined in MessageAnnotation
|
||||
return annotation.messageIdFromServer;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@
|
|||
"@vercel/analytics": "^1.3.1",
|
||||
"@vercel/blob": "^0.24.1",
|
||||
"@vercel/postgres": "^0.10.0",
|
||||
"ai": "3.4.27",
|
||||
"ai": "3.4.32",
|
||||
"bcrypt-ts": "^5.0.2",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"classnames": "^2.5.1",
|
||||
|
|
|
|||
52
pnpm-lock.yaml
generated
52
pnpm-lock.yaml
generated
|
|
@ -48,8 +48,8 @@ dependencies:
|
|||
specifier: ^0.10.0
|
||||
version: 0.10.0
|
||||
ai:
|
||||
specifier: 3.4.27
|
||||
version: 3.4.27(react@19.0.0-rc-45804af1-20241021)(svelte@5.1.3)(vue@3.5.12)(zod@3.23.8)
|
||||
specifier: 3.4.32
|
||||
version: 3.4.32(react@19.0.0-rc-45804af1-20241021)(svelte@5.1.3)(vue@3.5.12)(zod@3.23.8)
|
||||
bcrypt-ts:
|
||||
specifier: ^5.0.2
|
||||
version: 5.0.2
|
||||
|
|
@ -271,8 +271,8 @@ packages:
|
|||
json-schema: 0.4.0
|
||||
dev: false
|
||||
|
||||
/@ai-sdk/react@0.0.68(react@19.0.0-rc-45804af1-20241021)(zod@3.23.8):
|
||||
resolution: {integrity: sha512-dD7cm2UsPWkuWg+qKRXjF+sNLVcUzWUnV25FxvEliJP7I2ajOpq8c+/xyGlm+YodyvAB0fX+oSODOeIWi7lCKg==}
|
||||
/@ai-sdk/react@0.0.70(react@19.0.0-rc-45804af1-20241021)(zod@3.23.8):
|
||||
resolution: {integrity: sha512-GnwbtjW4/4z7MleLiW+TOZC2M29eCg1tOUpuEiYFMmFNZK8mkrqM0PFZMo6UsYeUYMWqEOOcPOU9OQVJMJh7IQ==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
react: ^18 || ^19 || ^19.0.0-rc
|
||||
|
|
@ -284,14 +284,15 @@ packages:
|
|||
optional: true
|
||||
dependencies:
|
||||
'@ai-sdk/provider-utils': 1.0.22(zod@3.23.8)
|
||||
'@ai-sdk/ui-utils': 0.0.49(zod@3.23.8)
|
||||
'@ai-sdk/ui-utils': 0.0.50(zod@3.23.8)
|
||||
react: 19.0.0-rc-45804af1-20241021
|
||||
swr: 2.2.5(react@19.0.0-rc-45804af1-20241021)
|
||||
throttleit: 2.1.0
|
||||
zod: 3.23.8
|
||||
dev: false
|
||||
|
||||
/@ai-sdk/solid@0.0.53(zod@3.23.8):
|
||||
resolution: {integrity: sha512-0yXkwTE75QKdmz40CBtAFy3sQdUnn/TNMTkTE2xfqC9YN7Ixql472TtC+3h6s4dPjRJm5bNnGJAWHwjT2PBmTw==}
|
||||
/@ai-sdk/solid@0.0.54(zod@3.23.8):
|
||||
resolution: {integrity: sha512-96KWTVK+opdFeRubqrgaJXoNiDP89gNxFRWUp0PJOotZW816AbhUf4EnDjBjXTLjXL1n0h8tGSE9sZsRkj9wQQ==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
solid-js: ^1.7.7
|
||||
|
|
@ -300,13 +301,13 @@ packages:
|
|||
optional: true
|
||||
dependencies:
|
||||
'@ai-sdk/provider-utils': 1.0.22(zod@3.23.8)
|
||||
'@ai-sdk/ui-utils': 0.0.49(zod@3.23.8)
|
||||
'@ai-sdk/ui-utils': 0.0.50(zod@3.23.8)
|
||||
transitivePeerDependencies:
|
||||
- zod
|
||||
dev: false
|
||||
|
||||
/@ai-sdk/svelte@0.0.56(svelte@5.1.3)(zod@3.23.8):
|
||||
resolution: {integrity: sha512-EmBHGxVkmC6Ugc2O3tH6+F0udYKUhdlqokKAdO3zZihpNCj4qC5msyzqbhRqX0415tD1eJib5SX2Sva47CHmLA==}
|
||||
/@ai-sdk/svelte@0.0.57(svelte@5.1.3)(zod@3.23.8):
|
||||
resolution: {integrity: sha512-SyF9ItIR9ALP9yDNAD+2/5Vl1IT6kchgyDH8xkmhysfJI6WrvJbtO1wdQ0nylvPLcsPoYu+cAlz1krU4lFHcYw==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
svelte: ^3.0.0 || ^4.0.0 || ^5.0.0
|
||||
|
|
@ -315,15 +316,15 @@ packages:
|
|||
optional: true
|
||||
dependencies:
|
||||
'@ai-sdk/provider-utils': 1.0.22(zod@3.23.8)
|
||||
'@ai-sdk/ui-utils': 0.0.49(zod@3.23.8)
|
||||
'@ai-sdk/ui-utils': 0.0.50(zod@3.23.8)
|
||||
sswr: 2.1.0(svelte@5.1.3)
|
||||
svelte: 5.1.3
|
||||
transitivePeerDependencies:
|
||||
- zod
|
||||
dev: false
|
||||
|
||||
/@ai-sdk/ui-utils@0.0.49(zod@3.23.8):
|
||||
resolution: {integrity: sha512-urg0KYrfJmfEBSva9d132YRxAVmdU12ISGVlOV7yJkL86NPaU15qcRRWpOJqmMl4SJYkyZGyL1Rw9/GtLVurKw==}
|
||||
/@ai-sdk/ui-utils@0.0.50(zod@3.23.8):
|
||||
resolution: {integrity: sha512-Z5QYJVW+5XpSaJ4jYCCAVG7zIAuKOOdikhgpksneNmKvx61ACFaf98pmOd+xnjahl0pIlc/QIe6O4yVaJ1sEaw==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
zod: ^3.0.0
|
||||
|
|
@ -339,8 +340,8 @@ packages:
|
|||
zod-to-json-schema: 3.23.5(zod@3.23.8)
|
||||
dev: false
|
||||
|
||||
/@ai-sdk/vue@0.0.58(vue@3.5.12)(zod@3.23.8):
|
||||
resolution: {integrity: sha512-8cuIekJV+jYz68Z+EDp8Df1WNiBEO1NOUGNCy+5gqIi+j382YjuhZfzC78zbzg0PndfF5JzcXhWPqmcc0loUQA==}
|
||||
/@ai-sdk/vue@0.0.59(vue@3.5.12)(zod@3.23.8):
|
||||
resolution: {integrity: sha512-+ofYlnqdc8c4F6tM0IKF0+7NagZRAiqBJpGDJ+6EYhDW8FHLUP/JFBgu32SjxSxC6IKFZxEnl68ZoP/Z38EMlw==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
vue: ^3.3.4
|
||||
|
|
@ -349,7 +350,7 @@ packages:
|
|||
optional: true
|
||||
dependencies:
|
||||
'@ai-sdk/provider-utils': 1.0.22(zod@3.23.8)
|
||||
'@ai-sdk/ui-utils': 0.0.49(zod@3.23.8)
|
||||
'@ai-sdk/ui-utils': 0.0.50(zod@3.23.8)
|
||||
swrv: 1.0.4(vue@3.5.12)
|
||||
vue: 3.5.12(typescript@5.6.3)
|
||||
transitivePeerDependencies:
|
||||
|
|
@ -2505,8 +2506,8 @@ packages:
|
|||
engines: {node: '>=0.4.0'}
|
||||
hasBin: true
|
||||
|
||||
/ai@3.4.27(react@19.0.0-rc-45804af1-20241021)(svelte@5.1.3)(vue@3.5.12)(zod@3.23.8):
|
||||
resolution: {integrity: sha512-xM5EuCSnqnZ2+3VeIkghj5Tgu+SttOkUHsCBhZrspt7EqI1+kesEeLxJMk/9vLpb9jakF4Fd0+P7wirUPRWDRg==}
|
||||
/ai@3.4.32(react@19.0.0-rc-45804af1-20241021)(svelte@5.1.3)(vue@3.5.12)(zod@3.23.8):
|
||||
resolution: {integrity: sha512-d5Im7kWsjw1T/IFfYPH4KDLUn4W+XIZqip34q7wcyVBAKLcjGSbJh+5F7Ktc1MIzThHUPh/NUzbHlDDNhTlCgA==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
openai: ^4.42.0
|
||||
|
|
@ -2528,11 +2529,11 @@ packages:
|
|||
dependencies:
|
||||
'@ai-sdk/provider': 0.0.26
|
||||
'@ai-sdk/provider-utils': 1.0.22(zod@3.23.8)
|
||||
'@ai-sdk/react': 0.0.68(react@19.0.0-rc-45804af1-20241021)(zod@3.23.8)
|
||||
'@ai-sdk/solid': 0.0.53(zod@3.23.8)
|
||||
'@ai-sdk/svelte': 0.0.56(svelte@5.1.3)(zod@3.23.8)
|
||||
'@ai-sdk/ui-utils': 0.0.49(zod@3.23.8)
|
||||
'@ai-sdk/vue': 0.0.58(vue@3.5.12)(zod@3.23.8)
|
||||
'@ai-sdk/react': 0.0.70(react@19.0.0-rc-45804af1-20241021)(zod@3.23.8)
|
||||
'@ai-sdk/solid': 0.0.54(zod@3.23.8)
|
||||
'@ai-sdk/svelte': 0.0.57(svelte@5.1.3)(zod@3.23.8)
|
||||
'@ai-sdk/ui-utils': 0.0.50(zod@3.23.8)
|
||||
'@ai-sdk/vue': 0.0.59(vue@3.5.12)(zod@3.23.8)
|
||||
'@opentelemetry/api': 1.9.0
|
||||
eventsource-parser: 1.1.2
|
||||
json-schema: 0.4.0
|
||||
|
|
@ -6417,6 +6418,11 @@ packages:
|
|||
dependencies:
|
||||
any-promise: 1.3.0
|
||||
|
||||
/throttleit@2.1.0:
|
||||
resolution: {integrity: sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw==}
|
||||
engines: {node: '>=18'}
|
||||
dev: false
|
||||
|
||||
/to-regex-range@5.0.1:
|
||||
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
|
||||
engines: {node: '>=8.0'}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue