From b7af8ab5369872fdae208538e8442c8207c18025 Mon Sep 17 00:00:00 2001 From: Musa Date: Thu, 8 Jan 2026 15:20:47 -0800 Subject: [PATCH] demo(vercel-ai-sdk): add Next.js app routes, auth, and assets --- .../vercel-ai-sdk/app/(auth)/actions.ts | 84 +++++ .../(auth)/api/auth/[...nextauth]/route.ts | 2 + .../app/(auth)/api/auth/guest/route.ts | 21 ++ .../vercel-ai-sdk/app/(auth)/auth.config.ts | 13 + .../vercel-ai-sdk/app/(auth)/auth.ts | 95 ++++++ .../vercel-ai-sdk/app/(auth)/login/page.tsx | 77 +++++ .../app/(auth)/register/page.tsx | 77 +++++ .../vercel-ai-sdk/app/(chat)/actions.ts | 51 +++ .../app/(chat)/api/chat/[id]/stream/route.ts | 113 +++++++ .../app/(chat)/api/chat/route.ts | 317 +++++++++++++++++ .../app/(chat)/api/chat/schema.ts | 39 +++ .../app/(chat)/api/document/route.ts | 126 +++++++ .../app/(chat)/api/files/upload/route.ts | 68 ++++ .../app/(chat)/api/history/route.ts | 46 +++ .../app/(chat)/api/suggestions/route.ts | 37 ++ .../app/(chat)/api/vote/route.ts | 75 +++++ .../app/(chat)/chat/[id]/page.tsx | 82 +++++ .../vercel-ai-sdk/app/(chat)/layout.tsx | 35 ++ .../app/(chat)/opengraph-image.png | Bin 0 -> 165578 bytes .../vercel-ai-sdk/app/(chat)/page.tsx | 52 +++ .../app/(chat)/twitter-image.png | Bin 0 -> 52049 bytes demos/use_cases/vercel-ai-sdk/app/favicon.ico | Bin 0 -> 15406 bytes demos/use_cases/vercel-ai-sdk/app/globals.css | 318 ++++++++++++++++++ demos/use_cases/vercel-ai-sdk/app/layout.tsx | 87 +++++ .../vercel-ai-sdk/artifacts/actions.ts | 8 + .../vercel-ai-sdk/artifacts/code/client.tsx | 280 +++++++++++++++ .../vercel-ai-sdk/artifacts/code/server.ts | 75 +++++ .../vercel-ai-sdk/artifacts/image/client.tsx | 76 +++++ .../vercel-ai-sdk/artifacts/sheet/client.tsx | 115 +++++++ .../vercel-ai-sdk/artifacts/sheet/server.ts | 81 +++++ .../vercel-ai-sdk/artifacts/text/client.tsx | 179 ++++++++++ .../vercel-ai-sdk/artifacts/text/server.ts | 73 ++++ .../public/images/demo-thumbnail.png | Bin 0 -> 23198 bytes .../images/mouth of the seine, monet.jpg | Bin 0 -> 33497 bytes 34 files changed, 2702 insertions(+) create mode 100644 demos/use_cases/vercel-ai-sdk/app/(auth)/actions.ts create mode 100644 demos/use_cases/vercel-ai-sdk/app/(auth)/api/auth/[...nextauth]/route.ts create mode 100644 demos/use_cases/vercel-ai-sdk/app/(auth)/api/auth/guest/route.ts create mode 100644 demos/use_cases/vercel-ai-sdk/app/(auth)/auth.config.ts create mode 100644 demos/use_cases/vercel-ai-sdk/app/(auth)/auth.ts create mode 100644 demos/use_cases/vercel-ai-sdk/app/(auth)/login/page.tsx create mode 100644 demos/use_cases/vercel-ai-sdk/app/(auth)/register/page.tsx create mode 100644 demos/use_cases/vercel-ai-sdk/app/(chat)/actions.ts create mode 100644 demos/use_cases/vercel-ai-sdk/app/(chat)/api/chat/[id]/stream/route.ts create mode 100644 demos/use_cases/vercel-ai-sdk/app/(chat)/api/chat/route.ts create mode 100644 demos/use_cases/vercel-ai-sdk/app/(chat)/api/chat/schema.ts create mode 100644 demos/use_cases/vercel-ai-sdk/app/(chat)/api/document/route.ts create mode 100644 demos/use_cases/vercel-ai-sdk/app/(chat)/api/files/upload/route.ts create mode 100644 demos/use_cases/vercel-ai-sdk/app/(chat)/api/history/route.ts create mode 100644 demos/use_cases/vercel-ai-sdk/app/(chat)/api/suggestions/route.ts create mode 100644 demos/use_cases/vercel-ai-sdk/app/(chat)/api/vote/route.ts create mode 100644 demos/use_cases/vercel-ai-sdk/app/(chat)/chat/[id]/page.tsx create mode 100644 demos/use_cases/vercel-ai-sdk/app/(chat)/layout.tsx create mode 100644 demos/use_cases/vercel-ai-sdk/app/(chat)/opengraph-image.png create mode 100644 demos/use_cases/vercel-ai-sdk/app/(chat)/page.tsx create mode 100644 demos/use_cases/vercel-ai-sdk/app/(chat)/twitter-image.png create mode 100644 demos/use_cases/vercel-ai-sdk/app/favicon.ico create mode 100644 demos/use_cases/vercel-ai-sdk/app/globals.css create mode 100644 demos/use_cases/vercel-ai-sdk/app/layout.tsx create mode 100644 demos/use_cases/vercel-ai-sdk/artifacts/actions.ts create mode 100644 demos/use_cases/vercel-ai-sdk/artifacts/code/client.tsx create mode 100644 demos/use_cases/vercel-ai-sdk/artifacts/code/server.ts create mode 100644 demos/use_cases/vercel-ai-sdk/artifacts/image/client.tsx create mode 100644 demos/use_cases/vercel-ai-sdk/artifacts/sheet/client.tsx create mode 100644 demos/use_cases/vercel-ai-sdk/artifacts/sheet/server.ts create mode 100644 demos/use_cases/vercel-ai-sdk/artifacts/text/client.tsx create mode 100644 demos/use_cases/vercel-ai-sdk/artifacts/text/server.ts create mode 100644 demos/use_cases/vercel-ai-sdk/public/images/demo-thumbnail.png create mode 100644 demos/use_cases/vercel-ai-sdk/public/images/mouth of the seine, monet.jpg diff --git a/demos/use_cases/vercel-ai-sdk/app/(auth)/actions.ts b/demos/use_cases/vercel-ai-sdk/app/(auth)/actions.ts new file mode 100644 index 00000000..024ff518 --- /dev/null +++ b/demos/use_cases/vercel-ai-sdk/app/(auth)/actions.ts @@ -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 => { + 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 => { + 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" }; + } +}; diff --git a/demos/use_cases/vercel-ai-sdk/app/(auth)/api/auth/[...nextauth]/route.ts b/demos/use_cases/vercel-ai-sdk/app/(auth)/api/auth/[...nextauth]/route.ts new file mode 100644 index 00000000..588ff6a5 --- /dev/null +++ b/demos/use_cases/vercel-ai-sdk/app/(auth)/api/auth/[...nextauth]/route.ts @@ -0,0 +1,2 @@ +// biome-ignore lint/performance/noBarrelFile: "Required" +export { GET, POST } from "@/app/(auth)/auth"; diff --git a/demos/use_cases/vercel-ai-sdk/app/(auth)/api/auth/guest/route.ts b/demos/use_cases/vercel-ai-sdk/app/(auth)/api/auth/guest/route.ts new file mode 100644 index 00000000..dca565c5 --- /dev/null +++ b/demos/use_cases/vercel-ai-sdk/app/(auth)/api/auth/guest/route.ts @@ -0,0 +1,21 @@ +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 redirectUrl = searchParams.get("redirectUrl") || "/"; + + const token = await getToken({ + req: request, + secret: process.env.AUTH_SECRET, + secureCookie: !isDevelopmentEnvironment, + }); + + if (token) { + return NextResponse.redirect(new URL("/", request.url)); + } + + return signIn("guest", { redirect: true, redirectTo: redirectUrl }); +} diff --git a/demos/use_cases/vercel-ai-sdk/app/(auth)/auth.config.ts b/demos/use_cases/vercel-ai-sdk/app/(auth)/auth.config.ts new file mode 100644 index 00000000..b8bc9e1f --- /dev/null +++ b/demos/use_cases/vercel-ai-sdk/app/(auth)/auth.config.ts @@ -0,0 +1,13 @@ +import type { NextAuthConfig } from "next-auth"; + +export const authConfig = { + pages: { + signIn: "/login", + newUser: "/", + }, + providers: [ + // added later in auth.ts since it requires bcrypt which is only compatible with Node.js + // while this file is also used in non-Node.js environments + ], + callbacks: {}, +} satisfies NextAuthConfig; diff --git a/demos/use_cases/vercel-ai-sdk/app/(auth)/auth.ts b/demos/use_cases/vercel-ai-sdk/app/(auth)/auth.ts new file mode 100644 index 00000000..dbebb1d9 --- /dev/null +++ b/demos/use_cases/vercel-ai-sdk/app/(auth)/auth.ts @@ -0,0 +1,95 @@ +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"]; + } + + // biome-ignore lint/nursery/useConsistentTypeDefinitions: "Required" + 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: {}, + async authorize({ email, password }: any) { + 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/demos/use_cases/vercel-ai-sdk/app/(auth)/login/page.tsx b/demos/use_cases/vercel-ai-sdk/app/(auth)/login/page.tsx new file mode 100644 index 00000000..666feee3 --- /dev/null +++ b/demos/use_cases/vercel-ai-sdk/app/(auth)/login/page.tsx @@ -0,0 +1,77 @@ +"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/auth-form"; +import { SubmitButton } from "@/components/submit-button"; +import { toast } from "@/components/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", + } + ); + + 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 ( +
+
+
+

Sign In

+

+ Use your email and password to sign in +

+
+ + Sign in +

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

+
+
+
+ ); +} diff --git a/demos/use_cases/vercel-ai-sdk/app/(auth)/register/page.tsx b/demos/use_cases/vercel-ai-sdk/app/(auth)/register/page.tsx new file mode 100644 index 00000000..ff2f1e80 --- /dev/null +++ b/demos/use_cases/vercel-ai-sdk/app/(auth)/register/page.tsx @@ -0,0 +1,77 @@ +"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/auth-form"; +import { SubmitButton } from "@/components/submit-button"; +import { toast } from "@/components/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", + } + ); + + 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 successfully!" }); + + setIsSuccessful(true); + updateSession(); + router.refresh(); + } + }, [state.status]); + + const handleSubmit = (formData: FormData) => { + setEmail(formData.get("email") as string); + formAction(formData); + }; + + return ( +
+
+
+

Sign Up

+

+ Create an account with your email and password +

+
+ + Sign Up +

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

+
+
+
+ ); +} diff --git a/demos/use_cases/vercel-ai-sdk/app/(chat)/actions.ts b/demos/use_cases/vercel-ai-sdk/app/(chat)/actions.ts new file mode 100644 index 00000000..19f73c1a --- /dev/null +++ b/demos/use_cases/vercel-ai-sdk/app/(chat)/actions.ts @@ -0,0 +1,51 @@ +"use server"; + +import { generateText, type UIMessage } from "ai"; +import { cookies } from "next/headers"; +import type { VisibilityType } from "@/components/visibility-selector"; +import { titlePrompt } from "@/lib/ai/prompts"; +import { getTitleModel } from "@/lib/ai/providers"; +import { + deleteMessagesByChatIdAfterTimestamp, + 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: title } = await generateText({ + model: getTitleModel(), + system: titlePrompt, + prompt: getTextFromMessage(message), + }); + + return title; +} + +export async function deleteTrailingMessages({ id }: { id: string }) { + const [message] = await getMessageById({ id }); + + await deleteMessagesByChatIdAfterTimestamp({ + chatId: message.chatId, + timestamp: message.createdAt, + }); +} + +export async function updateChatVisibility({ + chatId, + visibility, +}: { + chatId: string; + visibility: VisibilityType; +}) { + await updateChatVisibilityById({ chatId, visibility }); +} diff --git a/demos/use_cases/vercel-ai-sdk/app/(chat)/api/chat/[id]/stream/route.ts b/demos/use_cases/vercel-ai-sdk/app/(chat)/api/chat/[id]/stream/route.ts new file mode 100644 index 00000000..48352e97 --- /dev/null +++ b/demos/use_cases/vercel-ai-sdk/app/(chat)/api/chat/[id]/stream/route.ts @@ -0,0 +1,113 @@ +import { createUIMessageStream, JsonToSseTransformStream } from "ai"; +import { differenceInSeconds } from "date-fns"; +import { auth } from "@/app/(auth)/auth"; +import { + getChatById, + getMessagesByChatId, + getStreamIdsByChatId, +} from "@/lib/db/queries"; +import type { Chat } from "@/lib/db/schema"; +import { ChatSDKError } from "@/lib/errors"; +import type { ChatMessage } from "@/lib/types"; +import { getStreamContext } from "../../route"; + +export async function GET( + _: Request, + { params }: { params: Promise<{ id: string }> } +) { + const { id: chatId } = await params; + + const streamContext = getStreamContext(); + const resumeRequestedAt = new Date(); + + if (!streamContext) { + return new Response(null, { status: 204 }); + } + + if (!chatId) { + return new ChatSDKError("bad_request:api").toResponse(); + } + + const session = await auth(); + + if (!session?.user) { + return new ChatSDKError("unauthorized:chat").toResponse(); + } + + let chat: Chat | null; + + try { + chat = await getChatById({ id: chatId }); + } catch { + return new ChatSDKError("not_found:chat").toResponse(); + } + + if (!chat) { + return new ChatSDKError("not_found:chat").toResponse(); + } + + if (chat.visibility === "private" && chat.userId !== session.user.id) { + return new ChatSDKError("forbidden:chat").toResponse(); + } + + const streamIds = await getStreamIdsByChatId({ chatId }); + + if (!streamIds.length) { + return new ChatSDKError("not_found:stream").toResponse(); + } + + const recentStreamId = streamIds.at(-1); + + if (!recentStreamId) { + return new ChatSDKError("not_found:stream").toResponse(); + } + + const emptyDataStream = createUIMessageStream({ + // biome-ignore lint/suspicious/noEmptyBlockStatements: "Needs to exist" + execute: () => {}, + }); + + const stream = await streamContext.resumableStream(recentStreamId, () => + emptyDataStream.pipeThrough(new JsonToSseTransformStream()) + ); + + /* + * For when the generation is streaming during SSR + * but the resumable stream has concluded at this point. + */ + if (!stream) { + const messages = await getMessagesByChatId({ id: chatId }); + const mostRecentMessage = messages.at(-1); + + if (!mostRecentMessage) { + return new Response(emptyDataStream, { status: 200 }); + } + + if (mostRecentMessage.role !== "assistant") { + return new Response(emptyDataStream, { status: 200 }); + } + + const messageCreatedAt = new Date(mostRecentMessage.createdAt); + + if (differenceInSeconds(resumeRequestedAt, messageCreatedAt) > 15) { + return new Response(emptyDataStream, { status: 200 }); + } + + const restoredStream = createUIMessageStream({ + execute: ({ writer }) => { + writer.write({ + type: "data-appendMessage", + data: JSON.stringify(mostRecentMessage), + transient: true, + }); + }, + }); + + return new Response( + restoredStream.pipeThrough(new JsonToSseTransformStream()), + { status: 200 } + ); + } + + return new Response(stream, { status: 200 }); +} diff --git a/demos/use_cases/vercel-ai-sdk/app/(chat)/api/chat/route.ts b/demos/use_cases/vercel-ai-sdk/app/(chat)/api/chat/route.ts new file mode 100644 index 00000000..e9a5819b --- /dev/null +++ b/demos/use_cases/vercel-ai-sdk/app/(chat)/api/chat/route.ts @@ -0,0 +1,317 @@ +import { geolocation } from "@vercel/functions"; +import { + convertToModelMessages, + createUIMessageStream, + JsonToSseTransformStream, + smoothStream, + stepCountIs, + streamText, +} from "ai"; +import { after } from "next/server"; +import { + createResumableStreamContext, + type ResumableStreamContext, +} from "resumable-stream"; +import { auth, type UserType } from "@/app/(auth)/auth"; +import { entitlementsByUserType } from "@/lib/ai/entitlements"; +import { type RequestHints, systemPrompt } from "@/lib/ai/prompts"; +import { getLanguageModel } from "@/lib/ai/providers"; +import { getWeather } from "@/lib/ai/tools/get-weather"; +import { getCurrencyExchange } from "@/lib/ai/tools/get-currency-exchange"; +import { isProductionEnvironment } from "@/lib/constants"; +import { + createStreamId, + deleteChatById, + getChatById, + getMessageCountByUserId, + getMessagesByChatId, + saveChat, + saveMessages, + updateChatTitleById, + updateMessage, +} from "@/lib/db/queries"; +import type { DBMessage } from "@/lib/db/schema"; +import { ChatSDKError } from "@/lib/errors"; +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; + +let globalStreamContext: ResumableStreamContext | null = null; + +export function getStreamContext() { + if (!globalStreamContext) { + try { + globalStreamContext = createResumableStreamContext({ + waitUntil: after, + }); + } catch (error: any) { + if (error.message.includes("REDIS_URL")) { + console.log( + " > Resumable streams are disabled due to missing REDIS_URL" + ); + } else { + console.error(error); + } + } + } + + return globalStreamContext; +} + +export async function POST(request: Request) { + let requestBody: PostRequestBody; + + try { + const json = await request.json(); + requestBody = postRequestBodySchema.parse(json); + } catch (_) { + return new ChatSDKError("bad_request:api").toResponse(); + } + + try { + const { id, message, messages, selectedChatModel, selectedVisibilityType } = + requestBody; + + const session = await auth(); + + if (!session?.user) { + return new ChatSDKError("unauthorized:chat").toResponse(); + } + + const userType: UserType = session.user.type; + + const messageCount = await getMessageCountByUserId({ + id: session.user.id, + differenceInHours: 24, + }); + + if (messageCount > entitlementsByUserType[userType].maxMessagesPerDay) { + return new ChatSDKError("rate_limit:chat").toResponse(); + } + + // Check if this is a tool approval flow (all messages sent) + const isToolApprovalFlow = Boolean(messages); + + const chat = await getChatById({ id }); + let messagesFromDb: DBMessage[] = []; + let titlePromise: Promise | null = null; + + if (chat) { + if (chat.userId !== session.user.id) { + return new ChatSDKError("forbidden:chat").toResponse(); + } + // Only fetch messages if chat already exists and not tool approval + if (!isToolApprovalFlow) { + messagesFromDb = await getMessagesByChatId({ id }); + } + } else if (message?.role === "user") { + // Save chat immediately with placeholder title + await saveChat({ + id, + userId: session.user.id, + title: "New chat", + visibility: selectedVisibilityType, + }); + + // Start title generation in parallel (don't await) + titlePromise = generateTitleFromUserMessage({ message }); + } + + // Use all messages for tool approval, otherwise DB messages + new message + const uiMessages = isToolApprovalFlow + ? (messages as ChatMessage[]) + : [...convertToUIMessages(messagesFromDb), message as ChatMessage]; + + const { longitude, latitude, city, country } = geolocation(request); + + const requestHints: RequestHints = { + longitude, + latitude, + city, + country, + }; + + // Only save user messages to the database (not tool approval responses) + if (message?.role === "user") { + await saveMessages({ + messages: [ + { + chatId: id, + id: message.id, + role: "user", + parts: message.parts, + attachments: [], + createdAt: new Date(), + }, + ], + }); + } + + const streamId = generateUUID(); + await createStreamId({ streamId, chatId: id }); + + const stream = createUIMessageStream({ + // Pass original messages for tool approval continuation + originalMessages: isToolApprovalFlow ? uiMessages : undefined, + execute: async ({ writer: dataStream }) => { + // Handle title generation in parallel + if (titlePromise) { + titlePromise.then((title) => { + updateChatTitleById({ chatId: id, title }); + dataStream.write({ type: "data-chat-title", data: title }); + }); + } + + const isReasoningModel = + selectedChatModel.includes("reasoning") || + selectedChatModel.includes("thinking"); + + const result = streamText({ + model: getLanguageModel(selectedChatModel), + system: systemPrompt({ selectedChatModel, requestHints }), + messages: await convertToModelMessages(uiMessages), + stopWhen: stepCountIs(5), + experimental_activeTools: isReasoningModel + ? [] + : ["getWeather", "getCurrencyExchange"], + experimental_transform: isReasoningModel + ? undefined + : smoothStream({ chunking: "word" }), + providerOptions: isReasoningModel + ? { + anthropic: { + thinking: { type: "enabled", budgetTokens: 10_000 }, + }, + } + : undefined, + tools: { + getWeather, + getCurrencyExchange, + }, + experimental_telemetry: { + isEnabled: isProductionEnvironment, + functionId: "stream-text", + }, + }); + + result.consumeStream(); + + dataStream.merge( + result.toUIMessageStream({ + sendReasoning: true, + }) + ); + }, + generateId: generateUUID, + onFinish: async ({ messages: finishedMessages }) => { + if (isToolApprovalFlow) { + // For tool approval, update existing messages (tool state changed) and save new ones + for (const finishedMsg of finishedMessages) { + const existingMsg = uiMessages.find((m) => m.id === finishedMsg.id); + if (existingMsg) { + // Update existing message with new parts (tool state changed) + await updateMessage({ + id: finishedMsg.id, + parts: finishedMsg.parts, + }); + } else { + // Save new message + await saveMessages({ + messages: [ + { + id: finishedMsg.id, + role: finishedMsg.role, + parts: finishedMsg.parts, + createdAt: new Date(), + attachments: [], + chatId: id, + }, + ], + }); + } + } + } else if (finishedMessages.length > 0) { + // Normal flow - save all finished messages + await saveMessages({ + messages: finishedMessages.map((currentMessage) => ({ + id: currentMessage.id, + role: currentMessage.role, + parts: currentMessage.parts, + createdAt: new Date(), + attachments: [], + chatId: id, + })), + }); + } + }, + onError: () => { + return "Oops, an error occurred!"; + }, + }); + + const streamContext = getStreamContext(); + + if (streamContext) { + try { + const resumableStream = await streamContext.resumableStream( + streamId, + () => stream.pipeThrough(new JsonToSseTransformStream()) + ); + if (resumableStream) { + return new Response(resumableStream); + } + } catch (error) { + console.error("Failed to create resumable stream:", error); + } + } + + return new Response(stream.pipeThrough(new JsonToSseTransformStream())); + } catch (error) { + const vercelId = request.headers.get("x-vercel-id"); + + if (error instanceof ChatSDKError) { + return error.toResponse(); + } + + // Check for Vercel AI Gateway credit card error + if ( + error instanceof Error && + error.message?.includes( + "AI Gateway requires a valid credit card on file to service requests" + ) + ) { + return new ChatSDKError("bad_request:activate_gateway").toResponse(); + } + + console.error("Unhandled error in chat API:", error, { vercelId }); + return new ChatSDKError("offline:chat").toResponse(); + } +} + +export async function DELETE(request: Request) { + const { searchParams } = new URL(request.url); + const id = searchParams.get("id"); + + if (!id) { + return new ChatSDKError("bad_request:api").toResponse(); + } + + const session = await auth(); + + if (!session?.user) { + return new ChatSDKError("unauthorized:chat").toResponse(); + } + + const chat = await getChatById({ id }); + + if (chat?.userId !== session.user.id) { + return new ChatSDKError("forbidden:chat").toResponse(); + } + + const deletedChat = await deleteChatById({ id }); + + return Response.json(deletedChat, { status: 200 }); +} diff --git a/demos/use_cases/vercel-ai-sdk/app/(chat)/api/chat/schema.ts b/demos/use_cases/vercel-ai-sdk/app/(chat)/api/chat/schema.ts new file mode 100644 index 00000000..60a708ac --- /dev/null +++ b/demos/use_cases/vercel-ai-sdk/app/(chat)/api/chat/schema.ts @@ -0,0 +1,39 @@ +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"]), + name: z.string().min(1).max(100), + url: z.string().url(), +}); + +const partSchema = z.union([textPartSchema, filePartSchema]); + +const userMessageSchema = z.object({ + id: z.string().uuid(), + role: z.enum(["user"]), + parts: z.array(partSchema), +}); + +// For tool approval flows, we accept all messages (more permissive schema) +const messageSchema = z.object({ + id: z.string(), + role: z.string(), + parts: z.array(z.any()), +}); + +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(), + selectedChatModel: z.string(), + selectedVisibilityType: z.enum(["public", "private"]), +}); + +export type PostRequestBody = z.infer; diff --git a/demos/use_cases/vercel-ai-sdk/app/(chat)/api/document/route.ts b/demos/use_cases/vercel-ai-sdk/app/(chat)/api/document/route.ts new file mode 100644 index 00000000..0ea78ff5 --- /dev/null +++ b/demos/use_cases/vercel-ai-sdk/app/(chat)/api/document/route.ts @@ -0,0 +1,126 @@ +import { auth } from "@/app/(auth)/auth"; +import type { ArtifactKind } from "@/components/artifact"; +import { + deleteDocumentsByIdAfterTimestamp, + getDocumentsById, + saveDocument, +} from "@/lib/db/queries"; +import { ChatSDKError } from "@/lib/errors"; + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + const id = searchParams.get("id"); + + if (!id) { + return new ChatSDKError( + "bad_request:api", + "Parameter id is missing" + ).toResponse(); + } + + const session = await auth(); + + if (!session?.user) { + return new ChatSDKError("unauthorized:document").toResponse(); + } + + const documents = await getDocumentsById({ id }); + + const [document] = documents; + + if (!document) { + return new ChatSDKError("not_found:document").toResponse(); + } + + if (document.userId !== session.user.id) { + return new ChatSDKError("forbidden:document").toResponse(); + } + + return Response.json(documents, { status: 200 }); +} + +export async function POST(request: Request) { + const { searchParams } = new URL(request.url); + const id = searchParams.get("id"); + + if (!id) { + return new ChatSDKError( + "bad_request:api", + "Parameter id is required." + ).toResponse(); + } + + const session = await auth(); + + if (!session?.user) { + return new ChatSDKError("not_found:document").toResponse(); + } + + const { + content, + title, + kind, + }: { content: string; title: string; kind: ArtifactKind } = + await request.json(); + + const documents = await getDocumentsById({ id }); + + if (documents.length > 0) { + const [doc] = documents; + + if (doc.userId !== session.user.id) { + return new ChatSDKError("forbidden:document").toResponse(); + } + } + + const document = await saveDocument({ + id, + content, + title, + kind, + userId: session.user.id, + }); + + return Response.json(document, { status: 200 }); +} + +export async function DELETE(request: Request) { + const { searchParams } = new URL(request.url); + const id = searchParams.get("id"); + const timestamp = searchParams.get("timestamp"); + + if (!id) { + return new ChatSDKError( + "bad_request:api", + "Parameter id is required." + ).toResponse(); + } + + if (!timestamp) { + return new ChatSDKError( + "bad_request:api", + "Parameter timestamp is required." + ).toResponse(); + } + + const session = await auth(); + + if (!session?.user) { + return new ChatSDKError("unauthorized:document").toResponse(); + } + + const documents = await getDocumentsById({ id }); + + const [document] = documents; + + if (document.userId !== session.user.id) { + return new ChatSDKError("forbidden:document").toResponse(); + } + + const documentsDeleted = await deleteDocumentsByIdAfterTimestamp({ + id, + timestamp: new Date(timestamp), + }); + + return Response.json(documentsDeleted, { status: 200 }); +} diff --git a/demos/use_cases/vercel-ai-sdk/app/(chat)/api/files/upload/route.ts b/demos/use_cases/vercel-ai-sdk/app/(chat)/api/files/upload/route.ts new file mode 100644 index 00000000..4e4e4f3c --- /dev/null +++ b/demos/use_cases/vercel-ai-sdk/app/(chat)/api/files/upload/route.ts @@ -0,0 +1,68 @@ +import { put } from "@vercel/blob"; +import { NextResponse } from "next/server"; +import { z } from "zod"; + +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 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 }); + } + + // Get filename from formData since Blob doesn't have name property + const filename = (formData.get("file") as File).name; + const fileBuffer = await file.arrayBuffer(); + + try { + const data = await put(`${filename}`, fileBuffer, { + access: "public", + }); + + return NextResponse.json(data); + } catch (_error) { + return NextResponse.json({ error: "Upload failed" }, { status: 500 }); + } + } catch (_error) { + return NextResponse.json( + { error: "Failed to process request" }, + { status: 500 } + ); + } +} diff --git a/demos/use_cases/vercel-ai-sdk/app/(chat)/api/history/route.ts b/demos/use_cases/vercel-ai-sdk/app/(chat)/api/history/route.ts new file mode 100644 index 00000000..23615e30 --- /dev/null +++ b/demos/use_cases/vercel-ai-sdk/app/(chat)/api/history/route.ts @@ -0,0 +1,46 @@ +import type { NextRequest } from "next/server"; +import { auth } from "@/app/(auth)/auth"; +import { deleteAllChatsByUserId, getChatsByUserId } from "@/lib/db/queries"; +import { ChatSDKError } from "@/lib/errors"; + +export async function GET(request: NextRequest) { + const { searchParams } = request.nextUrl; + + const limit = Number.parseInt(searchParams.get("limit") || "10", 10); + const startingAfter = searchParams.get("starting_after"); + const endingBefore = searchParams.get("ending_before"); + + if (startingAfter && endingBefore) { + return new ChatSDKError( + "bad_request:api", + "Only one of starting_after or ending_before can be provided." + ).toResponse(); + } + + const session = await auth(); + + if (!session?.user) { + return new ChatSDKError("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 ChatSDKError("unauthorized:chat").toResponse(); + } + + const result = await deleteAllChatsByUserId({ userId: session.user.id }); + + return Response.json(result, { status: 200 }); +} diff --git a/demos/use_cases/vercel-ai-sdk/app/(chat)/api/suggestions/route.ts b/demos/use_cases/vercel-ai-sdk/app/(chat)/api/suggestions/route.ts new file mode 100644 index 00000000..8801004e --- /dev/null +++ b/demos/use_cases/vercel-ai-sdk/app/(chat)/api/suggestions/route.ts @@ -0,0 +1,37 @@ +import { auth } from "@/app/(auth)/auth"; +import { getSuggestionsByDocumentId } from "@/lib/db/queries"; +import { ChatSDKError } from "@/lib/errors"; + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + const documentId = searchParams.get("documentId"); + + if (!documentId) { + return new ChatSDKError( + "bad_request:api", + "Parameter documentId is required." + ).toResponse(); + } + + const session = await auth(); + + if (!session?.user) { + return new ChatSDKError("unauthorized:suggestions").toResponse(); + } + + const suggestions = await getSuggestionsByDocumentId({ + documentId, + }); + + const [suggestion] = suggestions; + + if (!suggestion) { + return Response.json([], { status: 200 }); + } + + if (suggestion.userId !== session.user.id) { + return new ChatSDKError("forbidden:api").toResponse(); + } + + return Response.json(suggestions, { status: 200 }); +} diff --git a/demos/use_cases/vercel-ai-sdk/app/(chat)/api/vote/route.ts b/demos/use_cases/vercel-ai-sdk/app/(chat)/api/vote/route.ts new file mode 100644 index 00000000..2c0ce3f7 --- /dev/null +++ b/demos/use_cases/vercel-ai-sdk/app/(chat)/api/vote/route.ts @@ -0,0 +1,75 @@ +import { auth } from "@/app/(auth)/auth"; +import { getChatById, getVotesByChatId, voteMessage } from "@/lib/db/queries"; +import { ChatSDKError } from "@/lib/errors"; + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + const chatId = searchParams.get("chatId"); + + if (!chatId) { + return new ChatSDKError( + "bad_request:api", + "Parameter chatId is required." + ).toResponse(); + } + + const session = await auth(); + + if (!session?.user) { + return new ChatSDKError("unauthorized:vote").toResponse(); + } + + const chat = await getChatById({ id: chatId }); + + if (!chat) { + return new ChatSDKError("not_found:chat").toResponse(); + } + + if (chat.userId !== session.user.id) { + return new ChatSDKError("forbidden:vote").toResponse(); + } + + const votes = await getVotesByChatId({ id: chatId }); + + return Response.json(votes, { status: 200 }); +} + +export async function PATCH(request: Request) { + const { + chatId, + messageId, + type, + }: { chatId: string; messageId: string; type: "up" | "down" } = + await request.json(); + + if (!chatId || !messageId || !type) { + return new ChatSDKError( + "bad_request:api", + "Parameters chatId, messageId, and type are required." + ).toResponse(); + } + + const session = await auth(); + + if (!session?.user) { + return new ChatSDKError("unauthorized:vote").toResponse(); + } + + const chat = await getChatById({ id: chatId }); + + if (!chat) { + return new ChatSDKError("not_found:vote").toResponse(); + } + + if (chat.userId !== session.user.id) { + return new ChatSDKError("forbidden:vote").toResponse(); + } + + await voteMessage({ + chatId, + messageId, + type, + }); + + return new Response("Message voted", { status: 200 }); +} diff --git a/demos/use_cases/vercel-ai-sdk/app/(chat)/chat/[id]/page.tsx b/demos/use_cases/vercel-ai-sdk/app/(chat)/chat/[id]/page.tsx new file mode 100644 index 00000000..1bd56937 --- /dev/null +++ b/demos/use_cases/vercel-ai-sdk/app/(chat)/chat/[id]/page.tsx @@ -0,0 +1,82 @@ +import { cookies } from "next/headers"; +import { notFound, redirect } from "next/navigation"; +import { Suspense } from "react"; + +import { auth } from "@/app/(auth)/auth"; +import { Chat } from "@/components/chat"; +import { DataStreamHandler } from "@/components/data-stream-handler"; +import { DEFAULT_CHAT_MODEL } from "@/lib/ai/models"; +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 auth(); + + if (!session) { + redirect("/api/auth/guest"); + } + + 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 ( + <> + + + + ); +} diff --git a/demos/use_cases/vercel-ai-sdk/app/(chat)/layout.tsx b/demos/use_cases/vercel-ai-sdk/app/(chat)/layout.tsx new file mode 100644 index 00000000..f6d4f5fe --- /dev/null +++ b/demos/use_cases/vercel-ai-sdk/app/(chat)/layout.tsx @@ -0,0 +1,35 @@ +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 { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"; +import { auth } from "../(auth)/auth"; + +export default function Layout({ children }: { children: React.ReactNode }) { + return ( + <> +