commit 3e21c2334cd053f5d0e497e686d3ce34be9a6e00 Author: dmitry.galkin Date: Mon May 25 14:54:04 2026 +0400 Initial commit: EGBE chatbot template Stripped from vercel/chatbot (Apache 2.0): - Dropped @vercel/* packages and AI Gateway - Removed artifacts feature (code/text/sheet/image side panel) - Switched AI provider to @ai-sdk/openai-compatible -> EGBE LiteLLM - Replaced Vercel Blob upload with data URLs - Dropped Redis resumable streams and rate limiter (in-memory now) - Added Dockerfile (Next.js standalone) + entrypoint that runs migrations - Wired DATABASE_URL, EGBE_AI_API_URL/KEY, NEXT_PUBLIC_BASE_URL for app-deploy.sh diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..3cabaa5 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,15 @@ +Dockerfile +.dockerignore +.git +.gitignore +.next +node_modules +npm-debug.log +README.md +.env* +!.env.example +tests +playwright-report +test-results +.vscode +.idea diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..73adfe5 --- /dev/null +++ b/.env.example @@ -0,0 +1,12 @@ +# NextAuth secret — generate with: openssl rand -base64 32 +AUTH_SECRET=replace-me + +# Postgres connection string (provisioned automatically by app-deploy.sh) +DATABASE_URL=postgres://user:pass@host:5432/db + +# EGBE LiteLLM proxy — auto-injected by app-deploy.sh from the deploying instance +EGBE_AI_API_URL=https://proxy.gcp.egbe.dev/v1 +EGBE_AI_API_KEY= + +# Public origin of this app — auto-injected by app-deploy.sh +NEXT_PUBLIC_BASE_URL=http://localhost:3000 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..344b787 --- /dev/null +++ b/.gitignore @@ -0,0 +1,27 @@ +node_modules +.pnp +.pnp.js +coverage +.next/ +out/ +build +.DS_Store +*.pem +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* +.env.local +.env.development.local +.env.test.local +.env.production.local +.turbo +.env +.vercel +.env*.local +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/* +next-env.d.ts +tsconfig.tsbuildinfo diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..699ed73 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["biomejs.biome"] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..9b52f23 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,53 @@ +{ + "editor.defaultFormatter": "esbenp.prettier-vscode", + "[javascript]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[typescript]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[javascriptreact]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[typescriptreact]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[json]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[jsonc]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[css]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[graphql]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "typescript.tsdk": "node_modules/typescript/lib", + "editor.formatOnSave": true, + "editor.formatOnPaste": true, + "emmet.showExpandedAbbreviation": "never", + "editor.codeActionsOnSave": { + "source.fixAll.biome": "explicit", + "source.organizeImports.biome": "explicit" + }, + "[html]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[vue]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[svelte]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[yaml]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[markdown]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[mdx]": { + "editor.defaultFormatter": "biomejs.biome" + } +} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1633b84 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,46 @@ +FROM node:20-alpine AS base + +# --- deps --- +FROM base AS deps +RUN apk add --no-cache libc6-compat +WORKDIR /app +RUN corepack enable +COPY package.json pnpm-lock.yaml* ./ +RUN pnpm install --frozen-lockfile + +# --- builder --- +FROM base AS builder +WORKDIR /app +RUN corepack enable +COPY --from=deps /app/node_modules ./node_modules +COPY . . +ENV NEXT_TELEMETRY_DISABLED=1 +RUN pnpm run build + +# --- runner --- +FROM base AS runner +WORKDIR /app + +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 +ENV HOSTNAME=0.0.0.0 +ENV PORT=3000 + +RUN addgroup --system --gid 1001 nodejs \ + && adduser --system --uid 1001 nextjs + +COPY --from=builder /app/public ./public +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static +COPY --from=builder --chown=nextjs:nodejs /app/lib/db/migrations ./lib/db/migrations +COPY --from=builder --chown=nextjs:nodejs /app/lib/db/migrate.ts ./lib/db/migrate.ts +COPY --from=builder --chown=nextjs:nodejs /app/node_modules/drizzle-orm ./node_modules/drizzle-orm +COPY --from=builder --chown=nextjs:nodejs /app/node_modules/postgres ./node_modules/postgres +COPY --from=builder --chown=nextjs:nodejs /app/node_modules/dotenv ./node_modules/dotenv +COPY --chown=nextjs:nodejs docker-entrypoint.sh ./docker-entrypoint.sh +RUN chmod +x ./docker-entrypoint.sh + +USER nextjs +EXPOSE 3000 +ENTRYPOINT ["./docker-entrypoint.sh"] +CMD ["node", "server.js"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..695ee2d --- /dev/null +++ b/LICENSE @@ -0,0 +1,13 @@ +Copyright 2024 Vercel, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..f164e63 --- /dev/null +++ b/README.md @@ -0,0 +1,57 @@ +# EGBE Chatbot Template + +Next.js chatbot wired to the EGBE LiteLLM proxy. Based on [vercel/chatbot](https://github.com/vercel/chatbot) (Apache 2.0), stripped of Vercel-specific bits and rewired for our infra. + +## Stack + +- **Next.js 16** (App Router, standalone output) +- **AI SDK** + `@ai-sdk/openai-compatible` → routes to `EGBE_AI_API_URL` (our LiteLLM) +- **NextAuth** (credentials + guest) +- **Drizzle ORM** + Postgres (DB gateway) +- **shadcn/ui** + Tailwind + +## Available models (via LiteLLM) + +`claude-sonnet`, `claude-haiku`, `gpt-5.4`, `gpt-5.4-mini`, `kimi-k2.6`, `minimax-m2.7`, `qwen3-coder`. + +Edit `lib/ai/models.ts` to add or remove. + +## Deploy (from inside an OpenClaw instance) + +```bash +${SKILLS_DIR}/scripts/app-deploy.sh chatbot-template +``` + +`app-deploy.sh` auto-injects: +- `DATABASE_URL` (per-app Postgres) +- `EGBE_AI_API_URL`, `EGBE_AI_API_KEY` (instance LiteLLM key) +- `NEXT_PUBLIC_BASE_URL` (the deployed `*.p.egbe.app` host) + +You also need `AUTH_SECRET` — set it via `APP_ENV_JSON`: + +```bash +APP_ENV_JSON='{"AUTH_SECRET":"'$(openssl rand -base64 32)'"}' \ + ${SKILLS_DIR}/scripts/app-deploy.sh mychat chatbot-template +``` + +## Local dev + +```bash +cp .env.example .env.local +# fill in DATABASE_URL, EGBE_AI_API_KEY, AUTH_SECRET +pnpm install +pnpm db:migrate +pnpm dev +``` + +## Tracking upstream + +Pull new vercel/chatbot features manually — `origin/upstream` is not configured by default here. Add it back with: + +```bash +git remote add upstream https://github.com/vercel/chatbot.git +``` + +## License + +Apache 2.0 — see `LICENSE`. Derivative work of Vercel, Inc.'s [chatbot](https://github.com/vercel/chatbot). diff --git a/app/(auth)/actions.ts b/app/(auth)/actions.ts new file mode 100644 index 0000000..024ff51 --- /dev/null +++ b/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/app/(auth)/api/auth/[...nextauth]/route.ts b/app/(auth)/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..d104b65 --- /dev/null +++ b/app/(auth)/api/auth/[...nextauth]/route.ts @@ -0,0 +1 @@ +export { GET, POST } from "@/app/(auth)/auth"; diff --git a/app/(auth)/api/auth/guest/route.ts b/app/(auth)/api/auth/guest/route.ts new file mode 100644 index 0000000..97ce3d2 --- /dev/null +++ b/app/(auth)/api/auth/guest/route.ts @@ -0,0 +1,26 @@ +import { NextResponse } from "next/server"; +import { getToken } from "next-auth/jwt"; +import { signIn } from "@/app/(auth)/auth"; +import { isDevelopmentEnvironment } from "@/lib/constants"; + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + const rawRedirect = searchParams.get("redirectUrl") || "/"; + const redirectUrl = + rawRedirect.startsWith("/") && !rawRedirect.startsWith("//") + ? rawRedirect + : "/"; + + const token = await getToken({ + req: request, + secret: process.env.AUTH_SECRET, + secureCookie: !isDevelopmentEnvironment, + }); + + if (token) { + const base = process.env.NEXT_PUBLIC_BASE_PATH ?? ""; + return NextResponse.redirect(new URL(`${base}/`, request.url)); + } + + return signIn("guest", { redirect: true, redirectTo: redirectUrl }); +} diff --git a/app/(auth)/auth.config.ts b/app/(auth)/auth.config.ts new file mode 100644 index 0000000..5056101 --- /dev/null +++ b/app/(auth)/auth.config.ts @@ -0,0 +1,14 @@ +import type { NextAuthConfig } from "next-auth"; + +const base = process.env.NEXT_PUBLIC_BASE_PATH ?? ""; + +export const authConfig = { + basePath: "/api/auth", + trustHost: true, + pages: { + signIn: `${base}/login`, + newUser: `${base}/`, + }, + providers: [], + callbacks: {}, +} satisfies NextAuthConfig; diff --git a/app/(auth)/auth.ts b/app/(auth)/auth.ts new file mode 100644 index 0000000..b75369f --- /dev/null +++ b/app/(auth)/auth.ts @@ -0,0 +1,99 @@ +import { compare } from "bcrypt-ts"; +import NextAuth, { type DefaultSession } from "next-auth"; +import type { DefaultJWT } from "next-auth/jwt"; +import Credentials from "next-auth/providers/credentials"; +import { DUMMY_PASSWORD } from "@/lib/constants"; +import { createGuestUser, getUser } from "@/lib/db/queries"; +import { authConfig } from "./auth.config"; + +export type UserType = "guest" | "regular"; + +declare module "next-auth" { + interface Session extends DefaultSession { + user: { + id: string; + type: UserType; + } & DefaultSession["user"]; + } + + interface User { + id?: string; + email?: string | null; + type: UserType; + } +} + +declare module "next-auth/jwt" { + interface JWT extends DefaultJWT { + id: string; + type: UserType; + } +} + +export const { + handlers: { GET, POST }, + auth, + signIn, + signOut, +} = NextAuth({ + ...authConfig, + providers: [ + Credentials({ + credentials: { + email: { label: "Email", type: "email" }, + password: { label: "Password", type: "password" }, + }, + async authorize(credentials) { + const email = String(credentials.email ?? ""); + const password = String(credentials.password ?? ""); + const users = await getUser(email); + + if (users.length === 0) { + await compare(password, DUMMY_PASSWORD); + return null; + } + + const [user] = users; + + if (!user.password) { + await compare(password, DUMMY_PASSWORD); + return null; + } + + const passwordsMatch = await compare(password, user.password); + + if (!passwordsMatch) { + return null; + } + + return { ...user, type: "regular" }; + }, + }), + Credentials({ + id: "guest", + credentials: {}, + async authorize() { + const [guestUser] = await createGuestUser(); + return { ...guestUser, type: "guest" }; + }, + }), + ], + callbacks: { + jwt({ token, user }) { + if (user) { + token.id = user.id as string; + token.type = user.type; + } + + return token; + }, + session({ session, token }) { + if (session.user) { + session.user.id = token.id; + session.user.type = token.type; + } + + return session; + }, + }, +}); diff --git a/app/(auth)/layout.tsx b/app/(auth)/layout.tsx new file mode 100644 index 0000000..d14e92c --- /dev/null +++ b/app/(auth)/layout.tsx @@ -0,0 +1,43 @@ +import { ArrowLeftIcon } from "lucide-react"; +import Link from "next/link"; +import { SparklesIcon, VercelIcon } from "@/components/chat/icons"; +import { Preview } from "@/components/chat/preview"; + +export default function AuthLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( +
+
+ + + Back + +
+
+
+ +
+ {children} +
+
+
+ +
+
+ Powered by + + AI Gateway +
+
+ +
+
+
+ ); +} diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx new file mode 100644 index 0000000..ea4c602 --- /dev/null +++ b/app/(auth)/login/page.tsx @@ -0,0 +1,66 @@ +"use client"; + +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useSession } from "next-auth/react"; +import { useActionState, useEffect, useState } from "react"; + +import { AuthForm } from "@/components/chat/auth-form"; +import { SubmitButton } from "@/components/chat/submit-button"; +import { toast } from "@/components/chat/toast"; +import { type LoginActionState, login } from "../actions"; + +export default function Page() { + const router = useRouter(); + const [email, setEmail] = useState(""); + const [isSuccessful, setIsSuccessful] = useState(false); + + const [state, formAction] = useActionState( + 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 + +

+
+ + ); +} diff --git a/app/(auth)/register/page.tsx b/app/(auth)/register/page.tsx new file mode 100644 index 0000000..f2abbc8 --- /dev/null +++ b/app/(auth)/register/page.tsx @@ -0,0 +1,66 @@ +"use client"; + +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useSession } from "next-auth/react"; +import { useActionState, useEffect, useState } from "react"; +import { AuthForm } from "@/components/chat/auth-form"; +import { SubmitButton } from "@/components/chat/submit-button"; +import { toast } from "@/components/chat/toast"; +import { type RegisterActionState, register } from "../actions"; + +export default function Page() { + const router = useRouter(); + const [email, setEmail] = useState(""); + const [isSuccessful, setIsSuccessful] = useState(false); + + const [state, formAction] = useActionState( + 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 + +

+
+ + ); +} diff --git a/app/(chat)/actions.ts b/app/(chat)/actions.ts new file mode 100644 index 0000000..83e287c --- /dev/null +++ b/app/(chat)/actions.ts @@ -0,0 +1,78 @@ +"use server"; + +import { generateText, type UIMessage } from "ai"; +import { cookies } from "next/headers"; +import { auth } from "@/app/(auth)/auth"; +import type { VisibilityType } from "@/components/chat/visibility-selector"; +import { titlePrompt } from "@/lib/ai/prompts"; +import { getTitleModel } from "@/lib/ai/providers"; +import { + deleteMessagesByChatIdAfterTimestamp, + getChatById, + getMessageById, + updateChatVisibilityById, +} from "@/lib/db/queries"; +import { getTextFromMessage } from "@/lib/utils"; + +export async function saveChatModelAsCookie(model: string) { + const cookieStore = await cookies(); + cookieStore.set("chat-model", model); +} + +export async function generateTitleFromUserMessage({ + message, +}: { + message: UIMessage; +}) { + const { text } = await generateText({ + model: getTitleModel(), + system: titlePrompt, + prompt: getTextFromMessage(message), + }); + return text + .replace(/^[#*"\s]+/, "") + .replace(/["]+$/, "") + .trim(); +} + +export async function deleteTrailingMessages({ id }: { id: string }) { + const session = await auth(); + if (!session?.user?.id) { + throw new Error("Unauthorized"); + } + + const [message] = await getMessageById({ id }); + if (!message) { + throw new Error("Message not found"); + } + + const chat = await getChatById({ id: message.chatId }); + if (!chat || chat.userId !== session.user.id) { + throw new Error("Unauthorized"); + } + + await deleteMessagesByChatIdAfterTimestamp({ + chatId: message.chatId, + timestamp: message.createdAt, + }); +} + +export async function updateChatVisibility({ + chatId, + visibility, +}: { + chatId: string; + visibility: VisibilityType; +}) { + const session = await auth(); + if (!session?.user?.id) { + throw new Error("Unauthorized"); + } + + const chat = await getChatById({ id: chatId }); + if (!chat || chat.userId !== session.user.id) { + throw new Error("Unauthorized"); + } + + await updateChatVisibilityById({ chatId, visibility }); +} diff --git a/app/(chat)/api/chat/[id]/stream/route.ts b/app/(chat)/api/chat/[id]/stream/route.ts new file mode 100644 index 0000000..3713ec5 --- /dev/null +++ b/app/(chat)/api/chat/[id]/stream/route.ts @@ -0,0 +1,3 @@ +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 new file mode 100644 index 0000000..04230f3 --- /dev/null +++ b/app/(chat)/api/chat/route.ts @@ -0,0 +1,272 @@ +import { + convertToModelMessages, + createUIMessageStream, + createUIMessageStreamResponse, + stepCountIs, + streamText, +} from "ai"; +import { auth, type UserType } from "@/app/(auth)/auth"; +import { entitlementsByUserType } from "@/lib/ai/entitlements"; +import { + allowedModelIds, + DEFAULT_CHAT_MODEL, + modelCapabilities, +} from "@/lib/ai/models"; +import { type RequestHints, systemPrompt } from "@/lib/ai/prompts"; +import { getLanguageModel } from "@/lib/ai/providers"; +import { getWeather } from "@/lib/ai/tools/get-weather"; +import { + deleteChatById, + getChatById, + getMessageCountByUserId, + getMessagesByChatId, + saveChat, + saveMessages, + updateChatTitleById, + updateMessage, +} from "@/lib/db/queries"; +import type { DBMessage } from "@/lib/db/schema"; +import { ChatbotError } from "@/lib/errors"; +import { checkIpRateLimit } from "@/lib/ratelimit"; +import type { ChatMessage } from "@/lib/types"; +import { convertToUIMessages, generateUUID } from "@/lib/utils"; +import { generateTitleFromUserMessage } from "../../actions"; +import { type PostRequestBody, postRequestBodySchema } from "./schema"; + +export const maxDuration = 60; + +function getClientIp(request: Request): string | undefined { + const fwd = request.headers.get("x-forwarded-for"); + if (fwd) { + return fwd.split(",")[0]?.trim(); + } + return request.headers.get("x-real-ip") ?? undefined; +} + +export async function POST(request: Request) { + let requestBody: PostRequestBody; + + try { + const json = await request.json(); + requestBody = postRequestBodySchema.parse(json); + } catch (_) { + return new ChatbotError("bad_request:api").toResponse(); + } + + try { + const { id, message, messages, selectedChatModel, selectedVisibilityType } = + requestBody; + + const session = await auth(); + + if (!session?.user) { + return new ChatbotError("unauthorized:chat").toResponse(); + } + + const chatModel = allowedModelIds.has(selectedChatModel) + ? selectedChatModel + : DEFAULT_CHAT_MODEL; + + await checkIpRateLimit(getClientIp(request)); + + const userType: UserType = session.user.type; + + const messageCount = await getMessageCountByUserId({ + id: session.user.id, + differenceInHours: 1, + }); + + if (messageCount > entitlementsByUserType[userType].maxMessagesPerHour) { + return new ChatbotError("rate_limit:chat").toResponse(); + } + + const isToolApprovalFlow = Boolean(messages); + + const chat = await getChatById({ id }); + let messagesFromDb: DBMessage[] = []; + let titlePromise: Promise | null = null; + + if (chat) { + if (chat.userId !== session.user.id) { + return new ChatbotError("forbidden:chat").toResponse(); + } + messagesFromDb = await getMessagesByChatId({ id }); + } else if (message?.role === "user") { + await saveChat({ + id, + userId: session.user.id, + title: "New chat", + visibility: selectedVisibilityType, + }); + titlePromise = generateTitleFromUserMessage({ message }); + } + + let uiMessages: ChatMessage[]; + + if (isToolApprovalFlow && messages) { + const dbMessages = convertToUIMessages(messagesFromDb); + const approvalStates = new Map( + messages.flatMap( + (m) => + m.parts + ?.filter( + (p: Record) => + p.state === "approval-responded" || + p.state === "output-denied" + ) + .map((p: Record) => [ + String(p.toolCallId ?? ""), + p, + ]) ?? [] + ) + ); + uiMessages = dbMessages.map((msg) => ({ + ...msg, + parts: msg.parts.map((part) => { + if ( + "toolCallId" in part && + approvalStates.has(String(part.toolCallId)) + ) { + return { ...part, ...approvalStates.get(String(part.toolCallId)) }; + } + return part; + }), + })) as ChatMessage[]; + } else { + uiMessages = [ + ...convertToUIMessages(messagesFromDb), + message as ChatMessage, + ]; + } + + const requestHints: RequestHints = { + city: request.headers.get("x-vercel-ip-city") ?? undefined, + country: request.headers.get("x-vercel-ip-country") ?? undefined, + }; + + if (message?.role === "user") { + await saveMessages({ + messages: [ + { + chatId: id, + id: message.id, + role: "user", + parts: message.parts, + attachments: [], + createdAt: new Date(), + }, + ], + }); + } + + const capabilities = modelCapabilities[chatModel]; + const isReasoningModel = capabilities?.reasoning === true; + const supportsTools = capabilities?.tools === true; + + const modelMessages = await convertToModelMessages(uiMessages); + + const stream = createUIMessageStream({ + originalMessages: isToolApprovalFlow ? uiMessages : undefined, + execute: async ({ writer: dataStream }) => { + const result = streamText({ + model: getLanguageModel(chatModel), + system: systemPrompt({ requestHints, supportsTools }), + messages: modelMessages, + stopWhen: stepCountIs(5), + experimental_activeTools: + isReasoningModel && !supportsTools ? [] : ["getWeather"], + tools: { + getWeather, + }, + }); + + dataStream.merge( + result.toUIMessageStream({ sendReasoning: isReasoningModel }) + ); + + if (titlePromise) { + try { + const title = await titlePromise; + dataStream.write({ type: "data-chat-title", data: title }); + updateChatTitleById({ chatId: id, title }); + } catch (_) { + /* non-fatal */ + } + } + }, + generateId: generateUUID, + onFinish: async ({ messages: finishedMessages }) => { + if (isToolApprovalFlow) { + for (const finishedMsg of finishedMessages) { + const existingMsg = uiMessages.find((m) => m.id === finishedMsg.id); + if (existingMsg) { + await updateMessage({ + id: finishedMsg.id, + parts: finishedMsg.parts, + }); + } else { + await saveMessages({ + messages: [ + { + id: finishedMsg.id, + role: finishedMsg.role, + parts: finishedMsg.parts, + createdAt: new Date(), + attachments: [], + chatId: id, + }, + ], + }); + } + } + } else if (finishedMessages.length > 0) { + await saveMessages({ + messages: finishedMessages.map((currentMessage) => ({ + id: currentMessage.id, + role: currentMessage.role, + parts: currentMessage.parts, + createdAt: new Date(), + attachments: [], + chatId: id, + })), + }); + } + }, + onError: () => "Oops, an error occurred!", + }); + + return createUIMessageStreamResponse({ stream }); + } catch (error) { + if (error instanceof ChatbotError) { + return error.toResponse(); + } + + console.error("Unhandled error in chat API:", error); + return new ChatbotError("offline:chat").toResponse(); + } +} + +export async function DELETE(request: Request) { + const { searchParams } = new URL(request.url); + const id = searchParams.get("id"); + + if (!id) { + return new ChatbotError("bad_request:api").toResponse(); + } + + const session = await auth(); + + if (!session?.user) { + return new ChatbotError("unauthorized:chat").toResponse(); + } + + const chat = await getChatById({ id }); + + if (chat?.userId !== session.user.id) { + return new ChatbotError("forbidden:chat").toResponse(); + } + + const deletedChat = await deleteChatById({ id }); + + return Response.json(deletedChat, { status: 200 }); +} diff --git a/app/(chat)/api/chat/schema.ts b/app/(chat)/api/chat/schema.ts new file mode 100644 index 0000000..d340721 --- /dev/null +++ b/app/(chat)/api/chat/schema.ts @@ -0,0 +1,37 @@ +import { z } from "zod"; + +const textPartSchema = z.object({ + type: z.enum(["text"]), + text: z.string().min(1).max(2000), +}); + +const filePartSchema = z.object({ + type: z.enum(["file"]), + mediaType: z.enum(["image/jpeg", "image/png", "image/webp", "image/gif"]), + name: z.string().min(1).max(100), + url: z.string(), +}); + +const partSchema = z.union([textPartSchema, filePartSchema]); + +const userMessageSchema = z.object({ + id: z.string().uuid(), + role: z.enum(["user"]), + parts: z.array(partSchema), +}); + +const toolApprovalMessageSchema = z.object({ + id: z.string(), + role: z.enum(["user", "assistant"]), + parts: z.array(z.record(z.unknown())), +}); + +export const postRequestBodySchema = z.object({ + id: z.string().uuid(), + message: userMessageSchema.optional(), + messages: z.array(toolApprovalMessageSchema).optional(), + selectedChatModel: z.string(), + selectedVisibilityType: z.enum(["public", "private"]), +}); + +export type PostRequestBody = z.infer; diff --git a/app/(chat)/api/files/upload/route.ts b/app/(chat)/api/files/upload/route.ts new file mode 100644 index 0000000..9dbd3b4 --- /dev/null +++ b/app/(chat)/api/files/upload/route.ts @@ -0,0 +1,64 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; + +import { auth } from "@/app/(auth)/auth"; + +const MAX_FILE_SIZE = 5 * 1024 * 1024; +const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp", "image/gif"]; + +const FileSchema = z.object({ + file: z + .instanceof(Blob) + .refine((file) => file.size <= MAX_FILE_SIZE, { + message: "File size should be less than 5MB", + }) + .refine((file) => ALLOWED_TYPES.includes(file.type), { + message: "File type should be JPEG, PNG, WebP, or GIF", + }), +}); + +export async function POST(request: Request) { + const session = await auth(); + + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + if (request.body === null) { + return new Response("Request body is empty", { status: 400 }); + } + + try { + const formData = await request.formData(); + const file = formData.get("file") as Blob; + + if (!file) { + return NextResponse.json({ error: "No file uploaded" }, { status: 400 }); + } + + const validatedFile = FileSchema.safeParse({ file }); + + if (!validatedFile.success) { + const errorMessage = validatedFile.error.errors + .map((error) => error.message) + .join(", "); + return NextResponse.json({ error: errorMessage }, { status: 400 }); + } + + const filename = (formData.get("file") as File).name; + const safeName = filename.replace(/[^a-zA-Z0-9._-]/g, "_"); + const buffer = Buffer.from(await file.arrayBuffer()); + const dataUrl = `data:${file.type};base64,${buffer.toString("base64")}`; + + return NextResponse.json({ + url: dataUrl, + pathname: safeName, + contentType: file.type, + }); + } catch (_error) { + return NextResponse.json( + { error: "Failed to process request" }, + { status: 500 } + ); + } +} diff --git a/app/(chat)/api/history/route.ts b/app/(chat)/api/history/route.ts new file mode 100644 index 0000000..064a385 --- /dev/null +++ b/app/(chat)/api/history/route.ts @@ -0,0 +1,49 @@ +import type { NextRequest } from "next/server"; +import { auth } from "@/app/(auth)/auth"; +import { deleteAllChatsByUserId, getChatsByUserId } from "@/lib/db/queries"; +import { ChatbotError } from "@/lib/errors"; + +export async function GET(request: NextRequest) { + const { searchParams } = request.nextUrl; + + const limit = Math.min( + Math.max(Number.parseInt(searchParams.get("limit") || "10", 10), 1), + 50 + ); + const startingAfter = searchParams.get("starting_after"); + const endingBefore = searchParams.get("ending_before"); + + if (startingAfter && endingBefore) { + return new ChatbotError( + "bad_request:api", + "Only one of starting_after or ending_before can be provided." + ).toResponse(); + } + + const session = await auth(); + + if (!session?.user) { + return new ChatbotError("unauthorized:chat").toResponse(); + } + + const chats = await getChatsByUserId({ + id: session.user.id, + limit, + startingAfter, + endingBefore, + }); + + return Response.json(chats); +} + +export async function DELETE() { + const session = await auth(); + + if (!session?.user) { + return new ChatbotError("unauthorized:chat").toResponse(); + } + + const result = await deleteAllChatsByUserId({ userId: session.user.id }); + + return Response.json(result, { status: 200 }); +} diff --git a/app/(chat)/api/messages/route.ts b/app/(chat)/api/messages/route.ts new file mode 100644 index 0000000..cda98de --- /dev/null +++ b/app/(chat)/api/messages/route.ts @@ -0,0 +1,43 @@ +import { auth } from "@/app/(auth)/auth"; +import { getChatById, getMessagesByChatId } from "@/lib/db/queries"; +import { convertToUIMessages } from "@/lib/utils"; + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + const chatId = searchParams.get("chatId"); + + if (!chatId) { + return Response.json({ error: "chatId required" }, { status: 400 }); + } + + const [session, chat, messages] = await Promise.all([ + auth(), + getChatById({ id: chatId }), + getMessagesByChatId({ id: chatId }), + ]); + + if (!chat) { + return Response.json({ + messages: [], + visibility: "private", + userId: null, + isReadonly: false, + }); + } + + if ( + chat.visibility === "private" && + (!session?.user || session.user.id !== chat.userId) + ) { + return Response.json({ error: "forbidden" }, { status: 403 }); + } + + const isReadonly = !session?.user || session.user.id !== chat.userId; + + return Response.json({ + messages: convertToUIMessages(messages), + visibility: chat.visibility, + userId: chat.userId, + isReadonly, + }); +} diff --git a/app/(chat)/api/models/route.ts b/app/(chat)/api/models/route.ts new file mode 100644 index 0000000..f15eae6 --- /dev/null +++ b/app/(chat)/api/models/route.ts @@ -0,0 +1,9 @@ +import { modelCapabilities } from "@/lib/ai/models"; + +export function GET() { + return Response.json(modelCapabilities, { + headers: { + "Cache-Control": "public, max-age=86400, s-maxage=86400", + }, + }); +} diff --git a/app/(chat)/api/vote/route.ts b/app/(chat)/api/vote/route.ts new file mode 100644 index 0000000..726ba56 --- /dev/null +++ b/app/(chat)/api/vote/route.ts @@ -0,0 +1,84 @@ +import { z } from "zod"; +import { auth } from "@/app/(auth)/auth"; +import { getChatById, getVotesByChatId, voteMessage } from "@/lib/db/queries"; +import { ChatbotError } from "@/lib/errors"; + +const voteSchema = z.object({ + chatId: z.string(), + messageId: z.string(), + type: z.enum(["up", "down"]), +}); + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + const chatId = searchParams.get("chatId"); + + if (!chatId) { + return new ChatbotError( + "bad_request:api", + "Parameter chatId is required." + ).toResponse(); + } + + const session = await auth(); + + if (!session?.user) { + return new ChatbotError("unauthorized:vote").toResponse(); + } + + const chat = await getChatById({ id: chatId }); + + if (!chat) { + return new ChatbotError("not_found:chat").toResponse(); + } + + if (chat.userId !== session.user.id) { + return new ChatbotError("forbidden:vote").toResponse(); + } + + const votes = await getVotesByChatId({ id: chatId }); + + return Response.json(votes, { status: 200 }); +} + +export async function PATCH(request: Request) { + let chatId: string; + let messageId: string; + let type: "up" | "down"; + + try { + const parsed = voteSchema.parse(await request.json()); + chatId = parsed.chatId; + messageId = parsed.messageId; + type = parsed.type; + } catch { + return new ChatbotError( + "bad_request:api", + "Parameters chatId, messageId, and type are required." + ).toResponse(); + } + + const session = await auth(); + + if (!session?.user) { + return new ChatbotError("unauthorized:vote").toResponse(); + } + + const chat = await getChatById({ id: chatId }); + + if (!chat) { + return new ChatbotError("not_found:vote").toResponse(); + } + + if (chat.userId !== session.user.id) { + return new ChatbotError("forbidden:vote").toResponse(); + } + + await voteMessage({ + chatId, + messageId, + type, + }); + + return new Response("Message voted", { status: 200 }); +} diff --git a/app/(chat)/chat/[id]/page.tsx b/app/(chat)/chat/[id]/page.tsx new file mode 100644 index 0000000..67e0859 --- /dev/null +++ b/app/(chat)/chat/[id]/page.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return null; +} diff --git a/app/(chat)/layout.tsx b/app/(chat)/layout.tsx new file mode 100644 index 0000000..7ece3a7 --- /dev/null +++ b/app/(chat)/layout.tsx @@ -0,0 +1,46 @@ +import { cookies } from "next/headers"; +import { Suspense } from "react"; +import { Toaster } from "sonner"; +import { AppSidebar } from "@/components/chat/app-sidebar"; +import { DataStreamProvider } from "@/components/chat/data-stream-provider"; +import { ChatShell } from "@/components/chat/shell"; +import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"; +import { ActiveChatProvider } from "@/hooks/use-active-chat"; +import { auth } from "../(auth)/auth"; + +export default function Layout({ children }: { children: React.ReactNode }) { + return ( + + }> + {children} + + + ); +} + +async function SidebarShell({ children }: { children: React.ReactNode }) { + const [session, cookieStore] = await Promise.all([auth(), cookies()]); + const isCollapsed = cookieStore.get("sidebar_state")?.value !== "true"; + + return ( + + + + + }> + + + + + {children} + + + ); +} diff --git a/app/(chat)/opengraph-image.png b/app/(chat)/opengraph-image.png new file mode 100644 index 0000000..c840271 Binary files /dev/null and b/app/(chat)/opengraph-image.png differ diff --git a/app/(chat)/page.tsx b/app/(chat)/page.tsx new file mode 100644 index 0000000..67e0859 --- /dev/null +++ b/app/(chat)/page.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return null; +} diff --git a/app/(chat)/twitter-image.png b/app/(chat)/twitter-image.png new file mode 100644 index 0000000..79fbc0f Binary files /dev/null and b/app/(chat)/twitter-image.png differ diff --git a/app/favicon.ico b/app/favicon.ico new file mode 100644 index 0000000..7452b5d Binary files /dev/null and b/app/favicon.ico differ diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..4143774 --- /dev/null +++ b/app/globals.css @@ -0,0 +1,500 @@ +@import "tailwindcss"; +@import "katex/dist/katex.min.css"; + +@source "../node_modules/streamdown/dist/index.js"; + +@custom-variant dark (&:is(.dark, .dark *)); + +@plugin "tailwindcss-animate"; +@plugin "@tailwindcss/typography"; + +@theme inline { + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); +} + +:root { + --radius: 0.625rem; + --background: oklch(0.985 0 0); + --foreground: oklch(0.12 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.12 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.12 0 0); + --primary: oklch(0.12 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.965 0 0); + --secondary-foreground: oklch(0.38 0 0); + --muted: oklch(0.94 0 0); + --muted-foreground: oklch(0.58 0 0); + --accent: oklch(0.965 0 0); + --accent-foreground: oklch(0.12 0 0); + --destructive: oklch(0.55 0.15 25); + --border: oklch(0.9 0 0); + --input: oklch(0.9 0 0); + --ring: oklch(0.5 0 0); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --sidebar: oklch(0.97 0 0); + --sidebar-foreground: oklch(0.38 0 0); + --sidebar-primary: oklch(0.12 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.12 0 0 / 0.06); + --sidebar-accent-foreground: oklch(0.12 0 0); + --sidebar-border: oklch(0.88 0 0); + --sidebar-ring: oklch(0.5 0 0); + + --shadow-card: 0 1px 3px oklch(0 0 0 / 0.05), 0 1px 1px oklch(0 0 0 / 0.03); + --shadow-float: + 0 8px 24px -6px oklch(0 0 0 / 0.1), 0 2px 8px -2px oklch(0 0 0 / 0.04); + --shadow-composer: 0 1px 2px oklch(0 0 0 / 0.04); + --shadow-composer-focus: + 0 0 0 1px oklch(0 0 0 / 0.06), 0 2px 8px -2px oklch(0 0 0 / 0.06); + --shadow-inset: inset 0 1px 1px oklch(0 0 0 / 0.03); + --shadow-glow: 0 0 20px oklch(0 0 0 / 0.08); + + --ease-spring: cubic-bezier(0.22, 1, 0.36, 1); + --ease-bounce: cubic-bezier(0.34, 1.56, 0.64, 1); + --ease-smooth: cubic-bezier(0.4, 0, 0.2, 1); +} + +.dark { + --background: oklch(0.195 0 0); + --foreground: oklch(0.94 0 0); + --card: oklch(0.225 0 0); + --card-foreground: oklch(0.94 0 0); + --popover: oklch(0.225 0 0); + --popover-foreground: oklch(0.94 0 0); + --primary: oklch(0.94 0 0); + --primary-foreground: oklch(0.195 0 0); + --secondary: oklch(0.26 0 0); + --secondary-foreground: oklch(0.75 0 0); + --muted: oklch(0.165 0 0); + --muted-foreground: oklch(0.6 0 0); + --accent: oklch(0.26 0 0); + --accent-foreground: oklch(0.94 0 0); + --destructive: oklch(0.7 0.15 25); + --border: oklch(0.27 0 0); + --input: oklch(0.27 0 0); + --ring: oklch(0.45 0 0); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.175 0 0); + --sidebar-foreground: oklch(0.78 0 0); + --sidebar-primary: oklch(0.94 0 0); + --sidebar-primary-foreground: oklch(0.195 0 0); + --sidebar-accent: oklch(0.94 0 0 / 0.06); + --sidebar-accent-foreground: oklch(0.94 0 0); + --sidebar-border: oklch(0.25 0 0); + --sidebar-ring: oklch(0.45 0 0); + + --shadow-card: + inset 0 1px 0 oklch(1 0 0 / 0.04), 0 1px 2px oklch(0 0 0 / 0.2), + 0 0.5px 1px oklch(0 0 0 / 0.15); + --shadow-float: + 0 0 0 1px oklch(1 0 0 / 0.06), 0 16px 48px -6px oklch(0 0 0 / 0.35), + 0 6px 12px -2px oklch(0 0 0 / 0.2); + --shadow-composer: + 0 1px 3px oklch(0 0 0 / 0.2), inset 0 1px 0 oklch(1 0 0 / 0.03); + --shadow-composer-focus: + 0 0 0 1px oklch(1 0 0 / 0.1), 0 4px 16px -4px oklch(0 0 0 / 0.3), + inset 0 1px 0 oklch(1 0 0 / 0.04); +} + +@layer base { + * { + @apply border-border ring-0; + } + body { + @apply bg-background text-foreground; + font-feature-settings: "ss01", "ss02", "cv01"; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-rendering: optimizeLegibility; + } +} + +@layer base { + *, + ::after, + ::before, + ::backdrop, + ::file-selector-button { + border-color: var(--border); + } +} + +@layer base { + body { + overflow-x: hidden; + position: relative; + } + + html { + overflow-x: hidden; + } + + h1, + h2, + h3, + h4, + h5, + h6 { + letter-spacing: -0.025em; + line-height: 1.2; + } + + p { + line-height: 1.6; + } +} + +button:focus-visible, +select:focus-visible, +[role="button"]:focus-visible, +input:focus-visible, +textarea:focus-visible { + outline: none; +} + +@utility text-balance { + text-wrap: balance; +} + +@utility -webkit-overflow-scrolling-touch { + -webkit-overflow-scrolling: touch; +} + +@utility touch-pan-y { + touch-action: pan-y; +} + +@utility overscroll-behavior-contain { + overscroll-behavior: contain; +} + +@utility no-scrollbar { + -ms-overflow-style: none; + scrollbar-width: none; + &::-webkit-scrollbar { + display: none; + } +} + +@layer utilities { + :root { + --foreground-rgb: 0, 0, 0; + --background-start-rgb: 214, 219, 220; + --background-end-rgb: 255, 255, 255; + } + + @media (prefers-color-scheme: dark) { + :root { + --foreground-rgb: 255, 255, 255; + --background-start-rgb: 0, 0, 0; + --background-end-rgb: 0, 0, 0; + } + } +} + +.skeleton { + * { + pointer-events: none !important; + } + + *[class^="text-"] { + color: transparent; + @apply rounded-md bg-foreground/20 select-none animate-pulse; + } + + .skeleton-bg { + @apply bg-foreground/10; + } + + .skeleton-div { + @apply bg-foreground/20 animate-pulse; + } +} + +@keyframes fade-up { + from { + opacity: 0; + transform: translateY(6px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes fade-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes shimmer { + 0% { + background-position: -200% 0; + } + 100% { + background-position: 200% 0; + } +} + +@keyframes dot-pulse { + 0%, + 60%, + 100% { + opacity: 0.3; + transform: translateY(0); + } + 30% { + opacity: 1; + transform: translateY(-3px); + } +} + +@keyframes message-in { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes thinking-dot { + 0%, + 60%, + 100% { + opacity: 0.3; + transform: translateY(0); + } + 30% { + opacity: 1; + transform: translateY(-3px); + } +} + +@keyframes glow-pulse { + 0%, + 100% { + box-shadow: 0 0 0 0 oklch(0.55 0.12 250 / 0%); + } + 50% { + box-shadow: 0 0 0 3px oklch(0.55 0.12 250 / 8%); + } +} + +@keyframes subtle-lift { + from { + transform: translateY(0); + box-shadow: var(--shadow-card); + } + to { + transform: translateY(-1px); + box-shadow: var(--shadow-float); + } +} + +@utility fade-up { + animation: fade-up 0.25s var(--ease-spring) both; +} + +@utility fade-in { + animation: fade-in 0.2s ease both; +} + +@utility shimmer { + animation: shimmer 2s linear infinite; + background: linear-gradient( + 90deg, + transparent, + oklch(1 0 0 / 0.04), + transparent + ); + background-size: 200% 100%; +} + +@utility dot-pulse { + animation: dot-pulse 1.4s ease-in-out infinite; +} + +@utility message-fade-in { + animation: message-in 0.3s cubic-bezier(0.16, 1, 0.3, 1) both; +} + +@utility thinking-dot { + animation: thinking-dot 1.4s ease-in-out infinite; +} + +@utility composer-glow { + animation: glow-pulse 2s ease-in-out infinite; +} + +.ProseMirror { + outline: none; +} + +.cm-editor { + @apply bg-transparent! outline-hidden! text-[13px]! leading-[1.6]!; + font-family: + "SF Mono", "Cascadia Code", "Fira Code", "JetBrains Mono", ui-monospace, + monospace !important; +} + +.cm-gutters { + @apply bg-transparent! border-r-0! outline-hidden!; +} + +.cm-gutter.cm-lineNumbers { + @apply min-w-[3rem] text-muted-foreground/40 text-[11px]!; +} + +.cm-scroller { + @apply overflow-auto!; +} + +.ͼo.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground, +.ͼo.cm-selectionBackground, +.ͼo.cm-content::selection { + background: oklch(0.55 0.12 250 / 0.15) !important; +} + +.dark + .ͼo.cm-focused + > .cm-scroller + > .cm-selectionLayer + .cm-selectionBackground, +.dark .ͼo.cm-selectionBackground, +.dark .ͼo.cm-content::selection { + background: oklch(0.55 0.12 250 / 0.2) !important; +} + +.cm-activeLine { + @apply bg-muted/50! rounded-sm!; +} + +.cm-activeLineGutter { + @apply bg-transparent!; +} + +.cm-activeLineGutter .cm-gutterElement { + color: var(--foreground) !important; + opacity: 0.7; +} + +.cm-gutter.cm-lineNumbers .cm-gutterElement { + padding-right: 12px !important; +} + +.cm-foldGutter { + @apply min-w-3; +} + +.cm-cursor { + border-left-color: oklch(0.55 0.12 250) !important; + border-left-width: 2px !important; +} + +.cm-matchingBracket { + background: oklch(0.55 0.12 250 / 0.12) !important; + outline: 1px solid oklch(0.55 0.12 250 / 0.3); + border-radius: 2px; +} + +.suggestion-highlight { + @apply cursor-pointer rounded-sm bg-blue-200 transition-colors hover:bg-blue-300 dark:bg-blue-500/40 dark:text-blue-50 dark:hover:bg-blue-400/50; + user-select: none; + -webkit-user-select: none; +} + +@layer base { + * { + scrollbar-width: thin; + scrollbar-color: oklch(0 0 0 / 0.12) transparent; + } + + .dark * { + scrollbar-color: oklch(1 0 0 / 0.1) transparent; + } + + *::-webkit-scrollbar { + width: 4px; + height: 4px; + } + + *::-webkit-scrollbar-track { + background: transparent; + } + + *::-webkit-scrollbar-thumb { + background: oklch(0 0 0 / 0.12); + border-radius: 9999px; + } + + *::-webkit-scrollbar-thumb:hover { + background: oklch(0 0 0 / 0.25); + } + + .dark *::-webkit-scrollbar-thumb { + background: oklch(1 0 0 / 0.1); + } + + .dark *::-webkit-scrollbar-thumb:hover { + background: oklch(1 0 0 / 0.2); + } + + *::-webkit-scrollbar-corner { + background: transparent; + } +} + +[data-testid="artifact"] { + isolation: isolate; +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..9e310aa --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,88 @@ +import type { Metadata } from "next"; +import { Geist, Geist_Mono } from "next/font/google"; +import { ThemeProvider } from "@/components/theme-provider"; +import { TooltipProvider } from "@/components/ui/tooltip"; + +import "./globals.css"; +import { SessionProvider } from "next-auth/react"; + +const baseUrl = process.env.NEXT_PUBLIC_BASE_URL ?? "http://localhost:3000"; + +export const metadata: Metadata = { + metadataBase: new URL(baseUrl), + title: "EGBE Chatbot", + description: "Chatbot template wired to EGBE LiteLLM proxy.", +}; + +export const viewport = { + maximumScale: 1, +}; + +const geist = Geist({ + subsets: ["latin"], + display: "swap", + variable: "--font-geist", +}); + +const geistMono = Geist_Mono({ + subsets: ["latin"], + display: "swap", + variable: "--font-geist-mono", +}); + +const LIGHT_THEME_COLOR = "hsl(0 0% 100%)"; +const DARK_THEME_COLOR = "hsl(240deg 10% 3.92%)"; +const THEME_COLOR_SCRIPT = `\ +(function() { + var html = document.documentElement; + var meta = document.querySelector('meta[name="theme-color"]'); + if (!meta) { + meta = document.createElement('meta'); + meta.setAttribute('name', 'theme-color'); + document.head.appendChild(meta); + } + function updateThemeColor() { + var isDark = html.classList.contains('dark'); + meta.setAttribute('content', isDark ? '${DARK_THEME_COLOR}' : '${LIGHT_THEME_COLOR}'); + } + var observer = new MutationObserver(updateThemeColor); + observer.observe(html, { attributes: true, attributeFilter: ['class'] }); + updateThemeColor(); +})();`; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + +