init
This commit is contained in:
commit
a04776256d
56 changed files with 6808 additions and 0 deletions
10
.editorconfig
Normal file
10
.editorconfig
Normal 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
5
.eslintignore
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
dist/*
|
||||||
|
.cache
|
||||||
|
public
|
||||||
|
node_modules
|
||||||
|
*.esm.js
|
||||||
20
.eslintrc.json
Normal file
20
.eslintrc.json
Normal 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
37
.gitignore
vendored
Normal 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
12
.prettierignore
Normal 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
4
.vscode/settings.json
vendored
Normal 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
1
README.md
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
# Chat
|
||||||
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
9
components/theme-provider.tsx
Normal file
9
components/theme-provider.tsx
Normal 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>;
|
||||||
|
}
|
||||||
28
components/theme-toggle.tsx
Normal file
28
components/theme-toggle.tsx
Normal 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
52
components/ui/button.tsx
Normal 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
14
components/ui/login.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
97
components/ui/user-menu.tsx
Normal file
97
components/ui/user-menu.tsx
Normal 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";
|
||||||
14
components/ui/vercel-logo.tsx
Normal file
14
components/ui/vercel-logo.tsx
Normal 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
62
lib/analytics.ts
Normal 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
21
lib/fonts.ts
Normal 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",
|
||||||
|
});
|
||||||
10
lib/hooks/is-modifier-pressed.tsx
Normal file
10
lib/hooks/is-modifier-pressed.tsx
Normal 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;
|
||||||
|
};
|
||||||
21
lib/hooks/use-command-enter-submit.tsx
Normal file
21
lib/hooks/use-command-enter-submit.tsx
Normal 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 };
|
||||||
|
}
|
||||||
32
lib/hooks/use-copy-to-cliipboard.tsx
Normal file
32
lib/hooks/use-copy-to-cliipboard.tsx
Normal 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 };
|
||||||
|
}
|
||||||
43
lib/hooks/use-follow-scroll.ts
Normal file
43
lib/hooks/use-follow-scroll.ts
Normal 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]);
|
||||||
|
}
|
||||||
43
lib/hooks/use-intersection-observer.ts
Normal file
43
lib/hooks/use-intersection-observer.ts
Normal 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;
|
||||||
26
lib/hooks/use-local-storage.ts
Normal file
26
lib/hooks/use-local-storage.ts
Normal 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
16
lib/hooks/use-scroll.ts
Normal 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;
|
||||||
|
}
|
||||||
39
lib/hooks/use-window-size.ts
Normal file
39
lib/hooks/use-window-size.ts
Normal 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
62
lib/openai.ts
Normal 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
11
lib/prisma.tsx
Normal 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
209
lib/tinykeys.tsx
Normal 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
34
lib/utils.ts
Normal 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
5
next-env.d.ts
vendored
Normal 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
38
next.config.mjs
Normal 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
70
package.json
Normal 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
4681
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load diff
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
78
prisma/schema.prisma
Normal file
78
prisma/schema.prisma
Normal 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
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
1
public/next.svg
Normal file
1
public/next.svg
Normal 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
1
public/thirteen.svg
Normal 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
1
public/vercel.svg
Normal 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
19
styles/globals.css
Normal 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
36
tailwind.config.js
Normal 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
29
tsconfig.json
Normal 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
6
types/nav.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
export interface NavItem {
|
||||||
|
title: string;
|
||||||
|
href?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
external?: boolean;
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue