Initial commit: EGBE chatbot template
Stripped from vercel/chatbot (Apache 2.0): - Dropped @vercel/* packages and AI Gateway - Removed artifacts feature (code/text/sheet/image side panel) - Switched AI provider to @ai-sdk/openai-compatible -> EGBE LiteLLM - Replaced Vercel Blob upload with data URLs - Dropped Redis resumable streams and rate limiter (in-memory now) - Added Dockerfile (Next.js standalone) + entrypoint that runs migrations - Wired DATABASE_URL, EGBE_AI_API_URL/KEY, NEXT_PUBLIC_BASE_URL for app-deploy.sh
This commit is contained in:
commit
3e21c2334c
129 changed files with 21913 additions and 0 deletions
84
app/(auth)/actions.ts
Normal file
84
app/(auth)/actions.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
"use server";
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
import { createUser, getUser } from "@/lib/db/queries";
|
||||
|
||||
import { signIn } from "./auth";
|
||||
|
||||
const authFormSchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(6),
|
||||
});
|
||||
|
||||
export type LoginActionState = {
|
||||
status: "idle" | "in_progress" | "success" | "failed" | "invalid_data";
|
||||
};
|
||||
|
||||
export const login = async (
|
||||
_: LoginActionState,
|
||||
formData: FormData
|
||||
): Promise<LoginActionState> => {
|
||||
try {
|
||||
const validatedData = authFormSchema.parse({
|
||||
email: formData.get("email"),
|
||||
password: formData.get("password"),
|
||||
});
|
||||
|
||||
await signIn("credentials", {
|
||||
email: validatedData.email,
|
||||
password: validatedData.password,
|
||||
redirect: false,
|
||||
});
|
||||
|
||||
return { status: "success" };
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return { status: "invalid_data" };
|
||||
}
|
||||
|
||||
return { status: "failed" };
|
||||
}
|
||||
};
|
||||
|
||||
export type RegisterActionState = {
|
||||
status:
|
||||
| "idle"
|
||||
| "in_progress"
|
||||
| "success"
|
||||
| "failed"
|
||||
| "user_exists"
|
||||
| "invalid_data";
|
||||
};
|
||||
|
||||
export const register = async (
|
||||
_: RegisterActionState,
|
||||
formData: FormData
|
||||
): Promise<RegisterActionState> => {
|
||||
try {
|
||||
const validatedData = authFormSchema.parse({
|
||||
email: formData.get("email"),
|
||||
password: formData.get("password"),
|
||||
});
|
||||
|
||||
const [user] = await getUser(validatedData.email);
|
||||
|
||||
if (user) {
|
||||
return { status: "user_exists" } as RegisterActionState;
|
||||
}
|
||||
await createUser(validatedData.email, validatedData.password);
|
||||
await signIn("credentials", {
|
||||
email: validatedData.email,
|
||||
password: validatedData.password,
|
||||
redirect: false,
|
||||
});
|
||||
|
||||
return { status: "success" };
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return { status: "invalid_data" };
|
||||
}
|
||||
|
||||
return { status: "failed" };
|
||||
}
|
||||
};
|
||||
1
app/(auth)/api/auth/[...nextauth]/route.ts
Normal file
1
app/(auth)/api/auth/[...nextauth]/route.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { GET, POST } from "@/app/(auth)/auth";
|
||||
26
app/(auth)/api/auth/guest/route.ts
Normal file
26
app/(auth)/api/auth/guest/route.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import { getToken } from "next-auth/jwt";
|
||||
import { signIn } from "@/app/(auth)/auth";
|
||||
import { isDevelopmentEnvironment } from "@/lib/constants";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const rawRedirect = searchParams.get("redirectUrl") || "/";
|
||||
const redirectUrl =
|
||||
rawRedirect.startsWith("/") && !rawRedirect.startsWith("//")
|
||||
? rawRedirect
|
||||
: "/";
|
||||
|
||||
const token = await getToken({
|
||||
req: request,
|
||||
secret: process.env.AUTH_SECRET,
|
||||
secureCookie: !isDevelopmentEnvironment,
|
||||
});
|
||||
|
||||
if (token) {
|
||||
const base = process.env.NEXT_PUBLIC_BASE_PATH ?? "";
|
||||
return NextResponse.redirect(new URL(`${base}/`, request.url));
|
||||
}
|
||||
|
||||
return signIn("guest", { redirect: true, redirectTo: redirectUrl });
|
||||
}
|
||||
14
app/(auth)/auth.config.ts
Normal file
14
app/(auth)/auth.config.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import type { NextAuthConfig } from "next-auth";
|
||||
|
||||
const base = process.env.NEXT_PUBLIC_BASE_PATH ?? "";
|
||||
|
||||
export const authConfig = {
|
||||
basePath: "/api/auth",
|
||||
trustHost: true,
|
||||
pages: {
|
||||
signIn: `${base}/login`,
|
||||
newUser: `${base}/`,
|
||||
},
|
||||
providers: [],
|
||||
callbacks: {},
|
||||
} satisfies NextAuthConfig;
|
||||
99
app/(auth)/auth.ts
Normal file
99
app/(auth)/auth.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import { compare } from "bcrypt-ts";
|
||||
import NextAuth, { type DefaultSession } from "next-auth";
|
||||
import type { DefaultJWT } from "next-auth/jwt";
|
||||
import Credentials from "next-auth/providers/credentials";
|
||||
import { DUMMY_PASSWORD } from "@/lib/constants";
|
||||
import { createGuestUser, getUser } from "@/lib/db/queries";
|
||||
import { authConfig } from "./auth.config";
|
||||
|
||||
export type UserType = "guest" | "regular";
|
||||
|
||||
declare module "next-auth" {
|
||||
interface Session extends DefaultSession {
|
||||
user: {
|
||||
id: string;
|
||||
type: UserType;
|
||||
} & DefaultSession["user"];
|
||||
}
|
||||
|
||||
interface User {
|
||||
id?: string;
|
||||
email?: string | null;
|
||||
type: UserType;
|
||||
}
|
||||
}
|
||||
|
||||
declare module "next-auth/jwt" {
|
||||
interface JWT extends DefaultJWT {
|
||||
id: string;
|
||||
type: UserType;
|
||||
}
|
||||
}
|
||||
|
||||
export const {
|
||||
handlers: { GET, POST },
|
||||
auth,
|
||||
signIn,
|
||||
signOut,
|
||||
} = NextAuth({
|
||||
...authConfig,
|
||||
providers: [
|
||||
Credentials({
|
||||
credentials: {
|
||||
email: { label: "Email", type: "email" },
|
||||
password: { label: "Password", type: "password" },
|
||||
},
|
||||
async authorize(credentials) {
|
||||
const email = String(credentials.email ?? "");
|
||||
const password = String(credentials.password ?? "");
|
||||
const users = await getUser(email);
|
||||
|
||||
if (users.length === 0) {
|
||||
await compare(password, DUMMY_PASSWORD);
|
||||
return null;
|
||||
}
|
||||
|
||||
const [user] = users;
|
||||
|
||||
if (!user.password) {
|
||||
await compare(password, DUMMY_PASSWORD);
|
||||
return null;
|
||||
}
|
||||
|
||||
const passwordsMatch = await compare(password, user.password);
|
||||
|
||||
if (!passwordsMatch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { ...user, type: "regular" };
|
||||
},
|
||||
}),
|
||||
Credentials({
|
||||
id: "guest",
|
||||
credentials: {},
|
||||
async authorize() {
|
||||
const [guestUser] = await createGuestUser();
|
||||
return { ...guestUser, type: "guest" };
|
||||
},
|
||||
}),
|
||||
],
|
||||
callbacks: {
|
||||
jwt({ token, user }) {
|
||||
if (user) {
|
||||
token.id = user.id as string;
|
||||
token.type = user.type;
|
||||
}
|
||||
|
||||
return token;
|
||||
},
|
||||
session({ session, token }) {
|
||||
if (session.user) {
|
||||
session.user.id = token.id;
|
||||
session.user.type = token.type;
|
||||
}
|
||||
|
||||
return session;
|
||||
},
|
||||
},
|
||||
});
|
||||
43
app/(auth)/layout.tsx
Normal file
43
app/(auth)/layout.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { ArrowLeftIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { SparklesIcon, VercelIcon } from "@/components/chat/icons";
|
||||
import { Preview } from "@/components/chat/preview";
|
||||
|
||||
export default function AuthLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex h-dvh w-screen bg-sidebar">
|
||||
<div className="flex w-full flex-col bg-background p-8 xl:w-[600px] xl:shrink-0 xl:rounded-r-2xl xl:border-r xl:border-border/40 md:p-16">
|
||||
<Link
|
||||
className="flex w-fit items-center gap-1.5 text-[13px] text-muted-foreground transition-colors hover:text-foreground"
|
||||
href="/"
|
||||
>
|
||||
<ArrowLeftIcon className="size-3.5" />
|
||||
Back
|
||||
</Link>
|
||||
<div className="mx-auto flex w-full max-w-md flex-1 flex-col justify-center gap-10">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="mb-2 flex size-9 items-center justify-center rounded-lg bg-muted/60 text-muted-foreground ring-1 ring-border/50">
|
||||
<SparklesIcon size={14} />
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hidden flex-1 flex-col overflow-hidden pl-12 xl:flex">
|
||||
<div className="flex items-center gap-1.5 pt-8 text-[13px] text-muted-foreground/50">
|
||||
Powered by
|
||||
<VercelIcon size={14} />
|
||||
<span className="font-medium text-muted-foreground">AI Gateway</span>
|
||||
</div>
|
||||
<div className="flex-1 pt-4">
|
||||
<Preview />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
66
app/(auth)/login/page.tsx
Normal file
66
app/(auth)/login/page.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useActionState, useEffect, useState } from "react";
|
||||
|
||||
import { AuthForm } from "@/components/chat/auth-form";
|
||||
import { SubmitButton } from "@/components/chat/submit-button";
|
||||
import { toast } from "@/components/chat/toast";
|
||||
import { type LoginActionState, login } from "../actions";
|
||||
|
||||
export default function Page() {
|
||||
const router = useRouter();
|
||||
const [email, setEmail] = useState("");
|
||||
const [isSuccessful, setIsSuccessful] = useState(false);
|
||||
|
||||
const [state, formAction] = useActionState<LoginActionState, FormData>(
|
||||
login,
|
||||
{ status: "idle" }
|
||||
);
|
||||
|
||||
const { update: updateSession } = useSession();
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: router and updateSession are stable refs
|
||||
useEffect(() => {
|
||||
if (state.status === "failed") {
|
||||
toast({ type: "error", description: "Invalid credentials!" });
|
||||
} else if (state.status === "invalid_data") {
|
||||
toast({
|
||||
type: "error",
|
||||
description: "Failed validating your submission!",
|
||||
});
|
||||
} else if (state.status === "success") {
|
||||
setIsSuccessful(true);
|
||||
updateSession();
|
||||
router.refresh();
|
||||
}
|
||||
}, [state.status]);
|
||||
|
||||
const handleSubmit = (formData: FormData) => {
|
||||
setEmail(formData.get("email") as string);
|
||||
formAction(formData);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Welcome back</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Sign in to your account to continue
|
||||
</p>
|
||||
<AuthForm action={handleSubmit} defaultEmail={email}>
|
||||
<SubmitButton isSuccessful={isSuccessful}>Sign in</SubmitButton>
|
||||
<p className="text-center text-[13px] text-muted-foreground">
|
||||
{"No account? "}
|
||||
<Link
|
||||
className="text-foreground underline-offset-4 hover:underline"
|
||||
href="/register"
|
||||
>
|
||||
Sign up
|
||||
</Link>
|
||||
</p>
|
||||
</AuthForm>
|
||||
</>
|
||||
);
|
||||
}
|
||||
66
app/(auth)/register/page.tsx
Normal file
66
app/(auth)/register/page.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useActionState, useEffect, useState } from "react";
|
||||
import { AuthForm } from "@/components/chat/auth-form";
|
||||
import { SubmitButton } from "@/components/chat/submit-button";
|
||||
import { toast } from "@/components/chat/toast";
|
||||
import { type RegisterActionState, register } from "../actions";
|
||||
|
||||
export default function Page() {
|
||||
const router = useRouter();
|
||||
const [email, setEmail] = useState("");
|
||||
const [isSuccessful, setIsSuccessful] = useState(false);
|
||||
|
||||
const [state, formAction] = useActionState<RegisterActionState, FormData>(
|
||||
register,
|
||||
{ status: "idle" }
|
||||
);
|
||||
|
||||
const { update: updateSession } = useSession();
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: router and updateSession are stable refs
|
||||
useEffect(() => {
|
||||
if (state.status === "user_exists") {
|
||||
toast({ type: "error", description: "Account already exists!" });
|
||||
} else if (state.status === "failed") {
|
||||
toast({ type: "error", description: "Failed to create account!" });
|
||||
} else if (state.status === "invalid_data") {
|
||||
toast({
|
||||
type: "error",
|
||||
description: "Failed validating your submission!",
|
||||
});
|
||||
} else if (state.status === "success") {
|
||||
toast({ type: "success", description: "Account created!" });
|
||||
setIsSuccessful(true);
|
||||
updateSession();
|
||||
router.refresh();
|
||||
}
|
||||
}, [state.status]);
|
||||
|
||||
const handleSubmit = (formData: FormData) => {
|
||||
setEmail(formData.get("email") as string);
|
||||
formAction(formData);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Create account</h1>
|
||||
<p className="text-sm text-muted-foreground">Get started for free</p>
|
||||
<AuthForm action={handleSubmit} defaultEmail={email}>
|
||||
<SubmitButton isSuccessful={isSuccessful}>Sign up</SubmitButton>
|
||||
<p className="text-center text-[13px] text-muted-foreground">
|
||||
{"Have an account? "}
|
||||
<Link
|
||||
className="text-foreground underline-offset-4 hover:underline"
|
||||
href="/login"
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
</AuthForm>
|
||||
</>
|
||||
);
|
||||
}
|
||||
78
app/(chat)/actions.ts
Normal file
78
app/(chat)/actions.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
"use server";
|
||||
|
||||
import { generateText, type UIMessage } from "ai";
|
||||
import { cookies } from "next/headers";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import type { VisibilityType } from "@/components/chat/visibility-selector";
|
||||
import { titlePrompt } from "@/lib/ai/prompts";
|
||||
import { getTitleModel } from "@/lib/ai/providers";
|
||||
import {
|
||||
deleteMessagesByChatIdAfterTimestamp,
|
||||
getChatById,
|
||||
getMessageById,
|
||||
updateChatVisibilityById,
|
||||
} from "@/lib/db/queries";
|
||||
import { getTextFromMessage } from "@/lib/utils";
|
||||
|
||||
export async function saveChatModelAsCookie(model: string) {
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set("chat-model", model);
|
||||
}
|
||||
|
||||
export async function generateTitleFromUserMessage({
|
||||
message,
|
||||
}: {
|
||||
message: UIMessage;
|
||||
}) {
|
||||
const { text } = await generateText({
|
||||
model: getTitleModel(),
|
||||
system: titlePrompt,
|
||||
prompt: getTextFromMessage(message),
|
||||
});
|
||||
return text
|
||||
.replace(/^[#*"\s]+/, "")
|
||||
.replace(/["]+$/, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
export async function deleteTrailingMessages({ id }: { id: string }) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
|
||||
const [message] = await getMessageById({ id });
|
||||
if (!message) {
|
||||
throw new Error("Message not found");
|
||||
}
|
||||
|
||||
const chat = await getChatById({ id: message.chatId });
|
||||
if (!chat || chat.userId !== session.user.id) {
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
|
||||
await deleteMessagesByChatIdAfterTimestamp({
|
||||
chatId: message.chatId,
|
||||
timestamp: message.createdAt,
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateChatVisibility({
|
||||
chatId,
|
||||
visibility,
|
||||
}: {
|
||||
chatId: string;
|
||||
visibility: VisibilityType;
|
||||
}) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
|
||||
const chat = await getChatById({ id: chatId });
|
||||
if (!chat || chat.userId !== session.user.id) {
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
|
||||
await updateChatVisibilityById({ chatId, visibility });
|
||||
}
|
||||
3
app/(chat)/api/chat/[id]/stream/route.ts
Normal file
3
app/(chat)/api/chat/[id]/stream/route.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export function GET() {
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
272
app/(chat)/api/chat/route.ts
Normal file
272
app/(chat)/api/chat/route.ts
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
import {
|
||||
convertToModelMessages,
|
||||
createUIMessageStream,
|
||||
createUIMessageStreamResponse,
|
||||
stepCountIs,
|
||||
streamText,
|
||||
} from "ai";
|
||||
import { auth, type UserType } from "@/app/(auth)/auth";
|
||||
import { entitlementsByUserType } from "@/lib/ai/entitlements";
|
||||
import {
|
||||
allowedModelIds,
|
||||
DEFAULT_CHAT_MODEL,
|
||||
modelCapabilities,
|
||||
} from "@/lib/ai/models";
|
||||
import { type RequestHints, systemPrompt } from "@/lib/ai/prompts";
|
||||
import { getLanguageModel } from "@/lib/ai/providers";
|
||||
import { getWeather } from "@/lib/ai/tools/get-weather";
|
||||
import {
|
||||
deleteChatById,
|
||||
getChatById,
|
||||
getMessageCountByUserId,
|
||||
getMessagesByChatId,
|
||||
saveChat,
|
||||
saveMessages,
|
||||
updateChatTitleById,
|
||||
updateMessage,
|
||||
} from "@/lib/db/queries";
|
||||
import type { DBMessage } from "@/lib/db/schema";
|
||||
import { ChatbotError } from "@/lib/errors";
|
||||
import { checkIpRateLimit } from "@/lib/ratelimit";
|
||||
import type { ChatMessage } from "@/lib/types";
|
||||
import { convertToUIMessages, generateUUID } from "@/lib/utils";
|
||||
import { generateTitleFromUserMessage } from "../../actions";
|
||||
import { type PostRequestBody, postRequestBodySchema } from "./schema";
|
||||
|
||||
export const maxDuration = 60;
|
||||
|
||||
function getClientIp(request: Request): string | undefined {
|
||||
const fwd = request.headers.get("x-forwarded-for");
|
||||
if (fwd) {
|
||||
return fwd.split(",")[0]?.trim();
|
||||
}
|
||||
return request.headers.get("x-real-ip") ?? undefined;
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
let requestBody: PostRequestBody;
|
||||
|
||||
try {
|
||||
const json = await request.json();
|
||||
requestBody = postRequestBodySchema.parse(json);
|
||||
} catch (_) {
|
||||
return new ChatbotError("bad_request:api").toResponse();
|
||||
}
|
||||
|
||||
try {
|
||||
const { id, message, messages, selectedChatModel, selectedVisibilityType } =
|
||||
requestBody;
|
||||
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user) {
|
||||
return new ChatbotError("unauthorized:chat").toResponse();
|
||||
}
|
||||
|
||||
const chatModel = allowedModelIds.has(selectedChatModel)
|
||||
? selectedChatModel
|
||||
: DEFAULT_CHAT_MODEL;
|
||||
|
||||
await checkIpRateLimit(getClientIp(request));
|
||||
|
||||
const userType: UserType = session.user.type;
|
||||
|
||||
const messageCount = await getMessageCountByUserId({
|
||||
id: session.user.id,
|
||||
differenceInHours: 1,
|
||||
});
|
||||
|
||||
if (messageCount > entitlementsByUserType[userType].maxMessagesPerHour) {
|
||||
return new ChatbotError("rate_limit:chat").toResponse();
|
||||
}
|
||||
|
||||
const isToolApprovalFlow = Boolean(messages);
|
||||
|
||||
const chat = await getChatById({ id });
|
||||
let messagesFromDb: DBMessage[] = [];
|
||||
let titlePromise: Promise<string> | null = null;
|
||||
|
||||
if (chat) {
|
||||
if (chat.userId !== session.user.id) {
|
||||
return new ChatbotError("forbidden:chat").toResponse();
|
||||
}
|
||||
messagesFromDb = await getMessagesByChatId({ id });
|
||||
} else if (message?.role === "user") {
|
||||
await saveChat({
|
||||
id,
|
||||
userId: session.user.id,
|
||||
title: "New chat",
|
||||
visibility: selectedVisibilityType,
|
||||
});
|
||||
titlePromise = generateTitleFromUserMessage({ message });
|
||||
}
|
||||
|
||||
let uiMessages: ChatMessage[];
|
||||
|
||||
if (isToolApprovalFlow && messages) {
|
||||
const dbMessages = convertToUIMessages(messagesFromDb);
|
||||
const approvalStates = new Map(
|
||||
messages.flatMap(
|
||||
(m) =>
|
||||
m.parts
|
||||
?.filter(
|
||||
(p: Record<string, unknown>) =>
|
||||
p.state === "approval-responded" ||
|
||||
p.state === "output-denied"
|
||||
)
|
||||
.map((p: Record<string, unknown>) => [
|
||||
String(p.toolCallId ?? ""),
|
||||
p,
|
||||
]) ?? []
|
||||
)
|
||||
);
|
||||
uiMessages = dbMessages.map((msg) => ({
|
||||
...msg,
|
||||
parts: msg.parts.map((part) => {
|
||||
if (
|
||||
"toolCallId" in part &&
|
||||
approvalStates.has(String(part.toolCallId))
|
||||
) {
|
||||
return { ...part, ...approvalStates.get(String(part.toolCallId)) };
|
||||
}
|
||||
return part;
|
||||
}),
|
||||
})) as ChatMessage[];
|
||||
} else {
|
||||
uiMessages = [
|
||||
...convertToUIMessages(messagesFromDb),
|
||||
message as ChatMessage,
|
||||
];
|
||||
}
|
||||
|
||||
const requestHints: RequestHints = {
|
||||
city: request.headers.get("x-vercel-ip-city") ?? undefined,
|
||||
country: request.headers.get("x-vercel-ip-country") ?? undefined,
|
||||
};
|
||||
|
||||
if (message?.role === "user") {
|
||||
await saveMessages({
|
||||
messages: [
|
||||
{
|
||||
chatId: id,
|
||||
id: message.id,
|
||||
role: "user",
|
||||
parts: message.parts,
|
||||
attachments: [],
|
||||
createdAt: new Date(),
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
const capabilities = modelCapabilities[chatModel];
|
||||
const isReasoningModel = capabilities?.reasoning === true;
|
||||
const supportsTools = capabilities?.tools === true;
|
||||
|
||||
const modelMessages = await convertToModelMessages(uiMessages);
|
||||
|
||||
const stream = createUIMessageStream({
|
||||
originalMessages: isToolApprovalFlow ? uiMessages : undefined,
|
||||
execute: async ({ writer: dataStream }) => {
|
||||
const result = streamText({
|
||||
model: getLanguageModel(chatModel),
|
||||
system: systemPrompt({ requestHints, supportsTools }),
|
||||
messages: modelMessages,
|
||||
stopWhen: stepCountIs(5),
|
||||
experimental_activeTools:
|
||||
isReasoningModel && !supportsTools ? [] : ["getWeather"],
|
||||
tools: {
|
||||
getWeather,
|
||||
},
|
||||
});
|
||||
|
||||
dataStream.merge(
|
||||
result.toUIMessageStream({ sendReasoning: isReasoningModel })
|
||||
);
|
||||
|
||||
if (titlePromise) {
|
||||
try {
|
||||
const title = await titlePromise;
|
||||
dataStream.write({ type: "data-chat-title", data: title });
|
||||
updateChatTitleById({ chatId: id, title });
|
||||
} catch (_) {
|
||||
/* non-fatal */
|
||||
}
|
||||
}
|
||||
},
|
||||
generateId: generateUUID,
|
||||
onFinish: async ({ messages: finishedMessages }) => {
|
||||
if (isToolApprovalFlow) {
|
||||
for (const finishedMsg of finishedMessages) {
|
||||
const existingMsg = uiMessages.find((m) => m.id === finishedMsg.id);
|
||||
if (existingMsg) {
|
||||
await updateMessage({
|
||||
id: finishedMsg.id,
|
||||
parts: finishedMsg.parts,
|
||||
});
|
||||
} else {
|
||||
await saveMessages({
|
||||
messages: [
|
||||
{
|
||||
id: finishedMsg.id,
|
||||
role: finishedMsg.role,
|
||||
parts: finishedMsg.parts,
|
||||
createdAt: new Date(),
|
||||
attachments: [],
|
||||
chatId: id,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (finishedMessages.length > 0) {
|
||||
await saveMessages({
|
||||
messages: finishedMessages.map((currentMessage) => ({
|
||||
id: currentMessage.id,
|
||||
role: currentMessage.role,
|
||||
parts: currentMessage.parts,
|
||||
createdAt: new Date(),
|
||||
attachments: [],
|
||||
chatId: id,
|
||||
})),
|
||||
});
|
||||
}
|
||||
},
|
||||
onError: () => "Oops, an error occurred!",
|
||||
});
|
||||
|
||||
return createUIMessageStreamResponse({ stream });
|
||||
} catch (error) {
|
||||
if (error instanceof ChatbotError) {
|
||||
return error.toResponse();
|
||||
}
|
||||
|
||||
console.error("Unhandled error in chat API:", error);
|
||||
return new ChatbotError("offline:chat").toResponse();
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const id = searchParams.get("id");
|
||||
|
||||
if (!id) {
|
||||
return new ChatbotError("bad_request:api").toResponse();
|
||||
}
|
||||
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user) {
|
||||
return new ChatbotError("unauthorized:chat").toResponse();
|
||||
}
|
||||
|
||||
const chat = await getChatById({ id });
|
||||
|
||||
if (chat?.userId !== session.user.id) {
|
||||
return new ChatbotError("forbidden:chat").toResponse();
|
||||
}
|
||||
|
||||
const deletedChat = await deleteChatById({ id });
|
||||
|
||||
return Response.json(deletedChat, { status: 200 });
|
||||
}
|
||||
37
app/(chat)/api/chat/schema.ts
Normal file
37
app/(chat)/api/chat/schema.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { z } from "zod";
|
||||
|
||||
const textPartSchema = z.object({
|
||||
type: z.enum(["text"]),
|
||||
text: z.string().min(1).max(2000),
|
||||
});
|
||||
|
||||
const filePartSchema = z.object({
|
||||
type: z.enum(["file"]),
|
||||
mediaType: z.enum(["image/jpeg", "image/png", "image/webp", "image/gif"]),
|
||||
name: z.string().min(1).max(100),
|
||||
url: z.string(),
|
||||
});
|
||||
|
||||
const partSchema = z.union([textPartSchema, filePartSchema]);
|
||||
|
||||
const userMessageSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
role: z.enum(["user"]),
|
||||
parts: z.array(partSchema),
|
||||
});
|
||||
|
||||
const toolApprovalMessageSchema = z.object({
|
||||
id: z.string(),
|
||||
role: z.enum(["user", "assistant"]),
|
||||
parts: z.array(z.record(z.unknown())),
|
||||
});
|
||||
|
||||
export const postRequestBodySchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
message: userMessageSchema.optional(),
|
||||
messages: z.array(toolApprovalMessageSchema).optional(),
|
||||
selectedChatModel: z.string(),
|
||||
selectedVisibilityType: z.enum(["public", "private"]),
|
||||
});
|
||||
|
||||
export type PostRequestBody = z.infer<typeof postRequestBodySchema>;
|
||||
64
app/(chat)/api/files/upload/route.ts
Normal file
64
app/(chat)/api/files/upload/route.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
|
||||
const MAX_FILE_SIZE = 5 * 1024 * 1024;
|
||||
const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp", "image/gif"];
|
||||
|
||||
const FileSchema = z.object({
|
||||
file: z
|
||||
.instanceof(Blob)
|
||||
.refine((file) => file.size <= MAX_FILE_SIZE, {
|
||||
message: "File size should be less than 5MB",
|
||||
})
|
||||
.refine((file) => ALLOWED_TYPES.includes(file.type), {
|
||||
message: "File type should be JPEG, PNG, WebP, or GIF",
|
||||
}),
|
||||
});
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const session = await auth();
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
if (request.body === null) {
|
||||
return new Response("Request body is empty", { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const formData = await request.formData();
|
||||
const file = formData.get("file") as Blob;
|
||||
|
||||
if (!file) {
|
||||
return NextResponse.json({ error: "No file uploaded" }, { status: 400 });
|
||||
}
|
||||
|
||||
const validatedFile = FileSchema.safeParse({ file });
|
||||
|
||||
if (!validatedFile.success) {
|
||||
const errorMessage = validatedFile.error.errors
|
||||
.map((error) => error.message)
|
||||
.join(", ");
|
||||
return NextResponse.json({ error: errorMessage }, { status: 400 });
|
||||
}
|
||||
|
||||
const filename = (formData.get("file") as File).name;
|
||||
const safeName = filename.replace(/[^a-zA-Z0-9._-]/g, "_");
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
const dataUrl = `data:${file.type};base64,${buffer.toString("base64")}`;
|
||||
|
||||
return NextResponse.json({
|
||||
url: dataUrl,
|
||||
pathname: safeName,
|
||||
contentType: file.type,
|
||||
});
|
||||
} catch (_error) {
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to process request" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
49
app/(chat)/api/history/route.ts
Normal file
49
app/(chat)/api/history/route.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import type { NextRequest } from "next/server";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { deleteAllChatsByUserId, getChatsByUserId } from "@/lib/db/queries";
|
||||
import { ChatbotError } from "@/lib/errors";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = request.nextUrl;
|
||||
|
||||
const limit = Math.min(
|
||||
Math.max(Number.parseInt(searchParams.get("limit") || "10", 10), 1),
|
||||
50
|
||||
);
|
||||
const startingAfter = searchParams.get("starting_after");
|
||||
const endingBefore = searchParams.get("ending_before");
|
||||
|
||||
if (startingAfter && endingBefore) {
|
||||
return new ChatbotError(
|
||||
"bad_request:api",
|
||||
"Only one of starting_after or ending_before can be provided."
|
||||
).toResponse();
|
||||
}
|
||||
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user) {
|
||||
return new ChatbotError("unauthorized:chat").toResponse();
|
||||
}
|
||||
|
||||
const chats = await getChatsByUserId({
|
||||
id: session.user.id,
|
||||
limit,
|
||||
startingAfter,
|
||||
endingBefore,
|
||||
});
|
||||
|
||||
return Response.json(chats);
|
||||
}
|
||||
|
||||
export async function DELETE() {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user) {
|
||||
return new ChatbotError("unauthorized:chat").toResponse();
|
||||
}
|
||||
|
||||
const result = await deleteAllChatsByUserId({ userId: session.user.id });
|
||||
|
||||
return Response.json(result, { status: 200 });
|
||||
}
|
||||
43
app/(chat)/api/messages/route.ts
Normal file
43
app/(chat)/api/messages/route.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { auth } from "@/app/(auth)/auth";
|
||||
import { getChatById, getMessagesByChatId } from "@/lib/db/queries";
|
||||
import { convertToUIMessages } from "@/lib/utils";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const chatId = searchParams.get("chatId");
|
||||
|
||||
if (!chatId) {
|
||||
return Response.json({ error: "chatId required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const [session, chat, messages] = await Promise.all([
|
||||
auth(),
|
||||
getChatById({ id: chatId }),
|
||||
getMessagesByChatId({ id: chatId }),
|
||||
]);
|
||||
|
||||
if (!chat) {
|
||||
return Response.json({
|
||||
messages: [],
|
||||
visibility: "private",
|
||||
userId: null,
|
||||
isReadonly: false,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
chat.visibility === "private" &&
|
||||
(!session?.user || session.user.id !== chat.userId)
|
||||
) {
|
||||
return Response.json({ error: "forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const isReadonly = !session?.user || session.user.id !== chat.userId;
|
||||
|
||||
return Response.json({
|
||||
messages: convertToUIMessages(messages),
|
||||
visibility: chat.visibility,
|
||||
userId: chat.userId,
|
||||
isReadonly,
|
||||
});
|
||||
}
|
||||
9
app/(chat)/api/models/route.ts
Normal file
9
app/(chat)/api/models/route.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { modelCapabilities } from "@/lib/ai/models";
|
||||
|
||||
export function GET() {
|
||||
return Response.json(modelCapabilities, {
|
||||
headers: {
|
||||
"Cache-Control": "public, max-age=86400, s-maxage=86400",
|
||||
},
|
||||
});
|
||||
}
|
||||
84
app/(chat)/api/vote/route.ts
Normal file
84
app/(chat)/api/vote/route.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import { z } from "zod";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { getChatById, getVotesByChatId, voteMessage } from "@/lib/db/queries";
|
||||
import { ChatbotError } from "@/lib/errors";
|
||||
|
||||
const voteSchema = z.object({
|
||||
chatId: z.string(),
|
||||
messageId: z.string(),
|
||||
type: z.enum(["up", "down"]),
|
||||
});
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const chatId = searchParams.get("chatId");
|
||||
|
||||
if (!chatId) {
|
||||
return new ChatbotError(
|
||||
"bad_request:api",
|
||||
"Parameter chatId is required."
|
||||
).toResponse();
|
||||
}
|
||||
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user) {
|
||||
return new ChatbotError("unauthorized:vote").toResponse();
|
||||
}
|
||||
|
||||
const chat = await getChatById({ id: chatId });
|
||||
|
||||
if (!chat) {
|
||||
return new ChatbotError("not_found:chat").toResponse();
|
||||
}
|
||||
|
||||
if (chat.userId !== session.user.id) {
|
||||
return new ChatbotError("forbidden:vote").toResponse();
|
||||
}
|
||||
|
||||
const votes = await getVotesByChatId({ id: chatId });
|
||||
|
||||
return Response.json(votes, { status: 200 });
|
||||
}
|
||||
|
||||
export async function PATCH(request: Request) {
|
||||
let chatId: string;
|
||||
let messageId: string;
|
||||
let type: "up" | "down";
|
||||
|
||||
try {
|
||||
const parsed = voteSchema.parse(await request.json());
|
||||
chatId = parsed.chatId;
|
||||
messageId = parsed.messageId;
|
||||
type = parsed.type;
|
||||
} catch {
|
||||
return new ChatbotError(
|
||||
"bad_request:api",
|
||||
"Parameters chatId, messageId, and type are required."
|
||||
).toResponse();
|
||||
}
|
||||
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user) {
|
||||
return new ChatbotError("unauthorized:vote").toResponse();
|
||||
}
|
||||
|
||||
const chat = await getChatById({ id: chatId });
|
||||
|
||||
if (!chat) {
|
||||
return new ChatbotError("not_found:vote").toResponse();
|
||||
}
|
||||
|
||||
if (chat.userId !== session.user.id) {
|
||||
return new ChatbotError("forbidden:vote").toResponse();
|
||||
}
|
||||
|
||||
await voteMessage({
|
||||
chatId,
|
||||
messageId,
|
||||
type,
|
||||
});
|
||||
|
||||
return new Response("Message voted", { status: 200 });
|
||||
}
|
||||
3
app/(chat)/chat/[id]/page.tsx
Normal file
3
app/(chat)/chat/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export default function Page() {
|
||||
return null;
|
||||
}
|
||||
46
app/(chat)/layout.tsx
Normal file
46
app/(chat)/layout.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { cookies } from "next/headers";
|
||||
import { Suspense } from "react";
|
||||
import { Toaster } from "sonner";
|
||||
import { AppSidebar } from "@/components/chat/app-sidebar";
|
||||
import { DataStreamProvider } from "@/components/chat/data-stream-provider";
|
||||
import { ChatShell } from "@/components/chat/shell";
|
||||
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
|
||||
import { ActiveChatProvider } from "@/hooks/use-active-chat";
|
||||
import { auth } from "../(auth)/auth";
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<DataStreamProvider>
|
||||
<Suspense fallback={<div className="flex h-dvh bg-sidebar" />}>
|
||||
<SidebarShell>{children}</SidebarShell>
|
||||
</Suspense>
|
||||
</DataStreamProvider>
|
||||
);
|
||||
}
|
||||
|
||||
async function SidebarShell({ children }: { children: React.ReactNode }) {
|
||||
const [session, cookieStore] = await Promise.all([auth(), cookies()]);
|
||||
const isCollapsed = cookieStore.get("sidebar_state")?.value !== "true";
|
||||
|
||||
return (
|
||||
<SidebarProvider defaultOpen={!isCollapsed}>
|
||||
<AppSidebar user={session?.user} />
|
||||
<SidebarInset>
|
||||
<Toaster
|
||||
position="top-center"
|
||||
theme="system"
|
||||
toastOptions={{
|
||||
className:
|
||||
"!bg-card !text-foreground !border-border/50 !shadow-[var(--shadow-float)]",
|
||||
}}
|
||||
/>
|
||||
<Suspense fallback={<div className="flex h-dvh" />}>
|
||||
<ActiveChatProvider>
|
||||
<ChatShell />
|
||||
</ActiveChatProvider>
|
||||
</Suspense>
|
||||
{children}
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
BIN
app/(chat)/opengraph-image.png
Normal file
BIN
app/(chat)/opengraph-image.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
3
app/(chat)/page.tsx
Normal file
3
app/(chat)/page.tsx
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export default function Page() {
|
||||
return null;
|
||||
}
|
||||
BIN
app/(chat)/twitter-image.png
Normal file
BIN
app/(chat)/twitter-image.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 51 KiB |
BIN
app/favicon.ico
Normal file
BIN
app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
500
app/globals.css
Normal file
500
app/globals.css
Normal file
|
|
@ -0,0 +1,500 @@
|
|||
@import "tailwindcss";
|
||||
@import "katex/dist/katex.min.css";
|
||||
|
||||
@source "../node_modules/streamdown/dist/index.js";
|
||||
|
||||
@custom-variant dark (&:is(.dark, .dark *));
|
||||
|
||||
@plugin "tailwindcss-animate";
|
||||
@plugin "@tailwindcss/typography";
|
||||
|
||||
@theme inline {
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(0.985 0 0);
|
||||
--foreground: oklch(0.12 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.12 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.12 0 0);
|
||||
--primary: oklch(0.12 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.965 0 0);
|
||||
--secondary-foreground: oklch(0.38 0 0);
|
||||
--muted: oklch(0.94 0 0);
|
||||
--muted-foreground: oklch(0.58 0 0);
|
||||
--accent: oklch(0.965 0 0);
|
||||
--accent-foreground: oklch(0.12 0 0);
|
||||
--destructive: oklch(0.55 0.15 25);
|
||||
--border: oklch(0.9 0 0);
|
||||
--input: oklch(0.9 0 0);
|
||||
--ring: oklch(0.5 0 0);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.97 0 0);
|
||||
--sidebar-foreground: oklch(0.38 0 0);
|
||||
--sidebar-primary: oklch(0.12 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.12 0 0 / 0.06);
|
||||
--sidebar-accent-foreground: oklch(0.12 0 0);
|
||||
--sidebar-border: oklch(0.88 0 0);
|
||||
--sidebar-ring: oklch(0.5 0 0);
|
||||
|
||||
--shadow-card: 0 1px 3px oklch(0 0 0 / 0.05), 0 1px 1px oklch(0 0 0 / 0.03);
|
||||
--shadow-float:
|
||||
0 8px 24px -6px oklch(0 0 0 / 0.1), 0 2px 8px -2px oklch(0 0 0 / 0.04);
|
||||
--shadow-composer: 0 1px 2px oklch(0 0 0 / 0.04);
|
||||
--shadow-composer-focus:
|
||||
0 0 0 1px oklch(0 0 0 / 0.06), 0 2px 8px -2px oklch(0 0 0 / 0.06);
|
||||
--shadow-inset: inset 0 1px 1px oklch(0 0 0 / 0.03);
|
||||
--shadow-glow: 0 0 20px oklch(0 0 0 / 0.08);
|
||||
|
||||
--ease-spring: cubic-bezier(0.22, 1, 0.36, 1);
|
||||
--ease-bounce: cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
--ease-smooth: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.195 0 0);
|
||||
--foreground: oklch(0.94 0 0);
|
||||
--card: oklch(0.225 0 0);
|
||||
--card-foreground: oklch(0.94 0 0);
|
||||
--popover: oklch(0.225 0 0);
|
||||
--popover-foreground: oklch(0.94 0 0);
|
||||
--primary: oklch(0.94 0 0);
|
||||
--primary-foreground: oklch(0.195 0 0);
|
||||
--secondary: oklch(0.26 0 0);
|
||||
--secondary-foreground: oklch(0.75 0 0);
|
||||
--muted: oklch(0.165 0 0);
|
||||
--muted-foreground: oklch(0.6 0 0);
|
||||
--accent: oklch(0.26 0 0);
|
||||
--accent-foreground: oklch(0.94 0 0);
|
||||
--destructive: oklch(0.7 0.15 25);
|
||||
--border: oklch(0.27 0 0);
|
||||
--input: oklch(0.27 0 0);
|
||||
--ring: oklch(0.45 0 0);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.175 0 0);
|
||||
--sidebar-foreground: oklch(0.78 0 0);
|
||||
--sidebar-primary: oklch(0.94 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.195 0 0);
|
||||
--sidebar-accent: oklch(0.94 0 0 / 0.06);
|
||||
--sidebar-accent-foreground: oklch(0.94 0 0);
|
||||
--sidebar-border: oklch(0.25 0 0);
|
||||
--sidebar-ring: oklch(0.45 0 0);
|
||||
|
||||
--shadow-card:
|
||||
inset 0 1px 0 oklch(1 0 0 / 0.04), 0 1px 2px oklch(0 0 0 / 0.2),
|
||||
0 0.5px 1px oklch(0 0 0 / 0.15);
|
||||
--shadow-float:
|
||||
0 0 0 1px oklch(1 0 0 / 0.06), 0 16px 48px -6px oklch(0 0 0 / 0.35),
|
||||
0 6px 12px -2px oklch(0 0 0 / 0.2);
|
||||
--shadow-composer:
|
||||
0 1px 3px oklch(0 0 0 / 0.2), inset 0 1px 0 oklch(1 0 0 / 0.03);
|
||||
--shadow-composer-focus:
|
||||
0 0 0 1px oklch(1 0 0 / 0.1), 0 4px 16px -4px oklch(0 0 0 / 0.3),
|
||||
inset 0 1px 0 oklch(1 0 0 / 0.04);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border ring-0;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
font-feature-settings: "ss01", "ss02", "cv01";
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
*,
|
||||
::after,
|
||||
::before,
|
||||
::backdrop,
|
||||
::file-selector-button {
|
||||
border-color: var(--border);
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
body {
|
||||
overflow-x: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
html {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
letter-spacing: -0.025em;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
p {
|
||||
line-height: 1.6;
|
||||
}
|
||||
}
|
||||
|
||||
button:focus-visible,
|
||||
select:focus-visible,
|
||||
[role="button"]:focus-visible,
|
||||
input:focus-visible,
|
||||
textarea:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
@utility text-balance {
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
@utility -webkit-overflow-scrolling-touch {
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
@utility touch-pan-y {
|
||||
touch-action: pan-y;
|
||||
}
|
||||
|
||||
@utility overscroll-behavior-contain {
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
@utility no-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
:root {
|
||||
--foreground-rgb: 0, 0, 0;
|
||||
--background-start-rgb: 214, 219, 220;
|
||||
--background-end-rgb: 255, 255, 255;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--foreground-rgb: 255, 255, 255;
|
||||
--background-start-rgb: 0, 0, 0;
|
||||
--background-end-rgb: 0, 0, 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.skeleton {
|
||||
* {
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
*[class^="text-"] {
|
||||
color: transparent;
|
||||
@apply rounded-md bg-foreground/20 select-none animate-pulse;
|
||||
}
|
||||
|
||||
.skeleton-bg {
|
||||
@apply bg-foreground/10;
|
||||
}
|
||||
|
||||
.skeleton-div {
|
||||
@apply bg-foreground/20 animate-pulse;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fade-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(6px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes dot-pulse {
|
||||
0%,
|
||||
60%,
|
||||
100% {
|
||||
opacity: 0.3;
|
||||
transform: translateY(0);
|
||||
}
|
||||
30% {
|
||||
opacity: 1;
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes message-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes thinking-dot {
|
||||
0%,
|
||||
60%,
|
||||
100% {
|
||||
opacity: 0.3;
|
||||
transform: translateY(0);
|
||||
}
|
||||
30% {
|
||||
opacity: 1;
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes glow-pulse {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 oklch(0.55 0.12 250 / 0%);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 3px oklch(0.55 0.12 250 / 8%);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes subtle-lift {
|
||||
from {
|
||||
transform: translateY(0);
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
to {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow-float);
|
||||
}
|
||||
}
|
||||
|
||||
@utility fade-up {
|
||||
animation: fade-up 0.25s var(--ease-spring) both;
|
||||
}
|
||||
|
||||
@utility fade-in {
|
||||
animation: fade-in 0.2s ease both;
|
||||
}
|
||||
|
||||
@utility shimmer {
|
||||
animation: shimmer 2s linear infinite;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
oklch(1 0 0 / 0.04),
|
||||
transparent
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
}
|
||||
|
||||
@utility dot-pulse {
|
||||
animation: dot-pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@utility message-fade-in {
|
||||
animation: message-in 0.3s cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
}
|
||||
|
||||
@utility thinking-dot {
|
||||
animation: thinking-dot 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@utility composer-glow {
|
||||
animation: glow-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.ProseMirror {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.cm-editor {
|
||||
@apply bg-transparent! outline-hidden! text-[13px]! leading-[1.6]!;
|
||||
font-family:
|
||||
"SF Mono", "Cascadia Code", "Fira Code", "JetBrains Mono", ui-monospace,
|
||||
monospace !important;
|
||||
}
|
||||
|
||||
.cm-gutters {
|
||||
@apply bg-transparent! border-r-0! outline-hidden!;
|
||||
}
|
||||
|
||||
.cm-gutter.cm-lineNumbers {
|
||||
@apply min-w-[3rem] text-muted-foreground/40 text-[11px]!;
|
||||
}
|
||||
|
||||
.cm-scroller {
|
||||
@apply overflow-auto!;
|
||||
}
|
||||
|
||||
.ͼo.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground,
|
||||
.ͼo.cm-selectionBackground,
|
||||
.ͼo.cm-content::selection {
|
||||
background: oklch(0.55 0.12 250 / 0.15) !important;
|
||||
}
|
||||
|
||||
.dark
|
||||
.ͼo.cm-focused
|
||||
> .cm-scroller
|
||||
> .cm-selectionLayer
|
||||
.cm-selectionBackground,
|
||||
.dark .ͼo.cm-selectionBackground,
|
||||
.dark .ͼo.cm-content::selection {
|
||||
background: oklch(0.55 0.12 250 / 0.2) !important;
|
||||
}
|
||||
|
||||
.cm-activeLine {
|
||||
@apply bg-muted/50! rounded-sm!;
|
||||
}
|
||||
|
||||
.cm-activeLineGutter {
|
||||
@apply bg-transparent!;
|
||||
}
|
||||
|
||||
.cm-activeLineGutter .cm-gutterElement {
|
||||
color: var(--foreground) !important;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.cm-gutter.cm-lineNumbers .cm-gutterElement {
|
||||
padding-right: 12px !important;
|
||||
}
|
||||
|
||||
.cm-foldGutter {
|
||||
@apply min-w-3;
|
||||
}
|
||||
|
||||
.cm-cursor {
|
||||
border-left-color: oklch(0.55 0.12 250) !important;
|
||||
border-left-width: 2px !important;
|
||||
}
|
||||
|
||||
.cm-matchingBracket {
|
||||
background: oklch(0.55 0.12 250 / 0.12) !important;
|
||||
outline: 1px solid oklch(0.55 0.12 250 / 0.3);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.suggestion-highlight {
|
||||
@apply cursor-pointer rounded-sm bg-blue-200 transition-colors hover:bg-blue-300 dark:bg-blue-500/40 dark:text-blue-50 dark:hover:bg-blue-400/50;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: oklch(0 0 0 / 0.12) transparent;
|
||||
}
|
||||
|
||||
.dark * {
|
||||
scrollbar-color: oklch(1 0 0 / 0.1) transparent;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background: oklch(0 0 0 / 0.12);
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb:hover {
|
||||
background: oklch(0 0 0 / 0.25);
|
||||
}
|
||||
|
||||
.dark *::-webkit-scrollbar-thumb {
|
||||
background: oklch(1 0 0 / 0.1);
|
||||
}
|
||||
|
||||
.dark *::-webkit-scrollbar-thumb:hover {
|
||||
background: oklch(1 0 0 / 0.2);
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-corner {
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
[data-testid="artifact"] {
|
||||
isolation: isolate;
|
||||
}
|
||||
88
app/layout.tsx
Normal file
88
app/layout.tsx
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
|
||||
import "./globals.css";
|
||||
import { SessionProvider } from "next-auth/react";
|
||||
|
||||
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL ?? "http://localhost:3000";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL(baseUrl),
|
||||
title: "EGBE Chatbot",
|
||||
description: "Chatbot template wired to EGBE LiteLLM proxy.",
|
||||
};
|
||||
|
||||
export const viewport = {
|
||||
maximumScale: 1,
|
||||
};
|
||||
|
||||
const geist = Geist({
|
||||
subsets: ["latin"],
|
||||
display: "swap",
|
||||
variable: "--font-geist",
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
subsets: ["latin"],
|
||||
display: "swap",
|
||||
variable: "--font-geist-mono",
|
||||
});
|
||||
|
||||
const LIGHT_THEME_COLOR = "hsl(0 0% 100%)";
|
||||
const DARK_THEME_COLOR = "hsl(240deg 10% 3.92%)";
|
||||
const THEME_COLOR_SCRIPT = `\
|
||||
(function() {
|
||||
var html = document.documentElement;
|
||||
var meta = document.querySelector('meta[name="theme-color"]');
|
||||
if (!meta) {
|
||||
meta = document.createElement('meta');
|
||||
meta.setAttribute('name', 'theme-color');
|
||||
document.head.appendChild(meta);
|
||||
}
|
||||
function updateThemeColor() {
|
||||
var isDark = html.classList.contains('dark');
|
||||
meta.setAttribute('content', isDark ? '${DARK_THEME_COLOR}' : '${LIGHT_THEME_COLOR}');
|
||||
}
|
||||
var observer = new MutationObserver(updateThemeColor);
|
||||
observer.observe(html, { attributes: true, attributeFilter: ['class'] });
|
||||
updateThemeColor();
|
||||
})();`;
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html
|
||||
className={`${geist.variable} ${geistMono.variable}`}
|
||||
lang="en"
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<head>
|
||||
<script
|
||||
// biome-ignore lint/security/noDangerouslySetInnerHtml: "Required"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: THEME_COLOR_SCRIPT,
|
||||
}}
|
||||
/>
|
||||
</head>
|
||||
<body className="antialiased">
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="system"
|
||||
disableTransitionOnChange
|
||||
enableSystem
|
||||
>
|
||||
<SessionProvider
|
||||
basePath={`${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/auth`}
|
||||
>
|
||||
<TooltipProvider>{children}</TooltipProvider>
|
||||
</SessionProvider>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue