init
This commit is contained in:
commit
a04776256d
56 changed files with 6808 additions and 0 deletions
25
app/actions.ts
Normal file
25
app/actions.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
"use server";
|
||||
|
||||
import { getServerSession } from "next-auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
|
||||
|
||||
export async function removeChat({ id, path }: { id: string; path: string }) {
|
||||
const session = await getServerSession(authOptions);
|
||||
const userId = session?.user?.email;
|
||||
if (!userId || !id) {
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
|
||||
await prisma.chat.delete({
|
||||
where: {
|
||||
id,
|
||||
// TODO: Add scoping
|
||||
// userId,
|
||||
},
|
||||
});
|
||||
|
||||
revalidatePath("/");
|
||||
revalidatePath("/chat/[id]");
|
||||
}
|
||||
18
app/api/auth/[...nextauth]/route.ts
Normal file
18
app/api/auth/[...nextauth]/route.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import NextAuth, { NextAuthOptions } from "next-auth";
|
||||
import { PrismaAdapter } from "@next-auth/prisma-adapter";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import GoogleProvider from "next-auth/providers/google";
|
||||
|
||||
export const authOptions: NextAuthOptions = {
|
||||
adapter: PrismaAdapter(prisma),
|
||||
providers: [
|
||||
GoogleProvider({
|
||||
clientId: process.env.GOOGLE_CLIENT_ID as string,
|
||||
clientSecret: process.env.GOOGLE_CLIENT_SECRET as string,
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
||||
const handler = NextAuth(authOptions);
|
||||
|
||||
export { handler as GET, handler as POST };
|
||||
27
app/api/generate/route.ts
Normal file
27
app/api/generate/route.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { OpenAIStream, openai } from "@/lib/openai";
|
||||
import { getServerSession } from "next-auth";
|
||||
|
||||
export const runtime = "edge";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const json = await req.json();
|
||||
const session = await getServerSession();
|
||||
|
||||
const res = await openai.createChatCompletion({
|
||||
model: "gpt-3.5-turbo",
|
||||
messages: json.messages,
|
||||
temperature: 0.7,
|
||||
top_p: 1,
|
||||
frequency_penalty: 1,
|
||||
max_tokens: 500,
|
||||
n: 1,
|
||||
stream: true,
|
||||
});
|
||||
|
||||
const stream = await OpenAIStream(res);
|
||||
|
||||
return new Response(stream, {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "text/event-stream" },
|
||||
});
|
||||
}
|
||||
28
app/chat-list.tsx
Normal file
28
app/chat-list.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
"use client";
|
||||
|
||||
import { type Message } from "@prisma/client";
|
||||
|
||||
import { ChatMessage } from "./chat-message";
|
||||
|
||||
export interface ChatList {
|
||||
messages: Message[];
|
||||
}
|
||||
|
||||
export function ChatList({ messages }: ChatList) {
|
||||
return (
|
||||
<div className="relative h-full dark:bg-zinc-900">
|
||||
<div className="h-full w-full overflow-auto">
|
||||
{messages.length > 0 ? (
|
||||
<div className="group w-full text-zinc-900 dark:text-white divide-y dark:divide-zinc-800">
|
||||
{messages.map((message) => (
|
||||
<ChatMessage
|
||||
key={message.id || message.content}
|
||||
message={message}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
119
app/chat-message.tsx
Normal file
119
app/chat-message.tsx
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
import { type Message } from "@prisma/client";
|
||||
import CodeBlock from "./codeblock";
|
||||
import { MemoizedReactMarkdown } from "./markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import remarkMath from "remark-math";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { fontMessage } from "@/lib/fonts";
|
||||
export interface ChatMessageProps {
|
||||
message: Message;
|
||||
}
|
||||
|
||||
export function ChatMessage(props: ChatMessageProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
{
|
||||
"bg-zinc-50": props.message.role === "assistant",
|
||||
},
|
||||
"pr-0 lg:pr-[260px]",
|
||||
fontMessage.className
|
||||
)}
|
||||
>
|
||||
<div className="m-auto flex gap-4 p-4 text-base md:max-w-2xl md:gap-6 md:py-6 lg:max-w-xl xl:max-w-3xl">
|
||||
<div className="flex w-full gap-4 items-start">
|
||||
{props.message.role === "user" ? (
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-gradient-to-r from-cyan-500 to-blue-500 p-2 select-none">
|
||||
<div
|
||||
className="font-medium uppercase text-zinc-100"
|
||||
style={{ fontSize: 6 }}
|
||||
>
|
||||
User
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center select-none bg-zinc-300 rounded-full">
|
||||
<IconOpenAI className="h-5 w-5" />
|
||||
</div>
|
||||
)}
|
||||
<MemoizedReactMarkdown
|
||||
className="prose prose-stone prose-base prose-pre:rounded-md w-full flex-1 leading-6 prose-p:leading-[1.8rem] prose-pre:bg-[#282c34] max-w-full"
|
||||
remarkPlugins={[remarkGfm, remarkMath]}
|
||||
components={{
|
||||
code({ node, inline, className, children, ...props }) {
|
||||
if (children.length) {
|
||||
if (children[0] == "▍") {
|
||||
return (
|
||||
<span className="mt-1 animate-pulse cursor-default">
|
||||
▍
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
children[0] = (children[0] as string).replace("`▍`", "▍");
|
||||
}
|
||||
|
||||
const match = /language-(\w+)/.exec(className || "");
|
||||
|
||||
return !inline ? (
|
||||
<CodeBlock
|
||||
key={Math.random()}
|
||||
language={(match && match[1]) || ""}
|
||||
value={String(children).replace(/\n$/, "")}
|
||||
{...props}
|
||||
/>
|
||||
) : (
|
||||
<code className={className} {...props}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
table({ children }) {
|
||||
return (
|
||||
<table className="border-collapse border border-black px-3 py-1 ">
|
||||
{children}
|
||||
</table>
|
||||
);
|
||||
},
|
||||
th({ children }) {
|
||||
return (
|
||||
<th className="break-words border border-black bg-gray-500 px-3 py-1 text-white ">
|
||||
{children}
|
||||
</th>
|
||||
);
|
||||
},
|
||||
td({ children }) {
|
||||
return (
|
||||
<td className="break-words border border-black px-3 py-1">
|
||||
{children}
|
||||
</td>
|
||||
);
|
||||
},
|
||||
}}
|
||||
>
|
||||
{props.message.content}
|
||||
</MemoizedReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
ChatMessage.displayName = "ChatMessage";
|
||||
|
||||
export function IconOpenAI(props: JSX.IntrinsicElements["svg"]) {
|
||||
return (
|
||||
<svg
|
||||
fill="#000000"
|
||||
width="800px"
|
||||
height="800px"
|
||||
viewBox="0 0 24 24"
|
||||
role="img"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<title>OpenAI icon</title>
|
||||
<path d="M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.872zm16.5963 3.8558L13.1038 8.364 15.1192 7.2a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.407-.667zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
47
app/chat.tsx
Normal file
47
app/chat.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
"use client";
|
||||
|
||||
import { type Message } from "@prisma/client";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ChatList } from "./chat-list";
|
||||
import { Prompt } from "./prompt";
|
||||
import { usePrompt } from "./use-prompt";
|
||||
|
||||
export interface ChatProps {
|
||||
// create?: (input: string) => Chat | undefined;
|
||||
messages?: Message[];
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export function Chat({
|
||||
id: _id,
|
||||
// create,
|
||||
messages,
|
||||
}: ChatProps) {
|
||||
const router = useRouter();
|
||||
|
||||
const { isLoading, messageList, appendUserMessage, reloadLastMessage } =
|
||||
usePrompt({
|
||||
messages,
|
||||
_id,
|
||||
// onCreate: (id: string) => {
|
||||
// router.push(`/chat/${id}`);
|
||||
// },
|
||||
});
|
||||
|
||||
return (
|
||||
<main className="transition-width relative min-h-full w-full flex-1 overflow-y-auto flex flex-col">
|
||||
<div className="flex-1">
|
||||
<ChatList messages={messageList} />
|
||||
</div>
|
||||
<div className="sticky light-gradient dark:bg-gradient-to-b dark:from-zinc-900 dark:to-zinc-950 bottom-0 left-0 w-full border-t bg-white dark:bg-black md:border-t-0 py-4 md:border-transparent md:!bg-transparent md:dark:border-transparent pr-0 lg:pr-[260px] flex dark:border-transparent items-center justify-center">
|
||||
<Prompt
|
||||
onSubmit={appendUserMessage}
|
||||
onRefresh={messageList.length ? reloadLastMessage : undefined}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
Chat.displayName = "Chat";
|
||||
56
app/chat/[id]/page.tsx
Normal file
56
app/chat/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import { Sidebar } from "@/app/sidebar";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
import { Chat } from "../../chat";
|
||||
import { type Metadata } from "next";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
|
||||
|
||||
export interface ChatPageProps {
|
||||
params: {
|
||||
id: string;
|
||||
};
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: ChatPageProps): Promise<Metadata> {
|
||||
const chat = await prisma.chat.findFirst({
|
||||
where: {
|
||||
id: params.id,
|
||||
},
|
||||
});
|
||||
return {
|
||||
title: chat?.title.slice(0, 50) ?? "Chat",
|
||||
};
|
||||
}
|
||||
|
||||
// Prisma does not support Edge without the Data Proxy currently
|
||||
export const runtime = "nodejs"; // default
|
||||
export const preferredRegion = "home";
|
||||
export const dynamic = "force-dynamic";
|
||||
export default async function ChatPage({ params }: ChatPageProps) {
|
||||
const session = await getServerSession(authOptions);
|
||||
const chat = await prisma.chat.findFirst({
|
||||
where: {
|
||||
id: params.id,
|
||||
},
|
||||
include: {
|
||||
messages: true,
|
||||
},
|
||||
});
|
||||
if (!chat) {
|
||||
throw new Error("Chat not found");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full w-full overflow-hidden">
|
||||
<Sidebar session={session} />
|
||||
<div className="flex h-full min-w-0 flex-1 flex-col">
|
||||
<Chat id={chat.id} messages={chat.messages} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
ChatPage.displayName = "ChatPage";
|
||||
127
app/codeblock.tsx
Normal file
127
app/codeblock.tsx
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
"use client";
|
||||
import { useCopyToClipboard } from "@/lib/hooks/use-copy-to-cliipboard";
|
||||
import { Check, Clipboard, Download } from "lucide-react";
|
||||
import { FC, memo } from "react";
|
||||
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
|
||||
import { coldarkDark } from "react-syntax-highlighter/dist/cjs/styles/prism";
|
||||
|
||||
interface Props {
|
||||
language: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface languageMap {
|
||||
[key: string]: string | undefined;
|
||||
}
|
||||
|
||||
export const programmingLanguages: languageMap = {
|
||||
javascript: ".js",
|
||||
python: ".py",
|
||||
java: ".java",
|
||||
c: ".c",
|
||||
cpp: ".cpp",
|
||||
"c++": ".cpp",
|
||||
"c#": ".cs",
|
||||
ruby: ".rb",
|
||||
php: ".php",
|
||||
swift: ".swift",
|
||||
"objective-c": ".m",
|
||||
kotlin: ".kt",
|
||||
typescript: ".ts",
|
||||
go: ".go",
|
||||
perl: ".pl",
|
||||
rust: ".rs",
|
||||
scala: ".scala",
|
||||
haskell: ".hs",
|
||||
lua: ".lua",
|
||||
shell: ".sh",
|
||||
sql: ".sql",
|
||||
html: ".html",
|
||||
css: ".css",
|
||||
// add more file extensions here, make sure the key is same as language prop in CodeBlock.tsx component
|
||||
};
|
||||
|
||||
export const generateRandomString = (length: number, lowercase = false) => {
|
||||
const chars = "ABCDEFGHJKLMNPQRSTUVWXY3456789"; // excluding similar looking characters like Z, 2, I, 1, O, 0
|
||||
let result = "";
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
return lowercase ? result.toLowerCase() : result;
|
||||
};
|
||||
const CodeBlock: FC<Props> = memo(({ language, value }) => {
|
||||
const { isCopied, copyToClipboard } = useCopyToClipboard({ timeout: 2000 });
|
||||
const downloadAsFile = () => {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
const fileExtension = programmingLanguages[language] || ".file";
|
||||
const suggestedFileName = `file-${generateRandomString(
|
||||
3,
|
||||
true
|
||||
)}${fileExtension}`;
|
||||
const fileName = window.prompt("Enter file name" || "", suggestedFileName);
|
||||
|
||||
if (!fileName) {
|
||||
// user pressed cancel on prompt
|
||||
return;
|
||||
}
|
||||
|
||||
const blob = new Blob([value], { type: "text/plain" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.download = fileName;
|
||||
link.href = url;
|
||||
link.style.display = "none";
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
return (
|
||||
<div className="codeblock relative w-full font-sans text-[16px]">
|
||||
<div className="flex w-full items-center justify-between px-4 py-1.5">
|
||||
<span className="text-xs lowercase text-white">{language}</span>
|
||||
|
||||
<div className="flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1.5 rounded bg-none p-1 text-xs text-white"
|
||||
onClick={() => copyToClipboard(value)}
|
||||
>
|
||||
{isCopied ? <Check size={18} /> : <Clipboard size={18} />}
|
||||
{isCopied ? "Copied!" : "Copy code"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center rounded bg-none p-1 text-xs text-white"
|
||||
onClick={downloadAsFile}
|
||||
>
|
||||
<Download size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SyntaxHighlighter
|
||||
language={language}
|
||||
style={coldarkDark}
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
width: "100%",
|
||||
background: "transparent",
|
||||
}}
|
||||
codeTagProps={{
|
||||
style: {
|
||||
fontSize: "0.9rem",
|
||||
fontFamily: "var(--font-mono)",
|
||||
},
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</SyntaxHighlighter>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
CodeBlock.displayName = "CodeBlock";
|
||||
|
||||
export default CodeBlock;
|
||||
50
app/layout.tsx
Normal file
50
app/layout.tsx
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import "@/styles/globals.css";
|
||||
import { Metadata } from "next";
|
||||
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
import { fontMono, fontSans } from "@/lib/fonts";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
default: "Vercel Chat",
|
||||
template: `%s - Vercel Chat`,
|
||||
},
|
||||
description:
|
||||
"Vercel Chat is an AI-powered chat app built with Next.js and Vercel.",
|
||||
themeColor: [
|
||||
{ media: "(prefers-color-scheme: light)", color: "white" },
|
||||
{ media: "(prefers-color-scheme: dark)", color: "black" },
|
||||
],
|
||||
icons: {
|
||||
icon: "/favicon.ico",
|
||||
shortcut: "/favicon-16x16.png",
|
||||
apple: "/apple-touch-icon.png",
|
||||
},
|
||||
};
|
||||
|
||||
interface RootLayoutProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function RootLayout({ children }: RootLayoutProps) {
|
||||
return (
|
||||
<>
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<head />
|
||||
<body
|
||||
className={cn(
|
||||
"font-sans antialiased",
|
||||
fontSans.variable,
|
||||
fontMono.variable
|
||||
)}
|
||||
>
|
||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||
{children}
|
||||
{/* <TailwindIndicator /> */}
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
</>
|
||||
);
|
||||
}
|
||||
9
app/markdown.tsx
Normal file
9
app/markdown.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { FC, memo } from "react";
|
||||
import ReactMarkdown, { Options } from "react-markdown";
|
||||
|
||||
export const MemoizedReactMarkdown: FC<Options> = memo(
|
||||
ReactMarkdown,
|
||||
(prevProps, nextProps) =>
|
||||
prevProps.children === nextProps.children &&
|
||||
prevProps.className === nextProps.className
|
||||
);
|
||||
21
app/page.tsx
Normal file
21
app/page.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { getServerSession } from "next-auth";
|
||||
import { Chat } from "./chat";
|
||||
import { Sidebar } from "./sidebar";
|
||||
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
|
||||
|
||||
// Prisma does not support Edge without the Data Proxy currently
|
||||
export const runtime = "nodejs"; // default
|
||||
export const preferredRegion = "home";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function IndexPage() {
|
||||
const session = await getServerSession(authOptions);
|
||||
return (
|
||||
<div className="relative flex h-full w-full overflow-hidden">
|
||||
<Sidebar session={session} newChat />
|
||||
<div className="flex h-full min-w-0 flex-1 flex-col">
|
||||
<Chat />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
114
app/prompt.tsx
Normal file
114
app/prompt.tsx
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
"use client";
|
||||
|
||||
import { CornerDownLeft, RefreshCcw, StopCircle } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import Textarea from "react-textarea-autosize";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { fontMessage } from "@/lib/fonts";
|
||||
import { useCmdEnterSubmit } from "@/lib/hooks/use-command-enter-submit";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface PromptProps {
|
||||
onSubmit: (value: string) => void;
|
||||
onRefresh?: () => void;
|
||||
onAbort?: () => void;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export function Prompt({
|
||||
onSubmit,
|
||||
onRefresh,
|
||||
onAbort,
|
||||
isLoading,
|
||||
}: PromptProps) {
|
||||
const [input, setInput] = useState("");
|
||||
const { formRef, onKeyDown } = useCmdEnterSubmit();
|
||||
return (
|
||||
<form
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
setInput("");
|
||||
await onSubmit(input);
|
||||
}}
|
||||
ref={formRef}
|
||||
className="stretch flex w-full flex-row gap-3 md:max-w-2xl lg:max-w-xl xl:max-w-3xl mx-auto px-4 lg:pl-16"
|
||||
>
|
||||
<div className="relative flex h-full flex-1 flex-row-reverse items-stretch md:flex-col">
|
||||
<div>
|
||||
<div className="ml-1 flex h-full justify-center gap-0 md:m-auto md:mb-2 md:w-full md:gap-2">
|
||||
{onRefresh ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="relative border-0 h-full md:h-auto px-3 md:border bg-white dark:bg-zinc-900 dark:border-zinc-800 dark:text-zinc-400"
|
||||
onClick={onRefresh}
|
||||
>
|
||||
<div className="flex h-gull w-full items-center justify-center gap-2">
|
||||
<RefreshCcw className="h-4 w-4 text-zinc-500 md:h-3 md:w-3" />
|
||||
<span className="hidden md:block">Regenerate response</span>
|
||||
</div>
|
||||
</Button>
|
||||
) : null}
|
||||
{/* <button
|
||||
id="share-button"
|
||||
className="btn btn-neutral flex justify-center gap-2"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth="1.5"
|
||||
stroke="currentColor"
|
||||
className="h-3 w-3"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M7.217 10.907a2.25 2.25 0 100 2.186m0-2.186c.18.324.283.696.283 1.093s-.103.77-.283 1.093m0-2.186l9.566-5.314m-9.566 7.5l9.566 5.314m0 0a2.25 2.25 0 103.935 2.186 2.25 2.25 0 00-3.935-2.186zm0-12.814a2.25 2.25 0 103.933-2.185 2.25 2.25 0 00-3.933 2.185z"
|
||||
/>
|
||||
</svg>
|
||||
Share
|
||||
</button> */}
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative flex w-full grow flex-col rounded-md dark:focus:border-white border border-zinc/10 bg-white shadow-[0_10px_20px_-10px_rgba(0,0,0,0.20)] dark:border-zinc-700 dark:bg-zinc-950 dark:text-white dark:shadow-[0_5px_15px_rgba(0,0,0,0.10)] focus-within:border-zinc-800 focus-within:shadow-[0_15px_10px_-12px_rgba(0,0,0,0.22)] transition-all duration-200 focus-within:duration-1000 ease-in-out">
|
||||
<Textarea
|
||||
tabIndex={0}
|
||||
onKeyDown={onKeyDown}
|
||||
rows={1}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder="Send a message."
|
||||
spellCheck={false}
|
||||
className={cn(
|
||||
"m-0 max-h-[200px] w-full sm:text-sm resize-none border-0 bg-transparent p-0 py-[13px] pl-4 pr-7 outline-none ring-0 focus:ring-0 focus-visible:ring-0 dark:bg-transparent",
|
||||
fontMessage.className
|
||||
)}
|
||||
style={{
|
||||
height: 46,
|
||||
overflowY: "hidden",
|
||||
}}
|
||||
/>
|
||||
{isLoading ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAbort}
|
||||
className="absolute top-0 bottom-0 m-auto h-8 w-8 flex items-center justify-center right-2 rounded p-2 text-zinc-500 hover:bg-zinc-100 disabled:opacity-40 disabled:hover:bg-transparent dark:hover:bg-zinc-900 enabled:dark:hover:text-zinc-400 dark:disabled:hover:bg-transparent"
|
||||
>
|
||||
<StopCircle className="h-4 w-4" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="submit"
|
||||
className="absolute top-0 bottom-0 m-auto h-8 w-8 flex items-center justify-center right-2 rounded p-2 text-zinc-500 hover:bg-zinc-100 disabled:opacity-40 disabled:hover:bg-transparent dark:hover:bg-zinc-900 enabled:dark:hover:text-zinc-400 dark:disabled:hover:bg-transparent"
|
||||
>
|
||||
<CornerDownLeft className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
Prompt.displayName = "Prompt";
|
||||
66
app/sidebar-item.tsx
Normal file
66
app/sidebar-item.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { MessageCircleIcon, Trash2Icon, Loader2Icon } from "lucide-react";
|
||||
import { removeChat } from "./actions";
|
||||
import { useTransition } from "react";
|
||||
|
||||
export function SidebarItem({
|
||||
title,
|
||||
href,
|
||||
id,
|
||||
}: {
|
||||
title: string;
|
||||
href: string;
|
||||
id: string;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const active = pathname === href;
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
if (!id) return null;
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className={cn(
|
||||
"group flex shrink-0 cursor-pointer items-center gap-2 rounded p-2 text-sm font-medium transition-colors duration-100 hover:bg-zinc-500/10 hover:dark:bg-zinc-300/20",
|
||||
isPending
|
||||
? "text-zinc-400 dark:text-zinc-500"
|
||||
: "text-zinc-800 dark:text-zinc-400",
|
||||
active
|
||||
? "bg-zinc-500/20 text-zinc-900 font-semibold hover:bg-zinc-500/30 dark:text-white"
|
||||
: "bg-transparent"
|
||||
)}
|
||||
>
|
||||
<MessageCircleIcon className="h-4 w-4" />
|
||||
<div
|
||||
className="relative max-h-5 flex-1 overflow-hidden text-ellipsis break-all select-none"
|
||||
title={title}
|
||||
>
|
||||
<span className="whitespace-nowrap">{title}</span>
|
||||
</div>
|
||||
<button
|
||||
className="opacity-0 -mr-6 transition-opacity duration-0 hover:bg-zinc-400/50 group-hover:mr-0 group-hover:opacity-80 p-1 -my-1 rounded group-hover:duration-600"
|
||||
disabled={isPending}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
startTransition(() => {
|
||||
removeChat({ id, path: href }).then(() => {
|
||||
router.push("/");
|
||||
});
|
||||
});
|
||||
}}
|
||||
>
|
||||
{isPending ? (
|
||||
<Loader2Icon className="animate-spin h-4 w-4" />
|
||||
) : (
|
||||
<Trash2Icon className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
72
app/sidebar.tsx
Normal file
72
app/sidebar.tsx
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import { Plus } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { Login } from "@/components/ui/login";
|
||||
import { UserMenu } from "@/components/ui/user-menu";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { VercelLogo } from "@/components/ui/vercel-logo";
|
||||
import { SidebarItem } from "./sidebar-item";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { type Session } from "next-auth";
|
||||
|
||||
export interface SidebarProps {
|
||||
session: Session | null;
|
||||
newChat?: boolean;
|
||||
}
|
||||
|
||||
export function Sidebar({ session, newChat }: SidebarProps) {
|
||||
return (
|
||||
<div className="hidden shrink-0 bg-zinc-200/80 dark:bg-black md:flex md:w-[260px] md:flex-col">
|
||||
<div className="flex h-full min-h-0 flex-col ">
|
||||
<div className="scrollbar-trigger relative h-full w-full flex-1 items-start">
|
||||
<aside className="flex h-full w-full flex-col p-2 shadow-lg ring-1 ring-zinc-900/10 dark:ring-zinc-200/10 relative z-10">
|
||||
<div className="flex flex-row gap-1 items-center justify-between text-base font-semibold tracking-tight antialiased bg-black text-white px-4 py-4 -m-2 mb-2">
|
||||
<div className="flex flex-row gap-1 whitespace-nowrap items-center">
|
||||
<VercelLogo className="h-3.5" />
|
||||
<span className="select-none">Vercel Chat</span>
|
||||
</div>
|
||||
<div>
|
||||
{session?.user ? <UserMenu session={session} /> : <Login />}
|
||||
</div>
|
||||
</div>
|
||||
<Link
|
||||
href="/"
|
||||
className={cn(
|
||||
"flex shrink-0 cursor-pointer items-center gap-2 rounded p-2 text-sm text-zinc-800 dark:text-zinc-200 font-medium transition-colors duration-300 dark:hover:bg-zinc-300/20 select-none",
|
||||
newChat
|
||||
? "bg-zinc-500/20 text-zinc-900 font-semibold hover:bg-zinc-500/30 dark:text-white"
|
||||
: "hover:bg-zinc-500/10"
|
||||
)}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
New Chat
|
||||
</Link>
|
||||
|
||||
<div className="mt-2 pt-2 border-t border-zinc-300 dark:border-zinc-700 flex-1 flex-col overflow-y-auto overflow-x-hidden transition-opacity duration-500">
|
||||
<div className="flex flex-col pb-2 text-sm text-zinc-100">
|
||||
{/* @ts-ignore */}
|
||||
<SidebarList session={session} />
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Sidebar.displayName = "Sidebar";
|
||||
|
||||
async function SidebarList({ session }: { session?: Session }) {
|
||||
const chats = await prisma.chat.findMany({
|
||||
where: {
|
||||
// This is for debugging, need to add scope to the query later
|
||||
// userId: session?.user.id,
|
||||
},
|
||||
orderBy: {
|
||||
updatedAt: "desc",
|
||||
},
|
||||
});
|
||||
return chats.map((c) => (
|
||||
<SidebarItem key={c.id} title={c.title} href={`/chat/${c.id}`} id={c.id} />
|
||||
));
|
||||
}
|
||||
126
app/use-prompt.tsx
Normal file
126
app/use-prompt.tsx
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
import { useState, useCallback, useRef, useEffect } from "react";
|
||||
import { type Message } from "@prisma/client";
|
||||
import { nanoid } from "@/lib/utils";
|
||||
|
||||
export function usePrompt({
|
||||
messages = [],
|
||||
_id,
|
||||
}: {
|
||||
messages?: Message[];
|
||||
_id: string | undefined | null;
|
||||
}) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [messageList, setMessageList] = useState(messages);
|
||||
|
||||
const isLoadingRef = useRef(isLoading);
|
||||
const messageListRef = useRef(messageList);
|
||||
|
||||
useEffect(() => {
|
||||
isLoadingRef.current = isLoading;
|
||||
}, [isLoading]);
|
||||
|
||||
useEffect(() => {
|
||||
messageListRef.current = messageList;
|
||||
}, [messageList]);
|
||||
|
||||
const appendUserMessage = useCallback(async (content: string | Message) => {
|
||||
// Prevent multiple requests at once
|
||||
if (isLoadingRef.current) return;
|
||||
|
||||
const userMsg =
|
||||
typeof content === "string"
|
||||
? ({ id: nanoid(10), role: "user", content } as Message)
|
||||
: content;
|
||||
const assMsg = {
|
||||
id: nanoid(10),
|
||||
role: "assistant",
|
||||
content: "",
|
||||
} as Message;
|
||||
const messageListSnapshot = messageListRef.current;
|
||||
|
||||
// Reset output
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
// Set user input immediately
|
||||
setMessageList([...messageListSnapshot, userMsg]);
|
||||
|
||||
// If streaming, we need to use fetchEventSource directly
|
||||
const response = await fetch(`/api/generate`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
messages: [...messageListSnapshot, userMsg].map((m) => ({
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
})),
|
||||
}),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
// This data is a ReadableStream
|
||||
const data = response.body;
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = data.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let done = false;
|
||||
let accumulatedValue = ""; // Variable to accumulate chunks
|
||||
|
||||
while (!done) {
|
||||
const { value, done: doneReading } = await reader.read();
|
||||
done = doneReading;
|
||||
const chunkValue = decoder.decode(value);
|
||||
accumulatedValue += chunkValue; // Accumulate the chunk value
|
||||
|
||||
// Check if the accumulated value contains the delimiter
|
||||
const delimiter = "\n";
|
||||
const chunks = accumulatedValue.split(delimiter);
|
||||
|
||||
// Process all chunks except the last one (which may be incomplete)
|
||||
while (chunks.length > 1) {
|
||||
const chunkToDispatch = chunks.shift(); // Get the first chunk
|
||||
if (chunkToDispatch && chunkToDispatch.length > 0) {
|
||||
const chunk = JSON.parse(chunkToDispatch);
|
||||
assMsg.content += chunk;
|
||||
setMessageList([...messageListSnapshot, userMsg, assMsg]);
|
||||
}
|
||||
}
|
||||
|
||||
// The last chunk may be incomplete, so keep it in the accumulated value
|
||||
accumulatedValue = chunks[0];
|
||||
}
|
||||
|
||||
// Process any remaining accumulated value after the loop is done
|
||||
if (accumulatedValue.length > 0) {
|
||||
assMsg.content += accumulatedValue;
|
||||
setMessageList([...messageListSnapshot, userMsg, assMsg]);
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const reloadLastMessage = useCallback(async () => {
|
||||
// Prevent multiple requests at once
|
||||
if (isLoadingRef.current) return;
|
||||
|
||||
const userMsg = messageListRef.current.at(-2);
|
||||
const assMsg = messageListRef.current.at(-1);
|
||||
|
||||
// Both should exist.
|
||||
if (!userMsg || !assMsg) return;
|
||||
|
||||
messageListRef.current = messageListRef.current.slice(0, -2);
|
||||
setMessageList(messageListRef.current);
|
||||
|
||||
await appendUserMessage(userMsg);
|
||||
}, [appendUserMessage]);
|
||||
|
||||
return {
|
||||
messageList,
|
||||
appendUserMessage,
|
||||
reloadLastMessage,
|
||||
isLoading,
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue