This commit is contained in:
Jared Palmer 2023-05-19 12:33:56 -04:00
commit a04776256d
56 changed files with 6808 additions and 0 deletions

10
.editorconfig Normal file
View file

@ -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

5
.eslintignore Normal file
View file

@ -0,0 +1,5 @@
dist/*
.cache
public
node_modules
*.esm.js

20
.eslintrc.json Normal file
View file

@ -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"
}
]
}

37
.gitignore vendored Normal file
View file

@ -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

12
.prettierignore Normal file
View file

@ -0,0 +1,12 @@
cache
.cache
package.json
package-lock.json
public
CHANGELOG.md
.yarn
dist
node_modules
.next
build
.contentlayer

4
.vscode/settings.json vendored Normal file
View file

@ -0,0 +1,4 @@
{
"typescript.tsdk": "../../node_modules/.pnpm/typescript@4.9.5/node_modules/typescript/lib",
"typescript.enablePromptUseWorkspaceTsdk": true
}

1
README.md Normal file
View file

@ -0,0 +1 @@
# Chat

25
app/actions.ts Normal file
View 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]");
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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,
};
}

View file

@ -0,0 +1,9 @@
"use client";
import * as React from "react";
import { ThemeProvider as NextThemesProvider } from "next-themes";
import { ThemeProviderProps } from "next-themes/dist/types";
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
}

View file

@ -0,0 +1,28 @@
"use client";
import { useTheme } from "next-themes";
import { Button } from "@/components/ui/button";
import { Moon, Sun } from "lucide-react";
export function ThemeToggle() {
const { setTheme, theme } = useTheme();
return (
<Button
variant="ghost"
size="xs"
className="p-0 leading-4 h-4 font-normal w-full"
onClick={() => setTheme(theme === "light" ? "dark" : "light")}
>
<span className="flex flex-row justify-between text-xs text-zinc-900 dark:text-zinc-400 w-full">
<span className="">Toggle theme</span>
{!theme ? null : theme === "dark" ? (
<Moon className="h-4 transition-all" />
) : (
<Sun className="h-4 transition-all" />
)}
</span>
</Button>
);
}

52
components/ui/button.tsx Normal file
View file

@ -0,0 +1,52 @@
import * as React from "react";
import { VariantProps, cva } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none ring-offset-background",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "underline-offset-4 hover:underline text-primary",
},
size: {
default: "h-10 py-2 px-4",
sm: "h-9 px-3 rounded-md",
lg: "h-11 px-8 rounded-md",
xs: "h-6 px-3 rounded-md",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, ...props }, ref) => {
return (
<button
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
}
);
Button.displayName = "Button";
export { Button, buttonVariants };

14
components/ui/login.tsx Normal file
View file

@ -0,0 +1,14 @@
"use client";
import Link from "next/link";
export function Login() {
return (
<Link
className="inline-flex w-full items-center justify-center rounded border border-zinc-800 bg-white h-8 px-4 -my-1.5 text-sm leading-6 tracking-tight text-zinc-900 transition-colors ease-in-out hover:bg-zinc-100 disabled:cursor-not-allowed disabled:opacity-50"
href="/api/auth/login?next=/?s=1"
>
<span className="font-medium">Login</span>
</Link>
);
}

View file

@ -0,0 +1,97 @@
"use client";
import { ThemeToggle } from "@/components/theme-toggle";
import { type Session } from "next-auth";
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
import Image from "next/image";
export interface UserMenuProps {
session: Session;
}
export function UserMenu({ session }: UserMenuProps) {
return (
<div className="flex items-center justify-between">
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<button className="focus:outline-none">
{session.user?.image ? (
<Image
width={24}
height={24}
className="h-6 w-6 rounded-full select-none ring-zinc-100/10 ring-1 hover:opacity-80 transition-opacity duration-300"
src={session.user?.image ? `${session.user.image}&s=60` : ""}
alt={session.user.name ?? "Avatar"}
/>
) : (
<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: 12 }}
>
{session.user?.name ? session.user?.name.slice(0, 2) : null}
</div>
</div>
)}
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
className="min-w-[200px] bg-white dark:bg-zinc-950 rounded-lg shadow-lg text-zinc-900 dark:text-zinc-400 overflow-hidden border border-transparent dark:border-zinc-700 focus:outline-none relative z-20 py-2"
sideOffset={8}
align="end"
>
<DropdownMenu.Item className="py-2 px-3 focus:outline-none">
<div className="text-xs font-medium">{session.user?.name}</div>
<div className="text-xs text-zinc-500">{session.user?.email}</div>
</DropdownMenu.Item>
<DropdownMenu.Separator className="my-1 h-[1px] bg-zinc-100 dark:bg-zinc-800" />
<DropdownMenu.Item className="py-2 px-3 dark:text-zinc-400 hover:bg-zinc-200 dark:hover:bg-zinc-800 transition-colors duration-200 cursor-pointer text-xs focus:outline-none">
<ThemeToggle />
</DropdownMenu.Item>
<DropdownMenu.Separator className="my-1 h-[1px] bg-zinc-100 dark:bg-zinc-800" />
<DropdownMenu.Item className="py-2 px-3 hover:bg-zinc-200 dark:hover:bg-zinc-800 transition-colors duration-200 cursor-pointer text-xs focus:outline-none ">
<a
href="https://vercel.com"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center justify-between w-full"
>
<span>Vercel Homepage</span>
<span>
<svg
fill="none"
height={16}
shapeRendering="geometricPrecision"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
viewBox="0 0 24 24"
width={16}
aria-hidden="true"
className="mr-1"
>
<path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6" />
<path d="M15 3h6v6" />
<path d="M10 14L21 3" />
</svg>
</span>
</a>
</DropdownMenu.Item>
<DropdownMenu.Item
className="py-2 px-3 hover:bg-zinc-200 dark:hover:bg-zinc-800 transition-colors duration-200 cursor-pointer text-xs focus:outline-none"
onClick={() => {
window.location.href = "/api/auth/logout";
}}
>
Log Out
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
</div>
);
}
UserMenu.displayName = "Signout";

View file

@ -0,0 +1,14 @@
import { cn } from "@/lib/utils";
export const VercelLogo = ({ className }: { className?: string }) => (
<svg
height={22}
viewBox="0 0 235 203"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-label="Vercel Logo"
className={cn(className, "dark:fill-white fill-black")}
>
<path d="M117.082 0L234.164 202.794H0L117.082 0Z" fill="currentColor" />
</svg>
);

62
lib/analytics.ts Normal file
View file

@ -0,0 +1,62 @@
import type { NextRequest, NextFetchEvent } from "next/server";
import type { NextApiRequest } from "next";
export const initAnalytics = ({
request,
event,
}: {
request: NextRequest | NextApiRequest | Request;
event?: NextFetchEvent;
}) => {
const endpoint = process.env.VERCEL_URL;
return {
track: async (eventName: string, data?: any) => {
try {
if (!endpoint && process.env.NODE_ENV === "development") {
console.log(
`[Vercel Web Analytics] Track "${eventName}"` +
(data ? ` with data ${JSON.stringify(data || {})}` : "")
);
return;
}
const headers: { [key: string]: string } = {};
Object.entries(request.headers).map(([key, value]) => {
headers[key] = value;
});
const body = {
o: headers.referer,
ts: new Date().getTime(),
r: "",
en: eventName,
ed: data,
};
const promise = fetch(
`https://${process.env.VERCEL_URL}/_vercel/insights/event`,
{
headers: {
"content-type": "application/json",
"user-agent": headers["user-agent"] as string,
"x-forwarded-for": headers["x-forwarded-for"] as string,
"x-va-server": "1",
},
body: JSON.stringify(body),
method: "POST",
}
);
if (event) {
event.waitUntil(promise);
}
{
await promise;
}
} catch (err) {
console.error(err);
}
},
};
};

21
lib/fonts.ts Normal file
View file

@ -0,0 +1,21 @@
import {
JetBrains_Mono as FontMono,
IBM_Plex_Sans as FontMessage,
Inter as FontSans,
} from "next/font/google";
export const fontSans = FontSans({
subsets: ["latin"],
variable: "--font-sans",
});
export const fontMessage = FontMessage({
subsets: ["latin"],
weight: ["400", "500", "600"],
variable: "--font-message",
});
export const fontMono = FontMono({
subsets: ["latin"],
variable: "--font-mono",
});

View file

@ -0,0 +1,10 @@
import { isAppleDevice } from "@/lib/tinykeys";
/**
* Uses the Meta key on macOS, and the Ctrl key on other platforms.
*/
export const isModifierPressed = (
event: KeyboardEvent | React.KeyboardEvent
): boolean => {
return isAppleDevice() ? event.metaKey : event.ctrlKey;
};

View file

@ -0,0 +1,21 @@
import type { RefObject } from "react";
import { useRef } from "react";
import { isModifierPressed } from "./is-modifier-pressed";
export function useCmdEnterSubmit(): {
formRef: RefObject<HTMLFormElement>;
onKeyDown: (event: React.KeyboardEvent<HTMLTextAreaElement>) => void;
} {
const formRef = useRef<HTMLFormElement>(null);
const handleKeyDown = (
event: React.KeyboardEvent<HTMLTextAreaElement>
): void => {
// Capture `⌘`|Ctrl + Enter
if (isModifierPressed(event) && event.key === "Enter") {
formRef.current?.requestSubmit();
}
};
return { formRef, onKeyDown: handleKeyDown };
}

View file

@ -0,0 +1,32 @@
'use client';
import { useState } from 'react';
export interface useCopyToClipboardProps {
timeout?: number;
}
export function useCopyToClipboard({
timeout = 2000,
}: useCopyToClipboardProps) {
const [isCopied, setIsCopied] = useState<Boolean>(false);
const copyToClipboard = (value: string) => {
if (
typeof window === 'undefined' ||
!navigator.clipboard ||
!navigator.clipboard.writeText
) {
return;
}
navigator.clipboard.writeText(value).then(() => {
setIsCopied(true);
setTimeout(() => {
setIsCopied(false);
}, timeout);
});
};
return { isCopied, copyToClipboard };
}

View file

@ -0,0 +1,43 @@
import { useEffect, useRef } from "react";
const scrollToEnd = () => {
window.scrollTo({
top: document.body.scrollHeight,
left: 0,
});
};
export function useFollowScroll(shouldFollow: boolean) {
const shouldFollowRef = useRef(shouldFollow);
const scrollRef = useRef(false);
const triggeredBySelfRef = useRef(false);
useEffect(() => {
const handleScroll = () => {
if (triggeredBySelfRef.current) {
triggeredBySelfRef.current = false;
return;
}
scrollRef.current = true;
};
window.addEventListener("scroll", handleScroll);
return () => window.removeEventListener("scroll", handleScroll);
}, []);
useEffect(() => {
if (!scrollRef.current && shouldFollowRef.current) {
setTimeout(() => {
triggeredBySelfRef.current = true;
scrollToEnd();
});
}
});
useEffect(() => {
shouldFollowRef.current = shouldFollow;
if (!shouldFollow) {
// Reset scrollRef
scrollRef.current = false;
}
}, [shouldFollow]);
}

View file

@ -0,0 +1,43 @@
import { RefObject, useEffect, useState } from "react";
interface Args extends IntersectionObserverInit {
freezeOnceVisible?: boolean;
}
function useIntersectionObserver(
elementRef: RefObject<Element>,
{
threshold = 0,
root = null,
rootMargin = "0%",
freezeOnceVisible = false,
}: Args
): IntersectionObserverEntry | undefined {
const [entry, setEntry] = useState<IntersectionObserverEntry>();
const frozen = entry?.isIntersecting && freezeOnceVisible;
const updateEntry = ([entry]: IntersectionObserverEntry[]): void => {
setEntry(entry);
};
useEffect(() => {
const node = elementRef?.current; // DOM Ref
const hasIOSupport = !!window.IntersectionObserver;
if (!hasIOSupport || frozen || !node) return;
const observerParams = { threshold, root, rootMargin };
const observer = new IntersectionObserver(updateEntry, observerParams);
observer.observe(node);
return () => observer.disconnect();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [threshold, root, rootMargin, frozen]);
return entry;
}
export default useIntersectionObserver;

View file

@ -0,0 +1,26 @@
import { useEffect, useState } from "react";
const useLocalStorage = <T>(
key: string,
initialValue: T
): [T, (value: T) => void] => {
const [storedValue, setStoredValue] = useState(initialValue);
useEffect(() => {
// Retrieve from localStorage
const item = window.localStorage.getItem(key);
if (item) {
setStoredValue(JSON.parse(item));
}
}, [key]);
const setValue = (value: T) => {
// Save state
setStoredValue(value);
// Save to localStorage
window.localStorage.setItem(key, JSON.stringify(value));
};
return [storedValue, setValue];
};
export default useLocalStorage;

16
lib/hooks/use-scroll.ts Normal file
View file

@ -0,0 +1,16 @@
import { useCallback, useEffect, useState } from "react";
export default function useScroll(threshold: number) {
const [scrolled, setScrolled] = useState(false);
const onScroll = useCallback(() => {
setScrolled(window.pageYOffset > threshold);
}, [threshold]);
useEffect(() => {
window.addEventListener("scroll", onScroll);
return () => window.removeEventListener("scroll", onScroll);
}, [onScroll]);
return scrolled;
}

View file

@ -0,0 +1,39 @@
"use client";
import { useEffect, useState } from "react";
export default function useWindowSize() {
const [windowSize, setWindowSize] = useState<{
width: number | undefined;
height: number | undefined;
}>({
width: undefined,
height: undefined,
});
useEffect(() => {
// Handler to call on window resize
function handleResize() {
// Set window width/height to state
setWindowSize({
width: window.innerWidth,
height: window.innerHeight,
});
}
// Add event listener
window.addEventListener("resize", handleResize);
// Call handler right away so state gets updated with initial window size
handleResize();
// Remove event listener on cleanup
return () => window.removeEventListener("resize", handleResize);
}, []); // Empty array ensures that effect is only run on mount
return {
windowSize,
isMobile: typeof windowSize?.width === "number" && windowSize?.width < 768,
isDesktop:
typeof windowSize?.width === "number" && windowSize?.width >= 768,
};
}

62
lib/openai.ts Normal file
View file

@ -0,0 +1,62 @@
import {
createParser,
type ParsedEvent,
type ReconnectInterval,
} from "eventsource-parser";
import { Configuration, OpenAIApi } from "openai-edge";
const configuration = new Configuration({
apiKey: process.env.OPENAI_API_KEY,
});
export const openai = new OpenAIApi(configuration);
if (!process.env.OPENAI_API_KEY) {
throw new Error("Missing env var from OpenAI");
}
export async function OpenAIStream(res: Response) {
const encoder = new TextEncoder();
const decoder = new TextDecoder();
let counter = 0;
const stream = new ReadableStream({
async start(controller) {
function onParse(event: ParsedEvent | ReconnectInterval) {
if (event.type === "event") {
const data = event.data;
if (data === "[DONE]") {
controller.close();
return;
}
try {
const json = JSON.parse(data) as {
choices: any;
};
const text =
json.choices[0]?.delta?.content ?? json.choices[0]?.text ?? "";
if (counter < 2 && (text.match(/\n/) || []).length) {
return;
}
const queue = encoder.encode(JSON.stringify(text) + "\n");
controller.enqueue(queue);
counter++;
} catch (e) {
controller.error(e);
}
}
}
const parser = createParser(onParse);
for await (const chunk of res.body as any) {
parser.feed(decoder.decode(chunk));
}
},
});
return stream;
}

11
lib/prisma.tsx Normal file
View file

@ -0,0 +1,11 @@
import { PrismaClient } from "@prisma/client";
declare global {
var prisma: PrismaClient | undefined;
}
const prisma = global.prisma || new PrismaClient();
if (process.env.NODE_ENV === "development") global.prisma = prisma;
export { prisma };

209
lib/tinykeys.tsx Normal file
View file

@ -0,0 +1,209 @@
// forked from https://github.com/jamiebuilds/tinykeys
// to fix navigator not being defined in SSR context
// import { type ModifierKey } from "react";
type ModifierKey = any;
/*
MIT License
Copyright (c) 2020 Jamie Kyle
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/
type KeyBindingPress = [string[], string];
/**
* A map of keybinding strings to event handlers.
*/
export interface KeyBindingMap {
[keybinding: string]: (event: React.KeyboardEvent) => void;
}
export interface Options {
ignoreFocus?: boolean;
}
/**
* These are the modifier keys that change the meaning of keybindings.
*
* Note: Ignoring "AltGraph" because it is covered by the others.
*/
let KEYBINDING_MODIFIER_KEYS = ["Shift", "Meta", "Alt", "Control"];
/**
* Keybinding sequences should timeout if individual key presses are more than
* 1s apart.
*/
let TIMEOUT = 1000;
/**
* When focus is on these elements, ignore the keydown event.
*/
let inputs = ["select", "textarea", "input"];
export const isAppleDevice = (): boolean => {
return /Mac|iPod|iPhone|iPad/.test(navigator.platform);
};
/**
* Parses a "Key Binding String" into its parts
*
* grammar = `<sequence>`
* <sequence> = `<press> <press> <press> ...`
* <press> = `<key>` or `<mods>+<key>`
* <mods> = `<mod>+<mod>+...`
*/
function parse(str: string): KeyBindingPress[] {
const modifierKey = (isAppleDevice() ? "Meta" : "Control") as ModifierKey;
return str
.trim()
.split(" ")
.map((press) => {
let mods = press.split("+");
let key = mods.pop()!;
mods = mods.map((mod) => (mod === "$mod" ? modifierKey : mod));
return [mods, key];
});
}
/**
* This tells us if a series of events matches a key binding sequence either
* partially or exactly.
*/
function match(event: React.KeyboardEvent, press: KeyBindingPress): boolean {
// prettier-ignore
return !(
// Allow either the `event.key` or the `event.code`
// MDN event.key: https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key
// MDN event.code: https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/code
(
press[1].toUpperCase() !== event.key.toUpperCase() &&
press[1] !== event.code
) ||
// Ensure all the modifiers in the keybinding are pressed.
press[0].find((mod) => {
return !event.getModifierState(mod as ModifierKey)
}) ||
// KEYBINDING_MODIFIER_KEYS (Shift/Control/etc) change the meaning of a
// keybinding. So if they are pressed but aren't part of this keybinding,
// then we don't have a match.
KEYBINDING_MODIFIER_KEYS.find(mod => {
return !press[0].includes(mod) && event.getModifierState(mod as ModifierKey)
})
)
}
/**
* Subscribes to keybindings.
*
* Returns an unsubscribe method.
*
* @example
* ```js
* import keybindings from "../src/keybindings"
*
* keybindings(window, {
* "Shift+d": () => {
* alert("The 'Shift' and 'd' keys were pressed at the same time")
* },
* "y e e t": () => {
* alert("The keys 'y', 'e', 'e', and 't' were pressed in order")
* },
* "$mod+d": () => {
* alert("Either 'Control+d' or 'Meta+d' were pressed")
* },
* })
* ```
*/
export default function keybindings(
target: Window | HTMLElement,
keyBindingMap: KeyBindingMap,
options: Options = {}
): () => void {
const keyBindings = Object.keys(keyBindingMap).map((key) => {
return [parse(key), keyBindingMap[key]] as const;
});
const possibleMatches = new Map<KeyBindingPress[], KeyBindingPress[]>();
let timer: any = null;
const onKeyDown = (event: React.KeyboardEvent): void => {
// Ignore modifier keydown events
// Note: This works because:
// - non-modifiers will always return false
// - if the current keypress is a modifier then it will return true when we check its state
// MDN: https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/getModifierState
if (
event.getModifierState &&
event.getModifierState(event.key as ModifierKey)
) {
return;
}
// Ignore event when a focusable item is focused
if (options.ignoreFocus) {
if (document.activeElement) {
if (
inputs.indexOf(document.activeElement.tagName.toLowerCase()) !== -1 ||
(document.activeElement as HTMLElement).contentEditable === "true"
) {
return;
}
}
}
keyBindings.forEach((keyBinding) => {
let sequence = keyBinding[0];
let callback = keyBinding[1];
let prev = possibleMatches.get(sequence);
let remainingExpectedPresses = prev ? prev : sequence;
let currentExpectedPress = remainingExpectedPresses[0];
let matches = match(event, currentExpectedPress);
if (!matches) {
possibleMatches.delete(sequence);
} else if (remainingExpectedPresses.length > 1) {
possibleMatches.set(sequence, remainingExpectedPresses.slice(1));
} else {
possibleMatches.delete(sequence);
callback(event);
}
});
clearTimeout(timer);
timer = setTimeout(possibleMatches.clear.bind(possibleMatches), TIMEOUT);
};
target.addEventListener("keydown", onKeyDown as any);
return (): void => {
target.removeEventListener("keydown", onKeyDown as any);
};
}

34
lib/utils.ts Normal file
View file

@ -0,0 +1,34 @@
import { clsx, type ClassValue } from "clsx";
import { customAlphabet } from "nanoid";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export const nanoid = customAlphabet(
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz",
7
); // 7-character random string
export async function fetcher<JSON = any>(
input: RequestInfo,
init?: RequestInit
): Promise<JSON> {
const res = await fetch(input, init);
if (!res.ok) {
const json = await res.json();
if (json.error) {
const error = new Error(json.error) as Error & {
status: number;
};
error.status = res.status;
throw error;
} else {
throw new Error("An unexpected error occurred");
}
}
return res.json();
}

5
next-env.d.ts vendored Normal file
View file

@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.

38
next.config.mjs Normal file
View file

@ -0,0 +1,38 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
experimental: {
appDir: true,
serverActions: true,
},
images: {
remotePatterns: [
{
protocol: "https",
hostname: "vercel.com",
port: "",
pathname: "/api/www/avatar/**",
},
],
},
headers: async () => {
// https://webcontainers.io/guides/quickstart#cross-origin-isolation
return [
{
source: "/(.*)",
headers: [
{
key: "Cross-Origin-Embedder-Policy",
value: "require-corp",
},
{
key: "Cross-Origin-Opener-Policy",
value: "same-origin",
},
],
},
];
},
};
export default nextConfig;

70
package.json Normal file
View file

@ -0,0 +1,70 @@
{
"name": "next-template",
"version": "0.0.2",
"private": true,
"scripts": {
"dev": "prisma generate && next dev",
"build": "prisma generate && next build",
"studio": "prisma studio",
"start": "next start",
"lint": "next lint",
"lint:fix": "next lint --fix",
"preview": "next build && next start",
"typecheck": "tsc --noEmit",
"format:write": "prettier --write \"**/*.{ts,tsx,mdx}\" --cache",
"format:check": "prettier --check \"**/*.{ts,tsx,mdx}\" --cache"
},
"dependencies": {
"@next-auth/prisma-adapter": "^1.0.6",
"@prisma/client": "^4.14.0",
"@radix-ui/react-dropdown-menu": "^2.0.4",
"@vercel/analytics": "^1.0.0",
"body-scroll-lock": "4.0.0-beta.0",
"class-variance-authority": "^0.4.0",
"clsx": "^1.2.1",
"common-tags": "^1.8.2",
"cookie": "^0.5.0",
"eventsource-parser": "^1.0.0",
"focus-trap-react": "^10.1.1",
"framer-motion": "^10.12.4",
"lucide-react": "0.216.0",
"nanoid": "^4.0.2",
"next": "13.4.3-canary.2",
"next-auth": "^4.22.1",
"next-themes": "^0.2.1",
"openai-edge": "^0.5.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hot-toast": "^2.4.1",
"react-markdown": "^8.0.7",
"react-syntax-highlighter": "^15.5.0",
"react-textarea-autosize": "^8.4.1",
"remark-gfm": "^3.0.1",
"remark-math": "^5.1.1",
"tailwind-merge": "^1.12.0",
"tailwindcss-animate": "^1.0.5"
},
"devDependencies": {
"@ianvs/prettier-plugin-sort-imports": "^3.7.1",
"@tailwindcss/typography": "^0.5.9",
"@types/common-tags": "^1.8.1",
"@types/cookie": "^0.5.1",
"@types/ms": "^0.7.31",
"@types/node": "^17.0.12",
"@types/react": "^18.0.22",
"@types/react-dom": "^18.0.7",
"@types/react-syntax-highlighter": "^15.5.6",
"@typescript-eslint/parser": "^5.58.0",
"autoprefixer": "^10.4.13",
"eslint": "^8.31.0",
"eslint-config-next": "13.4.3-canary.2",
"eslint-config-prettier": "^8.3.0",
"eslint-plugin-react": "^7.31.11",
"eslint-plugin-tailwindcss": "^3.8.0",
"postcss": "^8.4.21",
"prettier": "^2.7.1",
"prisma": "^4.14.0",
"tailwindcss": "^3.3.1",
"typescript": "^4.9.3"
}
}

4681
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load diff

6
postcss.config.js Normal file
View file

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

78
prisma/schema.prisma Normal file
View file

@ -0,0 +1,78 @@
datasource db {
provider = "postgresql"
url = env("POSTGRES_PRISMA_URL") // uses connection pooling
directUrl = env("POSTGRES_URL_NON_POOLING") // uses a direct connection
shadowDatabaseUrl = env("POSTGRES_URL_NON_POOLING") // used for migrations
}
generator client {
provider = "prisma-client-js"
previewFeatures = ["jsonProtocol"]
}
model Chat {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
title String
messages Message[]
userId String?
}
model Message {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
content String
role String
chat Chat @relation(fields: [chatId], references: [id], onDelete: Cascade)
chatId String
}
model Account {
id String @id @default(cuid())
userId String
type String
provider String
providerAccountId String
refresh_token String? @db.Text
access_token String? @db.Text
expires_at Int?
token_type String?
scope String?
id_token String? @db.Text
session_state String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
@@index([userId])
}
model Session {
id String @id @default(cuid())
sessionToken String @unique
userId String
expires DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
}
model User {
id String @id @default(cuid())
name String?
email String? @unique
emailVerified DateTime?
image String?
accounts Account[]
sessions Session[]
}
model VerificationToken {
identifier String
token String @unique
expires DateTime
@@unique([identifier, token])
}

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

1
public/next.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

1
public/thirteen.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="40" height="31" fill="none"><g opacity=".9"><path fill="url(#a)" d="M13 .4v29.3H7V6.3h-.2L0 10.5V5L7.2.4H13Z"/><path fill="url(#b)" d="M28.8 30.1c-2.2 0-4-.3-5.7-1-1.7-.8-3-1.8-4-3.1a7.7 7.7 0 0 1-1.4-4.6h6.2c0 .8.3 1.4.7 2 .4.5 1 .9 1.7 1.2.7.3 1.6.4 2.5.4 1 0 1.7-.2 2.5-.5.7-.3 1.3-.8 1.7-1.4.4-.6.6-1.2.6-2s-.2-1.5-.7-2.1c-.4-.6-1-1-1.8-1.4-.8-.4-1.8-.5-2.9-.5h-2.7v-4.6h2.7a6 6 0 0 0 2.5-.5 4 4 0 0 0 1.7-1.3c.4-.6.6-1.3.6-2a3.5 3.5 0 0 0-2-3.3 5.6 5.6 0 0 0-4.5 0 4 4 0 0 0-1.7 1.2c-.4.6-.6 1.2-.6 2h-6c0-1.7.6-3.2 1.5-4.5 1-1.3 2.2-2.3 3.8-3C25 .4 26.8 0 28.8 0s3.8.4 5.3 1.1c1.5.7 2.7 1.7 3.6 3a7.2 7.2 0 0 1 1.2 4.2c0 1.6-.5 3-1.5 4a7 7 0 0 1-4 2.2v.2c2.2.3 3.8 1 5 2.2a6.4 6.4 0 0 1 1.6 4.6c0 1.7-.5 3.1-1.4 4.4a9.7 9.7 0 0 1-4 3.1c-1.7.8-3.7 1.1-5.8 1.1Z"/></g><defs><linearGradient id="a" x1="20" x2="20" y1="0" y2="30.1" gradientUnits="userSpaceOnUse"><stop/><stop offset="1" stop-color="#3D3D3D"/></linearGradient><linearGradient id="b" x1="20" x2="20" y1="0" y2="30.1" gradientUnits="userSpaceOnUse"><stop/><stop offset="1" stop-color="#3D3D3D"/></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

1
public/vercel.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 283 64"><path fill="black" d="M141 16c-11 0-19 7-19 18s9 18 20 18c7 0 13-3 16-7l-7-5c-2 3-6 4-9 4-5 0-9-3-10-7h28v-3c0-11-8-18-19-18zm-9 15c1-4 4-7 9-7s8 3 9 7h-18zm117-15c-11 0-19 7-19 18s9 18 20 18c6 0 12-3 16-7l-8-5c-2 3-5 4-8 4-5 0-9-3-11-7h28l1-3c0-11-8-18-19-18zm-10 15c2-4 5-7 10-7s8 3 9 7h-19zm-39 3c0 6 4 10 10 10 4 0 7-2 9-5l8 5c-3 5-9 8-17 8-11 0-19-7-19-18s8-18 19-18c8 0 14 3 17 8l-8 5c-2-3-5-5-9-5-6 0-10 4-10 10zm83-29v46h-9V5h9zM37 0l37 64H0L37 0zm92 5-27 48L74 5h10l18 30 17-30h10zm59 12v10l-3-1c-6 0-10 4-10 10v15h-9V17h9v9c0-5 6-9 13-9z"/></svg>

After

Width:  |  Height:  |  Size: 629 B

19
styles/globals.css Normal file
View file

@ -0,0 +1,19 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
#__next,
#root {
height: 100%;
}
body,
html {
height: 100%;
}
}
pre:has(div.codeblock) {
padding: 0;
}

36
tailwind.config.js Normal file
View file

@ -0,0 +1,36 @@
const { fontFamily } = require("tailwindcss/defaultTheme");
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: ["class"],
content: ["app/**/*.{ts,tsx}", "components/**/*.{ts,tsx}"],
theme: {
container: {
center: true,
padding: "2rem",
screens: {
"2xl": "1400px",
},
},
extend: {
fontFamily: {
sans: ["var(--font-sans)", ...fontFamily.sans],
},
keyframes: {
"accordion-down": {
from: { height: 0 },
to: { height: "var(--radix-accordion-content-height)" },
},
"accordion-up": {
from: { height: "var(--radix-accordion-content-height)" },
to: { height: 0 },
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
},
},
},
plugins: [require("tailwindcss-animate"), require("@tailwindcss/typography")],
};

29
tsconfig.json Normal file
View file

@ -0,0 +1,29 @@
{
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"incremental": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"baseUrl": ".",
"paths": {
"@/*": ["./*"]
},
"plugins": [
{
"name": "next"
}
],
"strictNullChecks": true
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}

6
types/nav.ts Normal file
View file

@ -0,0 +1,6 @@
export interface NavItem {
title: string;
href?: string;
disabled?: boolean;
external?: boolean;
}