Move to drizzle (#6)

This commit is contained in:
Jared Palmer 2023-06-02 14:34:21 -04:00 committed by GitHub
commit 45d3a8d0ee
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
33 changed files with 1145 additions and 750 deletions

View file

@ -1,24 +1,31 @@
"use server"; "use server";
import { auth } from "@/auth"; import { auth } from "@/auth";
import { prisma } from "@/lib/prisma"; import { chats, db } from "@/lib/db/schema";
import { and, eq } from "drizzle-orm";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
export async function removeChat({ id, path }: { id: string; path: string }) { export async function removeChat({
const session = await auth();
const userId = session?.user?.email;
if (!userId || !id) {
throw new Error("Unauthorized");
}
await prisma.chat.delete({
where: {
id, id,
// TODO: Add scoping path,
// userId, userId,
}, }: {
}); id: string;
userId: string;
path: string;
}) {
// @todo next-auth doesn't work in server actions yet
// const session = await auth();
// const userId = session?.user?.email;
// if (!userId || !id) {
// throw new Error("Unauthorized");
// }
await db
.delete(chats)
.where(and(eq(chats.id, id), eq(chats.userId, userId)))
.execute();
revalidatePath("/"); revalidatePath("/");
revalidatePath("/chat/[id]"); revalidatePath("/chat/[id]");

View file

@ -1,18 +1,31 @@
import { auth } from "@/auth"; import { auth } from "@/auth";
import { Message, OpenAIStream, StreamingTextResponse } from "ai-connector"; import { chats, db } from "@/lib/db/schema";
import { openai } from "@/lib/openai"; import { OpenAIStream, StreamingTextResponse } from "ai-connector";
import { Configuration, OpenAIApi } from "openai-edge";
export const runtime = "edge";
const configuration = new Configuration({
apiKey: process.env.OPENAI_API_KEY,
});
const openai = new OpenAIApi(configuration);
if (!process.env.OPENAI_API_KEY) {
throw new Error("Missing env var from OpenAI");
}
export const POST = auth(async function POST(req: Request) { export const POST = auth(async function POST(req: Request) {
const json = await req.json(); const json = await req.json();
// @ts-ignore // @ts-ignore
console.log(req.auth); // todo fix types console.log(req.auth); // todo fix types
const messages = json.messages.map((m: any) => ({
const res = await openai.createChatCompletion({
model: "gpt-3.5-turbo",
messages: json.messages.map((m: Message) => ({
content: m.content, content: m.content,
role: m.role, role: m.role,
})), }));
const res = await openai.createChatCompletion({
model: "gpt-3.5-turbo",
messages,
temperature: 0.7, temperature: 0.7,
top_p: 1, top_p: 1,
frequency_penalty: 1, frequency_penalty: 1,
@ -21,6 +34,36 @@ export const POST = auth(async function POST(req: Request) {
stream: true, stream: true,
}); });
const stream = OpenAIStream(res); const stream = await OpenAIStream(res, {
async onCompletion(completion) {
// @ts-ignore
if (req.auth?.user?.email == null) {
return;
}
const title = json.messages[0].content.substring(0, 20);
const payload = {
id: crypto.randomUUID(),
title,
userId: (req as any).auth?.user?.email,
messages: [
...messages,
{
content: completion,
role: "assistant",
},
],
};
const chat = await db
.insert(chats)
.values(payload)
// .onConflictDoUpdate({
// target: payload.id,
// set: payload,
// })
.returning();
console.log(chat);
},
});
return new StreamingTextResponse(stream); return new StreamingTextResponse(stream);
}); });

View file

@ -1,6 +1,6 @@
"use client"; "use client";
import { type Message } from "@prisma/client"; import { type Message } from "ai-connector";
import { ChatMessage } from "./chat-message"; import { ChatMessage } from "./chat-message";
import { NextChatLogo } from "@/components/ui/nextchat-logo"; import { NextChatLogo } from "@/components/ui/nextchat-logo";

View file

@ -1,10 +1,10 @@
import { type Message } from "@prisma/client";
import CodeBlock from "@/components/ui/codeblock"; import CodeBlock from "@/components/ui/codeblock";
import { MemoizedReactMarkdown } from "@/components/markdown"; import { MemoizedReactMarkdown } from "@/components/markdown";
import remarkGfm from "remark-gfm"; import remarkGfm from "remark-gfm";
import remarkMath from "remark-math"; import remarkMath from "remark-math";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { fontMessage } from "@/lib/fonts"; import { fontMessage } from "@/lib/fonts";
import { Message } from "ai-connector";
export interface ChatMessageProps { export interface ChatMessageProps {
message: Message; message: Message;
} }

View file

@ -1,9 +1,9 @@
"use client"; "use client";
import { type Message } from "@prisma/client"; import { useRouter } from "next/navigation";
import { useChat, type Message as AIMessage } from "ai-connector";
import { ChatList } from "./chat-list";
import { Prompt } from "./prompt"; import { Prompt } from "./prompt";
import { useChat, type Message } from "ai-connector";
import { ChatList } from "./chat-list";
export interface ChatProps { export interface ChatProps {
// create?: (input: string) => Chat | undefined; // create?: (input: string) => Chat | undefined;
@ -16,24 +16,29 @@ export function Chat({
// create, // create,
initialMessages, initialMessages,
}: ChatProps) { }: ChatProps) {
const router = useRouter();
const { isLoading, messages, reload, append } = useChat({ const { isLoading, messages, reload, append } = useChat({
initialMessages: initialMessages as any[],
id, id,
initialMessages: initialMessages as AIMessage[], // onCreate: (id: string) => {
// router.push(`/chat/${id}`);
// },
}); });
return ( return (
<main className="transition-width relative min-h-full w-full flex-1 overflow-y-auto flex flex-col"> <main className="transition-width relative min-h-full w-full flex-1 overflow-y-auto flex flex-col">
<div className="flex-1"> <div className="flex-1">
<ChatList messages={messages as Message[]} /> <ChatList messages={messages} />
</div> </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"> <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 <Prompt
onSubmit={(v) => onSubmit={(value) => {
append({ append({
content: value,
role: "user", role: "user",
content: v, });
}) }}
}
onRefresh={messages.length ? reload : undefined} onRefresh={messages.length ? reload : undefined}
isLoading={isLoading} isLoading={isLoading}
/> />

View file

@ -1,10 +1,14 @@
import { Sidebar } from "@/app/sidebar"; import { Sidebar } from "@/app/sidebar";
import { prisma } from "@/lib/prisma";
import { Chat } from "../../chat";
import { type Metadata } from "next"; import { type Metadata } from "next";
import { auth } from "@/auth"; import { auth } from "@/auth";
import { db, chats } from "@/lib/db/schema";
import { eq } from "drizzle-orm";
import { Chat } from "@/app/chat";
import { Message } from "ai-connector";
export const runtime = "edge";
export const preferredRegion = "home";
export interface ChatPageProps { export interface ChatPageProps {
params: { params: {
id: string; id: string;
@ -14,30 +18,20 @@ export interface ChatPageProps {
export async function generateMetadata({ export async function generateMetadata({
params, params,
}: ChatPageProps): Promise<Metadata> { }: ChatPageProps): Promise<Metadata> {
const chat = await prisma.chat.findFirst({ const chat = await db.query.chats.findFirst({
where: { where: eq(chats.id, params.id),
id: params.id,
},
}); });
return { return {
title: chat?.title.slice(0, 50) ?? "Chat", 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) { export default async function ChatPage({ params }: ChatPageProps) {
const session = await auth(); const session = await auth();
const chat = await prisma.chat.findFirst({ const chat = await db.query.chats.findFirst({
where: { where: eq(chats.id, params.id),
id: params.id,
},
include: {
messages: true,
},
}); });
if (!chat) { if (!chat) {
throw new Error("Chat not found"); throw new Error("Chat not found");
} }
@ -46,7 +40,7 @@ export default async function ChatPage({ params }: ChatPageProps) {
<div className="relative flex h-full w-full overflow-hidden"> <div className="relative flex h-full w-full overflow-hidden">
<Sidebar session={session} /> <Sidebar session={session} />
<div className="flex h-full min-w-0 flex-1 flex-col"> <div className="flex h-full min-w-0 flex-1 flex-col">
<Chat id={chat.id} initialMessages={chat.messages} /> <Chat id={chat.id} initialMessages={chat.messages as Message[]} />
</div> </div>
</div> </div>
); );

View file

@ -2,10 +2,8 @@ import { Chat } from "./chat";
import { Sidebar } from "./sidebar"; import { Sidebar } from "./sidebar";
import { auth } from "@/auth"; import { auth } from "@/auth";
// Prisma does not support Edge without the Data Proxy currently export const runtime = "edge";
export const runtime = "nodejs"; // default
export const preferredRegion = "home"; export const preferredRegion = "home";
export const dynamic = "force-dynamic";
export default async function IndexPage() { export default async function IndexPage() {
const session = await auth(); const session = await auth();

View file

@ -11,10 +11,12 @@ export function SidebarItem({
title, title,
href, href,
id, id,
userId,
}: { }: {
title: string; title: string;
href: string; href: string;
id: string; id: string;
userId: string;
}) { }) {
const pathname = usePathname(); const pathname = usePathname();
const router = useRouter(); const router = useRouter();
@ -49,7 +51,7 @@ export function SidebarItem({
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
startTransition(() => { startTransition(() => {
removeChat({ id, path: href }).then(() => { removeChat({ id, userId, path: href }).then(() => {
router.push("/"); router.push("/");
}); });
}); });

View file

@ -1,12 +1,13 @@
import { NextChatLogo } from "@/components/ui/nextchat-logo";
import { Login } from "@/components/ui/login"; import { Login } from "@/components/ui/login";
import { NextChatLogo } from "@/components/ui/nextchat-logo";
import { UserMenu } from "@/components/ui/user-menu"; import { UserMenu } from "@/components/ui/user-menu";
import { prisma } from "@/lib/prisma"; import { db, chats } from "@/lib/db/schema";
import { type Session } from "@auth/nextjs/types";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { type Session } from "@auth/nextjs/types";
import { eq } from "drizzle-orm";
import { Plus } from "lucide-react"; import { Plus } from "lucide-react";
import Link from "next/link";
import { unstable_cache } from "next/cache"; import { unstable_cache } from "next/cache";
import Link from "next/link";
import { SidebarItem } from "./sidebar-item"; import { SidebarItem } from "./sidebar-item";
import { ExternalLink } from "./external-link"; import { ExternalLink } from "./external-link";
@ -86,27 +87,31 @@ export function Sidebar({ session, newChat }: SidebarProps) {
Sidebar.displayName = "Sidebar"; Sidebar.displayName = "Sidebar";
async function SidebarList({ session }: { session?: Session }) { async function SidebarList({ session }: { session?: Session }) {
const chats = await ( const results: any[] = await (
await unstable_cache( await unstable_cache(
() => () =>
prisma.chat.findMany({ db.query.chats.findMany({
where: { columns: {
// This is for debugging, need to add scope to the query later id: true,
// userId: session?.user.id, title: true,
},
orderBy: {
updatedAt: "desc",
}, },
where: eq(chats.userId, session?.user?.email || ""),
}), }),
// @ts-ignore // @ts-ignore
[session?.user?.id || ""], [session?.user?.email ?? ""],
{ {
revalidate: 3600, revalidate: 3600,
} }
) )
)(); )();
return chats.map((c) => ( return results.map((c) => (
<SidebarItem key={c.id} title={c.title} href={`/chat/${c.id}`} id={c.id} /> <SidebarItem
key={c.id}
title={c.title}
userId={session?.user?.email ?? ""}
href={`/chat/${c.id}`}
id={c.id}
/>
)); ));
} }

View file

@ -1,130 +0,0 @@
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;
}) {
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({
id: id || nanoid(10),
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);
}
},
[id]
);
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

@ -5,6 +5,7 @@ import { NextResponse } from "next/server";
export const { export const {
handlers: { GET, POST }, handlers: { GET, POST },
auth, auth,
CSRF_experimental,
} = NextAuth({ } = NextAuth({
// @ts-ignore // @ts-ignore
providers: [GitHub], providers: [GitHub],

View file

@ -1,11 +1,13 @@
"use client"; "use client";
import { signIn } from "@auth/nextjs/client";
import { useRouter } from "next/navigation";
export function Login() { export function Login() {
const router = useRouter();
return ( return (
<button <button
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" 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"
onClick={() => signIn("github")} onClick={() => router.push("/api/auth/signin")}
> >
<span className="font-medium">Login</span> <span className="font-medium">Login</span>
</button> </button>

View file

@ -2,15 +2,16 @@
import { ThemeToggle } from "@/components/theme-toggle"; import { ThemeToggle } from "@/components/theme-toggle";
import { type Session } from "@auth/nextjs/types"; import { type Session } from "@auth/nextjs/types";
import * as DropdownMenu from "@radix-ui/react-dropdown-menu"; import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
import { signOut } from "@auth/nextjs/client";
import Image from "next/image"; import Image from "next/image";
import { useRouter } from "next/navigation";
export interface UserMenuProps { export interface UserMenuProps {
session: Session; session: Session;
} }
export function UserMenu({ session }: UserMenuProps) { export function UserMenu({ session }: UserMenuProps) {
const router = useRouter();
return ( return (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<DropdownMenu.Root> <DropdownMenu.Root>
@ -83,7 +84,7 @@ export function UserMenu({ session }: UserMenuProps) {
</DropdownMenu.Item> </DropdownMenu.Item>
<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" 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={() => signOut()} onClick={() => router.push("/api/auth/signout")}
> >
Log Out Log Out
</DropdownMenu.Item> </DropdownMenu.Item>

6
drizzle.config.ts Normal file
View file

@ -0,0 +1,6 @@
import type { Config } from "drizzle-kit";
export default {
schema: "./lib/db/schema.ts",
out: "./lib/db/migrations",
} satisfies Config;

19
lib/db/migrate.ts Normal file
View file

@ -0,0 +1,19 @@
import { migrate } from "drizzle-orm/vercel-postgres/migrator";
import { db } from "./schema";
import * as dotenv from "dotenv";
dotenv.config();
// @ts-ignore
migrate(db, {
migrationsFolder: "lib/db/migrations",
})
.then(() => {
console.log("Migration was succesfull!");
})
.catch((err) => {
console.error("Migration failed:", err);
})
.finally(() => {
console.info("Migration finished");
});

View file

@ -0,0 +1,6 @@
CREATE TABLE IF NOT EXISTS "chats" (
"id" text PRIMARY KEY NOT NULL,
"role" text NOT NULL,
"userId" text NOT NULL,
"messages" json DEFAULT '{}'::json NOT NULL
);

View file

@ -0,0 +1 @@
ALTER TABLE "chats" DROP COLUMN IF EXISTS "messages";

View file

@ -0,0 +1 @@
ALTER TABLE "chats" ADD COLUMN "messages" json DEFAULT '{}'::json NOT NULL;

View file

@ -0,0 +1,49 @@
{
"version": "5",
"dialect": "pg",
"id": "3bddf21a-46a3-4ee8-8b41-bb79cb80d904",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"chats": {
"name": "chats",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": true
},
"userId": {
"name": "userId",
"type": "text",
"primaryKey": false,
"notNull": true
},
"messages": {
"name": "messages",
"type": "json",
"primaryKey": false,
"notNull": true,
"default": "'{}'::json"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {}
}
},
"enums": {},
"schemas": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
}
}

View file

@ -0,0 +1,42 @@
{
"version": "5",
"dialect": "pg",
"id": "e8eef642-fc25-4737-8e75-5ad1e8c53b84",
"prevId": "3bddf21a-46a3-4ee8-8b41-bb79cb80d904",
"tables": {
"chats": {
"name": "chats",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": true
},
"userId": {
"name": "userId",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {}
}
},
"enums": {},
"schemas": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
}
}

View file

@ -0,0 +1,49 @@
{
"version": "5",
"dialect": "pg",
"id": "e3f4035a-a940-4529-b037-1737e178d88b",
"prevId": "e8eef642-fc25-4737-8e75-5ad1e8c53b84",
"tables": {
"chats": {
"name": "chats",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": true
},
"userId": {
"name": "userId",
"type": "text",
"primaryKey": false,
"notNull": true
},
"messages": {
"name": "messages",
"type": "json",
"primaryKey": false,
"notNull": true,
"default": "'{}'::json"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {}
}
},
"enums": {},
"schemas": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
}
}

View file

@ -0,0 +1,27 @@
{
"version": "5",
"dialect": "pg",
"entries": [
{
"idx": 0,
"version": "5",
"when": 1685719783900,
"tag": "0000_fair_groot",
"breakpoints": false
},
{
"idx": 1,
"version": "5",
"when": 1685719963202,
"tag": "0001_naive_gunslinger",
"breakpoints": false
},
{
"idx": 2,
"version": "5",
"when": 1685720130302,
"tag": "0002_striped_purifiers",
"breakpoints": false
}
]
}

56
lib/db/schema.ts Normal file
View file

@ -0,0 +1,56 @@
import { sql } from "@vercel/postgres";
import { json, pgTable, text } from "drizzle-orm/pg-core";
import { drizzle } from "drizzle-orm/vercel-postgres";
// const connection = createPool({ connectionString: process.env.POSTGRES_URL });
export const chats = pgTable("chats", {
id: text("id").notNull().primaryKey(),
title: text("role").notNull(),
userId: text("userId").notNull(),
messages: json("messages").notNull().default({}),
// .references(() => users.id),
});
// export const messages = pgTable("messages", {
// id: text("id").notNull().primaryKey(),
// title: text("role").notNull(),
// chatId: text("chatId")
// .notNull()
// .references(() => chats.id),
// });
// export const chatsRelations = relations(chats, ({ many }) => ({
// // user: one(users, {
// // fields: [chats.userId],
// // references: [users.id],
// // }),
// messages: many(messages),
// }));
// export const messagesRelations = relations(messages, ({ one }) => ({
// chat: one(chats, {
// fields: [messages.chatId],
// references: [chats.id],
// }),
// }));
export const db = drizzle(sql, {
schema: {
// users,
// accounts,
// sessions,
// verificationTokens,
chats,
// chatsRelations,
// messages,
// messagesRelations,
// usersRelations,
},
});
export type DbClient = typeof db;
export type Schema = {
chats: typeof chats;
};

View file

@ -1,10 +0,0 @@
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

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

View file

@ -1,43 +0,0 @@
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

@ -1,62 +0,0 @@
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;
}

View file

@ -1,11 +0,0 @@
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 };

View file

@ -1,209 +0,0 @@
// 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);
};
}

View file

@ -3,9 +3,12 @@
"version": "0.0.2", "version": "0.0.2",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "prisma generate && next dev", "dev": "next dev",
"build": "prisma generate && next build", "build": "next build",
"studio": "prisma studio", "db:mg:create": "drizzle-kit generate:pg --config=drizzle.config.ts",
"db:mg:apply": "ts-node --project tsconfig.migration.json ./lib/db/migrate --config=drizzle.config.ts",
"db:check": "drizzle-kit check:pg --config=drizzle.config.ts",
"db:up": "drizzle-kit up:pg --config=drizzle.config.ts",
"start": "next start", "start": "next start",
"lint": "next lint", "lint": "next lint",
"lint:fix": "next lint --fix", "lint:fix": "next lint --fix",
@ -15,19 +18,19 @@
"format:check": "prettier --check \"**/*.{ts,tsx,mdx}\" --cache" "format:check": "prettier --check \"**/*.{ts,tsx,mdx}\" --cache"
}, },
"dependencies": { "dependencies": {
"@auth/core": "0.0.0-manual.527fff6c",
"@auth/nextjs": "0.0.0-manual.030e8328", "@auth/nextjs": "0.0.0-manual.030e8328",
"@next-auth/prisma-adapter": "1.0.6",
"@prisma/client": "^4.15.0",
"@radix-ui/react-dropdown-menu": "^2.0.4", "@radix-ui/react-dropdown-menu": "^2.0.4",
"@vercel/analytics": "^1.0.0", "@vercel/analytics": "^1.0.0",
"@vercel/kv": "^0.2.1", "@vercel/kv": "^0.2.1",
"@vercel/postgres": "^0.3.0",
"ai-connector": "^0.0.5", "ai-connector": "^0.0.5",
"body-scroll-lock": "4.0.0-beta.0", "body-scroll-lock": "4.0.0-beta.0",
"class-variance-authority": "^0.4.0", "class-variance-authority": "^0.4.0",
"clsx": "^1.2.1", "clsx": "^1.2.1",
"common-tags": "^1.8.2", "common-tags": "^1.8.2",
"cookie": "^0.5.0", "cookie": "^0.5.0",
"eventsource-parser": "^1.0.0", "drizzle-orm": "^0.26.0",
"focus-trap-react": "^10.1.1", "focus-trap-react": "^10.1.1",
"framer-motion": "^10.12.4", "framer-motion": "^10.12.4",
"jose": "^4.14.4", "jose": "^4.14.4",
@ -46,7 +49,8 @@
"remark-gfm": "^3.0.1", "remark-gfm": "^3.0.1",
"remark-math": "^5.1.1", "remark-math": "^5.1.1",
"tailwind-merge": "^1.12.0", "tailwind-merge": "^1.12.0",
"tailwindcss-animate": "^1.0.5" "tailwindcss-animate": "^1.0.5",
"uuid": "^9.0.0"
}, },
"devDependencies": { "devDependencies": {
"@ianvs/prettier-plugin-sort-imports": "^3.7.1", "@ianvs/prettier-plugin-sort-imports": "^3.7.1",
@ -61,6 +65,8 @@
"@types/react-syntax-highlighter": "^15.5.6", "@types/react-syntax-highlighter": "^15.5.6",
"@typescript-eslint/parser": "^5.58.0", "@typescript-eslint/parser": "^5.58.0",
"autoprefixer": "^10.4.13", "autoprefixer": "^10.4.13",
"dotenv": "^16.0.3",
"drizzle-kit": "^0.18.0",
"eslint": "^8.31.0", "eslint": "^8.31.0",
"eslint-config-next": "13.4.5-canary.3", "eslint-config-next": "13.4.5-canary.3",
"eslint-config-prettier": "^8.3.0", "eslint-config-prettier": "^8.3.0",
@ -68,8 +74,8 @@
"eslint-plugin-tailwindcss": "^3.8.0", "eslint-plugin-tailwindcss": "^3.8.0",
"postcss": "^8.4.21", "postcss": "^8.4.21",
"prettier": "^2.7.1", "prettier": "^2.7.1",
"prisma": "^4.15.0",
"tailwindcss": "^3.3.1", "tailwindcss": "^3.3.1",
"ts-node": "^10.9.1",
"typescript": "^4.9.3" "typescript": "^4.9.3"
}, },
"pnpm": { "pnpm": {

853
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -1,78 +0,0 @@
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])
}

11
tsconfig.migration.json Normal file
View file

@ -0,0 +1,11 @@
{
"compilerOptions": {
"module": "CommonJS",
"moduleResolution": "Node",
"target": "ES2020",
"jsx": "react",
"strictNullChecks": true,
"strictFunctionTypes": true
},
"exclude": ["node_modules", "**/node_modules/*"]
}