Compare commits

..

No commits in common. "2becdb4a56e7683ae08aef927cec1c6c52dfad5e" and "06b61bd82905164883ed119c5f5fe59915f5d1ca" have entirely different histories.

178 changed files with 8416 additions and 5547 deletions

View file

@ -1,15 +1,24 @@
# generate a random secret: https://generate-secret.vercel.app/32 or `openssl rand -base64 32`
AUTH_SECRET=****
# Generate a random secret: https://generate-secret.vercel.app/32 or `openssl rand -base64 32`
BETTER_AUTH_SECRET=****
BETTER_AUTH_URL=****
# required for non-vercel deployments, vercel uses OIDC automatically
# The following keys below are automatically created and
# added to your environment when you deploy on Vercel
# Instructions to create an AI Gateway API key here: https://vercel.com/ai-gateway
# API key required for non-Vercel deployments
# For Vercel deployments, OIDC tokens are used automatically
# https://vercel.com/ai-gateway
AI_GATEWAY_API_KEY=****
# https://vercel.com/docs/vercel-blob
# Instructions to create a Vercel Blob Store here: https://vercel.com/docs/vercel-blob
BLOB_READ_WRITE_TOKEN=****
# https://vercel.com/docs/postgres
# Instructions to create a PostgreSQL database here: https://vercel.com/docs/postgres
POSTGRES_URL=****
# Instructions to create a Redis store here:
# https://vercel.com/docs/redis
REDIS_URL=****

View file

@ -13,7 +13,7 @@ jobs:
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10.32.1
version: 9.12.3
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:

23
.gitignore vendored
View file

@ -1,27 +1,46 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
node_modules
.pnp
.pnp.js
# testing
coverage
# next.js
.next/
out/
build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# local env files
.env.local
.env.development.local
.env.test.local
.env.production.local
# turbo
.turbo
.env
.vercel
.env*.local
# Playwright
/test-results/
/playwright-report/
/blob-report/
/playwright/*
next-env.d.ts
tsconfig.tsbuildinfo
next-env.d.ts

View file

@ -1,4 +1,4 @@
<a href="https://chatbot.ai-sdk.dev/demo">
<a href="https://chat.vercel.ai/">
<img alt="Chatbot" src="app/(chat)/opengraph-image.png">
<h1 align="center">Chatbot</h1>
</a>
@ -8,7 +8,7 @@
</p>
<p align="center">
<a href="https://chatbot.ai-sdk.dev/docs"><strong>Read Docs</strong></a> ·
<a href="https://chatbot.dev"><strong>Read Docs</strong></a> ·
<a href="#features"><strong>Features</strong></a> ·
<a href="#model-providers"><strong>Model Providers</strong></a> ·
<a href="#deploy-your-own"><strong>Deploy Your Own</strong></a> ·
@ -36,7 +36,7 @@
## Model Providers
This template uses the [Vercel AI Gateway](https://vercel.com/docs/ai-gateway) to access multiple AI models through a unified interface. Models are configured in `lib/ai/models.ts` with per-model provider routing. Included models: Mistral, Moonshot, DeepSeek, OpenAI, and xAI.
This template uses the [Vercel AI Gateway](https://vercel.com/docs/ai-gateway) to access multiple AI models through a unified interface. The default model is [OpenAI](https://openai.com) GPT-4.1 Mini, with support for Anthropic, Google, and xAI models.
### AI Gateway Authentication

View file

@ -1,10 +1,7 @@
"use server";
import { z } from "zod";
import { createUser, getUser } from "@/lib/db/queries";
import { signIn } from "./auth";
import { auth } from "@/lib/auth";
const authFormSchema = z.object({
email: z.string().email(),
@ -25,10 +22,11 @@ export const login = async (
password: formData.get("password"),
});
await signIn("credentials", {
email: validatedData.email,
password: validatedData.password,
redirect: false,
await auth.api.signInEmail({
body: {
email: validatedData.email,
password: validatedData.password,
},
});
return { status: "success" };
@ -61,24 +59,29 @@ export const register = async (
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,
const result = await auth.api.signUpEmail({
body: {
email: validatedData.email,
password: validatedData.password,
name: validatedData.email,
},
});
if (!result) {
return { status: "failed" };
}
return { status: "success" };
} catch (error) {
if (error instanceof z.ZodError) {
return { status: "invalid_data" };
}
const message = error instanceof Error ? error.message : "";
if (message.includes("already exists") || message.includes("UNIQUE")) {
return { status: "user_exists" };
}
return { status: "failed" };
}
};

View file

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

View file

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

View file

@ -1,14 +0,0 @@
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;

View file

@ -1,99 +0,0 @@
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;
},
},
});

View file

@ -1,43 +0,0 @@
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>
);
}

View file

@ -2,30 +2,36 @@
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 { AuthForm } from "@/components/auth-form";
import { SubmitButton } from "@/components/submit-button";
import { toast } from "@/components/toast";
import { useSession } from "@/lib/client";
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" }
{
status: "idle",
}
);
const { update: updateSession } = useSession();
const { refetch } = useSession();
// biome-ignore lint/correctness/useExhaustiveDependencies: router and updateSession are stable refs
// biome-ignore lint/correctness/useExhaustiveDependencies: router and refetch are stable refs
useEffect(() => {
if (state.status === "failed") {
toast({ type: "error", description: "Invalid credentials!" });
toast({
type: "error",
description: "Invalid credentials!",
});
} else if (state.status === "invalid_data") {
toast({
type: "error",
@ -33,8 +39,8 @@ export default function Page() {
});
} else if (state.status === "success") {
setIsSuccessful(true);
updateSession();
router.refresh();
refetch();
router.push("/");
}
}, [state.status]);
@ -44,23 +50,28 @@ export default function Page() {
};
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>
</>
<div className="flex h-dvh w-screen items-start justify-center bg-background pt-12 md:items-center md:pt-0">
<div className="flex w-full max-w-md flex-col gap-12 overflow-hidden rounded-2xl">
<div className="flex flex-col items-center justify-center gap-2 px-4 text-center sm:px-16">
<h3 className="font-semibold text-xl dark:text-zinc-50">Sign In</h3>
<p className="text-gray-500 text-sm dark:text-zinc-400">
Use your email and password to sign in
</p>
</div>
<AuthForm action={handleSubmit} defaultEmail={email}>
<SubmitButton isSuccessful={isSuccessful}>Sign in</SubmitButton>
<p className="mt-4 text-center text-gray-600 text-sm dark:text-zinc-400">
{"Don't have an account? "}
<Link
className="font-semibold text-gray-800 hover:underline dark:text-zinc-200"
href="/register"
>
Sign up
</Link>
{" for free."}
</p>
</AuthForm>
</div>
</div>
);
}

View file

@ -2,26 +2,29 @@
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 { AuthForm } from "@/components/auth-form";
import { SubmitButton } from "@/components/submit-button";
import { toast } from "@/components/toast";
import { useSession } from "@/lib/client";
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" }
{
status: "idle",
}
);
const { update: updateSession } = useSession();
const { refetch } = useSession();
// biome-ignore lint/correctness/useExhaustiveDependencies: router and updateSession are stable refs
// biome-ignore lint/correctness/useExhaustiveDependencies: router and refetch are stable refs
useEffect(() => {
if (state.status === "user_exists") {
toast({ type: "error", description: "Account already exists!" });
@ -33,10 +36,11 @@ export default function Page() {
description: "Failed validating your submission!",
});
} else if (state.status === "success") {
toast({ type: "success", description: "Account created!" });
toast({ type: "success", description: "Account created successfully!" });
setIsSuccessful(true);
updateSession();
router.refresh();
refetch();
router.push("/");
}
}, [state.status]);
@ -46,21 +50,28 @@ export default function Page() {
};
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>
</>
<div className="flex h-dvh w-screen items-start justify-center bg-background pt-12 md:items-center md:pt-0">
<div className="flex w-full max-w-md flex-col gap-12 overflow-hidden rounded-2xl">
<div className="flex flex-col items-center justify-center gap-2 px-4 text-center sm:px-16">
<h3 className="font-semibold text-xl dark:text-zinc-50">Sign Up</h3>
<p className="text-gray-500 text-sm dark:text-zinc-400">
Create an account with your email and password
</p>
</div>
<AuthForm action={handleSubmit} defaultEmail={email}>
<SubmitButton isSuccessful={isSuccessful}>Sign Up</SubmitButton>
<p className="mt-4 text-center text-gray-600 text-sm dark:text-zinc-400">
{"Already have an account? "}
<Link
className="font-semibold text-gray-800 hover:underline dark:text-zinc-200"
href="/login"
>
Sign in
</Link>
{" instead."}
</p>
</AuthForm>
</div>
</div>
);
}

View file

@ -2,14 +2,11 @@
import { generateText, type UIMessage } from "ai";
import { cookies } from "next/headers";
import { auth } from "@/app/(auth)/auth";
import type { VisibilityType } from "@/components/chat/visibility-selector";
import { titleModel } from "@/lib/ai/models";
import type { VisibilityType } from "@/components/visibility-selector";
import { titlePrompt } from "@/lib/ai/prompts";
import { getTitleModel } from "@/lib/ai/providers";
import {
deleteMessagesByChatIdAfterTimestamp,
getChatById,
getMessageById,
updateChatVisibilityById,
} from "@/lib/db/queries";
@ -29,9 +26,6 @@ export async function generateTitleFromUserMessage({
model: getTitleModel(),
system: titlePrompt,
prompt: getTextFromMessage(message),
providerOptions: {
gateway: { order: titleModel.gatewayOrder },
},
});
return text
.replace(/^[#*"\s]+/, "")
@ -40,20 +34,7 @@ export async function generateTitleFromUserMessage({
}
export async function deleteTrailingMessages({ id }: { id: string }) {
const session = await auth();
if (!session?.user?.id) {
throw new Error("Unauthorized");
}
const [message] = await getMessageById({ id });
if (!message) {
throw new Error("Message not found");
}
const chat = await getChatById({ id: message.chatId });
if (!chat || chat.userId !== session.user.id) {
throw new Error("Unauthorized");
}
await deleteMessagesByChatIdAfterTimestamp({
chatId: message.chatId,
@ -68,15 +49,5 @@ export async function updateChatVisibility({
chatId: string;
visibility: VisibilityType;
}) {
const session = await auth();
if (!session?.user?.id) {
throw new Error("Unauthorized");
}
const chat = await getChatById({ id: chatId });
if (!chat || chat.userId !== session.user.id) {
throw new Error("Unauthorized");
}
await updateChatVisibilityById({ chatId, visibility });
}

View file

@ -0,0 +1,4 @@
import { toNextJsHandler } from "better-auth/next-js";
import { auth } from "@/lib/auth";
export const { GET, POST } = toNextJsHandler(auth);

View file

@ -10,21 +10,15 @@ import {
import { checkBotId } from "botid/server";
import { after } from "next/server";
import { createResumableStreamContext } from "resumable-stream";
import { auth, type UserType } from "@/app/(auth)/auth";
import { entitlementsByUserType } from "@/lib/ai/entitlements";
import {
allowedModelIds,
chatModels,
DEFAULT_CHAT_MODEL,
getCapabilities,
} from "@/lib/ai/models";
import { allowedModelIds } from "@/lib/ai/models";
import { type RequestHints, systemPrompt } from "@/lib/ai/prompts";
import { getLanguageModel } from "@/lib/ai/providers";
import { createDocument } from "@/lib/ai/tools/create-document";
import { editDocument } from "@/lib/ai/tools/edit-document";
import { getWeather } from "@/lib/ai/tools/get-weather";
import { requestSuggestions } from "@/lib/ai/tools/request-suggestions";
import { updateDocument } from "@/lib/ai/tools/update-document";
import { getSession, getUserType, type UserType } from "@/lib/auth";
import { isProductionEnvironment } from "@/lib/constants";
import {
createStreamId,
@ -73,20 +67,20 @@ export async function POST(request: Request) {
const [, session] = await Promise.all([
checkBotId().catch(() => null),
auth(),
getSession(),
]);
if (!session?.user) {
return new ChatbotError("unauthorized:chat").toResponse();
}
const chatModel = allowedModelIds.has(selectedChatModel)
? selectedChatModel
: DEFAULT_CHAT_MODEL;
if (!allowedModelIds.has(selectedChatModel)) {
return new ChatbotError("bad_request:api").toResponse();
}
await checkIpRateLimit(ipAddress(request));
const userType: UserType = session.user.type;
const userType: UserType = getUserType(session.user);
const messageCount = await getMessageCountByUserId({
id: session.user.id,
@ -107,7 +101,9 @@ export async function POST(request: Request) {
if (chat.userId !== session.user.id) {
return new ChatbotError("forbidden:chat").toResponse();
}
messagesFromDb = await getMessagesByChatId({ id });
if (!isToolApprovalFlow) {
messagesFromDb = await getMessagesByChatId({ id });
}
} else if (message?.role === "user") {
await saveChat({
id,
@ -118,43 +114,9 @@ export async function POST(request: Request) {
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 uiMessages = isToolApprovalFlow
? (messages as ChatMessage[])
: [...convertToUIMessages(messagesFromDb), message as ChatMessage];
const { longitude, latitude, city, country } = geolocation(request);
@ -180,11 +142,10 @@ export async function POST(request: Request) {
});
}
const modelConfig = chatModels.find((m) => m.id === chatModel);
const modelCapabilities = await getCapabilities();
const capabilities = modelCapabilities[chatModel];
const isReasoningModel = capabilities?.reasoning === true;
const supportsTools = capabilities?.tools === true;
const isReasoningModel =
selectedChatModel.endsWith("-thinking") ||
(selectedChatModel.includes("reasoning") &&
!selectedChatModel.includes("non-reasoning"));
const modelMessages = await convertToModelMessages(uiMessages);
@ -192,46 +153,30 @@ export async function POST(request: Request) {
originalMessages: isToolApprovalFlow ? uiMessages : undefined,
execute: async ({ writer: dataStream }) => {
const result = streamText({
model: getLanguageModel(chatModel),
system: systemPrompt({ requestHints, supportsTools }),
model: getLanguageModel(selectedChatModel),
system: systemPrompt({ selectedChatModel, requestHints }),
messages: modelMessages,
stopWhen: stepCountIs(5),
experimental_activeTools:
isReasoningModel && !supportsTools
? []
: [
"getWeather",
"createDocument",
"editDocument",
"updateDocument",
"requestSuggestions",
],
providerOptions: {
...(modelConfig?.gatewayOrder && {
gateway: { order: modelConfig.gatewayOrder },
}),
...(modelConfig?.reasoningEffort && {
openai: { reasoningEffort: modelConfig.reasoningEffort },
}),
},
experimental_activeTools: isReasoningModel
? []
: [
"getWeather",
"createDocument",
"updateDocument",
"requestSuggestions",
],
providerOptions: isReasoningModel
? {
anthropic: {
thinking: { type: "enabled", budgetTokens: 10_000 },
},
}
: undefined,
tools: {
getWeather,
createDocument: createDocument({
session,
dataStream,
modelId: chatModel,
}),
editDocument: editDocument({ dataStream, session }),
updateDocument: updateDocument({
session,
dataStream,
modelId: chatModel,
}),
requestSuggestions: requestSuggestions({
session,
dataStream,
modelId: chatModel,
}),
createDocument: createDocument({ session, dataStream }),
updateDocument: updateDocument({ session, dataStream }),
requestSuggestions: requestSuggestions({ session, dataStream }),
},
experimental_telemetry: {
isEnabled: isProductionEnvironment,
@ -244,13 +189,9 @@ export async function POST(request: Request) {
);
if (titlePromise) {
try {
const title = await titlePromise;
dataStream.write({ type: "data-chat-title", data: title });
updateChatTitleById({ chatId: id, title });
} catch (_) {
/* non-fatal */
}
const title = await titlePromise;
dataStream.write({ type: "data-chat-title", data: title });
updateChatTitleById({ chatId: id, title });
}
},
generateId: generateUUID,
@ -321,7 +262,7 @@ export async function POST(request: Request) {
);
}
} catch (_) {
/* non-critical */
// ignore redis errors
}
},
});
@ -354,7 +295,7 @@ export async function DELETE(request: Request) {
return new ChatbotError("bad_request:api").toResponse();
}
const session = await auth();
const session = await getSession();
if (!session?.user) {
return new ChatbotError("unauthorized:chat").toResponse();

View file

@ -20,16 +20,18 @@ const userMessageSchema = z.object({
parts: z.array(partSchema),
});
const toolApprovalMessageSchema = z.object({
// For tool approval flows, we accept all messages (more permissive schema)
const messageSchema = z.object({
id: z.string(),
role: z.enum(["user", "assistant"]),
parts: z.array(z.record(z.unknown())),
role: z.string(),
parts: z.array(z.any()),
});
export const postRequestBodySchema = z.object({
id: z.string().uuid(),
// Either a single new message or all messages (for tool approvals)
message: userMessageSchema.optional(),
messages: z.array(toolApprovalMessageSchema).optional(),
messages: z.array(messageSchema).optional(),
selectedChatModel: z.string(),
selectedVisibilityType: z.enum(["public", "private"]),
});

View file

@ -1,21 +1,12 @@
import { z } from "zod";
import { auth } from "@/app/(auth)/auth";
import type { ArtifactKind } from "@/components/chat/artifact";
import type { ArtifactKind } from "@/components/artifact";
import { getSession } from "@/lib/auth";
import {
deleteDocumentsByIdAfterTimestamp,
getDocumentsById,
saveDocument,
updateDocumentContent,
} from "@/lib/db/queries";
import { ChatbotError } from "@/lib/errors";
const documentSchema = z.object({
content: z.string(),
title: z.string(),
kind: z.enum(["text", "code", "image", "sheet"]),
isManualEdit: z.boolean().optional(),
});
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const id = searchParams.get("id");
@ -27,7 +18,7 @@ export async function GET(request: Request) {
).toResponse();
}
const session = await auth();
const session = await getSession();
if (!session?.user) {
return new ChatbotError("unauthorized:document").toResponse();
@ -59,29 +50,18 @@ export async function POST(request: Request) {
).toResponse();
}
const session = await auth();
const session = await getSession();
if (!session?.user) {
return new ChatbotError("not_found:document").toResponse();
}
let content: string;
let title: string;
let kind: ArtifactKind;
let isManualEdit: boolean | undefined;
try {
const parsed = documentSchema.parse(await request.json());
content = parsed.content;
title = parsed.title;
kind = parsed.kind;
isManualEdit = parsed.isManualEdit;
} catch {
return new ChatbotError(
"bad_request:api",
"Invalid request body."
).toResponse();
}
const {
content,
title,
kind,
}: { content: string; title: string; kind: ArtifactKind } =
await request.json();
const documents = await getDocumentsById({ id });
@ -93,11 +73,6 @@ export async function POST(request: Request) {
}
}
if (isManualEdit && documents.length > 0) {
const result = await updateDocumentContent({ id, content });
return Response.json(result, { status: 200 });
}
const document = await saveDocument({
id,
content,
@ -128,7 +103,7 @@ export async function DELETE(request: Request) {
).toResponse();
}
const session = await auth();
const session = await getSession();
if (!session?.user) {
return new ChatbotError("unauthorized:document").toResponse();
@ -142,18 +117,9 @@ export async function DELETE(request: Request) {
return new ChatbotError("forbidden:document").toResponse();
}
const parsedTimestamp = new Date(timestamp);
if (Number.isNaN(parsedTimestamp.getTime())) {
return new ChatbotError(
"bad_request:api",
"Invalid timestamp."
).toResponse();
}
const documentsDeleted = await deleteDocumentsByIdAfterTimestamp({
id,
timestamp: parsedTimestamp,
timestamp: new Date(timestamp),
});
return Response.json(documentsDeleted, { status: 200 });

View file

@ -2,21 +2,23 @@ import { put } from "@vercel/blob";
import { NextResponse } from "next/server";
import { z } from "zod";
import { auth } from "@/app/(auth)/auth";
import { getSession } from "@/lib/auth";
// Use Blob instead of File since File is not available in Node.js environment
const FileSchema = z.object({
file: z
.instanceof(Blob)
.refine((file) => file.size <= 5 * 1024 * 1024, {
message: "File size should be less than 5MB",
})
// Update the file type based on the kind of files you want to accept
.refine((file) => ["image/jpeg", "image/png"].includes(file.type), {
message: "File type should be JPEG or PNG",
}),
});
export async function POST(request: Request) {
const session = await auth();
const session = await getSession();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
@ -44,12 +46,12 @@ export async function POST(request: Request) {
return NextResponse.json({ error: errorMessage }, { status: 400 });
}
// Get filename from formData since Blob doesn't have name property
const filename = (formData.get("file") as File).name;
const safeName = filename.replace(/[^a-zA-Z0-9._-]/g, "_");
const fileBuffer = await file.arrayBuffer();
try {
const data = await put(`${safeName}`, fileBuffer, {
const data = await put(`${filename}`, fileBuffer, {
access: "public",
});

View file

@ -1,15 +1,12 @@
import type { NextRequest } from "next/server";
import { auth } from "@/app/(auth)/auth";
import { getSession } from "@/lib/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 limit = Number.parseInt(searchParams.get("limit") || "10", 10);
const startingAfter = searchParams.get("starting_after");
const endingBefore = searchParams.get("ending_before");
@ -20,7 +17,7 @@ export async function GET(request: NextRequest) {
).toResponse();
}
const session = await auth();
const session = await getSession();
if (!session?.user) {
return new ChatbotError("unauthorized:chat").toResponse();
@ -37,7 +34,7 @@ export async function GET(request: NextRequest) {
}
export async function DELETE() {
const session = await auth();
const session = await getSession();
if (!session?.user) {
return new ChatbotError("unauthorized:chat").toResponse();

View file

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

View file

@ -1,20 +0,0 @@
import { getAllGatewayModels, getCapabilities, isDemo } from "@/lib/ai/models";
export async function GET() {
const headers = {
"Cache-Control": "public, max-age=86400, s-maxage=86400",
};
const curatedCapabilities = await getCapabilities();
if (isDemo) {
const models = await getAllGatewayModels();
const capabilities = Object.fromEntries(
models.map((m) => [m.id, curatedCapabilities[m.id] ?? m.capabilities])
);
return Response.json({ capabilities, models }, { headers });
}
return Response.json(curatedCapabilities, { headers });
}

View file

@ -1,4 +1,4 @@
import { auth } from "@/app/(auth)/auth";
import { getSession } from "@/lib/auth";
import { getSuggestionsByDocumentId } from "@/lib/db/queries";
import { ChatbotError } from "@/lib/errors";
@ -13,7 +13,7 @@ export async function GET(request: Request) {
).toResponse();
}
const session = await auth();
const session = await getSession();
if (!session?.user) {
return new ChatbotError("unauthorized:suggestions").toResponse();

View file

@ -1,14 +1,7 @@
import { z } from "zod";
import { auth } from "@/app/(auth)/auth";
import { getSession } from "@/lib/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");
@ -20,7 +13,7 @@ export async function GET(request: Request) {
).toResponse();
}
const session = await auth();
const session = await getSession();
if (!session?.user) {
return new ChatbotError("unauthorized:vote").toResponse();
@ -42,23 +35,21 @@ export async function GET(request: Request) {
}
export async function PATCH(request: Request) {
let chatId: string;
let messageId: string;
let type: "up" | "down";
const {
chatId,
messageId,
type,
}: { chatId: string; messageId: string; type: "up" | "down" } =
await request.json();
try {
const parsed = voteSchema.parse(await request.json());
chatId = parsed.chatId;
messageId = parsed.messageId;
type = parsed.type;
} catch {
if (!chatId || !messageId || !type) {
return new ChatbotError(
"bad_request:api",
"Parameters chatId, messageId, and type are required."
).toResponse();
}
const session = await auth();
const session = await getSession();
if (!session?.user) {
return new ChatbotError("unauthorized:vote").toResponse();

View file

@ -1,3 +1,81 @@
export default function Page() {
return null;
import { cookies } from "next/headers";
import { notFound, redirect } from "next/navigation";
import { Suspense } from "react";
import { Chat } from "@/components/chat";
import { DataStreamHandler } from "@/components/data-stream-handler";
import { DEFAULT_CHAT_MODEL } from "@/lib/ai/models";
import { getSession } from "@/lib/auth";
import { getChatById, getMessagesByChatId } from "@/lib/db/queries";
import { convertToUIMessages } from "@/lib/utils";
export default function Page(props: { params: Promise<{ id: string }> }) {
return (
<Suspense fallback={<div className="flex h-dvh" />}>
<ChatPage params={props.params} />
</Suspense>
);
}
async function ChatPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const chat = await getChatById({ id });
if (!chat) {
redirect("/");
}
const session = await getSession();
if (!session) {
redirect("/login");
}
if (chat.visibility === "private") {
if (!session.user) {
return notFound();
}
if (session.user.id !== chat.userId) {
return notFound();
}
}
const messagesFromDb = await getMessagesByChatId({
id,
});
const uiMessages = convertToUIMessages(messagesFromDb);
const cookieStore = await cookies();
const chatModelFromCookie = cookieStore.get("chat-model");
if (!chatModelFromCookie) {
return (
<>
<Chat
autoResume={true}
id={chat.id}
initialChatModel={DEFAULT_CHAT_MODEL}
initialMessages={uiMessages}
initialVisibilityType={chat.visibility}
isReadonly={session?.user?.id !== chat.userId}
/>
<DataStreamHandler />
</>
);
}
return (
<>
<Chat
autoResume={true}
id={chat.id}
initialChatModel={chatModelFromCookie.value}
initialMessages={uiMessages}
initialVisibilityType={chat.visibility}
isReadonly={session?.user?.id !== chat.userId}
/>
<DataStreamHandler />
</>
);
}

View file

@ -1,53 +1,35 @@
import { cookies } from "next/headers";
import Script from "next/script";
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 { AppSidebar } from "@/components/app-sidebar";
import { DataStreamProvider } from "@/components/data-stream-provider";
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
import { ActiveChatProvider } from "@/hooks/use-active-chat";
import { auth } from "../(auth)/auth";
import { getSession } from "@/lib/auth";
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<>
<Script
src="https://cdn.jsdelivr.net/pyodide/v0.23.4/full/pyodide.js"
strategy="lazyOnload"
strategy="beforeInteractive"
/>
<DataStreamProvider>
<Suspense fallback={<div className="flex h-dvh bg-sidebar" />}>
<SidebarShell>{children}</SidebarShell>
<Suspense fallback={<div className="flex h-dvh" />}>
<SidebarWrapper>{children}</SidebarWrapper>
</Suspense>
</DataStreamProvider>
</>
);
}
async function SidebarShell({ children }: { children: React.ReactNode }) {
const [session, cookieStore] = await Promise.all([auth(), cookies()]);
async function SidebarWrapper({ children }: { children: React.ReactNode }) {
const [session, cookieStore] = await Promise.all([getSession(), 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>
<SidebarInset>{children}</SidebarInset>
</SidebarProvider>
);
}

View file

@ -1,3 +1,52 @@
import { cookies } from "next/headers";
import { Suspense } from "react";
import { Chat } from "@/components/chat";
import { DataStreamHandler } from "@/components/data-stream-handler";
import { DEFAULT_CHAT_MODEL } from "@/lib/ai/models";
import { generateUUID } from "@/lib/utils";
export default function Page() {
return null;
return (
<Suspense fallback={<div className="flex h-dvh" />}>
<NewChatPage />
</Suspense>
);
}
async function NewChatPage() {
const cookieStore = await cookies();
const modelIdFromCookie = cookieStore.get("chat-model");
const id = generateUUID();
if (!modelIdFromCookie) {
return (
<>
<Chat
autoResume={false}
id={id}
initialChatModel={DEFAULT_CHAT_MODEL}
initialMessages={[]}
initialVisibilityType="private"
isReadonly={false}
key={id}
/>
<DataStreamHandler />
</>
);
}
return (
<>
<Chat
autoResume={false}
id={id}
initialChatModel={modelIdFromCookie.value}
initialMessages={[]}
initialVisibilityType="private"
isReadonly={false}
key={id}
/>
<DataStreamHandler />
</>
);
}

View file

@ -1,42 +1,138 @@
@import "tailwindcss";
@import "katex/dist/katex.min.css";
/* include utility classes in streamdown */
@source "../node_modules/streamdown/dist/index.js";
/* custom variant for setting dark mode programmatically */
@custom-variant dark (&:is(.dark, .dark *));
/* include plugins */
@plugin "tailwindcss-animate";
@plugin "@tailwindcss/typography";
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
/* define design tokens (light mode) */
:root {
--background: hsl(0 0% 100%);
--foreground: hsl(240 10% 3.9%);
--card: hsl(0 0% 100%);
--card-foreground: hsl(240 10% 3.9%);
--popover: hsl(0 0% 100%);
--popover-foreground: hsl(240 10% 3.9%);
--primary: hsl(240 5.9% 10%);
--primary-foreground: hsl(0 0% 98%);
--secondary: hsl(240 4.8% 95.9%);
--secondary-foreground: hsl(240 5.9% 10%);
--muted: hsl(240 4.8% 95.9%);
--muted-foreground: hsl(240 3.8% 46.1%);
--accent: hsl(240 4.8% 95.9%);
--accent-foreground: hsl(240 5.9% 10%);
--destructive: hsl(0 84.2% 60.2%);
--destructive-foreground: hsl(0 0% 98%);
--border: hsl(240 5.9% 90%);
--input: hsl(240 5.9% 90%);
--ring: hsl(240 10% 3.9%);
--chart-1: hsl(12 76% 61%);
--chart-2: hsl(173 58% 39%);
--chart-3: hsl(197 37% 24%);
--chart-4: hsl(43 74% 66%);
--chart-5: hsl(27 87% 67%);
--sidebar-background: hsl(0 0% 98%);
--sidebar-foreground: hsl(240 5.3% 26.1%);
--sidebar-primary: hsl(240 5.9% 10%);
--sidebar-primary-foreground: hsl(0 0% 98%);
--sidebar-accent: hsl(240 4.8% 95.9%);
--sidebar-accent-foreground: hsl(240 5.9% 10%);
--sidebar-border: hsl(220 13% 91%);
--sidebar-ring: hsl(217.2 91.2% 59.8%);
/* border radius unit */
--radius: 0.5rem;
--sidebar: hsl(0 0% 98%);
}
/* define design tokens (dark mode) */
.dark {
--background: hsl(240 10% 3.9%);
--foreground: hsl(0 0% 98%);
--card: hsl(240 10% 3.9%);
--card-foreground: hsl(0 0% 98%);
--popover: hsl(240 10% 3.9%);
--popover-foreground: hsl(0 0% 98%);
--primary: hsl(0 0% 98%);
--primary-foreground: hsl(240 5.9% 10%);
--secondary: hsl(240 3.7% 15.9%);
--secondary-foreground: hsl(0 0% 98%);
--muted: hsl(240 3.7% 15.9%);
--muted-foreground: hsl(240 5% 64.9%);
--accent: hsl(240 3.7% 15.9%);
--accent-foreground: hsl(0 0% 98%);
--destructive: hsl(0 62.8% 30.6%);
--destructive-foreground: hsl(0 0% 98%);
--border: hsl(240 3.7% 15.9%);
--input: hsl(240 3.7% 15.9%);
--ring: hsl(240 4.9% 83.9%);
--chart-1: hsl(220 70% 50%);
--chart-2: hsl(160 60% 45%);
--chart-3: hsl(30 80% 55%);
--chart-4: hsl(280 65% 60%);
--chart-5: hsl(340 75% 55%);
--sidebar-background: hsl(240 5.9% 10%);
--sidebar-foreground: hsl(240 4.8% 95.9%);
--sidebar-primary: hsl(224.3 76.3% 48%);
--sidebar-primary-foreground: hsl(0 0% 100%);
--sidebar-accent: hsl(240 3.7% 15.9%);
--sidebar-accent-foreground: hsl(240 4.8% 95.9%);
--sidebar-border: hsl(240 3.7% 15.9%);
--sidebar-ring: hsl(217.2 91.2% 59.8%);
--sidebar: hsl(240 5.9% 10%);
}
/* define theme */
@theme {
--font-sans: var(--font-geist);
--font-mono: var(--font-geist-mono);
--breakpoint-toast-mobile: 600px;
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-sm: 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-destructive-foreground: var(--destructive-foreground);
--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: var(--sidebar-background);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
@ -46,156 +142,24 @@
--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;
}
}
/*
The default border color has changed to `currentcolor` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3.
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--border);
border-color: var(--color-gray-200, currentcolor);
}
}
@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;
}
@ -212,14 +176,6 @@ textarea:focus-visible {
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;
@ -236,6 +192,22 @@ textarea:focus-visible {
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
overflow-x: hidden;
position: relative;
}
html {
overflow-x: hidden;
}
}
.skeleton {
* {
pointer-events: none !important;
@ -255,246 +227,92 @@ textarea:focus-visible {
}
}
@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-editor,
.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!;
@apply bg-background! dark:bg-zinc-800! outline-hidden! selection:bg-zinc-900!;
}
.ͼ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!;
@apply bg-zinc-200! dark:bg-zinc-900!;
}
.cm-activeLine,
.cm-activeLineGutter {
@apply bg-transparent!;
}
.cm-activeLineGutter .cm-gutterElement {
color: var(--foreground) !important;
opacity: 0.7;
.cm-activeLine {
@apply rounded-r-sm!;
}
.cm-gutter.cm-lineNumbers .cm-gutterElement {
padding-right: 12px !important;
.cm-lineNumbers {
@apply min-w-7;
}
.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;
.cm-lineNumbers .cm-activeLineGutter {
@apply rounded-l-sm!;
}
.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;
@apply bg-blue-200 hover:bg-blue-300 dark:hover:bg-blue-400/50 dark:text-blue-50 dark:bg-blue-500/40;
}
/* minimal scrollbar styling */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 3px;
transition: background 0.2s ease;
}
::-webkit-scrollbar-thumb:hover {
background: --alpha(var(--muted-foreground) / 0.5);
}
::-webkit-scrollbar-corner {
background: transparent;
}
/* firefox scrollbar styling */
* {
scrollbar-width: thin;
scrollbar-color: var(--border) transparent;
}
@theme inline {
--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);
}
@layer base {
* {
scrollbar-width: thin;
scrollbar-color: oklch(0 0 0 / 0.12) transparent;
@apply border-border outline-ring/50;
}
.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;
body {
@apply bg-background text-foreground;
}
}
[data-testid="artifact"] {
isolation: isolate;
}

View file

@ -1,10 +1,11 @@
import { Analytics } from "@vercel/analytics/next";
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { Toaster } from "sonner";
import { ThemeProvider } from "@/components/theme-provider";
import { TooltipProvider } from "@/components/ui/tooltip";
import "katex/dist/katex.min.css";
import "./globals.css";
import { SessionProvider } from "next-auth/react";
export const metadata: Metadata = {
metadataBase: new URL("https://chat.vercel.ai"),
@ -13,7 +14,7 @@ export const metadata: Metadata = {
};
export const viewport = {
maximumScale: 1,
maximumScale: 1, // Disable auto-zoom on mobile Safari
};
const geist = Geist({
@ -56,6 +57,10 @@ export default function RootLayout({
return (
<html
className={`${geist.variable} ${geistMono.variable}`}
// `next-themes` injects an extra classname to the body element to avoid
// visual flicker before hydration. Hence the `suppressHydrationWarning`
// prop is necessary to avoid the React hydration mismatch warning.
// https://github.com/pacocoursey/next-themes?tab=readme-ov-file#with-app
lang="en"
suppressHydrationWarning
>
@ -74,12 +79,10 @@ export default function RootLayout({
disableTransitionOnChange
enableSystem
>
<SessionProvider
basePath={`${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/auth`}
>
<TooltipProvider>{children}</TooltipProvider>
</SessionProvider>
<Toaster position="top-center" />
{children}
</ThemeProvider>
<Analytics />
</body>
</html>
);

View file

@ -1,11 +1,11 @@
import { toast } from "sonner";
import { CodeEditor } from "@/components/chat/code-editor";
import { CodeEditor } from "@/components/code-editor";
import {
Console,
type ConsoleOutput,
type ConsoleOutputContent,
} from "@/components/chat/console";
import { Artifact } from "@/components/chat/create-artifact";
} from "@/components/console";
import { Artifact } from "@/components/create-artifact";
import {
CopyIcon,
LogsIcon,
@ -13,7 +13,7 @@ import {
PlayIcon,
RedoIcon,
UndoIcon,
} from "@/components/chat/icons";
} from "@/components/icons";
import { generateUUID } from "@/lib/utils";
const OUTPUT_HANDLERS = {
@ -93,7 +93,7 @@ export const codeArtifact = new Artifact<"code", Metadata>({
content: ({ metadata, setMetadata, ...props }) => {
return (
<>
<div className="relative min-h-[200px]">
<div className="px-1">
<CodeEditor {...props} />
</div>
@ -193,20 +193,14 @@ export const codeArtifact = new Artifact<"code", Metadata>({
},
],
}));
} catch (error: unknown) {
} catch (error: any) {
setMetadata((metadata) => ({
...metadata,
outputs: [
...metadata.outputs.filter((output) => output.id !== runId),
{
id: runId,
contents: [
{
type: "text",
value:
error instanceof Error ? error.message : String(error),
},
],
contents: [{ type: "text", value: error.message }],
status: "failed",
},
],

View file

@ -1,59 +1,75 @@
import { streamText } from "ai";
import { streamObject } from "ai";
import { z } from "zod";
import { codePrompt, updateDocumentPrompt } from "@/lib/ai/prompts";
import { getLanguageModel } from "@/lib/ai/providers";
import { getArtifactModel } from "@/lib/ai/providers";
import { createDocumentHandler } from "@/lib/artifacts/server";
function stripFences(code: string): string {
return code
.replace(/^```[\w]*\n?/, "")
.replace(/\n?```\s*$/, "")
.trim();
}
export const codeDocumentHandler = createDocumentHandler<"code">({
kind: "code",
onCreateDocument: async ({ title, dataStream, modelId }) => {
onCreateDocument: async ({ title, dataStream }) => {
let draftContent = "";
const { fullStream } = streamText({
model: getLanguageModel(modelId),
system: `${codePrompt}\n\nOutput ONLY the code. No explanations, no markdown fences, no wrapping.`,
const { fullStream } = streamObject({
model: getArtifactModel(),
system: codePrompt,
prompt: title,
schema: z.object({
code: z.string(),
}),
});
for await (const delta of fullStream) {
if (delta.type === "text-delta") {
draftContent += delta.text;
dataStream.write({
type: "data-codeDelta",
data: stripFences(draftContent),
transient: true,
});
const { type } = delta;
if (type === "object") {
const { object } = delta;
const { code } = object;
if (code) {
dataStream.write({
type: "data-codeDelta",
data: code ?? "",
transient: true,
});
draftContent = code;
}
}
}
return stripFences(draftContent);
return draftContent;
},
onUpdateDocument: async ({ document, description, dataStream, modelId }) => {
onUpdateDocument: async ({ document, description, dataStream }) => {
let draftContent = "";
const { fullStream } = streamText({
model: getLanguageModel(modelId),
system: `${updateDocumentPrompt(document.content, "code")}\n\nOutput ONLY the complete updated code. No explanations, no markdown fences, no wrapping.`,
const { fullStream } = streamObject({
model: getArtifactModel(),
system: updateDocumentPrompt(document.content, "code"),
prompt: description,
schema: z.object({
code: z.string(),
}),
});
for await (const delta of fullStream) {
if (delta.type === "text-delta") {
draftContent += delta.text;
dataStream.write({
type: "data-codeDelta",
data: stripFences(draftContent),
transient: true,
});
const { type } = delta;
if (type === "object") {
const { object } = delta;
const { code } = object;
if (code) {
dataStream.write({
type: "data-codeDelta",
data: code ?? "",
transient: true,
});
draftContent = code;
}
}
}
return stripFences(draftContent);
return draftContent;
},
});

View file

@ -1,7 +1,7 @@
import { toast } from "sonner";
import { Artifact } from "@/components/chat/create-artifact";
import { CopyIcon, RedoIcon, UndoIcon } from "@/components/chat/icons";
import { ImageEditor } from "@/components/chat/image-editor";
import { Artifact } from "@/components/create-artifact";
import { CopyIcon, RedoIcon, UndoIcon } from "@/components/icons";
import { ImageEditor } from "@/components/image-editor";
export const imageArtifact = new Artifact({
kind: "image",

View file

@ -1,16 +1,16 @@
import { parse, unparse } from "papaparse";
import { toast } from "sonner";
import { Artifact } from "@/components/chat/create-artifact";
import { Artifact } from "@/components/create-artifact";
import {
CopyIcon,
LineChartIcon,
RedoIcon,
SparklesIcon,
UndoIcon,
} from "@/components/chat/icons";
import { SpreadsheetEditor } from "@/components/chat/sheet-editor";
} from "@/components/icons";
import { SpreadsheetEditor } from "@/components/sheet-editor";
type Metadata = Record<string, never>;
type Metadata = any;
export const sheetArtifact = new Artifact<"sheet", Metadata>({
kind: "sheet",

View file

@ -1,49 +1,78 @@
import { streamText } from "ai";
import { streamObject } from "ai";
import { z } from "zod";
import { sheetPrompt, updateDocumentPrompt } from "@/lib/ai/prompts";
import { getLanguageModel } from "@/lib/ai/providers";
import { getArtifactModel } from "@/lib/ai/providers";
import { createDocumentHandler } from "@/lib/artifacts/server";
export const sheetDocumentHandler = createDocumentHandler<"sheet">({
kind: "sheet",
onCreateDocument: async ({ title, dataStream, modelId }) => {
onCreateDocument: async ({ title, dataStream }) => {
let draftContent = "";
const { fullStream } = streamText({
model: getLanguageModel(modelId),
system: `${sheetPrompt}\n\nOutput ONLY the raw CSV data. No explanations, no markdown fences.`,
const { fullStream } = streamObject({
model: getArtifactModel(),
system: sheetPrompt,
prompt: title,
schema: z.object({
csv: z.string().describe("CSV data"),
}),
});
for await (const delta of fullStream) {
if (delta.type === "text-delta") {
draftContent += delta.text;
dataStream.write({
type: "data-sheetDelta",
data: draftContent,
transient: true,
});
const { type } = delta;
if (type === "object") {
const { object } = delta;
const { csv } = object;
if (csv) {
dataStream.write({
type: "data-sheetDelta",
data: csv,
transient: true,
});
draftContent = csv;
}
}
}
dataStream.write({
type: "data-sheetDelta",
data: draftContent,
transient: true,
});
return draftContent;
},
onUpdateDocument: async ({ document, description, dataStream, modelId }) => {
onUpdateDocument: async ({ document, description, dataStream }) => {
let draftContent = "";
const { fullStream } = streamText({
model: getLanguageModel(modelId),
system: `${updateDocumentPrompt(document.content, "sheet")}\n\nOutput ONLY the raw CSV data. No explanations, no markdown fences.`,
const { fullStream } = streamObject({
model: getArtifactModel(),
system: updateDocumentPrompt(document.content, "sheet"),
prompt: description,
schema: z.object({
csv: z.string(),
}),
});
for await (const delta of fullStream) {
if (delta.type === "text-delta") {
draftContent += delta.text;
dataStream.write({
type: "data-sheetDelta",
data: draftContent,
transient: true,
});
const { type } = delta;
if (type === "object") {
const { object } = delta;
const { csv } = object;
if (csv) {
dataStream.write({
type: "data-sheetDelta",
data: csv,
transient: true,
});
draftContent = csv;
}
}
}

View file

@ -1,7 +1,7 @@
import { toast } from "sonner";
import { Artifact } from "@/components/chat/create-artifact";
import { DiffView } from "@/components/chat/diffview";
import { DocumentSkeleton } from "@/components/chat/document-skeleton";
import { Artifact } from "@/components/create-artifact";
import { DiffView } from "@/components/diffview";
import { DocumentSkeleton } from "@/components/document-skeleton";
import {
ClockRewind,
CopyIcon,
@ -9,8 +9,8 @@ import {
PenIcon,
RedoIcon,
UndoIcon,
} from "@/components/chat/icons";
import { Editor } from "@/components/chat/text-editor";
} from "@/components/icons";
import { Editor } from "@/components/text-editor";
import type { Suggestion } from "@/lib/db/schema";
import { getSuggestions } from "../actions";
@ -69,28 +69,21 @@ export const textArtifact = new Artifact<"text", TextArtifactMetadata>({
}
if (mode === "diff") {
const selectedContent = getDocumentContentById(currentVersionIndex);
const prevContent =
currentVersionIndex > 0
? getDocumentContentById(currentVersionIndex - 1)
: selectedContent;
const oldContent = getDocumentContentById(currentVersionIndex - 1);
const newContent = getDocumentContentById(currentVersionIndex);
return (
<div className="flex flex-row px-4 py-8 md:px-16 md:py-12 lg:px-20">
<DiffView newContent={selectedContent} oldContent={prevContent} />
</div>
);
return <DiffView newContent={newContent} oldContent={oldContent} />;
}
return (
<div className="flex flex-row px-4 py-8 md:px-16 md:py-12 lg:px-20">
<div className="flex flex-row px-4 py-8 md:p-20">
<Editor
content={content}
currentVersionIndex={currentVersionIndex}
isCurrentVersion={isCurrentVersion}
onSaveContent={onSaveContent}
status={status}
suggestions={isCurrentVersion && metadata ? metadata.suggestions : []}
suggestions={metadata ? metadata.suggestions : []}
/>
{metadata?.suggestions && metadata.suggestions.length > 0 ? (

View file

@ -1,15 +1,15 @@
import { smoothStream, streamText } from "ai";
import { updateDocumentPrompt } from "@/lib/ai/prompts";
import { getLanguageModel } from "@/lib/ai/providers";
import { getArtifactModel } from "@/lib/ai/providers";
import { createDocumentHandler } from "@/lib/artifacts/server";
export const textDocumentHandler = createDocumentHandler<"text">({
kind: "text",
onCreateDocument: async ({ title, dataStream, modelId }) => {
onCreateDocument: async ({ title, dataStream }) => {
let draftContent = "";
const { fullStream } = streamText({
model: getLanguageModel(modelId),
model: getArtifactModel(),
system:
"Write about the given topic. Markdown is supported. Use headings wherever appropriate.",
experimental_transform: smoothStream({ chunking: "word" }),
@ -17,11 +17,16 @@ export const textDocumentHandler = createDocumentHandler<"text">({
});
for await (const delta of fullStream) {
if (delta.type === "text-delta") {
draftContent += delta.text;
const { type } = delta;
if (type === "text-delta") {
const { text } = delta;
draftContent += text;
dataStream.write({
type: "data-textDelta",
data: delta.text,
data: text,
transient: true,
});
}
@ -29,22 +34,35 @@ export const textDocumentHandler = createDocumentHandler<"text">({
return draftContent;
},
onUpdateDocument: async ({ document, description, dataStream, modelId }) => {
onUpdateDocument: async ({ document, description, dataStream }) => {
let draftContent = "";
const { fullStream } = streamText({
model: getLanguageModel(modelId),
model: getArtifactModel(),
system: updateDocumentPrompt(document.content, "text"),
experimental_transform: smoothStream({ chunking: "word" }),
prompt: description,
providerOptions: {
openai: {
prediction: {
type: "content",
content: document.content,
},
},
},
});
for await (const delta of fullStream) {
if (delta.type === "text-delta") {
draftContent += delta.text;
const { type } = delta;
if (type === "text-delta") {
const { text } = delta;
draftContent += text;
dataStream.write({
type: "data-textDelta",
data: delta.text,
data: text,
transient: true,
});
}

View file

@ -1,12 +1,12 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "radix-maia",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "app/globals.css",
"baseColor": "neutral",
"baseColor": "zinc",
"cssVariables": true,
"prefix": ""
},

View file

@ -55,7 +55,9 @@ export const MessageContent = ({
}: MessageContentProps) => (
<div
className={cn(
"flex min-w-0 max-w-full flex-col gap-2 overflow-hidden text-sm text-foreground",
"is-user:dark flex w-fit min-w-0 max-w-full flex-col gap-2 overflow-hidden text-sm",
"group-[.is-user]:ml-auto group-[.is-user]:rounded-lg group-[.is-user]:bg-secondary group-[.is-user]:px-4 group-[.is-user]:py-3 group-[.is-user]:text-foreground",
"group-[.is-assistant]:text-foreground",
className
)}
{...props}

View file

@ -2,6 +2,7 @@ import type { ComponentProps, ReactNode } from "react";
import {
Command,
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
@ -11,49 +12,54 @@ import {
CommandShortcut,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import type { Popover as PopoverPrimitive } from "radix-ui";
Dialog,
DialogContent,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
export type ModelSelectorProps = React.ComponentProps<typeof PopoverPrimitive.Root>;
export type ModelSelectorProps = ComponentProps<typeof Dialog>;
export const ModelSelector = (props: ModelSelectorProps) => (
<Popover {...props} />
<Dialog {...props} />
);
export type ModelSelectorTriggerProps = ComponentProps<typeof PopoverTrigger>;
export type ModelSelectorTriggerProps = ComponentProps<typeof DialogTrigger>;
export const ModelSelectorTrigger = (props: ModelSelectorTriggerProps) => (
<PopoverTrigger {...props} />
<DialogTrigger {...props} />
);
export type ModelSelectorContentProps = ComponentProps<typeof PopoverContent> & {
export type ModelSelectorContentProps = ComponentProps<typeof DialogContent> & {
title?: ReactNode;
};
export const ModelSelectorContent = ({
className,
children,
title: _title,
title = "Model Selector",
...props
}: ModelSelectorContentProps) => (
<PopoverContent
align="start"
<DialogContent
aria-describedby={undefined}
className={cn(
"w-[280px] p-0 rounded-xl border border-border/60 bg-card/95 backdrop-blur-xl shadow-[var(--shadow-float)]",
"outline! border-none! p-0 outline-border! outline-solid!",
className
)}
side="top"
sideOffset={8}
{...props}
>
<DialogTitle className="sr-only">{title}</DialogTitle>
<Command className="**:data-[slot=command-input-wrapper]:h-auto">
{children}
</Command>
</PopoverContent>
</DialogContent>
);
export type ModelSelectorDialogProps = ComponentProps<typeof CommandDialog>;
export const ModelSelectorDialog = (props: ModelSelectorDialogProps) => (
<CommandDialog {...props} />
);
export type ModelSelectorInputProps = ComponentProps<typeof CommandInput>;
@ -62,13 +68,13 @@ export const ModelSelectorInput = ({
className,
...props
}: ModelSelectorInputProps) => (
<CommandInput className={cn("h-auto py-2.5 text-[13px]", className)} {...props} />
<CommandInput className={cn("h-auto py-3.5", className)} {...props} />
);
export type ModelSelectorListProps = ComponentProps<typeof CommandList>;
export const ModelSelectorList = ({ className, ...props }: ModelSelectorListProps) => (
<CommandList className={cn("max-h-[280px]", className)} {...props} />
export const ModelSelectorList = (props: ModelSelectorListProps) => (
<CommandList {...props} />
);
export type ModelSelectorEmptyProps = ComponentProps<typeof CommandEmpty>;
@ -85,8 +91,8 @@ export const ModelSelectorGroup = (props: ModelSelectorGroupProps) => (
export type ModelSelectorItemProps = ComponentProps<typeof CommandItem>;
export const ModelSelectorItem = ({ className, ...props }: ModelSelectorItemProps) => (
<CommandItem className={cn("w-full text-[13px] rounded-lg", className)} {...props} />
export const ModelSelectorItem = (props: ModelSelectorItemProps) => (
<CommandItem {...props} />
);
export type ModelSelectorShortcutProps = ComponentProps<typeof CommandShortcut>;
@ -176,10 +182,10 @@ export const ModelSelectorLogo = ({
<img
{...props}
alt={`${provider} logo`}
className={cn("size-4 dark:invert", className)}
height={16}
className={cn("size-3 dark:invert", className)}
height={12}
src={`https://models.dev/logos/${provider}.svg`}
width={16}
width={12}
/>
);
@ -191,7 +197,7 @@ export const ModelSelectorLogoGroup = ({
}: 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",
"flex shrink-0 items-center -space-x-1 [&>img]:rounded-full [&>img]:bg-background [&>img]:p-px [&>img]:ring-1 dark:[&>img]:bg-foreground",
className
)}
{...props}

View file

@ -1,10 +1,11 @@
"use client";
import type { ComponentProps, HTMLAttributes, ReactNode } from "react";
import type { ComponentProps, ReactNode } from "react";
import { useControllableState } from "@radix-ui/react-use-controllable-state";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { cn } from "@/lib/utils";
@ -12,7 +13,7 @@ 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 { BrainIcon, ChevronDownIcon } from "lucide-react";
import {
createContext,
memo,
@ -136,7 +137,7 @@ export const Reasoning = memo(
return (
<ReasoningContext.Provider value={contextValue}>
<Collapsible
className={cn("not-prose", className)}
className={cn("not-prose mb-4", className)}
onOpenChange={handleOpenChange}
open={isOpen}
{...props}
@ -156,7 +157,7 @@ export type ReasoningTriggerProps = ComponentProps<
const defaultGetThinkingMessage = (isStreaming: boolean, duration?: number) => {
if (isStreaming || duration === 0) {
return <Shimmer className="font-medium" duration={1}>Thinking...</Shimmer>;
return <Shimmer duration={1}>Thinking...</Shimmer>;
}
if (duration === undefined) {
return <p>Thought for a few seconds</p>;
@ -176,13 +177,14 @@ export const ReasoningTrigger = memo(
return (
<CollapsibleTrigger
className={cn(
"flex w-full items-center gap-2 text-muted-foreground text-[13px] leading-[1.65] transition-colors hover:text-foreground",
"flex w-full items-center gap-2 text-muted-foreground text-sm transition-colors hover:text-foreground",
className
)}
{...props}
>
{children ?? (
<>
<BrainIcon className="size-4" />
{getThinkingMessage(isStreaming, duration)}
<ChevronDownIcon
className={cn(
@ -197,43 +199,29 @@ export const ReasoningTrigger = memo(
}
);
export type ReasoningContentProps = HTMLAttributes<HTMLDivElement> & {
export type ReasoningContentProps = ComponentProps<
typeof CollapsibleContent
> & {
children: string;
};
const streamdownPlugins = { cjk, code, math, mermaid };
export const ReasoningContent = memo(
({ className, children, ...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}>
{children}
</Streamdown>
</div>
</div>
);
}
({ className, children, ...props }: ReasoningContentProps) => (
<CollapsibleContent
className={cn(
"mt-4 text-sm",
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-muted-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
className
)}
{...props}
>
<Streamdown plugins={streamdownPlugins} {...props}>
{children}
</Streamdown>
</CollapsibleContent>
)
);
Reasoning.displayName = "Reasoning";

View file

@ -161,7 +161,9 @@ export const ToolOutput = ({
<div
className={cn(
"overflow-x-auto rounded-md text-xs [&_table]:w-full",
errorText && "bg-destructive/10 text-destructive"
errorText
? "bg-destructive/10 text-destructive"
: "bg-muted/50 text-foreground"
)}
>
{errorText && <div>{errorText}</div>}

150
components/app-sidebar.tsx Normal file
View file

@ -0,0 +1,150 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { toast } from "sonner";
import { useSWRConfig } from "swr";
import { unstable_serialize } from "swr/infinite";
import { PlusIcon, TrashIcon } from "@/components/icons";
import {
getChatHistoryPaginationKey,
SidebarHistory,
} from "@/components/sidebar-history";
import { SidebarUserNav } from "@/components/sidebar-user-nav";
import { Button } from "@/components/ui/button";
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarHeader,
SidebarMenu,
useSidebar,
} from "@/components/ui/sidebar";
import type { AuthUser } from "@/lib/auth";
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: AuthUser | undefined }) {
const router = useRouter();
const { setOpenMobile } = useSidebar();
const { mutate } = useSWRConfig();
const [showDeleteAllDialog, setShowDeleteAllDialog] = useState(false);
const handleDeleteAll = () => {
const deletePromise = fetch(
`${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/history`,
{
method: "DELETE",
}
);
toast.promise(deletePromise, {
loading: "Deleting all chats...",
success: () => {
mutate(unstable_serialize(getChatHistoryPaginationKey));
setShowDeleteAllDialog(false);
router.replace("/");
router.refresh();
return "All chats deleted successfully";
},
error: "Failed to delete all chats",
});
};
return (
<>
<Sidebar className="group-data-[side=left]:border-r-0">
<SidebarHeader>
<SidebarMenu>
<div className="flex flex-row items-center justify-between">
<Link
className="flex flex-row items-center gap-3"
href="/"
onClick={() => {
setOpenMobile(false);
}}
>
<span className="cursor-pointer rounded-md px-2 font-semibold text-lg hover:bg-muted">
Chatbot
</span>
</Link>
<div className="flex flex-row gap-1">
{user && (
<Tooltip>
<TooltipTrigger asChild>
<Button
onClick={() => setShowDeleteAllDialog(true)}
size="icon-sm"
type="button"
variant="ghost"
>
<TrashIcon />
</Button>
</TooltipTrigger>
<TooltipContent align="end" className="hidden md:block">
Delete All Chats
</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
onClick={() => {
setOpenMobile(false);
router.push("/");
router.refresh();
}}
size="icon-sm"
type="button"
variant="ghost"
>
<PlusIcon />
</Button>
</TooltipTrigger>
<TooltipContent align="end" className="hidden md:block">
New Chat
</TooltipContent>
</Tooltip>
</div>
</div>
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
<SidebarHistory user={user} />
</SidebarContent>
<SidebarFooter>{user && <SidebarUserNav user={user} />}</SidebarFooter>
</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,107 @@
import { type Dispatch, memo, type SetStateAction, useState } from "react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { artifactDefinitions, type UIArtifact } from "./artifact";
import type { ArtifactActionContext } from "./create-artifact";
import { Button } from "./ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
type ArtifactActionsProps = {
artifact: UIArtifact;
handleVersionChange: (type: "next" | "prev" | "toggle" | "latest") => void;
currentVersionIndex: number;
isCurrentVersion: boolean;
mode: "edit" | "diff";
metadata: any;
setMetadata: Dispatch<SetStateAction<any>>;
};
function PureArtifactActions({
artifact,
handleVersionChange,
currentVersionIndex,
isCurrentVersion,
mode,
metadata,
setMetadata,
}: ArtifactActionsProps) {
const [isLoading, setIsLoading] = useState(false);
const artifactDefinition = artifactDefinitions.find(
(definition) => definition.kind === artifact.kind
);
if (!artifactDefinition) {
throw new Error("Artifact definition not found!");
}
const actionContext: ArtifactActionContext = {
content: artifact.content,
handleVersionChange,
currentVersionIndex,
isCurrentVersion,
mode,
metadata,
setMetadata,
};
return (
<div className="flex flex-row gap-1">
{artifactDefinition.actions.map((action) => (
<Tooltip key={action.description}>
<TooltipTrigger asChild>
<Button
className={cn("h-fit dark:hover:bg-zinc-700", {
"p-2": !action.label,
"px-2 py-1.5": action.label,
})}
disabled={
isLoading || artifact.status === "streaming"
? true
: action.isDisabled
? action.isDisabled(actionContext)
: false
}
onClick={async () => {
setIsLoading(true);
try {
await Promise.resolve(action.onClick(actionContext));
} catch (_error) {
toast.error("Failed to execute action");
} finally {
setIsLoading(false);
}
}}
variant="outline"
>
{action.icon}
{action.label}
</Button>
</TooltipTrigger>
<TooltipContent>{action.description}</TooltipContent>
</Tooltip>
))}
</div>
);
}
export const ArtifactActions = memo(
PureArtifactActions,
(prevProps, nextProps) => {
if (prevProps.artifact.status !== nextProps.artifact.status) {
return false;
}
if (prevProps.currentVersionIndex !== nextProps.currentVersionIndex) {
return false;
}
if (prevProps.isCurrentVersion !== nextProps.isCurrentVersion) {
return false;
}
if (prevProps.artifact.content !== nextProps.artifact.content) {
return false;
}
return true;
}
);

View file

@ -1,13 +1,14 @@
import { memo } from "react";
import { initialArtifactData, useArtifact } from "@/hooks/use-artifact";
import { CrossIcon } from "./icons";
import { Button } from "./ui/button";
function PureArtifactCloseButton() {
const { setArtifact } = useArtifact();
return (
<button
className="group flex size-8 items-center justify-center rounded-lg border border-transparent text-muted-foreground transition-all duration-150 hover:border-border hover:bg-muted hover:text-foreground active:scale-95"
<Button
className="h-fit p-2 dark:hover:bg-zinc-700"
data-testid="artifact-close-button"
onClick={() => {
setArtifact((currentArtifact) =>
@ -19,10 +20,10 @@ function PureArtifactCloseButton() {
: { ...initialArtifactData, status: "idle" }
);
}}
type="button"
variant="outline"
>
<CrossIcon size={16} />
</button>
<CrossIcon size={18} />
</Button>
);
}

535
components/artifact.tsx Normal file
View file

@ -0,0 +1,535 @@
import type { UseChatHelpers } from "@ai-sdk/react";
import { formatDistance } from "date-fns";
import equal from "fast-deep-equal";
import { AnimatePresence, motion } from "framer-motion";
import {
type Dispatch,
memo,
type SetStateAction,
useCallback,
useEffect,
useState,
} from "react";
import useSWR, { useSWRConfig } from "swr";
import { useDebounceCallback, useWindowSize } from "usehooks-ts";
import { codeArtifact } from "@/artifacts/code/client";
import { imageArtifact } from "@/artifacts/image/client";
import { sheetArtifact } from "@/artifacts/sheet/client";
import { textArtifact } from "@/artifacts/text/client";
import { useArtifact } from "@/hooks/use-artifact";
import type { Document, Vote } from "@/lib/db/schema";
import type { Attachment, ChatMessage } from "@/lib/types";
import { fetcher } from "@/lib/utils";
import { ArtifactActions } from "./artifact-actions";
import { ArtifactCloseButton } from "./artifact-close-button";
import { ArtifactMessages } from "./artifact-messages";
import { MultimodalInput } from "./multimodal-input";
import { Toolbar } from "./toolbar";
import { useSidebar } from "./ui/sidebar";
import { VersionFooter } from "./version-footer";
import type { VisibilityType } from "./visibility-selector";
export const artifactDefinitions = [
textArtifact,
codeArtifact,
imageArtifact,
sheetArtifact,
];
export type ArtifactKind = (typeof artifactDefinitions)[number]["kind"];
export type UIArtifact = {
title: string;
documentId: string;
kind: ArtifactKind;
content: string;
isVisible: boolean;
status: "streaming" | "idle";
boundingBox: {
top: number;
left: number;
width: number;
height: number;
};
};
function PureArtifact({
addToolApprovalResponse,
chatId,
input,
setInput,
status,
stop,
attachments,
setAttachments,
sendMessage,
messages,
setMessages,
regenerate,
votes,
isReadonly,
selectedVisibilityType,
selectedModelId,
}: {
addToolApprovalResponse: UseChatHelpers<ChatMessage>["addToolApprovalResponse"];
chatId: string;
input: string;
setInput: Dispatch<SetStateAction<string>>;
status: UseChatHelpers<ChatMessage>["status"];
stop: UseChatHelpers<ChatMessage>["stop"];
attachments: Attachment[];
setAttachments: Dispatch<SetStateAction<Attachment[]>>;
messages: ChatMessage[];
setMessages: UseChatHelpers<ChatMessage>["setMessages"];
votes: Vote[] | undefined;
sendMessage: UseChatHelpers<ChatMessage>["sendMessage"];
regenerate: UseChatHelpers<ChatMessage>["regenerate"];
isReadonly: boolean;
selectedVisibilityType: VisibilityType;
selectedModelId: string;
}) {
const { artifact, setArtifact, metadata, setMetadata } = useArtifact();
const {
data: documents,
isLoading: isDocumentsFetching,
mutate: mutateDocuments,
} = useSWR<Document[]>(
artifact.documentId !== "init" && artifact.status !== "streaming"
? `/api/document?id=${artifact.documentId}`
: null,
fetcher
);
const [mode, setMode] = useState<"edit" | "diff">("edit");
const [document, setDocument] = useState<Document | null>(null);
const [currentVersionIndex, setCurrentVersionIndex] = useState(-1);
const { open: isSidebarOpen } = useSidebar();
useEffect(() => {
if (documents && documents.length > 0) {
const mostRecentDocument = documents.at(-1);
if (mostRecentDocument) {
setDocument(mostRecentDocument);
setCurrentVersionIndex(documents.length - 1);
setArtifact((currentArtifact) => ({
...currentArtifact,
content: mostRecentDocument.content ?? "",
}));
}
}
}, [documents, setArtifact]);
useEffect(() => {
mutateDocuments();
}, [mutateDocuments]);
const { mutate } = useSWRConfig();
const [isContentDirty, setIsContentDirty] = useState(false);
const handleContentChange = useCallback(
(updatedContent: string) => {
if (!artifact) {
return;
}
mutate<Document[]>(
`/api/document?id=${artifact.documentId}`,
async (currentDocuments) => {
if (!currentDocuments) {
return [];
}
const currentDocument = currentDocuments.at(-1);
if (!currentDocument || !currentDocument.content) {
setIsContentDirty(false);
return currentDocuments;
}
if (currentDocument.content !== updatedContent) {
await fetch(
`${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/document?id=${artifact.documentId}`,
{
method: "POST",
body: JSON.stringify({
title: artifact.title,
content: updatedContent,
kind: artifact.kind,
}),
}
);
setIsContentDirty(false);
const newDocument = {
...currentDocument,
content: updatedContent,
createdAt: new Date(),
};
return [...currentDocuments, newDocument];
}
return currentDocuments;
},
{ revalidate: false }
);
},
[artifact, mutate]
);
const debouncedHandleContentChange = useDebounceCallback(
handleContentChange,
2000
);
const saveContent = useCallback(
(updatedContent: string, debounce: boolean) => {
if (document && updatedContent !== document.content) {
setIsContentDirty(true);
if (debounce) {
debouncedHandleContentChange(updatedContent);
} else {
handleContentChange(updatedContent);
}
}
},
[document, debouncedHandleContentChange, handleContentChange]
);
function getDocumentContentById(index: number) {
if (!documents) {
return "";
}
if (!documents[index]) {
return "";
}
return documents[index].content ?? "";
}
const handleVersionChange = (type: "next" | "prev" | "toggle" | "latest") => {
if (!documents) {
return;
}
if (type === "latest") {
setCurrentVersionIndex(documents.length - 1);
setMode("edit");
}
if (type === "toggle") {
setMode((currentMode) => (currentMode === "edit" ? "diff" : "edit"));
}
if (type === "prev") {
if (currentVersionIndex > 0) {
setCurrentVersionIndex((index) => index - 1);
}
} else if (type === "next" && currentVersionIndex < documents.length - 1) {
setCurrentVersionIndex((index) => index + 1);
}
};
const [isToolbarVisible, setIsToolbarVisible] = useState(false);
/*
* NOTE: if there are no documents, or if
* the documents are being fetched, then
* we mark it as the current version.
*/
const isCurrentVersion =
documents && documents.length > 0
? currentVersionIndex === documents.length - 1
: true;
const { width: windowWidth, height: windowHeight } = useWindowSize();
const isMobile = windowWidth ? windowWidth < 768 : false;
const artifactDefinition = artifactDefinitions.find(
(definition) => definition.kind === artifact.kind
);
if (!artifactDefinition) {
throw new Error("Artifact definition not found!");
}
useEffect(() => {
if (artifact.documentId !== "init" && artifactDefinition.initialize) {
artifactDefinition.initialize({
documentId: artifact.documentId,
setMetadata,
});
}
}, [artifact.documentId, artifactDefinition, setMetadata]);
return (
<AnimatePresence>
{artifact.isVisible && (
<motion.div
animate={{ opacity: 1 }}
className="fixed top-0 left-0 z-50 flex h-dvh w-dvw flex-row bg-transparent"
data-testid="artifact"
exit={{ opacity: 0, transition: { delay: 0.4 } }}
initial={{ opacity: 1 }}
>
{!isMobile && (
<motion.div
animate={{ width: windowWidth, right: 0 }}
className="fixed h-dvh bg-background"
exit={{
width: isSidebarOpen ? windowWidth - 256 : windowWidth,
right: 0,
}}
initial={{
width: isSidebarOpen ? windowWidth - 256 : windowWidth,
right: 0,
}}
/>
)}
{!isMobile && (
<motion.div
animate={{
opacity: 1,
x: 0,
scale: 1,
transition: {
delay: 0.1,
type: "spring",
stiffness: 300,
damping: 30,
},
}}
className="relative h-dvh w-[400px] shrink-0 bg-muted dark:bg-background"
exit={{
opacity: 0,
x: 0,
scale: 1,
transition: { duration: 0 },
}}
initial={{ opacity: 0, x: 10, scale: 1 }}
>
<AnimatePresence>
{!isCurrentVersion && (
<motion.div
animate={{ opacity: 1 }}
className="absolute top-0 left-0 z-50 h-dvh w-[400px] bg-zinc-900/50"
exit={{ opacity: 0 }}
initial={{ opacity: 0 }}
/>
)}
</AnimatePresence>
<div className="flex h-full flex-col items-center justify-between">
<ArtifactMessages
addToolApprovalResponse={addToolApprovalResponse}
artifactStatus={artifact.status}
chatId={chatId}
isReadonly={isReadonly}
messages={messages}
regenerate={regenerate}
setMessages={setMessages}
status={status}
votes={votes}
/>
<div className="relative flex w-full flex-row items-end gap-2 px-4 pb-4">
<MultimodalInput
attachments={attachments}
chatId={chatId}
className="bg-background dark:bg-muted"
input={input}
messages={messages}
selectedModelId={selectedModelId}
selectedVisibilityType={selectedVisibilityType}
sendMessage={sendMessage}
setAttachments={setAttachments}
setInput={setInput}
setMessages={setMessages}
status={status}
stop={stop}
/>
</div>
</div>
</motion.div>
)}
<motion.div
animate={
isMobile
? {
opacity: 1,
x: 0,
y: 0,
height: windowHeight,
width: windowWidth ? windowWidth : "calc(100dvw)",
borderRadius: 0,
transition: {
delay: 0,
type: "spring",
stiffness: 300,
damping: 30,
duration: 0.8,
},
}
: {
opacity: 1,
x: 400,
y: 0,
height: windowHeight,
width: windowWidth
? windowWidth - 400
: "calc(100dvw-400px)",
borderRadius: 0,
transition: {
delay: 0,
type: "spring",
stiffness: 300,
damping: 30,
duration: 0.8,
},
}
}
className="fixed flex h-dvh flex-col overflow-y-scroll border-zinc-200 bg-background md:border-l dark:border-zinc-700 dark:bg-muted"
exit={{
opacity: 0,
scale: 0.5,
transition: {
delay: 0.1,
type: "spring",
stiffness: 600,
damping: 30,
},
}}
initial={
isMobile
? {
opacity: 1,
x: artifact.boundingBox.left,
y: artifact.boundingBox.top,
height: artifact.boundingBox.height,
width: artifact.boundingBox.width,
borderRadius: 50,
}
: {
opacity: 1,
x: artifact.boundingBox.left,
y: artifact.boundingBox.top,
height: artifact.boundingBox.height,
width: artifact.boundingBox.width,
borderRadius: 50,
}
}
>
<div className="flex flex-row items-start justify-between p-2">
<div className="flex flex-row items-start gap-4">
<ArtifactCloseButton />
<div className="flex flex-col">
<div className="font-medium">{artifact.title}</div>
{isContentDirty ? (
<div className="text-muted-foreground text-sm">
Saving changes...
</div>
) : document ? (
<div className="text-muted-foreground text-sm">
{`Updated ${formatDistance(
new Date(document.createdAt),
new Date(),
{
addSuffix: true,
}
)}`}
</div>
) : (
<div className="mt-2 h-3 w-32 animate-pulse rounded-md bg-muted-foreground/20" />
)}
</div>
</div>
<ArtifactActions
artifact={artifact}
currentVersionIndex={currentVersionIndex}
handleVersionChange={handleVersionChange}
isCurrentVersion={isCurrentVersion}
metadata={metadata}
mode={mode}
setMetadata={setMetadata}
/>
</div>
<div className="h-full max-w-full! items-center overflow-y-scroll bg-background dark:bg-muted">
<artifactDefinition.content
content={
isCurrentVersion
? artifact.content
: getDocumentContentById(currentVersionIndex)
}
currentVersionIndex={currentVersionIndex}
getDocumentContentById={getDocumentContentById}
isCurrentVersion={isCurrentVersion}
isInline={false}
isLoading={isDocumentsFetching && !artifact.content}
metadata={metadata}
mode={mode}
onSaveContent={saveContent}
setMetadata={setMetadata}
status={artifact.status}
suggestions={[]}
title={artifact.title}
/>
<AnimatePresence>
{isCurrentVersion && (
<Toolbar
artifactKind={artifact.kind}
isToolbarVisible={isToolbarVisible}
sendMessage={sendMessage}
setIsToolbarVisible={setIsToolbarVisible}
setMessages={setMessages}
status={status}
stop={stop}
/>
)}
</AnimatePresence>
</div>
<AnimatePresence>
{!isCurrentVersion && (
<VersionFooter
currentVersionIndex={currentVersionIndex}
documents={documents}
handleVersionChange={handleVersionChange}
/>
)}
</AnimatePresence>
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
}
export const Artifact = memo(PureArtifact, (prevProps, nextProps) => {
if (prevProps.status !== nextProps.status) {
return false;
}
if (!equal(prevProps.votes, nextProps.votes)) {
return false;
}
if (prevProps.input !== nextProps.input) {
return false;
}
if (!equal(prevProps.messages, nextProps.messages.length)) {
return false;
}
if (prevProps.selectedVisibilityType !== nextProps.selectedVisibilityType) {
return false;
}
return true;
});

View file

@ -1,7 +1,7 @@
import Form from "next/form";
import { Input } from "../ui/input";
import { Label } from "../ui/label";
import { Input } from "./ui/input";
import { Label } from "./ui/label";
export function AuthForm({
action,
@ -15,33 +15,40 @@ export function AuthForm({
defaultEmail?: string;
}) {
return (
<Form action={action} className="flex flex-col gap-4">
<Form action={action} className="flex flex-col gap-4 px-4 sm:px-16">
<div className="flex flex-col gap-2">
<Label className="font-normal text-muted-foreground" htmlFor="email">
Email
<Label
className="font-normal text-zinc-600 dark:text-zinc-400"
htmlFor="email"
>
Email Address
</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"
className="bg-muted text-md md:text-sm"
defaultValue={defaultEmail}
id="email"
name="email"
placeholder="you@someo.ne"
placeholder="user@acme.com"
required
type="email"
/>
</div>
<div className="flex flex-col gap-2">
<Label className="font-normal text-muted-foreground" htmlFor="password">
<Label
className="font-normal text-zinc-600 dark:text-zinc-400"
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"
className="bg-muted text-md md:text-sm"
id="password"
name="password"
placeholder="&bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;"
required
type="password"
/>

View file

@ -0,0 +1,77 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { memo } from "react";
import { useWindowSize } from "usehooks-ts";
import { SidebarToggle } from "@/components/sidebar-toggle";
import { Button } from "@/components/ui/button";
import { PlusIcon, VercelIcon } from "./icons";
import { useSidebar } from "./ui/sidebar";
import { VisibilitySelector, type VisibilityType } from "./visibility-selector";
function PureChatHeader({
chatId,
selectedVisibilityType,
isReadonly,
}: {
chatId: string;
selectedVisibilityType: VisibilityType;
isReadonly: boolean;
}) {
const router = useRouter();
const { open } = useSidebar();
const { width: windowWidth } = useWindowSize();
return (
<header className="sticky top-0 flex items-center gap-2 bg-background px-2 py-1.5 md:px-2">
<SidebarToggle />
{(!open || windowWidth < 768) && (
<Button
className="order-2 ml-auto md:order-1 md:ml-0"
onClick={() => {
router.push("/");
router.refresh();
}}
size="icon-sm"
variant="outline"
>
<PlusIcon />
<span className="md:sr-only">New Chat</span>
</Button>
)}
{!isReadonly && (
<VisibilitySelector
chatId={chatId}
className="order-1 md:order-2"
selectedVisibilityType={selectedVisibilityType}
/>
)}
<Button
asChild
className="order-3 hidden bg-zinc-900 px-2 text-zinc-50 hover:bg-zinc-800 md:ml-auto md:flex md:h-fit dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-200"
>
<Link
href={"https://vercel.com/templates/next.js/chatbot"}
rel="noreferrer"
target="_noblank"
>
<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
);
});

288
components/chat.tsx Normal file
View file

@ -0,0 +1,288 @@
"use client";
import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai";
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import useSWR, { useSWRConfig } from "swr";
import { unstable_serialize } from "swr/infinite";
import { ChatHeader } from "@/components/chat-header";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { useArtifactSelector } from "@/hooks/use-artifact";
import { useAutoResume } from "@/hooks/use-auto-resume";
import { useChatVisibility } from "@/hooks/use-chat-visibility";
import type { Vote } from "@/lib/db/schema";
import { ChatbotError } from "@/lib/errors";
import type { Attachment, ChatMessage } from "@/lib/types";
import { fetcher, fetchWithErrorHandlers, generateUUID } from "@/lib/utils";
import { Artifact } from "./artifact";
import { useDataStream } from "./data-stream-provider";
import { Messages } from "./messages";
import { MultimodalInput } from "./multimodal-input";
import { getChatHistoryPaginationKey } from "./sidebar-history";
import { toast } from "./toast";
import type { VisibilityType } from "./visibility-selector";
export function Chat({
id,
initialMessages,
initialChatModel,
initialVisibilityType,
isReadonly,
autoResume,
}: {
id: string;
initialMessages: ChatMessage[];
initialChatModel: string;
initialVisibilityType: VisibilityType;
isReadonly: boolean;
autoResume: boolean;
}) {
const router = useRouter();
const { visibilityType } = useChatVisibility({
chatId: id,
initialVisibilityType,
});
const { mutate } = useSWRConfig();
// Handle browser back/forward navigation
useEffect(() => {
const handlePopState = () => {
// When user navigates back/forward, refresh to sync with URL
router.refresh();
};
window.addEventListener("popstate", handlePopState);
return () => window.removeEventListener("popstate", handlePopState);
}, [router]);
const { setDataStream } = useDataStream();
const [input, setInput] = useState<string>("");
const [showCreditCardAlert, setShowCreditCardAlert] = useState(false);
const [currentModelId, setCurrentModelId] = useState(initialChatModel);
const currentModelIdRef = useRef(currentModelId);
useEffect(() => {
currentModelIdRef.current = currentModelId;
}, [currentModelId]);
const {
messages,
setMessages,
sendMessage,
status,
stop,
regenerate,
resumeStream,
addToolApprovalResponse,
} = useChat<ChatMessage>({
id,
messages: initialMessages,
generateId: generateUUID,
sendAutomaticallyWhen: ({ messages: currentMessages }) => {
const lastMessage = currentMessages.at(-1);
const shouldContinue =
lastMessage?.parts?.some(
(part) =>
"state" in part &&
part.state === "approval-responded" &&
"approval" in part &&
(part.approval as { approved?: boolean })?.approved === true
) ?? false;
return shouldContinue;
},
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: visibilityType,
...request.body,
},
};
},
}),
onData: (dataPart) => {
setDataStream((ds) => (ds ? [...ds, dataPart] : []));
},
onFinish: () => {
mutate(unstable_serialize(getChatHistoryPaginationKey));
},
onError: (error) => {
if (error.message?.includes("AI Gateway requires a valid credit card")) {
setShowCreditCardAlert(true);
} else if (error instanceof ChatbotError) {
toast({
type: "error",
description: error.message,
});
} else {
toast({
type: "error",
description: error.message || "Oops, an error occurred!",
});
}
},
});
const searchParams = useSearchParams();
const query = searchParams.get("query");
const [hasAppendedQuery, setHasAppendedQuery] = useState(false);
useEffect(() => {
if (query && !hasAppendedQuery) {
sendMessage({
role: "user" as const,
parts: [{ type: "text", text: query }],
});
setHasAppendedQuery(true);
window.history.replaceState(
{},
"",
`${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/chat/${id}`
);
}
}, [query, sendMessage, hasAppendedQuery, id]);
const { data: votes } = useSWR<Vote[]>(
!isReadonly && messages.length >= 2
? `${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/vote?chatId=${id}`
: null,
fetcher
);
const [attachments, setAttachments] = useState<Attachment[]>([]);
const isArtifactVisible = useArtifactSelector((state) => state.isVisible);
useAutoResume({
autoResume,
initialMessages,
resumeStream,
setMessages,
});
return (
<>
<div className="overscroll-behavior-contain flex h-dvh min-w-0 touch-pan-y flex-col bg-background">
<ChatHeader
chatId={id}
isReadonly={isReadonly}
selectedVisibilityType={initialVisibilityType}
/>
<Messages
addToolApprovalResponse={addToolApprovalResponse}
chatId={id}
isArtifactVisible={isArtifactVisible}
isReadonly={isReadonly}
messages={messages}
regenerate={regenerate}
selectedModelId={initialChatModel}
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={id}
input={input}
messages={messages}
onModelChange={setCurrentModelId}
selectedModelId={currentModelId}
selectedVisibilityType={visibilityType}
sendMessage={sendMessage}
setAttachments={setAttachments}
setInput={setInput}
setMessages={setMessages}
status={status}
stop={stop}
/>
)}
</div>
</div>
<Artifact
addToolApprovalResponse={addToolApprovalResponse}
attachments={attachments}
chatId={id}
input={input}
isReadonly={isReadonly}
messages={messages}
regenerate={regenerate}
selectedModelId={currentModelId}
selectedVisibilityType={visibilityType}
sendMessage={sendMessage}
setAttachments={setAttachments}
setInput={setInput}
setMessages={setMessages}
status={status}
stop={stop}
votes={votes}
/>
<AlertDialog
onOpenChange={setShowCreditCardAlert}
open={showCreditCardAlert}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Activate AI Gateway</AlertDialogTitle>
<AlertDialogDescription>
This application requires{" "}
{process.env.NODE_ENV === "production" ? "the owner" : "you"} to
activate Vercel AI Gateway.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
window.open(
"https://vercel.com/d?to=%2F%5Bteam%5D%2F%7E%2Fai%3Fmodal%3Dadd-credit-card",
"_blank"
);
window.location.href = `${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/`;
}}
>
Activate
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}

View file

@ -1,165 +0,0 @@
"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

@ -1,119 +0,0 @@
import { memo, useState } from "react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
import { artifactDefinitions, type UIArtifact } from "./artifact";
import type { ArtifactActionContext } from "./create-artifact";
type ArtifactActionsProps = {
artifact: UIArtifact;
handleVersionChange: (type: "next" | "prev" | "toggle" | "latest") => void;
currentVersionIndex: number;
isCurrentVersion: boolean;
mode: "edit" | "diff";
metadata: ArtifactActionContext["metadata"];
setMetadata: ArtifactActionContext["setMetadata"];
};
function PureArtifactActions({
artifact,
handleVersionChange,
currentVersionIndex,
isCurrentVersion,
mode,
metadata,
setMetadata,
}: ArtifactActionsProps) {
const [isLoading, setIsLoading] = useState(false);
const artifactDefinition = artifactDefinitions.find(
(definition) => definition.kind === artifact.kind
);
if (!artifactDefinition) {
throw new Error("Artifact definition not found!");
}
const actionContext: ArtifactActionContext = {
content: artifact.content,
handleVersionChange,
currentVersionIndex,
isCurrentVersion,
mode,
metadata,
setMetadata,
};
return (
<div className="flex flex-col items-center gap-0.5">
{artifactDefinition.actions.map((action) => {
const disabled =
isLoading || artifact.status === "streaming"
? true
: action.isDisabled
? action.isDisabled(actionContext)
: false;
return (
<Tooltip key={action.description}>
<TooltipTrigger asChild>
<button
className={cn(
"flex items-center justify-center rounded-full p-3 text-muted-foreground transition-all duration-150",
"hover:text-foreground",
"active:scale-95",
"disabled:pointer-events-none disabled:opacity-30",
{
"text-foreground":
mode === "diff" && action.description === "View changes",
}
)}
disabled={disabled}
onClick={async () => {
setIsLoading(true);
try {
await Promise.resolve(action.onClick(actionContext));
} catch (_error) {
toast.error("Failed to execute action");
} finally {
setIsLoading(false);
}
}}
type="button"
>
{action.icon}
</button>
</TooltipTrigger>
<TooltipContent side="left" sideOffset={8}>
{action.description}
</TooltipContent>
</Tooltip>
);
})}
</div>
);
}
export const ArtifactActions = memo(
PureArtifactActions,
(prevProps, nextProps) => {
if (prevProps.artifact.status !== nextProps.artifact.status) {
return false;
}
if (prevProps.currentVersionIndex !== nextProps.currentVersionIndex) {
return false;
}
if (prevProps.isCurrentVersion !== nextProps.isCurrentVersion) {
return false;
}
if (prevProps.artifact.content !== nextProps.artifact.content) {
return false;
}
if (prevProps.mode !== nextProps.mode) {
return false;
}
return true;
}
);

View file

@ -1,482 +0,0 @@
import type { UseChatHelpers } from "@ai-sdk/react";
import { formatDistance } from "date-fns";
import equal from "fast-deep-equal";
import { AnimatePresence, motion } from "framer-motion";
import {
type Dispatch,
memo,
type SetStateAction,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import useSWR, { useSWRConfig } from "swr";
import { useWindowSize } from "usehooks-ts";
import { codeArtifact } from "@/artifacts/code/client";
import { imageArtifact } from "@/artifacts/image/client";
import { sheetArtifact } from "@/artifacts/sheet/client";
import { textArtifact } from "@/artifacts/text/client";
import { useArtifact } from "@/hooks/use-artifact";
import type { Document, Vote } from "@/lib/db/schema";
import type { Attachment, ChatMessage } from "@/lib/types";
import { fetcher } from "@/lib/utils";
import { useSidebar } from "../ui/sidebar";
import { ArtifactActions } from "./artifact-actions";
import { ArtifactCloseButton } from "./artifact-close-button";
import { LoaderIcon } from "./icons";
import { Toolbar } from "./toolbar";
import { VersionFooter } from "./version-footer";
import type { VisibilityType } from "./visibility-selector";
export const artifactDefinitions = [
textArtifact,
codeArtifact,
imageArtifact,
sheetArtifact,
];
export type ArtifactKind = (typeof artifactDefinitions)[number]["kind"];
export type UIArtifact = {
title: string;
documentId: string;
kind: ArtifactKind;
content: string;
isVisible: boolean;
status: "streaming" | "idle";
boundingBox: {
top: number;
left: number;
width: number;
height: number;
};
};
function PureArtifact({
addToolApprovalResponse: _addToolApprovalResponse,
chatId: _chatId,
input: _input,
setInput: _setInput,
status,
stop,
attachments: _attachments,
setAttachments: _setAttachments,
sendMessage,
messages: _messages,
setMessages,
regenerate: _regenerate,
votes: _votes,
isReadonly: _isReadonly,
selectedVisibilityType: _selectedVisibilityType,
selectedModelId: _selectedModelId,
}: {
addToolApprovalResponse: UseChatHelpers<ChatMessage>["addToolApprovalResponse"];
chatId: string;
input: string;
setInput: Dispatch<SetStateAction<string>>;
status: UseChatHelpers<ChatMessage>["status"];
stop: UseChatHelpers<ChatMessage>["stop"];
attachments: Attachment[];
setAttachments: Dispatch<SetStateAction<Attachment[]>>;
messages: ChatMessage[];
setMessages: UseChatHelpers<ChatMessage>["setMessages"];
votes: Vote[] | undefined;
sendMessage: UseChatHelpers<ChatMessage>["sendMessage"];
regenerate: UseChatHelpers<ChatMessage>["regenerate"];
isReadonly: boolean;
selectedVisibilityType: VisibilityType;
selectedModelId: string;
}) {
const { artifact, setArtifact, metadata, setMetadata } = useArtifact();
const {
data: documents,
isLoading: isDocumentsFetching,
mutate: mutateDocuments,
} = useSWR<Document[]>(
artifact.documentId !== "init" && artifact.status !== "streaming"
? `${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/document?id=${artifact.documentId}`
: null,
fetcher
);
const [mode, setMode] = useState<"edit" | "diff">("edit");
const [document, setDocument] = useState<Document | null>(null);
const [currentVersionIndex, setCurrentVersionIndex] = useState(-1);
const { state: sidebarState } = useSidebar();
const artifactContentRef = useRef<HTMLDivElement>(null);
const userScrolledArtifact = useRef(false);
const [isContentDirty, setIsContentDirty] = useState(false);
useEffect(() => {
if (artifact.status !== "streaming") {
userScrolledArtifact.current = false;
return;
}
if (userScrolledArtifact.current) {
return;
}
const el = artifactContentRef.current;
if (!el) {
return;
}
el.scrollTo({ top: el.scrollHeight });
}, [artifact.status]);
useEffect(() => {
if (documents && documents.length > 0) {
const mostRecentDocument = documents.at(-1);
if (mostRecentDocument) {
setDocument(mostRecentDocument);
setCurrentVersionIndex(documents.length - 1);
if (artifact.status === "streaming" || !isContentDirty) {
setArtifact((currentArtifact) => ({
...currentArtifact,
content: mostRecentDocument.content ?? "",
}));
}
}
}
}, [documents, setArtifact, artifact.status, isContentDirty]);
useEffect(() => {
mutateDocuments();
}, [mutateDocuments]);
const { mutate } = useSWRConfig();
const handleContentChange = useCallback(
(updatedContent: string) => {
if (!artifact) {
return;
}
mutate<Document[]>(
`${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/document?id=${artifact.documentId}`,
async (currentDocuments) => {
if (!currentDocuments) {
return [];
}
const currentDocument = currentDocuments.at(-1);
if (!currentDocument?.content) {
setIsContentDirty(false);
return currentDocuments;
}
if (currentDocument.content === updatedContent) {
setIsContentDirty(false);
return currentDocuments;
}
await fetch(
`${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/document?id=${artifact.documentId}`,
{
method: "POST",
body: JSON.stringify({
title: artifact.title,
content: updatedContent,
kind: artifact.kind,
isManualEdit: true,
}),
}
);
setIsContentDirty(false);
return currentDocuments.map((doc, i) =>
i === currentDocuments.length - 1
? { ...doc, content: updatedContent }
: doc
);
},
{ revalidate: false }
);
},
[artifact, mutate]
);
const latestContentRef = useRef<string>("");
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const saveContent = useCallback(
(updatedContent: string, debounce: boolean) => {
latestContentRef.current = updatedContent;
setIsContentDirty(true);
if (saveTimerRef.current) {
clearTimeout(saveTimerRef.current);
saveTimerRef.current = null;
}
if (debounce) {
saveTimerRef.current = setTimeout(() => {
handleContentChange(latestContentRef.current);
saveTimerRef.current = null;
}, 2000);
} else {
handleContentChange(updatedContent);
}
},
[handleContentChange]
);
function getDocumentContentById(index: number) {
if (!documents) {
return "";
}
if (!documents[index]) {
return "";
}
return documents[index].content ?? "";
}
const handleVersionChange = (type: "next" | "prev" | "toggle" | "latest") => {
if (!documents) {
return;
}
if (type === "latest") {
setCurrentVersionIndex(documents.length - 1);
setMode("edit");
}
if (type === "toggle") {
setMode((currentMode) => (currentMode === "edit" ? "diff" : "edit"));
}
if (type === "prev") {
if (currentVersionIndex > 0) {
setCurrentVersionIndex((index) => index - 1);
}
} else if (type === "next" && currentVersionIndex < documents.length - 1) {
setCurrentVersionIndex((index) => index + 1);
}
};
const [isToolbarVisible, setIsToolbarVisible] = useState(true);
const isCurrentVersion =
documents && documents.length > 0
? currentVersionIndex === documents.length - 1
: true;
const { width: windowWidth, height: windowHeight } = useWindowSize();
const isMobile = windowWidth ? windowWidth < 768 : false;
const artifactDefinition = artifactDefinitions.find(
(definition) => definition.kind === artifact.kind
);
if (!artifactDefinition) {
throw new Error("Artifact definition not found!");
}
useEffect(() => {
if (artifact.documentId !== "init" && artifactDefinition.initialize) {
artifactDefinition.initialize({
documentId: artifact.documentId,
setMetadata,
});
}
}, [artifact.documentId, artifactDefinition, setMetadata]);
if (!artifact.isVisible && !isMobile) {
return (
<div
className="h-dvh w-0 shrink-0 overflow-hidden transition-[width] duration-300 ease-[cubic-bezier(0.32,0.72,0,1)]"
data-testid="artifact"
/>
);
}
if (!artifact.isVisible) {
return null;
}
const consoleError =
metadata?.outputs
?.filter((o: { status: string }) => o.status === "failed")
.flatMap((o: { contents: { type: string; value: string }[] }) =>
o.contents.filter((c) => c.type === "text").map((c) => c.value)
)
.join("\n") || undefined;
const artifactPanel = (
<>
{sidebarState !== "collapsed" && (
<div className="flex h-[calc(3.5rem+1px)] shrink-0 items-center justify-between border-b border-border/50 px-4">
<div className="flex items-center gap-3">
<ArtifactCloseButton />
<div className="flex flex-col gap-0.5">
<div className="text-sm font-semibold leading-tight tracking-tight">
{artifact.title}
</div>
<div className="flex items-center gap-2">
{isContentDirty ? (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<div className="size-1.5 animate-pulse rounded-full bg-amber-500" />
Saving...
</div>
) : document ? (
<div className="text-xs text-muted-foreground">
{`Updated ${formatDistance(new Date(document.createdAt), new Date(), { addSuffix: true })}`}
</div>
) : artifact.status === "streaming" ? (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<div className="animate-spin">
<LoaderIcon size={12} />
</div>
Generating...
</div>
) : (
<div className="h-3 w-24 animate-pulse rounded bg-muted-foreground/10" />
)}
{documents && documents.length > 1 && (
<div className="rounded-md bg-muted px-1.5 py-0.5 text-[10px] font-medium tabular-nums text-muted-foreground">
v{currentVersionIndex + 1}/{documents.length}
</div>
)}
</div>
</div>
</div>
</div>
)}
<div
className="relative flex-1 overflow-y-auto bg-background"
data-slot="artifact-content"
onScroll={() => {
const el = artifactContentRef.current;
if (!el) {
return;
}
const atBottom =
el.scrollHeight - el.scrollTop - el.clientHeight < 40;
userScrolledArtifact.current = !atBottom;
}}
ref={artifactContentRef}
>
<artifactDefinition.content
content={
isCurrentVersion
? artifact.content
: getDocumentContentById(currentVersionIndex)
}
currentVersionIndex={currentVersionIndex}
getDocumentContentById={getDocumentContentById}
isCurrentVersion={isCurrentVersion}
isInline={false}
isLoading={isDocumentsFetching && !artifact.content}
metadata={metadata}
mode={mode}
onSaveContent={saveContent}
setMetadata={setMetadata}
status={artifact.status}
suggestions={[]}
title={artifact.title}
/>
<AnimatePresence>
{isCurrentVersion && (
<Toolbar
artifactActions={
<ArtifactActions
artifact={artifact}
currentVersionIndex={currentVersionIndex}
handleVersionChange={handleVersionChange}
isCurrentVersion={isCurrentVersion}
metadata={metadata}
mode={mode}
setMetadata={setMetadata}
/>
}
artifactKind={artifact.kind}
consoleError={consoleError}
documentId={artifact.documentId}
isToolbarVisible={isToolbarVisible}
onClose={() => {
setArtifact((prev) => ({ ...prev, isVisible: false }));
}}
sendMessage={sendMessage}
setIsToolbarVisible={setIsToolbarVisible}
setMessages={setMessages}
status={status}
stop={stop}
/>
)}
</AnimatePresence>
</div>
<AnimatePresence>
{!isCurrentVersion && (
<VersionFooter
currentVersionIndex={currentVersionIndex}
documents={documents}
handleVersionChange={handleVersionChange}
mode={mode}
setMode={setMode}
/>
)}
</AnimatePresence>
</>
);
if (isMobile) {
return (
<motion.div
animate={{
opacity: 1,
x: 0,
y: 0,
height: windowHeight,
width: "100dvw",
borderRadius: 0,
}}
className="fixed inset-0 z-50 flex h-dvh flex-col overflow-hidden bg-sidebar"
data-testid="artifact"
exit={{ opacity: 0, scale: 0.95 }}
initial={{
opacity: 1,
x: artifact.boundingBox.left,
y: artifact.boundingBox.top,
height: artifact.boundingBox.height,
width: artifact.boundingBox.width,
borderRadius: 50,
}}
transition={{ type: "spring", stiffness: 300, damping: 30 }}
>
{artifactPanel}
</motion.div>
);
}
return (
<div
className="flex h-dvh w-[60%] shrink-0 flex-col overflow-hidden border-l border-border/50 bg-sidebar transition-[width] duration-300 ease-[cubic-bezier(0.32,0.72,0,1)]"
data-testid="artifact"
>
{artifactPanel}
</div>
);
}
export const Artifact = memo(PureArtifact, (prevProps, nextProps) => {
if (prevProps.status !== nextProps.status) {
return false;
}
if (!equal(prevProps.votes, nextProps.votes)) {
return false;
}
if (prevProps.input !== nextProps.input) {
return false;
}
if (prevProps.messages.length !== nextProps.messages.length) {
return false;
}
if (prevProps.selectedVisibilityType !== nextProps.selectedVisibilityType) {
return false;
}
return true;
});

View file

@ -1,76 +0,0 @@
"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

@ -1,38 +0,0 @@
"use client";
import type { ArtifactKind } from "./artifact";
export const DocumentSkeleton = ({
artifactKind,
}: {
artifactKind: ArtifactKind;
}) => {
return artifactKind === "image" ? (
<div className="flex h-[calc(100dvh-60px)] w-full flex-col items-center justify-center gap-4">
<div className="size-96 animate-pulse rounded-lg bg-muted-foreground/10" />
</div>
) : (
<div className="flex w-full flex-col gap-4 px-4 py-8 md:px-20 md:py-12">
<div className="h-8 w-2/5 animate-pulse rounded-md bg-muted-foreground/10" />
<div className="h-4 w-full animate-pulse rounded-md bg-muted-foreground/8" />
<div className="h-4 w-full animate-pulse rounded-md bg-muted-foreground/8" />
<div className="h-4 w-3/4 animate-pulse rounded-md bg-muted-foreground/8" />
<div className="h-4 w-0 rounded-md" />
<div className="h-6 w-1/3 animate-pulse rounded-md bg-muted-foreground/10" />
<div className="h-4 w-5/6 animate-pulse rounded-md bg-muted-foreground/8" />
<div className="h-4 w-2/3 animate-pulse rounded-md bg-muted-foreground/8" />
</div>
);
};
export const InlineDocumentSkeleton = () => {
return (
<div className="flex w-full flex-col gap-3">
<div className="h-3.5 w-48 animate-pulse rounded bg-muted-foreground/10" />
<div className="h-3.5 w-3/4 animate-pulse rounded bg-muted-foreground/8" />
<div className="h-3.5 w-1/2 animate-pulse rounded bg-muted-foreground/8" />
<div className="h-3.5 w-64 animate-pulse rounded bg-muted-foreground/8" />
<div className="h-3.5 w-40 animate-pulse rounded bg-muted-foreground/8" />
</div>
);
};

View file

@ -1,24 +0,0 @@
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>
);
};

View file

@ -1,33 +0,0 @@
"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

@ -1,387 +0,0 @@
"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,
ToolOutput,
} from "../ai-elements/tool";
import { useDataStream } from "./data-stream-provider";
import { DocumentToolResult } from "./document";
import { DocumentPreview } from "./document-preview";
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>
);
}
if (type === "tool-createDocument") {
const { toolCallId } = part;
if (part.output && "error" in part.output) {
return (
<div
className="rounded-lg border border-red-200 bg-red-50 p-4 text-red-500 dark:bg-red-950/50"
key={toolCallId}
>
Error creating document: {String(part.output.error)}
</div>
);
}
return (
<DocumentPreview
isReadonly={isReadonly}
key={toolCallId}
result={part.output}
/>
);
}
if (type === "tool-updateDocument") {
const { toolCallId } = part;
if (part.output && "error" in part.output) {
return (
<div
className="rounded-lg border border-red-200 bg-red-50 p-4 text-red-500 dark:bg-red-950/50"
key={toolCallId}
>
Error updating document: {String(part.output.error)}
</div>
);
}
return (
<div className="relative" key={toolCallId}>
<DocumentPreview
args={{ ...part.output, isUpdate: true }}
isReadonly={isReadonly}
result={part.output}
/>
</div>
);
}
if (type === "tool-requestSuggestions") {
const { toolCallId, state } = part;
return (
<Tool
className="w-[min(100%,450px)]"
defaultOpen={true}
key={toolCallId}
>
<ToolHeader state={state} type="tool-requestSuggestions" />
<ToolContent>
{state === "input-available" && <ToolInput input={part.input} />}
{state === "output-available" && (
<ToolOutput
errorText={undefined}
output={
"error" in part.output ? (
<div className="rounded border p-2 text-red-500">
Error: {String(part.output.error)}
</div>
) : (
<DocumentToolResult
isReadonly={isReadonly}
result={part.output}
type="request-suggestions"
/>
)
}
/>
)}
</ToolContent>
</Tool>
);
}
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

@ -1,59 +0,0 @@
"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>
);
}

View file

@ -1,205 +0,0 @@
"use client";
import { useEffect, useRef, useState } from "react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { useActiveChat } from "@/hooks/use-active-chat";
import {
initialArtifactData,
useArtifact,
useArtifactSelector,
} from "@/hooks/use-artifact";
import type { Attachment, ChatMessage } from "@/lib/types";
import { cn } from "@/lib/utils";
import { Artifact } from "./artifact";
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,
showCreditCardAlert,
setShowCreditCardAlert,
} = useActiveChat();
const [editingMessage, setEditingMessage] = useState<ChatMessage | null>(
null
);
const [attachments, setAttachments] = useState<Attachment[]>([]);
const isArtifactVisible = useArtifactSelector((state) => state.isVisible);
const { setArtifact } = useArtifact();
const stopRef = useRef(stop);
stopRef.current = stop;
const prevChatIdRef = useRef(chatId);
useEffect(() => {
if (prevChatIdRef.current !== chatId) {
prevChatIdRef.current = chatId;
stopRef.current();
setArtifact(initialArtifactData);
setEditingMessage(null);
setAttachments([]);
}
}, [chatId, setArtifact]);
return (
<>
<div className="flex h-dvh w-full flex-row overflow-hidden">
<div
className={cn(
"flex min-w-0 flex-col bg-sidebar transition-[width] duration-300 ease-[cubic-bezier(0.32,0.72,0,1)]",
isArtifactVisible ? "w-[40%]" : "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}
isArtifactVisible={isArtifactVisible}
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>
<Artifact
addToolApprovalResponse={addToolApprovalResponse}
attachments={attachments}
chatId={chatId}
input={input}
isReadonly={isReadonly}
messages={messages}
regenerate={regenerate}
selectedModelId={currentModelId}
selectedVisibilityType={visibilityType}
sendMessage={sendMessage}
setAttachments={setAttachments}
setInput={setInput}
setMessages={setMessages}
status={status}
stop={stop}
votes={votes}
/>
</div>
<DataStreamHandler />
<AlertDialog
onOpenChange={setShowCreditCardAlert}
open={showCreditCardAlert}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Activate AI Gateway</AlertDialogTitle>
<AlertDialogDescription>
This application requires{" "}
{process.env.NODE_ENV === "production" ? "the owner" : "you"} to
activate Vercel AI Gateway.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
window.open(
"https://vercel.com/d?to=%2F%5Bteam%5D%2F%7E%2Fai%3Fmodal%3Dadd-credit-card",
"_blank"
);
window.location.href = `${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/`;
}}
>
Activate
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}

View file

@ -1,137 +0,0 @@
"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

@ -1,78 +0,0 @@
"use client";
import { AnimatePresence, motion } from "framer-motion";
import type { UISuggestion } from "@/lib/editor/suggestions";
import { Button } from "../ui/button";
import { CrossIcon, SparklesIcon } from "./icons";
export const SuggestionDialog = ({
suggestion,
onApply,
onClose,
}: {
suggestion: UISuggestion;
onApply: () => void;
onClose: () => void;
}) => {
return (
<AnimatePresence>
<div className="sticky inset-0 z-40 h-full w-full">
<div
aria-hidden="true"
className="absolute inset-0 bg-black/20 backdrop-blur-[2px]"
onClick={onClose}
onKeyDown={(e) => {
if (e.key === "Escape") {
onClose();
}
}}
role="presentation"
/>
<motion.div
animate={{ opacity: 1, scale: 1 }}
className="absolute left-1/2 top-1/2 z-50 flex w-[min(20rem,calc(100%-2rem))] -translate-x-1/2 -translate-y-1/2 flex-col gap-3 rounded-2xl border bg-background p-4 font-sans text-sm shadow-xl"
exit={{ opacity: 0, scale: 0.95 }}
initial={{ opacity: 0, scale: 0.95 }}
key={suggestion.id}
transition={{ duration: 0.15 }}
>
<div className="flex flex-row items-center justify-between">
<div className="flex flex-row items-center gap-2">
<div className="flex size-5 items-center justify-center rounded-md bg-muted/60 text-muted-foreground ring-1 ring-border/50">
<SparklesIcon size={10} />
</div>
<div className="font-medium">Suggestion</div>
</div>
<button
className="flex size-6 cursor-pointer items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
onClick={onClose}
type="button"
>
<CrossIcon size={12} />
</button>
</div>
<div className="text-muted-foreground leading-relaxed">
{suggestion.description}
</div>
<div className="flex gap-2">
<Button
className="w-fit rounded-full px-3 py-1.5"
onClick={onApply}
variant="outline"
>
Apply
</Button>
<Button
className="w-fit rounded-full px-3 py-1.5"
onClick={onClose}
variant="ghost"
>
Dismiss
</Button>
</div>
</motion.div>
</div>
</AnimatePresence>
);
};

View file

@ -1,148 +0,0 @@
"use client";
import { isAfter } from "date-fns";
import { motion } from "framer-motion";
import { ChevronLeftIcon, ChevronRightIcon, DiffIcon } from "lucide-react";
import type { Dispatch, SetStateAction } from "react";
import { useState } from "react";
import { useSWRConfig } from "swr";
import { useArtifact } from "@/hooks/use-artifact";
import type { Document } from "@/lib/db/schema";
import { cn, getDocumentTimestampByIndex } from "@/lib/utils";
import { LoaderIcon } from "./icons";
type VersionFooterProps = {
handleVersionChange: (type: "next" | "prev" | "toggle" | "latest") => void;
documents: Document[] | undefined;
currentVersionIndex: number;
mode: "edit" | "diff";
setMode: Dispatch<SetStateAction<"edit" | "diff">>;
};
export const VersionFooter = ({
handleVersionChange,
documents,
currentVersionIndex,
mode,
setMode,
}: VersionFooterProps) => {
const { artifact } = useArtifact();
const { mutate } = useSWRConfig();
const [isMutating, setIsMutating] = useState(false);
if (!documents) {
return;
}
const isFirst = currentVersionIndex === 0;
const isLast = currentVersionIndex === documents.length - 1;
return (
<motion.div
animate={{ opacity: 1 }}
className="z-50 flex w-full shrink-0 items-center justify-between gap-3 border-t border-border/50 bg-background px-4 py-3"
exit={{ opacity: 0, transition: { duration: 0 } }}
initial={{ opacity: 0 }}
transition={{ duration: 0.2 }}
>
<div className="flex items-center gap-3">
<div className="flex items-center gap-1">
<button
className="flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:pointer-events-none disabled:opacity-30"
disabled={isFirst}
onClick={() => handleVersionChange("prev")}
type="button"
>
<ChevronLeftIcon className="size-4" />
</button>
<span className="min-w-[4rem] text-center text-xs tabular-nums text-muted-foreground">
{currentVersionIndex + 1} of {documents.length}
</span>
<button
className="flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:pointer-events-none disabled:opacity-30"
disabled={isLast}
onClick={() => handleVersionChange("next")}
type="button"
>
<ChevronRightIcon className="size-4" />
</button>
</div>
<button
className={cn(
"flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground",
mode === "diff" && "bg-muted text-foreground"
)}
onClick={() => setMode(mode === "diff" ? "edit" : "diff")}
title="Show changes"
type="button"
>
<DiffIcon className="size-4" />
</button>
</div>
<div className="flex flex-row gap-2">
<button
className="inline-flex items-center justify-center gap-2 rounded-lg bg-foreground px-3 py-1.5 text-sm font-medium text-background transition-all duration-150 hover:opacity-90 active:scale-[0.98] disabled:pointer-events-none disabled:opacity-50"
disabled={isMutating}
onClick={async () => {
setIsMutating(true);
try {
await mutate(
`${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/document?id=${artifact.documentId}`,
await fetch(
`${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/document?id=${artifact.documentId}&timestamp=${getDocumentTimestampByIndex(
documents,
currentVersionIndex
)}`,
{
method: "DELETE",
}
),
{
optimisticData: documents
? [
...documents.filter((document) =>
isAfter(
new Date(document.createdAt),
new Date(
getDocumentTimestampByIndex(
documents,
currentVersionIndex
)
)
)
),
]
: [],
}
);
} finally {
setIsMutating(false);
}
}}
type="button"
>
Restore
{isMutating && (
<div className="animate-spin">
<LoaderIcon size={14} />
</div>
)}
</button>
<button
className="inline-flex items-center justify-center rounded-lg border border-border px-3 py-1.5 text-sm font-medium transition-all duration-150 hover:bg-muted active:scale-[0.98]"
onClick={() => {
setMode("edit");
handleVersionChange("latest");
}}
type="button"
>
Latest
</button>
</div>
</motion.div>
);
};

View file

@ -20,7 +20,6 @@ type EditorProps = {
function PureCodeEditor({ content, onSaveContent, status }: EditorProps) {
const containerRef = useRef<HTMLDivElement>(null);
const editorRef = useRef<EditorView | null>(null);
const userScrolledRef = useRef(false);
useEffect(() => {
if (containerRef.current && !editorRef.current) {
@ -41,6 +40,8 @@ function PureCodeEditor({ content, onSaveContent, status }: EditorProps) {
editorRef.current = null;
}
};
// NOTE: we only want to run this effect once
// eslint-disable-next-line
}, [content]);
useEffect(() => {
@ -58,44 +59,17 @@ function PureCodeEditor({ content, onSaveContent, status }: EditorProps) {
}
});
const scrollListener = EditorView.domEventHandlers({
scroll() {
if (status !== "streaming") {
return;
}
const dom = editorRef.current?.scrollDOM;
if (!dom) {
return;
}
const atBottom =
dom.scrollHeight - dom.scrollTop - dom.clientHeight < 40;
userScrolledRef.current = !atBottom;
},
});
const currentSelection = editorRef.current.state.selection;
const newState = EditorState.create({
doc: editorRef.current.state.doc,
extensions: [
basicSetup,
python(),
oneDark,
updateListener,
scrollListener,
],
extensions: [basicSetup, python(), oneDark, updateListener],
selection: currentSelection,
});
editorRef.current.setState(newState);
}
}, [onSaveContent, status]);
useEffect(() => {
if (status !== "streaming") {
userScrolledRef.current = false;
}
}, [status]);
}, [onSaveContent]);
useEffect(() => {
if (editorRef.current && content) {
@ -112,43 +86,36 @@ function PureCodeEditor({ content, onSaveContent, status }: EditorProps) {
});
editorRef.current.dispatch(transaction);
if (status === "streaming" && !userScrolledRef.current) {
requestAnimationFrame(() => {
const dom = editorRef.current?.scrollDOM;
if (dom) {
dom.scrollTo({ top: dom.scrollHeight });
}
});
}
}
}
}, [content, status]);
return (
<div
className="not-prose relative w-full min-h-[300px] pb-[calc(50dvh)]"
className="not-prose relative w-full pb-[calc(80dvh)] text-sm"
ref={containerRef}
/>
);
}
export const CodeEditor = memo(PureCodeEditor, (prevProps, nextProps) => {
function areEqual(prevProps: EditorProps, nextProps: EditorProps) {
if (prevProps.suggestions !== nextProps.suggestions) {
return false;
}
if (prevProps.currentVersionIndex !== nextProps.currentVersionIndex) {
return false;
}
if (prevProps.isCurrentVersion !== nextProps.isCurrentVersion) {
return false;
}
if (prevProps.status === "streaming" && nextProps.status === "streaming") {
return false;
}
if (prevProps.content !== nextProps.content) {
return false;
}
if (prevProps.status !== nextProps.status) {
return false;
}
if (prevProps.currentVersionIndex !== nextProps.currentVersionIndex) {
return false;
}
return true;
});
}
export const CodeEditor = memo(PureCodeEditor, areEqual);

View file

@ -8,9 +8,9 @@ import {
} from "react";
import { useArtifactSelector } from "@/hooks/use-artifact";
import { cn } from "@/lib/utils";
import { Button } from "../ui/button";
import { Spinner } from "../ui/spinner";
import { CrossSmallIcon, TerminalWindowIcon } from "./icons";
import { Button } from "./ui/button";
import { Spinner } from "./ui/spinner";
export type ConsoleOutputContent = {
type: "text" | "image";
@ -31,6 +31,7 @@ type ConsoleProps = {
export function Console({ consoleOutputs, setConsoleOutputs }: ConsoleProps) {
const [height, setHeight] = useState<number>(300);
const [isResizing, setIsResizing] = useState(false);
const consoleEndRef = useRef<HTMLDivElement>(null);
const isArtifactVisible = useArtifactSelector((state) => state.isVisible);
@ -66,13 +67,9 @@ export function Console({ consoleOutputs, setConsoleOutputs }: ConsoleProps) {
};
}, [resize, stopResizing]);
const consoleContainerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (consoleOutputs.length > 0) {
consoleContainerRef.current?.scrollTo({ top: 0, behavior: "smooth" });
}
}, [consoleOutputs.length]);
consoleEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, []);
useEffect(() => {
if (!isArtifactVisible) {
@ -104,35 +101,38 @@ export function Console({ consoleOutputs, setConsoleOutputs }: ConsoleProps) {
<div
className={cn(
"fixed bottom-0 z-40 flex w-full flex-col overflow-x-hidden overflow-y-auto border-t border-border/50 bg-background",
{ "select-none": isResizing }
"fixed bottom-0 z-40 flex w-full flex-col overflow-x-hidden overflow-y-scroll border-zinc-200 border-t bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-900",
{
"select-none": isResizing,
}
)}
ref={consoleContainerRef}
style={{ height }}
>
<div className="sticky top-0 z-50 flex h-10 w-full items-center justify-between border-b border-border/50 bg-background px-3">
<div className="flex items-center gap-2.5 text-[13px] text-muted-foreground">
<TerminalWindowIcon />
<span>Console</span>
<div className="sticky top-0 z-50 flex h-fit w-full flex-row items-center justify-between border-zinc-200 border-b bg-muted px-2 py-1 dark:border-zinc-700">
<div className="flex flex-row items-center gap-3 pl-2 text-sm text-zinc-800 dark:text-zinc-50">
<div className="text-muted-foreground">
<TerminalWindowIcon />
</div>
<div>Console</div>
</div>
<Button
className="size-7 text-muted-foreground/50 hover:text-foreground"
className="size-fit p-1 hover:bg-zinc-200 dark:hover:bg-zinc-700"
onClick={() => setConsoleOutputs([])}
size="icon-sm"
size="icon"
variant="ghost"
>
<CrossSmallIcon />
</Button>
</div>
<div className="bg-background">
{[...consoleOutputs].reverse().map((consoleOutput, index) => (
<div>
{consoleOutputs.map((consoleOutput, index) => (
<div
className="flex border-b border-border/30 px-4 py-2.5 font-mono text-[12px] leading-relaxed"
className="flex flex-row border-zinc-200 border-b bg-zinc-50 px-4 py-2 font-mono text-sm dark:border-zinc-700 dark:bg-zinc-900"
key={consoleOutput.id}
>
<div
className={cn("w-10 shrink-0 tabular-nums", {
className={cn("w-12 shrink-0", {
"text-muted-foreground": [
"in_progress",
"loading_packages",
@ -141,14 +141,16 @@ export function Console({ consoleOutputs, setConsoleOutputs }: ConsoleProps) {
"text-red-400": consoleOutput.status === "failed",
})}
>
[{consoleOutputs.length - index}]
[{index + 1}]
</div>
{["in_progress", "loading_packages"].includes(
consoleOutput.status
) ? (
<div className="flex items-center gap-2">
<Spinner className="size-3.5" />
<span className="text-muted-foreground">
<div className="flex flex-row gap-2">
<div className="mt-0.5 mb-auto size-fit self-center">
<Spinner className="size-4" />
</div>
<div className="text-muted-foreground">
{consoleOutput.status === "in_progress"
? "Initializing..."
: consoleOutput.status === "loading_packages"
@ -156,25 +158,23 @@ export function Console({ consoleOutputs, setConsoleOutputs }: ConsoleProps) {
content.type === "text" ? content.value : null
)
: null}
</span>
</div>
</div>
) : (
<div className="no-scrollbar flex w-full min-w-0 flex-col gap-2 overflow-x-auto text-foreground">
<div className="flex w-full flex-col gap-2 overflow-x-scroll text-zinc-900 dark:text-zinc-50">
{consoleOutput.contents.map((content) =>
content.type === "image" ? (
<picture
key={`${consoleOutput.id}-img-${content.value.slice(0, 32)}`}
>
<picture key={`${consoleOutput.id}-${content.value}`}>
<img
alt="output"
className="max-w-full rounded-md"
className="w-full max-w-(--breakpoint-toast-mobile) rounded-md"
src={content.value}
/>
</picture>
) : (
<div
className="w-full whitespace-pre-line break-words"
key={`${consoleOutput.id}-txt-${content.value.slice(0, 32)}`}
key={`${consoleOutput.id}-${content.value}`}
>
{content.value}
</div>
@ -184,6 +184,7 @@ export function Console({ consoleOutputs, setConsoleOutputs }: ConsoleProps) {
)}
</div>
))}
<div ref={consoleEndRef} />
</div>
</div>
</>

View file

@ -23,6 +23,7 @@ export function DataStreamHandler() {
setDataStream([]);
for (const delta of newDeltas) {
// Handle chat title updates
if (delta.type === "data-chat-title") {
mutate(unstable_serialize(getChatHistoryPaginationKey));
continue;

View file

@ -11,8 +11,8 @@ import { EditorState } from "prosemirror-state";
import { EditorView } from "prosemirror-view";
import { useEffect, useRef } from "react";
import { renderToString } from "react-dom/server";
import { Streamdown } from "streamdown";
import { MessageResponse } from "@/components/ai-elements/message";
import { DiffType, diffEditor } from "@/lib/editor/diff";
const diffSchema = new Schema({
@ -27,11 +27,11 @@ const diffSchema = new Schema({
switch (mark.attrs.type) {
case DiffType.Inserted:
className =
"bg-emerald-500/15 text-emerald-700 dark:text-emerald-300 rounded-sm px-0.5 -mx-0.5";
"bg-green-100 text-green-700 dark:bg-green-500/70 dark:text-green-300";
break;
case DiffType.Deleted:
className =
"bg-red-500/15 line-through text-red-600 dark:text-red-400 rounded-sm px-0.5 -mx-0.5 opacity-70";
"bg-red-100 line-through text-red-600 dark:bg-red-500/70 dark:text-red-300";
break;
default:
className = "";
@ -60,10 +60,10 @@ export const DiffView = ({ oldContent, newContent }: DiffEditorProps) => {
const parser = DOMParser.fromSchema(diffSchema);
const oldHtmlContent = renderToString(
<MessageResponse>{oldContent}</MessageResponse>
<Streamdown>{oldContent}</Streamdown>
);
const newHtmlContent = renderToString(
<MessageResponse>{newContent}</MessageResponse>
<Streamdown>{newContent}</Streamdown>
);
const oldContainer = document.createElement("div");
@ -86,15 +86,6 @@ export const DiffView = ({ oldContent, newContent }: DiffEditorProps) => {
state,
editable: () => false,
});
requestAnimationFrame(() => {
const firstDiff = editorRef.current?.querySelector(
"[class*='bg-emerald'], [class*='bg-red']"
);
if (firstDiff) {
firstDiff.scrollIntoView({ behavior: "smooth", block: "center" });
}
});
}
return () => {
@ -105,10 +96,5 @@ export const DiffView = ({ oldContent, newContent }: DiffEditorProps) => {
};
}, [oldContent, newContent]);
return (
<div
className="diff-editor prose dark:prose-invert prose-neutral relative max-w-none"
ref={editorRef}
/>
);
return <div className="diff-editor" ref={editorRef} />;
};

View file

@ -15,33 +15,21 @@ import type { Document } from "@/lib/db/schema";
import { cn, fetcher } from "@/lib/utils";
import type { ArtifactKind, UIArtifact } from "./artifact";
import { CodeEditor } from "./code-editor";
import { DocumentToolCall, DocumentToolResult } from "./document";
import { InlineDocumentSkeleton } from "./document-skeleton";
import {
CodeIcon,
FileIcon,
FullscreenIcon,
ImageIcon,
LoaderIcon,
} from "./icons";
import { FileIcon, FullscreenIcon, ImageIcon, LoaderIcon } from "./icons";
import { ImageEditor } from "./image-editor";
import { SpreadsheetEditor } from "./sheet-editor";
import { Editor } from "./text-editor";
type DocumentToolOutput = {
id: string;
title: string;
kind: ArtifactKind;
content?: string;
};
type DocumentPreviewProps = {
isReadonly: boolean;
result?: Partial<DocumentToolOutput>;
args?: Partial<DocumentToolOutput> & { isUpdate?: boolean };
result?: any;
args?: any;
};
export function DocumentPreview({
isReadonly: _isReadonly,
isReadonly,
result,
args,
}: DocumentPreviewProps) {
@ -49,12 +37,7 @@ export function DocumentPreview({
const { data: documents, isLoading: isDocumentsFetching } = useSWR<
Document[]
>(
result
? `${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/document?id=${result.id}`
: null,
fetcher
);
>(result ? `/api/document?id=${result.id}` : null, fetcher);
const previewDocument = useMemo(() => documents?.[0], [documents]);
const hitboxRef = useRef<HTMLDivElement>(null);
@ -75,28 +58,30 @@ export function DocumentPreview({
}
}, [artifact.documentId, setArtifact]);
if (isDocumentsFetching) {
const kind = result?.kind ?? args?.kind ?? artifact.kind;
const title = result?.title ?? args?.title ?? artifact.title;
if (artifact.isVisible) {
if (result) {
return (
<DocumentToolResult
isReadonly={isReadonly}
result={{ id: result.id, title: result.title, kind: result.kind }}
type="create"
/>
);
}
return (
<div className="w-full max-w-[450px]">
{title ? (
<DocumentHeader isStreaming={true} kind={kind} title={title} />
) : (
<div className="flex flex-row items-center justify-between gap-2 rounded-t-2xl border border-b-0 border-border/50 px-4 py-3 dark:bg-muted">
<div className="flex flex-row items-center gap-2.5">
<div className="size-3.5 animate-pulse rounded bg-muted-foreground/15" />
<div className="h-3.5 w-24 animate-pulse rounded bg-muted-foreground/15" />
</div>
<div className="w-8" />
</div>
)}
<div className="h-[257px] overflow-hidden rounded-b-2xl border border-t-0 border-border/50 bg-muted p-6">
<InlineDocumentSkeleton />
</div>
</div>
);
if (args) {
return (
<DocumentToolCall
args={{ title: args.title, kind: args.kind }}
isReadonly={isReadonly}
type="create"
/>
);
}
}
if (isDocumentsFetching) {
return <LoadingSkeleton artifactKind={result.kind ?? args.kind} />;
}
const document: Document | null = previewDocument
@ -135,19 +120,23 @@ export function DocumentPreview({
const LoadingSkeleton = ({ artifactKind }: { artifactKind: ArtifactKind }) => (
<div className="w-full max-w-[450px]">
<div className="flex flex-row items-center justify-between gap-2 rounded-t-2xl border border-b-0 border-border/50 px-4 py-3 dark:bg-muted">
<div className="flex flex-row items-center gap-2.5">
<div className="size-3.5 animate-pulse rounded bg-muted-foreground/15" />
<div className="h-3.5 w-24 animate-pulse rounded bg-muted-foreground/15" />
<div className="flex h-[57px] flex-row items-center justify-between gap-2 rounded-t-2xl border border-b-0 p-4 dark:border-zinc-700 dark:bg-muted">
<div className="flex flex-row items-center gap-3">
<div className="text-muted-foreground">
<div className="size-4 animate-pulse rounded-md bg-muted-foreground/20" />
</div>
<div className="h-4 w-24 animate-pulse rounded-lg bg-muted-foreground/20" />
</div>
<div>
<FullscreenIcon />
</div>
<div className="w-8" />
</div>
{artifactKind === "image" ? (
<div className="overflow-hidden rounded-b-2xl border border-t-0 border-border/50 bg-muted">
<div className="h-[257px] w-full animate-pulse bg-muted-foreground/10" />
<div className="overflow-y-scroll rounded-b-2xl border border-t-0 bg-muted dark:border-zinc-700">
<div className="h-[257px] w-full animate-pulse bg-muted-foreground/20" />
</div>
) : (
<div className="h-[257px] overflow-hidden rounded-b-2xl border border-t-0 border-border/50 bg-muted p-6">
<div className="overflow-y-scroll rounded-b-2xl border border-t-0 bg-muted p-8 pt-4 dark:border-zinc-700">
<InlineDocumentSkeleton />
</div>
)}
@ -160,7 +149,7 @@ const PureHitboxLayer = ({
setArtifact,
}: {
hitboxRef: React.RefObject<HTMLDivElement>;
result?: Partial<DocumentToolOutput>;
result: any;
setArtifact: (
updaterFn: UIArtifact | ((currentArtifact: UIArtifact) => UIArtifact)
) => void;
@ -169,19 +158,23 @@ const PureHitboxLayer = ({
(event: MouseEvent<HTMLElement>) => {
const boundingBox = event.currentTarget.getBoundingClientRect();
setArtifact((artifact) => ({
...artifact,
...(result?.id && { documentId: result.id }),
...(result?.title && { title: result.title }),
...(result?.kind && { kind: result.kind }),
isVisible: true,
boundingBox: {
left: boundingBox.x,
top: boundingBox.y,
width: boundingBox.width,
height: boundingBox.height,
},
}));
setArtifact((artifact) =>
artifact.status === "streaming"
? { ...artifact, isVisible: true }
: {
...artifact,
title: result.title,
documentId: result.id,
kind: result.kind,
isVisible: true,
boundingBox: {
left: boundingBox.x,
top: boundingBox.y,
width: boundingBox.width,
height: boundingBox.height,
},
}
);
},
[setArtifact, result]
);
@ -195,7 +188,7 @@ const PureHitboxLayer = ({
role="presentation"
>
<div className="flex w-full items-center justify-end p-4">
<div className="absolute top-[13px] right-[9px] rounded-lg p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground">
<div className="absolute top-[13px] right-[9px] rounded-md p-2 hover:bg-zinc-100 dark:hover:bg-zinc-700">
<FullscreenIcon />
</div>
</div>
@ -219,22 +212,20 @@ const PureDocumentHeader = ({
kind: ArtifactKind;
isStreaming: boolean;
}) => (
<div className="flex flex-row items-center justify-between gap-2 rounded-t-2xl border border-b-0 border-border/50 px-4 py-3 dark:bg-muted">
<div className="flex flex-row items-center gap-2.5">
<div className="flex flex-row items-start justify-between gap-2 rounded-t-2xl border border-b-0 p-4 sm:items-center dark:border-zinc-700 dark:bg-muted">
<div className="flex flex-row items-start gap-3 sm:items-center">
<div className="text-muted-foreground">
{isStreaming ? (
<div className="animate-spin">
<LoaderIcon size={14} />
<LoaderIcon />
</div>
) : kind === "image" ? (
<ImageIcon size={14} />
) : kind === "code" ? (
<CodeIcon size={14} />
<ImageIcon />
) : (
<FileIcon size={14} />
<FileIcon />
)}
</div>
<div className="text-sm font-medium">{title}</div>
<div className="-translate-y-1 font-medium sm:translate-y-0">{title}</div>
</div>
<div className="w-8" />
</div>
@ -255,9 +246,9 @@ const DocumentContent = ({ document }: { document: Document }) => {
const { artifact } = useArtifact();
const containerClassName = cn(
"h-[257px] overflow-hidden rounded-b-2xl border border-t-0 border-border/50 dark:bg-muted",
"h-[257px] overflow-y-scroll rounded-b-2xl border border-t-0 dark:border-zinc-700 dark:bg-muted",
{
"p-4 sm:px-10 sm:py-10": document.kind === "text",
"p-4 sm:px-14 sm:py-16": document.kind === "text",
"p-0": document.kind === "code",
}
);
@ -274,7 +265,7 @@ const DocumentContent = ({ document }: { document: Document }) => {
const handleSaveContent = () => null;
return (
<div className={cn(containerClassName, "relative")}>
<div className={containerClassName}>
{document.kind === "text" ? (
<Editor {...commonProps} onSaveContent={handleSaveContent} />
) : document.kind === "code" ? (
@ -299,10 +290,6 @@ const DocumentContent = ({ document }: { document: Document }) => {
title={document.title}
/>
) : null}
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-16 bg-gradient-to-t from-muted to-transparent dark:from-muted" />
{document.kind === "code" && (
<div className="pointer-events-none absolute inset-y-0 right-0 w-12 bg-gradient-to-l from-muted to-transparent dark:from-muted" />
)}
</div>
);
};

View file

@ -0,0 +1,39 @@
"use client";
import type { ArtifactKind } from "./artifact";
export const DocumentSkeleton = ({
artifactKind,
}: {
artifactKind: ArtifactKind;
}) => {
return artifactKind === "image" ? (
<div className="flex h-[calc(100dvh-60px)] w-full flex-col items-center justify-center gap-4">
<div className="size-96 animate-pulse rounded-lg bg-muted-foreground/20" />
</div>
) : (
<div className="flex w-full flex-col gap-4">
<div className="h-12 w-1/2 animate-pulse rounded-lg bg-muted-foreground/20" />
<div className="h-5 w-full animate-pulse rounded-lg bg-muted-foreground/20" />
<div className="h-5 w-full animate-pulse rounded-lg bg-muted-foreground/20" />
<div className="h-5 w-1/3 animate-pulse rounded-lg bg-muted-foreground/20" />
<div className="h-5 w-52 animate-pulse rounded-lg bg-transparent" />
<div className="h-8 w-52 animate-pulse rounded-lg bg-muted-foreground/20" />
<div className="h-5 w-2/3 animate-pulse rounded-lg bg-muted-foreground/20" />
</div>
);
};
export const InlineDocumentSkeleton = () => {
return (
<div className="flex w-full flex-col gap-4">
<div className="h-4 w-48 animate-pulse rounded-lg bg-muted-foreground/20" />
<div className="h-4 w-3/4 animate-pulse rounded-lg bg-muted-foreground/20" />
<div className="h-4 w-1/2 animate-pulse rounded-lg bg-muted-foreground/20" />
<div className="h-4 w-64 animate-pulse rounded-lg bg-muted-foreground/20" />
<div className="h-4 w-40 animate-pulse rounded-lg bg-muted-foreground/20" />
<div className="h-4 w-36 animate-pulse rounded-lg bg-muted-foreground/20" />
<div className="h-4 w-64 animate-pulse rounded-lg bg-muted-foreground/20" />
</div>
);
};

View file

@ -37,7 +37,7 @@ function PureDocumentToolResult({
return (
<button
className="flex w-fit cursor-pointer flex-row items-center gap-2 rounded-xl border bg-background px-3 py-2"
className="flex w-fit cursor-pointer flex-row items-start gap-3 rounded-xl border bg-background px-3 py-2"
onClick={(event) => {
if (isReadonly) {
toast.error(
@ -67,7 +67,7 @@ function PureDocumentToolResult({
}}
type="button"
>
<div className="text-muted-foreground">
<div className="mt-1 text-muted-foreground">
{type === "create" ? (
<FileIcon />
) : type === "update" ? (
@ -88,9 +88,9 @@ export const DocumentToolResult = memo(PureDocumentToolResult, () => true);
type DocumentToolCallProps = {
type: "create" | "update" | "request-suggestions";
args:
| { title: string; kind: ArtifactKind }
| { id: string; description: string }
| { documentId: string };
| { title: string; kind: ArtifactKind } // for create
| { id: string; description: string } // for update
| { documentId: string }; // for request-suggestions
isReadonly: boolean;
};
@ -130,7 +130,7 @@ function PureDocumentToolCall({
type="button"
>
<div className="flex flex-row items-start gap-3">
<div className="mt-1 text-neutral-500">
<div className="mt-1 text-zinc-500">
{type === "create" ? (
<FileIcon />
) : type === "update" ? (

29
components/greeting.tsx Normal file
View file

@ -0,0 +1,29 @@
import { motion } from "framer-motion";
export const Greeting = () => {
return (
<div
className="mx-auto mt-4 flex size-full max-w-3xl flex-col justify-center px-4 md:mt-16 md:px-8"
key="overview"
>
<motion.div
animate={{ opacity: 1, y: 0 }}
className="font-semibold text-xl md:text-2xl"
exit={{ opacity: 0, y: 10 }}
initial={{ opacity: 0, y: 10 }}
transition={{ delay: 0.5 }}
>
Hello there!
</motion.div>
<motion.div
animate={{ opacity: 1, y: 0 }}
className="text-xl text-zinc-500 md:text-2xl"
exit={{ opacity: 0, y: 10 }}
initial={{ opacity: 0, y: 10 }}
transition={{ delay: 0.6 }}
>
How can I help you today?
</motion.div>
</div>
);
};

View file

@ -8,7 +8,7 @@ import type { ChatMessage } from "@/lib/types";
import {
MessageAction as Action,
MessageActions as Actions,
} from "../ai-elements/message";
} from "./ai-elements/message";
import { CopyIcon, PencilEditIcon, ThumbDownIcon, ThumbUpIcon } from "./icons";
export function PureMessageActions({
@ -16,13 +16,13 @@ export function PureMessageActions({
message,
vote,
isLoading,
onEdit,
setMode,
}: {
chatId: string;
message: ChatMessage;
vote: Vote | undefined;
isLoading: boolean;
onEdit?: () => void;
setMode?: (mode: "view" | "edit") => void;
}) {
const { mutate } = useSWRConfig();
const [_, copyToClipboard] = useCopyToClipboard();
@ -47,25 +47,22 @@ export function PureMessageActions({
toast.success("Copied to clipboard!");
};
// User messages get edit (on hover) and copy actions
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 && (
<Actions className="-mr-0.5 justify-end">
<div className="relative">
{setMode && (
<Action
className="size-7 text-muted-foreground/50 hover:text-foreground"
className="absolute top-0 -left-10 opacity-0 transition-opacity focus-visible:opacity-100 group-hover/message:opacity-100"
data-testid="message-edit-button"
onClick={onEdit}
onClick={() => setMode("edit")}
tooltip="Edit"
>
<PencilEditIcon />
</Action>
)}
<Action
className="size-7 text-muted-foreground/50 hover:text-foreground"
onClick={handleCopy}
tooltip="Copy"
>
<Action onClick={handleCopy} tooltip="Copy">
<CopyIcon />
</Action>
</div>
@ -74,17 +71,12 @@ export function PureMessageActions({
}
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"
>
<Actions className="-ml-0.5">
<Action onClick={handleCopy} tooltip="Copy">
<CopyIcon />
</Action>
<Action
className="text-muted-foreground/50 hover:text-foreground"
data-testid="message-upvote"
disabled={vote?.isUpvoted}
onClick={() => {
@ -137,7 +129,6 @@ export function PureMessageActions({
</Action>
<Action
className="text-muted-foreground/50 hover:text-foreground"
data-testid="message-downvote"
disabled={vote && !vote.isUpvoted}
onClick={() => {

View file

@ -0,0 +1,112 @@
"use client";
import type { UseChatHelpers } from "@ai-sdk/react";
import {
type Dispatch,
type SetStateAction,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import { deleteTrailingMessages } from "@/app/(chat)/actions";
import type { ChatMessage } from "@/lib/types";
import { getTextFromMessage } from "@/lib/utils";
import { Button } from "./ui/button";
import { Textarea } from "./ui/textarea";
export type MessageEditorProps = {
message: ChatMessage;
setMode: Dispatch<SetStateAction<"view" | "edit">>;
setMessages: UseChatHelpers<ChatMessage>["setMessages"];
regenerate: UseChatHelpers<ChatMessage>["regenerate"];
};
export function MessageEditor({
message,
setMode,
setMessages,
regenerate,
}: MessageEditorProps) {
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
const [draftContent, setDraftContent] = useState<string>(
getTextFromMessage(message)
);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const adjustHeight = useCallback(() => {
if (textareaRef.current) {
textareaRef.current.style.height = "auto";
textareaRef.current.style.height = `${textareaRef.current.scrollHeight + 2}px`;
}
}, []);
useEffect(() => {
if (textareaRef.current) {
adjustHeight();
}
}, [adjustHeight]);
const handleInput = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
setDraftContent(event.target.value);
adjustHeight();
};
return (
<div className="flex w-full flex-col gap-2">
<Textarea
className="w-full resize-none overflow-hidden rounded-xl bg-transparent text-base! outline-hidden"
data-testid="message-editor"
onChange={handleInput}
ref={textareaRef}
value={draftContent}
/>
<div className="flex flex-row justify-end gap-2">
<Button
className="h-fit px-3 py-2"
onClick={() => {
setMode("view");
}}
variant="outline"
>
Cancel
</Button>
<Button
className="h-fit px-3 py-2"
data-testid="message-editor-send-button"
disabled={isSubmitting}
onClick={async () => {
setIsSubmitting(true);
await deleteTrailingMessages({
id: message.id,
});
setMessages((messages) => {
const index = messages.findIndex((m) => m.id === message.id);
if (index !== -1) {
const updatedMessage: ChatMessage = {
...message,
parts: [{ type: "text", text: draftContent }],
};
return [...messages.slice(0, index), updatedMessage];
}
return messages;
});
setMode("view");
regenerate();
}}
variant="default"
>
{isSubmitting ? "Sending..." : "Send"}
</Button>
</div>
</div>
);
}

View file

@ -5,7 +5,7 @@ import {
Reasoning,
ReasoningContent,
ReasoningTrigger,
} from "../ai-elements/reasoning";
} from "./ai-elements/reasoning";
type MessageReasoningProps = {
isLoading: boolean;

395
components/message.tsx Normal file
View file

@ -0,0 +1,395 @@
"use client";
import type { UseChatHelpers } from "@ai-sdk/react";
import { useState } from "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 {
Tool,
ToolContent,
ToolHeader,
ToolInput,
ToolOutput,
} from "./ai-elements/tool";
import { useDataStream } from "./data-stream-provider";
import { DocumentToolResult } from "./document";
import { DocumentPreview } from "./document-preview";
import { SparklesIcon } from "./icons";
import { MessageActions } from "./message-actions";
import { MessageEditor } from "./message-editor";
import { MessageReasoning } from "./message-reasoning";
import { PreviewAttachment } from "./preview-attachment";
import { Weather } from "./weather";
const PurePreviewMessage = ({
addToolApprovalResponse,
chatId,
message,
vote,
isLoading,
setMessages,
regenerate,
isReadonly,
requiresScrollPadding: _requiresScrollPadding,
}: {
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;
}) => {
const [mode, setMode] = useState<"view" | "edit">("view");
const attachmentsFromMessage = message.parts.filter(
(part) => part.type === "file"
);
useDataStream();
return (
<div
className="group/message fade-in w-full animate-in duration-200"
data-role={message.role}
data-testid={`message-${message.role}`}
>
<div
className={cn("flex w-full items-start gap-2 md:gap-3", {
"justify-end": message.role === "user" && mode !== "edit",
"justify-start": message.role === "assistant",
})}
>
{message.role === "assistant" && (
<div className="-mt-1 flex size-8 shrink-0 items-center justify-center rounded-full bg-background ring-1 ring-border">
<SparklesIcon size={14} />
</div>
)}
<div
className={cn("flex flex-col", {
"gap-2 md:gap-4": message.parts?.some(
(p) => p.type === "text" && p.text?.trim()
),
"w-full":
(message.role === "assistant" &&
(message.parts?.some(
(p) => p.type === "text" && p.text?.trim()
) ||
message.parts?.some((p) => p.type.startsWith("tool-")))) ||
mode === "edit",
"max-w-[calc(100%-2.5rem)] sm:max-w-[min(fit-content,80%)]":
message.role === "user" && mode !== "edit",
})}
>
{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>
)}
{message.parts?.map((part, index) => {
const { type } = part;
const key = `message-${message.id}-part-${index}`;
if (type === "reasoning") {
const hasContent = part.text?.trim().length > 0;
if (hasContent) {
const isStreaming =
"state" in part && part.state === "streaming";
return (
<MessageReasoning
isLoading={isLoading || isStreaming}
key={key}
reasoning={part.text}
/>
);
}
}
if (type === "text") {
if (mode === "view") {
return (
<div key={key}>
<MessageContent
className={cn({
"wrap-break-word w-fit rounded-2xl px-3 py-2 text-right text-white":
message.role === "user",
"bg-transparent px-0 py-0 text-left":
message.role === "assistant",
})}
data-testid="message-content"
style={
message.role === "user"
? { backgroundColor: "#006cff" }
: undefined
}
>
<MessageResponse>
{sanitizeText(part.text)}
</MessageResponse>
</MessageContent>
</div>
);
}
if (mode === "edit") {
return (
<div
className="flex w-full flex-row items-start gap-3"
key={key}
>
<div className="size-8" />
<div className="min-w-0 flex-1">
<MessageEditor
key={message.id}
message={message}
regenerate={regenerate}
setMessages={setMessages}
setMode={setMode}
/>
</div>
</div>
);
}
}
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>
);
}
if (type === "tool-createDocument") {
const { toolCallId } = part;
if (part.output && "error" in part.output) {
return (
<div
className="rounded-lg border border-red-200 bg-red-50 p-4 text-red-500 dark:bg-red-950/50"
key={toolCallId}
>
Error creating document: {String(part.output.error)}
</div>
);
}
return (
<DocumentPreview
isReadonly={isReadonly}
key={toolCallId}
result={part.output}
/>
);
}
if (type === "tool-updateDocument") {
const { toolCallId } = part;
if (part.output && "error" in part.output) {
return (
<div
className="rounded-lg border border-red-200 bg-red-50 p-4 text-red-500 dark:bg-red-950/50"
key={toolCallId}
>
Error updating document: {String(part.output.error)}
</div>
);
}
return (
<div className="relative" key={toolCallId}>
<DocumentPreview
args={{ ...part.output, isUpdate: true }}
isReadonly={isReadonly}
result={part.output}
/>
</div>
);
}
if (type === "tool-requestSuggestions") {
const { toolCallId, state } = part;
return (
<Tool defaultOpen={true} key={toolCallId}>
<ToolHeader state={state} type="tool-requestSuggestions" />
<ToolContent>
{state === "input-available" && (
<ToolInput input={part.input} />
)}
{state === "output-available" && (
<ToolOutput
errorText={undefined}
output={
"error" in part.output ? (
<div className="rounded border p-2 text-red-500">
Error: {String(part.output.error)}
</div>
) : (
<DocumentToolResult
isReadonly={isReadonly}
result={part.output}
type="request-suggestions"
/>
)
}
/>
)}
</ToolContent>
</Tool>
);
}
return null;
})}
{!isReadonly && (
<MessageActions
chatId={chatId}
isLoading={isLoading}
key={`action-${message.id}`}
message={message}
setMode={setMode}
vote={vote}
/>
)}
</div>
</div>
</div>
);
};
export const PreviewMessage = PurePreviewMessage;
export const ThinkingMessage = () => {
return (
<div
className="group/message fade-in w-full animate-in duration-300"
data-role="assistant"
data-testid="message-assistant-loading"
>
<div className="flex items-start justify-start gap-3">
<div className="-mt-1 flex size-8 shrink-0 items-center justify-center rounded-full bg-background ring-1 ring-border">
<div className="animate-pulse">
<SparklesIcon size={14} />
</div>
</div>
<div className="flex w-full flex-col gap-2 md:gap-4">
<div className="flex items-center gap-1 p-0 text-muted-foreground text-sm">
<span className="animate-pulse">Thinking</span>
<span className="inline-flex">
<span className="animate-bounce [animation-delay:0ms]">.</span>
<span className="animate-bounce [animation-delay:150ms]">.</span>
<span className="animate-bounce [animation-delay:300ms]">.</span>
</span>
</div>
</div>
</div>
</div>
);
};

View file

@ -1,10 +1,8 @@
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";
@ -19,9 +17,7 @@ type MessagesProps = {
regenerate: UseChatHelpers<ChatMessage>["regenerate"];
isReadonly: boolean;
isArtifactVisible: boolean;
isLoading?: boolean;
selectedModelId: string;
onEditMessage?: (message: ChatMessage) => void;
};
function PureMessages({
@ -33,10 +29,7 @@ function PureMessages({
setMessages,
regenerate,
isReadonly,
isArtifactVisible,
isLoading,
selectedModelId: _selectedModelId,
onEditMessage,
}: MessagesProps) {
const {
containerRef: messagesContainerRef,
@ -44,37 +37,21 @@ function PureMessages({
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"
)}
className="absolute inset-0 touch-pan-y overflow-y-auto bg-background"
ref={messagesContainerRef}
style={isArtifactVisible ? { scrollbarWidth: "none" } : undefined}
>
<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">
<div className="mx-auto flex min-w-0 max-w-4xl flex-col gap-4 px-2 py-4 md:gap-6 md:px-4">
{messages.length === 0 && <Greeting />}
{messages.map((message, index) => (
<PreviewMessage
addToolApprovalResponse={addToolApprovalResponse}
@ -85,7 +62,6 @@ function PureMessages({
isReadonly={isReadonly}
key={message.id}
message={message}
onEdit={onEditMessage}
regenerate={regenerate}
requiresScrollPadding={
hasSentMessage && index === messages.length - 1
@ -99,9 +75,12 @@ function PureMessages({
/>
))}
{status === "submitted" && messages.at(-1)?.role !== "assistant" && (
<ThinkingMessage />
)}
{status === "submitted" &&
!messages.some((msg) =>
msg.parts?.some(
(part) => "state" in part && part.state === "approval-responded"
)
) && <ThinkingMessage />}
<div
className="min-h-[24px] min-w-[24px] shrink-0"
@ -112,15 +91,15 @@ function PureMessages({
<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] ${
className={`absolute bottom-4 left-1/2 z-10 -translate-x-1/2 rounded-full border bg-background p-2 shadow-lg transition-all hover:bg-muted ${
isAtBottom
? "pointer-events-none scale-90 opacity-0"
? "pointer-events-none scale-0 opacity-0"
: "pointer-events-auto scale-100 opacity-100"
}`}
onClick={() => scrollToBottom("smooth")}
type="button"
>
<ArrowDownIcon className="size-3 text-muted-foreground" />
<ArrowDownIcon className="size-4" />
</button>
</div>
);

View file

@ -3,15 +3,7 @@
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 { ArrowUpIcon, CheckIcon } from "lucide-react";
import {
type ChangeEvent,
type Dispatch,
@ -23,7 +15,6 @@ import {
useState,
} from "react";
import { toast } from "sonner";
import useSWR from "swr";
import { useLocalStorage, useWindowSize } from "usehooks-ts";
import {
ModelSelector,
@ -37,11 +28,11 @@ import {
ModelSelectorTrigger,
} from "@/components/ai-elements/model-selector";
import {
type ChatModel,
chatModels,
DEFAULT_CHAT_MODEL,
type ModelCapabilities,
modelsByProvider,
} from "@/lib/ai/models";
import { signIn, useSession } from "@/lib/client";
import type { Attachment, ChatMessage } from "@/lib/types";
import { cn } from "@/lib/utils";
import {
@ -50,20 +41,15 @@ import {
PromptInputSubmit,
PromptInputTextarea,
PromptInputTools,
} from "../ai-elements/prompt-input";
import { Button } from "../ui/button";
} from "./ai-elements/prompt-input";
import { PaperclipIcon, StopIcon } from "./icons";
import { PreviewAttachment } from "./preview-attachment";
import {
type SlashCommand,
SlashCommandMenu,
slashCommands,
} from "./slash-commands";
import { SuggestedActions } from "./suggested-actions";
import { Button } from "./ui/button";
import type { VisibilityType } from "./visibility-selector";
function setCookie(name: string, value: string) {
const maxAge = 60 * 60 * 24 * 365;
const maxAge = 60 * 60 * 24 * 365; // 1 year
// biome-ignore lint/suspicious/noDocumentCookie: needed for client-side cookie setting
document.cookie = `${name}=${encodeURIComponent(value)}; path=/; max-age=${maxAge}`;
}
@ -83,9 +69,6 @@ function PureMultimodalInput({
selectedVisibilityType,
selectedModelId,
onModelChange,
editingMessage,
onCancelEdit,
isLoading,
}: {
chatId: string;
input: string;
@ -96,21 +79,17 @@ function PureMultimodalInput({
setAttachments: Dispatch<SetStateAction<Attachment[]>>;
messages: UIMessage[];
setMessages: UseChatHelpers<ChatMessage>["setMessages"];
sendMessage:
| UseChatHelpers<ChatMessage>["sendMessage"]
| (() => Promise<void>);
sendMessage: UseChatHelpers<ChatMessage>["sendMessage"];
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 { data: session, refetch: refetchSession } = useSession();
const [isSigningIn, setIsSigningIn] = useState(false);
const hasAutoFocused = useRef(false);
useEffect(() => {
if (!hasAutoFocused.current && width) {
@ -130,9 +109,12 @@ function PureMultimodalInput({
useEffect(() => {
if (textareaRef.current) {
const domValue = textareaRef.current.value;
// Prefer DOM value over localStorage to handle hydration
const finalValue = domValue || localStorageInput || "";
setInput(finalValue);
}
// Only run once after hydration
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [localStorageInput, setInput]);
useEffect(() => {
@ -140,80 +122,11 @@ function PureMultimodalInput({
}, [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;
}
setInput(event.target.value);
};
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(
@ -303,8 +216,8 @@ function PureMultimodalInput({
...currentAttachments,
...successfullyUploadedAttachments,
]);
} catch (_error) {
toast.error("Failed to upload files");
} catch (error) {
console.error("Error uploading files!", error);
} finally {
setUploadQueue([]);
}
@ -327,6 +240,7 @@ function PureMultimodalInput({
return;
}
// Prevent default paste behavior for images
event.preventDefault();
setUploadQueue((prev) => [...prev, "Pasted image"]);
@ -349,7 +263,8 @@ function PureMultimodalInput({
...curr,
...(successfullyUploadedAttachments as Attachment[]),
]);
} catch (_error) {
} catch (error) {
console.error("Error uploading pasted images:", error);
toast.error("Failed to upload pasted image(s)");
} finally {
setUploadQueue([]);
@ -358,6 +273,7 @@ function PureMultimodalInput({
[setAttachments, uploadFile]
);
// Add paste event listener to textarea
useEffect(() => {
const textarea = textareaRef.current;
if (!textarea) {
@ -370,25 +286,7 @@ function PureMultimodalInput({
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 &&
{messages.length === 0 &&
attachments.length === 0 &&
uploadQueue.length === 0 && (
<SuggestedActions
@ -407,32 +305,24 @@ function PureMultimodalInput({
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;
}
className="[&>div]:rounded-xl"
onSubmit={async () => {
if (!input.trim() && attachments.length === 0) {
return;
}
if (status === "ready" || status === "error") {
if (!session && !isSigningIn) {
setIsSigningIn(true);
const { error } = await signIn.anonymous();
if (error) {
toast.error("Failed to create session, please try again!");
setIsSigningIn(false);
return;
}
await refetchSession();
setIsSigningIn(false);
}
if (status === "ready") {
submitForm();
} else {
toast.error("Please wait for the model to finish its response!");
@ -441,7 +331,7 @@ function PureMultimodalInput({
>
{(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"
className="flex flex-row items-end gap-2 overflow-x-scroll"
data-testid="attachments-preview"
>
{attachments.map((attachment) => (
@ -473,49 +363,14 @@ function PureMultimodalInput({
</div>
)}
<PromptInputTextarea
className="min-h-24 text-[13px] leading-relaxed px-4 pt-3.5 pb-1.5 placeholder:text-muted-foreground/35"
className="p-6 min-h-24"
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..."
}
placeholder="Send a message..."
ref={textareaRef}
value={input}
/>
<PromptInputFooter className="px-3 pb-3">
<PromptInputFooter>
<PromptInputTools>
<AttachmentsButton
fileInputRef={fileInputRef}
@ -532,12 +387,7 @@ function PureMultimodalInput({
<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"
)}
className="rounded-full"
data-testid="send-button"
disabled={!input.trim() || uploadQueue.length > 0}
status={status}
@ -570,15 +420,6 @@ export const MultimodalInput = memo(
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;
}
@ -593,26 +434,14 @@ function PureAttachmentsButton({
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;
const isReasoningModel =
selectedModelId.includes("reasoning") || selectedModelId.includes("think");
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"
)}
className="aspect-square h-8 rounded-lg p-1 transition-colors hover:bg-accent"
data-testid="attachments-button"
disabled={status !== "ready" || !hasVision}
disabled={status !== "ready" || isReasoningModel}
onClick={(event) => {
event.preventDefault();
fileInputRef.current?.click();
@ -634,31 +463,26 @@ function PureModelSelectorCompact({
onModelChange?: (modelId: string) => void;
}) {
const [open, setOpen] = useState(false);
const { data: modelsData } = useSWR(
`${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/models`,
(url: string) => fetch(url).then((r) => r.json()),
{ revalidateOnFocus: false, dedupingInterval: 3_600_000 }
);
const capabilities: Record<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];
chatModels.find((m) => m.id === selectedModelId) ??
chatModels.find((m) => m.id === DEFAULT_CHAT_MODEL) ??
chatModels[0];
const [provider] = selectedModel.id.split("/");
// Provider display names
const providerNames: Record<string, string> = {
anthropic: "Anthropic",
openai: "OpenAI",
google: "Google",
xai: "xAI",
reasoning: "Reasoning",
};
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"
>
<Button className="h-8 w-[200px] justify-between px-2" variant="ghost">
{provider && <ModelSelectorLogo provider={provider} />}
<ModelSelectorName>{selectedModel.name}</ModelSelectorName>
</Button>
@ -666,123 +490,35 @@ function PureModelSelectorCompact({
<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) => (
{Object.entries(modelsByProvider).map(
([providerKey, providerModels]) => (
<ModelSelectorGroup
heading={
key === "_available"
? "Available"
: (providerNames[key] ?? key)
}
key={key}
heading={providerNames[providerKey] ?? providerKey}
key={providerKey}
>
{grouped[key].map(({ model, curated }) => {
{providerModels.map((model) => {
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>
{model.id === selectedModel.id && (
<CheckIcon className="ml-auto size-4" />
)}
</ModelSelectorItem>
);
})}
</ModelSelectorGroup>
));
})()}
)
)}
</ModelSelectorList>
</ModelSelectorContent>
</ModelSelector>
@ -800,7 +536,7 @@ function PureStopButton({
}) {
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"
className="size-7 rounded-full bg-foreground p-1 text-background transition-colors duration-200 hover:bg-foreground/90 disabled:bg-muted disabled:text-muted-foreground"
data-testid="stop-button"
onClick={(event) => {
event.preventDefault();

View file

@ -1,7 +1,8 @@
import Image from "next/image";
import type { Attachment } from "@/lib/types";
import { Spinner } from "../ui/spinner";
import { CrossSmallIcon } from "./icons";
import { Button } from "./ui/button";
import { Spinner } from "./ui/spinner";
export const PreviewAttachment = ({
attachment,
@ -16,16 +17,16 @@ export const PreviewAttachment = ({
return (
<div
className="group relative h-24 w-24 shrink-0 overflow-hidden rounded-xl border border-border/40 bg-muted"
className="group relative size-16 overflow-hidden rounded-lg border bg-muted"
data-testid="input-attachment-preview"
>
{contentType?.startsWith("image") ? (
<Image
alt={name ?? "attachment"}
alt={name ?? "An image attachment"}
className="size-full object-cover"
height={96}
height={64}
src={url}
width={96}
width={64}
/>
) : (
<div className="flex size-full items-center justify-center text-muted-foreground text-xs">
@ -35,22 +36,27 @@ export const PreviewAttachment = ({
{isUploading && (
<div
className="absolute inset-0 flex items-center justify-center rounded-xl bg-black/40 backdrop-blur-sm"
className="absolute inset-0 flex items-center justify-center bg-black/50"
data-testid="input-attachment-loader"
>
<Spinner className="size-5" />
<Spinner className="size-4" />
</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"
<Button
className="absolute top-0.5 right-0.5 size-4 rounded-full p-0 opacity-0 transition-opacity group-hover:opacity-100"
onClick={onRemove}
type="button"
size="sm"
variant="destructive"
>
<CrossSmallIcon size={10} />
</button>
<CrossSmallIcon size={8} />
</Button>
)}
<div className="absolute inset-x-0 bottom-0 truncate bg-linear-to-t from-black/80 to-transparent px-1 py-0.5 text-[10px] text-white">
{name}
</div>
</div>
);
};

View file

@ -50,9 +50,8 @@ const PureSpreadsheetEditor = ({ content, saveContent }: SheetEditorProps) => {
frozen: true,
width: 50,
renderCell: ({ rowIdx }: { rowIdx: number }) => rowIdx + 1,
cellClass: "border-t border-r dark:bg-neutral-950 dark:text-neutral-50",
headerCellClass:
"border-t border-r dark:bg-neutral-900 dark:text-neutral-50",
cellClass: "border-t border-r dark:bg-zinc-950 dark:text-zinc-50",
headerCellClass: "border-t border-r dark:bg-zinc-900 dark:text-zinc-50",
};
const dataColumns = Array.from({ length: MIN_COLS }, (_, i) => ({
@ -60,10 +59,10 @@ const PureSpreadsheetEditor = ({ content, saveContent }: SheetEditorProps) => {
name: String.fromCharCode(65 + i),
renderEditCell: textEditor,
width: 120,
cellClass: cn("border-t dark:bg-neutral-950 dark:text-neutral-50", {
cellClass: cn("border-t dark:bg-zinc-950 dark:text-zinc-50", {
"border-l": i !== 0,
}),
headerCellClass: cn("border-t dark:bg-neutral-900 dark:text-neutral-50", {
headerCellClass: cn("border-t dark:bg-zinc-900 dark:text-zinc-50", {
"border-l": i !== 0,
}),
}));
@ -73,7 +72,7 @@ const PureSpreadsheetEditor = ({ content, saveContent }: SheetEditorProps) => {
const initialRows = useMemo(() => {
return parseData.map((row, rowIndex) => {
const rowData: Record<string, string | number> = {
const rowData: any = {
id: rowIndex,
rowNumber: rowIndex + 1,
};
@ -92,15 +91,15 @@ const PureSpreadsheetEditor = ({ content, saveContent }: SheetEditorProps) => {
setLocalRows(initialRows);
}, [initialRows]);
const generateCsv = (data: string[][]) => {
const generateCsv = (data: any[][]) => {
return unparse(data);
};
const handleRowsChange = (newRows: Record<string, string | number>[]) => {
const handleRowsChange = (newRows: any[]) => {
setLocalRows(newRows);
const updatedData = newRows.map((row) => {
return columns.slice(1).map((col) => String(row[col.key] ?? ""));
return columns.slice(1).map((col) => row[col.key] || "");
});
const newCsvContent = generateCsv(updatedData);

View file

@ -2,6 +2,14 @@ import Link from "next/link";
import { memo } from "react";
import { useChatVisibility } from "@/hooks/use-chat-visibility";
import type { Chat } from "@/lib/db/schema";
import {
CheckCircleFillIcon,
GlobeIcon,
LockIcon,
MoreHorizontalIcon,
ShareIcon,
TrashIcon,
} from "./icons";
import {
DropdownMenu,
DropdownMenuContent,
@ -11,20 +19,12 @@ import {
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "../ui/dropdown-menu";
} from "./ui/dropdown-menu";
import {
SidebarMenuAction,
SidebarMenuButton,
SidebarMenuItem,
} from "../ui/sidebar";
import {
CheckCircleFillIcon,
GlobeIcon,
LockIcon,
MoreHorizontalIcon,
ShareIcon,
TrashIcon,
} from "./icons";
} from "./ui/sidebar";
const PureChatItem = ({
chat,
@ -44,20 +44,16 @@ const PureChatItem = ({
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}
>
<SidebarMenuButton asChild isActive={isActive}>
<Link href={`/chat/${chat.id}`} onClick={() => setOpenMobile(false)}>
<span className="truncate">{chat.title}</span>
<span>{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"
className="mr-0.5 data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
showOnHover={!isActive}
>
<MoreHorizontalIcon />
@ -104,8 +100,8 @@ const PureChatItem = ({
</DropdownMenuSub>
<DropdownMenuItem
className="cursor-pointer text-destructive focus:bg-destructive/15 focus:text-destructive dark:text-red-500"
onSelect={() => onDelete(chat.id)}
variant="destructive"
>
<TrashIcon />
<span>Delete</span>

View file

@ -3,7 +3,6 @@
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";
@ -20,10 +19,10 @@ import {
import {
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarMenu,
useSidebar,
} from "@/components/ui/sidebar";
import type { AuthUser } from "@/lib/auth";
import type { Chat } from "@/lib/db/schema";
import { fetcher } from "@/lib/utils";
import { LoaderIcon } from "./icons";
@ -98,7 +97,7 @@ export function getChatHistoryPaginationKey(
return `${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/history?ending_before=${firstChatFromPage.id}&limit=${PAGE_SIZE}`;
}
export function SidebarHistory({ user }: { user: User | undefined }) {
export function SidebarHistory({ user }: { user: AuthUser | undefined }) {
const { setOpenMobile } = useSidebar();
const pathname = usePathname();
const id = pathname?.startsWith("/chat/") ? pathname.split("/")[2] : null;
@ -112,7 +111,7 @@ export function SidebarHistory({ user }: { user: User | undefined }) {
} = useSWRInfinite<ChatHistory>(
user ? getChatHistoryPaginationKey : () => null,
fetcher,
{ fallbackData: [], revalidateOnFocus: false }
{ fallbackData: [] }
);
const router = useRouter();
@ -133,32 +132,43 @@ export function SidebarHistory({ user }: { user: User | undefined }) {
setShowDeleteDialog(false);
if (isCurrentChat) {
router.replace("/");
}
mutate((chatHistories) => {
if (chatHistories) {
return chatHistories.map((chatHistory) => ({
...chatHistory,
chats: chatHistory.chats.filter((chat) => chat.id !== chatToDelete),
}));
}
});
fetch(
const deletePromise = fetch(
`${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}/api/chat?id=${chatToDelete}`,
{ method: "DELETE" }
{
method: "DELETE",
}
);
toast.success("Chat deleted");
toast.promise(deletePromise, {
loading: "Deleting chat...",
success: () => {
mutate((chatHistories) => {
if (chatHistories) {
return chatHistories.map((chatHistory) => ({
...chatHistory,
chats: chatHistory.chats.filter(
(chat) => chat.id !== chatToDelete
),
}));
}
});
if (isCurrentChat) {
router.replace("/");
router.refresh();
}
return "Chat deleted successfully";
},
error: "Failed to delete chat",
});
};
if (!user) {
return (
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
<SidebarGroup>
<SidebarGroupContent>
<div className="flex w-full flex-row items-center justify-center gap-2 px-2 text-[13px] text-sidebar-foreground/60">
<div className="flex w-full flex-row items-center justify-center gap-2 px-2 text-sm text-zinc-500">
Login to save and revisit previous chats!
</div>
</SidebarGroupContent>
@ -168,19 +178,19 @@ export function SidebarHistory({ user }: { user: User | undefined }) {
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>
<SidebarGroup>
<div className="px-2 py-1 text-sidebar-foreground/50 text-xs">
Today
</div>
<SidebarGroupContent>
<div className="flex flex-col gap-0.5 px-1">
<div className="flex flex-col">
{[44, 32, 28, 64, 52].map((item) => (
<div
className="flex h-8 items-center gap-2 rounded-lg px-2"
className="flex h-8 items-center gap-2 rounded-md px-2"
key={item}
>
<div
className="h-3 max-w-(--skeleton-width) flex-1 animate-pulse rounded-md bg-sidebar-foreground/[0.06]"
className="h-4 max-w-(--skeleton-width) flex-1 rounded-md bg-sidebar-accent-foreground/10"
style={
{
"--skeleton-width": `${item}%`,
@ -197,12 +207,9 @@ export function SidebarHistory({ user }: { user: User | undefined }) {
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>
<SidebarGroup>
<SidebarGroupContent>
<div className="flex w-full flex-row items-center justify-center gap-2 px-2 text-[13px] text-sidebar-foreground/60">
<div className="flex w-full flex-row items-center justify-center gap-2 px-2 text-sm text-zinc-500">
Your conversations will appear here once you start chatting!
</div>
</SidebarGroupContent>
@ -212,10 +219,7 @@ export function SidebarHistory({ user }: { user: User | undefined }) {
return (
<>
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
<SidebarGroupLabel className="text-[10px] font-semibold uppercase tracking-[0.12em] text-sidebar-foreground/70">
History
</SidebarGroupLabel>
<SidebarGroup>
<SidebarGroupContent>
<SidebarMenu>
{paginatedChatHistories &&
@ -227,10 +231,10 @@ export function SidebarHistory({ user }: { user: User | undefined }) {
const groupedChats = groupChatsByDate(chatsFromHistory);
return (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-6">
{groupedChats.today.length > 0 && (
<div>
<div className="px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.12em] text-sidebar-foreground/70">
<div className="px-2 py-1 text-sidebar-foreground/50 text-xs">
Today
</div>
{groupedChats.today.map((chat) => (
@ -250,7 +254,7 @@ export function SidebarHistory({ user }: { user: User | undefined }) {
{groupedChats.yesterday.length > 0 && (
<div>
<div className="px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.12em] text-sidebar-foreground/70">
<div className="px-2 py-1 text-sidebar-foreground/50 text-xs">
Yesterday
</div>
{groupedChats.yesterday.map((chat) => (
@ -270,7 +274,7 @@ export function SidebarHistory({ user }: { user: User | undefined }) {
{groupedChats.lastWeek.length > 0 && (
<div>
<div className="px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.12em] text-sidebar-foreground/70">
<div className="px-2 py-1 text-sidebar-foreground/50 text-xs">
Last 7 days
</div>
{groupedChats.lastWeek.map((chat) => (
@ -290,7 +294,7 @@ export function SidebarHistory({ user }: { user: User | undefined }) {
{groupedChats.lastMonth.length > 0 && (
<div>
<div className="px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.12em] text-sidebar-foreground/70">
<div className="px-2 py-1 text-sidebar-foreground/50 text-xs">
Last 30 days
</div>
{groupedChats.lastMonth.map((chat) => (
@ -310,8 +314,8 @@ export function SidebarHistory({ user }: { user: User | undefined }) {
{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 className="px-2 py-1 text-sidebar-foreground/50 text-xs">
Older than last month
</div>
{groupedChats.older.map((chat) => (
<ChatItem
@ -340,12 +344,16 @@ export function SidebarHistory({ user }: { user: User | undefined }) {
}}
/>
{hasReachedEnd ? null : (
<div className="mt-1 flex flex-row items-center gap-2 px-4 py-2 text-sidebar-foreground/50">
{hasReachedEnd ? (
<div className="mt-8 flex w-full flex-row items-center justify-center gap-2 px-2 text-sm text-zinc-500">
You have reached the end of your chat history.
</div>
) : (
<div className="mt-8 flex flex-row items-center gap-2 p-2 text-zinc-500 dark:text-zinc-400">
<div className="animate-spin">
<LoaderIcon />
</div>
<div className="text-[11px]">Loading...</div>
<div>Loading Chats...</div>
</div>
)}
</SidebarGroupContent>

View file

@ -6,8 +6,8 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Button } from "../ui/button";
import { SidebarLeftIcon } from "./icons";
import { Button } from "./ui/button";
export function SidebarToggle({
className,

View file

@ -1,9 +1,8 @@
"use client";
import { ChevronUp } from "lucide-react";
import Image from "next/image";
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,
@ -17,67 +16,64 @@ import {
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar";
import { guestRegex } from "@/lib/constants";
import { signOut, useSession } from "@/lib/client";
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 }) {
export function SidebarUserNav({
user,
}: {
user: { email?: string | null; isAnonymous?: boolean | null };
}) {
const router = useRouter();
const { data, status } = useSession();
const { data, isPending } = useSession();
const { setTheme, resolvedTheme } = useTheme();
const isGuest = guestRegex.test(data?.user?.email ?? "");
const isGuest = data?.user?.isAnonymous ?? user.isAnonymous ?? false;
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...
{isPending ? (
<SidebarMenuButton className="h-10 justify-between bg-background data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground">
<div className="flex flex-row gap-2">
<div className="size-6 animate-pulse rounded-full bg-zinc-500/30" />
<span className="animate-pulse rounded-md bg-zinc-500/30 text-transparent">
Loading auth status
</span>
</div>
<div className="animate-spin text-sidebar-foreground/50">
<div className="animate-spin text-zinc-500">
<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"
className="h-10 bg-background 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}))`,
}}
<Image
alt={user.email ?? "User Avatar"}
className="rounded-full"
height={24}
src={`https://avatar.vercel.sh/${user.email}`}
width={24}
/>
<span className="truncate text-[13px]" data-testid="user-email">
<span className="truncate" data-testid="user-email">
{isGuest ? "Guest" : user?.email}
</span>
<ChevronUp className="ml-auto size-3.5 text-sidebar-foreground/50" />
<ChevronUp className="ml-auto" />
</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)]"
className="w-(--radix-popper-anchor-width)"
data-testid="user-nav-menu"
side="top"
>
<DropdownMenuItem
className="cursor-pointer text-[13px]"
className="cursor-pointer"
data-testid="user-nav-item-theme"
onSelect={() =>
setTheme(resolvedTheme === "dark" ? "light" : "dark")
@ -88,9 +84,9 @@ export function SidebarUserNav({ user }: { user: User }) {
<DropdownMenuSeparator />
<DropdownMenuItem asChild data-testid="user-nav-item-auth">
<button
className="w-full cursor-pointer text-[13px]"
className="w-full cursor-pointer"
onClick={() => {
if (status === "loading") {
if (isPending) {
toast({
type: "error",
description:
@ -104,7 +100,12 @@ export function SidebarUserNav({ user }: { user: User }) {
router.push("/login");
} else {
signOut({
redirectTo: "/",
fetchOptions: {
onSuccess: () => {
router.push("/");
router.refresh();
},
},
});
}
}}

View file

@ -1,6 +1,7 @@
import Form from "next/form";
import { signOut } from "@/app/(auth)/auth";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";
export const SignOutForm = () => {
return (
@ -8,9 +9,11 @@ export const SignOutForm = () => {
action={async () => {
"use server";
await signOut({
redirectTo: "/",
await auth.api.signOut({
headers: await headers(),
});
redirect("/");
}}
className="w-full"
>

View file

@ -2,9 +2,9 @@
import { useFormStatus } from "react-dom";
import { LoaderIcon } from "@/components/chat/icons";
import { LoaderIcon } from "@/components/icons";
import { Button } from "../ui/button";
import { Button } from "./ui/button";
export function SubmitButton({
children,

View file

@ -3,9 +3,8 @@
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 { Suggestion } from "./ai-elements/suggestion";
import type { VisibilityType } from "./visibility-selector";
type SuggestedActionsProps = {
@ -15,33 +14,28 @@ type SuggestedActionsProps = {
};
function PureSuggestedActions({ chatId, sendMessage }: SuggestedActionsProps) {
const suggestedActions = suggestions;
const suggestedActions = [
"What are the advantages of using Next.js?",
"Write code to demonstrate Dijkstra's algorithm",
"Help me write an essay about Silicon Valley",
"What is the weather in San Francisco?",
];
return (
<div
className="flex w-full gap-2.5 overflow-x-auto pb-1 sm:grid sm:grid-cols-2 sm:overflow-visible"
className="grid w-full gap-2 sm:grid-cols-2"
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 }}
exit={{ opacity: 0, y: 20 }}
initial={{ opacity: 0, y: 20 }}
key={suggestedAction}
transition={{
delay: 0.06 * index,
duration: 0.4,
ease: [0.22, 1, 0.36, 1],
}}
transition={{ delay: 0.05 * index }}
>
<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)]"
className="h-auto w-full whitespace-normal p-3 text-left"
onClick={(suggestion) => {
window.history.pushState(
{},

77
components/suggestion.tsx Normal file
View file

@ -0,0 +1,77 @@
"use client";
import { AnimatePresence, motion } from "framer-motion";
import { useState } from "react";
import { useWindowSize } from "usehooks-ts";
import type { UISuggestion } from "@/lib/editor/suggestions";
import { cn } from "@/lib/utils";
import type { ArtifactKind } from "./artifact";
import { CrossIcon, MessageIcon } from "./icons";
import { Button } from "./ui/button";
export const Suggestion = ({
suggestion,
onApply,
artifactKind,
}: {
suggestion: UISuggestion;
onApply: () => void;
artifactKind: ArtifactKind;
}) => {
const [isExpanded, setIsExpanded] = useState(false);
const { width: windowWidth } = useWindowSize();
return (
<AnimatePresence>
{isExpanded ? (
<motion.div
animate={{ opacity: 1, y: -20 }}
className="absolute -right-12 z-50 flex w-56 flex-col gap-3 rounded-2xl border bg-background p-3 font-sans text-sm shadow-xl md:-right-16"
exit={{ opacity: 0, y: -10 }}
initial={{ opacity: 0, y: -10 }}
key={suggestion.id}
transition={{ type: "spring", stiffness: 500, damping: 30 }}
whileHover={{ scale: 1.05 }}
>
<div className="flex flex-row items-center justify-between">
<div className="flex flex-row items-center gap-2">
<div className="size-4 rounded-full bg-muted-foreground/25" />
<div className="font-medium">Assistant</div>
</div>
<button
className="cursor-pointer text-gray-500 text-xs"
onClick={() => {
setIsExpanded(false);
}}
type="button"
>
<CrossIcon size={12} />
</button>
</div>
<div>{suggestion.description}</div>
<Button
className="w-fit rounded-full px-3 py-1.5"
onClick={onApply}
variant="outline"
>
Apply
</Button>
</motion.div>
) : (
<motion.div
className={cn("cursor-pointer p-1 text-muted-foreground", {
"absolute -right-8": artifactKind === "text",
"sticky top-0 right-4": artifactKind === "code",
})}
onClick={() => {
setIsExpanded(true);
}}
whileHover={{ scale: 1.1 }}
>
<MessageIcon size={windowWidth && windowWidth < 768 ? 16 : 14} />
</motion.div>
)}
</AnimatePresence>
);
};

View file

@ -3,9 +3,8 @@
import { exampleSetup } from "prosemirror-example-setup";
import { inputRules } from "prosemirror-inputrules";
import { EditorState } from "prosemirror-state";
import { type Decoration, DecorationSet, EditorView } from "prosemirror-view";
import { memo, useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { EditorView } from "prosemirror-view";
import { memo, useEffect, useRef } from "react";
import type { Suggestion } from "@/lib/db/schema";
import {
@ -22,9 +21,7 @@ import {
projectWithPositions,
suggestionsPlugin,
suggestionsPluginKey,
type UISuggestion,
} from "@/lib/editor/suggestions";
import { SuggestionDialog } from "./suggestion";
type EditorProps = {
content: string;
@ -33,9 +30,6 @@ type EditorProps = {
isCurrentVersion: boolean;
currentVersionIndex: number;
suggestions: Suggestion[];
onSuggestionSelect?: (suggestion: UISuggestion | null) => void;
onSuggestionApply?: () => void;
activeSuggestion?: UISuggestion | null;
};
function PureEditor({
@ -46,10 +40,6 @@ function PureEditor({
}: EditorProps) {
const containerRef = useRef<HTMLDivElement>(null);
const editorRef = useRef<EditorView | null>(null);
const [activeSuggestion, setActiveSuggestion] = useState<UISuggestion | null>(
null
);
const suggestionsRef = useRef<UISuggestion[]>([]);
useEffect(() => {
if (containerRef.current && !editorRef.current) {
@ -73,21 +63,6 @@ function PureEditor({
editorRef.current = new EditorView(containerRef.current, {
state,
handleDOMEvents: {
click(_view, event) {
const target = event.target as HTMLElement;
const highlight = target.closest(".suggestion-highlight");
if (highlight) {
const id = highlight.getAttribute("data-suggestion-id");
const found = suggestionsRef.current.find((s) => s.id === id);
if (found) {
setActiveSuggestion(found);
}
return true;
}
return false;
},
},
});
}
@ -97,6 +72,8 @@ function PureEditor({
editorRef.current = null;
}
};
// NOTE: we only want to run this effect once
// eslint-disable-next-line
}, [content]);
useEffect(() => {
@ -157,8 +134,6 @@ function PureEditor({
(suggestion) => suggestion.selectionStart && suggestion.selectionEnd
);
suggestionsRef.current = projectedSuggestions;
const decorations = createDecorations(
projectedSuggestions,
editorRef.current
@ -170,61 +145,8 @@ function PureEditor({
}
}, [suggestions, content]);
const handleApply = useCallback(() => {
if (!editorRef.current || !activeSuggestion) {
return;
}
const { state, dispatch } = editorRef.current;
const currentState = suggestionsPluginKey.getState(state);
const currentDecorations = currentState?.decorations;
if (currentDecorations) {
const newDecorations = DecorationSet.create(
state.doc,
currentDecorations.find().filter((decoration: Decoration) => {
return decoration.spec.suggestionId !== activeSuggestion.id;
})
);
const decorationTransaction = state.tr;
decorationTransaction.setMeta(suggestionsPluginKey, {
decorations: newDecorations,
selected: null,
});
dispatch(decorationTransaction);
}
const textTransaction = editorRef.current.state.tr.replaceWith(
activeSuggestion.selectionStart,
activeSuggestion.selectionEnd,
state.schema.text(activeSuggestion.suggestedText)
);
textTransaction.setMeta("no-debounce", true);
dispatch(textTransaction);
setActiveSuggestion(null);
}, [activeSuggestion]);
return (
<>
<div
className="prose dark:prose-invert prose-neutral relative max-w-none"
ref={containerRef}
/>
{activeSuggestion &&
containerRef.current?.closest("[data-slot='artifact-content']") &&
createPortal(
<SuggestionDialog
onApply={handleApply}
onClose={() => setActiveSuggestion(null)}
suggestion={activeSuggestion}
/>,
containerRef.current.closest(
"[data-slot='artifact-content']"
) as HTMLElement
)}
</>
<div className="prose dark:prose-invert relative" ref={containerRef} />
);
}

View file

@ -34,8 +34,8 @@ function Toast(props: ToastProps) {
setMultiLine(lines > 1);
};
update();
const ro = new ResizeObserver(update);
update(); // initial check
const ro = new ResizeObserver(update); // re-check on width changes
ro.observe(el);
return () => ro.disconnect();
@ -45,7 +45,7 @@ function Toast(props: ToastProps) {
<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",
"flex toast-mobile:w-fit w-full flex-row gap-3 rounded-lg bg-zinc-100 p-3",
multiLine ? "items-start" : "items-center"
)}
data-testid="toast"
@ -60,7 +60,7 @@ function Toast(props: ToastProps) {
>
{iconsByType[type]}
</div>
<div className="text-sm text-foreground" ref={descriptionRef}>
<div className="text-sm text-zinc-950" ref={descriptionRef}>
{description}
</div>
</div>

View file

@ -1,8 +1,12 @@
"use client";
import type { UseChatHelpers } from "@ai-sdk/react";
import cx from "classnames";
import { motion, useMotionValue, useTransform } from "framer-motion";
import { WrenchIcon, XIcon } from "lucide-react";
import {
AnimatePresence,
motion,
useMotionValue,
useTransform,
} from "framer-motion";
import { nanoid } from "nanoid";
import {
type Dispatch,
@ -241,18 +245,24 @@ const ReadingLevelSelector = ({
};
export const Tools = ({
isToolbarVisible,
selectedTool,
setSelectedTool,
sendMessage,
isAnimating,
setIsToolbarVisible,
tools,
}: {
isToolbarVisible: boolean;
selectedTool: string | null;
setSelectedTool: Dispatch<SetStateAction<string | null>>;
sendMessage: UseChatHelpers<ChatMessage>["sendMessage"];
isAnimating: boolean;
setIsToolbarVisible: Dispatch<SetStateAction<boolean>>;
tools: ArtifactToolbarItem[];
}) => {
const [primaryTool, ...secondaryTools] = tools;
return (
<motion.div
animate={{ opacity: 1, scale: 1 }}
@ -260,53 +270,45 @@ export const Tools = ({
exit={{ opacity: 0, scale: 0.95 }}
initial={{ opacity: 0, scale: 0.95 }}
>
{[...tools].reverse().map((tool) => (
<Tool
description={tool.description}
icon={tool.icon}
isAnimating={isAnimating}
key={tool.description}
onClick={tool.onClick}
selectedTool={selectedTool}
sendMessage={sendMessage}
setSelectedTool={setSelectedTool}
/>
))}
<AnimatePresence>
{isToolbarVisible &&
secondaryTools.map((secondaryTool) => (
<Tool
description={secondaryTool.description}
icon={secondaryTool.icon}
isAnimating={isAnimating}
key={secondaryTool.description}
onClick={secondaryTool.onClick}
selectedTool={selectedTool}
sendMessage={sendMessage}
setSelectedTool={setSelectedTool}
/>
))}
</AnimatePresence>
<Tool
description={primaryTool.description}
icon={primaryTool.icon}
isAnimating={isAnimating}
isToolbarVisible={isToolbarVisible}
onClick={primaryTool.onClick}
selectedTool={selectedTool}
sendMessage={sendMessage}
setIsToolbarVisible={setIsToolbarVisible}
setSelectedTool={setSelectedTool}
/>
</motion.div>
);
};
const createFixErrorTool = (
consoleOutput: string,
documentId?: string
): ArtifactToolbarItem => ({
icon: <WrenchIcon className="size-4" />,
description: "Fix error",
onClick: ({ sendMessage: send }) => {
send({
role: "user",
parts: [
{
type: "text",
text: `Fix the error in the existing script${documentId ? ` (id: ${documentId})` : ""} using updateDocument. Do not create a new script. Console error:\n\n${consoleOutput}`,
},
],
});
},
});
const PureToolbar = ({
isToolbarVisible: _isToolbarVisible,
isToolbarVisible,
setIsToolbarVisible,
sendMessage,
status,
stop,
setMessages,
artifactKind,
consoleError,
documentId,
artifactActions,
onClose,
}: {
isToolbarVisible: boolean;
setIsToolbarVisible: Dispatch<SetStateAction<boolean>>;
@ -315,10 +317,6 @@ const PureToolbar = ({
stop: UseChatHelpers<ChatMessage>["stop"];
setMessages: UseChatHelpers<ChatMessage>["setMessages"];
artifactKind: ArtifactKind;
consoleError?: string;
documentId?: string;
artifactActions?: ReactNode;
onClose?: () => void;
}) => {
const toolbarRef = useRef<HTMLDivElement>(null);
const timeoutRef = useRef<ReturnType<typeof setTimeout>>();
@ -370,12 +368,7 @@ const PureToolbar = ({
throw new Error("Artifact definition not found!");
}
const toolsByArtifactKind = consoleError
? [
createFixErrorTool(consoleError, documentId),
...artifactDefinition.toolbar.slice(1),
]
: artifactDefinition.toolbar;
const toolsByArtifactKind = artifactDefinition.toolbar;
if (toolsByArtifactKind.length === 0) {
return null;
@ -384,8 +377,26 @@ const PureToolbar = ({
return (
<TooltipProvider delayDuration={0}>
<motion.div
animate={{ opacity: 1, y: 0, scale: 1 }}
className="fixed right-6 bottom-6 z-50 flex cursor-pointer flex-col items-center rounded-3xl border bg-background py-1 shadow-lg"
animate={
isToolbarVisible
? selectedTool === "adjust-reading-level"
? {
opacity: 1,
y: 0,
height: 6 * 43,
transition: { delay: 0 },
scale: 0.95,
}
: {
opacity: 1,
y: 0,
height: toolsByArtifactKind.length * 50,
transition: { delay: 0 },
scale: 1,
}
: { opacity: 1, y: 0, height: 54, transition: { delay: 0 } }
}
className="absolute right-6 bottom-6 flex cursor-pointer flex-col justify-end rounded-full border bg-background p-1.5 shadow-lg"
exit={{ opacity: 0, y: -20, transition: { duration: 0.1 } }}
initial={{ opacity: 0, y: -20, scale: 1 }}
onAnimationComplete={() => {
@ -412,17 +423,6 @@ const PureToolbar = ({
ref={toolbarRef}
transition={{ type: "spring", stiffness: 300, damping: 25 }}
>
{onClose && (
<motion.div
animate={{ opacity: 1 }}
className="p-3 text-muted-foreground transition-colors hover:text-foreground"
initial={{ opacity: 0 }}
onClick={onClose}
>
<XIcon className="size-4" />
</motion.div>
)}
{status === "streaming" ? (
<motion.div
animate={{ scale: 1.4 }}
@ -445,17 +445,16 @@ const PureToolbar = ({
setSelectedTool={setSelectedTool}
/>
) : (
<>
{artifactActions}
<Tools
isAnimating={isAnimating}
key="tools"
selectedTool={selectedTool}
sendMessage={sendMessage}
setSelectedTool={setSelectedTool}
tools={toolsByArtifactKind}
/>
</>
<Tools
isAnimating={isAnimating}
isToolbarVisible={isToolbarVisible}
key="tools"
selectedTool={selectedTool}
sendMessage={sendMessage}
setIsToolbarVisible={setIsToolbarVisible}
setSelectedTool={setSelectedTool}
tools={toolsByArtifactKind}
/>
)}
</motion.div>
</TooltipProvider>
@ -472,15 +471,6 @@ export const Toolbar = memo(PureToolbar, (prevProps, nextProps) => {
if (prevProps.artifactKind !== nextProps.artifactKind) {
return false;
}
if (prevProps.consoleError !== nextProps.consoleError) {
return false;
}
if (prevProps.artifactActions !== nextProps.artifactActions) {
return false;
}
if (prevProps.onClose !== nextProps.onClose) {
return false;
}
return true;
});

View file

@ -36,7 +36,7 @@ function AlertDialogOverlay({
<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",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
@ -58,7 +58,7 @@ function AlertDialogContent({
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",
"bg-background 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 group/alert-dialog-content fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-lg",
className
)}
{...props}
@ -99,22 +99,6 @@ function AlertDialogFooter({
)
}
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
@ -123,7 +107,7 @@ function AlertDialogTitle({
<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",
"text-lg font-semibold sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
className
)}
{...props}
@ -138,8 +122,21 @@ function AlertDialogDescription({
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function AlertDialogMedia({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-media"
className={cn(
"text-sm text-balance text-muted-foreground md:text-pretty *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
"bg-muted mb-2 inline-flex size-16 items-center justify-center rounded-md sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-8",
className
)}
{...props}

View file

@ -5,20 +5,19 @@ 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!",
"inline-flex items-center justify-center rounded-full border border-transparent px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
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",
"bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
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",
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
link: "text-primary underline-offset-4 [a&]:hover:underline",
},
},
defaultVariants: {

View file

@ -5,14 +5,14 @@ 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",
"flex w-fit items-stretch [&>*]:focus-visible:z-10 [&>*]:focus-visible:relative [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md has-[>[data-slot=button-group]]:gap-2",
{
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!",
"[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none",
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!",
"flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none",
},
},
defaultVariants: {
@ -49,7 +49,7 @@ function ButtonGroupText({
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",
"bg-muted flex items-center gap-2 rounded-md border px-4 text-sm font-medium shadow-xs [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
@ -67,7 +67,7 @@ function ButtonGroupSeparator({
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",
"bg-input relative !m-0 self-stretch data-[orientation=vertical]:h-auto",
className
)}
{...props}

View file

@ -5,29 +5,28 @@ 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",
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
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",
default: "bg-primary text-primary-foreground hover:bg-primary/90",
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",
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
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",
default: "h-9 px-4 py-2 has-[>svg]:px-3",
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-xs": "size-6 [&_svg:not([class*='size-'])]:size-3",
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-8",
"icon-lg": "size-10",
},

View file

@ -2,6 +2,7 @@
import * as React from "react"
import { Command as CommandPrimitive } from "cmdk"
import { SearchIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import {
@ -11,11 +12,6 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import {
InputGroup,
InputGroupAddon,
} from "@/components/ui/input-group"
import { SearchIcon } from "lucide-react"
function Command({
className,
@ -25,7 +21,7 @@ function Command({
<CommandPrimitive
data-slot="command"
className={cn(
"flex size-full flex-col overflow-hidden rounded-4xl bg-popover p-1 text-popover-foreground",
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
className
)}
{...props}
@ -38,7 +34,7 @@ function CommandDialog({
description = "Search for a command to run...",
children,
className,
showCloseButton = false,
showCloseButton = true,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string
@ -53,13 +49,12 @@ function CommandDialog({
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent
className={cn(
"top-1/3 translate-y-0 overflow-hidden rounded-4xl! p-0",
className
)}
className={cn("overflow-hidden p-0", className)}
showCloseButton={showCloseButton}
>
{children}
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
)
@ -70,20 +65,19 @@ function CommandInput({
...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
data-slot="command-input-wrapper"
className="flex h-9 items-center gap-2 border-b px-3"
>
<SearchIcon className="size-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
)
}
@ -96,7 +90,7 @@ function CommandList({
<CommandPrimitive.List
data-slot="command-list"
className={cn(
"no-scrollbar max-h-72 scroll-py-1 overflow-x-hidden overflow-y-auto outline-none",
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
className
)}
{...props}
@ -105,13 +99,12 @@ function CommandList({
}
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)}
className="py-6 text-center text-sm"
{...props}
/>
)
@ -125,7 +118,7 @@ function CommandGroup({
<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",
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
className
)}
{...props}
@ -140,7 +133,7 @@ function CommandSeparator({
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn("my-1 h-px bg-border/50", className)}
className={cn("bg-border -mx-1 h-px", className)}
{...props}
/>
)
@ -148,20 +141,17 @@ function CommandSeparator({
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",
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
</CommandPrimitive.Item>
/>
)
}
@ -173,7 +163,7 @@ function CommandShortcut({
<span
data-slot="command-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-data-selected/command-item:text-foreground",
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}

View file

@ -1,11 +1,11 @@
"use client"
import * as React from "react"
import { XIcon } from "lucide-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
@ -39,7 +39,7 @@ function DialogOverlay({
<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",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
@ -56,28 +56,24 @@ function DialogContent({
showCloseButton?: boolean
}) {
return (
<DialogPortal>
<DialogPortal data-slot="dialog-portal">
<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",
"bg-background 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 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
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
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
@ -89,7 +85,7 @@ function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2", className)}
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
@ -129,7 +125,7 @@ function DialogTitle({
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-base leading-none font-medium", className)}
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
@ -142,10 +138,7 @@ function DialogDescription({
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
)}
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)

View file

@ -1,10 +1,10 @@
"use client"
import * as React from "react"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { CheckIcon, ChevronRightIcon } from "lucide-react"
function DropdownMenu({
...props
@ -33,7 +33,6 @@ function DropdownMenuTrigger({
function DropdownMenuContent({
className,
align = "start",
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
@ -42,8 +41,10 @@ function DropdownMenuContent({
<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 )}
className={cn(
"bg-popover text-popover-foreground 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 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
@ -73,7 +74,7 @@ function DropdownMenuItem({
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",
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
@ -85,29 +86,21 @@ function DropdownMenuCheckboxItem({
className,
children,
checked,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem> & {
inset?: boolean
}) {
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
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",
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none 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"
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon
/>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
@ -129,28 +122,20 @@ function DropdownMenuRadioGroup({
function DropdownMenuRadioItem({
className,
children,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem> & {
inset?: boolean
}) {
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
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",
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none 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"
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon
/>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
@ -170,7 +155,7 @@ function DropdownMenuLabel({
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",
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
@ -185,7 +170,7 @@ function DropdownMenuSeparator({
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border/50", className)}
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
@ -199,7 +184,7 @@ function DropdownMenuShortcut({
<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",
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
@ -226,13 +211,13 @@ function DropdownMenuSubTrigger({
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",
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
}
@ -244,7 +229,10 @@ function DropdownMenuSubContent({
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 )}
className={cn(
"bg-popover text-popover-foreground 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 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
)

View file

@ -32,7 +32,7 @@ function HoverCardContent({
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",
"bg-popover text-popover-foreground 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 z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className
)}
{...props}

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