From 21abfb93dab15af6b534e2d63fdc6b716e8c1104 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ili=C4=87?= Date: Tue, 26 May 2026 16:28:45 +0200 Subject: [PATCH 1/2] Frontend Repository Cleanup & Local Run --- .env.example | 20 +- README.md | 80 +- app/(auth)/actions.ts | 84 -- app/(auth)/api/auth/guest/route.ts | 26 - app/(auth)/auth.ts | 91 +- app/(auth)/layout.tsx | 9 +- app/(auth)/login/login-form.tsx | 49 + app/(auth)/login/page.tsx | 66 +- app/(auth)/register/page.tsx | 68 +- app/(chat)/actions.ts | 82 -- app/(chat)/api/chat/[id]/stream/route.ts | 3 - app/(chat)/api/chat/route.ts | 424 ++------ app/(chat)/api/document/route.ts | 160 --- app/(chat)/api/files/upload/route.ts | 66 -- app/(chat)/api/history/route.ts | 49 - app/(chat)/api/messages/route.ts | 43 - app/(chat)/api/models/route.ts | 20 - app/(chat)/api/suggestions/route.ts | 37 - app/(chat)/api/vote/route.ts | 84 -- artifacts/actions.ts | 8 - artifacts/text/client.tsx | 7 +- components/chat/app-sidebar.tsx | 187 +--- components/chat/artifact-messages.tsx | 2 +- components/chat/artifact.tsx | 159 +-- components/chat/chat-header.tsx | 25 - components/chat/code-editor.tsx | 2 +- components/chat/create-artifact.tsx | 2 +- components/chat/data-stream-handler.tsx | 7 +- components/chat/document-preview.tsx | 84 +- components/chat/message-actions.tsx | 116 +- components/chat/message-editor.tsx | 3 - components/chat/message.tsx | 2 +- components/chat/messages.tsx | 2 +- components/chat/multimodal-input.tsx | 126 +-- components/chat/shell.tsx | 42 - components/chat/sidebar-history-item.tsx | 2 +- components/chat/sidebar-history.tsx | 368 +------ components/chat/sidebar-user-nav.tsx | 19 +- components/chat/sign-out-form.tsx | 25 - components/chat/text-editor.tsx | 2 +- components/chat/version-footer.tsx | 59 +- drizzle.config.ts | 15 - hooks/use-active-chat.tsx | 83 +- hooks/use-chat-visibility.ts | 41 +- instrumentation-client.ts | 10 - instrumentation.ts | 5 - lib/ai/entitlements.ts | 3 - lib/ai/models.ts | 137 +-- lib/ai/prompts.ts | 9 +- lib/ai/tools/create-document.ts | 32 +- lib/ai/tools/edit-document.ts | 64 +- lib/ai/tools/request-suggestions.ts | 100 +- lib/ai/tools/update-document.ts | 44 +- lib/artifacts/server.ts | 28 +- lib/auth-mode.ts | 14 + lib/chat/types.ts | 47 + lib/constants.ts | 6 - lib/db/migrate.ts | 33 - lib/db/migrations/0000_initial.sql | 67 -- lib/db/migrations/meta/_journal.json | 13 - lib/db/queries.ts | 632 ----------- lib/db/schema.ts | 136 --- lib/db/utils.ts | 16 - lib/editor/suggestions.tsx | 2 +- lib/errors.ts | 7 +- lib/ratelimit.ts | 48 - lib/types.ts | 2 +- lib/utils.ts | 23 +- next.config.ts | 7 +- package.json | 23 +- pnpm-lock.yaml | 1272 +--------------------- proxy.ts | 24 +- 72 files changed, 566 insertions(+), 5087 deletions(-) delete mode 100644 app/(auth)/actions.ts delete mode 100644 app/(auth)/api/auth/guest/route.ts create mode 100644 app/(auth)/login/login-form.tsx delete mode 100644 app/(chat)/actions.ts delete mode 100644 app/(chat)/api/chat/[id]/stream/route.ts delete mode 100644 app/(chat)/api/document/route.ts delete mode 100644 app/(chat)/api/files/upload/route.ts delete mode 100644 app/(chat)/api/history/route.ts delete mode 100644 app/(chat)/api/messages/route.ts delete mode 100644 app/(chat)/api/models/route.ts delete mode 100644 app/(chat)/api/suggestions/route.ts delete mode 100644 app/(chat)/api/vote/route.ts delete mode 100644 artifacts/actions.ts delete mode 100644 components/chat/sign-out-form.tsx delete mode 100644 drizzle.config.ts delete mode 100644 instrumentation-client.ts delete mode 100644 instrumentation.ts create mode 100644 lib/auth-mode.ts create mode 100644 lib/chat/types.ts delete mode 100644 lib/db/migrate.ts delete mode 100644 lib/db/migrations/0000_initial.sql delete mode 100644 lib/db/migrations/meta/_journal.json delete mode 100644 lib/db/queries.ts delete mode 100644 lib/db/schema.ts delete mode 100644 lib/db/utils.ts delete mode 100644 lib/ratelimit.ts diff --git a/.env.example b/.env.example index 8433714cc5..183994dce3 100644 --- a/.env.example +++ b/.env.example @@ -1,15 +1,11 @@ # generate a random secret: https://generate-secret.vercel.app/32 or `openssl rand -base64 32` AUTH_SECRET=**** -# required for non-vercel deployments, vercel uses OIDC automatically -# https://vercel.com/ai-gateway -AI_GATEWAY_API_KEY=**** - -# https://vercel.com/docs/vercel-blob -BLOB_READ_WRITE_TOKEN=**** - -# https://vercel.com/docs/postgres -POSTGRES_URL=**** - -# https://vercel.com/docs/redis -REDIS_URL=**** +# Keep false while reviewing UI architecture. Set true when the app should +# require Microsoft Entra login before opening chat routes. +AUTH_REQUIRED=false + +# Microsoft Entra ID / Azure AD auth. +AUTH_MICROSOFT_ENTRA_ID_ID=**** +AUTH_MICROSOFT_ENTRA_ID_SECRET=**** +AUTH_MICROSOFT_ENTRA_ID_TENANT_ID=**** diff --git a/README.md b/README.md index b4070187c5..d9170fa66e 100644 --- a/README.md +++ b/README.md @@ -1,71 +1,29 @@ - - Chatbot -

Chatbot

-
+# Chatbot UI -

- Chatbot (formerly AI Chatbot) is a free, open-source template built with Next.js and the AI SDK that helps you quickly build powerful chatbot applications. -

+Next.js chat UI built around the Vercel AI SDK client state, currently running in UI-only mode. -

- Read Docs · - Features · - Model Providers · - Deploy Your Own · - Running locally -

-
+## Current Mode -## Features +- Chat streaming is served by a local AI SDK-compatible stub in `/api/chat`. +- Chat history, votes, persisted messages, artifact persistence, suggestions, uploads, and model listing are not implemented in this UI layer. +- Authentication shell is handled with Auth.js / NextAuth and Microsoft Entra ID. Chat routes stay open while `AUTH_REQUIRED=false`; set `AUTH_REQUIRED=true` when login should be enforced. +- PostgreSQL, Drizzle, Redis, Vercel Blob, Vercel BotID, Vercel OTel, and Vercel AI Gateway are not part of the active app runtime. -- [Next.js](https://nextjs.org) App Router - - Advanced routing for seamless navigation and performance - - React Server Components (RSCs) and Server Actions for server-side rendering and increased performance -- [AI SDK](https://ai-sdk.dev/docs/introduction) - - Unified API for generating text, structured objects, and tool calls with LLMs - - Hooks for building dynamic chat and generative user interfaces - - Supports OpenAI, Anthropic, Google, xAI, and other model providers via AI Gateway -- [shadcn/ui](https://ui.shadcn.com) - - Styling with [Tailwind CSS](https://tailwindcss.com) - - Component primitives from [Radix UI](https://radix-ui.com) for accessibility and flexibility -- Data Persistence - - [Neon Serverless Postgres](https://vercel.com/marketplace/neon) for saving chat history and user data - - [Vercel Blob](https://vercel.com/storage/blob) for efficient file storage -- [Auth.js](https://authjs.dev) - - Simple and secure authentication - -## Model Providers - -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 - -**For Vercel deployments**: Authentication is handled automatically via OIDC tokens. - -**For non-Vercel deployments**: You need to provide an AI Gateway API key by setting the `AI_GATEWAY_API_KEY` environment variable in your `.env.local` file. - -With the [AI SDK](https://ai-sdk.dev/docs/introduction), you can also switch to direct LLM providers like [OpenAI](https://openai.com), [Anthropic](https://anthropic.com), [Cohere](https://cohere.com/), and [many more](https://ai-sdk.dev/providers/ai-sdk-providers) with just a few lines of code. - -## Deploy Your Own - -You can deploy your own version of Chatbot to Vercel with one click: - -[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/templates/next.js/chatbot) - -## Running locally - -You will need to use the environment variables [defined in `.env.example`](.env.example) to run Chatbot. It's recommended you use [Vercel Environment Variables](https://vercel.com/docs/projects/environment-variables) for this, but a `.env` file is all that is necessary. - -> Note: You should not commit your `.env` file or it will expose secrets that will allow others to control access to your various AI and authentication provider accounts. - -1. Install Vercel CLI: `npm i -g vercel` -2. Link local instance with Vercel and GitHub accounts (creates `.vercel` directory): `vercel link` -3. Download your environment variables: `vercel env pull` +## Running Locally ```bash pnpm install -pnpm db:migrate # Setup database or apply latest database changes pnpm dev ``` -Your app template should now be running on [localhost:3000](http://localhost:3000). +The app runs on [localhost:3000](http://localhost:3000) by default. + +## Backend Integration + +When the production backend contract is ready, wire `/api/chat` to the backend streaming endpoint and add explicit frontend fetch clients for the backend-owned resources: + +- chat list/history +- persisted messages +- feedback/votes +- artifact documents and suggestions +- uploads/attachments diff --git a/app/(auth)/actions.ts b/app/(auth)/actions.ts deleted file mode 100644 index 024ff518ed..0000000000 --- a/app/(auth)/actions.ts +++ /dev/null @@ -1,84 +0,0 @@ -"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/app/(auth)/api/auth/guest/route.ts b/app/(auth)/api/auth/guest/route.ts deleted file mode 100644 index 97ce3d2c14..0000000000 --- a/app/(auth)/api/auth/guest/route.ts +++ /dev/null @@ -1,26 +0,0 @@ -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.ts b/app/(auth)/auth.ts index b75369fe3d..8dabd991eb 100644 --- a/app/(auth)/auth.ts +++ b/app/(auth)/auth.ts @@ -1,12 +1,10 @@ -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 MicrosoftEntraID from "next-auth/providers/microsoft-entra-id"; +import { hasEntraAuthConfig } from "@/lib/auth-mode"; import { authConfig } from "./auth.config"; -export type UserType = "guest" | "regular"; +export type UserType = "regular"; declare module "next-auth" { interface Session extends DefaultSession { @@ -14,12 +12,14 @@ declare module "next-auth" { id: string; type: UserType; } & DefaultSession["user"]; + accessToken?: string; + idToken?: string; } interface User { id?: string; email?: string | null; - type: UserType; + type?: UserType; } } @@ -27,9 +27,27 @@ declare module "next-auth/jwt" { interface JWT extends DefaultJWT { id: string; type: UserType; + accessToken?: string; + idToken?: string; } } +const tenantId = process.env.AUTH_MICROSOFT_ENTRA_ID_TENANT_ID; +const entraProvider = + hasEntraAuthConfig() + ? MicrosoftEntraID({ + clientId: process.env.AUTH_MICROSOFT_ENTRA_ID_ID, + clientSecret: process.env.AUTH_MICROSOFT_ENTRA_ID_SECRET, + ...(tenantId && { + issuer: `https://login.microsoftonline.com/${tenantId}/v2.0`, + }), + }) + : null; + +const providers = [entraProvider].filter( + (provider): provider is NonNullable => Boolean(provider) +); + export const { handlers: { GET, POST }, auth, @@ -37,52 +55,20 @@ export const { 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" }; - }, - }), - ], + providers, callbacks: { - jwt({ token, user }) { + jwt({ token, user, account }) { if (user) { - token.id = user.id as string; - token.type = user.type; + token.id = user.id ?? token.sub ?? user.email ?? ""; + token.type = "regular"; + } + + if (account?.access_token) { + token.accessToken = account.access_token; + } + + if (account?.id_token) { + token.idToken = account.id_token; } return token; @@ -90,9 +76,12 @@ export const { session({ session, token }) { if (session.user) { session.user.id = token.id; - session.user.type = token.type; + session.user.type = token.type ?? "regular"; } + session.accessToken = token.accessToken; + session.idToken = token.idToken; + return session; }, }, diff --git a/app/(auth)/layout.tsx b/app/(auth)/layout.tsx index d14e92cedc..89a9d1c80b 100644 --- a/app/(auth)/layout.tsx +++ b/app/(auth)/layout.tsx @@ -1,6 +1,6 @@ import { ArrowLeftIcon } from "lucide-react"; import Link from "next/link"; -import { SparklesIcon, VercelIcon } from "@/components/chat/icons"; +import { SparklesIcon } from "@/components/chat/icons"; import { Preview } from "@/components/chat/preview"; export default function AuthLayout({ @@ -29,12 +29,7 @@ export default function AuthLayout({
-
- Powered by - - AI Gateway -
-
+
diff --git a/app/(auth)/login/login-form.tsx b/app/(auth)/login/login-form.tsx new file mode 100644 index 0000000000..b848c13776 --- /dev/null +++ b/app/(auth)/login/login-form.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { LogIn } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { signIn, useSession } from "next-auth/react"; +import { useEffect, useState } from "react"; +import { Button } from "@/components/ui/button"; + +export function LoginForm({ + isEntraConfigured, +}: { + isEntraConfigured: boolean; +}) { + const router = useRouter(); + const { status } = useSession(); + const [isRedirecting, setIsRedirecting] = useState(false); + + useEffect(() => { + if (status === "authenticated") { + router.replace("/"); + } + }, [router, status]); + + return ( + <> +

Sign in

+

+ {isEntraConfigured + ? "Use your Microsoft Entra ID account to continue" + : "Microsoft Entra ID is not configured for this environment."} +

+ + + ); +} diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx index ea4c602eeb..cd61f0e2e7 100644 --- a/app/(auth)/login/page.tsx +++ b/app/(auth)/login/page.tsx @@ -1,66 +1,6 @@ -"use client"; - -import Link from "next/link"; -import { useRouter } from "next/navigation"; -import { useSession } from "next-auth/react"; -import { useActionState, useEffect, useState } from "react"; - -import { AuthForm } from "@/components/chat/auth-form"; -import { SubmitButton } from "@/components/chat/submit-button"; -import { toast } from "@/components/chat/toast"; -import { type LoginActionState, login } from "../actions"; +import { hasEntraAuthConfig } from "@/lib/auth-mode"; +import { LoginForm } from "./login-form"; 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 ( - <> -

Welcome back

-

- Sign in to your account to continue -

- - Sign in -

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

-
- - ); + return ; } diff --git a/app/(auth)/register/page.tsx b/app/(auth)/register/page.tsx index f2abbc8620..63b91e73bf 100644 --- a/app/(auth)/register/page.tsx +++ b/app/(auth)/register/page.tsx @@ -1,66 +1,18 @@ -"use client"; - import Link from "next/link"; -import { useRouter } from "next/navigation"; -import { useSession } from "next-auth/react"; -import { useActionState, useEffect, useState } from "react"; -import { AuthForm } from "@/components/chat/auth-form"; -import { SubmitButton } from "@/components/chat/submit-button"; -import { toast } from "@/components/chat/toast"; -import { type RegisterActionState, register } from "../actions"; +import { Button } from "@/components/ui/button"; 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!" }); - setIsSuccessful(true); - updateSession(); - router.refresh(); - } - }, [state.status]); - - const handleSubmit = (formData: FormData) => { - setEmail(formData.get("email") as string); - formAction(formData); - }; - return ( <> -

Create account

-

Get started for free

- - Sign up -

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

-
+

+ Organization account +

+

+ Accounts are managed in Microsoft Entra ID. +

+ ); } diff --git a/app/(chat)/actions.ts b/app/(chat)/actions.ts deleted file mode 100644 index 2955a53fbc..0000000000 --- a/app/(chat)/actions.ts +++ /dev/null @@ -1,82 +0,0 @@ -"use server"; - -import { generateText, type UIMessage } from "ai"; -import { cookies } from "next/headers"; -import { auth } from "@/app/(auth)/auth"; -import type { VisibilityType } from "@/components/chat/visibility-selector"; -import { 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"; -import { getTextFromMessage } from "@/lib/utils"; - -export async function saveChatModelAsCookie(model: string) { - const cookieStore = await cookies(); - cookieStore.set("chat-model", model); -} - -export async function generateTitleFromUserMessage({ - message, -}: { - message: UIMessage; -}) { - const { text } = await generateText({ - model: getTitleModel(), - system: titlePrompt, - prompt: getTextFromMessage(message), - providerOptions: { - gateway: { order: titleModel.gatewayOrder }, - }, - }); - return text - .replace(/^[#*"\s]+/, "") - .replace(/["]+$/, "") - .trim(); -} - -export async function deleteTrailingMessages({ id }: { id: string }) { - const session = await auth(); - if (!session?.user?.id) { - throw new Error("Unauthorized"); - } - - const [message] = await getMessageById({ id }); - if (!message) { - throw new Error("Message not found"); - } - - const chat = await getChatById({ id: message.chatId }); - if (!chat || chat.userId !== session.user.id) { - throw new Error("Unauthorized"); - } - - await deleteMessagesByChatIdAfterTimestamp({ - chatId: message.chatId, - timestamp: message.createdAt, - }); -} - -export async function updateChatVisibility({ - chatId, - visibility, -}: { - chatId: string; - visibility: VisibilityType; -}) { - const session = await auth(); - if (!session?.user?.id) { - throw new Error("Unauthorized"); - } - - const chat = await getChatById({ id: chatId }); - if (!chat || chat.userId !== session.user.id) { - throw new Error("Unauthorized"); - } - - await updateChatVisibilityById({ chatId, visibility }); -} diff --git a/app/(chat)/api/chat/[id]/stream/route.ts b/app/(chat)/api/chat/[id]/stream/route.ts deleted file mode 100644 index 3713ec5dac..0000000000 --- a/app/(chat)/api/chat/[id]/stream/route.ts +++ /dev/null @@ -1,3 +0,0 @@ -export function GET() { - return new Response(null, { status: 204 }); -} diff --git a/app/(chat)/api/chat/route.ts b/app/(chat)/api/chat/route.ts index eb70454bfd..de8e512d73 100644 --- a/app/(chat)/api/chat/route.ts +++ b/app/(chat)/api/chat/route.ts @@ -1,61 +1,57 @@ -import { geolocation, ipAddress } from "@vercel/functions"; -import { - convertToModelMessages, - createUIMessageStream, - createUIMessageStreamResponse, - generateId, - stepCountIs, - streamText, -} from "ai"; -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, - 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 { 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 { createUIMessageStream, createUIMessageStreamResponse } from "ai"; +import { auth } from "@/app/(auth)/auth"; +import { shouldRequireAuth } from "@/lib/auth-mode"; import { ChatbotError } from "@/lib/errors"; -import { checkIpRateLimit } from "@/lib/ratelimit"; -import type { ChatMessage } from "@/lib/types"; -import { convertToUIMessages, generateUUID } from "@/lib/utils"; -import { generateTitleFromUserMessage } from "../../actions"; +import { generateUUID } from "@/lib/utils"; import { type PostRequestBody, postRequestBodySchema } from "./schema"; export const maxDuration = 60; -function getStreamContext() { - try { - return createResumableStreamContext({ waitUntil: after }); - } catch (_) { - return null; +type ChatLikeMessage = { + role?: string; + parts?: Array<{ + type?: string; + text?: unknown; + }>; +}; + +function extractTextFromParts(parts: ChatLikeMessage["parts"]) { + return ( + parts + ?.filter((part) => part.type === "text") + .map((part) => (typeof part.text === "string" ? part.text : "")) + .join("\n") + .trim() ?? "" + ); +} + +function getUserQuestion({ + message, + messages, +}: { + message?: ChatLikeMessage; + messages?: ChatLikeMessage[]; +}) { + const directQuestion = extractTextFromParts(message?.parts); + + if (directQuestion) { + return directQuestion; } + + const lastUserMessage = messages + ?.filter((currentMessage) => currentMessage.role === "user") + .at(-1); + + return extractTextFromParts(lastUserMessage?.parts); +} + +function chunkText(text: string) { + return text.match(/\S+\s*/g) ?? [text]; } -export { getStreamContext }; +function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} export async function POST(request: Request) { let requestBody: PostRequestBody; @@ -67,306 +63,62 @@ export async function POST(request: Request) { return new ChatbotError("bad_request:api").toResponse(); } - try { - const { id, message, messages, selectedChatModel, selectedVisibilityType } = - requestBody; - - const [, session] = await Promise.all([ - checkBotId().catch(() => null), - auth(), - ]); + if (shouldRequireAuth()) { + const session = await auth(); if (!session?.user) { return new ChatbotError("unauthorized:chat").toResponse(); } + } - const chatModel = allowedModelIds.has(selectedChatModel) - ? selectedChatModel - : DEFAULT_CHAT_MODEL; - - await checkIpRateLimit(ipAddress(request)); - - const userType: UserType = session.user.type; - - const messageCount = await getMessageCountByUserId({ - id: session.user.id, - differenceInHours: 1, - }); - - if (messageCount > entitlementsByUserType[userType].maxMessagesPerHour) { - return new ChatbotError("rate_limit:chat").toResponse(); - } - - const isToolApprovalFlow = Boolean(messages); - - const chat = await getChatById({ id }); - let messagesFromDb: DBMessage[] = []; - let titlePromise: Promise | null = null; + const question = getUserQuestion({ + message: requestBody.message, + messages: requestBody.messages, + }); - if (chat) { - if (chat.userId !== session.user.id) { - return new ChatbotError("forbidden:chat").toResponse(); - } - messagesFromDb = await getMessagesByChatId({ id }); - } else if (message?.role === "user") { - await saveChat({ - id, - userId: session.user.id, - title: "New chat", - visibility: selectedVisibilityType, - }); - titlePromise = generateTitleFromUserMessage({ message }); - } + if (!question) { + return new ChatbotError("bad_request:api").toResponse(); + } - let uiMessages: ChatMessage[]; + const stream = createUIMessageStream({ + generateId: generateUUID, + execute: async ({ writer }) => { + const textId = generateUUID(); + const responseText = `UI-only mode is active. - 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, - ]; - } +I received your message: - const { longitude, latitude, city, country } = geolocation(request); +> ${question} - const requestHints: RequestHints = { - longitude, - latitude, - city, - country, - }; +The chat shell, streaming state, markdown rendering, and message layout are working. We will connect this route to your production backend once the backend contract is ready.`; - if (message?.role === "user") { - await saveMessages({ - messages: [ - { - chatId: id, - id: message.id, - role: "user", - parts: message.parts, - attachments: [], - createdAt: new Date(), - }, - ], + writer.write({ + type: "text-start", + id: textId, }); - } - - 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); - const stream = createUIMessageStream({ - originalMessages: isToolApprovalFlow ? uiMessages : undefined, - execute: async ({ writer: dataStream }) => { - const result = streamText({ - model: getLanguageModel(chatModel), - system: systemPrompt({ requestHints, supportsTools }), - messages: modelMessages, - stopWhen: stepCountIs(5), - experimental_activeTools: - isReasoningModel && !supportsTools - ? [] - : [ - "getWeather", - "createDocument", - "editDocument", - "updateDocument", - "requestSuggestions", - ], - providerOptions: { - ...(modelConfig?.gatewayOrder && { - gateway: { order: modelConfig.gatewayOrder }, - }), - ...(modelConfig?.reasoningEffort && { - openai: { reasoningEffort: modelConfig.reasoningEffort }, - }), - }, - tools: { - getWeather, - 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, - functionId: "stream-text", - }, - }); - - dataStream.merge( - result.toUIMessageStream({ sendReasoning: isReasoningModel }) - ); - - if (titlePromise) { - try { - const title = await titlePromise; - dataStream.write({ type: "data-chat-title", data: title }); - updateChatTitleById({ chatId: id, title }); - } catch (_) { - /* non-fatal */ - } - } - }, - generateId: generateUUID, - onFinish: async ({ messages: finishedMessages }) => { - if (isToolApprovalFlow) { - for (const finishedMsg of finishedMessages) { - const existingMsg = uiMessages.find((m) => m.id === finishedMsg.id); - if (existingMsg) { - await updateMessage({ - id: finishedMsg.id, - parts: finishedMsg.parts, - }); - } else { - await saveMessages({ - messages: [ - { - id: finishedMsg.id, - role: finishedMsg.role, - parts: finishedMsg.parts, - createdAt: new Date(), - attachments: [], - chatId: id, - }, - ], - }); - } - } - } else if (finishedMessages.length > 0) { - await saveMessages({ - messages: finishedMessages.map((currentMessage) => ({ - id: currentMessage.id, - role: currentMessage.role, - parts: currentMessage.parts, - createdAt: new Date(), - attachments: [], - chatId: id, - })), - }); - } - }, - onError: (error) => { - if ( - error instanceof Error && - error.message?.includes( - "AI Gateway requires a valid credit card on file to service requests" - ) - ) { - return "AI Gateway requires a valid credit card on file to service requests. Please visit https://vercel.com/d?to=%2F%5Bteam%5D%2F%7E%2Fai%3Fmodal%3Dadd-credit-card to add a card and unlock your free credits."; - } - return "Oops, an error occurred!"; - }, - }); - - return createUIMessageStreamResponse({ - stream, - async consumeSseStream({ stream: sseStream }) { - if (!process.env.REDIS_URL) { - return; + for (const delta of chunkText(responseText)) { + if (request.signal.aborted) { + break; } - try { - const streamContext = getStreamContext(); - if (streamContext) { - const streamId = generateId(); - await createStreamId({ streamId, chatId: id }); - await streamContext.createNewResumableStream( - streamId, - () => sseStream - ); - } - } catch (_) { - /* non-critical */ - } - }, - }); - } catch (error) { - const vercelId = request.headers.get("x-vercel-id"); - - if (error instanceof ChatbotError) { - return error.toResponse(); - } - - if ( - error instanceof Error && - error.message?.includes( - "AI Gateway requires a valid credit card on file to service requests" - ) - ) { - return new ChatbotError("bad_request:activate_gateway").toResponse(); - } - - console.error("Unhandled error in chat API:", error, { vercelId }); - return new ChatbotError("offline:chat").toResponse(); - } -} - -export async function DELETE(request: Request) { - const { searchParams } = new URL(request.url); - const id = searchParams.get("id"); - - if (!id) { - return new ChatbotError("bad_request:api").toResponse(); - } - - const session = await auth(); - - if (!session?.user) { - return new ChatbotError("unauthorized:chat").toResponse(); - } - - const chat = await getChatById({ id }); - - if (chat?.userId !== session.user.id) { - return new ChatbotError("forbidden:chat").toResponse(); - } - const deletedChat = await deleteChatById({ id }); + writer.write({ + type: "text-delta", + id: textId, + delta, + }); + await sleep(18); + } - return Response.json(deletedChat, { status: 200 }); + if (!request.signal.aborted) { + writer.write({ type: "text-end", id: textId }); + } + }, + onError: (error) => { + console.error("UI-only chat stream error:", error); + return "The local UI-only chat stream failed."; + }, + }); + + return createUIMessageStreamResponse({ stream }); } diff --git a/app/(chat)/api/document/route.ts b/app/(chat)/api/document/route.ts deleted file mode 100644 index ee66e3057f..0000000000 --- a/app/(chat)/api/document/route.ts +++ /dev/null @@ -1,160 +0,0 @@ -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"); - - if (!id) { - return new ChatbotError( - "bad_request:api", - "Parameter id is missing" - ).toResponse(); - } - - const session = await auth(); - - if (!session?.user) { - return new ChatbotError("unauthorized:document").toResponse(); - } - - const documents = await getDocumentsById({ id }); - - const [document] = documents; - - if (!document) { - return new ChatbotError("not_found:document").toResponse(); - } - - if (document.userId !== session.user.id) { - return new ChatbotError("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 ChatbotError( - "bad_request:api", - "Parameter id is required." - ).toResponse(); - } - - const session = await auth(); - - if (!session?.user) { - return new ChatbotError("not_found:document").toResponse(); - } - - 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 }); - - if (documents.length > 0) { - const [doc] = documents; - - if (doc.userId !== session.user.id) { - return new ChatbotError("forbidden:document").toResponse(); - } - } - - if (isManualEdit && documents.length > 0) { - const result = await updateDocumentContent({ id, content }); - return Response.json(result, { status: 200 }); - } - - 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 ChatbotError( - "bad_request:api", - "Parameter id is required." - ).toResponse(); - } - - if (!timestamp) { - return new ChatbotError( - "bad_request:api", - "Parameter timestamp is required." - ).toResponse(); - } - - const session = await auth(); - - if (!session?.user) { - return new ChatbotError("unauthorized:document").toResponse(); - } - - const documents = await getDocumentsById({ id }); - - const [document] = documents; - - if (document.userId !== session.user.id) { - 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: 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 deleted file mode 100644 index b2270331e3..0000000000 --- a/app/(chat)/api/files/upload/route.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { put } from "@vercel/blob"; -import { NextResponse } from "next/server"; -import { z } from "zod"; - -import { auth } from "@/app/(auth)/auth"; - -const FileSchema = z.object({ - file: z - .instanceof(Blob) - .refine((file) => file.size <= 5 * 1024 * 1024, { - message: "File size should be less than 5MB", - }) - .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 }); - } - - 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(`${safeName}`, 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/app/(chat)/api/history/route.ts b/app/(chat)/api/history/route.ts deleted file mode 100644 index 064a385473..0000000000 --- a/app/(chat)/api/history/route.ts +++ /dev/null @@ -1,49 +0,0 @@ -import type { NextRequest } from "next/server"; -import { auth } from "@/app/(auth)/auth"; -import { deleteAllChatsByUserId, getChatsByUserId } from "@/lib/db/queries"; -import { ChatbotError } from "@/lib/errors"; - -export async function GET(request: NextRequest) { - const { searchParams } = request.nextUrl; - - const limit = Math.min( - Math.max(Number.parseInt(searchParams.get("limit") || "10", 10), 1), - 50 - ); - const startingAfter = searchParams.get("starting_after"); - const endingBefore = searchParams.get("ending_before"); - - if (startingAfter && endingBefore) { - return new ChatbotError( - "bad_request:api", - "Only one of starting_after or ending_before can be provided." - ).toResponse(); - } - - const session = await auth(); - - if (!session?.user) { - return new ChatbotError("unauthorized:chat").toResponse(); - } - - const chats = await getChatsByUserId({ - id: session.user.id, - limit, - startingAfter, - endingBefore, - }); - - return Response.json(chats); -} - -export async function DELETE() { - const session = await auth(); - - if (!session?.user) { - return new ChatbotError("unauthorized:chat").toResponse(); - } - - const result = await deleteAllChatsByUserId({ userId: session.user.id }); - - return Response.json(result, { status: 200 }); -} diff --git a/app/(chat)/api/messages/route.ts b/app/(chat)/api/messages/route.ts deleted file mode 100644 index cda98dedb2..0000000000 --- a/app/(chat)/api/messages/route.ts +++ /dev/null @@ -1,43 +0,0 @@ -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 deleted file mode 100644 index de1d12a822..0000000000 --- a/app/(chat)/api/models/route.ts +++ /dev/null @@ -1,20 +0,0 @@ -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 deleted file mode 100644 index 303f45ed26..0000000000 --- a/app/(chat)/api/suggestions/route.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { auth } from "@/app/(auth)/auth"; -import { getSuggestionsByDocumentId } from "@/lib/db/queries"; -import { ChatbotError } from "@/lib/errors"; - -export async function GET(request: Request) { - const { searchParams } = new URL(request.url); - const documentId = searchParams.get("documentId"); - - if (!documentId) { - return new ChatbotError( - "bad_request:api", - "Parameter documentId is required." - ).toResponse(); - } - - const session = await auth(); - - if (!session?.user) { - return new ChatbotError("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 ChatbotError("forbidden:api").toResponse(); - } - - return Response.json(suggestions, { status: 200 }); -} diff --git a/app/(chat)/api/vote/route.ts b/app/(chat)/api/vote/route.ts deleted file mode 100644 index 726ba56465..0000000000 --- a/app/(chat)/api/vote/route.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { z } from "zod"; -import { auth } from "@/app/(auth)/auth"; -import { getChatById, getVotesByChatId, voteMessage } from "@/lib/db/queries"; -import { ChatbotError } from "@/lib/errors"; - -const voteSchema = z.object({ - chatId: z.string(), - messageId: z.string(), - type: z.enum(["up", "down"]), -}); - -export async function GET(request: Request) { - const { searchParams } = new URL(request.url); - const chatId = searchParams.get("chatId"); - - if (!chatId) { - return new ChatbotError( - "bad_request:api", - "Parameter chatId is required." - ).toResponse(); - } - - const session = await auth(); - - if (!session?.user) { - return new ChatbotError("unauthorized:vote").toResponse(); - } - - const chat = await getChatById({ id: chatId }); - - if (!chat) { - return new ChatbotError("not_found:chat").toResponse(); - } - - if (chat.userId !== session.user.id) { - return new ChatbotError("forbidden:vote").toResponse(); - } - - const votes = await getVotesByChatId({ id: chatId }); - - return Response.json(votes, { status: 200 }); -} - -export async function PATCH(request: Request) { - let chatId: string; - let messageId: string; - let type: "up" | "down"; - - try { - const parsed = voteSchema.parse(await request.json()); - chatId = parsed.chatId; - messageId = parsed.messageId; - type = parsed.type; - } catch { - return new ChatbotError( - "bad_request:api", - "Parameters chatId, messageId, and type are required." - ).toResponse(); - } - - const session = await auth(); - - if (!session?.user) { - return new ChatbotError("unauthorized:vote").toResponse(); - } - - const chat = await getChatById({ id: chatId }); - - if (!chat) { - return new ChatbotError("not_found:vote").toResponse(); - } - - if (chat.userId !== session.user.id) { - return new ChatbotError("forbidden:vote").toResponse(); - } - - await voteMessage({ - chatId, - messageId, - type, - }); - - return new Response("Message voted", { status: 200 }); -} diff --git a/artifacts/actions.ts b/artifacts/actions.ts deleted file mode 100644 index 2000ca1110..0000000000 --- a/artifacts/actions.ts +++ /dev/null @@ -1,8 +0,0 @@ -"use server"; - -import { getSuggestionsByDocumentId } from "@/lib/db/queries"; - -export async function getSuggestions({ documentId }: { documentId: string }) { - const suggestions = await getSuggestionsByDocumentId({ documentId }); - return suggestions ?? []; -} diff --git a/artifacts/text/client.tsx b/artifacts/text/client.tsx index a0b056c857..373f9a05c6 100644 --- a/artifacts/text/client.tsx +++ b/artifacts/text/client.tsx @@ -11,8 +11,7 @@ import { UndoIcon, } from "@/components/chat/icons"; import { Editor } from "@/components/chat/text-editor"; -import type { Suggestion } from "@/lib/db/schema"; -import { getSuggestions } from "../actions"; +import type { Suggestion } from "@/lib/chat/types"; type TextArtifactMetadata = { suggestions: Suggestion[]; @@ -22,10 +21,10 @@ export const textArtifact = new Artifact<"text", TextArtifactMetadata>({ kind: "text", description: "Useful for text content, like drafting essays and emails.", initialize: async ({ documentId, setMetadata }) => { - const suggestions = await getSuggestions({ documentId }); + void documentId; setMetadata({ - suggestions, + suggestions: [], }); }, onStreamPart: ({ streamPart, setMetadata, setArtifact }) => { diff --git a/components/chat/app-sidebar.tsx b/components/chat/app-sidebar.tsx index d276842d13..e9f89a50c7 100644 --- a/components/chat/app-sidebar.tsx +++ b/components/chat/app-sidebar.tsx @@ -4,19 +4,11 @@ import { MessageSquareIcon, PanelLeftIcon, PenSquareIcon, - TrashIcon, } from "lucide-react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import type { User } from "next-auth"; -import { useState } from "react"; -import { toast } from "sonner"; -import { useSWRConfig } from "swr"; -import { unstable_serialize } from "swr/infinite"; -import { - getChatHistoryPaginationKey, - SidebarHistory, -} from "@/components/chat/sidebar-history"; +import { SidebarHistory } from "@/components/chat/sidebar-history"; import { SidebarUserNav } from "@/components/chat/sidebar-user-nav"; import { Sidebar, @@ -32,134 +24,73 @@ import { SidebarTrigger, useSidebar, } from "@/components/ui/sidebar"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "../ui/alert-dialog"; import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"; export function AppSidebar({ user }: { user: User | undefined }) { const router = useRouter(); const { setOpenMobile, toggleSidebar } = useSidebar(); - const { mutate } = useSWRConfig(); - const [showDeleteAllDialog, setShowDeleteAllDialog] = useState(false); - - const handleDeleteAll = () => { - setShowDeleteAllDialog(false); - router.replace("/"); - mutate(unstable_serialize(getChatHistoryPaginationKey), [], { - revalidate: false, - }); - - fetch(`${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/history`, { - method: "DELETE", - }); - - toast.success("All chats deleted"); - }; return ( - <> - - - - -
- - setOpenMobile(false)}> - - - - - - toggleSidebar()} - > - - - - - Open sidebar - - -
-
- -
-
-
-
- - - - - + + + + +
+ + setOpenMobile(false)}> + + + + + { - setOpenMobile(false); - router.push("/"); - }} - tooltip="New Chat" + className="pointer-events-none absolute inset-0 size-8 opacity-0 group-data-[collapsible=icon]:pointer-events-auto group-data-[collapsible=icon]:group-hover/logo:opacity-100" + onClick={() => toggleSidebar()} > - - New chat + - - {user && ( - - setShowDeleteAllDialog(true)} - tooltip="Delete All Chats" - > - - Delete all - - - )} - - - - - - - {user && } - - - - - - - - Delete all chats? - - This action cannot be undone. This will permanently delete all - your chats and remove them from our servers. - - - - Cancel - - Delete All - - - - - + + + Open sidebar + + +
+
+ +
+
+
+
+ + + + + + { + setOpenMobile(false); + router.push("/"); + }} + tooltip="New Chat" + > + + New chat + + + + + + + + + {user && } + + +
); } diff --git a/components/chat/artifact-messages.tsx b/components/chat/artifact-messages.tsx index 3cfa972b3d..2352b30c27 100644 --- a/components/chat/artifact-messages.tsx +++ b/components/chat/artifact-messages.tsx @@ -3,7 +3,7 @@ import equal from "fast-deep-equal"; import { AnimatePresence, motion } from "framer-motion"; import { memo } from "react"; import { useMessages } from "@/hooks/use-messages"; -import type { Vote } from "@/lib/db/schema"; +import type { Vote } from "@/lib/chat/types"; import type { ChatMessage } from "@/lib/types"; import type { UIArtifact } from "./artifact"; import { PreviewMessage, ThinkingMessage } from "./message"; diff --git a/components/chat/artifact.tsx b/components/chat/artifact.tsx index e233effcd0..fb3b4fba1b 100644 --- a/components/chat/artifact.tsx +++ b/components/chat/artifact.tsx @@ -1,5 +1,4 @@ import type { UseChatHelpers } from "@ai-sdk/react"; -import { formatDistance } from "date-fns"; import equal from "fast-deep-equal"; import { AnimatePresence, motion } from "framer-motion"; import { @@ -11,16 +10,14 @@ import { useRef, useState, } from "react"; -import useSWR, { useSWRConfig } from "swr"; import { useWindowSize } from "usehooks-ts"; import { codeArtifact } from "@/artifacts/code/client"; import { imageArtifact } from "@/artifacts/image/client"; import { sheetArtifact } from "@/artifacts/sheet/client"; import { textArtifact } from "@/artifacts/text/client"; import { useArtifact } from "@/hooks/use-artifact"; -import type { Document, Vote } from "@/lib/db/schema"; +import type { Vote } from "@/lib/chat/types"; import type { Attachment, ChatMessage } from "@/lib/types"; -import { fetcher } from "@/lib/utils"; import { useSidebar } from "../ui/sidebar"; import { ArtifactActions } from "./artifact-actions"; import { ArtifactCloseButton } from "./artifact-close-button"; @@ -89,20 +86,8 @@ function PureArtifact({ }) { const { artifact, setArtifact, metadata, setMetadata } = useArtifact(); - const { - data: documents, - isLoading: isDocumentsFetching, - mutate: mutateDocuments, - } = useSWR( - artifact.documentId !== "init" && artifact.status !== "streaming" - ? `${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/document?id=${artifact.documentId}` - : null, - fetcher - ); - const [mode, setMode] = useState<"edit" | "diff">("edit"); - const [document, setDocument] = useState(null); - const [currentVersionIndex, setCurrentVersionIndex] = useState(-1); + const [currentVersionIndex, setCurrentVersionIndex] = useState(0); const { state: sidebarState } = useSidebar(); const artifactContentRef = useRef(null); @@ -124,81 +109,6 @@ function PureArtifact({ el.scrollTo({ top: el.scrollHeight }); }, [artifact.status]); - useEffect(() => { - if (documents && documents.length > 0) { - const mostRecentDocument = documents.at(-1); - - if (mostRecentDocument) { - setDocument(mostRecentDocument); - setCurrentVersionIndex(documents.length - 1); - if (artifact.status === "streaming" || !isContentDirty) { - setArtifact((currentArtifact) => ({ - ...currentArtifact, - content: mostRecentDocument.content ?? "", - })); - } - } - } - }, [documents, setArtifact, artifact.status, isContentDirty]); - - useEffect(() => { - mutateDocuments(); - }, [mutateDocuments]); - - const { mutate } = useSWRConfig(); - - const handleContentChange = useCallback( - (updatedContent: string) => { - if (!artifact) { - return; - } - - mutate( - `${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/document?id=${artifact.documentId}`, - async (currentDocuments) => { - if (!currentDocuments) { - return []; - } - - const currentDocument = currentDocuments.at(-1); - - if (!currentDocument?.content) { - setIsContentDirty(false); - return currentDocuments; - } - - if (currentDocument.content === updatedContent) { - setIsContentDirty(false); - return currentDocuments; - } - - await fetch( - `${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/document?id=${artifact.documentId}`, - { - method: "POST", - body: JSON.stringify({ - title: artifact.title, - content: updatedContent, - kind: artifact.kind, - isManualEdit: true, - }), - } - ); - - setIsContentDirty(false); - - return currentDocuments.map((doc, i) => - i === currentDocuments.length - 1 - ? { ...doc, content: updatedContent } - : doc - ); - }, - { revalidate: false } - ); - }, - [artifact, mutate] - ); - const latestContentRef = useRef(""); const saveTimerRef = useRef | null>(null); @@ -212,57 +122,53 @@ function PureArtifact({ saveTimerRef.current = null; } + const commitContent = () => { + setArtifact((currentArtifact) => ({ + ...currentArtifact, + content: latestContentRef.current, + })); + setIsContentDirty(false); + }; + if (debounce) { saveTimerRef.current = setTimeout(() => { - handleContentChange(latestContentRef.current); + commitContent(); saveTimerRef.current = null; }, 2000); } else { - handleContentChange(updatedContent); + commitContent(); } }, - [handleContentChange] + [setArtifact] + ); + + useEffect( + () => () => { + if (saveTimerRef.current) { + clearTimeout(saveTimerRef.current); + } + }, + [] ); function getDocumentContentById(index: number) { - if (!documents) { - return ""; - } - if (!documents[index]) { - return ""; - } - return documents[index].content ?? ""; + return index === 0 ? artifact.content : ""; } const handleVersionChange = (type: "next" | "prev" | "toggle" | "latest") => { - if (!documents) { - return; - } - if (type === "latest") { - setCurrentVersionIndex(documents.length - 1); + setCurrentVersionIndex(0); setMode("edit"); } if (type === "toggle") { setMode((currentMode) => (currentMode === "edit" ? "diff" : "edit")); } - - if (type === "prev") { - if (currentVersionIndex > 0) { - setCurrentVersionIndex((index) => index - 1); - } - } else if (type === "next" && currentVersionIndex < documents.length - 1) { - setCurrentVersionIndex((index) => index + 1); - } }; const [isToolbarVisible, setIsToolbarVisible] = useState(true); - const isCurrentVersion = - documents && documents.length > 0 - ? currentVersionIndex === documents.length - 1 - : true; + const isCurrentVersion = true; const { width: windowWidth, height: windowHeight } = useWindowSize(); const isMobile = windowWidth ? windowWidth < 768 : false; @@ -321,10 +227,6 @@ function PureArtifact({
Saving...
- ) : document ? ( -
- {`Updated ${formatDistance(new Date(document.createdAt), new Date(), { addSuffix: true })}`} -
) : artifact.status === "streaming" ? (
@@ -333,11 +235,8 @@ function PureArtifact({ Generating...
) : ( -
- )} - {documents && documents.length > 1 && ( -
- v{currentVersionIndex + 1}/{documents.length} +
+ Local draft
)}
@@ -369,7 +268,7 @@ function PureArtifact({ getDocumentContentById={getDocumentContentById} isCurrentVersion={isCurrentVersion} isInline={false} - isLoading={isDocumentsFetching && !artifact.content} + isLoading={false} metadata={metadata} mode={mode} onSaveContent={saveContent} @@ -412,7 +311,7 @@ function PureArtifact({ {!isCurrentVersion && ( - - - - {!isReadonly && ( )} - - ); } diff --git a/components/chat/code-editor.tsx b/components/chat/code-editor.tsx index 721b908e26..b5a9a4584a 100644 --- a/components/chat/code-editor.tsx +++ b/components/chat/code-editor.tsx @@ -6,7 +6,7 @@ import { oneDark } from "@codemirror/theme-one-dark"; import { EditorView } from "@codemirror/view"; import { basicSetup } from "codemirror"; import { memo, useEffect, useRef } from "react"; -import type { Suggestion } from "@/lib/db/schema"; +import type { Suggestion } from "@/lib/chat/types"; type EditorProps = { content: string; diff --git a/components/chat/create-artifact.tsx b/components/chat/create-artifact.tsx index 78b24b1030..5f9349e016 100644 --- a/components/chat/create-artifact.tsx +++ b/components/chat/create-artifact.tsx @@ -1,7 +1,7 @@ import type { UseChatHelpers } from "@ai-sdk/react"; import type { DataUIPart } from "ai"; import type { ComponentType, Dispatch, ReactNode, SetStateAction } from "react"; -import type { Suggestion } from "@/lib/db/schema"; +import type { Suggestion } from "@/lib/chat/types"; import type { ChatMessage, CustomUIDataTypes } from "@/lib/types"; import type { UIArtifact } from "./artifact"; diff --git a/components/chat/data-stream-handler.tsx b/components/chat/data-stream-handler.tsx index 239ade6b7c..05ea1505a4 100644 --- a/components/chat/data-stream-handler.tsx +++ b/components/chat/data-stream-handler.tsx @@ -1,16 +1,12 @@ "use client"; import { useEffect } from "react"; -import { useSWRConfig } from "swr"; -import { unstable_serialize } from "swr/infinite"; import { initialArtifactData, useArtifact } from "@/hooks/use-artifact"; import { artifactDefinitions } from "./artifact"; import { useDataStream } from "./data-stream-provider"; -import { getChatHistoryPaginationKey } from "./sidebar-history"; export function DataStreamHandler() { const { dataStream, setDataStream } = useDataStream(); - const { mutate } = useSWRConfig(); const { artifact, setArtifact, setMetadata } = useArtifact(); @@ -24,7 +20,6 @@ export function DataStreamHandler() { for (const delta of newDeltas) { if (delta.type === "data-chat-title") { - mutate(unstable_serialize(getChatHistoryPaginationKey)); continue; } const artifactDefinition = artifactDefinitions.find( @@ -85,7 +80,7 @@ export function DataStreamHandler() { } }); } - }, [dataStream, setArtifact, setMetadata, artifact, setDataStream, mutate]); + }, [dataStream, setArtifact, setMetadata, artifact, setDataStream]); return null; } diff --git a/components/chat/document-preview.tsx b/components/chat/document-preview.tsx index 747aa58eed..cbe9921a5c 100644 --- a/components/chat/document-preview.tsx +++ b/components/chat/document-preview.tsx @@ -9,10 +9,9 @@ import { useMemo, useRef, } from "react"; -import useSWR from "swr"; import { useArtifact } from "@/hooks/use-artifact"; -import type { Document } from "@/lib/db/schema"; -import { cn, fetcher } from "@/lib/utils"; +import type { Document } from "@/lib/chat/types"; +import { cn } from "@/lib/utils"; import type { ArtifactKind, UIArtifact } from "./artifact"; import { CodeEditor } from "./code-editor"; import { InlineDocumentSkeleton } from "./document-skeleton"; @@ -46,17 +45,6 @@ export function DocumentPreview({ args, }: DocumentPreviewProps) { const { artifact, setArtifact } = useArtifact(); - - const { data: documents, isLoading: isDocumentsFetching } = useSWR< - Document[] - >( - result - ? `${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/document?id=${result.id}` - : null, - fetcher - ); - - const previewDocument = useMemo(() => documents?.[0], [documents]); const hitboxRef = useRef(null); useEffect(() => { @@ -75,42 +63,40 @@ export function DocumentPreview({ } }, [artifact.documentId, setArtifact]); - if (isDocumentsFetching) { - const kind = result?.kind ?? args?.kind ?? artifact.kind; - const title = result?.title ?? args?.title ?? artifact.title; + const document = useMemo(() => { + if (result?.id && result.title && result.kind) { + return { + title: result.title, + kind: result.kind, + content: result.content ?? "", + id: result.id, + createdAt: new Date(), + userId: "ui-only", + }; + } - return ( -
- {title ? ( - - ) : ( -
-
-
-
-
-
-
- )} -
- -
-
- ); - } + if (args?.id && args.title && args.kind) { + return { + title: args.title, + kind: args.kind, + content: args.content ?? "", + id: args.id, + createdAt: new Date(), + userId: "ui-only", + }; + } - const document: Document | null = previewDocument - ? previewDocument - : artifact.status === "streaming" + return artifact.status === "streaming" ? { title: artifact.title, kind: artifact.kind, content: artifact.content, id: artifact.documentId, createdAt: new Date(), - userId: "noop", + userId: "ui-only", } : null; + }, [result, args, artifact]); if (!document) { return ; @@ -119,8 +105,8 @@ export function DocumentPreview({ return (
( ); const PureHitboxLayer = ({ + document, hitboxRef, - result, setArtifact, }: { + document: Document; hitboxRef: React.RefObject; - result?: Partial; setArtifact: ( updaterFn: UIArtifact | ((currentArtifact: UIArtifact) => UIArtifact) ) => void; @@ -171,9 +157,11 @@ const PureHitboxLayer = ({ setArtifact((artifact) => ({ ...artifact, - ...(result?.id && { documentId: result.id }), - ...(result?.title && { title: result.title }), - ...(result?.kind && { kind: result.kind }), + documentId: document.id, + title: document.title, + kind: document.kind, + content: document.content ?? "", + status: artifact.status === "streaming" ? "streaming" : "idle", isVisible: true, boundingBox: { left: boundingBox.x, @@ -183,7 +171,7 @@ const PureHitboxLayer = ({ }, })); }, - [setArtifact, result] + [setArtifact, document] ); return ( @@ -204,7 +192,7 @@ const PureHitboxLayer = ({ }; const HitboxLayer = memo(PureHitboxLayer, (prevProps, nextProps) => { - if (!equal(prevProps.result, nextProps.result)) { + if (!equal(prevProps.document, nextProps.document)) { return false; } return true; diff --git a/components/chat/message-actions.tsx b/components/chat/message-actions.tsx index ecb74abc5a..9214c65733 100644 --- a/components/chat/message-actions.tsx +++ b/components/chat/message-actions.tsx @@ -1,20 +1,19 @@ import equal from "fast-deep-equal"; import { memo } from "react"; import { toast } from "sonner"; -import { useSWRConfig } from "swr"; import { useCopyToClipboard } from "usehooks-ts"; -import type { Vote } from "@/lib/db/schema"; +import type { Vote } from "@/lib/chat/types"; import type { ChatMessage } from "@/lib/types"; import { MessageAction as Action, MessageActions as Actions, } from "../ai-elements/message"; -import { CopyIcon, PencilEditIcon, ThumbDownIcon, ThumbUpIcon } from "./icons"; +import { CopyIcon, PencilEditIcon } from "./icons"; export function PureMessageActions({ - chatId, + chatId: _chatId, message, - vote, + vote: _vote, isLoading, onEdit, }: { @@ -24,7 +23,6 @@ export function PureMessageActions({ isLoading: boolean; onEdit?: () => void; }) { - const { mutate } = useSWRConfig(); const [_, copyToClipboard] = useCopyToClipboard(); if (isLoading) { @@ -82,112 +80,6 @@ export function PureMessageActions({ > - - { - const upvote = fetch( - `${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/vote`, - { - method: "PATCH", - body: JSON.stringify({ - chatId, - messageId: message.id, - type: "up", - }), - } - ); - - toast.promise(upvote, { - loading: "Upvoting Response...", - success: () => { - mutate( - `${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/vote?chatId=${chatId}`, - (currentVotes) => { - if (!currentVotes) { - return []; - } - - const votesWithoutCurrent = currentVotes.filter( - (currentVote) => currentVote.messageId !== message.id - ); - - return [ - ...votesWithoutCurrent, - { - chatId, - messageId: message.id, - isUpvoted: true, - }, - ]; - }, - { revalidate: false } - ); - - return "Upvoted Response!"; - }, - error: "Failed to upvote response.", - }); - }} - tooltip="Upvote Response" - > - - - - { - const downvote = fetch( - `${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/vote`, - { - method: "PATCH", - body: JSON.stringify({ - chatId, - messageId: message.id, - type: "down", - }), - } - ); - - toast.promise(downvote, { - loading: "Downvoting Response...", - success: () => { - mutate( - `${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/vote?chatId=${chatId}`, - (currentVotes) => { - if (!currentVotes) { - return []; - } - - const votesWithoutCurrent = currentVotes.filter( - (currentVote) => currentVote.messageId !== message.id - ); - - return [ - ...votesWithoutCurrent, - { - chatId, - messageId: message.id, - isUpvoted: false, - }, - ]; - }, - { revalidate: false } - ); - - return "Downvoted Response!"; - }, - error: "Failed to downvote response.", - }); - }} - tooltip="Downvote Response" - > - - ); } diff --git a/components/chat/message-editor.tsx b/components/chat/message-editor.tsx index a867f79df4..420848691e 100644 --- a/components/chat/message-editor.tsx +++ b/components/chat/message-editor.tsx @@ -1,7 +1,6 @@ "use client"; import type { UseChatHelpers } from "@ai-sdk/react"; -import { deleteTrailingMessages } from "@/app/(chat)/actions"; import type { ChatMessage } from "@/lib/types"; export async function submitEditedMessage({ @@ -15,8 +14,6 @@ export async function submitEditedMessage({ setMessages: UseChatHelpers["setMessages"]; regenerate: UseChatHelpers["regenerate"]; }) { - await deleteTrailingMessages({ id: message.id }); - setMessages((messages) => { const index = messages.findIndex((m) => m.id === message.id); if (index === -1) { diff --git a/components/chat/message.tsx b/components/chat/message.tsx index b08c5cc130..594eed135f 100644 --- a/components/chat/message.tsx +++ b/components/chat/message.tsx @@ -1,6 +1,6 @@ "use client"; import type { UseChatHelpers } from "@ai-sdk/react"; -import type { Vote } from "@/lib/db/schema"; +import type { Vote } from "@/lib/chat/types"; import type { ChatMessage } from "@/lib/types"; import { cn, sanitizeText } from "@/lib/utils"; import { MessageContent, MessageResponse } from "../ai-elements/message"; diff --git a/components/chat/messages.tsx b/components/chat/messages.tsx index c3b6359cb7..7b96d0c472 100644 --- a/components/chat/messages.tsx +++ b/components/chat/messages.tsx @@ -2,7 +2,7 @@ import type { UseChatHelpers } from "@ai-sdk/react"; import { ArrowDownIcon } from "lucide-react"; import { useEffect, useRef } from "react"; import { useMessages } from "@/hooks/use-messages"; -import type { Vote } from "@/lib/db/schema"; +import type { Vote } from "@/lib/chat/types"; import type { ChatMessage } from "@/lib/types"; import { cn } from "@/lib/utils"; import { useDataStream } from "./data-stream-provider"; diff --git a/components/chat/multimodal-input.tsx b/components/chat/multimodal-input.tsx index bb07f7cc42..8e8bee5c25 100644 --- a/components/chat/multimodal-input.tsx +++ b/components/chat/multimodal-input.tsx @@ -23,7 +23,6 @@ import { useState, } from "react"; import { toast } from "sonner"; -import useSWR from "swr"; import { useLocalStorage, useWindowSize } from "usehooks-ts"; import { ModelSelector, @@ -40,6 +39,7 @@ import { type ChatModel, chatModels, DEFAULT_CHAT_MODEL, + getCapabilities, type ModelCapabilities, } from "@/lib/ai/models"; import type { Attachment, ChatMessage } from "@/lib/types"; @@ -180,10 +180,7 @@ function PureMultimodalInput({ action: { label: "Delete", onClick: () => { - fetch( - `${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/chat?id=${chatId}`, - { method: "DELETE" } - ); + setMessages(() => []); router.push("/"); toast.success("Chat deleted"); }, @@ -195,9 +192,7 @@ function PureMultimodalInput({ action: { label: "Delete all", onClick: () => { - fetch(`${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/history`, { - method: "DELETE", - }); + setMessages(() => []); router.push("/"); toast.success("All chats deleted"); }, @@ -210,7 +205,7 @@ function PureMultimodalInput({ }; const fileInputRef = useRef(null); - const [uploadQueue, setUploadQueue] = useState([]); + const [uploadQueue] = useState([]); const [slashOpen, setSlashOpen] = useState(false); const [slashQuery, setSlashQuery] = useState(""); const [slashIndex, setSlashIndex] = useState(0); @@ -256,64 +251,24 @@ function PureMultimodalInput({ chatId, ]); - const uploadFile = useCallback(async (file: File) => { - const formData = new FormData(); - formData.append("file", file); - - try { - const response = await fetch( - `${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/files/upload`, - { - method: "POST", - body: formData, - } - ); - - if (response.ok) { - const data = await response.json(); - const { url, pathname, contentType } = data; - - return { - url, - name: pathname, - contentType, - }; - } - const { error } = await response.json(); - toast.error(error); - } catch (_error) { - toast.error("Failed to upload file, please try again!"); - } + const showUploadsDisabled = useCallback(() => { + toast.error("File uploads are disabled until your backend is connected."); }, []); const handleFileChange = useCallback( - async (event: ChangeEvent) => { + (event: ChangeEvent) => { const files = Array.from(event.target.files || []); - setUploadQueue(files.map((file) => file.name)); - - try { - const uploadPromises = files.map((file) => uploadFile(file)); - const uploadedAttachments = await Promise.all(uploadPromises); - const successfullyUploadedAttachments = uploadedAttachments.filter( - (attachment) => attachment !== undefined - ); - - setAttachments((currentAttachments) => [ - ...currentAttachments, - ...successfullyUploadedAttachments, - ]); - } catch (_error) { - toast.error("Failed to upload files"); - } finally { - setUploadQueue([]); + if (files.length > 0) { + showUploadsDisabled(); + event.target.value = ""; } }, - [setAttachments, uploadFile] + [showUploadsDisabled] ); const handlePaste = useCallback( - async (event: ClipboardEvent) => { + (event: ClipboardEvent) => { const items = event.clipboardData?.items; if (!items) { return; @@ -328,34 +283,9 @@ function PureMultimodalInput({ } event.preventDefault(); - - setUploadQueue((prev) => [...prev, "Pasted image"]); - - try { - const uploadPromises = imageItems - .map((item) => item.getAsFile()) - .filter((file): file is File => file !== null) - .map((file) => uploadFile(file)); - - const uploadedAttachments = await Promise.all(uploadPromises); - const successfullyUploadedAttachments = uploadedAttachments.filter( - (attachment) => - attachment !== undefined && - attachment.url !== undefined && - attachment.contentType !== undefined - ); - - setAttachments((curr) => [ - ...curr, - ...(successfullyUploadedAttachments as Attachment[]), - ]); - } catch (_error) { - toast.error("Failed to upload pasted image(s)"); - } finally { - setUploadQueue([]); - } + showUploadsDisabled(); }, - [setAttachments, uploadFile] + [showUploadsDisabled] ); useEffect(() => { @@ -404,6 +334,7 @@ function PureMultimodalInput({ onChange={handleFileChange} ref={fileInputRef} tabIndex={-1} + accept="image/png,image/jpeg" type="file" /> @@ -593,14 +524,7 @@ function PureAttachmentsButton({ status: UseChatHelpers["status"]; selectedModelId: string; }) { - const { data: modelsResponse } = useSWR( - `${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/models`, - (url: string) => fetch(url).then((r) => r.json()), - { revalidateOnFocus: false, dedupingInterval: 3_600_000 } - ); - - const caps: Record | undefined = - modelsResponse?.capabilities ?? modelsResponse; + const caps: Record = getCapabilities(); const hasVision = caps?.[selectedModelId]?.vision ?? false; return ( @@ -634,16 +558,9 @@ function PureModelSelectorCompact({ onModelChange?: (modelId: string) => void; }) { const [open, setOpen] = useState(false); - const { data: modelsData } = useSWR( - `${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/models`, - (url: string) => fetch(url).then((r) => r.json()), - { revalidateOnFocus: false, dedupingInterval: 3_600_000 } - ); - const capabilities: Record | undefined = - modelsData?.capabilities ?? modelsData; - const dynamicModels: ChatModel[] | undefined = modelsData?.models; - const activeModels = dynamicModels ?? chatModels; + const capabilities: Record = getCapabilities(); + const activeModels = chatModels; const selectedModel = activeModels.find((m: ChatModel) => m.id === selectedModelId) ?? @@ -668,12 +585,7 @@ function PureModelSelectorCompact({ {(() => { const curatedIds = new Set(chatModels.map((m) => m.id)); - const allModels = dynamicModels - ? [ - ...chatModels, - ...dynamicModels.filter((m) => !curatedIds.has(m.id)), - ] - : chatModels; + const allModels = chatModels; const grouped: Record< string, diff --git a/components/chat/shell.tsx b/components/chat/shell.tsx index 9327206e82..b071864264 100644 --- a/components/chat/shell.tsx +++ b/components/chat/shell.tsx @@ -1,16 +1,6 @@ "use client"; import { useEffect, useRef, useState } from "react"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "@/components/ui/alert-dialog"; import { useActiveChat } from "@/hooks/use-active-chat"; import { initialArtifactData, @@ -44,8 +34,6 @@ export function ChatShell() { votes, currentModelId, setCurrentModelId, - showCreditCardAlert, - setShowCreditCardAlert, } = useActiveChat(); const [editingMessage, setEditingMessage] = useState( @@ -170,36 +158,6 @@ export function ChatShell() {
- - - - - Activate AI Gateway - - This application requires{" "} - {process.env.NODE_ENV === "production" ? "the owner" : "you"} to - activate Vercel AI Gateway. - - - - Cancel - { - window.open( - "https://vercel.com/d?to=%2F%5Bteam%5D%2F%7E%2Fai%3Fmodal%3Dadd-credit-card", - "_blank" - ); - window.location.href = `${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/`; - }} - > - Activate - - - - ); } diff --git a/components/chat/sidebar-history-item.tsx b/components/chat/sidebar-history-item.tsx index 1940e5826b..29d0391c76 100644 --- a/components/chat/sidebar-history-item.tsx +++ b/components/chat/sidebar-history-item.tsx @@ -1,7 +1,7 @@ import Link from "next/link"; import { memo } from "react"; import { useChatVisibility } from "@/hooks/use-chat-visibility"; -import type { Chat } from "@/lib/db/schema"; +import type { Chat } from "@/lib/chat/types"; import { DropdownMenu, DropdownMenuContent, diff --git a/components/chat/sidebar-history.tsx b/components/chat/sidebar-history.tsx index fa56db15d5..bde2192cf9 100644 --- a/components/chat/sidebar-history.tsx +++ b/components/chat/sidebar-history.tsx @@ -1,373 +1,31 @@ "use client"; -import { isToday, isYesterday, subMonths, subWeeks } from "date-fns"; -import { motion } from "framer-motion"; -import { usePathname, useRouter } from "next/navigation"; import type { User } from "next-auth"; -import { useState } from "react"; -import { toast } from "sonner"; -import useSWRInfinite from "swr/infinite"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "@/components/ui/alert-dialog"; import { SidebarGroup, SidebarGroupContent, SidebarGroupLabel, - SidebarMenu, - useSidebar, } from "@/components/ui/sidebar"; -import type { Chat } from "@/lib/db/schema"; -import { fetcher } from "@/lib/utils"; -import { LoaderIcon } from "./icons"; -import { ChatItem } from "./sidebar-history-item"; - -type GroupedChats = { - today: Chat[]; - yesterday: Chat[]; - lastWeek: Chat[]; - lastMonth: Chat[]; - older: Chat[]; -}; +import type { Chat } from "@/lib/chat/types"; export type ChatHistory = { chats: Chat[]; hasMore: boolean; }; -const PAGE_SIZE = 20; - -const groupChatsByDate = (chats: Chat[]): GroupedChats => { - const now = new Date(); - const oneWeekAgo = subWeeks(now, 1); - const oneMonthAgo = subMonths(now, 1); - - return chats.reduce( - (groups, chat) => { - const chatDate = new Date(chat.createdAt); - - if (isToday(chatDate)) { - groups.today.push(chat); - } else if (isYesterday(chatDate)) { - groups.yesterday.push(chat); - } else if (chatDate > oneWeekAgo) { - groups.lastWeek.push(chat); - } else if (chatDate > oneMonthAgo) { - groups.lastMonth.push(chat); - } else { - groups.older.push(chat); - } - - return groups; - }, - { - today: [], - yesterday: [], - lastWeek: [], - lastMonth: [], - older: [], - } as GroupedChats - ); -}; - -export function getChatHistoryPaginationKey( - pageIndex: number, - previousPageData: ChatHistory -) { - if (previousPageData && previousPageData.hasMore === false) { - return null; - } - - if (pageIndex === 0) { - return `${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/history?limit=${PAGE_SIZE}`; - } - - const firstChatFromPage = previousPageData.chats.at(-1); - - if (!firstChatFromPage) { - return null; - } - - return `${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/history?ending_before=${firstChatFromPage.id}&limit=${PAGE_SIZE}`; -} - export function SidebarHistory({ user }: { user: User | undefined }) { - const { setOpenMobile } = useSidebar(); - const pathname = usePathname(); - const id = pathname?.startsWith("/chat/") ? pathname.split("/")[2] : null; - - const { - data: paginatedChatHistories, - setSize, - isValidating, - isLoading, - mutate, - } = useSWRInfinite( - user ? getChatHistoryPaginationKey : () => null, - fetcher, - { fallbackData: [], revalidateOnFocus: false } - ); - - const router = useRouter(); - const [deleteId, setDeleteId] = useState(null); - const [showDeleteDialog, setShowDeleteDialog] = useState(false); - - const hasReachedEnd = paginatedChatHistories - ? paginatedChatHistories.some((page) => page.hasMore === false) - : false; - - const hasEmptyChatHistory = paginatedChatHistories - ? paginatedChatHistories.every((page) => page.chats.length === 0) - : false; - - const handleDelete = () => { - const chatToDelete = deleteId; - const isCurrentChat = pathname === `/chat/${chatToDelete}`; - - setShowDeleteDialog(false); - - if (isCurrentChat) { - router.replace("/"); - } - - mutate((chatHistories) => { - if (chatHistories) { - return chatHistories.map((chatHistory) => ({ - ...chatHistory, - chats: chatHistory.chats.filter((chat) => chat.id !== chatToDelete), - })); - } - }); - - fetch( - `${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/chat?id=${chatToDelete}`, - { method: "DELETE" } - ); - - toast.success("Chat deleted"); - }; - - if (!user) { - return ( - - -
- Login to save and revisit previous chats! -
-
-
- ); - } - - if (isLoading) { - return ( - - - History - - -
- {[44, 32, 28, 64, 52].map((item) => ( -
-
-
- ))} -
- - - ); - } - - if (hasEmptyChatHistory) { - return ( - - - History - - -
- Your conversations will appear here once you start chatting! -
-
-
- ); - } - return ( - <> - - - History - - - - {paginatedChatHistories && - (() => { - const chatsFromHistory = paginatedChatHistories.flatMap( - (paginatedChatHistory) => paginatedChatHistory.chats - ); - - const groupedChats = groupChatsByDate(chatsFromHistory); - - return ( -
- {groupedChats.today.length > 0 && ( -
-
- Today -
- {groupedChats.today.map((chat) => ( - { - setDeleteId(chatId); - setShowDeleteDialog(true); - }} - setOpenMobile={setOpenMobile} - /> - ))} -
- )} - - {groupedChats.yesterday.length > 0 && ( -
-
- Yesterday -
- {groupedChats.yesterday.map((chat) => ( - { - setDeleteId(chatId); - setShowDeleteDialog(true); - }} - setOpenMobile={setOpenMobile} - /> - ))} -
- )} - - {groupedChats.lastWeek.length > 0 && ( -
-
- Last 7 days -
- {groupedChats.lastWeek.map((chat) => ( - { - setDeleteId(chatId); - setShowDeleteDialog(true); - }} - setOpenMobile={setOpenMobile} - /> - ))} -
- )} - - {groupedChats.lastMonth.length > 0 && ( -
-
- Last 30 days -
- {groupedChats.lastMonth.map((chat) => ( - { - setDeleteId(chatId); - setShowDeleteDialog(true); - }} - setOpenMobile={setOpenMobile} - /> - ))} -
- )} - - {groupedChats.older.length > 0 && ( -
-
- Older -
- {groupedChats.older.map((chat) => ( - { - setDeleteId(chatId); - setShowDeleteDialog(true); - }} - setOpenMobile={setOpenMobile} - /> - ))} -
- )} -
- ); - })()} -
- - { - if (!isValidating && !hasReachedEnd) { - setSize((size) => size + 1); - } - }} - /> - - {hasReachedEnd ? null : ( -
-
- -
-
Loading...
-
- )} -
-
- - - - - Are you absolutely sure? - - This action cannot be undone. This will permanently delete your - chat and remove it from our servers. - - - - Cancel - - Continue - - - - - + + + History + + +
+ {user + ? "History is disabled until your backend is connected." + : "Sign in to use your connected backend history later."} +
+
+
); } diff --git a/components/chat/sidebar-user-nav.tsx b/components/chat/sidebar-user-nav.tsx index 2a1e83dcad..b572459a13 100644 --- a/components/chat/sidebar-user-nav.tsx +++ b/components/chat/sidebar-user-nav.tsx @@ -1,7 +1,6 @@ "use client"; import { ChevronUp } from "lucide-react"; -import { useRouter } from "next/navigation"; import type { User } from "next-auth"; import { signOut, useSession } from "next-auth/react"; import { useTheme } from "next-themes"; @@ -17,7 +16,6 @@ import { SidebarMenuButton, SidebarMenuItem, } from "@/components/ui/sidebar"; -import { guestRegex } from "@/lib/constants"; import { LoaderIcon } from "./icons"; import { toast } from "./toast"; @@ -30,12 +28,9 @@ function emailToHue(email: string): number { } export function SidebarUserNav({ user }: { user: User }) { - const router = useRouter(); const { data, status } = useSession(); const { setTheme, resolvedTheme } = useTheme(); - const isGuest = guestRegex.test(data?.user?.email ?? ""); - return ( @@ -65,7 +60,7 @@ export function SidebarUserNav({ user }: { user: User }) { }} /> - {isGuest ? "Guest" : user?.email} + {data?.user?.email ?? user?.email} @@ -100,17 +95,13 @@ export function SidebarUserNav({ user }: { user: User }) { return; } - if (isGuest) { - router.push("/login"); - } else { - signOut({ - redirectTo: "/", - }); - } + signOut({ + redirectTo: "/", + }); }} type="button" > - {isGuest ? "Login to your account" : "Sign out"} + Sign out diff --git a/components/chat/sign-out-form.tsx b/components/chat/sign-out-form.tsx deleted file mode 100644 index b56ea41c29..0000000000 --- a/components/chat/sign-out-form.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import Form from "next/form"; - -import { signOut } from "@/app/(auth)/auth"; - -export const SignOutForm = () => { - return ( -
{ - "use server"; - - await signOut({ - redirectTo: "/", - }); - }} - className="w-full" - > - -
- ); -}; diff --git a/components/chat/text-editor.tsx b/components/chat/text-editor.tsx index b2af5991b1..bb1af5cf6d 100644 --- a/components/chat/text-editor.tsx +++ b/components/chat/text-editor.tsx @@ -7,7 +7,7 @@ import { type Decoration, DecorationSet, EditorView } from "prosemirror-view"; import { memo, useCallback, useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; -import type { Suggestion } from "@/lib/db/schema"; +import type { Suggestion } from "@/lib/chat/types"; import { documentSchema, handleTransaction, diff --git a/components/chat/version-footer.tsx b/components/chat/version-footer.tsx index 9da560c99b..067757cce0 100644 --- a/components/chat/version-footer.tsx +++ b/components/chat/version-footer.tsx @@ -1,15 +1,10 @@ "use client"; -import { isAfter } from "date-fns"; import { motion } from "framer-motion"; import { ChevronLeftIcon, ChevronRightIcon, DiffIcon } from "lucide-react"; import type { Dispatch, SetStateAction } from "react"; -import { useState } from "react"; -import { useSWRConfig } from "swr"; -import { useArtifact } from "@/hooks/use-artifact"; -import type { Document } from "@/lib/db/schema"; -import { cn, getDocumentTimestampByIndex } from "@/lib/utils"; -import { LoaderIcon } from "./icons"; +import type { Document } from "@/lib/chat/types"; +import { cn } from "@/lib/utils"; type VersionFooterProps = { handleVersionChange: (type: "next" | "prev" | "toggle" | "latest") => void; @@ -26,11 +21,6 @@ export const VersionFooter = ({ mode, setMode, }: VersionFooterProps) => { - const { artifact } = useArtifact(); - - const { mutate } = useSWRConfig(); - const [isMutating, setIsMutating] = useState(false); - if (!documents) { return; } @@ -85,52 +75,13 @@ export const VersionFooter = ({