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
This commit is contained in:
dmitry.galkin 2026-05-25 14:54:04 +04:00
commit 3e21c2334c
129 changed files with 21913 additions and 0 deletions

15
.dockerignore Normal file
View file

@ -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

12
.env.example Normal file
View file

@ -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

27
.gitignore vendored Normal file
View file

@ -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

3
.vscode/extensions.json vendored Normal file
View file

@ -0,0 +1,3 @@
{
"recommendations": ["biomejs.biome"]
}

53
.vscode/settings.json vendored Normal file
View file

@ -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"
}
}

46
Dockerfile Normal file
View file

@ -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"]

13
LICENSE Normal file
View file

@ -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.

57
README.md Normal file
View file

@ -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 <slug> 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).

84
app/(auth)/actions.ts Normal file
View file

@ -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<LoginActionState> => {
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<RegisterActionState> => {
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" };
}
};

View file

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

View file

@ -0,0 +1,26 @@
import { NextResponse } from "next/server";
import { getToken } from "next-auth/jwt";
import { signIn } from "@/app/(auth)/auth";
import { isDevelopmentEnvironment } from "@/lib/constants";
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const rawRedirect = searchParams.get("redirectUrl") || "/";
const redirectUrl =
rawRedirect.startsWith("/") && !rawRedirect.startsWith("//")
? rawRedirect
: "/";
const token = await getToken({
req: request,
secret: process.env.AUTH_SECRET,
secureCookie: !isDevelopmentEnvironment,
});
if (token) {
const base = process.env.NEXT_PUBLIC_BASE_PATH ?? "";
return NextResponse.redirect(new URL(`${base}/`, request.url));
}
return signIn("guest", { redirect: true, redirectTo: redirectUrl });
}

14
app/(auth)/auth.config.ts Normal file
View file

@ -0,0 +1,14 @@
import type { NextAuthConfig } from "next-auth";
const base = process.env.NEXT_PUBLIC_BASE_PATH ?? "";
export const authConfig = {
basePath: "/api/auth",
trustHost: true,
pages: {
signIn: `${base}/login`,
newUser: `${base}/`,
},
providers: [],
callbacks: {},
} satisfies NextAuthConfig;

99
app/(auth)/auth.ts Normal file
View file

@ -0,0 +1,99 @@
import { compare } from "bcrypt-ts";
import NextAuth, { type DefaultSession } from "next-auth";
import type { DefaultJWT } from "next-auth/jwt";
import Credentials from "next-auth/providers/credentials";
import { DUMMY_PASSWORD } from "@/lib/constants";
import { createGuestUser, getUser } from "@/lib/db/queries";
import { authConfig } from "./auth.config";
export type UserType = "guest" | "regular";
declare module "next-auth" {
interface Session extends DefaultSession {
user: {
id: string;
type: UserType;
} & DefaultSession["user"];
}
interface User {
id?: string;
email?: string | null;
type: UserType;
}
}
declare module "next-auth/jwt" {
interface JWT extends DefaultJWT {
id: string;
type: UserType;
}
}
export const {
handlers: { GET, POST },
auth,
signIn,
signOut,
} = NextAuth({
...authConfig,
providers: [
Credentials({
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" },
},
async authorize(credentials) {
const email = String(credentials.email ?? "");
const password = String(credentials.password ?? "");
const users = await getUser(email);
if (users.length === 0) {
await compare(password, DUMMY_PASSWORD);
return null;
}
const [user] = users;
if (!user.password) {
await compare(password, DUMMY_PASSWORD);
return null;
}
const passwordsMatch = await compare(password, user.password);
if (!passwordsMatch) {
return null;
}
return { ...user, type: "regular" };
},
}),
Credentials({
id: "guest",
credentials: {},
async authorize() {
const [guestUser] = await createGuestUser();
return { ...guestUser, type: "guest" };
},
}),
],
callbacks: {
jwt({ token, user }) {
if (user) {
token.id = user.id as string;
token.type = user.type;
}
return token;
},
session({ session, token }) {
if (session.user) {
session.user.id = token.id;
session.user.type = token.type;
}
return session;
},
},
});

43
app/(auth)/layout.tsx Normal file
View file

@ -0,0 +1,43 @@
import { ArrowLeftIcon } from "lucide-react";
import Link from "next/link";
import { SparklesIcon, VercelIcon } from "@/components/chat/icons";
import { Preview } from "@/components/chat/preview";
export default function AuthLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="flex h-dvh w-screen bg-sidebar">
<div className="flex w-full flex-col bg-background p-8 xl:w-[600px] xl:shrink-0 xl:rounded-r-2xl xl:border-r xl:border-border/40 md:p-16">
<Link
className="flex w-fit items-center gap-1.5 text-[13px] text-muted-foreground transition-colors hover:text-foreground"
href="/"
>
<ArrowLeftIcon className="size-3.5" />
Back
</Link>
<div className="mx-auto flex w-full max-w-md flex-1 flex-col justify-center gap-10">
<div className="flex flex-col gap-2">
<div className="mb-2 flex size-9 items-center justify-center rounded-lg bg-muted/60 text-muted-foreground ring-1 ring-border/50">
<SparklesIcon size={14} />
</div>
{children}
</div>
</div>
</div>
<div className="hidden flex-1 flex-col overflow-hidden pl-12 xl:flex">
<div className="flex items-center gap-1.5 pt-8 text-[13px] text-muted-foreground/50">
Powered by
<VercelIcon size={14} />
<span className="font-medium text-muted-foreground">AI Gateway</span>
</div>
<div className="flex-1 pt-4">
<Preview />
</div>
</div>
</div>
);
}

66
app/(auth)/login/page.tsx Normal file
View file

@ -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<LoginActionState, FormData>(
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 (
<>
<h1 className="text-2xl font-semibold tracking-tight">Welcome back</h1>
<p className="text-sm text-muted-foreground">
Sign in to your account to continue
</p>
<AuthForm action={handleSubmit} defaultEmail={email}>
<SubmitButton isSuccessful={isSuccessful}>Sign in</SubmitButton>
<p className="text-center text-[13px] text-muted-foreground">
{"No account? "}
<Link
className="text-foreground underline-offset-4 hover:underline"
href="/register"
>
Sign up
</Link>
</p>
</AuthForm>
</>
);
}

View file

@ -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<RegisterActionState, FormData>(
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 (
<>
<h1 className="text-2xl font-semibold tracking-tight">Create account</h1>
<p className="text-sm text-muted-foreground">Get started for free</p>
<AuthForm action={handleSubmit} defaultEmail={email}>
<SubmitButton isSuccessful={isSuccessful}>Sign up</SubmitButton>
<p className="text-center text-[13px] text-muted-foreground">
{"Have an account? "}
<Link
className="text-foreground underline-offset-4 hover:underline"
href="/login"
>
Sign in
</Link>
</p>
</AuthForm>
</>
);
}

78
app/(chat)/actions.ts Normal file
View file

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

View file

@ -0,0 +1,3 @@
export function GET() {
return new Response(null, { status: 204 });
}

View file

@ -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<string> | 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<string, unknown>) =>
p.state === "approval-responded" ||
p.state === "output-denied"
)
.map((p: Record<string, unknown>) => [
String(p.toolCallId ?? ""),
p,
]) ?? []
)
);
uiMessages = dbMessages.map((msg) => ({
...msg,
parts: msg.parts.map((part) => {
if (
"toolCallId" in part &&
approvalStates.has(String(part.toolCallId))
) {
return { ...part, ...approvalStates.get(String(part.toolCallId)) };
}
return part;
}),
})) as ChatMessage[];
} else {
uiMessages = [
...convertToUIMessages(messagesFromDb),
message as ChatMessage,
];
}
const 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 });
}

View file

@ -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<typeof postRequestBodySchema>;

View file

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

View file

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

View file

@ -0,0 +1,43 @@
import { auth } from "@/app/(auth)/auth";
import { getChatById, getMessagesByChatId } from "@/lib/db/queries";
import { convertToUIMessages } from "@/lib/utils";
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const chatId = searchParams.get("chatId");
if (!chatId) {
return Response.json({ error: "chatId required" }, { status: 400 });
}
const [session, chat, messages] = await Promise.all([
auth(),
getChatById({ id: chatId }),
getMessagesByChatId({ id: chatId }),
]);
if (!chat) {
return Response.json({
messages: [],
visibility: "private",
userId: null,
isReadonly: false,
});
}
if (
chat.visibility === "private" &&
(!session?.user || session.user.id !== chat.userId)
) {
return Response.json({ error: "forbidden" }, { status: 403 });
}
const isReadonly = !session?.user || session.user.id !== chat.userId;
return Response.json({
messages: convertToUIMessages(messages),
visibility: chat.visibility,
userId: chat.userId,
isReadonly,
});
}

View file

@ -0,0 +1,9 @@
import { modelCapabilities } from "@/lib/ai/models";
export function GET() {
return Response.json(modelCapabilities, {
headers: {
"Cache-Control": "public, max-age=86400, s-maxage=86400",
},
});
}

View file

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

View file

@ -0,0 +1,3 @@
export default function Page() {
return null;
}

46
app/(chat)/layout.tsx Normal file
View file

@ -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 (
<DataStreamProvider>
<Suspense fallback={<div className="flex h-dvh bg-sidebar" />}>
<SidebarShell>{children}</SidebarShell>
</Suspense>
</DataStreamProvider>
);
}
async function SidebarShell({ children }: { children: React.ReactNode }) {
const [session, cookieStore] = await Promise.all([auth(), cookies()]);
const isCollapsed = cookieStore.get("sidebar_state")?.value !== "true";
return (
<SidebarProvider defaultOpen={!isCollapsed}>
<AppSidebar user={session?.user} />
<SidebarInset>
<Toaster
position="top-center"
theme="system"
toastOptions={{
className:
"!bg-card !text-foreground !border-border/50 !shadow-[var(--shadow-float)]",
}}
/>
<Suspense fallback={<div className="flex h-dvh" />}>
<ActiveChatProvider>
<ChatShell />
</ActiveChatProvider>
</Suspense>
{children}
</SidebarInset>
</SidebarProvider>
);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

3
app/(chat)/page.tsx Normal file
View file

@ -0,0 +1,3 @@
export default function Page() {
return null;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

BIN
app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

500
app/globals.css Normal file
View file

@ -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;
}

88
app/layout.tsx Normal file
View file

@ -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 (
<html
className={`${geist.variable} ${geistMono.variable}`}
lang="en"
suppressHydrationWarning
>
<head>
<script
// biome-ignore lint/security/noDangerouslySetInnerHtml: "Required"
dangerouslySetInnerHTML={{
__html: THEME_COLOR_SCRIPT,
}}
/>
</head>
<body className="antialiased">
<ThemeProvider
attribute="class"
defaultTheme="system"
disableTransitionOnChange
enableSystem
>
<SessionProvider
basePath={`${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/auth`}
>
<TooltipProvider>{children}</TooltipProvider>
</SessionProvider>
</ThemeProvider>
</body>
</html>
);
}

56
biome.jsonc Normal file
View file

@ -0,0 +1,56 @@
{
"$schema": "./node_modules/@biomejs/biome/configuration_schema.json",
"extends": [
"ultracite/biome/core",
"ultracite/biome/next",
"ultracite/biome/react"
],
"formatter": {
"indentStyle": "space",
"indentWidth": 2
},
"files": {
"includes": [
"**/*",
"!components/ai-elements",
"!components/elements",
"!components/ui",
"!lib/utils.ts",
"!hooks/use-mobile.ts"
]
},
"linter": {
"rules": {
"suspicious": {
"noExplicitAny": "off",
"noUnknownAtRules": "off",
"noConsole": "off",
"noBitwiseOperators": "off"
},
"style": {
"noMagicNumbers": "off",
"noNestedTernary": "off",
"useConsistentTypeDefinitions": "off"
},
"nursery": {
"noUnnecessaryConditions": "off",
"useSortedClasses": "off"
},
"complexity": {
"noExcessiveCognitiveComplexity": "off",
"useSimplifiedLogicExpression": "off"
},
"a11y": {
"noSvgWithoutTitle": "off"
},
"correctness": {
"useUniqueElementIds": "off",
"useImageSize": "off"
},
"performance": {
"noBarrelFile": "off",
"useTopLevelRegex": "off"
}
}
}
}

20
components.json Normal file
View file

@ -0,0 +1,20 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "radix-maia",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
}
}

View file

@ -0,0 +1,555 @@
"use client";
import type { ComponentProps, CSSProperties, HTMLAttributes } from "react";
import type {
BundledLanguage,
BundledTheme,
HighlighterGeneric,
ThemedToken,
} from "shiki";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { cn } from "@/lib/utils";
import { CheckIcon, CopyIcon } from "lucide-react";
import {
createContext,
memo,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { createHighlighter } from "shiki";
// Shiki uses bitflags for font styles: 1=italic, 2=bold, 4=underline
// biome-ignore lint/suspicious/noBitwiseOperators: shiki bitflag check
// eslint-disable-next-line no-bitwise -- shiki bitflag check
const isItalic = (fontStyle: number | undefined) => fontStyle && fontStyle & 1;
// biome-ignore lint/suspicious/noBitwiseOperators: shiki bitflag check
// eslint-disable-next-line no-bitwise -- shiki bitflag check
// oxlint-disable-next-line eslint(no-bitwise)
const isBold = (fontStyle: number | undefined) => fontStyle && fontStyle & 2;
const isUnderline = (fontStyle: number | undefined) =>
// biome-ignore lint/suspicious/noBitwiseOperators: shiki bitflag check
// oxlint-disable-next-line eslint(no-bitwise)
fontStyle && fontStyle & 4;
// Transform tokens to include pre-computed keys to avoid noArrayIndexKey lint
interface KeyedToken {
token: ThemedToken;
key: string;
}
interface KeyedLine {
tokens: KeyedToken[];
key: string;
}
const addKeysToTokens = (lines: ThemedToken[][]): KeyedLine[] =>
lines.map((line, lineIdx) => ({
key: `line-${lineIdx}`,
tokens: line.map((token, tokenIdx) => ({
key: `line-${lineIdx}-${tokenIdx}`,
token,
})),
}));
// Token rendering component
const TokenSpan = ({ token }: { token: ThemedToken }) => (
<span
className="dark:!bg-[var(--shiki-dark-bg)] dark:!text-[var(--shiki-dark)]"
style={
{
backgroundColor: token.bgColor,
color: token.color,
fontStyle: isItalic(token.fontStyle) ? "italic" : undefined,
fontWeight: isBold(token.fontStyle) ? "bold" : undefined,
textDecoration: isUnderline(token.fontStyle) ? "underline" : undefined,
...token.htmlStyle,
} as CSSProperties
}
>
{token.content}
</span>
);
// Line rendering component
const LineSpan = ({
keyedLine,
showLineNumbers,
}: {
keyedLine: KeyedLine;
showLineNumbers: boolean;
}) => (
<span className={showLineNumbers ? LINE_NUMBER_CLASSES : "block"}>
{keyedLine.tokens.length === 0
? "\n"
: keyedLine.tokens.map(({ token, key }) => (
<TokenSpan key={key} token={token} />
))}
</span>
);
// Types
type CodeBlockProps = HTMLAttributes<HTMLDivElement> & {
code: string;
language: BundledLanguage;
showLineNumbers?: boolean;
};
interface TokenizedCode {
tokens: ThemedToken[][];
fg: string;
bg: string;
}
interface CodeBlockContextType {
code: string;
}
// Context
const CodeBlockContext = createContext<CodeBlockContextType>({
code: "",
});
// Highlighter cache (singleton per language)
const highlighterCache = new Map<
string,
Promise<HighlighterGeneric<BundledLanguage, BundledTheme>>
>();
// Token cache
const tokensCache = new Map<string, TokenizedCode>();
// Subscribers for async token updates
const subscribers = new Map<string, Set<(result: TokenizedCode) => void>>();
const getTokensCacheKey = (code: string, language: BundledLanguage) => {
const start = code.slice(0, 100);
const end = code.length > 100 ? code.slice(-100) : "";
return `${language}:${code.length}:${start}:${end}`;
};
const getHighlighter = (
language: BundledLanguage
): Promise<HighlighterGeneric<BundledLanguage, BundledTheme>> => {
const cached = highlighterCache.get(language);
if (cached) {
return cached;
}
const highlighterPromise = createHighlighter({
langs: [language],
themes: ["github-light", "github-dark"],
});
highlighterCache.set(language, highlighterPromise);
return highlighterPromise;
};
// Create raw tokens for immediate display while highlighting loads
const createRawTokens = (code: string): TokenizedCode => ({
bg: "transparent",
fg: "inherit",
tokens: code.split("\n").map((line) =>
line === ""
? []
: [
{
color: "inherit",
content: line,
} as ThemedToken,
]
),
});
// Synchronous highlight with callback for async results
export const highlightCode = (
code: string,
language: BundledLanguage,
// oxlint-disable-next-line eslint-plugin-promise(prefer-await-to-callbacks)
callback?: (result: TokenizedCode) => void
): TokenizedCode | null => {
const tokensCacheKey = getTokensCacheKey(code, language);
// Return cached result if available
const cached = tokensCache.get(tokensCacheKey);
if (cached) {
return cached;
}
// Subscribe callback if provided
if (callback) {
if (!subscribers.has(tokensCacheKey)) {
subscribers.set(tokensCacheKey, new Set());
}
subscribers.get(tokensCacheKey)?.add(callback);
}
// Start highlighting in background - fire-and-forget async pattern
getHighlighter(language)
// oxlint-disable-next-line eslint-plugin-promise(prefer-await-to-then)
.then((highlighter) => {
const availableLangs = highlighter.getLoadedLanguages();
const langToUse = availableLangs.includes(language) ? language : "text";
const result = highlighter.codeToTokens(code, {
lang: langToUse,
themes: {
dark: "github-dark",
light: "github-light",
},
});
const tokenized: TokenizedCode = {
bg: result.bg ?? "transparent",
fg: result.fg ?? "inherit",
tokens: result.tokens,
};
// Cache the result
tokensCache.set(tokensCacheKey, tokenized);
// Notify all subscribers
const subs = subscribers.get(tokensCacheKey);
if (subs) {
for (const sub of subs) {
sub(tokenized);
}
subscribers.delete(tokensCacheKey);
}
})
// oxlint-disable-next-line eslint-plugin-promise(prefer-await-to-then), eslint-plugin-promise(prefer-await-to-callbacks)
.catch((error) => {
console.error("Failed to highlight code:", error);
subscribers.delete(tokensCacheKey);
});
return null;
};
// Line number styles using CSS counters
const LINE_NUMBER_CLASSES = cn(
"block",
"before:content-[counter(line)]",
"before:inline-block",
"before:[counter-increment:line]",
"before:w-8",
"before:mr-4",
"before:text-right",
"before:text-muted-foreground/50",
"before:font-mono",
"before:select-none"
);
const CodeBlockBody = memo(
({
tokenized,
showLineNumbers,
className,
}: {
tokenized: TokenizedCode;
showLineNumbers: boolean;
className?: string;
}) => {
const preStyle = useMemo(
() => ({
backgroundColor: tokenized.bg,
color: tokenized.fg,
}),
[tokenized.bg, tokenized.fg]
);
const keyedLines = useMemo(
() => addKeysToTokens(tokenized.tokens),
[tokenized.tokens]
);
return (
<pre
className={cn(
"dark:!bg-[var(--shiki-dark-bg)] dark:!text-[var(--shiki-dark)] m-0 p-4 text-sm",
className
)}
style={preStyle}
>
<code
className={cn(
"font-mono text-sm",
showLineNumbers && "[counter-increment:line_0] [counter-reset:line]"
)}
>
{keyedLines.map((keyedLine) => (
<LineSpan
key={keyedLine.key}
keyedLine={keyedLine}
showLineNumbers={showLineNumbers}
/>
))}
</code>
</pre>
);
},
(prevProps, nextProps) =>
prevProps.tokenized === nextProps.tokenized &&
prevProps.showLineNumbers === nextProps.showLineNumbers &&
prevProps.className === nextProps.className
);
CodeBlockBody.displayName = "CodeBlockBody";
export const CodeBlockContainer = ({
className,
language,
style,
...props
}: HTMLAttributes<HTMLDivElement> & { language: string }) => (
<div
className={cn(
"group relative w-full overflow-hidden rounded-md border bg-background text-foreground",
className
)}
data-language={language}
style={{
containIntrinsicSize: "auto 200px",
contentVisibility: "auto",
...style,
}}
{...props}
/>
);
export const CodeBlockHeader = ({
children,
className,
...props
}: HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex items-center justify-between border-b bg-muted/80 px-3 py-2 text-muted-foreground text-xs",
className
)}
{...props}
>
{children}
</div>
);
export const CodeBlockTitle = ({
children,
className,
...props
}: HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex items-center gap-2", className)} {...props}>
{children}
</div>
);
export const CodeBlockFilename = ({
children,
className,
...props
}: HTMLAttributes<HTMLSpanElement>) => (
<span className={cn("font-mono", className)} {...props}>
{children}
</span>
);
export const CodeBlockActions = ({
children,
className,
...props
}: HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("-my-1 -mr-1 flex items-center gap-2", className)}
{...props}
>
{children}
</div>
);
export const CodeBlockContent = ({
code,
language,
showLineNumbers = false,
}: {
code: string;
language: BundledLanguage;
showLineNumbers?: boolean;
}) => {
// Memoized raw tokens for immediate display
const rawTokens = useMemo(() => createRawTokens(code), [code]);
// Try to get cached result synchronously, otherwise use raw tokens
const [tokenized, setTokenized] = useState<TokenizedCode>(
() => highlightCode(code, language) ?? rawTokens
);
useEffect(() => {
let cancelled = false;
// Reset to raw tokens when code changes (shows current code, not stale tokens)
setTokenized(highlightCode(code, language) ?? rawTokens);
// Subscribe to async highlighting result
highlightCode(code, language, (result) => {
if (!cancelled) {
setTokenized(result);
}
});
return () => {
cancelled = true;
};
}, [code, language, rawTokens]);
return (
<div className="relative overflow-auto">
<CodeBlockBody showLineNumbers={showLineNumbers} tokenized={tokenized} />
</div>
);
};
export const CodeBlock = ({
code,
language,
showLineNumbers = false,
className,
children,
...props
}: CodeBlockProps) => {
const contextValue = useMemo(() => ({ code }), [code]);
return (
<CodeBlockContext.Provider value={contextValue}>
<CodeBlockContainer className={className} language={language} {...props}>
{children}
<CodeBlockContent
code={code}
language={language}
showLineNumbers={showLineNumbers}
/>
</CodeBlockContainer>
</CodeBlockContext.Provider>
);
};
export type CodeBlockCopyButtonProps = ComponentProps<typeof Button> & {
onCopy?: () => void;
onError?: (error: Error) => void;
timeout?: number;
};
export const CodeBlockCopyButton = ({
onCopy,
onError,
timeout = 2000,
children,
className,
...props
}: CodeBlockCopyButtonProps) => {
const [isCopied, setIsCopied] = useState(false);
const timeoutRef = useRef<number>(0);
const { code } = useContext(CodeBlockContext);
const copyToClipboard = useCallback(async () => {
if (typeof window === "undefined" || !navigator?.clipboard?.writeText) {
onError?.(new Error("Clipboard API not available"));
return;
}
try {
if (!isCopied) {
await navigator.clipboard.writeText(code);
setIsCopied(true);
onCopy?.();
timeoutRef.current = window.setTimeout(
() => setIsCopied(false),
timeout
);
}
} catch (error) {
onError?.(error as Error);
}
}, [code, onCopy, onError, timeout, isCopied]);
useEffect(
() => () => {
window.clearTimeout(timeoutRef.current);
},
[]
);
const Icon = isCopied ? CheckIcon : CopyIcon;
return (
<Button
className={cn("shrink-0", className)}
onClick={copyToClipboard}
size="icon"
variant="ghost"
{...props}
>
{children ?? <Icon size={14} />}
</Button>
);
};
export type CodeBlockLanguageSelectorProps = ComponentProps<typeof Select>;
export const CodeBlockLanguageSelector = (
props: CodeBlockLanguageSelectorProps
) => <Select {...props} />;
export type CodeBlockLanguageSelectorTriggerProps = ComponentProps<
typeof SelectTrigger
>;
export const CodeBlockLanguageSelectorTrigger = ({
className,
...props
}: CodeBlockLanguageSelectorTriggerProps) => (
<SelectTrigger
className={cn(
"h-7 border-none bg-transparent px-2 text-xs shadow-none",
className
)}
size="sm"
{...props}
/>
);
export type CodeBlockLanguageSelectorValueProps = ComponentProps<
typeof SelectValue
>;
export const CodeBlockLanguageSelectorValue = (
props: CodeBlockLanguageSelectorValueProps
) => <SelectValue {...props} />;
export type CodeBlockLanguageSelectorContentProps = ComponentProps<
typeof SelectContent
>;
export const CodeBlockLanguageSelectorContent = ({
align = "end",
...props
}: CodeBlockLanguageSelectorContentProps) => (
<SelectContent align={align} {...props} />
);
export type CodeBlockLanguageSelectorItemProps = ComponentProps<
typeof SelectItem
>;
export const CodeBlockLanguageSelectorItem = (
props: CodeBlockLanguageSelectorItemProps
) => <SelectItem {...props} />;

View file

@ -0,0 +1,167 @@
"use client";
import type { ComponentProps } from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { ArrowDownIcon, DownloadIcon } from "lucide-react";
import { useCallback } from "react";
import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom";
export type ConversationProps = ComponentProps<typeof StickToBottom>;
export const Conversation = ({ className, ...props }: ConversationProps) => (
<StickToBottom
className={cn("relative flex-1 overflow-y-hidden", className)}
initial="smooth"
resize="smooth"
role="log"
{...props}
/>
);
export type ConversationContentProps = ComponentProps<
typeof StickToBottom.Content
>;
export const ConversationContent = ({
className,
...props
}: ConversationContentProps) => (
<StickToBottom.Content
className={cn("flex flex-col gap-8 p-4", className)}
{...props}
/>
);
export type ConversationEmptyStateProps = ComponentProps<"div"> & {
title?: string;
description?: string;
icon?: React.ReactNode;
};
export const ConversationEmptyState = ({
className,
title = "No messages yet",
description = "Start a conversation to see messages here",
icon,
children,
...props
}: ConversationEmptyStateProps) => (
<div
className={cn(
"flex size-full flex-col items-center justify-center gap-3 p-8 text-center",
className
)}
{...props}
>
{children ?? (
<>
{icon && <div className="text-muted-foreground">{icon}</div>}
<div className="space-y-1">
<h3 className="font-medium text-sm">{title}</h3>
{description && (
<p className="text-muted-foreground text-sm">{description}</p>
)}
</div>
</>
)}
</div>
);
export type ConversationScrollButtonProps = ComponentProps<typeof Button>;
export const ConversationScrollButton = ({
className,
...props
}: ConversationScrollButtonProps) => {
const { isAtBottom, scrollToBottom } = useStickToBottomContext();
const handleScrollToBottom = useCallback(() => {
scrollToBottom();
}, [scrollToBottom]);
return (
!isAtBottom && (
<Button
className={cn(
"absolute bottom-4 left-[50%] translate-x-[-50%] rounded-full dark:bg-background dark:hover:bg-muted",
className
)}
onClick={handleScrollToBottom}
size="icon"
type="button"
variant="outline"
{...props}
>
<ArrowDownIcon className="size-4" />
</Button>
)
);
};
export interface ConversationMessage {
role: "user" | "assistant" | "system" | "data" | "tool";
content: string;
}
export type ConversationDownloadProps = Omit<
ComponentProps<typeof Button>,
"onClick"
> & {
messages: ConversationMessage[];
filename?: string;
formatMessage?: (message: ConversationMessage, index: number) => string;
};
const defaultFormatMessage = (message: ConversationMessage): string => {
const roleLabel =
message.role.charAt(0).toUpperCase() + message.role.slice(1);
return `**${roleLabel}:** ${message.content}`;
};
export const messagesToMarkdown = (
messages: ConversationMessage[],
formatMessage: (
message: ConversationMessage,
index: number
) => string = defaultFormatMessage
): string => messages.map((msg, i) => formatMessage(msg, i)).join("\n\n");
export const ConversationDownload = ({
messages,
filename = "conversation.md",
formatMessage = defaultFormatMessage,
className,
children,
...props
}: ConversationDownloadProps) => {
const handleDownload = useCallback(() => {
const markdown = messagesToMarkdown(messages, formatMessage);
const blob = new Blob([markdown], { type: "text/markdown" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.append(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
}, [messages, filename, formatMessage]);
return (
<Button
className={cn(
"absolute top-4 right-4 rounded-full dark:bg-background dark:hover:bg-muted",
className
)}
onClick={handleDownload}
size="icon"
type="button"
variant="outline"
{...props}
>
{children ?? <DownloadIcon className="size-4" />}
</Button>
);
};

View file

@ -0,0 +1,357 @@
"use client";
import type { UIMessage } from "ai";
import type { ComponentProps, HTMLAttributes, ReactElement } from "react";
import { Button } from "@/components/ui/button";
import {
ButtonGroup,
ButtonGroupText,
} from "@/components/ui/button-group";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { cjk } from "@streamdown/cjk";
import { code } from "@streamdown/code";
import { math } from "@streamdown/math";
import { mermaid } from "@streamdown/mermaid";
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
import {
createContext,
memo,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from "react";
import { Streamdown } from "streamdown";
export type MessageProps = HTMLAttributes<HTMLDivElement> & {
from: UIMessage["role"];
};
export const Message = ({ className, from, ...props }: MessageProps) => (
<div
className={cn(
"group flex w-full max-w-[95%] flex-col gap-2",
from === "user" ? "is-user ml-auto justify-end" : "is-assistant",
className
)}
{...props}
/>
);
export type MessageContentProps = HTMLAttributes<HTMLDivElement>;
export const MessageContent = ({
children,
className,
...props
}: MessageContentProps) => (
<div
className={cn(
"flex min-w-0 max-w-full flex-col gap-2 overflow-hidden text-sm text-foreground",
className
)}
{...props}
>
{children}
</div>
);
export type MessageActionsProps = ComponentProps<"div">;
export const MessageActions = ({
className,
children,
...props
}: MessageActionsProps) => (
<div className={cn("flex items-center gap-1", className)} {...props}>
{children}
</div>
);
export type MessageActionProps = ComponentProps<typeof Button> & {
tooltip?: string;
label?: string;
};
export const MessageAction = ({
tooltip,
children,
label,
variant = "ghost",
size = "icon-sm",
...props
}: MessageActionProps) => {
const button = (
<Button size={size} type="button" variant={variant} {...props}>
{children}
<span className="sr-only">{label || tooltip}</span>
</Button>
);
if (tooltip) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent>
<p>{tooltip}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
return button;
};
interface MessageBranchContextType {
currentBranch: number;
totalBranches: number;
goToPrevious: () => void;
goToNext: () => void;
branches: ReactElement[];
setBranches: (branches: ReactElement[]) => void;
}
const MessageBranchContext = createContext<MessageBranchContextType | null>(
null
);
const useMessageBranch = () => {
const context = useContext(MessageBranchContext);
if (!context) {
throw new Error(
"MessageBranch components must be used within MessageBranch"
);
}
return context;
};
export type MessageBranchProps = HTMLAttributes<HTMLDivElement> & {
defaultBranch?: number;
onBranchChange?: (branchIndex: number) => void;
};
export const MessageBranch = ({
defaultBranch = 0,
onBranchChange,
className,
...props
}: MessageBranchProps) => {
const [currentBranch, setCurrentBranch] = useState(defaultBranch);
const [branches, setBranches] = useState<ReactElement[]>([]);
const handleBranchChange = useCallback(
(newBranch: number) => {
setCurrentBranch(newBranch);
onBranchChange?.(newBranch);
},
[onBranchChange]
);
const goToPrevious = useCallback(() => {
const newBranch =
currentBranch > 0 ? currentBranch - 1 : branches.length - 1;
handleBranchChange(newBranch);
}, [currentBranch, branches.length, handleBranchChange]);
const goToNext = useCallback(() => {
const newBranch =
currentBranch < branches.length - 1 ? currentBranch + 1 : 0;
handleBranchChange(newBranch);
}, [currentBranch, branches.length, handleBranchChange]);
const contextValue = useMemo<MessageBranchContextType>(
() => ({
branches,
currentBranch,
goToNext,
goToPrevious,
setBranches,
totalBranches: branches.length,
}),
[branches, currentBranch, goToNext, goToPrevious]
);
return (
<MessageBranchContext.Provider value={contextValue}>
<div
className={cn("grid w-full gap-2 [&>div]:pb-0", className)}
{...props}
/>
</MessageBranchContext.Provider>
);
};
export type MessageBranchContentProps = HTMLAttributes<HTMLDivElement>;
export const MessageBranchContent = ({
children,
...props
}: MessageBranchContentProps) => {
const { currentBranch, setBranches, branches } = useMessageBranch();
const childrenArray = useMemo(
() => (Array.isArray(children) ? children : [children]),
[children]
);
// Use useEffect to update branches when they change
useEffect(() => {
if (branches.length !== childrenArray.length) {
setBranches(childrenArray);
}
}, [childrenArray, branches, setBranches]);
return childrenArray.map((branch, index) => (
<div
className={cn(
"grid gap-2 overflow-hidden [&>div]:pb-0",
index === currentBranch ? "block" : "hidden"
)}
key={branch.key}
{...props}
>
{branch}
</div>
));
};
export type MessageBranchSelectorProps = ComponentProps<typeof ButtonGroup>;
export const MessageBranchSelector = ({
className,
...props
}: MessageBranchSelectorProps) => {
const { totalBranches } = useMessageBranch();
// Don't render if there's only one branch
if (totalBranches <= 1) {
return null;
}
return (
<ButtonGroup
className={cn(
"[&>*:not(:first-child)]:rounded-l-md [&>*:not(:last-child)]:rounded-r-md",
className
)}
orientation="horizontal"
{...props}
/>
);
};
export type MessageBranchPreviousProps = ComponentProps<typeof Button>;
export const MessageBranchPrevious = ({
children,
...props
}: MessageBranchPreviousProps) => {
const { goToPrevious, totalBranches } = useMessageBranch();
return (
<Button
aria-label="Previous branch"
disabled={totalBranches <= 1}
onClick={goToPrevious}
size="icon-sm"
type="button"
variant="ghost"
{...props}
>
{children ?? <ChevronLeftIcon size={14} />}
</Button>
);
};
export type MessageBranchNextProps = ComponentProps<typeof Button>;
export const MessageBranchNext = ({
children,
...props
}: MessageBranchNextProps) => {
const { goToNext, totalBranches } = useMessageBranch();
return (
<Button
aria-label="Next branch"
disabled={totalBranches <= 1}
onClick={goToNext}
size="icon-sm"
type="button"
variant="ghost"
{...props}
>
{children ?? <ChevronRightIcon size={14} />}
</Button>
);
};
export type MessageBranchPageProps = HTMLAttributes<HTMLSpanElement>;
export const MessageBranchPage = ({
className,
...props
}: MessageBranchPageProps) => {
const { currentBranch, totalBranches } = useMessageBranch();
return (
<ButtonGroupText
className={cn(
"border-none bg-transparent text-muted-foreground shadow-none",
className
)}
{...props}
>
{currentBranch + 1} of {totalBranches}
</ButtonGroupText>
);
};
export type MessageResponseProps = ComponentProps<typeof Streamdown>;
const streamdownPlugins = { cjk, code, math, mermaid };
export const MessageResponse = memo(
({ className, ...props }: MessageResponseProps) => (
<Streamdown
className={cn(
"size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
className
)}
plugins={streamdownPlugins}
{...props}
/>
),
(prevProps, nextProps) => prevProps.children === nextProps.children
);
MessageResponse.displayName = "MessageResponse";
export type MessageToolbarProps = ComponentProps<"div">;
export const MessageToolbar = ({
className,
children,
...props
}: MessageToolbarProps) => (
<div
className={cn(
"mt-4 flex w-full items-center justify-between gap-4",
className
)}
{...props}
>
{children}
</div>
);

View file

@ -0,0 +1,208 @@
import type { ComponentProps, ReactNode } from "react";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
CommandShortcut,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import type { Popover as PopoverPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
export type ModelSelectorProps = React.ComponentProps<typeof PopoverPrimitive.Root>;
export const ModelSelector = (props: ModelSelectorProps) => (
<Popover {...props} />
);
export type ModelSelectorTriggerProps = ComponentProps<typeof PopoverTrigger>;
export const ModelSelectorTrigger = (props: ModelSelectorTriggerProps) => (
<PopoverTrigger {...props} />
);
export type ModelSelectorContentProps = ComponentProps<typeof PopoverContent> & {
title?: ReactNode;
};
export const ModelSelectorContent = ({
className,
children,
title: _title,
...props
}: ModelSelectorContentProps) => (
<PopoverContent
align="start"
className={cn(
"w-[280px] p-0 rounded-xl border border-border/60 bg-card/95 backdrop-blur-xl shadow-[var(--shadow-float)]",
className
)}
side="top"
sideOffset={8}
{...props}
>
<Command className="**:data-[slot=command-input-wrapper]:h-auto">
{children}
</Command>
</PopoverContent>
);
export type ModelSelectorInputProps = ComponentProps<typeof CommandInput>;
export const ModelSelectorInput = ({
className,
...props
}: ModelSelectorInputProps) => (
<CommandInput className={cn("h-auto py-2.5 text-[13px]", className)} {...props} />
);
export type ModelSelectorListProps = ComponentProps<typeof CommandList>;
export const ModelSelectorList = ({ className, ...props }: ModelSelectorListProps) => (
<CommandList className={cn("max-h-[280px]", className)} {...props} />
);
export type ModelSelectorEmptyProps = ComponentProps<typeof CommandEmpty>;
export const ModelSelectorEmpty = (props: ModelSelectorEmptyProps) => (
<CommandEmpty {...props} />
);
export type ModelSelectorGroupProps = ComponentProps<typeof CommandGroup>;
export const ModelSelectorGroup = (props: ModelSelectorGroupProps) => (
<CommandGroup {...props} />
);
export type ModelSelectorItemProps = ComponentProps<typeof CommandItem>;
export const ModelSelectorItem = ({ className, ...props }: ModelSelectorItemProps) => (
<CommandItem className={cn("w-full text-[13px] rounded-lg", className)} {...props} />
);
export type ModelSelectorShortcutProps = ComponentProps<typeof CommandShortcut>;
export const ModelSelectorShortcut = (props: ModelSelectorShortcutProps) => (
<CommandShortcut {...props} />
);
export type ModelSelectorSeparatorProps = ComponentProps<
typeof CommandSeparator
>;
export const ModelSelectorSeparator = (props: ModelSelectorSeparatorProps) => (
<CommandSeparator {...props} />
);
export type ModelSelectorLogoProps = Omit<
ComponentProps<"img">,
"src" | "alt"
> & {
provider:
| "moonshotai-cn"
| "lucidquery"
| "moonshotai"
| "zai-coding-plan"
| "alibaba"
| "xai"
| "vultr"
| "nvidia"
| "upstage"
| "groq"
| "github-copilot"
| "mistral"
| "vercel"
| "nebius"
| "deepseek"
| "alibaba-cn"
| "google-vertex-anthropic"
| "venice"
| "chutes"
| "cortecs"
| "github-models"
| "togetherai"
| "azure"
| "baseten"
| "huggingface"
| "opencode"
| "fastrouter"
| "google"
| "google-vertex"
| "cloudflare-workers-ai"
| "inception"
| "wandb"
| "openai"
| "zhipuai-coding-plan"
| "perplexity"
| "openrouter"
| "zenmux"
| "v0"
| "iflowcn"
| "synthetic"
| "deepinfra"
| "zhipuai"
| "submodel"
| "zai"
| "inference"
| "requesty"
| "morph"
| "lmstudio"
| "anthropic"
| "aihubmix"
| "fireworks-ai"
| "modelscope"
| "llama"
| "scaleway"
| "amazon-bedrock"
| "cerebras"
// oxlint-disable-next-line typescript-eslint(ban-types) -- intentional pattern for autocomplete-friendly string union
| (string & {});
};
export const ModelSelectorLogo = ({
provider,
className,
...props
}: ModelSelectorLogoProps) => (
<img
{...props}
alt={`${provider} logo`}
className={cn("size-4 dark:invert", className)}
height={16}
src={`https://models.dev/logos/${provider}.svg`}
width={16}
/>
);
export type ModelSelectorLogoGroupProps = ComponentProps<"div">;
export const ModelSelectorLogoGroup = ({
className,
...props
}: ModelSelectorLogoGroupProps) => (
<div
className={cn(
"flex shrink-0 items-center -space-x-1 [&>img]:rounded-full [&>img]:p-px [&>img]:ring-1 [&>img]:ring-border/30",
className
)}
{...props}
/>
);
export type ModelSelectorNameProps = ComponentProps<"span">;
export const ModelSelectorName = ({
className,
...props
}: ModelSelectorNameProps) => (
<span className={cn("flex-1 truncate text-left", className)} {...props} />
);

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,241 @@
"use client";
import type { ComponentProps, HTMLAttributes, ReactNode } from "react";
import { useControllableState } from "@radix-ui/react-use-controllable-state";
import {
Collapsible,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { cn } from "@/lib/utils";
import { cjk } from "@streamdown/cjk";
import { code } from "@streamdown/code";
import { math } from "@streamdown/math";
import { mermaid } from "@streamdown/mermaid";
import { ChevronDownIcon } from "lucide-react";
import {
createContext,
memo,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { Streamdown } from "streamdown";
import { Shimmer } from "./shimmer";
interface ReasoningContextValue {
isStreaming: boolean;
isOpen: boolean;
setIsOpen: (open: boolean) => void;
duration: number | undefined;
}
const ReasoningContext = createContext<ReasoningContextValue | null>(null);
export const useReasoning = () => {
const context = useContext(ReasoningContext);
if (!context) {
throw new Error("Reasoning components must be used within Reasoning");
}
return context;
};
export type ReasoningProps = ComponentProps<typeof Collapsible> & {
isStreaming?: boolean;
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
duration?: number;
};
const AUTO_CLOSE_DELAY = 1000;
const MS_IN_S = 1000;
export const Reasoning = memo(
({
className,
isStreaming = false,
open,
defaultOpen,
onOpenChange,
duration: durationProp,
children,
...props
}: ReasoningProps) => {
const resolvedDefaultOpen = defaultOpen ?? isStreaming;
// Track if defaultOpen was explicitly set to false (to prevent auto-open)
const isExplicitlyClosed = defaultOpen === false;
const [isOpen, setIsOpen] = useControllableState<boolean>({
defaultProp: resolvedDefaultOpen,
onChange: onOpenChange,
prop: open,
});
const [duration, setDuration] = useControllableState<number | undefined>({
defaultProp: undefined,
prop: durationProp,
});
const hasEverStreamedRef = useRef(isStreaming);
const [hasAutoClosed, setHasAutoClosed] = useState(false);
const startTimeRef = useRef<number | null>(null);
// Track when streaming starts and compute duration
useEffect(() => {
if (isStreaming) {
hasEverStreamedRef.current = true;
if (startTimeRef.current === null) {
startTimeRef.current = Date.now();
}
} else if (startTimeRef.current !== null) {
setDuration(Math.ceil((Date.now() - startTimeRef.current) / MS_IN_S));
startTimeRef.current = null;
}
}, [isStreaming, setDuration]);
// Auto-open when streaming starts (unless explicitly closed)
useEffect(() => {
if (isStreaming && !isOpen && !isExplicitlyClosed) {
setIsOpen(true);
}
}, [isStreaming, isOpen, setIsOpen, isExplicitlyClosed]);
// Auto-close when streaming ends (once only, and only if it ever streamed)
useEffect(() => {
if (
hasEverStreamedRef.current &&
!isStreaming &&
isOpen &&
!hasAutoClosed
) {
const timer = setTimeout(() => {
setIsOpen(false);
setHasAutoClosed(true);
}, AUTO_CLOSE_DELAY);
return () => clearTimeout(timer);
}
}, [isStreaming, isOpen, setIsOpen, hasAutoClosed]);
const handleOpenChange = useCallback(
(newOpen: boolean) => {
setIsOpen(newOpen);
},
[setIsOpen]
);
const contextValue = useMemo(
() => ({ duration, isOpen, isStreaming, setIsOpen }),
[duration, isOpen, isStreaming, setIsOpen]
);
return (
<ReasoningContext.Provider value={contextValue}>
<Collapsible
className={cn("not-prose", className)}
onOpenChange={handleOpenChange}
open={isOpen}
{...props}
>
{children}
</Collapsible>
</ReasoningContext.Provider>
);
}
);
export type ReasoningTriggerProps = ComponentProps<
typeof CollapsibleTrigger
> & {
getThinkingMessage?: (isStreaming: boolean, duration?: number) => ReactNode;
};
const defaultGetThinkingMessage = (isStreaming: boolean, duration?: number) => {
if (isStreaming || duration === 0) {
return <Shimmer className="font-medium" duration={1}>Thinking...</Shimmer>;
}
if (duration === undefined) {
return <p>Thought for a few seconds</p>;
}
return <p>Thought for {duration} seconds</p>;
};
export const ReasoningTrigger = memo(
({
className,
children,
getThinkingMessage = defaultGetThinkingMessage,
...props
}: ReasoningTriggerProps) => {
const { isStreaming, isOpen, duration } = useReasoning();
return (
<CollapsibleTrigger
className={cn(
"flex w-full items-center gap-2 text-muted-foreground text-[13px] leading-[1.65] transition-colors hover:text-foreground",
className
)}
{...props}
>
{children ?? (
<>
{getThinkingMessage(isStreaming, duration)}
<ChevronDownIcon
className={cn(
"size-4 transition-transform",
isOpen ? "rotate-180" : "rotate-0"
)}
/>
</>
)}
</CollapsibleTrigger>
);
}
);
export type ReasoningContentProps = HTMLAttributes<HTMLDivElement> & {
children: string;
};
const streamdownPlugins = { cjk, code, math, mermaid };
export const ReasoningContent = memo(
({ className, children, dir: _dir, ...props }: ReasoningContentProps) => {
const { isStreaming, isOpen } = useReasoning();
const scrollRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (isStreaming && scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [children, isStreaming]);
if (!isOpen) return null;
return (
<div
className={cn(
"mt-2 animate-in fade-in-0 duration-200 text-muted-foreground/60 [overflow-anchor:none]",
className
)}
>
<div
className="max-h-[200px] overflow-y-auto rounded-lg border border-border/20 bg-muted/30 px-3 py-2 text-[11px] leading-relaxed"
ref={scrollRef}
style={{ scrollbarWidth: "none", msOverflowStyle: "none" }}
>
<Streamdown plugins={streamdownPlugins} {...(props as object)}>
{children}
</Streamdown>
</div>
</div>
);
}
);
Reasoning.displayName = "Reasoning";
ReasoningTrigger.displayName = "ReasoningTrigger";
ReasoningContent.displayName = "ReasoningContent";

View file

@ -0,0 +1,78 @@
"use client";
import type { MotionProps } from "motion/react";
import type { CSSProperties, ElementType, JSX } from "react";
import { cn } from "@/lib/utils";
import { motion } from "motion/react";
import { memo, useMemo } from "react";
type MotionHTMLProps = MotionProps & Record<string, unknown>;
// Cache motion components at module level to avoid creating during render
const motionComponentCache = new Map<
keyof JSX.IntrinsicElements,
React.ComponentType<MotionHTMLProps>
>();
const getMotionComponent = (element: keyof JSX.IntrinsicElements) => {
let component = motionComponentCache.get(element);
if (!component) {
component = motion.create(element);
motionComponentCache.set(element, component);
}
return component;
};
export interface TextShimmerProps {
children: string;
as?: ElementType;
className?: string;
duration?: number;
spread?: number;
}
const ShimmerComponent = ({
children,
as: Component = "p",
className,
duration = 2,
spread = 2,
}: TextShimmerProps) => {
const MotionComponent = getMotionComponent(
Component as keyof JSX.IntrinsicElements
);
const dynamicSpread = useMemo(
() => (children?.length ?? 0) * spread,
[children, spread]
);
return (
<MotionComponent
animate={{ backgroundPosition: "0% center" }}
className={cn(
"relative inline-block bg-[length:250%_100%,auto] bg-clip-text text-transparent",
"[--bg:linear-gradient(90deg,#0000_calc(50%-var(--spread)),var(--color-background),#0000_calc(50%+var(--spread)))] [background-repeat:no-repeat,padding-box]",
className
)}
initial={{ backgroundPosition: "100% center" }}
style={
{
"--spread": `${dynamicSpread}px`,
backgroundImage:
"var(--bg), linear-gradient(var(--color-muted-foreground), var(--color-muted-foreground))",
} as CSSProperties
}
transition={{
duration,
ease: "linear",
repeat: Number.POSITIVE_INFINITY,
}}
>
{children}
</MotionComponent>
);
};
export const Shimmer = memo(ShimmerComponent);

View file

@ -0,0 +1,58 @@
"use client";
import type { ComponentProps } from "react";
import { Button } from "@/components/ui/button";
import {
ScrollArea,
ScrollBar,
} from "@/components/ui/scroll-area";
import { cn } from "@/lib/utils";
import { useCallback } from "react";
export type SuggestionsProps = ComponentProps<typeof ScrollArea>;
export const Suggestions = ({
className,
children,
...props
}: SuggestionsProps) => (
<ScrollArea className="w-full overflow-x-auto whitespace-nowrap" {...props}>
<div className={cn("flex w-max flex-nowrap items-center gap-2", className)}>
{children}
</div>
<ScrollBar className="hidden" orientation="horizontal" />
</ScrollArea>
);
export type SuggestionProps = Omit<ComponentProps<typeof Button>, "onClick"> & {
suggestion: string;
onClick?: (suggestion: string) => void;
};
export const Suggestion = ({
suggestion,
onClick,
className,
variant = "outline",
size = "sm",
children,
...props
}: SuggestionProps) => {
const handleClick = useCallback(() => {
onClick?.(suggestion);
}, [onClick, suggestion]);
return (
<Button
className={cn("cursor-pointer rounded-full px-4", className)}
onClick={handleClick}
size={size}
type="button"
variant={variant}
{...props}
>
{children || suggestion}
</Button>
);
};

View file

@ -0,0 +1,172 @@
"use client";
import type { DynamicToolUIPart, ToolUIPart } from "ai";
import type { ComponentProps, ReactNode } from "react";
import { Badge } from "@/components/ui/badge";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { cn } from "@/lib/utils";
import {
CheckCircleIcon,
ChevronDownIcon,
CircleIcon,
ClockIcon,
WrenchIcon,
XCircleIcon,
} from "lucide-react";
import { isValidElement } from "react";
import { CodeBlock } from "./code-block";
export type ToolProps = ComponentProps<typeof Collapsible>;
export const Tool = ({ className, ...props }: ToolProps) => (
<Collapsible
className={cn("group not-prose mb-4 w-full rounded-md border", className)}
{...props}
/>
);
export type ToolPart = ToolUIPart | DynamicToolUIPart;
export type ToolHeaderProps = {
title?: string;
className?: string;
} & (
| { type: ToolUIPart["type"]; state: ToolUIPart["state"]; toolName?: never }
| {
type: DynamicToolUIPart["type"];
state: DynamicToolUIPart["state"];
toolName: string;
}
);
const statusLabels: Record<ToolPart["state"], string> = {
"approval-requested": "Awaiting Approval",
"approval-responded": "Responded",
"input-available": "Running",
"input-streaming": "Pending",
"output-available": "Completed",
"output-denied": "Denied",
"output-error": "Error",
};
const statusIcons: Record<ToolPart["state"], ReactNode> = {
"approval-requested": <ClockIcon className="size-4 text-yellow-600" />,
"approval-responded": <CheckCircleIcon className="size-4 text-blue-600" />,
"input-available": <ClockIcon className="size-4 animate-pulse" />,
"input-streaming": <CircleIcon className="size-4" />,
"output-available": <CheckCircleIcon className="size-4 text-green-600" />,
"output-denied": <XCircleIcon className="size-4 text-orange-600" />,
"output-error": <XCircleIcon className="size-4 text-red-600" />,
};
export const getStatusBadge = (status: ToolPart["state"]) => (
<Badge className="gap-1.5 rounded-full text-xs" variant="secondary">
{statusIcons[status]}
{statusLabels[status]}
</Badge>
);
export const ToolHeader = ({
className,
title,
type,
state,
toolName,
...props
}: ToolHeaderProps) => {
const derivedName =
type === "dynamic-tool" ? toolName : type.split("-").slice(1).join("-");
return (
<CollapsibleTrigger
className={cn(
"flex w-full items-center justify-between gap-4 p-3",
className
)}
{...props}
>
<div className="flex items-center gap-2">
<WrenchIcon className="size-4 text-muted-foreground" />
<span className="font-medium text-sm">{title ?? derivedName}</span>
{getStatusBadge(state)}
</div>
<ChevronDownIcon className="size-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
</CollapsibleTrigger>
);
};
export type ToolContentProps = ComponentProps<typeof CollapsibleContent>;
export const ToolContent = ({ className, ...props }: ToolContentProps) => (
<CollapsibleContent
className={cn(
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 space-y-4 p-4 text-popover-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
className
)}
{...props}
/>
);
export type ToolInputProps = ComponentProps<"div"> & {
input: ToolPart["input"];
};
export const ToolInput = ({ className, input, ...props }: ToolInputProps) => (
<div className={cn("space-y-2 overflow-hidden", className)} {...props}>
<h4 className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
Parameters
</h4>
<div className="rounded-md bg-muted/50">
<CodeBlock code={JSON.stringify(input, null, 2)} language="json" />
</div>
</div>
);
export type ToolOutputProps = ComponentProps<"div"> & {
output: ToolPart["output"];
errorText: ToolPart["errorText"];
};
export const ToolOutput = ({
className,
output,
errorText,
...props
}: ToolOutputProps) => {
if (!(output || errorText)) {
return null;
}
let Output = <div>{output as ReactNode}</div>;
if (typeof output === "object" && !isValidElement(output)) {
Output = (
<CodeBlock code={JSON.stringify(output, null, 2)} language="json" />
);
} else if (typeof output === "string") {
Output = <CodeBlock code={output} language="json" />;
}
return (
<div className={cn("space-y-2", className)} {...props}>
<h4 className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
{errorText ? "Error" : "Result"}
</h4>
<div
className={cn(
"overflow-x-auto rounded-md text-xs [&_table]:w-full",
errorText && "bg-destructive/10 text-destructive"
)}
>
{errorText && <div>{errorText}</div>}
{Output}
</div>
</div>
);
};

View file

@ -0,0 +1,165 @@
"use client";
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 { SidebarUserNav } from "@/components/chat/sidebar-user-nav";
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarRail,
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 (
<>
<Sidebar collapsible="icon">
<SidebarHeader className="pb-0 pt-3">
<SidebarMenu>
<SidebarMenuItem className="flex flex-row items-center justify-between">
<div className="group/logo relative flex items-center justify-center">
<SidebarMenuButton
asChild
className="size-8 !px-0 items-center justify-center group-data-[collapsible=icon]:group-hover/logo:opacity-0"
tooltip="Chatbot"
>
<Link href="/" onClick={() => setOpenMobile(false)}>
<MessageSquareIcon className="size-4 text-sidebar-foreground/50" />
</Link>
</SidebarMenuButton>
<Tooltip>
<TooltipTrigger asChild>
<SidebarMenuButton
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()}
>
<PanelLeftIcon className="size-4" />
</SidebarMenuButton>
</TooltipTrigger>
<TooltipContent className="hidden md:block" side="right">
Open sidebar
</TooltipContent>
</Tooltip>
</div>
<div className="group-data-[collapsible=icon]:hidden">
<SidebarTrigger className="text-sidebar-foreground/60 transition-colors duration-150 hover:text-sidebar-foreground" />
</div>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
<SidebarGroup className="pt-1">
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
className="h-8 rounded-lg border border-sidebar-border text-[13px] text-sidebar-foreground/70 transition-colors duration-150 hover:bg-sidebar-accent/50 hover:text-sidebar-foreground"
onClick={() => {
setOpenMobile(false);
router.push("/");
}}
tooltip="New Chat"
>
<PenSquareIcon className="size-4" />
<span className="font-medium">New chat</span>
</SidebarMenuButton>
</SidebarMenuItem>
{user && (
<SidebarMenuItem>
<SidebarMenuButton
className="rounded-lg text-sidebar-foreground/40 transition-colors duration-150 hover:bg-destructive/10 hover:text-destructive"
onClick={() => setShowDeleteAllDialog(true)}
tooltip="Delete All Chats"
>
<TrashIcon className="size-4" />
<span className="text-[13px]">Delete all</span>
</SidebarMenuButton>
</SidebarMenuItem>
)}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
<SidebarHistory user={user} />
</SidebarContent>
<SidebarFooter className="border-t border-sidebar-border pt-2 pb-3">
{user && <SidebarUserNav user={user} />}
</SidebarFooter>
<SidebarRail />
</Sidebar>
<AlertDialog
onOpenChange={setShowDeleteAllDialog}
open={showDeleteAllDialog}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete all chats?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete all
your chats and remove them from our servers.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleDeleteAll}>
Delete All
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}

View file

@ -0,0 +1,53 @@
import Form from "next/form";
import { Input } from "../ui/input";
import { Label } from "../ui/label";
export function AuthForm({
action,
children,
defaultEmail = "",
}: {
action: NonNullable<
string | ((formData: FormData) => void | Promise<void>) | undefined
>;
children: React.ReactNode;
defaultEmail?: string;
}) {
return (
<Form action={action} className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Label className="font-normal text-muted-foreground" htmlFor="email">
Email
</Label>
<Input
autoComplete="email"
autoFocus
className="h-10 rounded-lg border-border/50 bg-muted/50 text-sm transition-colors focus:border-foreground/20 focus:bg-muted"
defaultValue={defaultEmail}
id="email"
name="email"
placeholder="you@someo.ne"
required
type="email"
/>
</div>
<div className="flex flex-col gap-2">
<Label className="font-normal text-muted-foreground" htmlFor="password">
Password
</Label>
<Input
className="h-10 rounded-lg border-border/50 bg-muted/50 text-sm transition-colors focus:border-foreground/20 focus:bg-muted"
id="password"
name="password"
placeholder="&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;"
required
type="password"
/>
</div>
{children}
</Form>
);
}

View file

@ -0,0 +1,76 @@
"use client";
import { PanelLeftIcon } from "lucide-react";
import Link from "next/link";
import { memo } from "react";
import { Button } from "@/components/ui/button";
import { useSidebar } from "@/components/ui/sidebar";
import { VercelIcon } from "./icons";
import { VisibilitySelector, type VisibilityType } from "./visibility-selector";
function PureChatHeader({
chatId,
selectedVisibilityType,
isReadonly,
}: {
chatId: string;
selectedVisibilityType: VisibilityType;
isReadonly: boolean;
}) {
const { state, toggleSidebar, isMobile } = useSidebar();
if (state === "collapsed" && !isMobile) {
return null;
}
return (
<header className="sticky top-0 flex h-14 items-center gap-2 bg-sidebar px-3">
<Button
className="md:hidden"
onClick={toggleSidebar}
size="icon-sm"
variant="ghost"
>
<PanelLeftIcon className="size-4" />
</Button>
<Link
className="flex size-8 items-center justify-center rounded-lg md:hidden"
href="https://vercel.com/templates/next.js/chatbot"
rel="noopener noreferrer"
target="_blank"
>
<VercelIcon size={14} />
</Link>
{!isReadonly && (
<VisibilitySelector
chatId={chatId}
selectedVisibilityType={selectedVisibilityType}
/>
)}
<Button
asChild
className="hidden rounded-lg bg-foreground px-4 text-background hover:bg-foreground/90 md:ml-auto md:flex"
>
<Link
href="https://vercel.com/templates/next.js/chatbot"
rel="noopener noreferrer"
target="_blank"
>
<VercelIcon size={16} />
Deploy with Vercel
</Link>
</Button>
</header>
);
}
export const ChatHeader = memo(PureChatHeader, (prevProps, nextProps) => {
return (
prevProps.chatId === nextProps.chatId &&
prevProps.selectedVisibilityType === nextProps.selectedVisibilityType &&
prevProps.isReadonly === nextProps.isReadonly
);
});

View file

@ -0,0 +1,29 @@
"use client";
import { useEffect } from "react";
import { useSWRConfig } from "swr";
import { unstable_serialize } from "swr/infinite";
import { useDataStream } from "./data-stream-provider";
import { getChatHistoryPaginationKey } from "./sidebar-history";
export function DataStreamHandler() {
const { dataStream, setDataStream } = useDataStream();
const { mutate } = useSWRConfig();
useEffect(() => {
if (!dataStream?.length) {
return;
}
const newDeltas = dataStream.slice();
setDataStream([]);
for (const delta of newDeltas) {
if (delta.type === "data-chat-title") {
mutate(unstable_serialize(getChatHistoryPaginationKey));
}
}
}, [dataStream, setDataStream, mutate]);
return null;
}

View file

@ -0,0 +1,41 @@
"use client";
import type { DataUIPart } from "ai";
import type React from "react";
import { createContext, useContext, useMemo, useState } from "react";
import type { CustomUIDataTypes } from "@/lib/types";
type DataStreamContextValue = {
dataStream: DataUIPart<CustomUIDataTypes>[];
setDataStream: React.Dispatch<
React.SetStateAction<DataUIPart<CustomUIDataTypes>[]>
>;
};
const DataStreamContext = createContext<DataStreamContextValue | null>(null);
export function DataStreamProvider({
children,
}: {
children: React.ReactNode;
}) {
const [dataStream, setDataStream] = useState<DataUIPart<CustomUIDataTypes>[]>(
[]
);
const value = useMemo(() => ({ dataStream, setDataStream }), [dataStream]);
return (
<DataStreamContext.Provider value={value}>
{children}
</DataStreamContext.Provider>
);
}
export function useDataStream() {
const context = useContext(DataStreamContext);
if (!context) {
throw new Error("useDataStream must be used within a DataStreamProvider");
}
return context;
}

View file

@ -0,0 +1,24 @@
import { motion } from "framer-motion";
export const Greeting = () => {
return (
<div className="flex flex-col items-center px-4" key="overview">
<motion.div
animate={{ opacity: 1, y: 0 }}
className="text-center font-semibold text-2xl tracking-tight text-foreground md:text-3xl"
initial={{ opacity: 0, y: 10 }}
transition={{ delay: 0.35, duration: 0.5, ease: [0.22, 1, 0.36, 1] }}
>
What can I help with?
</motion.div>
<motion.div
animate={{ opacity: 1, y: 0 }}
className="mt-3 text-center text-muted-foreground/80 text-sm"
initial={{ opacity: 0, y: 10 }}
transition={{ delay: 0.5, duration: 0.5, ease: [0.22, 1, 0.36, 1] }}
>
Ask a question, write code, or explore ideas.
</motion.div>
</div>
);
};

1213
components/chat/icons.tsx Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,207 @@
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 { ChatMessage } from "@/lib/types";
import {
MessageAction as Action,
MessageActions as Actions,
} from "../ai-elements/message";
import { CopyIcon, PencilEditIcon, ThumbDownIcon, ThumbUpIcon } from "./icons";
export function PureMessageActions({
chatId,
message,
vote,
isLoading,
onEdit,
}: {
chatId: string;
message: ChatMessage;
vote: Vote | undefined;
isLoading: boolean;
onEdit?: () => void;
}) {
const { mutate } = useSWRConfig();
const [_, copyToClipboard] = useCopyToClipboard();
if (isLoading) {
return null;
}
const textFromParts = message.parts
?.filter((part) => part.type === "text")
.map((part) => part.text)
.join("\n")
.trim();
const handleCopy = async () => {
if (!textFromParts) {
toast.error("There's no text to copy!");
return;
}
await copyToClipboard(textFromParts);
toast.success("Copied to clipboard!");
};
if (message.role === "user") {
return (
<Actions className="-mr-0.5 justify-end opacity-0 transition-opacity duration-150 group-hover/message:opacity-100">
<div className="flex items-center gap-0.5">
{onEdit && (
<Action
className="size-7 text-muted-foreground/50 hover:text-foreground"
data-testid="message-edit-button"
onClick={onEdit}
tooltip="Edit"
>
<PencilEditIcon />
</Action>
)}
<Action
className="size-7 text-muted-foreground/50 hover:text-foreground"
onClick={handleCopy}
tooltip="Copy"
>
<CopyIcon />
</Action>
</div>
</Actions>
);
}
return (
<Actions className="-ml-0.5 opacity-0 transition-opacity duration-150 group-hover/message:opacity-100">
<Action
className="text-muted-foreground/50 hover:text-foreground"
onClick={handleCopy}
tooltip="Copy"
>
<CopyIcon />
</Action>
<Action
className="text-muted-foreground/50 hover:text-foreground"
data-testid="message-upvote"
disabled={vote?.isUpvoted}
onClick={() => {
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<Vote[]>(
`${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"
>
<ThumbUpIcon />
</Action>
<Action
className="text-muted-foreground/50 hover:text-foreground"
data-testid="message-downvote"
disabled={vote && !vote.isUpvoted}
onClick={() => {
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<Vote[]>(
`${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"
>
<ThumbDownIcon />
</Action>
</Actions>
);
}
export const MessageActions = memo(
PureMessageActions,
(prevProps, nextProps) => {
if (!equal(prevProps.vote, nextProps.vote)) {
return false;
}
if (prevProps.isLoading !== nextProps.isLoading) {
return false;
}
return true;
}
);

View file

@ -0,0 +1,33 @@
"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({
message,
text,
setMessages,
regenerate,
}: {
message: ChatMessage;
text: string;
setMessages: UseChatHelpers<ChatMessage>["setMessages"];
regenerate: UseChatHelpers<ChatMessage>["regenerate"];
}) {
await deleteTrailingMessages({ id: message.id });
setMessages((messages) => {
const index = messages.findIndex((m) => m.id === message.id);
if (index === -1) {
return messages;
}
return [
...messages.slice(0, index),
{ ...message, parts: [{ type: "text" as const, text }] },
];
});
regenerate();
}

View file

@ -0,0 +1,37 @@
"use client";
import { useEffect, useState } from "react";
import {
Reasoning,
ReasoningContent,
ReasoningTrigger,
} from "../ai-elements/reasoning";
type MessageReasoningProps = {
isLoading: boolean;
reasoning: string;
};
export function MessageReasoning({
isLoading,
reasoning,
}: MessageReasoningProps) {
const [hasBeenStreaming, setHasBeenStreaming] = useState(isLoading);
useEffect(() => {
if (isLoading) {
setHasBeenStreaming(true);
}
}, [isLoading]);
return (
<Reasoning
data-testid="message-reasoning"
defaultOpen={hasBeenStreaming}
isStreaming={isLoading}
>
<ReasoningTrigger />
<ReasoningContent>{reasoning}</ReasoningContent>
</Reasoning>
);
}

301
components/chat/message.tsx Normal file
View file

@ -0,0 +1,301 @@
"use client";
import type { UseChatHelpers } from "@ai-sdk/react";
import type { Vote } from "@/lib/db/schema";
import type { ChatMessage } from "@/lib/types";
import { cn, sanitizeText } from "@/lib/utils";
import { MessageContent, MessageResponse } from "../ai-elements/message";
import { Shimmer } from "../ai-elements/shimmer";
import {
Tool,
ToolContent,
ToolHeader,
ToolInput,
} from "../ai-elements/tool";
import { useDataStream } from "./data-stream-provider";
import { SparklesIcon } from "./icons";
import { MessageActions } from "./message-actions";
import { MessageReasoning } from "./message-reasoning";
import { PreviewAttachment } from "./preview-attachment";
import { Weather } from "./weather";
const PurePreviewMessage = ({
addToolApprovalResponse,
chatId,
message,
vote,
isLoading,
setMessages: _setMessages,
regenerate: _regenerate,
isReadonly,
requiresScrollPadding: _requiresScrollPadding,
onEdit,
}: {
addToolApprovalResponse: UseChatHelpers<ChatMessage>["addToolApprovalResponse"];
chatId: string;
message: ChatMessage;
vote: Vote | undefined;
isLoading: boolean;
setMessages: UseChatHelpers<ChatMessage>["setMessages"];
regenerate: UseChatHelpers<ChatMessage>["regenerate"];
isReadonly: boolean;
requiresScrollPadding: boolean;
onEdit?: (message: ChatMessage) => void;
}) => {
const attachmentsFromMessage = message.parts.filter(
(part) => part.type === "file"
);
useDataStream();
const isUser = message.role === "user";
const isAssistant = message.role === "assistant";
const hasAnyContent = message.parts?.some(
(part) =>
(part.type === "text" && part.text?.trim().length > 0) ||
(part.type === "reasoning" &&
"text" in part &&
part.text?.trim().length > 0) ||
part.type.startsWith("tool-")
);
const isThinking = isAssistant && isLoading && !hasAnyContent;
const attachments = attachmentsFromMessage.length > 0 && (
<div
className="flex flex-row justify-end gap-2"
data-testid={"message-attachments"}
>
{attachmentsFromMessage.map((attachment) => (
<PreviewAttachment
attachment={{
name: attachment.filename ?? "file",
contentType: attachment.mediaType,
url: attachment.url,
}}
key={attachment.url}
/>
))}
</div>
);
const mergedReasoning = message.parts?.reduce(
(acc, part) => {
if (part.type === "reasoning" && part.text?.trim().length > 0) {
return {
text: acc.text ? `${acc.text}\n\n${part.text}` : part.text,
isStreaming: "state" in part ? part.state === "streaming" : false,
rendered: false,
};
}
return acc;
},
{ text: "", isStreaming: false, rendered: false }
) ?? { text: "", isStreaming: false, rendered: false };
const parts = message.parts?.map((part, index) => {
const { type } = part;
const key = `message-${message.id}-part-${index}`;
if (type === "reasoning") {
if (!mergedReasoning.rendered && mergedReasoning.text) {
mergedReasoning.rendered = true;
return (
<MessageReasoning
isLoading={isLoading || mergedReasoning.isStreaming}
key={key}
reasoning={mergedReasoning.text}
/>
);
}
return null;
}
if (type === "text") {
return (
<MessageContent
className={cn("text-[13px] leading-[1.65]", {
"w-fit max-w-[min(80%,56ch)] overflow-hidden break-words rounded-2xl rounded-br-lg border border-border/30 bg-gradient-to-br from-secondary to-muted px-3.5 py-2 shadow-[var(--shadow-card)]":
message.role === "user",
})}
data-testid="message-content"
key={key}
>
<MessageResponse>{sanitizeText(part.text)}</MessageResponse>
</MessageContent>
);
}
if (type === "tool-getWeather") {
const { toolCallId, state } = part;
const approvalId = (part as { approval?: { id: string } }).approval?.id;
const isDenied =
state === "output-denied" ||
(state === "approval-responded" &&
(part as { approval?: { approved?: boolean } }).approval?.approved ===
false);
const widthClass = "w-[min(100%,450px)]";
if (state === "output-available") {
return (
<div className={widthClass} key={toolCallId}>
<Weather weatherAtLocation={part.output} />
</div>
);
}
if (isDenied) {
return (
<div className={widthClass} key={toolCallId}>
<Tool className="w-full" defaultOpen={true}>
<ToolHeader state="output-denied" type="tool-getWeather" />
<ToolContent>
<div className="px-4 py-3 text-muted-foreground text-sm">
Weather lookup was denied.
</div>
</ToolContent>
</Tool>
</div>
);
}
if (state === "approval-responded") {
return (
<div className={widthClass} key={toolCallId}>
<Tool className="w-full" defaultOpen={true}>
<ToolHeader state={state} type="tool-getWeather" />
<ToolContent>
<ToolInput input={part.input} />
</ToolContent>
</Tool>
</div>
);
}
return (
<div className={widthClass} key={toolCallId}>
<Tool className="w-full" defaultOpen={true}>
<ToolHeader state={state} type="tool-getWeather" />
<ToolContent>
{(state === "input-available" ||
state === "approval-requested") && (
<ToolInput input={part.input} />
)}
{state === "approval-requested" && approvalId && (
<div className="flex items-center justify-end gap-2 border-t px-4 py-3">
<button
className="rounded-md px-3 py-1.5 text-muted-foreground text-sm transition-colors hover:bg-muted hover:text-foreground"
onClick={() => {
addToolApprovalResponse({
id: approvalId,
approved: false,
reason: "User denied weather lookup",
});
}}
type="button"
>
Deny
</button>
<button
className="rounded-md bg-primary px-3 py-1.5 text-primary-foreground text-sm transition-colors hover:bg-primary/90"
onClick={() => {
addToolApprovalResponse({
id: approvalId,
approved: true,
});
}}
type="button"
>
Allow
</button>
</div>
)}
</ToolContent>
</Tool>
</div>
);
}
return null;
});
const actions = !isReadonly && (
<MessageActions
chatId={chatId}
isLoading={isLoading}
key={`action-${message.id}`}
message={message}
onEdit={onEdit ? () => onEdit(message) : undefined}
vote={vote}
/>
);
const content = isThinking ? (
<div className="flex h-[calc(13px*1.65)] items-center text-[13px] leading-[1.65]">
<Shimmer className="font-medium" duration={1}>
Thinking...
</Shimmer>
</div>
) : (
<>
{attachments}
{parts}
{actions}
</>
);
return (
<div
className={cn(
"group/message w-full",
!isAssistant && "animate-[fade-up_0.25s_cubic-bezier(0.22,1,0.36,1)]"
)}
data-role={message.role}
data-testid={`message-${message.role}`}
>
<div
className={cn(
isUser ? "flex flex-col items-end gap-2" : "flex items-start gap-3"
)}
>
{isAssistant && (
<div className="flex h-[calc(13px*1.65)] shrink-0 items-center">
<div className="flex size-7 items-center justify-center rounded-lg bg-muted/60 text-muted-foreground ring-1 ring-border/50">
<SparklesIcon size={13} />
</div>
</div>
)}
{isAssistant ? (
<div className="flex min-w-0 flex-1 flex-col gap-2">{content}</div>
) : (
content
)}
</div>
</div>
);
};
export const PreviewMessage = PurePreviewMessage;
export const ThinkingMessage = () => {
return (
<div
className="group/message w-full"
data-role="assistant"
data-testid="message-assistant-loading"
>
<div className="flex items-start gap-3">
<div className="flex h-[calc(13px*1.65)] shrink-0 items-center">
<div className="flex size-7 items-center justify-center rounded-lg bg-muted/60 text-muted-foreground ring-1 ring-border/50">
<SparklesIcon size={13} />
</div>
</div>
<div className="flex h-[calc(13px*1.65)] items-center text-[13px] leading-[1.65]">
<Shimmer className="font-medium" duration={1}>
Thinking...
</Shimmer>
</div>
</div>
</div>
);
};

View file

@ -0,0 +1,126 @@
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 { ChatMessage } from "@/lib/types";
import { cn } from "@/lib/utils";
import { useDataStream } from "./data-stream-provider";
import { Greeting } from "./greeting";
import { PreviewMessage, ThinkingMessage } from "./message";
type MessagesProps = {
addToolApprovalResponse: UseChatHelpers<ChatMessage>["addToolApprovalResponse"];
chatId: string;
status: UseChatHelpers<ChatMessage>["status"];
votes: Vote[] | undefined;
messages: ChatMessage[];
setMessages: UseChatHelpers<ChatMessage>["setMessages"];
regenerate: UseChatHelpers<ChatMessage>["regenerate"];
isReadonly: boolean;
isLoading?: boolean;
selectedModelId: string;
onEditMessage?: (message: ChatMessage) => void;
};
function PureMessages({
addToolApprovalResponse,
chatId,
status,
votes,
messages,
setMessages,
regenerate,
isReadonly,
isLoading,
selectedModelId: _selectedModelId,
onEditMessage,
}: MessagesProps) {
const {
containerRef: messagesContainerRef,
endRef: messagesEndRef,
isAtBottom,
scrollToBottom,
hasSentMessage,
reset,
} = useMessages({
status,
});
useDataStream();
const prevChatIdRef = useRef(chatId);
useEffect(() => {
if (prevChatIdRef.current !== chatId) {
prevChatIdRef.current = chatId;
reset();
}
}, [chatId, reset]);
return (
<div className="relative flex-1 bg-background">
{messages.length === 0 && !isLoading && (
<div className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center">
<Greeting />
</div>
)}
<div
className={cn(
"absolute inset-0 touch-pan-y overflow-y-auto",
messages.length > 0 ? "bg-background" : "bg-transparent"
)}
ref={messagesContainerRef}
>
<div className="mx-auto flex min-h-full min-w-0 max-w-4xl flex-col gap-5 px-2 py-6 md:gap-7 md:px-4">
{messages.map((message, index) => (
<PreviewMessage
addToolApprovalResponse={addToolApprovalResponse}
chatId={chatId}
isLoading={
status === "streaming" && messages.length - 1 === index
}
isReadonly={isReadonly}
key={message.id}
message={message}
onEdit={onEditMessage}
regenerate={regenerate}
requiresScrollPadding={
hasSentMessage && index === messages.length - 1
}
setMessages={setMessages}
vote={
votes
? votes.find((vote) => vote.messageId === message.id)
: undefined
}
/>
))}
{status === "submitted" && messages.at(-1)?.role !== "assistant" && (
<ThinkingMessage />
)}
<div
className="min-h-[24px] min-w-[24px] shrink-0"
ref={messagesEndRef}
/>
</div>
</div>
<button
aria-label="Scroll to bottom"
className={`absolute bottom-4 left-1/2 z-10 flex -translate-x-1/2 items-center rounded-full border border-border/50 bg-card/90 px-3.5 shadow-[var(--shadow-float)] backdrop-blur-lg transition-all duration-200 h-7 text-[10px] ${
isAtBottom
? "pointer-events-none scale-90 opacity-0"
: "pointer-events-auto scale-100 opacity-100"
}`}
onClick={() => scrollToBottom("smooth")}
type="button"
>
<ArrowDownIcon className="size-3 text-muted-foreground" />
</button>
</div>
);
}
export const Messages = PureMessages;

View file

@ -0,0 +1,816 @@
"use client";
import type { UseChatHelpers } from "@ai-sdk/react";
import type { UIMessage } from "ai";
import equal from "fast-deep-equal";
import {
ArrowUpIcon,
BrainIcon,
EyeIcon,
LockIcon,
WrenchIcon,
} from "lucide-react";
import { useRouter } from "next/navigation";
import { useTheme } from "next-themes";
import {
type ChangeEvent,
type Dispatch,
memo,
type SetStateAction,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import { toast } from "sonner";
import useSWR from "swr";
import { useLocalStorage, useWindowSize } from "usehooks-ts";
import {
ModelSelector,
ModelSelectorContent,
ModelSelectorGroup,
ModelSelectorInput,
ModelSelectorItem,
ModelSelectorList,
ModelSelectorLogo,
ModelSelectorName,
ModelSelectorTrigger,
} from "@/components/ai-elements/model-selector";
import {
type ChatModel,
chatModels,
DEFAULT_CHAT_MODEL,
type ModelCapabilities,
} from "@/lib/ai/models";
import type { Attachment, ChatMessage } from "@/lib/types";
import { cn } from "@/lib/utils";
import {
PromptInput,
PromptInputFooter,
PromptInputSubmit,
PromptInputTextarea,
PromptInputTools,
} from "../ai-elements/prompt-input";
import { Button } from "../ui/button";
import { PaperclipIcon, StopIcon } from "./icons";
import { PreviewAttachment } from "./preview-attachment";
import {
type SlashCommand,
SlashCommandMenu,
slashCommands,
} from "./slash-commands";
import { SuggestedActions } from "./suggested-actions";
import type { VisibilityType } from "./visibility-selector";
function setCookie(name: string, value: string) {
const maxAge = 60 * 60 * 24 * 365;
// biome-ignore lint/suspicious/noDocumentCookie: needed for client-side cookie setting
document.cookie = `${name}=${encodeURIComponent(value)}; path=/; max-age=${maxAge}`;
}
function PureMultimodalInput({
chatId,
input,
setInput,
status,
stop,
attachments,
setAttachments,
messages,
setMessages,
sendMessage,
className,
selectedVisibilityType,
selectedModelId,
onModelChange,
editingMessage,
onCancelEdit,
isLoading,
}: {
chatId: string;
input: string;
setInput: Dispatch<SetStateAction<string>>;
status: UseChatHelpers<ChatMessage>["status"];
stop: () => void;
attachments: Attachment[];
setAttachments: Dispatch<SetStateAction<Attachment[]>>;
messages: UIMessage[];
setMessages: UseChatHelpers<ChatMessage>["setMessages"];
sendMessage:
| UseChatHelpers<ChatMessage>["sendMessage"]
| (() => Promise<void>);
className?: string;
selectedVisibilityType: VisibilityType;
selectedModelId: string;
onModelChange?: (modelId: string) => void;
editingMessage?: ChatMessage | null;
onCancelEdit?: () => void;
isLoading?: boolean;
}) {
const router = useRouter();
const { setTheme, resolvedTheme } = useTheme();
const textareaRef = useRef<HTMLTextAreaElement>(null);
const { width } = useWindowSize();
const hasAutoFocused = useRef(false);
useEffect(() => {
if (!hasAutoFocused.current && width) {
const timer = setTimeout(() => {
textareaRef.current?.focus();
hasAutoFocused.current = true;
}, 100);
return () => clearTimeout(timer);
}
}, [width]);
const [localStorageInput, setLocalStorageInput] = useLocalStorage(
"input",
""
);
useEffect(() => {
if (textareaRef.current) {
const domValue = textareaRef.current.value;
const finalValue = domValue || localStorageInput || "";
setInput(finalValue);
}
}, [localStorageInput, setInput]);
useEffect(() => {
setLocalStorageInput(input);
}, [input, setLocalStorageInput]);
const handleInput = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
const val = event.target.value;
setInput(val);
if (val.startsWith("/") && !val.includes(" ")) {
setSlashOpen(true);
setSlashQuery(val.slice(1));
setSlashIndex(0);
} else {
setSlashOpen(false);
}
};
const handleSlashSelect = (cmd: SlashCommand) => {
setSlashOpen(false);
setInput("");
switch (cmd.action) {
case "new":
router.push("/");
break;
case "clear":
setMessages(() => []);
break;
case "rename":
toast("Rename is available from the sidebar chat menu.");
break;
case "model": {
const modelBtn = document.querySelector<HTMLButtonElement>(
"[data-testid='model-selector']"
);
modelBtn?.click();
break;
}
case "theme":
setTheme(resolvedTheme === "dark" ? "light" : "dark");
break;
case "delete":
toast("Delete this chat?", {
action: {
label: "Delete",
onClick: () => {
fetch(
`${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/chat?id=${chatId}`,
{ method: "DELETE" }
);
router.push("/");
toast.success("Chat deleted");
},
},
});
break;
case "purge":
toast("Delete all chats?", {
action: {
label: "Delete all",
onClick: () => {
fetch(`${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/history`, {
method: "DELETE",
});
router.push("/");
toast.success("All chats deleted");
},
},
});
break;
default:
break;
}
};
const fileInputRef = useRef<HTMLInputElement>(null);
const [uploadQueue, setUploadQueue] = useState<string[]>([]);
const [slashOpen, setSlashOpen] = useState(false);
const [slashQuery, setSlashQuery] = useState("");
const [slashIndex, setSlashIndex] = useState(0);
const submitForm = useCallback(() => {
window.history.pushState(
{},
"",
`${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/chat/${chatId}`
);
sendMessage({
role: "user",
parts: [
...attachments.map((attachment) => ({
type: "file" as const,
url: attachment.url,
name: attachment.name,
mediaType: attachment.contentType,
})),
{
type: "text",
text: input,
},
],
});
setAttachments([]);
setLocalStorageInput("");
setInput("");
if (width && width > 768) {
textareaRef.current?.focus();
}
}, [
input,
setInput,
attachments,
sendMessage,
setAttachments,
setLocalStorageInput,
width,
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 handleFileChange = useCallback(
async (event: ChangeEvent<HTMLInputElement>) => {
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([]);
}
},
[setAttachments, uploadFile]
);
const handlePaste = useCallback(
async (event: ClipboardEvent) => {
const items = event.clipboardData?.items;
if (!items) {
return;
}
const imageItems = Array.from(items).filter((item) =>
item.type.startsWith("image/")
);
if (imageItems.length === 0) {
return;
}
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([]);
}
},
[setAttachments, uploadFile]
);
useEffect(() => {
const textarea = textareaRef.current;
if (!textarea) {
return;
}
textarea.addEventListener("paste", handlePaste);
return () => textarea.removeEventListener("paste", handlePaste);
}, [handlePaste]);
return (
<div className={cn("relative flex w-full flex-col gap-4", className)}>
{editingMessage && onCancelEdit && (
<div className="flex items-center gap-2 text-[12px] text-muted-foreground">
<span>Editing message</span>
<button
className="rounded px-1.5 py-0.5 text-muted-foreground/50 transition-colors hover:bg-muted hover:text-foreground"
onMouseDown={(e) => {
e.preventDefault();
onCancelEdit();
}}
type="button"
>
Cancel
</button>
</div>
)}
{!editingMessage &&
!isLoading &&
messages.length === 0 &&
attachments.length === 0 &&
uploadQueue.length === 0 && (
<SuggestedActions
chatId={chatId}
selectedVisibilityType={selectedVisibilityType}
sendMessage={sendMessage}
/>
)}
<input
className="pointer-events-none fixed -top-4 -left-4 size-0.5 opacity-0"
multiple
onChange={handleFileChange}
ref={fileInputRef}
tabIndex={-1}
type="file"
/>
<div className="relative">
{slashOpen && (
<SlashCommandMenu
onClose={() => setSlashOpen(false)}
onSelect={handleSlashSelect}
query={slashQuery}
selectedIndex={slashIndex}
/>
)}
</div>
<PromptInput
className="[&>div]:rounded-2xl [&>div]:border [&>div]:border-border/30 [&>div]:bg-card/70 [&>div]:shadow-[var(--shadow-composer)] [&>div]:transition-shadow [&>div]:duration-300 [&>div]:focus-within:shadow-[var(--shadow-composer-focus)]"
onSubmit={() => {
if (input.startsWith("/")) {
const query = input.slice(1).trim();
const cmd = slashCommands.find((c) => c.name === query);
if (cmd) {
handleSlashSelect(cmd);
}
return;
}
if (!input.trim() && attachments.length === 0) {
return;
}
if (status === "ready" || status === "error") {
submitForm();
} else {
toast.error("Please wait for the model to finish its response!");
}
}}
>
{(attachments.length > 0 || uploadQueue.length > 0) && (
<div
className="flex w-full self-start flex-row gap-2 overflow-x-auto px-3 pt-3 no-scrollbar"
data-testid="attachments-preview"
>
{attachments.map((attachment) => (
<PreviewAttachment
attachment={attachment}
key={attachment.url}
onRemove={() => {
setAttachments((currentAttachments) =>
currentAttachments.filter((a) => a.url !== attachment.url)
);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
}}
/>
))}
{uploadQueue.map((filename) => (
<PreviewAttachment
attachment={{
url: "",
name: filename,
contentType: "",
}}
isUploading={true}
key={filename}
/>
))}
</div>
)}
<PromptInputTextarea
className="min-h-24 text-[13px] leading-relaxed px-4 pt-3.5 pb-1.5 placeholder:text-muted-foreground/35"
data-testid="multimodal-input"
onChange={handleInput}
onKeyDown={(e) => {
if (slashOpen) {
const filtered = slashCommands.filter((cmd) =>
cmd.name.startsWith(slashQuery.toLowerCase())
);
if (e.key === "ArrowDown") {
e.preventDefault();
setSlashIndex((i) => Math.min(i + 1, filtered.length - 1));
return;
}
if (e.key === "ArrowUp") {
e.preventDefault();
setSlashIndex((i) => Math.max(i - 1, 0));
return;
}
if (e.key === "Enter" || e.key === "Tab") {
e.preventDefault();
if (filtered[slashIndex]) {
handleSlashSelect(filtered[slashIndex]);
}
return;
}
if (e.key === "Escape") {
e.preventDefault();
setSlashOpen(false);
return;
}
}
if (e.key === "Escape" && editingMessage && onCancelEdit) {
e.preventDefault();
onCancelEdit();
}
}}
placeholder={
editingMessage ? "Edit your message..." : "Ask anything..."
}
ref={textareaRef}
value={input}
/>
<PromptInputFooter className="px-3 pb-3">
<PromptInputTools>
<AttachmentsButton
fileInputRef={fileInputRef}
selectedModelId={selectedModelId}
status={status}
/>
<ModelSelectorCompact
onModelChange={onModelChange}
selectedModelId={selectedModelId}
/>
</PromptInputTools>
{status === "submitted" ? (
<StopButton setMessages={setMessages} stop={stop} />
) : (
<PromptInputSubmit
className={cn(
"h-7 w-7 rounded-xl transition-all duration-200",
input.trim()
? "bg-foreground text-background hover:opacity-85 active:scale-95"
: "bg-muted text-muted-foreground/25 cursor-not-allowed"
)}
data-testid="send-button"
disabled={!input.trim() || uploadQueue.length > 0}
status={status}
variant="secondary"
>
<ArrowUpIcon className="size-4" />
</PromptInputSubmit>
)}
</PromptInputFooter>
</PromptInput>
</div>
);
}
export const MultimodalInput = memo(
PureMultimodalInput,
(prevProps, nextProps) => {
if (prevProps.input !== nextProps.input) {
return false;
}
if (prevProps.status !== nextProps.status) {
return false;
}
if (!equal(prevProps.attachments, nextProps.attachments)) {
return false;
}
if (prevProps.selectedVisibilityType !== nextProps.selectedVisibilityType) {
return false;
}
if (prevProps.selectedModelId !== nextProps.selectedModelId) {
return false;
}
if (prevProps.editingMessage !== nextProps.editingMessage) {
return false;
}
if (prevProps.isLoading !== nextProps.isLoading) {
return false;
}
if (prevProps.messages.length !== nextProps.messages.length) {
return false;
}
return true;
}
);
function PureAttachmentsButton({
fileInputRef,
status,
selectedModelId,
}: {
fileInputRef: React.MutableRefObject<HTMLInputElement | null>;
status: UseChatHelpers<ChatMessage>["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<string, ModelCapabilities> | undefined =
modelsResponse?.capabilities ?? modelsResponse;
const hasVision = caps?.[selectedModelId]?.vision ?? false;
return (
<Button
className={cn(
"h-7 w-7 rounded-lg border border-border/40 p-1 transition-colors",
hasVision
? "text-foreground hover:border-border hover:text-foreground"
: "text-muted-foreground/30 cursor-not-allowed"
)}
data-testid="attachments-button"
disabled={status !== "ready" || !hasVision}
onClick={(event) => {
event.preventDefault();
fileInputRef.current?.click();
}}
variant="ghost"
>
<PaperclipIcon size={14} style={{ width: 14, height: 14 }} />
</Button>
);
}
const AttachmentsButton = memo(PureAttachmentsButton);
function PureModelSelectorCompact({
selectedModelId,
onModelChange,
}: {
selectedModelId: string;
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<string, ModelCapabilities> | undefined =
modelsData?.capabilities ?? modelsData;
const dynamicModels: ChatModel[] | undefined = modelsData?.models;
const activeModels = dynamicModels ?? chatModels;
const selectedModel =
activeModels.find((m: ChatModel) => m.id === selectedModelId) ??
activeModels.find((m: ChatModel) => m.id === DEFAULT_CHAT_MODEL) ??
activeModels[0];
const [provider] = selectedModel.id.split("/");
return (
<ModelSelector onOpenChange={setOpen} open={open}>
<ModelSelectorTrigger asChild>
<Button
className="h-7 max-w-[200px] justify-between gap-1.5 rounded-lg px-2 text-[12px] text-muted-foreground transition-colors hover:text-foreground"
data-testid="model-selector"
variant="ghost"
>
{provider && <ModelSelectorLogo provider={provider} />}
<ModelSelectorName>{selectedModel.name}</ModelSelectorName>
</Button>
</ModelSelectorTrigger>
<ModelSelectorContent>
<ModelSelectorInput placeholder="Search models..." />
<ModelSelectorList>
{(() => {
const curatedIds = new Set(chatModels.map((m) => m.id));
const allModels = dynamicModels
? [
...chatModels,
...dynamicModels.filter((m) => !curatedIds.has(m.id)),
]
: chatModels;
const grouped: Record<
string,
{ model: ChatModel; curated: boolean }[]
> = {};
for (const model of allModels) {
const key = curatedIds.has(model.id)
? "_available"
: model.provider;
if (!grouped[key]) {
grouped[key] = [];
}
grouped[key].push({ model, curated: curatedIds.has(model.id) });
}
const sortedKeys = Object.keys(grouped).sort((a, b) => {
if (a === "_available") {
return -1;
}
if (b === "_available") {
return 1;
}
return a.localeCompare(b);
});
const providerNames: Record<string, string> = {
alibaba: "Alibaba",
anthropic: "Anthropic",
"arcee-ai": "Arcee AI",
bytedance: "ByteDance",
cohere: "Cohere",
deepseek: "DeepSeek",
google: "Google",
inception: "Inception",
kwaipilot: "Kwaipilot",
meituan: "Meituan",
meta: "Meta",
minimax: "MiniMax",
mistral: "Mistral",
moonshotai: "Moonshot",
morph: "Morph",
nvidia: "Nvidia",
openai: "OpenAI",
perplexity: "Perplexity",
"prime-intellect": "Prime Intellect",
xiaomi: "Xiaomi",
xai: "xAI",
zai: "Zai",
};
return sortedKeys.map((key) => (
<ModelSelectorGroup
heading={
key === "_available"
? "Available"
: (providerNames[key] ?? key)
}
key={key}
>
{grouped[key].map(({ model, curated }) => {
const logoProvider = model.id.split("/")[0];
return (
<ModelSelectorItem
className={cn(
"flex w-full",
model.id === selectedModel.id &&
"border-b border-dashed border-foreground/50",
!curated && "opacity-40 cursor-default"
)}
key={model.id}
onSelect={() => {
if (!curated) {
return;
}
onModelChange?.(model.id);
setCookie("chat-model", model.id);
setOpen(false);
setTimeout(() => {
document
.querySelector<HTMLTextAreaElement>(
"[data-testid='multimodal-input']"
)
?.focus();
}, 50);
}}
value={model.id}
>
<ModelSelectorLogo provider={logoProvider} />
<ModelSelectorName>{model.name}</ModelSelectorName>
<div className="ml-auto flex items-center gap-2 text-foreground/70">
{capabilities?.[model.id]?.tools && (
<WrenchIcon className="size-3.5" />
)}
{capabilities?.[model.id]?.vision && (
<EyeIcon className="size-3.5" />
)}
{capabilities?.[model.id]?.reasoning && (
<BrainIcon className="size-3.5" />
)}
{!curated && (
<LockIcon className="size-3 text-muted-foreground/50" />
)}
</div>
</ModelSelectorItem>
);
})}
</ModelSelectorGroup>
));
})()}
</ModelSelectorList>
</ModelSelectorContent>
</ModelSelector>
);
}
const ModelSelectorCompact = memo(PureModelSelectorCompact);
function PureStopButton({
stop,
setMessages,
}: {
stop: () => void;
setMessages: UseChatHelpers<ChatMessage>["setMessages"];
}) {
return (
<Button
className="h-7 w-7 rounded-xl bg-foreground p-1 text-background transition-all duration-200 hover:opacity-85 active:scale-95 disabled:bg-muted disabled:text-muted-foreground/25 disabled:cursor-not-allowed"
data-testid="stop-button"
onClick={(event) => {
event.preventDefault();
stop();
setMessages((messages) => messages);
}}
>
<StopIcon size={14} />
</Button>
);
}
const StopButton = memo(PureStopButton);

View file

@ -0,0 +1,54 @@
import type { Attachment } from "@/lib/types";
import { Spinner } from "../ui/spinner";
import { CrossSmallIcon } from "./icons";
export const PreviewAttachment = ({
attachment,
isUploading = false,
onRemove,
}: {
attachment: Attachment;
isUploading?: boolean;
onRemove?: () => void;
}) => {
const { name, url, contentType } = attachment;
return (
<div
className="group relative h-24 w-24 shrink-0 overflow-hidden rounded-xl border border-border/40 bg-muted"
data-testid="input-attachment-preview"
>
{contentType?.startsWith("image") ? (
// eslint-disable-next-line @next/next/no-img-element
<img
alt={name ?? "attachment"}
className="size-full object-cover"
src={url}
/>
) : (
<div className="flex size-full items-center justify-center text-muted-foreground text-xs">
File
</div>
)}
{isUploading && (
<div
className="absolute inset-0 flex items-center justify-center rounded-xl bg-black/40 backdrop-blur-sm"
data-testid="input-attachment-loader"
>
<Spinner className="size-5" />
</div>
)}
{onRemove && !isUploading && (
<button
className="absolute top-1.5 right-1.5 flex size-5 items-center justify-center rounded-full bg-black/60 text-white opacity-0 backdrop-blur-sm transition-opacity hover:bg-black/80 group-hover:opacity-100"
onClick={onRemove}
type="button"
>
<CrossSmallIcon size={10} />
</button>
)}
</div>
);
};

View file

@ -0,0 +1,59 @@
"use client";
import { useRouter } from "next/navigation";
import { suggestions } from "@/lib/constants";
import { SparklesIcon } from "./icons";
export function Preview() {
const router = useRouter();
const handleAction = (query?: string) => {
const url = query ? `/?query=${encodeURIComponent(query)}` : "/";
router.push(url);
};
return (
<div className="flex h-full flex-col overflow-hidden rounded-tl-2xl bg-background">
<div className="flex h-14 shrink-0 items-center gap-3 border-b border-border/20 px-5">
<div className="flex size-5 items-center justify-center rounded bg-muted/60 ring-1 ring-border/50">
<SparklesIcon size={10} />
</div>
<span className="text-[13px] text-muted-foreground">Chatbot</span>
</div>
<div className="flex flex-1 flex-col items-center justify-center gap-8 px-8">
<div className="text-center">
<h2 className="text-xl font-semibold tracking-tight">
What can I help with?
</h2>
<p className="mt-1.5 text-sm text-muted-foreground">
Ask a question, write code, or explore ideas.
</p>
</div>
<div className="grid w-full max-w-md grid-cols-2 gap-2">
{suggestions.map((suggestion) => (
<button
className="rounded-xl border border-border/30 bg-card/20 px-3 py-2.5 text-left text-[11px] leading-relaxed text-muted-foreground/70 transition-all duration-200 hover:border-border/60 hover:bg-card/40 hover:text-muted-foreground"
key={suggestion}
onClick={() => handleAction(suggestion)}
type="button"
>
{suggestion}
</button>
))}
</div>
</div>
<div className="shrink-0 px-5 pb-5">
<button
className="flex w-full items-center rounded-2xl border border-border/30 bg-card/30 px-4 py-3 text-left text-[13px] text-muted-foreground/40 transition-colors hover:border-border/50 hover:text-muted-foreground/60"
onClick={() => handleAction()}
type="button"
>
Ask anything...
</button>
</div>
</div>
);
}

128
components/chat/shell.tsx Normal file
View file

@ -0,0 +1,128 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useActiveChat } from "@/hooks/use-active-chat";
import type { Attachment, ChatMessage } from "@/lib/types";
import { ChatHeader } from "./chat-header";
import { DataStreamHandler } from "./data-stream-handler";
import { submitEditedMessage } from "./message-editor";
import { Messages } from "./messages";
import { MultimodalInput } from "./multimodal-input";
export function ChatShell() {
const {
chatId,
messages,
setMessages,
sendMessage,
status,
stop,
regenerate,
addToolApprovalResponse,
input,
setInput,
visibilityType,
isReadonly,
isLoading,
votes,
currentModelId,
setCurrentModelId,
} = useActiveChat();
const [editingMessage, setEditingMessage] = useState<ChatMessage | null>(
null
);
const [attachments, setAttachments] = useState<Attachment[]>([]);
const stopRef = useRef(stop);
stopRef.current = stop;
const prevChatIdRef = useRef(chatId);
useEffect(() => {
if (prevChatIdRef.current !== chatId) {
prevChatIdRef.current = chatId;
stopRef.current();
setEditingMessage(null);
setAttachments([]);
}
}, [chatId]);
return (
<>
<div className="flex h-dvh w-full flex-row overflow-hidden">
<div className="flex min-w-0 flex-col bg-sidebar w-full">
<ChatHeader
chatId={chatId}
isReadonly={isReadonly}
selectedVisibilityType={visibilityType}
/>
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden bg-background md:rounded-tl-[12px] md:border-t md:border-l md:border-border/40">
<Messages
addToolApprovalResponse={addToolApprovalResponse}
chatId={chatId}
isLoading={isLoading}
isReadonly={isReadonly}
messages={messages}
onEditMessage={(msg) => {
const text = msg.parts
?.filter((p) => p.type === "text")
.map((p) => p.text)
.join("");
setInput(text ?? "");
setEditingMessage(msg);
}}
regenerate={regenerate}
selectedModelId={currentModelId}
setMessages={setMessages}
status={status}
votes={votes}
/>
<div className="sticky bottom-0 z-1 mx-auto flex w-full max-w-4xl gap-2 border-t-0 bg-background px-2 pb-3 md:px-4 md:pb-4">
{!isReadonly && (
<MultimodalInput
attachments={attachments}
chatId={chatId}
editingMessage={editingMessage}
input={input}
isLoading={isLoading}
messages={messages}
onCancelEdit={() => {
setEditingMessage(null);
setInput("");
}}
onModelChange={setCurrentModelId}
selectedModelId={currentModelId}
selectedVisibilityType={visibilityType}
sendMessage={
editingMessage
? async () => {
const msg = editingMessage;
setEditingMessage(null);
await submitEditedMessage({
message: msg,
text: input,
setMessages,
regenerate,
});
setInput("");
}
: sendMessage
}
setAttachments={setAttachments}
setInput={setInput}
setMessages={setMessages}
status={status}
stop={stop}
/>
)}
</div>
</div>
</div>
</div>
<DataStreamHandler />
</>
);
}

View file

@ -0,0 +1,124 @@
import Link from "next/link";
import { memo } from "react";
import { useChatVisibility } from "@/hooks/use-chat-visibility";
import type { Chat } from "@/lib/db/schema";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "../ui/dropdown-menu";
import {
SidebarMenuAction,
SidebarMenuButton,
SidebarMenuItem,
} from "../ui/sidebar";
import {
CheckCircleFillIcon,
GlobeIcon,
LockIcon,
MoreHorizontalIcon,
ShareIcon,
TrashIcon,
} from "./icons";
const PureChatItem = ({
chat,
isActive,
onDelete,
setOpenMobile,
}: {
chat: Chat;
isActive: boolean;
onDelete: (chatId: string) => void;
setOpenMobile: (open: boolean) => void;
}) => {
const { visibilityType, setVisibilityType } = useChatVisibility({
chatId: chat.id,
initialVisibilityType: chat.visibility,
});
return (
<SidebarMenuItem>
<SidebarMenuButton
asChild
className="h-8 rounded-none text-[13px] text-sidebar-foreground/50 transition-all duration-150 hover:bg-transparent hover:text-sidebar-foreground data-active:bg-transparent data-active:font-normal data-active:text-sidebar-foreground/50 data-[active=true]:text-sidebar-foreground data-[active=true]:font-medium data-[active=true]:border-b data-[active=true]:border-dashed data-[active=true]:border-sidebar-foreground/50"
isActive={isActive}
>
<Link href={`/chat/${chat.id}`} onClick={() => setOpenMobile(false)}>
<span className="truncate">{chat.title}</span>
</Link>
</SidebarMenuButton>
<DropdownMenu modal={true}>
<DropdownMenuTrigger asChild>
<SidebarMenuAction
className="mr-0.5 rounded-md text-sidebar-foreground/50 ring-0 transition-colors duration-150 focus-visible:ring-0 hover:text-sidebar-foreground data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
showOnHover={!isActive}
>
<MoreHorizontalIcon />
<span className="sr-only">More</span>
</SidebarMenuAction>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" side="bottom">
<DropdownMenuSub>
<DropdownMenuSubTrigger className="cursor-pointer">
<ShareIcon />
<span>Share</span>
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent>
<DropdownMenuItem
className="cursor-pointer flex-row justify-between"
onClick={() => {
setVisibilityType("private");
}}
>
<div className="flex flex-row items-center gap-2">
<LockIcon size={12} />
<span>Private</span>
</div>
{visibilityType === "private" ? (
<CheckCircleFillIcon />
) : null}
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer flex-row justify-between"
onClick={() => {
setVisibilityType("public");
}}
>
<div className="flex flex-row items-center gap-2">
<GlobeIcon />
<span>Public</span>
</div>
{visibilityType === "public" ? <CheckCircleFillIcon /> : null}
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
<DropdownMenuItem
onSelect={() => onDelete(chat.id)}
variant="destructive"
>
<TrashIcon />
<span>Delete</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
);
};
export const ChatItem = memo(PureChatItem, (prevProps, nextProps) => {
if (prevProps.isActive !== nextProps.isActive) {
return false;
}
return true;
});

View file

@ -0,0 +1,373 @@
"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[];
};
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<ChatHistory>(
user ? getChatHistoryPaginationKey : () => null,
fetcher,
{ fallbackData: [], revalidateOnFocus: false }
);
const router = useRouter();
const [deleteId, setDeleteId] = useState<string | null>(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 (
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
<SidebarGroupContent>
<div className="flex w-full flex-row items-center justify-center gap-2 px-2 text-[13px] text-sidebar-foreground/60">
Login to save and revisit previous chats!
</div>
</SidebarGroupContent>
</SidebarGroup>
);
}
if (isLoading) {
return (
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
<SidebarGroupLabel className="text-[10px] font-semibold uppercase tracking-[0.12em] text-sidebar-foreground/70">
History
</SidebarGroupLabel>
<SidebarGroupContent>
<div className="flex flex-col gap-0.5 px-1">
{[44, 32, 28, 64, 52].map((item) => (
<div
className="flex h-8 items-center gap-2 rounded-lg px-2"
key={item}
>
<div
className="h-3 max-w-(--skeleton-width) flex-1 animate-pulse rounded-md bg-sidebar-foreground/[0.06]"
style={
{
"--skeleton-width": `${item}%`,
} as React.CSSProperties
}
/>
</div>
))}
</div>
</SidebarGroupContent>
</SidebarGroup>
);
}
if (hasEmptyChatHistory) {
return (
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
<SidebarGroupLabel className="text-[10px] font-semibold uppercase tracking-[0.12em] text-sidebar-foreground/70">
History
</SidebarGroupLabel>
<SidebarGroupContent>
<div className="flex w-full flex-row items-center justify-center gap-2 px-2 text-[13px] text-sidebar-foreground/60">
Your conversations will appear here once you start chatting!
</div>
</SidebarGroupContent>
</SidebarGroup>
);
}
return (
<>
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
<SidebarGroupLabel className="text-[10px] font-semibold uppercase tracking-[0.12em] text-sidebar-foreground/70">
History
</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{paginatedChatHistories &&
(() => {
const chatsFromHistory = paginatedChatHistories.flatMap(
(paginatedChatHistory) => paginatedChatHistory.chats
);
const groupedChats = groupChatsByDate(chatsFromHistory);
return (
<div className="flex flex-col gap-4">
{groupedChats.today.length > 0 && (
<div>
<div className="px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.12em] text-sidebar-foreground/70">
Today
</div>
{groupedChats.today.map((chat) => (
<ChatItem
chat={chat}
isActive={chat.id === id}
key={chat.id}
onDelete={(chatId) => {
setDeleteId(chatId);
setShowDeleteDialog(true);
}}
setOpenMobile={setOpenMobile}
/>
))}
</div>
)}
{groupedChats.yesterday.length > 0 && (
<div>
<div className="px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.12em] text-sidebar-foreground/70">
Yesterday
</div>
{groupedChats.yesterday.map((chat) => (
<ChatItem
chat={chat}
isActive={chat.id === id}
key={chat.id}
onDelete={(chatId) => {
setDeleteId(chatId);
setShowDeleteDialog(true);
}}
setOpenMobile={setOpenMobile}
/>
))}
</div>
)}
{groupedChats.lastWeek.length > 0 && (
<div>
<div className="px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.12em] text-sidebar-foreground/70">
Last 7 days
</div>
{groupedChats.lastWeek.map((chat) => (
<ChatItem
chat={chat}
isActive={chat.id === id}
key={chat.id}
onDelete={(chatId) => {
setDeleteId(chatId);
setShowDeleteDialog(true);
}}
setOpenMobile={setOpenMobile}
/>
))}
</div>
)}
{groupedChats.lastMonth.length > 0 && (
<div>
<div className="px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.12em] text-sidebar-foreground/70">
Last 30 days
</div>
{groupedChats.lastMonth.map((chat) => (
<ChatItem
chat={chat}
isActive={chat.id === id}
key={chat.id}
onDelete={(chatId) => {
setDeleteId(chatId);
setShowDeleteDialog(true);
}}
setOpenMobile={setOpenMobile}
/>
))}
</div>
)}
{groupedChats.older.length > 0 && (
<div>
<div className="px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.12em] text-sidebar-foreground/70">
Older
</div>
{groupedChats.older.map((chat) => (
<ChatItem
chat={chat}
isActive={chat.id === id}
key={chat.id}
onDelete={(chatId) => {
setDeleteId(chatId);
setShowDeleteDialog(true);
}}
setOpenMobile={setOpenMobile}
/>
))}
</div>
)}
</div>
);
})()}
</SidebarMenu>
<motion.div
onViewportEnter={() => {
if (!isValidating && !hasReachedEnd) {
setSize((size) => size + 1);
}
}}
/>
{hasReachedEnd ? null : (
<div className="mt-1 flex flex-row items-center gap-2 px-4 py-2 text-sidebar-foreground/50">
<div className="animate-spin">
<LoaderIcon />
</div>
<div className="text-[11px]">Loading...</div>
</div>
)}
</SidebarGroupContent>
</SidebarGroup>
<AlertDialog onOpenChange={setShowDeleteDialog} open={showDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete your
chat and remove it from our servers.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleDelete}>
Continue
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}

View file

@ -0,0 +1,35 @@
import type { ComponentProps } from "react";
import { type SidebarTrigger, useSidebar } from "@/components/ui/sidebar";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Button } from "../ui/button";
import { SidebarLeftIcon } from "./icons";
export function SidebarToggle({
className,
}: ComponentProps<typeof SidebarTrigger>) {
const { toggleSidebar } = useSidebar();
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
className={className}
data-testid="sidebar-toggle-button"
onClick={toggleSidebar}
size="icon-sm"
variant="outline"
>
<SidebarLeftIcon size={16} />
</Button>
</TooltipTrigger>
<TooltipContent align="start" className="hidden md:block">
Toggle Sidebar
</TooltipContent>
</Tooltip>
);
}

View file

@ -0,0 +1,121 @@
"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";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar";
import { guestRegex } from "@/lib/constants";
import { LoaderIcon } from "./icons";
import { toast } from "./toast";
function emailToHue(email: string): number {
let hash = 0;
for (const char of email) {
hash = char.charCodeAt(0) + ((hash << 5) - hash);
}
return Math.abs(hash) % 360;
}
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 (
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
{status === "loading" ? (
<SidebarMenuButton className="h-10 justify-between rounded-lg bg-transparent text-sidebar-foreground/50 transition-colors duration-150 data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground">
<div className="flex flex-row items-center gap-2">
<div className="size-6 animate-pulse rounded-full bg-sidebar-foreground/10" />
<span className="animate-pulse rounded-md bg-sidebar-foreground/10 text-transparent text-[13px]">
Loading...
</span>
</div>
<div className="animate-spin text-sidebar-foreground/50">
<LoaderIcon />
</div>
</SidebarMenuButton>
) : (
<SidebarMenuButton
className="h-8 px-2 rounded-lg bg-transparent text-sidebar-foreground/70 transition-colors duration-150 hover:text-sidebar-foreground data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
data-testid="user-nav-button"
>
<div
className="size-5 shrink-0 rounded-full ring-1 ring-sidebar-border/50"
style={{
background: `linear-gradient(135deg, oklch(0.35 0.08 ${emailToHue(user.email ?? "")}), oklch(0.25 0.05 ${emailToHue(user.email ?? "") + 40}))`,
}}
/>
<span className="truncate text-[13px]" data-testid="user-email">
{isGuest ? "Guest" : user?.email}
</span>
<ChevronUp className="ml-auto size-3.5 text-sidebar-foreground/50" />
</SidebarMenuButton>
)}
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-(--radix-popper-anchor-width) rounded-lg border border-border/60 bg-card/95 backdrop-blur-xl shadow-[var(--shadow-float)]"
data-testid="user-nav-menu"
side="top"
>
<DropdownMenuItem
className="cursor-pointer text-[13px]"
data-testid="user-nav-item-theme"
onSelect={() =>
setTheme(resolvedTheme === "dark" ? "light" : "dark")
}
>
{`Toggle ${resolvedTheme === "light" ? "dark" : "light"} mode`}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem asChild data-testid="user-nav-item-auth">
<button
className="w-full cursor-pointer text-[13px]"
onClick={() => {
if (status === "loading") {
toast({
type: "error",
description:
"Checking authentication status, please try again!",
});
return;
}
if (isGuest) {
router.push("/login");
} else {
signOut({
redirectTo: "/",
});
}
}}
type="button"
>
{isGuest ? "Login to your account" : "Sign out"}
</button>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
);
}

View file

@ -0,0 +1,25 @@
import Form from "next/form";
import { signOut } from "@/app/(auth)/auth";
export const SignOutForm = () => {
return (
<Form
action={async () => {
"use server";
await signOut({
redirectTo: "/",
});
}}
className="w-full"
>
<button
className="w-full px-1 py-0.5 text-left text-red-500"
type="submit"
>
Sign out
</button>
</Form>
);
};

View file

@ -0,0 +1,137 @@
"use client";
import {
BombIcon,
ListIcon,
PaletteIcon,
PenLineIcon,
PenSquareIcon,
Trash2Icon,
XIcon,
} from "lucide-react";
import { type ReactNode, useEffect, useRef } from "react";
import { cn } from "@/lib/utils";
export type SlashCommand = {
name: string;
description: string;
icon: ReactNode;
action: string;
shortcut?: string;
};
export const slashCommands: SlashCommand[] = [
{
name: "new",
description: "Start a new chat",
icon: <PenSquareIcon className="size-3.5" />,
action: "new",
},
{
name: "clear",
description: "Clear current chat",
icon: <Trash2Icon className="size-3.5" />,
action: "clear",
},
{
name: "rename",
description: "Rename current chat",
icon: <PenLineIcon className="size-3.5" />,
action: "rename",
},
{
name: "model",
description: "Change the AI model",
icon: <ListIcon className="size-3.5" />,
action: "model",
},
{
name: "theme",
description: "Toggle dark/light mode",
icon: <PaletteIcon className="size-3.5" />,
action: "theme",
},
{
name: "delete",
description: "Delete current chat",
icon: <XIcon className="size-3.5" />,
action: "delete",
},
{
name: "purge",
description: "Delete all chats",
icon: <BombIcon className="size-3.5" />,
action: "purge",
},
];
type SlashCommandMenuProps = {
query: string;
onSelect: (command: SlashCommand) => void;
onClose: () => void;
selectedIndex: number;
};
export function SlashCommandMenu({
query,
onSelect,
onClose: _onClose,
selectedIndex,
}: SlashCommandMenuProps) {
const menuRef = useRef<HTMLDivElement>(null);
const filtered = slashCommands.filter((cmd) =>
cmd.name.startsWith(query.toLowerCase())
);
useEffect(() => {
const selected = menuRef.current?.querySelector("[data-selected='true']");
if (selected) {
selected.scrollIntoView({ block: "nearest" });
}
}, []);
if (filtered.length === 0) {
return null;
}
return (
<div
className="absolute bottom-full left-0 right-0 z-50 mb-2 overflow-hidden rounded-xl border border-border/50 bg-card/95 shadow-[var(--shadow-float)] backdrop-blur-xl"
ref={menuRef}
>
<div className="px-4 py-2.5 text-[10px] font-medium uppercase tracking-wider text-muted-foreground/40">
Commands
</div>
<div className="max-h-64 overflow-y-auto pb-1 no-scrollbar">
{filtered.map((cmd, index) => (
<button
className={cn(
"flex w-full items-center gap-3 px-4 py-2.5 text-left transition-colors",
index === selectedIndex ? "bg-muted/70" : "hover:bg-muted/40"
)}
data-selected={index === selectedIndex}
key={cmd.name}
onClick={() => onSelect(cmd)}
onMouseDown={(e) => e.preventDefault()}
type="button"
>
<div className="flex size-6 shrink-0 items-center justify-center text-muted-foreground/60">
{cmd.icon}
</div>
<span className="font-mono text-[13px] text-foreground">
/{cmd.name}
</span>
<span className="text-[12px] text-muted-foreground/50">
{cmd.description}
</span>
{cmd.shortcut && (
<span className="ml-auto text-[11px] text-muted-foreground/30">
{cmd.shortcut}
</span>
)}
</button>
))}
</div>
</div>
);
}

View file

@ -0,0 +1,38 @@
"use client";
import { useFormStatus } from "react-dom";
import { LoaderIcon } from "@/components/chat/icons";
import { Button } from "../ui/button";
export function SubmitButton({
children,
isSuccessful,
}: {
children: React.ReactNode;
isSuccessful: boolean;
}) {
const { pending } = useFormStatus();
return (
<Button
aria-disabled={pending || isSuccessful}
className="relative"
disabled={pending || isSuccessful}
type={pending ? "button" : "submit"}
>
{children}
{(pending || isSuccessful) && (
<span className="absolute right-4 animate-spin">
<LoaderIcon />
</span>
)}
<output aria-live="polite" className="sr-only">
{pending || isSuccessful ? "Loading" : "Submit form"}
</output>
</Button>
);
}

View file

@ -0,0 +1,78 @@
"use client";
import type { UseChatHelpers } from "@ai-sdk/react";
import { motion } from "framer-motion";
import { memo } from "react";
import { suggestions } from "@/lib/constants";
import type { ChatMessage } from "@/lib/types";
import { Suggestion } from "../ai-elements/suggestion";
import type { VisibilityType } from "./visibility-selector";
type SuggestedActionsProps = {
chatId: string;
sendMessage: UseChatHelpers<ChatMessage>["sendMessage"];
selectedVisibilityType: VisibilityType;
};
function PureSuggestedActions({ chatId, sendMessage }: SuggestedActionsProps) {
const suggestedActions = suggestions;
return (
<div
className="flex w-full gap-2.5 overflow-x-auto pb-1 sm:grid sm:grid-cols-2 sm:overflow-visible"
data-testid="suggested-actions"
style={{
scrollbarWidth: "none",
WebkitOverflowScrolling: "touch",
msOverflowStyle: "none",
}}
>
{suggestedActions.map((suggestedAction, index) => (
<motion.div
animate={{ opacity: 1, y: 0 }}
className="min-w-[200px] shrink-0 sm:min-w-0 sm:shrink"
exit={{ opacity: 0, y: 16 }}
initial={{ opacity: 0, y: 16 }}
key={suggestedAction}
transition={{
delay: 0.06 * index,
duration: 0.4,
ease: [0.22, 1, 0.36, 1],
}}
>
<Suggestion
className="h-auto w-full whitespace-nowrap rounded-xl border border-border/50 bg-card/30 px-4 py-3 text-left text-[12px] leading-relaxed text-muted-foreground transition-all duration-200 sm:whitespace-normal sm:p-4 sm:text-[13px] hover:-translate-y-0.5 hover:bg-card/60 hover:text-foreground hover:shadow-[var(--shadow-card)]"
onClick={(suggestion) => {
window.history.pushState(
{},
"",
`${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/chat/${chatId}`
);
sendMessage({
role: "user",
parts: [{ type: "text", text: suggestion }],
});
}}
suggestion={suggestedAction}
>
{suggestedAction}
</Suggestion>
</motion.div>
))}
</div>
);
}
export const SuggestedActions = memo(
PureSuggestedActions,
(prevProps, nextProps) => {
if (prevProps.chatId !== nextProps.chatId) {
return false;
}
if (prevProps.selectedVisibilityType !== nextProps.selectedVisibilityType) {
return false;
}
return true;
}
);

75
components/chat/toast.tsx Normal file
View file

@ -0,0 +1,75 @@
"use client";
import { type ReactNode, useEffect, useRef, useState } from "react";
import { toast as sonnerToast } from "sonner";
import { cn } from "@/lib/utils";
import { CheckCircleFillIcon, WarningIcon } from "./icons";
const iconsByType: Record<"success" | "error", ReactNode> = {
success: <CheckCircleFillIcon />,
error: <WarningIcon />,
};
export function toast(props: Omit<ToastProps, "id">) {
return sonnerToast.custom((id) => (
<Toast description={props.description} id={id} type={props.type} />
));
}
function Toast(props: ToastProps) {
const { id, type, description } = props;
const descriptionRef = useRef<HTMLDivElement>(null);
const [multiLine, setMultiLine] = useState(false);
useEffect(() => {
const el = descriptionRef.current;
if (!el) {
return;
}
const update = () => {
const lineHeight = Number.parseFloat(getComputedStyle(el).lineHeight);
const lines = Math.round(el.scrollHeight / lineHeight);
setMultiLine(lines > 1);
};
update();
const ro = new ResizeObserver(update);
ro.observe(el);
return () => ro.disconnect();
}, []);
return (
<div className="flex toast-mobile:w-[356px] w-full justify-center">
<div
className={cn(
"flex toast-mobile:w-fit w-full flex-row gap-3 rounded-lg bg-card border border-border/50 shadow-[var(--shadow-float)] p-3",
multiLine ? "items-start" : "items-center"
)}
data-testid="toast"
key={id}
>
<div
className={cn(
"data-[type=error]:text-red-600 data-[type=success]:text-green-600",
{ "pt-1": multiLine }
)}
data-type={type}
>
{iconsByType[type]}
</div>
<div className="text-sm text-foreground" ref={descriptionRef}>
{description}
</div>
</div>
</div>
);
}
type ToastProps = {
id: string | number;
type: "success" | "error";
description: string;
};

View file

@ -0,0 +1,111 @@
"use client";
import { type ReactNode, useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useChatVisibility } from "@/hooks/use-chat-visibility";
import { cn } from "@/lib/utils";
import {
CheckCircleFillIcon,
ChevronDownIcon,
GlobeIcon,
LockIcon,
} from "./icons";
export type VisibilityType = "private" | "public";
const visibilities: Array<{
id: VisibilityType;
label: string;
description: string;
icon: ReactNode;
}> = [
{
id: "private",
label: "Private",
description: "Only you can access this chat",
icon: <LockIcon />,
},
{
id: "public",
label: "Public",
description: "Anyone with the link can access this chat",
icon: <GlobeIcon />,
},
];
export function VisibilitySelector({
chatId,
className,
selectedVisibilityType,
}: {
chatId: string;
selectedVisibilityType: VisibilityType;
} & React.ComponentProps<typeof Button>) {
const [open, setOpen] = useState(false);
const { visibilityType, setVisibilityType } = useChatVisibility({
chatId,
initialVisibilityType: selectedVisibilityType,
});
const selectedVisibility = useMemo(
() => visibilities.find((visibility) => visibility.id === visibilityType),
[visibilityType]
);
return (
<DropdownMenu onOpenChange={setOpen} open={open}>
<DropdownMenuTrigger
asChild
className={cn(
"w-fit data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
className
)}
>
<Button
className="gap-1.5 rounded-lg border-border/50 text-muted-foreground shadow-none transition-colors hover:text-foreground focus-visible:ring-0 focus-visible:border-border/50 active:translate-y-0"
data-testid="visibility-selector"
size="sm"
variant="outline"
>
{selectedVisibility?.icon}
<span className="md:sr-only">{selectedVisibility?.label}</span>
<ChevronDownIcon />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="min-w-[300px]">
{visibilities.map((visibility) => (
<DropdownMenuItem
className="group/item flex flex-row items-center justify-between gap-4"
data-active={visibility.id === visibilityType}
data-testid={`visibility-selector-item-${visibility.id}`}
key={visibility.id}
onSelect={() => {
setVisibilityType(visibility.id);
setOpen(false);
}}
>
<div className="flex flex-col items-start gap-1">
{visibility.label}
{visibility.description && (
<div className="text-muted-foreground text-xs">
{visibility.description}
</div>
)}
</div>
<div className="text-foreground opacity-0 group-data-[active=true]/item:opacity-100 dark:text-foreground">
<CheckCircleFillIcon />
</div>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}

434
components/chat/weather.tsx Normal file
View file

@ -0,0 +1,434 @@
"use client";
import cx from "classnames";
import { format, isWithinInterval } from "date-fns";
import { useEffect, useState } from "react";
const SunIcon = ({ size = 40 }: { size?: number }) => (
<svg fill="none" height={size} viewBox="0 0 24 24" width={size}>
<circle cx="12" cy="12" fill="currentColor" r="5" />
<line stroke="currentColor" strokeWidth="2" x1="12" x2="12" y1="1" y2="3" />
<line
stroke="currentColor"
strokeWidth="2"
x1="12"
x2="12"
y1="21"
y2="23"
/>
<line
stroke="currentColor"
strokeWidth="2"
x1="4.22"
x2="5.64"
y1="4.22"
y2="5.64"
/>
<line
stroke="currentColor"
strokeWidth="2"
x1="18.36"
x2="19.78"
y1="18.36"
y2="19.78"
/>
<line stroke="currentColor" strokeWidth="2" x1="1" x2="3" y1="12" y2="12" />
<line
stroke="currentColor"
strokeWidth="2"
x1="21"
x2="23"
y1="12"
y2="12"
/>
<line
stroke="currentColor"
strokeWidth="2"
x1="4.22"
x2="5.64"
y1="19.78"
y2="18.36"
/>
<line
stroke="currentColor"
strokeWidth="2"
x1="18.36"
x2="19.78"
y1="5.64"
y2="4.22"
/>
</svg>
);
const MoonIcon = ({ size = 40 }: { size?: number }) => (
<svg fill="none" height={size} viewBox="0 0 24 24" width={size}>
<path
d="M21 12.79A9 9 0 1 1 11.21 3A7 7 0 0 0 21 12.79z"
fill="currentColor"
/>
</svg>
);
const CloudIcon = ({ size = 24 }: { size?: number }) => (
<svg fill="none" height={size} viewBox="0 0 24 24" width={size}>
<path
d="M18 10h-1.26A8 8 0 1 0 9 20h9a5 5 0 0 0 0-10z"
fill="none"
stroke="currentColor"
strokeWidth="2"
/>
</svg>
);
type WeatherAtLocation = {
latitude: number;
longitude: number;
generationtime_ms: number;
utc_offset_seconds: number;
timezone: string;
timezone_abbreviation: string;
elevation: number;
cityName?: string;
current_units: {
time: string;
interval: string;
temperature_2m: string;
};
current: {
time: string;
interval: number;
temperature_2m: number;
};
hourly_units: {
time: string;
temperature_2m: string;
};
hourly: {
time: string[];
temperature_2m: number[];
};
daily_units: {
time: string;
sunrise: string;
sunset: string;
};
daily: {
time: string[];
sunrise: string[];
sunset: string[];
};
};
const SAMPLE = {
latitude: 37.763_283,
longitude: -122.412_86,
generationtime_ms: 0.027_894_973_754_882_812,
utc_offset_seconds: 0,
timezone: "GMT",
timezone_abbreviation: "GMT",
elevation: 18,
current_units: { time: "iso8601", interval: "seconds", temperature_2m: "°C" },
current: { time: "2024-10-07T19:30", interval: 900, temperature_2m: 29.3 },
hourly_units: { time: "iso8601", temperature_2m: "°C" },
hourly: {
time: [
"2024-10-07T00:00",
"2024-10-07T01:00",
"2024-10-07T02:00",
"2024-10-07T03:00",
"2024-10-07T04:00",
"2024-10-07T05:00",
"2024-10-07T06:00",
"2024-10-07T07:00",
"2024-10-07T08:00",
"2024-10-07T09:00",
"2024-10-07T10:00",
"2024-10-07T11:00",
"2024-10-07T12:00",
"2024-10-07T13:00",
"2024-10-07T14:00",
"2024-10-07T15:00",
"2024-10-07T16:00",
"2024-10-07T17:00",
"2024-10-07T18:00",
"2024-10-07T19:00",
"2024-10-07T20:00",
"2024-10-07T21:00",
"2024-10-07T22:00",
"2024-10-07T23:00",
"2024-10-08T00:00",
"2024-10-08T01:00",
"2024-10-08T02:00",
"2024-10-08T03:00",
"2024-10-08T04:00",
"2024-10-08T05:00",
"2024-10-08T06:00",
"2024-10-08T07:00",
"2024-10-08T08:00",
"2024-10-08T09:00",
"2024-10-08T10:00",
"2024-10-08T11:00",
"2024-10-08T12:00",
"2024-10-08T13:00",
"2024-10-08T14:00",
"2024-10-08T15:00",
"2024-10-08T16:00",
"2024-10-08T17:00",
"2024-10-08T18:00",
"2024-10-08T19:00",
"2024-10-08T20:00",
"2024-10-08T21:00",
"2024-10-08T22:00",
"2024-10-08T23:00",
"2024-10-09T00:00",
"2024-10-09T01:00",
"2024-10-09T02:00",
"2024-10-09T03:00",
"2024-10-09T04:00",
"2024-10-09T05:00",
"2024-10-09T06:00",
"2024-10-09T07:00",
"2024-10-09T08:00",
"2024-10-09T09:00",
"2024-10-09T10:00",
"2024-10-09T11:00",
"2024-10-09T12:00",
"2024-10-09T13:00",
"2024-10-09T14:00",
"2024-10-09T15:00",
"2024-10-09T16:00",
"2024-10-09T17:00",
"2024-10-09T18:00",
"2024-10-09T19:00",
"2024-10-09T20:00",
"2024-10-09T21:00",
"2024-10-09T22:00",
"2024-10-09T23:00",
"2024-10-10T00:00",
"2024-10-10T01:00",
"2024-10-10T02:00",
"2024-10-10T03:00",
"2024-10-10T04:00",
"2024-10-10T05:00",
"2024-10-10T06:00",
"2024-10-10T07:00",
"2024-10-10T08:00",
"2024-10-10T09:00",
"2024-10-10T10:00",
"2024-10-10T11:00",
"2024-10-10T12:00",
"2024-10-10T13:00",
"2024-10-10T14:00",
"2024-10-10T15:00",
"2024-10-10T16:00",
"2024-10-10T17:00",
"2024-10-10T18:00",
"2024-10-10T19:00",
"2024-10-10T20:00",
"2024-10-10T21:00",
"2024-10-10T22:00",
"2024-10-10T23:00",
"2024-10-11T00:00",
"2024-10-11T01:00",
"2024-10-11T02:00",
"2024-10-11T03:00",
],
temperature_2m: [
36.6, 32.8, 29.5, 28.6, 29.2, 28.2, 27.5, 26.6, 26.5, 26, 25, 23.5, 23.9,
24.2, 22.9, 21, 24, 28.1, 31.4, 33.9, 32.1, 28.9, 26.9, 25.2, 23, 21.1,
19.6, 18.6, 17.7, 16.8, 16.2, 15.5, 14.9, 14.4, 14.2, 13.7, 13.3, 12.9,
12.5, 13.5, 15.8, 17.7, 19.6, 21, 21.9, 22.3, 22, 20.7, 18.9, 17.9, 17.3,
17, 16.7, 16.2, 15.6, 15.2, 15, 15, 15.1, 14.8, 14.8, 14.9, 14.7, 14.8,
15.3, 16.2, 17.9, 19.6, 20.5, 21.6, 21, 20.7, 19.3, 18.7, 18.4, 17.9,
17.3, 17, 17, 16.8, 16.4, 16.2, 16, 15.8, 15.7, 15.4, 15.4, 16.1, 16.7,
17, 18.6, 19, 19.5, 19.4, 18.5, 17.9, 17.5, 16.7, 16.3, 16.1,
],
},
daily_units: {
time: "iso8601",
sunrise: "iso8601",
sunset: "iso8601",
},
daily: {
time: [
"2024-10-07",
"2024-10-08",
"2024-10-09",
"2024-10-10",
"2024-10-11",
],
sunrise: [
"2024-10-07T07:15",
"2024-10-08T07:16",
"2024-10-09T07:17",
"2024-10-10T07:18",
"2024-10-11T07:19",
],
sunset: [
"2024-10-07T19:00",
"2024-10-08T18:58",
"2024-10-09T18:57",
"2024-10-10T18:55",
"2024-10-11T18:54",
],
},
};
function n(num: number): number {
return Math.ceil(num);
}
export function Weather({
weatherAtLocation = SAMPLE,
}: {
weatherAtLocation?: WeatherAtLocation;
}) {
const currentHigh = Math.max(
...weatherAtLocation.hourly.temperature_2m.slice(0, 24)
);
const currentLow = Math.min(
...weatherAtLocation.hourly.temperature_2m.slice(0, 24)
);
const isDay = isWithinInterval(new Date(weatherAtLocation.current.time), {
start: new Date(weatherAtLocation.daily.sunrise[0]),
end: new Date(weatherAtLocation.daily.sunset[0]),
});
const [isMobile, setIsMobile] = useState(false);
useEffect(() => {
const handleResize = () => {
setIsMobile(window.innerWidth < 768);
};
handleResize();
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, []);
const hoursToShow = isMobile ? 5 : 6;
const currentTimeIndex = weatherAtLocation.hourly.time.findIndex(
(time) => new Date(time) >= new Date(weatherAtLocation.current.time)
);
const displayTimes = weatherAtLocation.hourly.time.slice(
currentTimeIndex,
currentTimeIndex + hoursToShow
);
const displayTemperatures = weatherAtLocation.hourly.temperature_2m.slice(
currentTimeIndex,
currentTimeIndex + hoursToShow
);
const location =
weatherAtLocation.cityName ||
`${weatherAtLocation.latitude?.toFixed(1)}°, ${weatherAtLocation.longitude?.toFixed(1)}°`;
return (
<div
className={cx(
"relative flex w-full flex-col gap-3 overflow-hidden rounded-2xl p-4 shadow-lg backdrop-blur-sm",
{
"bg-gradient-to-br from-sky-400 via-blue-500 to-blue-600": isDay,
},
{
"bg-gradient-to-br from-indigo-900 via-purple-900 to-slate-900":
!isDay,
}
)}
>
<div className="absolute inset-0 bg-white/10 backdrop-blur-sm" />
<div className="relative z-10">
<div className="mb-2 flex items-center justify-between">
<div className="font-medium text-white/80 text-xs">{location}</div>
<div className="text-white/60 text-xs">
{format(new Date(weatherAtLocation.current.time), "MMM d, h:mm a")}
</div>
</div>
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-3">
<div
className={cx("text-white/90", {
"text-yellow-200": isDay,
"text-blue-200": !isDay,
})}
>
{isDay ? <SunIcon size={32} /> : <MoonIcon size={32} />}
</div>
<div className="font-light text-3xl text-white">
{n(weatherAtLocation.current.temperature_2m)}
<span className="text-lg text-white/80">
{weatherAtLocation.current_units.temperature_2m}
</span>
</div>
</div>
<div className="text-right">
<div className="font-medium text-white/90 text-xs">
H: {n(currentHigh)}°
</div>
<div className="text-white/70 text-xs">L: {n(currentLow)}°</div>
</div>
</div>
<div className="rounded-xl bg-white/10 p-3 backdrop-blur-sm">
<div className="mb-2 font-medium text-white/80 text-xs">
Hourly Forecast
</div>
<div className="flex justify-between gap-1">
{displayTimes.map((time, index) => {
const hourTime = new Date(time);
const isCurrentHour =
hourTime.getHours() === new Date().getHours();
return (
<div
className={cx(
"flex min-w-0 flex-1 flex-col items-center gap-1 rounded-md px-1 py-1.5",
{
"bg-white/20": isCurrentHour,
}
)}
key={time}
>
<div className="font-medium text-white/70 text-xs">
{index === 0 ? "Now" : format(hourTime, "ha")}
</div>
<div
className={cx("text-white/60", {
"text-yellow-200": isDay,
"text-blue-200": !isDay,
})}
>
<CloudIcon size={16} />
</div>
<div className="font-medium text-white text-xs">
{n(displayTemperatures[index])}°
</div>
</div>
);
})}
</div>
</div>
<div className="mt-2 flex justify-between text-white/60 text-xs">
<div>
Sunrise:{" "}
{format(new Date(weatherAtLocation.daily.sunrise[0]), "h:mm a")}
</div>
<div>
Sunset:{" "}
{format(new Date(weatherAtLocation.daily.sunset[0]), "h:mm a")}
</div>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,8 @@
"use client";
import { ThemeProvider as NextThemesProvider } from "next-themes";
import type { ThemeProviderProps } from "next-themes/dist/types";
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
}

View file

@ -0,0 +1,199 @@
"use client"
import * as React from "react"
import { AlertDialog as AlertDialogPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
function AlertDialog({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
}
function AlertDialogTrigger({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
)
}
function AlertDialogPortal({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
)
}
function AlertDialogOverlay({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
return (
<AlertDialogPrimitive.Overlay
data-slot="alert-dialog-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/80 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function AlertDialogContent({
className,
size = "default",
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content> & {
size?: "default" | "sm"
}) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
data-slot="alert-dialog-content"
data-size={size}
className={cn(
"group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 gap-6 rounded-lg bg-background p-6 ring-1 ring-foreground/5 duration-100 outline-none data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-md data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
/>
</AlertDialogPortal>
)
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn(
"grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-6 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
className
)}
{...props}
/>
)
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function AlertDialogMedia({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-media"
className={cn(
"mb-2 inline-flex size-16 items-center justify-center rounded-full bg-muted sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-8",
className
)}
{...props}
/>
)
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn(
"text-lg font-medium sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
className
)}
{...props}
/>
)
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn(
"text-sm text-balance text-muted-foreground md:text-pretty *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
function AlertDialogAction({
className,
variant = "default",
size = "default",
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action> &
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return (
<Button variant={variant} size={size} asChild>
<AlertDialogPrimitive.Action
data-slot="alert-dialog-action"
className={cn(className)}
{...props}
/>
</Button>
)
}
function AlertDialogCancel({
className,
variant = "outline",
size = "default",
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel> &
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return (
<Button variant={variant} size={size} asChild>
<AlertDialogPrimitive.Cancel
data-slot="alert-dialog-cancel"
className={cn(className)}
{...props}
/>
</Button>
)
}
export {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogTitle,
AlertDialogTrigger,
}

49
components/ui/badge.tsx Normal file
View file

@ -0,0 +1,49 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
secondary:
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive:
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
outline:
"border-border bg-input/30 text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost:
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "span"
return (
<Comp
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }

View file

@ -0,0 +1,83 @@
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
import { Separator } from "@/components/ui/separator"
const buttonGroupVariants = cva(
"flex w-fit items-stretch *:focus-visible:relative *:focus-visible:z-10 has-[>[data-slot=button-group]]:gap-2 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-4xl [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1",
{
variants: {
orientation: {
horizontal:
"[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-r-4xl!",
vertical:
"flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-4xl!",
},
},
defaultVariants: {
orientation: "horizontal",
},
}
)
function ButtonGroup({
className,
orientation,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof buttonGroupVariants>) {
return (
<div
role="group"
data-slot="button-group"
data-orientation={orientation}
className={cn(buttonGroupVariants({ orientation }), className)}
{...props}
/>
)
}
function ButtonGroupText({
className,
asChild = false,
...props
}: React.ComponentProps<"div"> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "div"
return (
<Comp
className={cn(
"flex items-center gap-2 rounded-4xl border bg-muted px-2.5 text-sm font-medium [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function ButtonGroupSeparator({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="button-group-separator"
orientation={orientation}
className={cn(
"relative self-stretch bg-input data-horizontal:mx-px data-horizontal:w-auto data-vertical:my-px data-vertical:h-auto",
className
)}
{...props}
/>
)
}
export {
ButtonGroup,
ButtonGroupSeparator,
ButtonGroupText,
buttonGroupVariants,
}

65
components/ui/button.tsx Normal file
View file

@ -0,0 +1,65 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none active:translate-y-px disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/80",
outline:
"border-border bg-input/30 hover:bg-input/50 hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default:
"h-9 gap-1.5 px-3 has-data-[icon=inline-end]:pr-2.5 has-data-[icon=inline-start]:pl-2.5",
xs: "h-6 gap-1 px-2.5 text-xs has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2 [&_svg:not([class*='size-'])]:size-3",
sm: "h-8 gap-1 px-3 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
lg: "h-10 gap-1.5 px-4 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
icon: "size-9",
"icon-xs": "size-6 [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View file

@ -0,0 +1,33 @@
"use client"
import { Collapsible as CollapsiblePrimitive } from "radix-ui"
function Collapsible({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
}
function CollapsibleTrigger({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
return (
<CollapsiblePrimitive.CollapsibleTrigger
data-slot="collapsible-trigger"
{...props}
/>
)
}
function CollapsibleContent({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
return (
<CollapsiblePrimitive.CollapsibleContent
data-slot="collapsible-content"
{...props}
/>
)
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent }

194
components/ui/command.tsx Normal file
View file

@ -0,0 +1,194 @@
"use client"
import * as React from "react"
import { Command as CommandPrimitive } from "cmdk"
import { cn } from "@/lib/utils"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import {
InputGroup,
InputGroupAddon,
} from "@/components/ui/input-group"
import { SearchIcon } from "lucide-react"
function Command({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
"flex size-full flex-col overflow-hidden rounded-4xl bg-popover p-1 text-popover-foreground",
className
)}
{...props}
/>
)
}
function CommandDialog({
title = "Command Palette",
description = "Search for a command to run...",
children,
className,
showCloseButton = false,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string
description?: string
className?: string
showCloseButton?: boolean
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent
className={cn(
"top-1/3 translate-y-0 overflow-hidden rounded-4xl! p-0",
className
)}
showCloseButton={showCloseButton}
>
{children}
</DialogContent>
</Dialog>
)
}
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div data-slot="command-input-wrapper" className="p-1 pb-0">
<InputGroup className="h-9 bg-input/30">
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
"w-full text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
<InputGroupAddon>
<SearchIcon className="size-4 shrink-0 opacity-50" />
</InputGroupAddon>
</InputGroup>
</div>
)
}
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn(
"no-scrollbar max-h-72 scroll-py-1 overflow-x-hidden overflow-y-auto outline-none",
className
)}
{...props}
/>
)
}
function CommandEmpty({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className={cn("py-6 text-center text-sm", className)}
{...props}
/>
)
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
"overflow-hidden p-1 text-foreground **:[[cmdk-group-heading]]:px-3 **:[[cmdk-group-heading]]:py-2 **:[[cmdk-group-heading]]:text-xs **:[[cmdk-group-heading]]:font-medium **:[[cmdk-group-heading]]:text-muted-foreground",
className
)}
{...props}
/>
)
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn("my-1 h-px bg-border/50", className)}
{...props}
/>
)
}
function CommandItem({
className,
children,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"group/command-item relative flex cursor-default items-center gap-2 rounded-lg px-3 py-2 text-sm outline-hidden select-none in-data-[slot=dialog-content]:rounded-2xl data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-selected:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-selected:*:[svg]:text-foreground",
className
)}
{...props}
>
{children}
</CommandPrimitive.Item>
)
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="command-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-data-selected/command-item:text-foreground",
className
)}
{...props}
/>
)
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}

165
components/ui/dialog.tsx Normal file
View file

@ -0,0 +1,165 @@
"use client"
import * as React from "react"
import { Dialog as DialogPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/80 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-6 rounded-4xl bg-background p-6 text-sm ring-1 ring-foreground/5 duration-100 outline-none sm:max-w-md data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close data-slot="dialog-close" asChild>
<Button
variant="ghost"
className="absolute top-4 right-4"
size="icon-sm"
>
<XIcon
/>
<span className="sr-only">Close</span>
</Button>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close asChild>
<Button variant="outline">Close</Button>
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-base leading-none font-medium", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn(
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

View file

@ -0,0 +1,269 @@
"use client"
import * as React from "react"
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { CheckIcon, ChevronRightIcon } from "lucide-react"
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
align = "start",
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
align={align}
className={cn("z-50 max-h-(--radix-dropdown-menu-content-available-height) w-(--radix-dropdown-menu-trigger-width) min-w-48 origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-2xl ring-1 ring-foreground/5 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:overflow-hidden dark:ring-foreground/10 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"group/dropdown-menu-item relative flex cursor-default items-center gap-2.5 rounded-lg px-3 py-2 text-sm outline-hidden select-none transition-colors duration-150 focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-9.5 data-[variant=destructive]:focus:bg-destructive/10 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-2.5 rounded-xl py-2 pr-8 pl-3 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-9.5 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-checkbox-item-indicator"
>
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon
/>
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-2.5 rounded-xl py-2 pr-8 pl-3 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-9.5 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-radio-item-indicator"
>
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon
/>
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-3 py-2.5 text-xs text-muted-foreground data-inset:pl-9.5",
className
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border/50", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-2 rounded-xl px-3 py-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-9.5 data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn("z-50 min-w-36 origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-lg bg-popover p-1 text-popover-foreground shadow-2xl ring-1 ring-foreground/5 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}

View file

@ -0,0 +1,44 @@
"use client"
import * as React from "react"
import { HoverCard as HoverCardPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function HoverCard({
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />
}
function HoverCardTrigger({
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
return (
<HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
)
}
function HoverCardContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
return (
<HoverCardPrimitive.Portal data-slot="hover-card-portal">
<HoverCardPrimitive.Content
data-slot="hover-card-content"
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 origin-(--radix-hover-card-content-transform-origin) rounded-2xl bg-popover p-4 text-sm text-popover-foreground shadow-2xl ring-1 ring-foreground/5 outline-hidden duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
/>
</HoverCardPrimitive.Portal>
)
}
export { HoverCard, HoverCardTrigger, HoverCardContent }

View file

@ -0,0 +1,155 @@
"use client"
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-group"
role="group"
className={cn(
"group/input-group relative flex h-9 w-full min-w-0 items-center rounded-4xl border border-input bg-input/30 transition-colors outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-data-[align=block-end]:rounded-2xl has-data-[align=block-start]:rounded-2xl has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-[3px] has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-[3px] has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[textarea]:rounded-xl has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5",
className
)}
{...props}
/>
)
}
const inputGroupAddonVariants = cva(
"flex h-auto cursor-text items-center justify-center gap-2 py-2 text-sm font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 **:data-[slot=kbd]:rounded-4xl **:data-[slot=kbd]:bg-muted-foreground/10 **:data-[slot=kbd]:px-1.5 [&>svg:not([class*='size-'])]:size-4",
{
variants: {
align: {
"inline-start":
"order-first pl-3 has-[>button]:-ml-1 has-[>kbd]:ml-[-0.15rem]",
"inline-end":
"order-last pr-3 has-[>button]:-mr-1 has-[>kbd]:mr-[-0.15rem]",
"block-start":
"order-first w-full justify-start px-3 pt-3 group-has-[>input]/input-group:pt-3 [.border-b]:pb-3",
"block-end":
"order-last w-full justify-start px-3 pb-3 group-has-[>input]/input-group:pb-3 [.border-t]:pt-3",
},
},
defaultVariants: {
align: "inline-start",
},
}
)
function InputGroupAddon({
className,
align = "inline-start",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
return (
<div
role="group"
data-slot="input-group-addon"
data-align={align}
className={cn(inputGroupAddonVariants({ align }), className)}
onClick={(e) => {
if ((e.target as HTMLElement).closest("button")) {
return
}
e.currentTarget.parentElement?.querySelector("input")?.focus()
}}
{...props}
/>
)
}
const inputGroupButtonVariants = cva(
"flex items-center gap-2 rounded-4xl text-sm shadow-none",
{
variants: {
size: {
xs: "h-6 gap-1 px-1.5 [&>svg:not([class*='size-'])]:size-3.5",
sm: "",
"icon-xs": "size-6 p-0 has-[>svg]:p-0",
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
},
},
defaultVariants: {
size: "xs",
},
}
)
function InputGroupButton({
className,
type = "button",
variant = "ghost",
size = "xs",
...props
}: Omit<React.ComponentProps<typeof Button>, "size"> &
VariantProps<typeof inputGroupButtonVariants>) {
return (
<Button
type={type}
data-size={size}
variant={variant}
className={cn(inputGroupButtonVariants({ size }), className)}
{...props}
/>
)
}
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
className={cn(
"flex items-center gap-2 text-sm text-muted-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function InputGroupInput({
className,
...props
}: React.ComponentProps<"input">) {
return (
<Input
data-slot="input-group-control"
className={cn(
"flex-1 rounded-none border-0 bg-transparent shadow-none ring-0 focus-visible:ring-0 aria-invalid:ring-0 dark:bg-transparent",
className
)}
{...props}
/>
)
}
function InputGroupTextarea({
className,
...props
}: React.ComponentProps<"textarea">) {
return (
<Textarea
data-slot="input-group-control"
className={cn(
"flex-1 resize-none rounded-none border-0 bg-transparent py-2 shadow-none ring-0 focus-visible:ring-0 aria-invalid:ring-0 dark:bg-transparent",
className
)}
{...props}
/>
)
}
export {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupText,
InputGroupInput,
InputGroupTextarea,
}

19
components/ui/input.tsx Normal file
View file

@ -0,0 +1,19 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"h-9 w-full min-w-0 rounded-4xl border border-input bg-input/30 px-3 py-1 text-base transition-colors outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-[3px] aria-invalid:ring-destructive/20 md:text-sm dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Input }

24
components/ui/label.tsx Normal file
View file

@ -0,0 +1,24 @@
"use client"
import * as React from "react"
import { Label as LabelPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }

40
components/ui/popover.tsx Normal file
View file

@ -0,0 +1,40 @@
"use client";
import { Popover } from "radix-ui";
import { cn } from "@/lib/utils";
function PopoverRoot({ ...props }: React.ComponentProps<typeof Popover.Root>) {
return <Popover.Root data-slot="popover" {...props} />;
}
function PopoverTrigger({ ...props }: React.ComponentProps<typeof Popover.Trigger>) {
return <Popover.Trigger data-slot="popover-trigger" {...props} />;
}
function PopoverAnchor({ ...props }: React.ComponentProps<typeof Popover.Anchor>) {
return <Popover.Anchor data-slot="popover-anchor" {...props} />;
}
function PopoverContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof Popover.Content>) {
return (
<Popover.Portal>
<Popover.Content
align={align}
className={cn(
"z-50 w-72 rounded-xl border border-border/60 bg-card/95 p-4 shadow-[var(--shadow-float)] backdrop-blur-xl outline-hidden data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
data-slot="popover-content"
sideOffset={sideOffset}
{...props}
/>
</Popover.Portal>
);
}
export { PopoverRoot as Popover, PopoverTrigger, PopoverContent, PopoverAnchor };

View file

@ -0,0 +1,55 @@
"use client"
import * as React from "react"
import { ScrollArea as ScrollAreaPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function ScrollArea({
className,
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
data-orientation={orientation}
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-l data-vertical:border-l-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="relative flex-1 rounded-full bg-border"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
)
}
export { ScrollArea, ScrollBar }

195
components/ui/select.tsx Normal file
View file

@ -0,0 +1,195 @@
"use client"
import * as React from "react"
import { Select as SelectPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return (
<SelectPrimitive.Group
data-slot="select-group"
className={cn("scroll-my-1 p-1", className)}
{...props}
/>
)
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-1.5 rounded-4xl border border-input bg-input/30 px-3 py-2 text-sm whitespace-nowrap transition-colors outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-[3px] aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "item-aligned",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
data-align-trigger={position === "item-aligned"}
className={cn("relative z-50 max-h-(--radix-select-content-available-height) min-w-36 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-2xl bg-popover text-popover-foreground shadow-2xl ring-1 ring-foreground/5 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", position ==="popper"&&"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", className )}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
data-position={position}
className={cn(
"data-[position=popper]:h-(--radix-select-trigger-height) data-[position=popper]:w-full data-[position=popper]:min-w-(--radix-select-trigger-width)",
position === "popper" && ""
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("px-3 py-2.5 text-xs text-muted-foreground", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-2.5 rounded-xl py-2 pr-8 pl-3 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="pointer-events-none" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn(
"pointer-events-none -mx-1 my-1 h-px bg-border/50",
className
)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronUpIcon
/>
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronDownIcon
/>
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}

View file

@ -0,0 +1,28 @@
"use client"
import * as React from "react"
import { Separator as SeparatorPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
className
)}
{...props}
/>
)
}
export { Separator }

144
components/ui/sheet.tsx Normal file
View file

@ -0,0 +1,144 @@
"use client"
import * as React from "react"
import { Dialog as SheetPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/50 supports-backdrop-filter:backdrop-blur-sm data-open:animate-in data-open:fade-in-0 data-open:duration-300 data-closed:animate-out data-closed:fade-out-0 data-closed:duration-200",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
showCloseButton = true,
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left"
showCloseButton?: boolean
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
data-side={side}
className={cn(
"fixed z-50 flex flex-col bg-background bg-clip-padding text-sm shadow-2xl data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-[85%] data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-[85%] data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:duration-400 data-open:ease-[cubic-bezier(0.32,0.72,0,1)] data-[side=bottom]:data-open:slide-in-from-bottom-full data-[side=left]:data-open:slide-in-from-left-full data-[side=right]:data-open:slide-in-from-right-full data-[side=top]:data-open:slide-in-from-top-full data-closed:animate-out data-closed:fade-out-0 data-closed:duration-300 data-closed:ease-[cubic-bezier(0.32,0.72,0,1)] data-[side=bottom]:data-closed:slide-out-to-bottom-full data-[side=left]:data-closed:slide-out-to-left-full data-[side=right]:data-closed:slide-out-to-right-full data-[side=top]:data-closed:slide-out-to-top-full",
className
)}
{...props}
>
{children}
{showCloseButton && (
<SheetPrimitive.Close data-slot="sheet-close" asChild>
<Button
variant="ghost"
className="absolute top-4 right-4"
size="icon-sm"
>
<XIcon
/>
<span className="sr-only">Close</span>
</Button>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Content>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-1.5 p-6", className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-6", className)}
{...props}
/>
)
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("text-base font-medium text-foreground", className)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}

713
components/ui/sidebar.tsx Normal file
View file

@ -0,0 +1,713 @@
"use client"
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { useIsMobile } from "@/hooks/use-mobile"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
import { Skeleton } from "@/components/ui/skeleton"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { PanelLeftIcon } from "lucide-react"
const SIDEBAR_COOKIE_NAME = "sidebar_state"
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
const SIDEBAR_WIDTH = "16rem"
const SIDEBAR_WIDTH_MOBILE = "18rem"
const SIDEBAR_WIDTH_ICON = "3rem"
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
type SidebarContextProps = {
state: "expanded" | "collapsed"
open: boolean
setOpen: (open: boolean) => void
openMobile: boolean
setOpenMobile: (open: boolean) => void
isMobile: boolean
toggleSidebar: () => void
}
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
function useSidebar() {
const context = React.useContext(SidebarContext)
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.")
}
return context
}
function SidebarProvider({
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
}: React.ComponentProps<"div"> & {
defaultOpen?: boolean
open?: boolean
onOpenChange?: (open: boolean) => void
}) {
const isMobile = useIsMobile()
const [openMobile, setOpenMobile] = React.useState(false)
const [_open, _setOpen] = React.useState(defaultOpen)
const open = openProp ?? _open
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value
if (setOpenProp) {
setOpenProp(openState)
} else {
_setOpen(openState)
}
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
},
[setOpenProp, open]
)
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
}, [isMobile, setOpen, setOpenMobile])
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault()
toggleSidebar()
}
}
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [toggleSidebar])
const state = open ? "expanded" : "collapsed"
const contextValue = React.useMemo<SidebarContextProps>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
)
return (
<SidebarContext.Provider value={contextValue}>
<div
data-slot="sidebar-wrapper"
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn(
"group/sidebar-wrapper flex min-h-svh w-full bg-sidebar",
className
)}
{...props}
>
{children}
</div>
</SidebarContext.Provider>
)
}
function Sidebar({
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
dir,
...props
}: React.ComponentProps<"div"> & {
side?: "left" | "right"
variant?: "sidebar" | "floating" | "inset"
collapsible?: "offcanvas" | "icon" | "none"
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
if (collapsible === "none") {
return (
<div
data-slot="sidebar"
className={cn(
"flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground",
className
)}
{...props}
>
{children}
</div>
)
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
dir={dir}
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
className="inset-x-0 bottom-0 top-auto h-[70dvh] w-full rounded-t-2xl border-t border-border/30 bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
showCloseButton={false}
side="bottom"
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div className="mx-auto mt-2 h-1 w-10 rounded-full bg-sidebar-foreground/20" />
<div className="flex h-full w-full flex-col overflow-y-auto pt-2">{children}</div>
</SheetContent>
</Sheet>
)
}
return (
<div
className="group peer hidden text-sidebar-foreground md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
data-slot="sidebar"
>
<div
data-slot="sidebar-gap"
className={cn(
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
)}
/>
<div
data-slot="sidebar-container"
data-side={side}
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)] data-[side=left]:left-0 data-[side=left]:group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)] data-[side=right]:right-0 data-[side=right]:group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)] md:flex",
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)",
className
)}
{...props}
>
<div
data-sidebar="sidebar"
data-slot="sidebar-inner"
className="flex size-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border-none group-data-[variant=floating]:shadow-sm group-data-[variant=floating]:ring-1 group-data-[variant=floating]:ring-sidebar-border"
>
{children}
</div>
</div>
</div>
)
}
function SidebarTrigger({
className,
onClick,
...props
}: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar()
return (
<Button
data-sidebar="trigger"
data-slot="sidebar-trigger"
variant="ghost"
size="icon-sm"
className={cn(className)}
onClick={(event) => {
onClick?.(event)
toggleSidebar()
}}
{...props}
>
<PanelLeftIcon />
<span className="sr-only">Toggle Sidebar</span>
</Button>
)
}
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
const { toggleSidebar, state } = useSidebar()
const isCollapsed = state === "collapsed"
return (
<div
data-slot="sidebar-rail"
className={cn(
"group/rail absolute inset-y-0 z-20 hidden w-4 overflow-visible group-data-[side=left]:-right-4 sm:block",
className
)}
>
<button
data-sidebar="rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
className="absolute inset-y-0 left-0 w-4 cursor-w-resize [[data-side=left][data-state=collapsed]_&]:cursor-e-resize"
{...props}
/>
<button
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
className={cn(
"absolute left-0 h-3 w-3 cursor-e-resize",
isCollapsed ? "top-0" : "top-[calc(3.5rem-6px)] cursor-w-resize"
)}
/>
<button
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
className={cn(
"absolute left-3 h-[6px] w-[100vw] cursor-e-resize",
isCollapsed ? "top-0" : "top-[calc(3.5rem-6px)] cursor-w-resize"
)}
/>
<div className={cn(
"pointer-events-none absolute bottom-0 left-0 w-[100vw] rounded-tl-[12px] border-t border-l border-sidebar-border opacity-0 transition-opacity duration-150 group-hover/rail:opacity-100",
isCollapsed ? "top-0" : "top-14"
)} />
</div>
)
}
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
return (
<main
data-slot="sidebar-inset"
className={cn(
"relative flex w-full flex-1 flex-col bg-sidebar [transform:translate3d(0,0,0)]",
className
)}
{...props}
/>
)
}
function SidebarInput({
className,
...props
}: React.ComponentProps<typeof Input>) {
return (
<Input
data-slot="sidebar-input"
data-sidebar="input"
className={cn("h-8 w-full bg-background shadow-none", className)}
{...props}
/>
)
}
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-header"
data-sidebar="header"
className={cn(
"flex flex-col gap-2 p-2 [--radius:var(--radius-xl)]",
className
)}
{...props}
/>
)
}
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-footer"
data-sidebar="footer"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
}
function SidebarSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="sidebar-separator"
data-sidebar="separator"
className={cn("mx-2 w-auto bg-sidebar-border", className)}
{...props}
/>
)
}
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-content"
data-sidebar="content"
className={cn(
"no-scrollbar flex min-h-0 flex-1 flex-col gap-2 overflow-auto [--radius:var(--radius-xl)] group-data-[collapsible=icon]:overflow-hidden",
className
)}
{...props}
/>
)
}
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group"
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
)
}
function SidebarGroupLabel({
className,
asChild = false,
...props
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "div"
return (
<Comp
data-slot="sidebar-group-label"
data-sidebar="group-label"
className={cn(
"flex h-8 shrink-0 items-center rounded-md px-2 text-[10px] font-semibold uppercase tracking-[0.12em] text-sidebar-foreground/70 ring-sidebar-ring outline-hidden transition-[margin,opacity] duration-200 ease-linear group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
className
)}
{...props}
/>
)
}
function SidebarGroupAction({
className,
asChild = false,
...props
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="sidebar-group-action"
data-sidebar="group-action"
className={cn(
"absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
className
)}
{...props}
/>
)
}
function SidebarGroupContent({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group-content"
data-sidebar="group-content"
className={cn("w-full text-sm", className)}
{...props}
/>
)
}
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu"
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
{...props}
/>
)
}
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-item"
data-sidebar="menu-item"
className={cn("group/menu-item relative", className)}
{...props}
/>
)
}
const sidebarMenuButtonVariants = cva(
"peer/menu-button group/menu-button flex w-full items-center gap-2 overflow-hidden px-2.5 text-left text-[13px] text-sidebar-foreground/70 outline-hidden transition-colors duration-150 group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! hover:text-sidebar-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-open:hover:text-sidebar-accent-foreground data-active:font-medium data-active:text-sidebar-accent-foreground [&_svg]:size-4 [&_svg]:shrink-0 [&>span:last-child]:truncate",
{
variants: {
variant: {
default: "hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
},
size: {
default: "h-8 text-[13px]",
sm: "h-8 text-xs",
lg: "h-14 px-3 text-sm group-data-[collapsible=icon]:p-0!",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function SidebarMenuButton({
asChild = false,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean
isActive?: boolean
tooltip?: string | React.ComponentProps<typeof TooltipContent>
} & VariantProps<typeof sidebarMenuButtonVariants>) {
const Comp = asChild ? Slot.Root : "button"
const { isMobile, state } = useSidebar()
const button = (
<Comp
data-slot="sidebar-menu-button"
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
)
if (!tooltip) {
return button
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
}
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
{...tooltip}
/>
</Tooltip>
)
}
function SidebarMenuAction({
className,
asChild = false,
showOnHover = false,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean
showOnHover?: boolean
}) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="sidebar-menu-action"
data-sidebar="menu-action"
className={cn(
"absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground/40 outline-hidden transition-colors duration-150 group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-foreground/60 peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
showOnHover &&
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 peer-data-active/menu-button:text-sidebar-accent-foreground aria-expanded:opacity-100 md:opacity-0",
className
)}
{...props}
/>
)
}
function SidebarMenuBadge({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-menu-badge"
data-sidebar="menu-badge"
className={cn(
"pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium text-sidebar-foreground tabular-nums select-none group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 peer-data-active/menu-button:text-sidebar-accent-foreground",
className
)}
{...props}
/>
)
}
function SidebarMenuSkeleton({
className,
showIcon = false,
...props
}: React.ComponentProps<"div"> & {
showIcon?: boolean
}) {
const [width] = React.useState(() => {
return `${Math.floor(Math.random() * 40) + 50}%`
})
return (
<div
data-slot="sidebar-menu-skeleton"
data-sidebar="menu-skeleton"
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...props}
>
{showIcon && (
<Skeleton
className="size-4 rounded-md"
data-sidebar="menu-skeleton-icon"
/>
)}
<Skeleton
className="h-4 max-w-(--skeleton-width) flex-1"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width,
} as React.CSSProperties
}
/>
</div>
)
}
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu-sub"
data-sidebar="menu-sub"
className={cn(
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5 group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
function SidebarMenuSubItem({
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-sub-item"
data-sidebar="menu-sub-item"
className={cn("group/menu-sub-item relative", className)}
{...props}
/>
)
}
function SidebarMenuSubButton({
asChild = false,
size = "md",
isActive = false,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean
size?: "sm" | "md"
isActive?: boolean
}) {
const Comp = asChild ? Slot.Root : "a"
return (
<Comp
data-slot="sidebar-menu-sub-button"
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground ring-sidebar-ring outline-hidden group-data-[collapsible=icon]:hidden hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[size=md]:text-sm data-[size=sm]:text-xs data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
className
)}
{...props}
/>
)
}
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
}

View file

@ -0,0 +1,13 @@
import { cn } from "@/lib/utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("animate-pulse rounded-xl bg-muted", className)}
{...props}
/>
)
}
export { Skeleton }

10
components/ui/spinner.tsx Normal file
View file

@ -0,0 +1,10 @@
import { cn } from "@/lib/utils"
import { Loader2Icon } from "lucide-react"
function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
return (
<Loader2Icon role="status" aria-label="Loading" className={cn("size-4 animate-spin", className)} {...props} />
)
}
export { Spinner }

View file

@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-16 w-full resize-none rounded-xl border border-input bg-input/30 px-3 py-3 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-[3px] aria-invalid:ring-destructive/20 md:text-sm dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Textarea }

57
components/ui/tooltip.tsx Normal file
View file

@ -0,0 +1,57 @@
"use client"
import * as React from "react"
import { Tooltip as TooltipPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"z-50 inline-flex w-fit max-w-xs origin-(--radix-tooltip-content-transform-origin) items-center gap-1.5 rounded-2xl bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-4xl data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground data-[side=left]:translate-x-[-1.5px] data-[side=right]:translate-x-[1.5px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }

22
docker-entrypoint.sh Normal file
View file

@ -0,0 +1,22 @@
#!/bin/sh
set -e
if [ -n "$DATABASE_URL" ]; then
echo "==> Running database migrations"
node -e "
const { drizzle } = require('drizzle-orm/postgres-js');
const { migrate } = require('drizzle-orm/postgres-js/migrator');
const postgres = require('postgres');
(async () => {
const sql = postgres(process.env.DATABASE_URL, { max: 1 });
const db = drizzle(sql);
await migrate(db, { migrationsFolder: './lib/db/migrations' });
await sql.end();
console.log('Migrations completed');
})().catch((err) => { console.error('Migration failed:', err); process.exit(1); });
"
else
echo "==> DATABASE_URL not set, skipping migrations"
fi
exec "$@"

15
drizzle.config.ts Normal file
View file

@ -0,0 +1,15 @@
import { config } from "dotenv";
import { defineConfig } from "drizzle-kit";
config({
path: ".env.local",
});
export default defineConfig({
schema: "./lib/db/schema.ts",
out: "./lib/db/migrations",
dialect: "postgresql",
dbCredentials: {
url: process.env.DATABASE_URL ?? "",
},
});

284
hooks/use-active-chat.tsx Normal file
View file

@ -0,0 +1,284 @@
"use client";
import type { UseChatHelpers } from "@ai-sdk/react";
import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai";
import { usePathname } from "next/navigation";
import {
createContext,
type Dispatch,
type ReactNode,
type SetStateAction,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import useSWR, { useSWRConfig } from "swr";
import { unstable_serialize } from "swr/infinite";
import { useDataStream } from "@/components/chat/data-stream-provider";
import { getChatHistoryPaginationKey } from "@/components/chat/sidebar-history";
import { toast } from "@/components/chat/toast";
import type { VisibilityType } from "@/components/chat/visibility-selector";
import { DEFAULT_CHAT_MODEL } from "@/lib/ai/models";
import type { Vote } from "@/lib/db/schema";
import { ChatbotError } from "@/lib/errors";
import type { ChatMessage } from "@/lib/types";
import { fetcher, fetchWithErrorHandlers, generateUUID } from "@/lib/utils";
type ActiveChatContextValue = {
chatId: string;
messages: ChatMessage[];
setMessages: UseChatHelpers<ChatMessage>["setMessages"];
sendMessage: UseChatHelpers<ChatMessage>["sendMessage"];
status: UseChatHelpers<ChatMessage>["status"];
stop: UseChatHelpers<ChatMessage>["stop"];
regenerate: UseChatHelpers<ChatMessage>["regenerate"];
addToolApprovalResponse: UseChatHelpers<ChatMessage>["addToolApprovalResponse"];
input: string;
setInput: Dispatch<SetStateAction<string>>;
visibilityType: VisibilityType;
isReadonly: boolean;
isLoading: boolean;
votes: Vote[] | undefined;
currentModelId: string;
setCurrentModelId: (id: string) => void;
};
const ActiveChatContext = createContext<ActiveChatContextValue | null>(null);
function extractChatId(pathname: string): string | null {
const match = pathname.match(/\/chat\/([^/]+)/);
return match ? match[1] : null;
}
export function ActiveChatProvider({ children }: { children: ReactNode }) {
const pathname = usePathname();
const { setDataStream } = useDataStream();
const { mutate } = useSWRConfig();
const chatIdFromUrl = extractChatId(pathname);
const isNewChat = !chatIdFromUrl;
const newChatIdRef = useRef(generateUUID());
const prevPathnameRef = useRef(pathname);
if (isNewChat && prevPathnameRef.current !== pathname) {
newChatIdRef.current = generateUUID();
}
prevPathnameRef.current = pathname;
const chatId = chatIdFromUrl ?? newChatIdRef.current;
const [currentModelId, setCurrentModelId] = useState(DEFAULT_CHAT_MODEL);
const currentModelIdRef = useRef(currentModelId);
useEffect(() => {
currentModelIdRef.current = currentModelId;
}, [currentModelId]);
const [input, setInput] = useState("");
const { data: chatData, isLoading } = useSWR(
isNewChat
? null
: `${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/messages?chatId=${chatId}`,
fetcher,
{ revalidateOnFocus: false }
);
const initialMessages: ChatMessage[] = isNewChat
? []
: (chatData?.messages ?? []);
const visibility: VisibilityType = isNewChat
? "private"
: (chatData?.visibility ?? "private");
const {
messages,
setMessages,
sendMessage,
status,
stop,
regenerate,
addToolApprovalResponse,
} = useChat<ChatMessage>({
id: chatId,
messages: initialMessages,
generateId: generateUUID,
sendAutomaticallyWhen: ({ messages: currentMessages }) => {
const lastMessage = currentMessages.at(-1);
return (
lastMessage?.parts?.some(
(part) =>
"state" in part &&
part.state === "approval-responded" &&
"approval" in part &&
(part.approval as { approved?: boolean })?.approved === true
) ?? false
);
},
transport: new DefaultChatTransport({
api: `${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/chat`,
fetch: fetchWithErrorHandlers,
prepareSendMessagesRequest(request) {
const lastMessage = request.messages.at(-1);
const isToolApprovalContinuation =
lastMessage?.role !== "user" ||
request.messages.some((msg) =>
msg.parts?.some((part) => {
const state = (part as { state?: string }).state;
return (
state === "approval-responded" || state === "output-denied"
);
})
);
return {
body: {
id: request.id,
...(isToolApprovalContinuation
? { messages: request.messages }
: { message: lastMessage }),
selectedChatModel: currentModelIdRef.current,
selectedVisibilityType: visibility,
...request.body,
},
};
},
}),
onData: (dataPart) => {
setDataStream((ds) => (ds ? [...ds, dataPart] : []));
},
onFinish: () => {
mutate(unstable_serialize(getChatHistoryPaginationKey));
},
onError: (error) => {
if (error instanceof ChatbotError) {
toast({ type: "error", description: error.message });
} else {
toast({
type: "error",
description: error.message || "Oops, an error occurred!",
});
}
},
});
const loadedChatIds = useRef(new Set<string>());
if (isNewChat && !loadedChatIds.current.has(newChatIdRef.current)) {
loadedChatIds.current.add(newChatIdRef.current);
}
useEffect(() => {
if (loadedChatIds.current.has(chatId)) {
return;
}
if (chatData?.messages) {
loadedChatIds.current.add(chatId);
setMessages(chatData.messages);
}
}, [chatId, chatData?.messages, setMessages]);
const prevChatIdRef = useRef(chatId);
useEffect(() => {
if (prevChatIdRef.current !== chatId) {
prevChatIdRef.current = chatId;
if (isNewChat) {
setMessages([]);
}
}
}, [chatId, isNewChat, setMessages]);
useEffect(() => {
if (chatData && !isNewChat) {
const cookieModel = document.cookie
.split("; ")
.find((row) => row.startsWith("chat-model="))
?.split("=")[1];
if (cookieModel) {
setCurrentModelId(decodeURIComponent(cookieModel));
}
}
}, [chatData, isNewChat]);
const hasAppendedQueryRef = useRef(false);
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const query = params.get("query");
if (query && !hasAppendedQueryRef.current) {
hasAppendedQueryRef.current = true;
window.history.replaceState(
{},
"",
`${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/chat/${chatId}`
);
sendMessage({
role: "user" as const,
parts: [{ type: "text", text: query }],
});
}
}, [sendMessage, chatId]);
const isReadonly = isNewChat ? false : (chatData?.isReadonly ?? false);
const { data: votes } = useSWR<Vote[]>(
!isReadonly && messages.length >= 2
? `${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/vote?chatId=${chatId}`
: null,
fetcher,
{ revalidateOnFocus: false }
);
const value = useMemo<ActiveChatContextValue>(
() => ({
chatId,
messages,
setMessages,
sendMessage,
status,
stop,
regenerate,
addToolApprovalResponse,
input,
setInput,
visibilityType: visibility,
isReadonly,
isLoading: !isNewChat && isLoading,
votes,
currentModelId,
setCurrentModelId,
}),
[
chatId,
messages,
setMessages,
sendMessage,
status,
stop,
regenerate,
addToolApprovalResponse,
input,
visibility,
isReadonly,
isNewChat,
isLoading,
votes,
currentModelId,
]
);
return (
<ActiveChatContext.Provider value={value}>
{children}
</ActiveChatContext.Provider>
);
}
export function useActiveChat() {
const context = useContext(ActiveChatContext);
if (!context) {
throw new Error("useActiveChat must be used within ActiveChatProvider");
}
return context;
}

View file

@ -0,0 +1,55 @@
"use client";
import { useMemo } from "react";
import useSWR, { useSWRConfig } from "swr";
import { unstable_serialize } from "swr/infinite";
import { updateChatVisibility } from "@/app/(chat)/actions";
import {
type ChatHistory,
getChatHistoryPaginationKey,
} from "@/components/chat/sidebar-history";
import type { VisibilityType } from "@/components/chat/visibility-selector";
export function useChatVisibility({
chatId,
initialVisibilityType,
}: {
chatId: string;
initialVisibilityType: VisibilityType;
}) {
const { mutate, cache } = useSWRConfig();
const history: ChatHistory = cache.get(
`${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/history`
)?.data;
const { data: localVisibility, mutate: setLocalVisibility } = useSWR(
`${chatId}-visibility`,
null,
{
fallbackData: initialVisibilityType,
}
);
const visibilityType = useMemo(() => {
if (!history) {
return localVisibility;
}
const chat = history.chats.find((currentChat) => currentChat.id === chatId);
if (!chat) {
return "private";
}
return chat.visibility;
}, [history, chatId, localVisibility]);
const setVisibilityType = (updatedVisibilityType: VisibilityType) => {
setLocalVisibility(updatedVisibilityType);
mutate(unstable_serialize(getChatHistoryPaginationKey));
updateChatVisibility({
chatId,
visibility: updatedVisibilityType,
});
};
return { visibilityType, setVisibilityType };
}

39
hooks/use-messages.tsx Normal file
View file

@ -0,0 +1,39 @@
import type { UseChatHelpers } from "@ai-sdk/react";
import { useEffect, useState } from "react";
import type { ChatMessage } from "@/lib/types";
import { useScrollToBottom } from "./use-scroll-to-bottom";
export function useMessages({
status,
}: {
status: UseChatHelpers<ChatMessage>["status"];
}) {
const {
containerRef,
endRef,
isAtBottom,
scrollToBottom,
onViewportEnter,
onViewportLeave,
reset,
} = useScrollToBottom();
const [hasSentMessage, setHasSentMessage] = useState(false);
useEffect(() => {
if (status === "submitted") {
setHasSentMessage(true);
}
}, [status]);
return {
containerRef,
endRef,
isAtBottom,
scrollToBottom,
onViewportEnter,
onViewportLeave,
hasSentMessage,
reset,
};
}

19
hooks/use-mobile.ts Normal file
View file

@ -0,0 +1,19 @@
import * as React from "react"
const MOBILE_BREAKPOINT = 768
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
}
mql.addEventListener("change", onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener("change", onChange)
}, [])
return !!isMobile
}

Some files were not shown because too many files have changed in this diff Show more