diff --git a/.env.example b/.env.example index 50c7f12f7f..8433714cc5 100644 --- a/.env.example +++ b/.env.example @@ -1,24 +1,15 @@ -# Generate a random secret: https://generate-secret.vercel.app/32 or `openssl rand -base64 32` -BETTER_AUTH_SECRET=**** -BETTER_AUTH_URL=**** +# generate a random secret: https://generate-secret.vercel.app/32 or `openssl rand -base64 32` +AUTH_SECRET=**** -# The following keys below are automatically created and -# added to your environment when you deploy on Vercel - -# Instructions to create an AI Gateway API key here: https://vercel.com/ai-gateway -# API key required for non-Vercel deployments -# For Vercel deployments, OIDC tokens are used automatically +# required for non-vercel deployments, vercel uses OIDC automatically # https://vercel.com/ai-gateway AI_GATEWAY_API_KEY=**** - -# Instructions to create a Vercel Blob Store here: https://vercel.com/docs/vercel-blob +# https://vercel.com/docs/vercel-blob BLOB_READ_WRITE_TOKEN=**** -# Instructions to create a PostgreSQL database here: https://vercel.com/docs/postgres +# https://vercel.com/docs/postgres POSTGRES_URL=**** - -# Instructions to create a Redis store here: # https://vercel.com/docs/redis REDIS_URL=**** diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 809a353d6b..8e872793b3 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -13,7 +13,7 @@ jobs: - name: Install pnpm uses: pnpm/action-setup@v4 with: - version: 9.12.3 + version: 10.32.1 - name: Use Node.js ${{ matrix.node-version }} uses: actions/setup-node@v4 with: diff --git a/.gitignore b/.gitignore index 05bfb89a1e..344b787948 100644 --- a/.gitignore +++ b/.gitignore @@ -1,46 +1,27 @@ -# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. - -# dependencies node_modules .pnp .pnp.js - -# testing coverage - -# next.js .next/ out/ build - -# misc .DS_Store *.pem - -# debug npm-debug.log* yarn-debug.log* yarn-error.log* .pnpm-debug.log* - - -# local env files .env.local .env.development.local .env.test.local .env.production.local - -# turbo .turbo - .env .vercel .env*.local - -# Playwright /test-results/ /playwright-report/ /blob-report/ /playwright/* - -next-env.d.ts \ No newline at end of file +next-env.d.ts +tsconfig.tsbuildinfo diff --git a/README.md b/README.md index 0c54ca8e7a..d493dc430f 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ ## Model Providers -This template uses the [Vercel AI Gateway](https://vercel.com/docs/ai-gateway) to access multiple AI models through a unified interface. The default model is [OpenAI](https://openai.com) GPT-4.1 Mini, with support for Anthropic, Google, and xAI models. +This template uses the [Vercel AI Gateway](https://vercel.com/docs/ai-gateway) to access multiple AI models through a unified interface. Models are configured in `lib/ai/models.ts` with per-model provider routing. Included models: Mistral, Moonshot, DeepSeek, OpenAI, and xAI. ### AI Gateway Authentication diff --git a/app/(auth)/actions.ts b/app/(auth)/actions.ts index fb817ad50f..024ff518ed 100644 --- a/app/(auth)/actions.ts +++ b/app/(auth)/actions.ts @@ -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" }; } }; diff --git a/app/(auth)/api/auth/[...nextauth]/route.ts b/app/(auth)/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000000..d104b65e6d --- /dev/null +++ b/app/(auth)/api/auth/[...nextauth]/route.ts @@ -0,0 +1 @@ +export { GET, POST } from "@/app/(auth)/auth"; diff --git a/app/(auth)/api/auth/guest/route.ts b/app/(auth)/api/auth/guest/route.ts new file mode 100644 index 0000000000..97ce3d2c14 --- /dev/null +++ b/app/(auth)/api/auth/guest/route.ts @@ -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 }); +} diff --git a/app/(auth)/auth.config.ts b/app/(auth)/auth.config.ts new file mode 100644 index 0000000000..50561014a0 --- /dev/null +++ b/app/(auth)/auth.config.ts @@ -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; diff --git a/app/(auth)/auth.ts b/app/(auth)/auth.ts new file mode 100644 index 0000000000..b75369fe3d --- /dev/null +++ b/app/(auth)/auth.ts @@ -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; + }, + }, +}); diff --git a/app/(auth)/layout.tsx b/app/(auth)/layout.tsx new file mode 100644 index 0000000000..d14e92cedc --- /dev/null +++ b/app/(auth)/layout.tsx @@ -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 ( +
+
+ + + Back + +
+
+
+ +
+ {children} +
+
+
+ +
+
+ Powered by + + AI Gateway +
+
+ +
+
+
+ ); +} diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx index d2445e2611..ea4c602eeb 100644 --- a/app/(auth)/login/page.tsx +++ b/app/(auth)/login/page.tsx @@ -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( 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 ( -
-
-
-

- Sign In -

-

- Use your email and password to sign in -

-
- - Sign in -

- {"Don't have an account? "} - - Sign up - - {" for free."} -

-
-
-
+ <> +

Welcome back

+

+ Sign in to your account to continue +

+ + Sign in +

+ {"No account? "} + + Sign up + +

+
+ ); } diff --git a/app/(auth)/register/page.tsx b/app/(auth)/register/page.tsx index 46c73e9f13..f2abbc8620 100644 --- a/app/(auth)/register/page.tsx +++ b/app/(auth)/register/page.tsx @@ -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( 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 ( -
-
-
-

- Sign Up -

-

- Create an account with your email and password -

-
- - Sign Up -

- {"Already have an account? "} - - Sign in - - {" instead."} -

-
-
-
+ <> +

Create account

+

Get started for free

+ + Sign up +

+ {"Have an account? "} + + Sign in + +

+
+ ); } diff --git a/app/(chat)/actions.ts b/app/(chat)/actions.ts index 7846b806bc..2955a53fbc 100644 --- a/app/(chat)/actions.ts +++ b/app/(chat)/actions.ts @@ -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 }); } diff --git a/app/(chat)/api/auth/[...all]/route.ts b/app/(chat)/api/auth/[...all]/route.ts deleted file mode 100644 index 83ab371a71..0000000000 --- a/app/(chat)/api/auth/[...all]/route.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { toNextJsHandler } from "better-auth/next-js"; -import { auth } from "@/lib/auth"; - -export const { GET, POST } = toNextJsHandler(auth); diff --git a/app/(chat)/api/chat/route.ts b/app/(chat)/api/chat/route.ts index c91a8c32b6..ac52197803 100644 --- a/app/(chat)/api/chat/route.ts +++ b/app/(chat)/api/chat/route.ts @@ -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) => + p.state === "approval-responded" || + p.state === "output-denied" + ) + .map((p: Record) => [ + 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(); diff --git a/app/(chat)/api/chat/schema.ts b/app/(chat)/api/chat/schema.ts index 60a708acda..35f785b846 100644 --- a/app/(chat)/api/chat/schema.ts +++ b/app/(chat)/api/chat/schema.ts @@ -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"]), }); diff --git a/app/(chat)/api/document/route.ts b/app/(chat)/api/document/route.ts index 36a550f03d..ee66e3057f 100644 --- a/app/(chat)/api/document/route.ts +++ b/app/(chat)/api/document/route.ts @@ -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 }); diff --git a/app/(chat)/api/files/upload/route.ts b/app/(chat)/api/files/upload/route.ts index b81566d5b2..b2270331e3 100644 --- a/app/(chat)/api/files/upload/route.ts +++ b/app/(chat)/api/files/upload/route.ts @@ -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", }); diff --git a/app/(chat)/api/history/route.ts b/app/(chat)/api/history/route.ts index 4e80080b39..064a385473 100644 --- a/app/(chat)/api/history/route.ts +++ b/app/(chat)/api/history/route.ts @@ -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(); diff --git a/app/(chat)/api/messages/route.ts b/app/(chat)/api/messages/route.ts new file mode 100644 index 0000000000..cda98dedb2 --- /dev/null +++ b/app/(chat)/api/messages/route.ts @@ -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, + }); +} diff --git a/app/(chat)/api/models/route.ts b/app/(chat)/api/models/route.ts new file mode 100644 index 0000000000..de1d12a822 --- /dev/null +++ b/app/(chat)/api/models/route.ts @@ -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 }); +} diff --git a/app/(chat)/api/suggestions/route.ts b/app/(chat)/api/suggestions/route.ts index babfb9d58d..303f45ed26 100644 --- a/app/(chat)/api/suggestions/route.ts +++ b/app/(chat)/api/suggestions/route.ts @@ -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(); diff --git a/app/(chat)/api/vote/route.ts b/app/(chat)/api/vote/route.ts index 4f69e2cad1..726ba56465 100644 --- a/app/(chat)/api/vote/route.ts +++ b/app/(chat)/api/vote/route.ts @@ -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(); - - if (!chatId || !messageId || !type) { + 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 getSession(); + const session = await auth(); if (!session?.user) { return new ChatbotError("unauthorized:vote").toResponse(); diff --git a/app/(chat)/chat/[id]/page.tsx b/app/(chat)/chat/[id]/page.tsx index f8c10fa151..67e0859135 100644 --- a/app/(chat)/chat/[id]/page.tsx +++ b/app/(chat)/chat/[id]/page.tsx @@ -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 ( - }> - - - ); -} - -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 ( - <> - - - - ); - } - - return ( - <> - - - - ); +export default function Page() { + return null; } diff --git a/app/(chat)/layout.tsx b/app/(chat)/layout.tsx index aca0f0fe37..8ea30ecf77 100644 --- a/app/(chat)/layout.tsx +++ b/app/(chat)/layout.tsx @@ -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 ( <>