diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 000000000..ae10a5cce --- /dev/null +++ b/.editorconfig @@ -0,0 +1,10 @@ +# editorconfig.org +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 2 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 000000000..dc0f9d8bf --- /dev/null +++ b/.eslintignore @@ -0,0 +1,5 @@ +dist/* +.cache +public +node_modules +*.esm.js diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 000000000..19a19a4e8 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://json.schemastore.org/eslintrc", + "root": true, + "extends": ["next/core-web-vitals", "prettier"], + "rules": { + "@next/next/no-html-link-for-pages": "off", + "react/jsx-key": "off" + }, + "settings": { + "next": { + "rootDir": ["./"] + } + }, + "overrides": [ + { + "files": ["*.ts", "*.tsx"], + "parser": "@typescript-eslint/parser" + } + ] +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..d8de1d3b3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,37 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +node_modules +.pnp +.pnp.js + +# testing +coverage + +# next.js +.next/ +out/ +build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# local env files +.env.local +.env.development.local +.env.test.local +.env.production.local + +# turbo +.turbo + +.contentlayer +.env +.vercel diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 000000000..2f730936c --- /dev/null +++ b/.prettierignore @@ -0,0 +1,12 @@ +cache +.cache +package.json +package-lock.json +public +CHANGELOG.md +.yarn +dist +node_modules +.next +build +.contentlayer \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 000000000..99438ebd2 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,4 @@ +{ + "typescript.tsdk": "../../node_modules/.pnpm/typescript@4.9.5/node_modules/typescript/lib", + "typescript.enablePromptUseWorkspaceTsdk": true +} diff --git a/README.md b/README.md new file mode 100644 index 000000000..279df4ad7 --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +# Chat diff --git a/app/actions.ts b/app/actions.ts new file mode 100644 index 000000000..9fe805e37 --- /dev/null +++ b/app/actions.ts @@ -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]"); +} diff --git a/app/api/auth/[...nextauth]/route.ts b/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 000000000..dea6129cb --- /dev/null +++ b/app/api/auth/[...nextauth]/route.ts @@ -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 }; diff --git a/app/api/generate/route.ts b/app/api/generate/route.ts new file mode 100644 index 000000000..80ae6aabb --- /dev/null +++ b/app/api/generate/route.ts @@ -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" }, + }); +} diff --git a/app/chat-list.tsx b/app/chat-list.tsx new file mode 100644 index 000000000..fe4544c9a --- /dev/null +++ b/app/chat-list.tsx @@ -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 ( +
+
+ {messages.length > 0 ? ( +
+ {messages.map((message) => ( + + ))} +
+ ) : null} +
+
+ ); +} diff --git a/app/chat-message.tsx b/app/chat-message.tsx new file mode 100644 index 000000000..569d5aff7 --- /dev/null +++ b/app/chat-message.tsx @@ -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 ( +
+
+
+ {props.message.role === "user" ? ( +
+
+ User +
+
+ ) : ( +
+ +
+ )} + + ▍ + + ); + } + + children[0] = (children[0] as string).replace("`▍`", "▍"); + } + + const match = /language-(\w+)/.exec(className || ""); + + return !inline ? ( + + ) : ( + + {children} + + ); + }, + table({ children }) { + return ( + + {children} +
+ ); + }, + th({ children }) { + return ( + + {children} + + ); + }, + td({ children }) { + return ( + + {children} + + ); + }, + }} + > + {props.message.content} +
+
+
+
+ ); +} + +ChatMessage.displayName = "ChatMessage"; + +export function IconOpenAI(props: JSX.IntrinsicElements["svg"]) { + return ( + + OpenAI icon + + + ); +} diff --git a/app/chat.tsx b/app/chat.tsx new file mode 100644 index 000000000..0ce7861bf --- /dev/null +++ b/app/chat.tsx @@ -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 ( +
+
+ +
+
+ +
+
+ ); +} + +Chat.displayName = "Chat"; diff --git a/app/chat/[id]/page.tsx b/app/chat/[id]/page.tsx new file mode 100644 index 000000000..10c65170f --- /dev/null +++ b/app/chat/[id]/page.tsx @@ -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 { + 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 ( +
+ +
+ +
+
+ ); +} + +ChatPage.displayName = "ChatPage"; diff --git a/app/codeblock.tsx b/app/codeblock.tsx new file mode 100644 index 000000000..264cd6f86 --- /dev/null +++ b/app/codeblock.tsx @@ -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 = 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 ( +
+
+ {language} + +
+ + +
+
+ + + {value} + +
+ ); +}); +CodeBlock.displayName = "CodeBlock"; + +export default CodeBlock; diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 000000000..8fca08b3e --- /dev/null +++ b/app/layout.tsx @@ -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 ( + <> + + + + + {children} + {/* */} + + + + + ); +} diff --git a/app/markdown.tsx b/app/markdown.tsx new file mode 100644 index 000000000..6e6aba283 --- /dev/null +++ b/app/markdown.tsx @@ -0,0 +1,9 @@ +import { FC, memo } from "react"; +import ReactMarkdown, { Options } from "react-markdown"; + +export const MemoizedReactMarkdown: FC = memo( + ReactMarkdown, + (prevProps, nextProps) => + prevProps.children === nextProps.children && + prevProps.className === nextProps.className +); diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 000000000..e7e6f0ba6 --- /dev/null +++ b/app/page.tsx @@ -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 ( +
+ +
+ +
+
+ ); +} diff --git a/app/prompt.tsx b/app/prompt.tsx new file mode 100644 index 000000000..f8d2a8c7d --- /dev/null +++ b/app/prompt.tsx @@ -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 ( +
{ + 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" + > +
+
+
+ {onRefresh ? ( + + ) : null} + {/* */} +
+
+
+