feat: v1 — persistent shell, model gateway, artifact improvements (#1462)

This commit is contained in:
dancer 2026-03-20 09:37:02 +00:00 committed by GitHub
parent 3651670fb9
commit f9652b452a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
161 changed files with 5166 additions and 8009 deletions

View file

@ -1,7 +1,10 @@
"use server";
import { z } from "zod";
import { auth } from "@/lib/auth";
import { createUser, getUser } from "@/lib/db/queries";
import { signIn } from "./auth";
const authFormSchema = z.object({
email: z.string().email(),
@ -22,11 +25,10 @@ export const login = async (
password: formData.get("password"),
});
await auth.api.signInEmail({
body: {
email: validatedData.email,
password: validatedData.password,
},
await signIn("credentials", {
email: validatedData.email,
password: validatedData.password,
redirect: false,
});
return { status: "success" };
@ -59,17 +61,17 @@ export const register = async (
password: formData.get("password"),
});
const result = await auth.api.signUpEmail({
body: {
email: validatedData.email,
password: validatedData.password,
name: validatedData.email,
},
});
const [user] = await getUser(validatedData.email);
if (!result) {
return { status: "failed" };
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) {
@ -77,11 +79,6 @@ export const register = async (
return { status: "invalid_data" };
}
const message = error instanceof Error ? error.message : "";
if (message.includes("already exists") || message.includes("UNIQUE")) {
return { status: "user_exists" };
}
return { status: "failed" };
}
};

View file

@ -0,0 +1 @@
export { GET, POST } from "@/app/(auth)/auth";

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

View file

@ -2,36 +2,30 @@
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/auth-form";
import { SubmitButton } from "@/components/submit-button";
import { toast } from "@/components/toast";
import { useSession } from "@/lib/client";
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",
}
{ status: "idle" }
);
const { refetch } = useSession();
const { update: updateSession } = useSession();
// biome-ignore lint/correctness/useExhaustiveDependencies: router and refetch are stable refs
// biome-ignore lint/correctness/useExhaustiveDependencies: router and updateSession are stable refs
useEffect(() => {
if (state.status === "failed") {
toast({
type: "error",
description: "Invalid credentials!",
});
toast({ type: "error", description: "Invalid credentials!" });
} else if (state.status === "invalid_data") {
toast({
type: "error",
@ -39,8 +33,8 @@ export default function Page() {
});
} else if (state.status === "success") {
setIsSuccessful(true);
refetch();
router.push("/");
updateSession();
router.refresh();
}
}, [state.status]);
@ -50,30 +44,23 @@ export default function Page() {
};
return (
<div className="flex h-dvh w-screen items-start justify-center bg-background pt-12 md:items-center md:pt-0">
<div className="flex w-full max-w-md flex-col gap-12 overflow-hidden rounded-2xl">
<div className="flex flex-col items-center justify-center gap-2 px-4 text-center sm:px-16">
<h3 className="font-semibold text-xl dark:text-neutral-50">
Sign In
</h3>
<p className="text-gray-500 text-sm dark:text-neutral-400">
Use your email and password to sign in
</p>
</div>
<AuthForm action={handleSubmit} defaultEmail={email}>
<SubmitButton isSuccessful={isSuccessful}>Sign in</SubmitButton>
<p className="mt-4 text-center text-gray-600 text-sm dark:text-neutral-400">
{"Don't have an account? "}
<Link
className="font-semibold text-gray-800 hover:underline dark:text-neutral-200"
href="/register"
>
Sign up
</Link>
{" for free."}
</p>
</AuthForm>
</div>
</div>
<>
<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>
</>
);
}

View file

@ -2,29 +2,26 @@
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/auth-form";
import { SubmitButton } from "@/components/submit-button";
import { toast } from "@/components/toast";
import { useSession } from "@/lib/client";
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",
}
{ status: "idle" }
);
const { refetch } = useSession();
const { update: updateSession } = useSession();
// biome-ignore lint/correctness/useExhaustiveDependencies: router and refetch are stable refs
// biome-ignore lint/correctness/useExhaustiveDependencies: router and updateSession are stable refs
useEffect(() => {
if (state.status === "user_exists") {
toast({ type: "error", description: "Account already exists!" });
@ -36,11 +33,10 @@ export default function Page() {
description: "Failed validating your submission!",
});
} else if (state.status === "success") {
toast({ type: "success", description: "Account created successfully!" });
toast({ type: "success", description: "Account created!" });
setIsSuccessful(true);
refetch();
router.push("/");
updateSession();
router.refresh();
}
}, [state.status]);
@ -50,30 +46,21 @@ export default function Page() {
};
return (
<div className="flex h-dvh w-screen items-start justify-center bg-background pt-12 md:items-center md:pt-0">
<div className="flex w-full max-w-md flex-col gap-12 overflow-hidden rounded-2xl">
<div className="flex flex-col items-center justify-center gap-2 px-4 text-center sm:px-16">
<h3 className="font-semibold text-xl dark:text-neutral-50">
Sign Up
</h3>
<p className="text-gray-500 text-sm dark:text-neutral-400">
Create an account with your email and password
</p>
</div>
<AuthForm action={handleSubmit} defaultEmail={email}>
<SubmitButton isSuccessful={isSuccessful}>Sign Up</SubmitButton>
<p className="mt-4 text-center text-gray-600 text-sm dark:text-neutral-400">
{"Already have an account? "}
<Link
className="font-semibold text-gray-800 hover:underline dark:text-neutral-200"
href="/login"
>
Sign in
</Link>
{" instead."}
</p>
</AuthForm>
</div>
</div>
<>
<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>
</>
);
}

View file

@ -2,11 +2,14 @@
import { generateText, type UIMessage } from "ai";
import { cookies } from "next/headers";
import type { VisibilityType } from "@/components/visibility-selector";
import { auth } from "@/app/(auth)/auth";
import type { VisibilityType } from "@/components/chat/visibility-selector";
import { titleModel } from "@/lib/ai/models";
import { titlePrompt } from "@/lib/ai/prompts";
import { getTitleModel } from "@/lib/ai/providers";
import {
deleteMessagesByChatIdAfterTimestamp,
getChatById,
getMessageById,
updateChatVisibilityById,
} from "@/lib/db/queries";
@ -26,6 +29,9 @@ export async function generateTitleFromUserMessage({
model: getTitleModel(),
system: titlePrompt,
prompt: getTextFromMessage(message),
providerOptions: {
gateway: { order: titleModel.gatewayOrder },
},
});
return text
.replace(/^[#*"\s]+/, "")
@ -34,7 +40,20 @@ export async function generateTitleFromUserMessage({
}
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,
@ -49,5 +68,15 @@ export async function updateChatVisibility({
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 });
}

View file

@ -1,4 +0,0 @@
import { toNextJsHandler } from "better-auth/next-js";
import { auth } from "@/lib/auth";
export const { GET, POST } = toNextJsHandler(auth);

View file

@ -10,15 +10,21 @@ import {
import { checkBotId } from "botid/server";
import { after } from "next/server";
import { createResumableStreamContext } from "resumable-stream";
import { auth, type UserType } from "@/app/(auth)/auth";
import { entitlementsByUserType } from "@/lib/ai/entitlements";
import { allowedModelIds } from "@/lib/ai/models";
import {
allowedModelIds,
chatModels,
DEFAULT_CHAT_MODEL,
getCapabilities,
} from "@/lib/ai/models";
import { type RequestHints, systemPrompt } from "@/lib/ai/prompts";
import { getLanguageModel } from "@/lib/ai/providers";
import { createDocument } from "@/lib/ai/tools/create-document";
import { editDocument } from "@/lib/ai/tools/edit-document";
import { getWeather } from "@/lib/ai/tools/get-weather";
import { requestSuggestions } from "@/lib/ai/tools/request-suggestions";
import { updateDocument } from "@/lib/ai/tools/update-document";
import { getSession, getUserType, type UserType } from "@/lib/auth";
import { isProductionEnvironment } from "@/lib/constants";
import {
createStreamId,
@ -67,20 +73,20 @@ export async function POST(request: Request) {
const [, session] = await Promise.all([
checkBotId().catch(() => null),
getSession(),
auth(),
]);
if (!session?.user) {
return new ChatbotError("unauthorized:chat").toResponse();
}
if (!allowedModelIds.has(selectedChatModel)) {
return new ChatbotError("bad_request:api").toResponse();
}
const chatModel = allowedModelIds.has(selectedChatModel)
? selectedChatModel
: DEFAULT_CHAT_MODEL;
await checkIpRateLimit(ipAddress(request));
const userType: UserType = getUserType(session.user);
const userType: UserType = session.user.type;
const messageCount = await getMessageCountByUserId({
id: session.user.id,
@ -101,9 +107,7 @@ export async function POST(request: Request) {
if (chat.userId !== session.user.id) {
return new ChatbotError("forbidden:chat").toResponse();
}
if (!isToolApprovalFlow) {
messagesFromDb = await getMessagesByChatId({ id });
}
messagesFromDb = await getMessagesByChatId({ id });
} else if (message?.role === "user") {
await saveChat({
id,
@ -114,9 +118,43 @@ export async function POST(request: Request) {
titlePromise = generateTitleFromUserMessage({ message });
}
const uiMessages = isToolApprovalFlow
? (messages as ChatMessage[])
: [...convertToUIMessages(messagesFromDb), message as ChatMessage];
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 { longitude, latitude, city, country } = geolocation(request);
@ -142,10 +180,11 @@ export async function POST(request: Request) {
});
}
const isReasoningModel =
selectedChatModel.endsWith("-thinking") ||
(selectedChatModel.includes("reasoning") &&
!selectedChatModel.includes("non-reasoning"));
const modelConfig = chatModels.find((m) => m.id === chatModel);
const modelCapabilities = await getCapabilities();
const capabilities = modelCapabilities[chatModel];
const isReasoningModel = capabilities?.reasoning === true;
const supportsTools = capabilities?.tools === true;
const modelMessages = await convertToModelMessages(uiMessages);
@ -153,30 +192,46 @@ export async function POST(request: Request) {
originalMessages: isToolApprovalFlow ? uiMessages : undefined,
execute: async ({ writer: dataStream }) => {
const result = streamText({
model: getLanguageModel(selectedChatModel),
system: systemPrompt({ selectedChatModel, requestHints }),
model: getLanguageModel(chatModel),
system: systemPrompt({ requestHints, supportsTools }),
messages: modelMessages,
stopWhen: stepCountIs(5),
experimental_activeTools: isReasoningModel
? []
: [
"getWeather",
"createDocument",
"updateDocument",
"requestSuggestions",
],
providerOptions: isReasoningModel
? {
anthropic: {
thinking: { type: "enabled", budgetTokens: 10_000 },
},
}
: undefined,
experimental_activeTools:
isReasoningModel && !supportsTools
? []
: [
"getWeather",
"createDocument",
"editDocument",
"updateDocument",
"requestSuggestions",
],
providerOptions: {
...(modelConfig?.gatewayOrder && {
gateway: { order: modelConfig.gatewayOrder },
}),
...(modelConfig?.reasoningEffort && {
openai: { reasoningEffort: modelConfig.reasoningEffort },
}),
},
tools: {
getWeather,
createDocument: createDocument({ session, dataStream }),
updateDocument: updateDocument({ session, dataStream }),
requestSuggestions: requestSuggestions({ session, dataStream }),
createDocument: createDocument({
session,
dataStream,
modelId: chatModel,
}),
editDocument: editDocument({ dataStream, session }),
updateDocument: updateDocument({
session,
dataStream,
modelId: chatModel,
}),
requestSuggestions: requestSuggestions({
session,
dataStream,
modelId: chatModel,
}),
},
experimental_telemetry: {
isEnabled: isProductionEnvironment,
@ -262,7 +317,7 @@ export async function POST(request: Request) {
);
}
} catch (_) {
// ignore redis errors
/* non-critical */
}
},
});
@ -295,7 +350,7 @@ export async function DELETE(request: Request) {
return new ChatbotError("bad_request:api").toResponse();
}
const session = await getSession();
const session = await auth();
if (!session?.user) {
return new ChatbotError("unauthorized:chat").toResponse();

View file

@ -20,18 +20,16 @@ const userMessageSchema = z.object({
parts: z.array(partSchema),
});
// For tool approval flows, we accept all messages (more permissive schema)
const messageSchema = z.object({
const toolApprovalMessageSchema = z.object({
id: z.string(),
role: z.string(),
parts: z.array(z.any()),
role: z.enum(["user", "assistant"]),
parts: z.array(z.record(z.unknown())),
});
export const postRequestBodySchema = z.object({
id: z.string().uuid(),
// Either a single new message or all messages (for tool approvals)
message: userMessageSchema.optional(),
messages: z.array(messageSchema).optional(),
messages: z.array(toolApprovalMessageSchema).optional(),
selectedChatModel: z.string(),
selectedVisibilityType: z.enum(["public", "private"]),
});

View file

@ -1,12 +1,21 @@
import type { ArtifactKind } from "@/components/artifact";
import { getSession } from "@/lib/auth";
import { z } from "zod";
import { auth } from "@/app/(auth)/auth";
import type { ArtifactKind } from "@/components/chat/artifact";
import {
deleteDocumentsByIdAfterTimestamp,
getDocumentsById,
saveDocument,
updateDocumentContent,
} from "@/lib/db/queries";
import { ChatbotError } from "@/lib/errors";
const documentSchema = z.object({
content: z.string(),
title: z.string(),
kind: z.enum(["text", "code", "image", "sheet"]),
isManualEdit: z.boolean().optional(),
});
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const id = searchParams.get("id");
@ -18,7 +27,7 @@ export async function GET(request: Request) {
).toResponse();
}
const session = await getSession();
const session = await auth();
if (!session?.user) {
return new ChatbotError("unauthorized:document").toResponse();
@ -50,18 +59,29 @@ export async function POST(request: Request) {
).toResponse();
}
const session = await getSession();
const session = await auth();
if (!session?.user) {
return new ChatbotError("not_found:document").toResponse();
}
const {
content,
title,
kind,
}: { content: string; title: string; kind: ArtifactKind } =
await request.json();
let content: string;
let title: string;
let kind: ArtifactKind;
let isManualEdit: boolean | undefined;
try {
const parsed = documentSchema.parse(await request.json());
content = parsed.content;
title = parsed.title;
kind = parsed.kind;
isManualEdit = parsed.isManualEdit;
} catch {
return new ChatbotError(
"bad_request:api",
"Invalid request body."
).toResponse();
}
const documents = await getDocumentsById({ id });
@ -73,6 +93,11 @@ export async function POST(request: Request) {
}
}
if (isManualEdit && documents.length > 0) {
const result = await updateDocumentContent({ id, content });
return Response.json(result, { status: 200 });
}
const document = await saveDocument({
id,
content,
@ -103,7 +128,7 @@ export async function DELETE(request: Request) {
).toResponse();
}
const session = await getSession();
const session = await auth();
if (!session?.user) {
return new ChatbotError("unauthorized:document").toResponse();
@ -117,9 +142,18 @@ export async function DELETE(request: Request) {
return new ChatbotError("forbidden:document").toResponse();
}
const parsedTimestamp = new Date(timestamp);
if (Number.isNaN(parsedTimestamp.getTime())) {
return new ChatbotError(
"bad_request:api",
"Invalid timestamp."
).toResponse();
}
const documentsDeleted = await deleteDocumentsByIdAfterTimestamp({
id,
timestamp: new Date(timestamp),
timestamp: parsedTimestamp,
});
return Response.json(documentsDeleted, { status: 200 });

View file

@ -2,23 +2,21 @@ import { put } from "@vercel/blob";
import { NextResponse } from "next/server";
import { z } from "zod";
import { getSession } from "@/lib/auth";
import { auth } from "@/app/(auth)/auth";
// Use Blob instead of File since File is not available in Node.js environment
const FileSchema = z.object({
file: z
.instanceof(Blob)
.refine((file) => file.size <= 5 * 1024 * 1024, {
message: "File size should be less than 5MB",
})
// Update the file type based on the kind of files you want to accept
.refine((file) => ["image/jpeg", "image/png"].includes(file.type), {
message: "File type should be JPEG or PNG",
}),
});
export async function POST(request: Request) {
const session = await getSession();
const session = await auth();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
@ -46,12 +44,12 @@ export async function POST(request: Request) {
return NextResponse.json({ error: errorMessage }, { status: 400 });
}
// Get filename from formData since Blob doesn't have name property
const filename = (formData.get("file") as File).name;
const safeName = filename.replace(/[^a-zA-Z0-9._-]/g, "_");
const fileBuffer = await file.arrayBuffer();
try {
const data = await put(`${filename}`, fileBuffer, {
const data = await put(`${safeName}`, fileBuffer, {
access: "public",
});

View file

@ -1,12 +1,15 @@
import type { NextRequest } from "next/server";
import { getSession } from "@/lib/auth";
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 = Number.parseInt(searchParams.get("limit") || "10", 10);
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");
@ -17,7 +20,7 @@ export async function GET(request: NextRequest) {
).toResponse();
}
const session = await getSession();
const session = await auth();
if (!session?.user) {
return new ChatbotError("unauthorized:chat").toResponse();
@ -34,7 +37,7 @@ export async function GET(request: NextRequest) {
}
export async function DELETE() {
const session = await getSession();
const session = await auth();
if (!session?.user) {
return new ChatbotError("unauthorized:chat").toResponse();

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

View file

@ -0,0 +1,20 @@
import { getAllGatewayModels, getCapabilities, isDemo } from "@/lib/ai/models";
export async function GET() {
const headers = {
"Cache-Control": "public, max-age=86400, s-maxage=86400",
};
const curatedCapabilities = await getCapabilities();
if (isDemo) {
const models = await getAllGatewayModels();
const capabilities = Object.fromEntries(
models.map((m) => [m.id, curatedCapabilities[m.id] ?? m.capabilities])
);
return Response.json({ capabilities, models }, { headers });
}
return Response.json(curatedCapabilities, { headers });
}

View file

@ -1,4 +1,4 @@
import { getSession } from "@/lib/auth";
import { auth } from "@/app/(auth)/auth";
import { getSuggestionsByDocumentId } from "@/lib/db/queries";
import { ChatbotError } from "@/lib/errors";
@ -13,7 +13,7 @@ export async function GET(request: Request) {
).toResponse();
}
const session = await getSession();
const session = await auth();
if (!session?.user) {
return new ChatbotError("unauthorized:suggestions").toResponse();

View file

@ -1,7 +1,14 @@
import { getSession } from "@/lib/auth";
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");
@ -13,7 +20,7 @@ export async function GET(request: Request) {
).toResponse();
}
const session = await getSession();
const session = await auth();
if (!session?.user) {
return new ChatbotError("unauthorized:vote").toResponse();
@ -35,21 +42,23 @@ export async function GET(request: Request) {
}
export async function PATCH(request: Request) {
const {
chatId,
messageId,
type,
}: { chatId: string; messageId: string; type: "up" | "down" } =
await request.json();
let chatId: string;
let messageId: string;
let type: "up" | "down";
if (!chatId || !messageId || !type) {
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 getSession();
const session = await auth();
if (!session?.user) {
return new ChatbotError("unauthorized:vote").toResponse();

View file

@ -1,81 +1,3 @@
import { cookies } from "next/headers";
import { notFound, redirect } from "next/navigation";
import { Suspense } from "react";
import { Chat } from "@/components/chat";
import { DataStreamHandler } from "@/components/data-stream-handler";
import { DEFAULT_CHAT_MODEL } from "@/lib/ai/models";
import { getSession } from "@/lib/auth";
import { getChatById, getMessagesByChatId } from "@/lib/db/queries";
import { convertToUIMessages } from "@/lib/utils";
export default function Page(props: { params: Promise<{ id: string }> }) {
return (
<Suspense fallback={<div className="flex h-dvh" />}>
<ChatPage params={props.params} />
</Suspense>
);
}
async function ChatPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const chat = await getChatById({ id });
if (!chat) {
redirect("/");
}
const session = await getSession();
if (!session) {
redirect("/login");
}
if (chat.visibility === "private") {
if (!session.user) {
return notFound();
}
if (session.user.id !== chat.userId) {
return notFound();
}
}
const messagesFromDb = await getMessagesByChatId({
id,
});
const uiMessages = convertToUIMessages(messagesFromDb);
const cookieStore = await cookies();
const chatModelFromCookie = cookieStore.get("chat-model");
if (!chatModelFromCookie) {
return (
<>
<Chat
autoResume={true}
id={chat.id}
initialChatModel={DEFAULT_CHAT_MODEL}
initialMessages={uiMessages}
initialVisibilityType={chat.visibility}
isReadonly={session?.user?.id !== chat.userId}
/>
<DataStreamHandler />
</>
);
}
return (
<>
<Chat
autoResume={true}
id={chat.id}
initialChatModel={chatModelFromCookie.value}
initialMessages={uiMessages}
initialVisibilityType={chat.visibility}
isReadonly={session?.user?.id !== chat.userId}
/>
<DataStreamHandler />
</>
);
export default function Page() {
return null;
}

View file

@ -1,35 +1,53 @@
import { cookies } from "next/headers";
import Script from "next/script";
import { Suspense } from "react";
import { AppSidebar } from "@/components/app-sidebar";
import { DataStreamProvider } from "@/components/data-stream-provider";
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 { getSession } from "@/lib/auth";
import { ActiveChatProvider } from "@/hooks/use-active-chat";
import { auth } from "../(auth)/auth";
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<>
<Script
src="https://cdn.jsdelivr.net/pyodide/v0.23.4/full/pyodide.js"
strategy="beforeInteractive"
strategy="lazyOnload"
/>
<DataStreamProvider>
<Suspense fallback={<div className="flex h-dvh" />}>
<SidebarWrapper>{children}</SidebarWrapper>
<Suspense fallback={<div className="flex h-dvh bg-sidebar" />}>
<SidebarShell>{children}</SidebarShell>
</Suspense>
</DataStreamProvider>
</>
);
}
async function SidebarWrapper({ children }: { children: React.ReactNode }) {
const [session, cookieStore] = await Promise.all([getSession(), cookies()]);
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>{children}</SidebarInset>
<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>
);
}

View file

@ -1,52 +1,3 @@
import { cookies } from "next/headers";
import { Suspense } from "react";
import { Chat } from "@/components/chat";
import { DataStreamHandler } from "@/components/data-stream-handler";
import { DEFAULT_CHAT_MODEL } from "@/lib/ai/models";
import { generateUUID } from "@/lib/utils";
export default function Page() {
return (
<Suspense fallback={<div className="flex h-dvh" />}>
<NewChatPage />
</Suspense>
);
}
async function NewChatPage() {
const cookieStore = await cookies();
const modelIdFromCookie = cookieStore.get("chat-model");
const id = generateUUID();
if (!modelIdFromCookie) {
return (
<>
<Chat
autoResume={false}
id={id}
initialChatModel={DEFAULT_CHAT_MODEL}
initialMessages={[]}
initialVisibilityType="private"
isReadonly={false}
key={id}
/>
<DataStreamHandler />
</>
);
}
return (
<>
<Chat
autoResume={false}
id={id}
initialChatModel={modelIdFromCookie.value}
initialMessages={[]}
initialVisibilityType="private"
isReadonly={false}
key={id}
/>
<DataStreamHandler />
</>
);
return null;
}

View file

@ -1,13 +1,10 @@
@import "tailwindcss";
@import "katex/dist/katex.min.css";
/* include utility classes in streamdown */
@source "../node_modules/streamdown/dist/index.js";
/* custom variant for setting dark mode programmatically */
@custom-variant dark (&:is(.dark, .dark *));
/* include plugins */
@plugin "tailwindcss-animate";
@plugin "@tailwindcss/typography";
@ -51,100 +48,154 @@
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--background: oklch(0.985 0 0);
--foreground: oklch(0.12 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--card-foreground: oklch(0.12 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(57.61% 0.2508 258.23);
--primary-foreground: oklch(1 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 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.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--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.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 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.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(57.61% 0.2508 258.23);
--primary-foreground: oklch(1 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--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.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
--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 outline-ring/50;
@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;
}
}
/*
The default border color has changed to `currentcolor` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3.
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentcolor);
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;
}
@ -161,6 +212,14 @@
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;
@ -177,17 +236,6 @@
}
}
@layer base {
body {
overflow-x: hidden;
position: relative;
}
html {
overflow-x: hidden;
}
}
.skeleton {
* {
pointer-events: none !important;
@ -207,92 +255,246 @@
}
}
@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,
.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-background! dark:bg-neutral-800! outline-hidden! selection:bg-neutral-900!;
@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 {
@apply bg-neutral-200! dark:bg-neutral-900!;
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-activeLine,
.cm-activeLineGutter {
@apply bg-transparent!;
}
.cm-activeLine {
@apply rounded-r-sm!;
.cm-activeLineGutter .cm-gutterElement {
color: var(--foreground) !important;
opacity: 0.7;
}
.cm-lineNumbers {
@apply min-w-7;
.cm-gutter.cm-lineNumbers .cm-gutterElement {
padding-right: 12px !important;
}
.cm-foldGutter {
@apply min-w-3;
}
.cm-lineNumbers .cm-activeLineGutter {
@apply rounded-l-sm!;
.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 bg-blue-200 hover:bg-blue-300 dark:hover:bg-blue-400/50 dark:text-blue-50 dark:bg-blue-500/40;
}
/* minimal scrollbar styling */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 3px;
transition: background 0.2s ease;
}
::-webkit-scrollbar-thumb:hover {
background: --alpha(var(--muted-foreground) / 0.5);
}
::-webkit-scrollbar-corner {
background: transparent;
}
/* firefox scrollbar styling */
* {
scrollbar-width: thin;
scrollbar-color: var(--border) transparent;
}
@theme inline {
--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);
@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 {
* {
@apply border-border outline-ring/50;
scrollbar-width: thin;
scrollbar-color: oklch(0 0 0 / 0.12) transparent;
}
body {
@apply bg-background text-foreground;
.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;
}

View file

@ -1,12 +1,10 @@
import { Analytics } from "@vercel/analytics/next";
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { Toaster } from "sonner";
import { ThemeProvider } from "@/components/theme-provider";
import "katex/dist/katex.min.css";
import { TooltipProvider } from "@/components/ui/tooltip";
import "./globals.css";
import { TooltipProvider } from "@/components/ui/tooltip";
import { SessionProvider } from "next-auth/react";
export const metadata: Metadata = {
metadataBase: new URL("https://chat.vercel.ai"),
@ -15,7 +13,7 @@ export const metadata: Metadata = {
};
export const viewport = {
maximumScale: 1, // Disable auto-zoom on mobile Safari
maximumScale: 1,
};
const geist = Geist({
@ -58,10 +56,6 @@ export default function RootLayout({
return (
<html
className={`${geist.variable} ${geistMono.variable}`}
// `next-themes` injects an extra classname to the body element to avoid
// visual flicker before hydration. Hence the `suppressHydrationWarning`
// prop is necessary to avoid the React hydration mismatch warning.
// https://github.com/pacocoursey/next-themes?tab=readme-ov-file#with-app
lang="en"
suppressHydrationWarning
>
@ -74,18 +68,18 @@ export default function RootLayout({
/>
</head>
<body className="antialiased">
<TooltipProvider>
<ThemeProvider
attribute="class"
defaultTheme="system"
disableTransitionOnChange
enableSystem
<ThemeProvider
attribute="class"
defaultTheme="system"
disableTransitionOnChange
enableSystem
>
<SessionProvider
basePath={`${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/auth`}
>
<Toaster position="top-center" />
{children}
</ThemeProvider>
</TooltipProvider>
<Analytics />
<TooltipProvider>{children}</TooltipProvider>
</SessionProvider>
</ThemeProvider>
</body>
</html>
);