Move to drizzle (#6)
This commit is contained in:
commit
45d3a8d0ee
33 changed files with 1145 additions and 750 deletions
|
|
@ -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();
|
id,
|
||||||
|
path,
|
||||||
|
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;
|
// const userId = session?.user?.email;
|
||||||
if (!userId || !id) {
|
// if (!userId || !id) {
|
||||||
throw new Error("Unauthorized");
|
// throw new Error("Unauthorized");
|
||||||
}
|
// }
|
||||||
|
|
||||||
await prisma.chat.delete({
|
await db
|
||||||
where: {
|
.delete(chats)
|
||||||
id,
|
.where(and(eq(chats.id, id), eq(chats.userId, userId)))
|
||||||
// TODO: Add scoping
|
.execute();
|
||||||
// userId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
revalidatePath("/");
|
revalidatePath("/");
|
||||||
revalidatePath("/chat/[id]");
|
revalidatePath("/chat/[id]");
|
||||||
|
|
|
||||||
|
|
@ -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) => ({
|
||||||
|
content: m.content,
|
||||||
|
role: m.role,
|
||||||
|
}));
|
||||||
const res = await openai.createChatCompletion({
|
const res = await openai.createChatCompletion({
|
||||||
model: "gpt-3.5-turbo",
|
model: "gpt-3.5-turbo",
|
||||||
messages: json.messages.map((m: Message) => ({
|
messages,
|
||||||
content: m.content,
|
|
||||||
role: m.role,
|
|
||||||
})),
|
|
||||||
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);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
23
app/chat.tsx
23
app/chat.tsx
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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("/");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
/>
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
1
auth.ts
1
auth.ts
|
|
@ -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],
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
6
drizzle.config.ts
Normal 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
19
lib/db/migrate.ts
Normal 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");
|
||||||
|
});
|
||||||
6
lib/db/migrations/0000_fair_groot.sql
Normal file
6
lib/db/migrations/0000_fair_groot.sql
Normal 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
|
||||||
|
);
|
||||||
1
lib/db/migrations/0001_naive_gunslinger.sql
Normal file
1
lib/db/migrations/0001_naive_gunslinger.sql
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
ALTER TABLE "chats" DROP COLUMN IF EXISTS "messages";
|
||||||
1
lib/db/migrations/0002_striped_purifiers.sql
Normal file
1
lib/db/migrations/0002_striped_purifiers.sql
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
ALTER TABLE "chats" ADD COLUMN "messages" json DEFAULT '{}'::json NOT NULL;
|
||||||
49
lib/db/migrations/meta/0000_snapshot.json
Normal file
49
lib/db/migrations/meta/0000_snapshot.json
Normal 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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
42
lib/db/migrations/meta/0001_snapshot.json
Normal file
42
lib/db/migrations/meta/0001_snapshot.json
Normal 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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
49
lib/db/migrations/meta/0002_snapshot.json
Normal file
49
lib/db/migrations/meta/0002_snapshot.json
Normal 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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
27
lib/db/migrations/meta/_journal.json
Normal file
27
lib/db/migrations/meta/_journal.json
Normal 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
56
lib/db/schema.ts
Normal 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;
|
||||||
|
};
|
||||||
|
|
@ -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;
|
|
||||||
};
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -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 };
|
|
||||||
209
lib/tinykeys.tsx
209
lib/tinykeys.tsx
|
|
@ -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);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
22
package.json
22
package.json
|
|
@ -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
853
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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
11
tsconfig.migration.json
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "CommonJS",
|
||||||
|
"moduleResolution": "Node",
|
||||||
|
"target": "ES2020",
|
||||||
|
"jsx": "react",
|
||||||
|
"strictNullChecks": true,
|
||||||
|
"strictFunctionTypes": true
|
||||||
|
},
|
||||||
|
"exclude": ["node_modules", "**/node_modules/*"]
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue